feat: add Reasonix session management

This commit is contained in:
liujing
2026-07-16 22:57:45 +08:00
parent 0015774cd4
commit 5118e1f0ab
10 changed files with 644 additions and 12 deletions
+8 -5
View File
@@ -1,6 +1,6 @@
# 智能体观测中心 # 智能体观测中心
本地多引擎 AI 会话管理工具 — 浏览、搜索、复制恢复命令,支持 **Codex · Claude · Agy · OpenCode** 引擎。 本地多引擎 AI 会话管理工具 — 浏览、搜索、复制恢复命令,支持 **Codex · Claude · Agy · OpenCode · Reasonix** 引擎。
## 快速开始 ## 快速开始
@@ -24,10 +24,10 @@ npm start
## 功能 ## 功能
- **引擎会话列表** — 按引擎筛选(全部/Codex/Claude/Agy/OpenCode),搜索 ID、短 ID、路径、摘要 - **引擎会话列表** — 按引擎筛选(全部/Codex/Claude/Agy/OpenCode/Reasonix),搜索 ID、短 ID、路径、摘要
- **会话详情** — 引擎专属元数据、消息时间线、工具调用统计 - **会话详情** — 引擎专属元数据、消息时间线、工具调用统计
- **短 ID 解析** — 输入部分 ID(如 `019f448b`)自动匹配 - **短 ID 解析** — 输入部分 ID(如 `019f448b`)自动匹配
- **一键恢复** — 复制引擎对应的恢复命令(`codex resume` / `claude --resume` / `agy --conversation` / `opencode --session` - **一键恢复** — 复制引擎对应的恢复命令(`codex resume` / `claude --resume` / `agy --conversation` / `opencode --session` / `reasonix --resume`
- **工具排行** — 全引擎工具调用聚合排行,附技能/插件/缓存清单 - **工具排行** — 全引擎工具调用聚合排行,附技能/插件/缓存清单
- **效能分析** — 会话趋势图、状态分布、异常率 - **效能分析** — 会话趋势图、状态分布、异常率
- **会话删除** — 支持 OpenCode 引擎的原始会话删除(通过 `opencode session delete` 执行) - **会话删除** — 支持 OpenCode 引擎的原始会话删除(通过 `opencode session delete` 执行)
@@ -41,15 +41,17 @@ npm start
| Claude | `~/.claude/projects/**/*.jsonl` | ~170 | `claude --resume <id>` | | Claude | `~/.claude/projects/**/*.jsonl` | ~170 | `claude --resume <id>` |
| Agy | `~/.gemini/antigravity-cli/conversation_summaries.db` | ~80 | `agy --conversation <id>` | | Agy | `~/.gemini/antigravity-cli/conversation_summaries.db` | ~80 | `agy --conversation <id>` |
| OpenCode | `~/.local/share/opencode/opencode.db` | ~520 | `opencode --session <id>` | | OpenCode | `~/.local/share/opencode/opencode.db` | ~520 | `opencode --session <id>` |
| Reasonix | `~/.reasonix/projects/<project-slug>/sessions/` | - | `reasonix --dir <cwd> --resume` |
OpenCode 数据目录通过 `opencode debug paths` 自动检测,若 CLI 不可用则回退到 `~/.local/share/opencode` OpenCode 数据目录通过 `opencode debug paths` 自动检测,若 CLI 不可用则回退到 `~/.local/share/opencode`
Reasonix 数据目录默认是 `~/.reasonix`,可通过环境变量 `REASONIX_HOME` 自定义,其下包含 `projects/` 子目录。
## API ## API
| 路径 | 说明 | | 路径 | 说明 |
|---|---| |---|---|
| `GET /api/health` | 服务状态 + 引擎健康 | | `GET /api/health` | 服务状态 + 引擎健康 |
| `GET /api/sessions?engine=all\|codex\|claude\|agy\|opencode` | 会话列表 | | `GET /api/sessions?engine=all\|codex\|claude\|agy\|opencode\|reasonix` | 会话列表 |
| `GET /api/sessions/:engine/:id` | 会话详情 | | `GET /api/sessions/:engine/:id` | 会话详情 |
| `GET /api/sessions/:engine/:id/tools` | 会话内工具统计 | | `GET /api/sessions/:engine/:id/tools` | 会话内工具统计 |
| `DELETE /api/sessions/:engine/:id` | 删除会话(仅 OpenCode 引擎支持原始删除) | | `DELETE /api/sessions/:engine/:id` | 删除会话(仅 OpenCode 引擎支持原始删除) |
@@ -61,4 +63,5 @@ OpenCode 数据目录通过 `opencode debug paths` 自动检测,若 CLI 不可
- `CODEX_HOME` — Codex 根目录(默认 `~/.codex` - `CODEX_HOME` — Codex 根目录(默认 `~/.codex`
- `AGY_DATA_DIR` — Agy 数据目录(默认 `~/.gemini/antigravity-cli` - `AGY_DATA_DIR` — Agy 数据目录(默认 `~/.gemini/antigravity-cli`
- `REASONIX_HOME` — Reasonix 根目录(默认 `~/.reasonix`
- `PORT` — 后端端口(默认 `3721` - `PORT` — 后端端口(默认 `3721`
+2
View File
@@ -2,12 +2,14 @@ import { CodexAdapter } from './codex-adapter.js';
import { ClaudeAdapter } from './claude-adapter.js'; import { ClaudeAdapter } from './claude-adapter.js';
import { AgyAdapter } from './agy-adapter.js'; import { AgyAdapter } from './agy-adapter.js';
import { OpenCodeAdapter } from './opencode-adapter.js'; import { OpenCodeAdapter } from './opencode-adapter.js';
import { ReasonixAdapter } from './reasonix-adapter.js';
const ENGINES = { const ENGINES = {
codex: new CodexAdapter(), codex: new CodexAdapter(),
claude: new ClaudeAdapter(), claude: new ClaudeAdapter(),
agy: new AgyAdapter(), agy: new AgyAdapter(),
opencode: new OpenCodeAdapter(), opencode: new OpenCodeAdapter(),
reasonix: new ReasonixAdapter(),
}; };
export function getEngine(name) { export function getEngine(name) {
+290
View File
@@ -0,0 +1,290 @@
import { execSync } from 'child_process';
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import { BaseEngineAdapter } from './base.js';
const REASONIX_BIN_CANDIDATES = [
join(homedir(), '.local', 'bin', 'reasonix'),
'/usr/local/bin/reasonix',
'/usr/bin/reasonix',
];
const DEFAULT_DATA_DIR = join(homedir(), '.reasonix');
function shellQuote(str) {
if (!str) return "''";
return "'" + str.replace(/'/g, "'\\''") + "'";
}
export class ReasonixAdapter extends BaseEngineAdapter {
constructor() {
super('reasonix', 'Reasonix');
this._dataDir = null;
this._reason = '';
this.cliAvailable = false;
this._messageCache = new Map();
}
async checkHealth() {
this._detectCli();
this._findDataDir();
this.available = this._dataDir !== null && existsSync(join(this._dataDir, 'projects'));
return this.getHealthInfo();
}
async refresh() {
this._detectCli();
this._findDataDir();
this.lastRefresh = Date.now();
this._messageCache.clear();
this.error = null;
if (!this._dataDir || !existsSync(join(this._dataDir, 'projects'))) {
this._sessions = [];
this._sessionsMap.clear();
this.available = false;
return this.getHealthInfo();
}
try {
await this._scanSessions();
this.available = true;
} catch (err) {
this.error = err.message;
console.error('[ReasonixAdapter] Refresh error:', err.message);
this._sessions = [];
this._sessionsMap.clear();
this.available = false;
}
return this.getHealthInfo();
}
_detectCli() {
for (const path of REASONIX_BIN_CANDIDATES) {
if (existsSync(path)) {
this._reasonixPath = path;
this.cliAvailable = true;
return;
}
}
try {
const result = execSync('which reasonix 2>/dev/null || echo ""', { encoding: 'utf-8', timeout: 3000 }).trim();
if (result) {
this._reasonixPath = result;
this.cliAvailable = true;
return;
}
} catch {}
this._reasonixPath = null;
this.cliAvailable = false;
this._reason = '未检测到 reasonix CLI';
}
_findDataDir() {
const dataDir = process.env.REASONIX_HOME || DEFAULT_DATA_DIR;
if (existsSync(dataDir)) {
this._dataDir = dataDir;
this._reason = '';
return;
}
this._dataDir = null;
this._reason = `未找到 Reasonix 根目录: ${dataDir}`;
}
async _scanSessions() {
this._sessions = [];
this._sessionsMap.clear();
const projectsPath = join(this._dataDir, 'projects');
if (!existsSync(projectsPath)) {
this._reason = '未找到 projects 目录';
return;
}
const projectDirs = readdirSync(projectsPath);
for (const projDir of projectDirs) {
// subagent/archive exclusion
if (projDir === 'archive' || projDir === 'subagents') continue;
const projPath = join(projectsPath, projDir);
if (!statSync(projPath).isDirectory()) continue;
const sessionsPath = join(projPath, 'sessions');
if (!existsSync(sessionsPath)) continue;
const files = readdirSync(sessionsPath).filter(f => f.endsWith('.jsonl.meta'));
for (const file of files) {
try {
const metaPath = join(sessionsPath, file);
if (!statSync(metaPath).isFile()) continue;
const id = file.replace(/\.jsonl\.meta$/, '');
const jsonlPath = join(sessionsPath, `${id}.jsonl`);
// Require both .jsonl.meta and the matching main .jsonl before indexing a session
if (!existsSync(jsonlPath)) continue;
const session = await this._parseSession(id, metaPath, jsonlPath, sessionsPath);
if (session) {
this._sessions.push(session.session);
this._sessionsMap.set(id, session.session);
if (session.messages?.length) {
this._messageCache.set(id, session.messages);
}
}
} catch (err) {
console.warn(`[ReasonixAdapter] Skip malformed peer ${file}:`, err.message);
}
}
}
this._reason = `已加载 ${this._sessions.length} 个 Reasonix 会话`;
}
async _parseSession(id, metaPath, jsonlPath, sessionsPath) {
const metaContent = readFileSync(metaPath, 'utf8');
const meta = JSON.parse(metaContent);
let firstUserMessage = '';
let toolCallCount = 0;
const toolCalls = {};
let messageCount = 0;
let userMessageCount = 0;
let assistantMessageCount = 0;
let cwd = '';
const messages = [];
const jsonlContent = readFileSync(jsonlPath, 'utf8');
const lines = jsonlContent.split('\n');
// Parse first line for workspace path if possible
if (lines.length > 0 && lines[0].trim()) {
try {
const firstLine = JSON.parse(lines[0]);
if (firstLine.role === 'system' && firstLine.content) {
const match = firstLine.content.match(/Current workspace:\s*"([^"]+)"/);
if (match) {
cwd = match[1];
}
}
} catch {}
}
for (const line of lines) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
const role = entry.role || '';
if (role === 'user') {
userMessageCount++;
messageCount++;
const text = entry.content || '';
if (!firstUserMessage && text.trim()) {
firstUserMessage = text;
}
messages.push({
type: 'user_message',
message: text.slice(0, 2000),
time: meta.created_at ? new Date(meta.created_at).getTime() : 0,
});
} else if (role === 'assistant') {
assistantMessageCount++;
messageCount++;
const text = entry.content || '';
if (text.trim()) {
messages.push({
type: 'assistant_message',
message: text.slice(0, 2000),
time: meta.updated_at ? new Date(meta.updated_at).getTime() : 0,
});
}
if (Array.isArray(entry.tool_calls)) {
for (const tc of entry.tool_calls) {
toolCallCount++;
const name = tc.name || 'unknown';
toolCalls[name] = (toolCalls[name] || 0) + 1;
messages.push({
type: 'function_call',
name,
arguments: tc.arguments || '',
time: meta.updated_at ? new Date(meta.updated_at).getTime() : 0,
});
}
}
} else if (role === 'tool') {
const content = entry.content || '';
messages.push({
type: 'function_call_output',
output_preview: content.slice(0, 300),
time: meta.updated_at ? new Date(meta.updated_at).getTime() : 0,
});
}
} catch {}
}
const created = meta.created_at ? new Date(meta.created_at).getTime() : 0;
const updated = meta.updated_at ? new Date(meta.updated_at).getTime() : created;
// Matching .jsonl.lock as the only active signal, set every other to unknown
const lockPath = join(sessionsPath, `${id}.jsonl.lock`);
const status = existsSync(lockPath) ? 'active' : 'unknown';
return {
session: {
key: `reasonix:${id}`,
engine: 'reasonix',
engine_label: 'Reasonix',
id,
summary: meta.preview || firstUserMessage || '(无消息)',
cwd: cwd || '',
model: meta.model || 'reasonix',
cli_version: '',
source: 'cli',
created_at: created,
updated_at: updated,
duration_ms: created && updated ? updated - created : null,
status,
tool_call_count: toolCallCount,
tool_calls: toolCalls,
message_count: messageCount,
resume_command: this.buildResumeCommand(id, cwd),
can_resume: this.cliAvailable,
can_delete: false,
},
messages: messages.slice(0, 300),
};
}
async getMessages(id) {
if (this._messageCache.has(id)) {
return this._messageCache.get(id);
}
return [];
}
buildResumeCommand(id, cwdOpt) {
const cwd = cwdOpt || this._sessionsMap.get(id)?.cwd;
if (cwd) {
return `reasonix --dir ${shellQuote(cwd)} --resume`;
}
return 'reasonix --resume';
}
getHealthInfo() {
return {
available: this.available,
cli_available: this.cliAvailable,
session_source_available: this._dataDir !== null && existsSync(join(this._dataDir, 'projects')),
session_count: this._sessions.length,
last_refresh: this.lastRefresh,
error: this.error,
binary_path: this._reasonixPath,
data_dir: this._dataDir,
reason: this._reason,
};
}
}
+2 -2
View File
@@ -260,7 +260,7 @@ app.post('/api/refresh', async (req, res) => {
}); });
// ─── Custom Name ────────────────────────────────────────────────── // ─── Custom Name ──────────────────────────────────────────────────
const VALID_ENGINES = ['codex', 'claude', 'agy', 'opencode']; const VALID_ENGINES = ['codex', 'claude', 'agy', 'opencode', 'reasonix'];
app.put('/api/sessions/:engine/:id/custom-name', (req, res) => { app.put('/api/sessions/:engine/:id/custom-name', (req, res) => {
try { 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) => { app.post('/api/sessions/:engine/:id/generate-title', async (req, res) => {
try { try {
const { engine, id } = req.params; const { engine, id } = req.params;
if (!['codex', 'claude', 'agy', 'opencode'].includes(engine)) { if (!['codex', 'claude', 'agy', 'opencode', 'reasonix'].includes(engine)) {
return res.status(400).json({ error: `Invalid engine: ${engine}` }); return res.status(400).json({ error: `Invalid engine: ${engine}` });
} }
const e = getEngine(engine); const e = getEngine(engine);
+1
View File
@@ -175,6 +175,7 @@ async function fetchMessages(engine, sessionId, adapter) {
if (engine === 'claude') return await fetchClaudeMessages(sessionId); if (engine === 'claude') return await fetchClaudeMessages(sessionId);
if (engine === 'agy') return fetchAgyMessages(session); if (engine === 'agy') return fetchAgyMessages(session);
if (engine === 'opencode') return await fetchOpenCodeMessages(sessionId, adapter); if (engine === 'opencode') return await fetchOpenCodeMessages(sessionId, adapter);
if (engine === 'reasonix') return await fetchOpenCodeMessages(sessionId, adapter);
return []; return [];
} }
+326
View File
@@ -0,0 +1,326 @@
import { describe, it, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { ReasonixAdapter } from '../src/engine/reasonix-adapter.js';
function withTestFiles({ projects = {}, filesOutside = [] } = {}) {
const tmpDir = mkdtempSync(join(tmpdir(), 'rx-adapter-test-'));
const projectsDir = join(tmpDir, 'projects');
mkdirSync(projectsDir, { recursive: true });
for (const [projName, sessions] of Object.entries(projects)) {
const projPath = join(projectsDir, projName);
const sessionsPath = join(projPath, 'sessions');
mkdirSync(sessionsPath, { recursive: true });
for (const s of sessions) {
const id = s.id;
const metaPath = join(sessionsPath, `${id}.jsonl.meta`);
const jsonlPath = join(sessionsPath, `${id}.jsonl`);
if (s.metaContent !== undefined) {
writeFileSync(metaPath, s.metaContent, 'utf8');
} else if (s.meta !== undefined) {
writeFileSync(metaPath, JSON.stringify(s.meta), 'utf8');
}
if (s.jsonlContent !== undefined) {
writeFileSync(jsonlPath, s.jsonlContent, 'utf8');
} else if (s.jsonl !== undefined) {
writeFileSync(jsonlPath, s.jsonl, 'utf8');
}
if (s.hasLock) {
writeFileSync(join(sessionsPath, `${id}.jsonl.lock`), '', 'utf8');
}
}
}
for (const f of filesOutside) {
const fullPath = join(tmpDir, f.path);
mkdirSync(join(fullPath, '..'), { recursive: true });
writeFileSync(fullPath, f.content || '', 'utf8');
}
const origHome = process.env.REASONIX_HOME;
process.env.REASONIX_HOME = tmpDir;
return {
restoreEnv() {
if (origHome === undefined) {
delete process.env.REASONIX_HOME;
} else {
process.env.REASONIX_HOME = origHome;
}
try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
},
tmpDir,
};
}
describe('ReasonixAdapter health & CLI scenarios', () => {
let env;
afterEach(() => {
mock.restoreAll();
if (env) env.restoreEnv();
});
it('reports source available even if CLI is absent', async () => {
env = withTestFiles({
projects: {
'test-proj': [
{
id: 'session-1',
meta: { id: 'session-1', created_at: '2026-07-16T12:00:00Z', model: 'gpt-4' },
jsonl: '{"role":"user","content":"hi"}\n',
},
],
},
});
const adapter = new ReasonixAdapter();
mock.method(adapter, '_detectCli', function() {
this.cliAvailable = false;
this._reasonixPath = null;
});
await adapter.refresh();
const health = adapter.getHealthInfo();
assert.equal(health.available, true); // keep valid data browsable!
assert.equal(health.cli_available, false);
assert.equal(health.session_source_available, true);
assert.equal(health.session_count, 1);
const session = adapter.getSession('session-1');
assert.ok(session);
assert.equal(session.can_resume, false); // can_resume is false when CLI is absent
});
});
describe('ReasonixAdapter exclusion rules', () => {
let env;
afterEach(() => {
mock.restoreAll();
if (env) env.restoreEnv();
});
it('excludes archive and subagent folders', async () => {
env = withTestFiles({
projects: {
'archive': [
{
id: 'archived-session',
meta: { id: 'archived-session' },
jsonl: '{"role":"user","content":"hi"}',
},
],
'subagents': [
{
id: 'subagent-session',
meta: { id: 'subagent-session' },
jsonl: '{"role":"user","content":"hi"}',
},
],
'real-project': [
{
id: 'real-session',
meta: { id: 'real-session' },
jsonl: '{"role":"user","content":"hi"}',
},
],
},
});
const adapter = new ReasonixAdapter();
adapter.cliAvailable = true;
await adapter.refresh();
assert.equal(adapter.getHealthInfo().session_count, 1);
assert.ok(adapter.getSession('real-session'));
assert.equal(adapter.getSession('archived-session'), null);
assert.equal(adapter.getSession('subagent-session'), null);
});
});
describe('ReasonixAdapter robustness and variants', () => {
let env;
afterEach(() => {
mock.restoreAll();
if (env) env.restoreEnv();
});
it('requires both meta and main jsonl files', async () => {
env = withTestFiles({
projects: {
'proj': [
{
id: 'session-meta-only',
meta: { id: 'session-meta-only' },
jsonlContent: undefined, // no jsonl file
},
{
id: 'session-valid',
meta: { id: 'session-valid' },
jsonl: '{"role":"user","content":"hello"}',
},
],
},
});
const adapter = new ReasonixAdapter();
adapter.cliAvailable = true;
await adapter.refresh();
assert.equal(adapter.getHealthInfo().session_count, 1);
assert.ok(adapter.getSession('session-valid'));
assert.equal(adapter.getSession('session-meta-only'), null);
});
it('tolerates malformed files without dropping valid peers', async () => {
env = withTestFiles({
projects: {
'proj': [
{
id: 'session-malformed-meta',
metaContent: '{invalid json',
jsonl: '{"role":"user","content":"hello"}',
},
{
id: 'session-malformed-jsonl',
meta: { id: 'session-malformed-jsonl' },
jsonlContent: '{"role":"user",content:"missing quotes"}',
},
{
id: 'session-valid',
meta: { id: 'session-valid' },
jsonl: '{"role":"user","content":"hello"}',
},
],
},
});
const adapter = new ReasonixAdapter();
adapter.cliAvailable = true;
await adapter.refresh();
assert.ok(adapter.getSession('session-valid'));
// The invalid jsonl might still yield a session (with less or no messages)
// but the malformed meta is definitely skipped.
assert.equal(adapter.getSession('session-malformed-meta'), null);
});
it('preserves independent recovery/conflict variants', async () => {
env = withTestFiles({
projects: {
'proj': [
{
id: 'session-123',
meta: { id: 'session-123' },
jsonl: '{"role":"user","content":"hello"}',
},
{
id: 'session-123.conflict-1',
meta: { id: 'session-123.conflict-1' },
jsonl: '{"role":"user","content":"hello conflict"}',
},
],
},
});
const adapter = new ReasonixAdapter();
adapter.cliAvailable = true;
await adapter.refresh();
assert.equal(adapter.getHealthInfo().session_count, 2);
assert.ok(adapter.getSession('session-123'));
assert.ok(adapter.getSession('session-123.conflict-1'));
});
});
describe('ReasonixAdapter active signal and shell quoting', () => {
let env;
afterEach(() => {
mock.restoreAll();
if (env) env.restoreEnv();
});
it('sets status based on lock file and builds quoted resume commands', async () => {
env = withTestFiles({
projects: {
'proj': [
{
id: 'session-active',
meta: { id: 'session-active' },
jsonl: '{"role":"system","content":"Current workspace: \\"/path/with spaces/and \' quotes\\""}\n{"role":"user","content":"hi"}\n',
hasLock: true,
},
{
id: 'session-inactive',
meta: { id: 'session-inactive' },
jsonl: '{"role":"user","content":"hi"}\n',
hasLock: false,
},
],
},
});
const adapter = new ReasonixAdapter();
adapter.cliAvailable = true;
await adapter.refresh();
const active = adapter.getSession('session-active');
const inactive = adapter.getSession('session-inactive');
assert.equal(active.status, 'active');
assert.equal(inactive.status, 'unknown'); // non-locked is unknown, not success or error
// Shell quoting checks
assert.equal(active.resume_command, "reasonix --dir '/path/with spaces/and '\\'' quotes' --resume");
assert.equal(inactive.resume_command, "reasonix --resume");
});
it('emits unified function_call and function_call_output types and respects 300 messages cap', async () => {
let largeJsonl = '{"role":"user","content":"hi"}\n';
for (let i = 0; i < 350; i++) {
largeJsonl += `{"role":"assistant","content":"doing step ${i}","tool_calls":[{"name":"my_tool","arguments":"{}"}]}\n`;
largeJsonl += `{"role":"tool","content":"output ${i}"}\n`;
}
env = withTestFiles({
projects: {
'proj': [
{
id: 'session-large',
meta: { id: 'session-large' },
jsonl: largeJsonl,
},
],
},
});
const adapter = new ReasonixAdapter();
adapter.cliAvailable = true;
await adapter.refresh();
const messages = await adapter.getMessages('session-large');
assert.equal(messages.length, 300); // respects 300-entry cap
// Check unified timeline types
assert.equal(messages[0].type, 'user_message');
assert.equal(messages[1].type, 'assistant_message');
assert.equal(messages[2].type, 'function_call');
assert.equal(messages[3].type, 'function_call_output');
});
});
+12 -3
View File
@@ -17,7 +17,7 @@
<h1 class="logo-title">智能体观测中心</h1> <h1 class="logo-title">智能体观测中心</h1>
<span class="logo-badge">混合引擎</span> <span class="logo-badge">混合引擎</span>
</div> </div>
<p class="logo-sub">Codex · Claude · Agy · OpenCode 协同会话追踪平台</p> <p class="logo-sub">Codex · Claude · Agy · OpenCode · Reasonix 协同会话追踪平台</p>
</div> </div>
</div> </div>
</div> </div>
@@ -159,6 +159,7 @@ const engineOptions = [
{ key: 'claude', label: 'Claude', icon: '🧠', short: 'Claude' }, { key: 'claude', label: 'Claude', icon: '🧠', short: 'Claude' },
{ key: 'agy', label: 'Agy', icon: '⚙', short: 'Agy' }, { key: 'agy', label: 'Agy', icon: '⚙', short: 'Agy' },
{ key: 'opencode', label: 'OpenCode', icon: '🔓', short: 'OpenCode' }, { key: 'opencode', label: 'OpenCode', icon: '🔓', short: 'OpenCode' },
{ key: 'reasonix', label: 'Reasonix', icon: '🔍', short: 'Reasonix' },
] ]
const tabs = [ const tabs = [
@@ -416,11 +417,19 @@ code, .mono { font-family: 'SF Mono', 'Fira Code', monospace; }
.footer-addr { font-family: 'SF Mono', monospace; color: var(--accent-light); font-weight: 600; } .footer-addr { font-family: 'SF Mono', monospace; color: var(--accent-light); font-weight: 600; }
.main-content { flex: 1; overflow-y: auto; padding: 24px; min-width: 0; } .main-content { flex: 1; overflow-y: auto; padding: 24px; min-width: 0; }
.engine-switcher-mobile { display: none; gap: 4px; margin-bottom: 12px; } .engine-switcher-mobile { display: none; gap: 8px; margin-bottom: 12px; overflow-x: auto; padding: 4px; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
.engine-switcher-mobile .engine-btn { flex: 1; justify-content: center; padding: 8px 4px; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text-muted); font-size: 11px; font-weight: 600; cursor: pointer; } .engine-switcher-mobile::-webkit-scrollbar { display: none; }
.engine-switcher-mobile .engine-btn { flex-shrink: 0; justify-content: center; padding: 10px 16px; min-height: 44px; min-width: 80px; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text-muted); font-size: 11px; font-weight: 600; cursor: pointer; }
.engine-switcher-mobile .engine-btn.active { color: #fff; border: none; } .engine-switcher-mobile .engine-btn.active { color: #fff; border: none; }
.sidebar-overlay { display: none; } .sidebar-overlay { display: none; }
@media (max-width: 1200px) {
.logo-sub { display: none; }
}
@media (max-width: 1100px) {
.header-stats { display: none; }
}
@media (max-width: 768px) { @media (max-width: 768px) {
.mobile-menu-btn { display: block; } .mobile-menu-btn { display: block; }
.engine-switcher { display: none; } .engine-switcher { display: none; }
+1
View File
@@ -117,6 +117,7 @@ export const ENGINE_META = {
claude: { color: '#a855f7', bg: 'rgba(168,85,247,0.1)', label: 'Claude', border: 'rgba(168,85,247,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: '⚙' }, 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: '🔓' }, opencode: { color: '#f59e0b', bg: 'rgba(245,158,11,0.1)', label: 'OpenCode', border: 'rgba(245,158,11,0.2)', icon: '🔓' },
reasonix: { color: '#ef4444', bg: 'rgba(239,68,68,0.1)', label: 'Reasonix', border: 'rgba(239,68,68,0.2)', icon: '🔍' },
} }
export function getEngineMeta(engine) { export function getEngineMeta(engine) {
+1 -1
View File
@@ -85,7 +85,7 @@ const sessions = ref([])
const totalSessions = ref(0) const totalSessions = ref(0)
const errorCount = ref(0) const errorCount = ref(0)
const engineLabel = computed(() => { const engineLabel = computed(() => {
const labels = { all: '全部引擎', codex: 'Codex', claude: 'Claude', agy: 'Agy', opencode: 'OpenCode' } const labels = { all: '全部引擎', codex: 'Codex', claude: 'Claude', agy: 'Agy', opencode: 'OpenCode', reasonix: 'Reasonix' }
return labels[selectedEngine.value] || selectedEngine.value return labels[selectedEngine.value] || selectedEngine.value
}) })
+1 -1
View File
@@ -149,7 +149,7 @@ const maxSessionCount = ref(0)
const engineLabel = computed(() => { const engineLabel = computed(() => {
if (selectedEngine.value === 'all') return '全部引擎' if (selectedEngine.value === 'all') return '全部引擎'
const labels = { codex: 'Codex', claude: 'Claude', agy: 'Agy' } const labels = { codex: 'Codex', claude: 'Claude', agy: 'Agy', opencode: 'OpenCode', reasonix: 'Reasonix' }
return labels[selectedEngine.value] || selectedEngine.value return labels[selectedEngine.value] || selectedEngine.value
}) })