diff --git a/README.md b/README.md index 3d6ad54..9cd5579 100644 --- a/README.md +++ b/README.md @@ -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`)自动匹配 -- **一键恢复** — 复制引擎对应的恢复命令(`codex resume` / `claude --resume` / `agy --conversation` / `opencode --session`) +- **一键恢复** — 复制引擎对应的恢复命令(`codex resume` / `claude --resume` / `agy --conversation` / `opencode --session` / `reasonix --resume`) - **工具排行** — 全引擎工具调用聚合排行,附技能/插件/缓存清单 - **效能分析** — 会话趋势图、状态分布、异常率 - **会话删除** — 支持 OpenCode 引擎的原始会话删除(通过 `opencode session delete` 执行) @@ -41,15 +41,17 @@ npm start | Claude | `~/.claude/projects/**/*.jsonl` | ~170 | `claude --resume ` | | Agy | `~/.gemini/antigravity-cli/conversation_summaries.db` | ~80 | `agy --conversation ` | | OpenCode | `~/.local/share/opencode/opencode.db` | ~520 | `opencode --session ` | +| Reasonix | `~/.reasonix/projects//sessions/` | - | `reasonix --dir --resume` | OpenCode 数据目录通过 `opencode debug paths` 自动检测,若 CLI 不可用则回退到 `~/.local/share/opencode`。 +Reasonix 数据目录默认是 `~/.reasonix`,可通过环境变量 `REASONIX_HOME` 自定义,其下包含 `projects/` 子目录。 ## API | 路径 | 说明 | |---|---| -| `GET /api/health` | 服务状态 + 四引擎健康 | -| `GET /api/sessions?engine=all\|codex\|claude\|agy\|opencode` | 会话列表 | +| `GET /api/health` | 服务状态 + 五引擎健康 | +| `GET /api/sessions?engine=all\|codex\|claude\|agy\|opencode\|reasonix` | 会话列表 | | `GET /api/sessions/:engine/:id` | 会话详情 | | `GET /api/sessions/:engine/:id/tools` | 会话内工具统计 | | `DELETE /api/sessions/:engine/:id` | 删除会话(仅 OpenCode 引擎支持原始删除) | @@ -61,4 +63,5 @@ OpenCode 数据目录通过 `opencode debug paths` 自动检测,若 CLI 不可 - `CODEX_HOME` — Codex 根目录(默认 `~/.codex`) - `AGY_DATA_DIR` — Agy 数据目录(默认 `~/.gemini/antigravity-cli`) +- `REASONIX_HOME` — Reasonix 根目录(默认 `~/.reasonix`) - `PORT` — 后端端口(默认 `3721`) diff --git a/backend/src/engine/index.js b/backend/src/engine/index.js index a96ab42..df9b017 100644 --- a/backend/src/engine/index.js +++ b/backend/src/engine/index.js @@ -2,12 +2,14 @@ import { CodexAdapter } from './codex-adapter.js'; import { ClaudeAdapter } from './claude-adapter.js'; import { AgyAdapter } from './agy-adapter.js'; import { OpenCodeAdapter } from './opencode-adapter.js'; +import { ReasonixAdapter } from './reasonix-adapter.js'; const ENGINES = { codex: new CodexAdapter(), claude: new ClaudeAdapter(), agy: new AgyAdapter(), opencode: new OpenCodeAdapter(), + reasonix: new ReasonixAdapter(), }; export function getEngine(name) { diff --git a/backend/src/engine/reasonix-adapter.js b/backend/src/engine/reasonix-adapter.js new file mode 100644 index 0000000..303cda1 --- /dev/null +++ b/backend/src/engine/reasonix-adapter.js @@ -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, + }; + } +} diff --git a/backend/src/index.js b/backend/src/index.js index a424b81..d75dd6c 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -260,7 +260,7 @@ app.post('/api/refresh', async (req, res) => { }); // ─── Custom Name ────────────────────────────────────────────────── -const VALID_ENGINES = ['codex', 'claude', 'agy', 'opencode']; +const VALID_ENGINES = ['codex', 'claude', 'agy', 'opencode', 'reasonix']; 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', 'opencode'].includes(engine)) { + if (!['codex', 'claude', 'agy', 'opencode', 'reasonix'].includes(engine)) { return res.status(400).json({ error: `Invalid engine: ${engine}` }); } const e = getEngine(engine); diff --git a/backend/src/title-generator.js b/backend/src/title-generator.js index 72c84a3..49ae830 100644 --- a/backend/src/title-generator.js +++ b/backend/src/title-generator.js @@ -175,6 +175,7 @@ async function fetchMessages(engine, sessionId, adapter) { if (engine === 'claude') return await fetchClaudeMessages(sessionId); if (engine === 'agy') return fetchAgyMessages(session); if (engine === 'opencode') return await fetchOpenCodeMessages(sessionId, adapter); + if (engine === 'reasonix') return await fetchOpenCodeMessages(sessionId, adapter); return []; } diff --git a/backend/test/reasonix-adapter.test.js b/backend/test/reasonix-adapter.test.js new file mode 100644 index 0000000..8e18a6c --- /dev/null +++ b/backend/test/reasonix-adapter.test.js @@ -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'); + }); +}); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 8d2729c..c81c78e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -17,7 +17,7 @@

智能体观测中心

混合引擎 -

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

+

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

@@ -159,6 +159,7 @@ const engineOptions = [ { key: 'claude', label: 'Claude', icon: '🧠', short: 'Claude' }, { key: 'agy', label: 'Agy', icon: '⚙', short: 'Agy' }, { key: 'opencode', label: 'OpenCode', icon: '🔓', short: 'OpenCode' }, + { key: 'reasonix', label: 'Reasonix', icon: '🔍', short: 'Reasonix' }, ] 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; } .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 .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 { display: none; gap: 8px; margin-bottom: 12px; overflow-x: auto; padding: 4px; scrollbar-width: none; -webkit-overflow-scrolling: touch; } +.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; } .sidebar-overlay { display: none; } +@media (max-width: 1200px) { + .logo-sub { display: none; } +} +@media (max-width: 1100px) { + .header-stats { display: none; } +} + @media (max-width: 768px) { .mobile-menu-btn { display: block; } .engine-switcher { display: none; } diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 5d02f9f..c82a523 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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: '🧠' }, 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: '🔓' }, + reasonix: { color: '#ef4444', bg: 'rgba(239,68,68,0.1)', label: 'Reasonix', border: 'rgba(239,68,68,0.2)', icon: '🔍' }, } export function getEngineMeta(engine) { diff --git a/frontend/src/views/AnalyticsView.vue b/frontend/src/views/AnalyticsView.vue index da4ab2c..39b59f9 100644 --- a/frontend/src/views/AnalyticsView.vue +++ b/frontend/src/views/AnalyticsView.vue @@ -85,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', opencode: 'OpenCode' } + const labels = { all: '全部引擎', codex: 'Codex', claude: 'Claude', agy: 'Agy', opencode: 'OpenCode', reasonix: 'Reasonix' } return labels[selectedEngine.value] || selectedEngine.value }) diff --git a/frontend/src/views/ToolsView.vue b/frontend/src/views/ToolsView.vue index 0a3d151..3590c53 100644 --- a/frontend/src/views/ToolsView.vue +++ b/frontend/src/views/ToolsView.vue @@ -149,7 +149,7 @@ const maxSessionCount = ref(0) const engineLabel = computed(() => { 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 })