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