mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
Add comprehensive tests for schema cleanup migration and search comparison
- Implement tests for migration 005 to verify removal of deprecated fields in the database schema. - Ensure that new databases are created with a clean schema. - Validate that keywords are correctly extracted from the normalized file_keywords table. - Test symbol insertion without deprecated fields and subdir operations without direct_files. - Create a detailed search comparison test to evaluate vector search vs hybrid search performance. - Add a script for reindexing projects to extract code relationships and verify GraphAnalyzer functionality. - Include a test script to check TreeSitter parser availability and relationship extraction from sample files.
This commit is contained in:
360
ccw/docs/CODEX_MCP_IMPLEMENTATION_SUMMARY.md
Normal file
360
ccw/docs/CODEX_MCP_IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,360 @@
|
||||
# Codex MCP 功能实现总结
|
||||
|
||||
## 📝 已完成的修复
|
||||
|
||||
### 1. CCW Tools MCP 卡片样式修复
|
||||
|
||||
**文件**: `ccw/src/templates/dashboard-js/views/mcp-manager.js`
|
||||
|
||||
**修改内容**:
|
||||
- ✅ 卡片边框: `border-primary` → `border-orange-500` (第345行)
|
||||
- ✅ 图标背景: `bg-primary` → `bg-orange-500` (第348行)
|
||||
- ✅ 图标颜色: `text-primary-foreground` → `text-white` (第349行)
|
||||
- ✅ "Available"徽章: `bg-primary/20 text-primary` → `bg-orange-500/20 text-orange-600` (第360行)
|
||||
- ✅ 选择按钮颜色: `text-primary` → `text-orange-500` (第378-379行)
|
||||
- ✅ 安装按钮: `bg-primary` → `bg-orange-500` (第386行、第399行)
|
||||
|
||||
**影响范围**: Claude 模式下的 CCW Tools MCP 卡片
|
||||
|
||||
---
|
||||
|
||||
### 2. Toast 消息显示时间增强
|
||||
|
||||
**文件**: `ccw/src/templates/dashboard-js/components/navigation.js`
|
||||
|
||||
**修改内容**:
|
||||
- ✅ 显示时间: 2000ms → 3500ms (第300行)
|
||||
|
||||
**影响范围**: 所有 Toast 消息(MCP 安装、删除、切换等操作反馈)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 功能实现细节
|
||||
|
||||
### Codex MCP 安装流程
|
||||
|
||||
```
|
||||
用户操作
|
||||
↓
|
||||
前端函数: copyClaudeServerToCodex(serverName, serverConfig)
|
||||
↓
|
||||
调用: addCodexMcpServer(serverName, serverConfig)
|
||||
↓
|
||||
API 请求: POST /api/codex-mcp-add
|
||||
↓
|
||||
后端处理: addCodexMcpServer(serverName, serverConfig)
|
||||
↓
|
||||
文件操作:
|
||||
1. 读取 ~/.codex/config.toml (如存在)
|
||||
2. 解析 TOML 配置
|
||||
3. 添加/更新 mcp_servers[serverName]
|
||||
4. 序列化为 TOML
|
||||
5. 写入文件
|
||||
↓
|
||||
返回响应: {success: true} 或 {error: "..."}
|
||||
↓
|
||||
前端更新:
|
||||
1. loadMcpConfig() - 重新加载配置
|
||||
2. renderMcpManager() - 重新渲染 UI
|
||||
3. showRefreshToast(...) - 显示成功/失败消息 (3.5秒)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📍 关键代码位置
|
||||
|
||||
### 前端
|
||||
|
||||
| 功能 | 文件 | 行号 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 复制到 Codex | `components/mcp-manager.js` | 175-177 | `copyClaudeServerToCodex()` 函数 |
|
||||
| 添加到 Codex | `components/mcp-manager.js` | 87-114 | `addCodexMcpServer()` 函数 |
|
||||
| Toast 消息 | `components/navigation.js` | 286-301 | `showRefreshToast()` 函数 |
|
||||
| CCW Tools 样式 | `views/mcp-manager.js` | 342-415 | Claude 模式卡片渲染 |
|
||||
| 其他项目按钮 | `views/mcp-manager.js` | 1015-1020 | "Install to Codex" 按钮 |
|
||||
|
||||
### 后端
|
||||
|
||||
| 功能 | 文件 | 行号 | 说明 |
|
||||
|------|------|------|------|
|
||||
| API 端点 | `core/routes/mcp-routes.ts` | 1001-1010 | `/api/codex-mcp-add` 路由 |
|
||||
| 添加服务器 | `core/routes/mcp-routes.ts` | 251-330 | `addCodexMcpServer()` 函数 |
|
||||
| TOML 序列化 | `core/routes/mcp-routes.ts` | 166-188 | `serializeToml()` 函数 |
|
||||
|
||||
### CSS
|
||||
|
||||
| 功能 | 文件 | 行号 | 说明 |
|
||||
|------|------|------|------|
|
||||
| Toast 样式 | `dashboard-css/06-cards.css` | 1501-1538 | Toast 容器和类型样式 |
|
||||
| Toast 动画 | `dashboard-css/06-cards.css` | 1540-1551 | 滑入/淡出动画 |
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试用例
|
||||
|
||||
### 测试用例 1: CCW Tools 样式验证
|
||||
|
||||
**前置条件**: Dashboard 运行,进入 MCP 管理页面
|
||||
|
||||
**测试步骤**:
|
||||
1. 确保在 Claude 模式
|
||||
2. 查看 CCW Tools MCP 卡片
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 卡片有橙色边框(`border-orange-500/30`)
|
||||
- [ ] 图标背景是橙色(`bg-orange-500`)
|
||||
- [ ] 图标是白色(`text-white`)
|
||||
- [ ] "Available"徽章是橙色
|
||||
- [ ] 按钮是橙色
|
||||
|
||||
**优先级**: High
|
||||
|
||||
---
|
||||
|
||||
### 测试用例 2: Codex MCP 新建安装
|
||||
|
||||
**前置条件**: Dashboard 运行,进入 MCP 管理页面
|
||||
|
||||
**测试步骤**:
|
||||
1. 切换到 Codex 模式
|
||||
2. 勾选 CCW Tools 的 4 个核心工具
|
||||
3. 点击"Install"按钮
|
||||
4. 观察 Toast 消息
|
||||
|
||||
**预期结果**:
|
||||
- [ ] Toast 消息显示
|
||||
- [ ] 消息内容: "CCW Tools installed to Codex (4 tools)"
|
||||
- [ ] Toast 停留时间: 3.5秒
|
||||
- [ ] 卡片状态更新(显示"4 tools"绿色徽章)
|
||||
- [ ] `~/.codex/config.toml` 文件创建成功
|
||||
- [ ] config.toml 包含正确的 `[mcp_servers.ccw-tools]` 配置
|
||||
|
||||
**优先级**: Critical
|
||||
|
||||
---
|
||||
|
||||
### 测试用例 3: Claude MCP 复制到 Codex
|
||||
|
||||
**前置条件**:
|
||||
- Dashboard 运行
|
||||
- Claude 模式下已创建全局 MCP 服务器 `test-server`
|
||||
|
||||
**测试步骤**:
|
||||
1. 切换到 Codex 模式
|
||||
2. 滚动到"Copy Claude Servers to Codex"区域
|
||||
3. 找到 `test-server` 卡片
|
||||
4. 点击"→ Codex"按钮
|
||||
5. 观察 Toast 消息
|
||||
|
||||
**预期结果**:
|
||||
- [ ] Toast 消息显示
|
||||
- [ ] 消息内容: "Codex MCP server 'test-server' added"
|
||||
- [ ] Toast 停留时间: 3.5秒
|
||||
- [ ] 卡片出现"Already added"绿色徽章
|
||||
- [ ] "→ Codex"按钮消失
|
||||
- [ ] 服务器出现在"Codex Global Servers"区域
|
||||
- [ ] `~/.codex/config.toml` 包含 `test-server` 配置
|
||||
|
||||
**优先级**: Critical
|
||||
|
||||
---
|
||||
|
||||
### 测试用例 4: 其他项目 MCP 复制到 Codex
|
||||
|
||||
**前置条件**:
|
||||
- Dashboard 运行
|
||||
- 其他项目中存在 MCP 服务器
|
||||
|
||||
**测试步骤**:
|
||||
1. 切换到 Codex 模式
|
||||
2. 滚动到"Available from Other Projects"区域
|
||||
3. 找到来自其他项目的服务器卡片
|
||||
4. 点击"Install to Codex"按钮
|
||||
5. 观察 Toast 消息
|
||||
|
||||
**预期结果**:
|
||||
- [ ] Toast 消息显示
|
||||
- [ ] 消息内容包含服务器名称
|
||||
- [ ] Toast 停留时间: 3.5秒
|
||||
- [ ] 服务器出现在"Codex Global Servers"区域
|
||||
- [ ] `~/.codex/config.toml` 包含新服务器配置
|
||||
|
||||
**优先级**: High
|
||||
|
||||
---
|
||||
|
||||
## 🔍 验证清单
|
||||
|
||||
### 代码审查
|
||||
|
||||
- [x] ✅ 前端函数正确调用后端 API
|
||||
- [x] ✅ 后端正确处理请求并写入配置文件
|
||||
- [x] ✅ Toast 消息在成功和失败时都正确显示
|
||||
- [x] ✅ Toast 显示时间更新为 3.5秒
|
||||
- [x] ✅ CCW Tools 卡片使用橙色样式
|
||||
- [x] ✅ 复制按钮调用正确的函数
|
||||
- [x] ✅ 配置文件路径正确 (`~/.codex/config.toml`)
|
||||
- [x] ✅ TOML 序列化正确处理所有字段
|
||||
|
||||
### 功能测试
|
||||
|
||||
- [ ] ⬜ CCW Tools 样式在 Claude 模式下正确显示
|
||||
- [ ] ⬜ Codex MCP 新建安装成功
|
||||
- [ ] ⬜ Toast 消息正确显示并停留 3.5秒
|
||||
- [ ] ⬜ config.toml 文件正确创建
|
||||
- [ ] ⬜ 从 Claude 复制到 Codex 成功
|
||||
- [ ] ⬜ 从其他项目复制到 Codex 成功
|
||||
- [ ] ⬜ 卡片状态正确更新
|
||||
- [ ] ⬜ UI 刷新正确
|
||||
|
||||
### 边界情况
|
||||
|
||||
- [ ] ⬜ Codex 目录不存在时自动创建
|
||||
- [ ] ⬜ config.toml 不存在时正确创建
|
||||
- [ ] ⬜ config.toml 已存在时正确追加
|
||||
- [ ] ⬜ 重复安装同一服务器正确更新配置
|
||||
- [ ] ⬜ API 失败时显示错误 Toast
|
||||
- [ ] ⬜ 网络错误时显示错误信息
|
||||
|
||||
---
|
||||
|
||||
## 📦 相关文件清单
|
||||
|
||||
### 已修改文件
|
||||
|
||||
1. `ccw/src/templates/dashboard-js/views/mcp-manager.js`
|
||||
- 修改: CCW Tools 卡片样式(第342-415行)
|
||||
|
||||
2. `ccw/src/templates/dashboard-js/components/navigation.js`
|
||||
- 修改: Toast 显示时间(第300行)
|
||||
|
||||
### 核心功能文件(未修改但相关)
|
||||
|
||||
3. `ccw/src/templates/dashboard-js/components/mcp-manager.js`
|
||||
- 包含: `addCodexMcpServer()`, `copyClaudeServerToCodex()` 函数
|
||||
|
||||
4. `ccw/src/core/routes/mcp-routes.ts`
|
||||
- 包含: Codex MCP API 端点和后端逻辑
|
||||
|
||||
5. `ccw/src/templates/dashboard-css/06-cards.css`
|
||||
- 包含: Toast 样式定义
|
||||
|
||||
### 新增文档
|
||||
|
||||
6. `ccw/docs/CODEX_MCP_TESTING_GUIDE.md`
|
||||
- 详细测试指南
|
||||
|
||||
7. `ccw/docs/QUICK_TEST_CODEX_MCP.md`
|
||||
- 快速测试步骤
|
||||
|
||||
8. `ccw/docs/CODEX_MCP_IMPLEMENTATION_SUMMARY.md`
|
||||
- 本文档
|
||||
|
||||
---
|
||||
|
||||
## 🎯 下一步行动
|
||||
|
||||
### 立即执行
|
||||
|
||||
1. **重启 Dashboard**:
|
||||
```bash
|
||||
# 停止当前 Dashboard
|
||||
# 重新启动
|
||||
npm run dev # 或你的启动命令
|
||||
```
|
||||
|
||||
2. **执行快速测试**:
|
||||
- 按照 `QUICK_TEST_CODEX_MCP.md` 执行测试
|
||||
- 重点验证:
|
||||
- CCW Tools 样式
|
||||
- Toast 消息显示和时长
|
||||
- config.toml 文件创建
|
||||
|
||||
3. **记录测试结果**:
|
||||
- 填写 `QUICK_TEST_CODEX_MCP.md` 中的检查清单
|
||||
- 截图保存关键步骤
|
||||
|
||||
### 如果测试失败
|
||||
|
||||
1. **检查浏览器控制台**:
|
||||
- F12 打开开发者工具
|
||||
- Console 标签查看错误
|
||||
- Network 标签查看 API 请求
|
||||
|
||||
2. **检查后端日志**:
|
||||
- 查看 CCW Dashboard 的控制台输出
|
||||
- 查找 `Error adding Codex MCP server` 等错误信息
|
||||
|
||||
3. **验证文件权限**:
|
||||
```bash
|
||||
ls -la ~/.codex/
|
||||
# 确保有读写权限
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 测试报告模板
|
||||
|
||||
```markdown
|
||||
# Codex MCP 功能测试报告
|
||||
|
||||
**测试日期**: ___________
|
||||
**测试人员**: ___________
|
||||
**CCW 版本**: ___________
|
||||
**浏览器**: ___________
|
||||
|
||||
## 测试结果
|
||||
|
||||
### CCW Tools 样式 (Claude 模式)
|
||||
- [ ] ✅ 通过 / [ ] ❌ 失败
|
||||
- 备注: ___________
|
||||
|
||||
### Codex MCP 新建安装
|
||||
- [ ] ✅ 通过 / [ ] ❌ 失败
|
||||
- Toast 显示: [ ] ✅ 是 / [ ] ❌ 否
|
||||
- Toast 时长: _____ 秒
|
||||
- config.toml 创建: [ ] ✅ 是 / [ ] ❌ 否
|
||||
- 备注: ___________
|
||||
|
||||
### Claude → Codex 复制
|
||||
- [ ] ✅ 通过 / [ ] ❌ 失败
|
||||
- Toast 显示: [ ] ✅ 是 / [ ] ❌ 否
|
||||
- Toast 内容正确: [ ] ✅ 是 / [ ] ❌ 否
|
||||
- 备注: ___________
|
||||
|
||||
### 其他项目 → Codex 安装
|
||||
- [ ] ✅ 通过 / [ ] ❌ 失败
|
||||
- 备注: ___________
|
||||
|
||||
## 发现的问题
|
||||
|
||||
1. ___________
|
||||
2. ___________
|
||||
3. ___________
|
||||
|
||||
## 建议改进
|
||||
|
||||
1. ___________
|
||||
2. ___________
|
||||
3. ___________
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
所有功能已经实现并准备好测试:
|
||||
|
||||
✅ **已完成**:
|
||||
- CCW Tools MCP 卡片样式修复(橙色)
|
||||
- Toast 消息显示时间增强(3.5秒)
|
||||
- Codex MCP 安装功能(已存在,无需修改)
|
||||
- Claude → Codex 复制功能(已存在,无需修改)
|
||||
- 详细测试文档和指南
|
||||
|
||||
⚠️ **待验证**:
|
||||
- 实际运行环境中的功能测试
|
||||
- 用户体验反馈
|
||||
- 边界情况处理
|
||||
|
||||
请按照 `QUICK_TEST_CODEX_MCP.md` 开始测试!
|
||||
321
ccw/docs/CODEX_MCP_TESTING_GUIDE.md
Normal file
321
ccw/docs/CODEX_MCP_TESTING_GUIDE.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# Codex MCP 安装测试指南
|
||||
|
||||
## 测试准备
|
||||
|
||||
### 前置条件
|
||||
1. 确保 CCW Dashboard 正在运行
|
||||
2. 打开浏览器访问 Dashboard 界面
|
||||
3. 导航到 "MCP 管理" 页面
|
||||
|
||||
### 测试环境
|
||||
- **Codex 配置文件**: `~/.codex/config.toml`
|
||||
- **Claude 配置文件**: `~/.claude.json`
|
||||
- **Dashboard URL**: `http://localhost:3000`
|
||||
|
||||
---
|
||||
|
||||
## 测试场景 1: Codex MCP 新建安装
|
||||
|
||||
### 测试步骤
|
||||
|
||||
1. **切换到 Codex 模式**
|
||||
- 点击页面顶部的 "Codex" 按钮(橙色高亮)
|
||||
- 确认右侧显示配置文件路径:`~/.codex/config.toml`
|
||||
|
||||
2. **查看 CCW Tools MCP 卡片**
|
||||
- ✅ 验证卡片有橙色边框 (`border-orange-500`)
|
||||
- ✅ 验证图标背景是橙色 (`bg-orange-500`)
|
||||
- ✅ 验证图标颜色是白色
|
||||
- ✅ 验证"Available"徽章是橙色
|
||||
- ✅ 验证"Core only"/"All"按钮是橙色
|
||||
|
||||
3. **选择工具并安装**
|
||||
- 勾选需要的工具(例如:所有核心工具)
|
||||
- 点击橙色的"Install"按钮
|
||||
- **预期结果**:
|
||||
- 屏幕底部中央显示 Toast 消息
|
||||
- Toast 消息内容:`"CCW Tools installed to Codex (X tools)"` (X 为选择的工具数量)
|
||||
- Toast 消息类型:绿色成功提示
|
||||
- Toast 显示时间:3.5秒
|
||||
- 卡片状态更新为"已安装"(绿色对勾徽章)
|
||||
- 安装按钮文字变为"Update"
|
||||
|
||||
4. **验证安装结果**
|
||||
- 打开 `~/.codex/config.toml` 文件
|
||||
- 确认存在 `[mcp_servers.ccw-tools]` 配置块
|
||||
- 示例配置:
|
||||
```toml
|
||||
[mcp_servers.ccw-tools]
|
||||
command = "npx"
|
||||
args = ["-y", "ccw-mcp"]
|
||||
env = { CCW_ENABLED_TOOLS = "write_file,edit_file,codex_lens,smart_search" }
|
||||
```
|
||||
|
||||
### 测试数据记录
|
||||
|
||||
| 测试项 | 预期结果 | 实际结果 | 状态 |
|
||||
|--------|----------|----------|------|
|
||||
| 卡片样式(橙色边框) | ✅ | _待填写_ | ⬜ |
|
||||
| 图标样式(橙色背景) | ✅ | _待填写_ | ⬜ |
|
||||
| Toast 消息显示 | ✅ 3.5秒 | _待填写_ | ⬜ |
|
||||
| Toast 消息内容 | "CCW Tools installed to Codex (X tools)" | _待填写_ | ⬜ |
|
||||
| config.toml 文件创建 | ✅ | _待填写_ | ⬜ |
|
||||
| MCP 服务器配置正确 | ✅ | _待填写_ | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
## 测试场景 2: 从 Claude MCP 复制到 Codex
|
||||
|
||||
### 测试步骤
|
||||
|
||||
1. **前置准备:在 Claude 模式下创建 MCP 服务器**
|
||||
- 切换到 "Claude" 模式
|
||||
- 在"全局可用 MCP"区域点击"+ New Global Server"
|
||||
- 创建测试服务器:
|
||||
- **名称**: `test-mcp-server`
|
||||
- **命令**: `npx`
|
||||
- **参数**: `-y @modelcontextprotocol/server-filesystem /tmp`
|
||||
- 点击"Create"按钮
|
||||
- 确认服务器出现在"全局可用 MCP"列表中
|
||||
|
||||
2. **切换到 Codex 模式**
|
||||
- 点击顶部的 "Codex" 按钮
|
||||
- 向下滚动到"Copy Claude Servers to Codex"区域
|
||||
|
||||
3. **找到测试服务器**
|
||||
- 在列表中找到 `test-mcp-server`
|
||||
- 卡片应该显示:
|
||||
- 蓝色"Claude"徽章
|
||||
- 虚线边框(表示可复制)
|
||||
- 橙色"→ Codex"按钮
|
||||
|
||||
4. **执行复制操作**
|
||||
- 点击橙色的"→ Codex"按钮
|
||||
- **预期结果**:
|
||||
- Toast 消息显示:`"Codex MCP server 'test-mcp-server' added"` (中文:`"Codex MCP 服务器 'test-mcp-server' 已添加"`)
|
||||
- Toast 类型:绿色成功提示
|
||||
- Toast 显示时间:3.5秒
|
||||
- 卡片出现绿色"Already added"徽章
|
||||
- "→ Codex"按钮消失
|
||||
- 服务器出现在"Codex Global Servers"区域
|
||||
|
||||
5. **验证复制结果**
|
||||
- 检查 `~/.codex/config.toml` 文件
|
||||
- 确认存在 `[mcp_servers.test-mcp-server]` 配置块
|
||||
- 示例配置:
|
||||
```toml
|
||||
[mcp_servers.test-mcp-server]
|
||||
command = "npx"
|
||||
args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
```
|
||||
|
||||
### 测试数据记录
|
||||
|
||||
| 测试项 | 预期结果 | 实际结果 | 状态 |
|
||||
|--------|----------|----------|------|
|
||||
| Claude 服务器创建 | ✅ | _待填写_ | ⬜ |
|
||||
| 复制区域显示服务器 | ✅ | _待填写_ | ⬜ |
|
||||
| Toast 消息显示 | ✅ 3.5秒 | _待填写_ | ⬜ |
|
||||
| Toast 消息内容 | "Codex MCP server 'test-mcp-server' added" | _待填写_ | ⬜ |
|
||||
| config.toml 配置正确 | ✅ | _待填写_ | ⬜ |
|
||||
| 卡片状态更新 | "Already added"徽章 | _待填写_ | ⬜ |
|
||||
| Codex区域显示服务器 | ✅ | _待填写_ | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
## 测试场景 3: 从其他项目复制到 Codex
|
||||
|
||||
### 测试步骤
|
||||
|
||||
1. **前置准备:在其他项目中创建 MCP 服务器**
|
||||
- 假设你有另一个项目(例如:`/path/to/other-project`)
|
||||
- 在该项目的 `.claude.json` 或 `.mcp.json` 中添加 MCP 服务器配置
|
||||
- 或在 Dashboard 中为该项目创建 MCP 服务器
|
||||
|
||||
2. **切换回当前项目**
|
||||
- 在 Dashboard 左上角切换到当前测试项目
|
||||
- 切换到 "Codex" 模式
|
||||
|
||||
3. **查看"其他项目可用"区域**
|
||||
- 向下滚动到最底部的"Available from Other Projects"区域
|
||||
- 应该看到来自其他项目的 MCP 服务器
|
||||
- 卡片显示:
|
||||
- 服务器名称
|
||||
- 蓝色"Claude"徽章
|
||||
- 项目来源标签(例如:`other-project`)
|
||||
- 橙色"Install to Codex"按钮
|
||||
|
||||
4. **执行安装操作**
|
||||
- 点击橙色的"Install to Codex"按钮
|
||||
- **预期结果**:
|
||||
- Toast 消息显示成功信息
|
||||
- Toast 显示时间:3.5秒
|
||||
- 服务器出现在"Codex Global Servers"区域
|
||||
- 原卡片显示"Already added"徽章
|
||||
|
||||
5. **验证安装结果**
|
||||
- 检查 `~/.codex/config.toml` 文件
|
||||
- 确认新服务器配置正确
|
||||
|
||||
### 测试数据记录
|
||||
|
||||
| 测试项 | 预期结果 | 实际结果 | 状态 |
|
||||
|--------|----------|----------|------|
|
||||
| 其他项目服务器显示 | ✅ | _待填写_ | ⬜ |
|
||||
| Toast 消息显示 | ✅ 3.5秒 | _待填写_ | ⬜ |
|
||||
| Toast 消息内容 | 成功消息 | _待填写_ | ⬜ |
|
||||
| config.toml 配置正确 | ✅ | _待填写_ | ⬜ |
|
||||
| Codex区域显示服务器 | ✅ | _待填写_ | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
## 故障排查
|
||||
|
||||
### Toast 消息不显示
|
||||
|
||||
**可能原因**:
|
||||
1. Toast 容器 CSS 被覆盖
|
||||
2. JavaScript 错误阻止了消息显示
|
||||
|
||||
**排查步骤**:
|
||||
1. 打开浏览器开发者工具(F12)
|
||||
2. 切换到 Console 标签页
|
||||
3. 执行安装操作
|
||||
4. 查看是否有错误信息
|
||||
5. 检查 Network 标签页,确认 API 请求成功(状态码 200)
|
||||
|
||||
### config.toml 未创建
|
||||
|
||||
**可能原因**:
|
||||
1. 文件权限问题
|
||||
2. 后端 API 错误
|
||||
|
||||
**排查步骤**:
|
||||
1. 检查 `~/.codex` 目录是否存在
|
||||
2. 检查该目录的读写权限
|
||||
3. 查看 CCW Dashboard 后端日志
|
||||
4. 检查 API 响应:
|
||||
```bash
|
||||
# 在浏览器开发者工具 Network 标签页查看
|
||||
# POST /api/codex-mcp-add
|
||||
# 响应应该是: {"success": true}
|
||||
```
|
||||
|
||||
### 服务器配置格式不正确
|
||||
|
||||
**可能原因**:
|
||||
1. Claude 格式到 Codex 格式转换错误
|
||||
2. 特殊字段未正确处理
|
||||
|
||||
**排查步骤**:
|
||||
1. 对比 Claude 和 Codex 配置格式
|
||||
2. 检查转换逻辑(`addCodexMcpServer` 函数)
|
||||
3. 验证 TOML 序列化正确性
|
||||
|
||||
---
|
||||
|
||||
## 成功标准
|
||||
|
||||
所有测试场景通过以下标准:
|
||||
|
||||
✅ **UI 样式正确**
|
||||
- Claude 模式:CCW Tools 卡片使用橙色样式
|
||||
- Codex 模式:CCW Tools 卡片使用橙色样式
|
||||
- 按钮颜色和边框符合设计规范
|
||||
|
||||
✅ **Toast 反馈完整**
|
||||
- 安装成功时显示成功 Toast
|
||||
- Toast 消息内容准确(包含服务器名称)
|
||||
- Toast 显示时间为 3.5秒
|
||||
- Toast 类型正确(success/error)
|
||||
|
||||
✅ **配置文件正确**
|
||||
- `~/.codex/config.toml` 创建成功
|
||||
- MCP 服务器配置格式正确
|
||||
- 配置内容与源配置匹配
|
||||
|
||||
✅ **UI 状态同步**
|
||||
- 安装后卡片状态更新
|
||||
- 服务器出现在正确的区域
|
||||
- 徽章显示正确
|
||||
|
||||
---
|
||||
|
||||
## 测试报告模板
|
||||
|
||||
### 测试信息
|
||||
- **测试日期**: _____
|
||||
- **测试人员**: _____
|
||||
- **CCW 版本**: _____
|
||||
- **浏览器**: _____
|
||||
|
||||
### 测试结果总结
|
||||
|
||||
| 测试场景 | 通过 | 失败 | 备注 |
|
||||
|----------|------|------|------|
|
||||
| Codex MCP 新建安装 | ⬜ | ⬜ | |
|
||||
| 从 Claude MCP 复制到 Codex | ⬜ | ⬜ | |
|
||||
| 从其他项目复制到 Codex | ⬜ | ⬜ | |
|
||||
|
||||
### 发现的问题
|
||||
|
||||
1. **问题描述**: _____
|
||||
- **严重程度**: Critical / High / Medium / Low
|
||||
- **复现步骤**: _____
|
||||
- **预期结果**: _____
|
||||
- **实际结果**: _____
|
||||
- **截图/日志**: _____
|
||||
|
||||
### 改进建议
|
||||
|
||||
_____
|
||||
|
||||
---
|
||||
|
||||
## 附录:功能实现细节
|
||||
|
||||
### Toast 消息机制
|
||||
|
||||
**实现位置**:
|
||||
- `ccw/src/templates/dashboard-js/components/navigation.js:286-301`
|
||||
- 显示时间:3500ms (3.5秒)
|
||||
- 淡出动画:300ms
|
||||
|
||||
**Toast 类型**:
|
||||
- `success`: 绿色背景 (`hsl(142 76% 36%)`)
|
||||
- `error`: 红色背景 (`hsl(0 72% 51%)`)
|
||||
- `info`: 主色调背景
|
||||
- `warning`: 橙色背景 (`hsl(38 92% 50%)`)
|
||||
|
||||
### Codex MCP 安装流程
|
||||
|
||||
1. **前端调用**: `copyClaudeServerToCodex(serverName, serverConfig)`
|
||||
2. **API 端点**: `POST /api/codex-mcp-add`
|
||||
3. **后端处理**: `addCodexMcpServer(serverName, serverConfig)`
|
||||
4. **配置写入**: 序列化为 TOML 格式并写入 `~/.codex/config.toml`
|
||||
5. **响应返回**: `{success: true}` 或 `{error: "错误消息"}`
|
||||
6. **前端更新**:
|
||||
- 重新加载 MCP 配置
|
||||
- 重新渲染 UI
|
||||
- 显示 Toast 消息
|
||||
|
||||
### 格式转换规则
|
||||
|
||||
**Claude 格式** → **Codex 格式**:
|
||||
- `command` → `command` (保持不变)
|
||||
- `args` → `args` (保持不变)
|
||||
- `env` → `env` (保持不变)
|
||||
- `cwd` → `cwd` (可选)
|
||||
- `url` → `url` (HTTP 服务器)
|
||||
- `enabled` → `enabled` (默认 true)
|
||||
|
||||
---
|
||||
|
||||
## 联系支持
|
||||
|
||||
如果遇到问题,请提供以下信息:
|
||||
1. 测试场景编号
|
||||
2. 浏览器开发者工具的 Console 输出
|
||||
3. Network 标签页的 API 请求/响应详情
|
||||
4. `~/.codex/config.toml` 文件内容(如果存在)
|
||||
5. CCW Dashboard 后端日志
|
||||
237
ccw/docs/GRAPH_EXPLORER_FIX.md
Normal file
237
ccw/docs/GRAPH_EXPLORER_FIX.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# Graph Explorer Fix - Migration 005 Compatibility
|
||||
|
||||
## Issue Description
|
||||
|
||||
The CCW Dashboard's Graph Explorer view was broken after codex-lens migration 005, which cleaned up unused database fields.
|
||||
|
||||
### Root Cause
|
||||
|
||||
Migration 005 removed unused/redundant columns from the codex-lens database:
|
||||
- `symbols.token_count` (unused, always NULL)
|
||||
- `symbols.symbol_type` (redundant duplicate of `kind`)
|
||||
|
||||
However, `ccw/src/core/routes/graph-routes.ts` was still querying these removed columns, causing SQL errors:
|
||||
|
||||
```typescript
|
||||
// BEFORE (broken):
|
||||
SELECT
|
||||
s.id,
|
||||
s.name,
|
||||
s.kind,
|
||||
s.start_line,
|
||||
s.token_count, // ❌ Column removed in migration 005
|
||||
s.symbol_type, // ❌ Column removed in migration 005
|
||||
f.path as file
|
||||
FROM symbols s
|
||||
```
|
||||
|
||||
This resulted in database query failures when trying to load the graph visualization.
|
||||
|
||||
## Fix Applied
|
||||
|
||||
Updated `graph-routes.ts` to match the new database schema (v5):
|
||||
|
||||
### 1. Updated GraphNode Interface
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
interface GraphNode {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
file: string;
|
||||
line: number;
|
||||
docstring?: string; // ❌ Removed (no longer available)
|
||||
tokenCount?: number; // ❌ Removed (no longer available)
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
interface GraphNode {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
file: string;
|
||||
line: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Updated SQL Query
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
SELECT
|
||||
s.id,
|
||||
s.name,
|
||||
s.kind,
|
||||
s.start_line,
|
||||
s.token_count, // ❌ Removed
|
||||
s.symbol_type, // ❌ Removed
|
||||
f.path as file
|
||||
FROM symbols s
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
SELECT
|
||||
s.id,
|
||||
s.name,
|
||||
s.kind,
|
||||
s.start_line,
|
||||
f.path as file
|
||||
FROM symbols s
|
||||
```
|
||||
|
||||
### 3. Updated Row Mapping
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
return rows.map((row: any) => ({
|
||||
id: `${row.file}:${row.name}:${row.start_line}`,
|
||||
name: row.name,
|
||||
type: mapSymbolKind(row.kind),
|
||||
file: row.file,
|
||||
line: row.start_line,
|
||||
docstring: row.symbol_type || undefined, // ❌ Removed
|
||||
tokenCount: row.token_count || undefined, // ❌ Removed
|
||||
}));
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
return rows.map((row: any) => ({
|
||||
id: `${row.file}:${row.name}:${row.start_line}`,
|
||||
name: row.name,
|
||||
type: mapSymbolKind(row.kind),
|
||||
file: row.file,
|
||||
line: row.start_line,
|
||||
}));
|
||||
```
|
||||
|
||||
### 4. Updated API Documentation
|
||||
|
||||
Updated `graph-routes.md` to reflect the simplified schema without the removed fields.
|
||||
|
||||
## How to Use Graph Explorer
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **CodexLens must be installed and initialized:**
|
||||
```bash
|
||||
pip install -e codex-lens/
|
||||
```
|
||||
|
||||
2. **Project must be indexed:**
|
||||
```bash
|
||||
# Via CLI
|
||||
codex init <project-path>
|
||||
|
||||
# Or via CCW Dashboard
|
||||
# Navigate to "CodexLens" view → Click "Initialize" → Select project
|
||||
```
|
||||
|
||||
This creates the `_index.db` database at `~/.codexlens/indexes/<normalized-path>/_index.db`
|
||||
|
||||
3. **Symbols and relationships must be extracted:**
|
||||
- CodexLens automatically indexes symbols during `init`
|
||||
- Requires TreeSitter parsers for your programming language
|
||||
- Relationships are extracted via migration 003 (code_relationships table)
|
||||
|
||||
### Accessing Graph Explorer
|
||||
|
||||
1. **Start CCW Dashboard:**
|
||||
```bash
|
||||
ccw view
|
||||
```
|
||||
|
||||
2. **Navigate to Graph Explorer:**
|
||||
- Click the "Graph" icon in the left sidebar (git-branch icon)
|
||||
- Or use keyboard shortcut if configured
|
||||
|
||||
3. **View Code Structure:**
|
||||
- **Code Relations Tab**: Interactive graph visualization of symbols and their relationships
|
||||
- **Search Process Tab**: Visualizes search pipeline steps (experimental)
|
||||
|
||||
### Graph Controls
|
||||
|
||||
**Toolbar (top-right):**
|
||||
- **Fit View**: Zoom to fit all nodes in viewport
|
||||
- **Center**: Center the graph
|
||||
- **Reset Filters**: Clear all node/edge type filters
|
||||
- **Refresh**: Reload data from database
|
||||
|
||||
**Sidebar Filters:**
|
||||
- **Node Types**: Filter by MODULE, CLASS, FUNCTION, METHOD, VARIABLE
|
||||
- **Edge Types**: Filter by CALLS, IMPORTS, INHERITS
|
||||
- **Legend**: Color-coded guide for node/edge types
|
||||
|
||||
**Interaction:**
|
||||
- **Click node**: Show details panel with symbol information
|
||||
- **Drag nodes**: Rearrange graph layout
|
||||
- **Scroll**: Zoom in/out
|
||||
- **Pan**: Click and drag on empty space
|
||||
|
||||
### API Endpoints
|
||||
|
||||
The Graph Explorer uses these REST endpoints:
|
||||
|
||||
1. **GET /api/graph/nodes**
|
||||
- Returns all symbols as graph nodes
|
||||
- Query param: `path` (optional, defaults to current project)
|
||||
|
||||
2. **GET /api/graph/edges**
|
||||
- Returns all code relationships as graph edges
|
||||
- Query param: `path` (optional)
|
||||
|
||||
3. **GET /api/graph/impact**
|
||||
- Returns impact analysis for a symbol
|
||||
- Query params: `path`, `symbol` (required, format: `file:name:line`)
|
||||
|
||||
## Verification
|
||||
|
||||
To verify the fix works:
|
||||
|
||||
1. **Ensure project is indexed:**
|
||||
```bash
|
||||
ls ~/.codexlens/indexes/
|
||||
# Should show your project path
|
||||
```
|
||||
|
||||
2. **Check database has symbols:**
|
||||
```bash
|
||||
sqlite3 ~/.codexlens/indexes/<your-project>/_index.db "SELECT COUNT(*) FROM symbols"
|
||||
# Should return > 0
|
||||
```
|
||||
|
||||
3. **Check schema version:**
|
||||
```bash
|
||||
sqlite3 ~/.codexlens/indexes/<your-project>/_index.db "PRAGMA user_version"
|
||||
# Should return: 5 (after migration 005)
|
||||
```
|
||||
|
||||
4. **Test Graph Explorer:**
|
||||
- Open CCW dashboard: `ccw view`
|
||||
- Navigate to Graph view
|
||||
- Should see nodes/edges displayed without errors
|
||||
|
||||
## Related Files
|
||||
|
||||
- **Implementation**: `ccw/src/core/routes/graph-routes.ts`
|
||||
- **Frontend**: `ccw/src/templates/dashboard-js/views/graph-explorer.js`
|
||||
- **Styles**: `ccw/src/templates/dashboard-css/14-graph-explorer.css`
|
||||
- **API Docs**: `ccw/src/core/routes/graph-routes.md`
|
||||
- **Migration**: `codex-lens/src/codexlens/storage/migrations/migration_005_cleanup_unused_fields.py`
|
||||
|
||||
## Impact
|
||||
|
||||
- **Breaking Change**: Graph Explorer required codex-lens database schema v5
|
||||
- **Data Loss**: None (removed fields were unused or redundant)
|
||||
- **Compatibility**: Graph Explorer now works correctly with migration 005+
|
||||
- **Future**: All CCW features requiring codex-lens database access must respect schema version 5
|
||||
|
||||
## References
|
||||
|
||||
- Migration 005 Documentation: `codex-lens/docs/MIGRATION_005_SUMMARY.md`
|
||||
- Graph Routes API: `ccw/src/core/routes/graph-routes.md`
|
||||
- CodexLens Schema: `codex-lens/src/codexlens/storage/dir_index.py`
|
||||
331
ccw/docs/GRAPH_EXPLORER_TROUBLESHOOTING.md
Normal file
331
ccw/docs/GRAPH_EXPLORER_TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,331 @@
|
||||
# Graph Explorer 故障排查指南
|
||||
|
||||
## 问题 1: 数据库列名错误
|
||||
|
||||
### 症状
|
||||
```
|
||||
[Graph] Failed to query symbols: no such column: s.token_count
|
||||
[Graph] Failed to query relationships: no such column: f.path
|
||||
```
|
||||
|
||||
### 原因
|
||||
Migration 004 和 005 修改了数据库 schema:
|
||||
- Migration 004: `files.path` → `files.full_path`
|
||||
- Migration 005: 删除 `symbols.token_count` 和 `symbols.symbol_type`
|
||||
|
||||
### 解决方案
|
||||
✅ **已修复** - `graph-routes.ts` 已更新为使用正确的列名:
|
||||
- 使用 `f.full_path` 代替 `f.path`
|
||||
- 移除对 `s.token_count` 和 `s.symbol_type` 的引用
|
||||
|
||||
---
|
||||
|
||||
## 问题 2: 图谱显示为空(无节点/边)
|
||||
|
||||
### 症状
|
||||
- 前端 Graph Explorer 视图加载成功,但图谱为空
|
||||
- 控制台显示 `nodes: []` 和 `edges: []`
|
||||
|
||||
### 诊断步骤
|
||||
|
||||
#### 1. 检查数据库是否存在
|
||||
|
||||
```bash
|
||||
# Windows (Git Bash)
|
||||
ls ~/.codexlens/indexes/
|
||||
|
||||
# 应该看到您的项目路径,例如:
|
||||
# D/Claude_dms3/
|
||||
```
|
||||
|
||||
#### 2. 检查数据库内容
|
||||
|
||||
```bash
|
||||
# 进入项目索引数据库
|
||||
cd ~/.codexlens/indexes/D/Claude_dms3/ # 替换为您的项目路径
|
||||
|
||||
# 检查符号数量
|
||||
sqlite3 _index.db "SELECT COUNT(*) FROM symbols;"
|
||||
|
||||
# 检查文件数量
|
||||
sqlite3 _index.db "SELECT COUNT(*) FROM files;"
|
||||
|
||||
# 检查关系数量(重要!)
|
||||
sqlite3 _index.db "SELECT COUNT(*) FROM code_relationships;"
|
||||
```
|
||||
|
||||
#### 3. 判断问题类型
|
||||
|
||||
**情况 A:所有计数都是 0**
|
||||
- 问题:项目未索引
|
||||
- 解决方案:运行 `codex init <project-path>`
|
||||
|
||||
**情况 B:symbols > 0, files > 0, code_relationships = 0**
|
||||
- 问题:**旧索引缺少关系数据**(本次遇到的情况)
|
||||
- 解决方案:重新索引以提取关系
|
||||
|
||||
**情况 C:所有计数都 > 0**
|
||||
- 问题:前端或 API 路由错误
|
||||
- 解决方案:检查浏览器控制台错误
|
||||
|
||||
### 解决方案:重新索引提取代码关系
|
||||
|
||||
#### 方案 1: 使用 CodexLens CLI(推荐)
|
||||
|
||||
```bash
|
||||
# 1. 清除旧索引(可选但推荐)
|
||||
rm -rf ~/.codexlens/indexes/D/Claude_dms3/_index.db
|
||||
|
||||
# 2. 重新初始化项目
|
||||
cd /d/Claude_dms3
|
||||
codex init .
|
||||
|
||||
# 3. 验证关系数据已提取
|
||||
sqlite3 ~/.codexlens/indexes/D/Claude_dms3/_index.db "SELECT COUNT(*) FROM code_relationships;"
|
||||
# 应该返回 > 0
|
||||
```
|
||||
|
||||
#### 方案 2: 使用 Python 脚本手动提取
|
||||
|
||||
创建临时脚本 `extract_relationships.py`:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
临时脚本:为已索引项目提取代码关系
|
||||
适用于 migration 003 之前创建的索引
|
||||
"""
|
||||
from pathlib import Path
|
||||
from codexlens.storage.dir_index import DirIndexStore
|
||||
from codexlens.semantic.graph_analyzer import GraphAnalyzer
|
||||
|
||||
def extract_relationships_for_project(project_path: str):
|
||||
"""为已索引项目提取并添加代码关系"""
|
||||
project = Path(project_path).resolve()
|
||||
|
||||
# 打开索引数据库
|
||||
store = DirIndexStore(project)
|
||||
store.initialize()
|
||||
|
||||
print(f"Processing project: {project}")
|
||||
|
||||
# 获取所有已索引文件
|
||||
with store._get_connection() as conn:
|
||||
cursor = conn.execute("""
|
||||
SELECT f.id, f.full_path, f.language, f.content
|
||||
FROM files f
|
||||
WHERE f.language IN ('python', 'javascript', 'typescript')
|
||||
AND f.content IS NOT NULL
|
||||
""")
|
||||
files = cursor.fetchall()
|
||||
|
||||
total = len(files)
|
||||
processed = 0
|
||||
relationships_added = 0
|
||||
|
||||
for file_id, file_path, language, content in files:
|
||||
processed += 1
|
||||
print(f"[{processed}/{total}] Processing {file_path}...")
|
||||
|
||||
try:
|
||||
# 创建图分析器
|
||||
analyzer = GraphAnalyzer(language)
|
||||
|
||||
if not analyzer.is_available():
|
||||
print(f" ⚠ GraphAnalyzer not available for {language}")
|
||||
continue
|
||||
|
||||
# 获取符号
|
||||
with store._get_connection() as conn:
|
||||
cursor = conn.execute("""
|
||||
SELECT name, kind, start_line, end_line
|
||||
FROM symbols
|
||||
WHERE file_id = ?
|
||||
ORDER BY start_line
|
||||
""", (file_id,))
|
||||
symbol_rows = cursor.fetchall()
|
||||
|
||||
# 构造 Symbol 对象
|
||||
from codexlens.entities import Symbol
|
||||
symbols = [
|
||||
Symbol(
|
||||
name=row[0],
|
||||
kind=row[1],
|
||||
start_line=row[2],
|
||||
end_line=row[3],
|
||||
file_path=file_path
|
||||
)
|
||||
for row in symbol_rows
|
||||
]
|
||||
|
||||
# 提取关系
|
||||
relationships = analyzer.analyze_with_symbols(
|
||||
content,
|
||||
Path(file_path),
|
||||
symbols
|
||||
)
|
||||
|
||||
if relationships:
|
||||
store.add_relationships(file_path, relationships)
|
||||
relationships_added += len(relationships)
|
||||
print(f" ✓ Added {len(relationships)} relationships")
|
||||
else:
|
||||
print(f" - No relationships found")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Error: {e}")
|
||||
continue
|
||||
|
||||
store.close()
|
||||
|
||||
print(f"\n✅ Complete!")
|
||||
print(f" Files processed: {processed}")
|
||||
print(f" Relationships added: {relationships_added}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python extract_relationships.py <project-path>")
|
||||
sys.exit(1)
|
||||
|
||||
extract_relationships_for_project(sys.argv[1])
|
||||
```
|
||||
|
||||
运行脚本:
|
||||
|
||||
```bash
|
||||
cd /d/Claude_dms3/codex-lens
|
||||
python extract_relationships.py D:/Claude_dms3
|
||||
```
|
||||
|
||||
#### 验证修复
|
||||
|
||||
```bash
|
||||
# 1. 检查关系数量
|
||||
sqlite3 ~/.codexlens/indexes/D/Claude_dms3/_index.db "SELECT COUNT(*) FROM code_relationships;"
|
||||
# 应该 > 0
|
||||
|
||||
# 2. 检查示例关系
|
||||
sqlite3 ~/.codexlens/indexes/D/Claude_dms3/_index.db "
|
||||
SELECT
|
||||
s.name as source,
|
||||
r.relationship_type,
|
||||
r.target_qualified_name
|
||||
FROM code_relationships r
|
||||
JOIN symbols s ON r.source_symbol_id = s.id
|
||||
LIMIT 5;
|
||||
"
|
||||
|
||||
# 3. 重启 CCW Dashboard
|
||||
ccw view
|
||||
|
||||
# 4. 打开 Graph Explorer,应该能看到节点和边
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 问题 3: Graph Explorer 不显示(404 或空白)
|
||||
|
||||
### 症状
|
||||
- 左侧边栏的 Graph 图标不响应点击
|
||||
- 或点击后显示空白页面
|
||||
|
||||
### 诊断
|
||||
|
||||
1. **检查路由是否注册**:
|
||||
```bash
|
||||
cd /d/Claude_dms3/ccw
|
||||
rg "handleGraphRoutes" src/
|
||||
```
|
||||
|
||||
2. **检查前端是否包含 graph-explorer 视图**:
|
||||
```bash
|
||||
ls src/templates/dashboard-js/views/graph-explorer.js
|
||||
```
|
||||
|
||||
3. **检查 dashboard-generator.ts 是否包含 graph explorer**:
|
||||
```bash
|
||||
rg "graph-explorer" src/core/dashboard-generator.ts
|
||||
```
|
||||
|
||||
### 解决方案
|
||||
|
||||
确保以下文件存在且正确:
|
||||
- `src/core/routes/graph-routes.ts` - API 路由处理
|
||||
- `src/templates/dashboard-js/views/graph-explorer.js` - 前端视图
|
||||
- `src/templates/dashboard-css/14-graph-explorer.css` - 样式
|
||||
- `src/templates/dashboard.html` - 包含 Graph 导航项(line 334)
|
||||
|
||||
---
|
||||
|
||||
## 问题 4: 关系提取失败(调试模式)
|
||||
|
||||
### 启用调试日志
|
||||
|
||||
```bash
|
||||
# 设置日志级别为 DEBUG
|
||||
export CODEXLENS_LOG_LEVEL=DEBUG
|
||||
|
||||
# 重新索引
|
||||
codex init /d/Claude_dms3
|
||||
|
||||
# 检查日志中的关系提取信息
|
||||
# 应该看到:
|
||||
# DEBUG: Extracting relationships from <file>
|
||||
# DEBUG: Found N relationships
|
||||
```
|
||||
|
||||
### 常见失败原因
|
||||
|
||||
1. **TreeSitter 解析器缺失**:
|
||||
```bash
|
||||
python -c "from codexlens.semantic.graph_analyzer import GraphAnalyzer; print(GraphAnalyzer('python').is_available())"
|
||||
# 应该返回: True
|
||||
```
|
||||
|
||||
2. **文件语言未识别**:
|
||||
```sql
|
||||
sqlite3 _index.db "SELECT DISTINCT language FROM files;"
|
||||
# 应该看到: python, javascript, typescript
|
||||
```
|
||||
|
||||
3. **源代码无法解析**:
|
||||
- 语法错误的文件会被静默跳过
|
||||
- 检查 DEBUG 日志中的解析错误
|
||||
|
||||
---
|
||||
|
||||
## 快速诊断命令汇总
|
||||
|
||||
```bash
|
||||
# 1. 检查数据库 schema 版本
|
||||
sqlite3 ~/.codexlens/indexes/D/Claude_dms3/_index.db "PRAGMA user_version;"
|
||||
# 应该 >= 5
|
||||
|
||||
# 2. 检查表结构
|
||||
sqlite3 ~/.codexlens/indexes/D/Claude_dms3/_index.db "PRAGMA table_info(files);"
|
||||
# 应该看到: full_path(不是 path)
|
||||
|
||||
sqlite3 ~/.codexlens/indexes/D/Claude_dms3/_index.db "PRAGMA table_info(symbols);"
|
||||
# 不应该看到: token_count, symbol_type
|
||||
|
||||
# 3. 检查数据统计
|
||||
sqlite3 ~/.codexlens/indexes/D/Claude_dms3/_index.db "
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM files) as files,
|
||||
(SELECT COUNT(*) FROM symbols) as symbols,
|
||||
(SELECT COUNT(*) FROM code_relationships) as relationships;
|
||||
"
|
||||
|
||||
# 4. 测试 API 端点
|
||||
curl "http://localhost:3000/api/graph/nodes" | jq '.nodes | length'
|
||||
curl "http://localhost:3000/api/graph/edges" | jq '.edges | length'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [Graph Explorer 修复说明](./GRAPH_EXPLORER_FIX.md)
|
||||
- [Migration 005 总结](../../codex-lens/docs/MIGRATION_005_SUMMARY.md)
|
||||
- [Graph Routes API](../src/core/routes/graph-routes.md)
|
||||
273
ccw/docs/QUICK_TEST_CODEX_MCP.md
Normal file
273
ccw/docs/QUICK_TEST_CODEX_MCP.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# Codex MCP 快速测试指南
|
||||
|
||||
## 🎯 快速测试步骤
|
||||
|
||||
### 测试 1: CCW Tools 样式检查(1分钟)
|
||||
|
||||
1. 打开 Dashboard → MCP 管理
|
||||
2. 确保在 **Claude 模式**
|
||||
3. 查看 CCW Tools MCP 卡片
|
||||
4. ✅ **验证点**:
|
||||
- 卡片有橙色边框(不是蓝色)
|
||||
- 左上角图标是橙色背景(不是蓝色)
|
||||
- "Available"徽章是橙色(不是蓝色)
|
||||
- "Core only"/"All"按钮是橙色文字
|
||||
|
||||
**预期效果**:
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 🔧 CCW Tools MCP │ ← 橙色边框
|
||||
│ [橙色图标] Available (橙色徽章) │
|
||||
│ │
|
||||
│ [✓] Write/create files │
|
||||
│ [✓] Edit/replace content │
|
||||
│ ... │
|
||||
│ │
|
||||
│ [橙色按钮] Core only [橙色按钮] All │
|
||||
│ │
|
||||
│ [橙色安装按钮] Install to Workspace │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 测试 2: Codex MCP 安装 + Toast 反馈(2分钟)
|
||||
|
||||
#### 步骤
|
||||
|
||||
1. **切换到 Codex 模式**
|
||||
- 点击页面顶部的 "Codex" 按钮
|
||||
- 确认右侧显示 `~/.codex/config.toml`
|
||||
|
||||
2. **选择并安装 CCW Tools**
|
||||
- 在 CCW Tools 卡片中勾选所有核心工具
|
||||
- 点击橙色"Install"按钮
|
||||
|
||||
3. **观察 Toast 消息**
|
||||
- **关键点**: 盯住屏幕底部中央
|
||||
- 应该看到绿色的成功消息
|
||||
- 消息内容: `"CCW Tools installed to Codex (4 tools)"` 或中文版本
|
||||
- 消息停留 **3.5秒**(不是2秒)
|
||||
|
||||
4. **验证安装结果**
|
||||
```bash
|
||||
# 查看 Codex 配置文件
|
||||
cat ~/.codex/config.toml
|
||||
|
||||
# 应该看到类似以下内容:
|
||||
# [mcp_servers.ccw-tools]
|
||||
# command = "npx"
|
||||
# args = ["-y", "ccw-mcp"]
|
||||
# env = { CCW_ENABLED_TOOLS = "write_file,edit_file,codex_lens,smart_search" }
|
||||
```
|
||||
|
||||
#### ✅ 成功标准
|
||||
|
||||
| 项目 | 预期 | 通过? |
|
||||
|------|------|-------|
|
||||
| Toast 显示 | ✅ | ⬜ |
|
||||
| Toast 内容正确 | ✅ | ⬜ |
|
||||
| Toast 停留 3.5秒 | ✅ | ⬜ |
|
||||
| config.toml 创建 | ✅ | ⬜ |
|
||||
| 卡片状态更新 | ✅ | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
### 测试 3: 从 Claude 复制到 Codex(3分钟)
|
||||
|
||||
#### 前置步骤:创建测试服务器
|
||||
|
||||
1. **切换到 Claude 模式**
|
||||
2. **创建全局 MCP 服务器**:
|
||||
- 点击"全局可用 MCP"区域的"+ New Global Server"
|
||||
- 填写信息:
|
||||
- 名称: `test-filesystem`
|
||||
- 命令: `npx`
|
||||
- 参数(每行一个):
|
||||
```
|
||||
-y
|
||||
@modelcontextprotocol/server-filesystem
|
||||
/tmp
|
||||
```
|
||||
- 点击"Create"
|
||||
|
||||
3. **验证创建成功**: 服务器应该出现在"全局可用 MCP"列表中
|
||||
|
||||
#### 测试步骤
|
||||
|
||||
1. **切换到 Codex 模式**
|
||||
2. **找到复制区域**: 向下滚动到"Copy Claude Servers to Codex"
|
||||
3. **找到测试服务器**: 应该看到 `test-filesystem` 卡片
|
||||
4. **点击复制按钮**: 橙色的"→ Codex"按钮
|
||||
5. **观察反馈**:
|
||||
- Toast 消息: `"Codex MCP server 'test-filesystem' added"`
|
||||
- 停留时间: 3.5秒
|
||||
- 卡片出现"Already added"绿色徽章
|
||||
6. **验证结果**:
|
||||
```bash
|
||||
cat ~/.codex/config.toml
|
||||
|
||||
# 应该看到:
|
||||
# [mcp_servers.test-filesystem]
|
||||
# command = "npx"
|
||||
# args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
```
|
||||
|
||||
#### ✅ 成功标准
|
||||
|
||||
| 项目 | 预期 | 通过? |
|
||||
|------|------|-------|
|
||||
| Toast 显示(包含服务器名称) | ✅ | ⬜ |
|
||||
| Toast 停留 3.5秒 | ✅ | ⬜ |
|
||||
| config.toml 正确添加 | ✅ | ⬜ |
|
||||
| "Already added"徽章显示 | ✅ | ⬜ |
|
||||
| 服务器出现在 Codex 区域 | ✅ | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 调试清单
|
||||
|
||||
### Toast 消息不显示?
|
||||
|
||||
**检查点**:
|
||||
1. 打开浏览器开发者工具 (F12)
|
||||
2. 切换到 **Console** 标签
|
||||
3. 执行安装操作
|
||||
4. 查看是否有错误(红色文字)
|
||||
|
||||
**常见错误**:
|
||||
```javascript
|
||||
// 如果看到这个错误,说明 API 调用失败
|
||||
Failed to add Codex MCP server: ...
|
||||
|
||||
// 如果看到这个,说明 Toast 函数未定义
|
||||
showRefreshToast is not defined
|
||||
```
|
||||
|
||||
### 配置文件未创建?
|
||||
|
||||
**检查步骤**:
|
||||
```bash
|
||||
# 1. 检查目录是否存在
|
||||
ls -la ~/.codex/
|
||||
|
||||
# 2. 如果不存在,手动创建
|
||||
mkdir -p ~/.codex
|
||||
|
||||
# 3. 检查权限
|
||||
ls -la ~/.codex/
|
||||
# 应该看到: drwxr-xr-x (可读写)
|
||||
|
||||
# 4. 重试安装操作
|
||||
```
|
||||
|
||||
### 样式不对?
|
||||
|
||||
**可能原因**:
|
||||
- 浏览器缓存了旧的 CSS
|
||||
- 需要硬刷新
|
||||
|
||||
**解决方法**:
|
||||
```
|
||||
按 Ctrl + Shift + R (Windows/Linux)
|
||||
或 Cmd + Shift + R (Mac)
|
||||
强制刷新页面
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 测试报告模板
|
||||
|
||||
**测试时间**: ___________
|
||||
**浏览器**: Chrome / Firefox / Safari / Edge
|
||||
**操作系统**: Windows / macOS / Linux
|
||||
|
||||
### 测试结果
|
||||
|
||||
| 测试项 | 通过 | 失败 | 备注 |
|
||||
|--------|------|------|------|
|
||||
| CCW Tools 橙色样式 | ⬜ | ⬜ | |
|
||||
| Codex MCP 安装 | ⬜ | ⬜ | |
|
||||
| Toast 消息显示 | ⬜ | ⬜ | |
|
||||
| Toast 停留 3.5秒 | ⬜ | ⬜ | |
|
||||
| Claude → Codex 复制 | ⬜ | ⬜ | |
|
||||
| config.toml 正确性 | ⬜ | ⬜ | |
|
||||
|
||||
### 发现的问题
|
||||
|
||||
_请在这里描述任何问题_
|
||||
|
||||
### 截图
|
||||
|
||||
_如果有问题,请附上截图_
|
||||
|
||||
---
|
||||
|
||||
## 🎬 视频演示脚本
|
||||
|
||||
如果需要录制演示视频,按照以下脚本操作:
|
||||
|
||||
### 第1段:样式检查(15秒)
|
||||
|
||||
```
|
||||
1. 打开 MCP 管理页面
|
||||
2. 指向 CCW Tools 卡片
|
||||
3. 圈出橙色边框
|
||||
4. 圈出橙色图标
|
||||
5. 圈出橙色按钮
|
||||
```
|
||||
|
||||
### 第2段:Codex 安装演示(30秒)
|
||||
|
||||
```
|
||||
1. 切换到 Codex 模式
|
||||
2. 勾选核心工具
|
||||
3. 点击 Install 按钮
|
||||
4. 暂停并放大 Toast 消息(绿色成功消息)
|
||||
5. 数秒数:1、2、3、3.5秒后消失
|
||||
6. 显示 config.toml 文件内容
|
||||
```
|
||||
|
||||
### 第3段:Claude → Codex 复制演示(45秒)
|
||||
|
||||
```
|
||||
1. 切换到 Claude 模式
|
||||
2. 创建测试服务器
|
||||
3. 切换到 Codex 模式
|
||||
4. 找到复制区域
|
||||
5. 点击"→ Codex"按钮
|
||||
6. 暂停并放大 Toast 消息(包含服务器名称)
|
||||
7. 显示卡片状态变化("Already added"徽章)
|
||||
8. 显示 config.toml 更新后的内容
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 完整测试检查清单
|
||||
|
||||
打印此清单并在测试时勾选:
|
||||
|
||||
```
|
||||
□ 启动 CCW Dashboard
|
||||
□ 导航到 MCP 管理页面
|
||||
□ 【Claude模式】CCW Tools 卡片样式正确(橙色)
|
||||
□ 【Claude模式】创建全局 MCP 测试服务器
|
||||
□ 【Codex模式】CCW Tools 卡片样式正确(橙色)
|
||||
□ 【Codex模式】安装 CCW Tools
|
||||
□ 【Codex模式】Toast 消息显示 3.5秒
|
||||
□ 【Codex模式】config.toml 创建成功
|
||||
□ 【Codex模式】从 Claude 复制测试服务器
|
||||
□ 【Codex模式】Toast 消息包含服务器名称
|
||||
□ 【Codex模式】卡片显示"Already added"
|
||||
□ 【Codex模式】config.toml 包含新服务器
|
||||
□ 清理测试数据(删除测试服务器)
|
||||
□ 填写测试报告
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎉 成功!
|
||||
|
||||
如果所有测试通过,恭喜!功能工作正常。
|
||||
|
||||
如果有任何问题,请参考 `CODEX_MCP_TESTING_GUIDE.md` 的详细故障排查部分。
|
||||
280
ccw/docs/mcp-manager-guide.md
Normal file
280
ccw/docs/mcp-manager-guide.md
Normal file
@@ -0,0 +1,280 @@
|
||||
# MCP Manager - 使用指南
|
||||
|
||||
## 概述
|
||||
|
||||
全新的 MCP 管理器提供了统一的界面来管理 MCP 服务器,支持多种安装方式和配置管理。
|
||||
|
||||
## 主要特性
|
||||
|
||||
### 1. 统一的 MCP 编辑弹窗
|
||||
- **三种模式**:
|
||||
- 创建模式(Create):创建新的 MCP 服务器
|
||||
- 编辑模式(Edit):编辑现有 MCP 服务器
|
||||
- 查看模式(View):只读查看 MCP 服务器详情
|
||||
|
||||
- **两种服务器类型**:
|
||||
- STDIO (Command-based):通过命令行启动的 MCP 服务器
|
||||
- HTTP (URL-based):通过 HTTP/HTTPS 访问的 MCP 服务器
|
||||
|
||||
### 2. 多种安装目标
|
||||
|
||||
支持安装到以下位置:
|
||||
|
||||
| 目标 | 配置文件 | 说明 |
|
||||
|------|---------|------|
|
||||
| **Claude** | `.mcp.json` | 项目级配置,推荐用于 Claude CLI |
|
||||
| **Codex** | `~/.codex/config.toml` | Codex 全局配置 |
|
||||
| **Project** | `.mcp.json` | 项目级配置(与 Claude 相同) |
|
||||
| **Global** | `~/.claude.json` | 全局配置,所有项目可用 |
|
||||
|
||||
### 3. MCP 模板系统
|
||||
|
||||
- **保存模板**:从现有 MCP 服务器创建可复用模板
|
||||
- **浏览模板**:按分类查看所有已保存的模板
|
||||
- **一键安装**:从模板快速安装 MCP 服务器到任意目标
|
||||
|
||||
### 4. 统一的服务器管理
|
||||
|
||||
- **查看所有服务器**:
|
||||
- Project(项目级)
|
||||
- Global(全局级)
|
||||
- Codex(Codex 全局)
|
||||
- Enterprise(企业级,只读)
|
||||
|
||||
- **操作**:
|
||||
- 启用/禁用
|
||||
- 查看详情
|
||||
- 编辑配置
|
||||
- 删除服务器
|
||||
- 保存为模板
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 创建新的 MCP 服务器
|
||||
|
||||
1. 点击 **"Create New"** 按钮
|
||||
2. 填写服务器信息:
|
||||
- **名称**:唯一标识符(必填)
|
||||
- **描述**:简要说明(可选)
|
||||
- **分类**:从预定义分类中选择
|
||||
|
||||
3. 选择服务器类型:
|
||||
- **STDIO**:填写 `command`、`args`、`env`、`cwd`
|
||||
- **HTTP**:填写 `url`、HTTP 头
|
||||
|
||||
4. (可选)勾选 **"Save as Template"** 保存为模板
|
||||
|
||||
5. 选择安装目标(Claude/Codex/Project/Global)
|
||||
|
||||
6. 点击 **"Install"** 完成安装
|
||||
|
||||
### STDIO 服务器示例
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/directory"],
|
||||
"env": {
|
||||
"DEBUG": "true"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### HTTP 服务器示例
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://api.example.com/mcp",
|
||||
"http_headers": {
|
||||
"Authorization": "Bearer YOUR_TOKEN",
|
||||
"X-API-Key": "YOUR_KEY"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 从模板安装
|
||||
|
||||
1. 点击 **"Templates"** 按钮
|
||||
2. 浏览分类中的模板
|
||||
3. 点击模板卡片上的 **"Install"** 按钮
|
||||
4. 在弹窗中修改配置(如需要)
|
||||
5. 选择安装目标
|
||||
6. 点击 **"Install"**
|
||||
|
||||
### 编辑现有服务器
|
||||
|
||||
1. 在服务器列表中找到目标服务器
|
||||
2. 点击 **编辑图标**(✏️)
|
||||
3. 修改配置
|
||||
4. 点击 **"Update"** 保存更改
|
||||
|
||||
### 管理服务器
|
||||
|
||||
- **启用/禁用**:点击开关图标(🔄)
|
||||
- **查看详情**:点击眼睛图标(👁️)
|
||||
- **保存为模板**:点击书签图标(🔖)
|
||||
- **删除**:点击垃圾桶图标(🗑️)
|
||||
|
||||
## CLI 模式切换
|
||||
|
||||
支持两种 CLI 模式:
|
||||
|
||||
- **Claude 模式**:管理 `~/.claude.json` 和 `.mcp.json` 中的服务器
|
||||
- **Codex 模式**:管理 `~/.codex/config.toml` 中的服务器
|
||||
|
||||
在界面顶部切换 CLI 模式以查看和管理相应的服务器。
|
||||
|
||||
## 统计信息
|
||||
|
||||
仪表板顶部显示以下统计:
|
||||
|
||||
- **Total Servers**:总服务器数量
|
||||
- **Enabled**:已启用的服务器数量
|
||||
- **Claude**:Claude 相关服务器数量(Project + Global)
|
||||
- **Codex**:Codex 服务器数量
|
||||
|
||||
## 服务器分类
|
||||
|
||||
预定义分类:
|
||||
- Development Tools
|
||||
- Data & APIs
|
||||
- Files & Storage
|
||||
- AI & ML
|
||||
- DevOps
|
||||
- Custom
|
||||
|
||||
## API 支持
|
||||
|
||||
后端 API 已完整实现,支持:
|
||||
|
||||
### Claude MCP API
|
||||
- `POST /api/mcp-copy-server` - 安装到项目/全局
|
||||
- `POST /api/mcp-remove-server` - 从项目删除
|
||||
- `POST /api/mcp-add-global-server` - 添加全局服务器
|
||||
- `POST /api/mcp-remove-global-server` - 删除全局服务器
|
||||
- `POST /api/mcp-toggle` - 启用/禁用服务器
|
||||
|
||||
### Codex MCP API
|
||||
- `POST /api/codex-mcp-add` - 添加 Codex 服务器
|
||||
- `POST /api/codex-mcp-remove` - 删除 Codex 服务器
|
||||
- `POST /api/codex-mcp-toggle` - 启用/禁用 Codex 服务器
|
||||
- `GET /api/codex-mcp-config` - 获取 Codex 配置
|
||||
|
||||
### 模板 API
|
||||
- `GET /api/mcp-templates` - 获取所有模板
|
||||
- `POST /api/mcp-templates` - 保存模板
|
||||
- `DELETE /api/mcp-templates/:name` - 删除模板
|
||||
- `GET /api/mcp-templates/search?q=keyword` - 搜索模板
|
||||
- `GET /api/mcp-templates/categories` - 获取所有分类
|
||||
|
||||
## 配置文件格式
|
||||
|
||||
### .mcp.json (项目级)
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"server-name": {
|
||||
"command": "node",
|
||||
"args": ["server.js"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### .claude.json (全局级)
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"server-name": {
|
||||
"command": "node",
|
||||
"args": ["server.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### config.toml (Codex)
|
||||
```toml
|
||||
[mcp_servers.server-name]
|
||||
command = "node"
|
||||
args = ["server.js"]
|
||||
enabled = true
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **服务器无法启用**
|
||||
- 检查命令是否正确
|
||||
- 确认依赖是否已安装
|
||||
- 查看环境变量是否正确
|
||||
|
||||
2. **无法保存到 Codex**
|
||||
- 确认 `~/.codex` 目录存在
|
||||
- 检查文件权限
|
||||
|
||||
3. **模板无法加载**
|
||||
- 刷新页面重试
|
||||
- 检查浏览器控制台错误信息
|
||||
|
||||
### 调试技巧
|
||||
|
||||
- 打开浏览器开发者工具查看网络请求
|
||||
- 检查控制台日志
|
||||
- 查看配置文件是否正确生成
|
||||
|
||||
## 兼容性
|
||||
|
||||
- **支持的配置格式**:
|
||||
- `.mcp.json` (JSON)
|
||||
- `.claude.json` (JSON)
|
||||
- `config.toml` (TOML for Codex)
|
||||
|
||||
- **浏览器支持**:
|
||||
- Chrome/Edge (推荐)
|
||||
- Firefox
|
||||
- Safari
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用 .mcp.json 优先**:
|
||||
- 便于版本控制
|
||||
- 项目独立配置
|
||||
- Claude 和 Codex 都能识别
|
||||
|
||||
2. **分类管理模板**:
|
||||
- 为模板选择合适的分类
|
||||
- 添加清晰的描述
|
||||
- 避免重复的模板名称
|
||||
|
||||
3. **环境变量安全**:
|
||||
- 敏感信息使用环境变量
|
||||
- 不要在配置文件中硬编码 token
|
||||
- 使用 `.env` 文件管理密钥
|
||||
|
||||
4. **服务器命名规范**:
|
||||
- 使用小写字母和连字符
|
||||
- 避免特殊字符
|
||||
- 名称具有描述性
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v2.0 (当前版本)
|
||||
- ✅ 全新的统一编辑弹窗
|
||||
- ✅ 支持多种安装目标(Claude/Codex/Project/Global)
|
||||
- ✅ 完整的模板系统
|
||||
- ✅ STDIO 和 HTTP 服务器类型支持
|
||||
- ✅ 统一的服务器列表视图
|
||||
- ✅ 实时统计信息
|
||||
- ✅ 国际化支持(英文/中文)
|
||||
- ✅ 响应式设计
|
||||
|
||||
### 从旧版本迁移
|
||||
|
||||
旧版本的 MCP 配置会自动识别,无需手动迁移。新版本完全兼容旧配置文件。
|
||||
|
||||
## 支持
|
||||
|
||||
如有问题或建议,请联系开发团队或提交 issue。
|
||||
@@ -9,6 +9,7 @@ import { homedir } from 'os';
|
||||
import { join, resolve, dirname, relative, sep } from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
import { existsSync, mkdirSync, renameSync, rmSync, readdirSync } from 'fs';
|
||||
import { readdir } from 'fs/promises';
|
||||
|
||||
// Environment variable override for custom storage location
|
||||
// Made dynamic to support testing environments
|
||||
@@ -533,6 +534,77 @@ export function scanChildProjects(projectPath: string): ChildProjectInfo[] {
|
||||
return children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously scan for child projects in hierarchical storage structure
|
||||
* Non-blocking version using fs.promises for better performance
|
||||
* @param projectPath - Parent project path
|
||||
* @returns Promise resolving to array of child project information
|
||||
*/
|
||||
export async function scanChildProjectsAsync(projectPath: string): Promise<ChildProjectInfo[]> {
|
||||
const absolutePath = resolve(projectPath);
|
||||
const parentId = getProjectId(absolutePath);
|
||||
const parentStorageDir = join(getCCWHome(), 'projects', parentId);
|
||||
|
||||
// If parent storage doesn't exist, no children
|
||||
if (!existsSync(parentStorageDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const children: ChildProjectInfo[] = [];
|
||||
|
||||
/**
|
||||
* Recursively scan directory for project data directories (async)
|
||||
*/
|
||||
async function scanDirectoryAsync(dir: string, relativePath: string): Promise<void> {
|
||||
if (!existsSync(dir)) return;
|
||||
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
// Process directories in parallel for better performance
|
||||
const promises = entries
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(async (entry) => {
|
||||
const fullPath = join(dir, entry.name);
|
||||
const currentRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
||||
|
||||
// Check if this directory contains project data
|
||||
const dataMarkers = ['cli-history', 'memory', 'cache', 'config'];
|
||||
const hasData = dataMarkers.some(marker => existsSync(join(fullPath, marker)));
|
||||
|
||||
if (hasData) {
|
||||
// This is a child project
|
||||
const childProjectPath = join(absolutePath, currentRelPath.replace(/\//g, sep));
|
||||
const childId = getProjectId(childProjectPath);
|
||||
|
||||
children.push({
|
||||
projectPath: childProjectPath,
|
||||
relativePath: currentRelPath,
|
||||
projectId: childId,
|
||||
paths: getProjectPaths(childProjectPath)
|
||||
});
|
||||
}
|
||||
|
||||
// Continue scanning subdirectories (skip data directories)
|
||||
if (!dataMarkers.includes(entry.name)) {
|
||||
await scanDirectoryAsync(fullPath, currentRelPath);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
} catch (error) {
|
||||
// Ignore read errors
|
||||
if (process.env.DEBUG) {
|
||||
console.error(`[scanChildProjectsAsync] Failed to scan ${dir}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await scanDirectoryAsync(parentStorageDir, '');
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy storage paths (for backward compatibility detection)
|
||||
*/
|
||||
|
||||
@@ -24,7 +24,13 @@ const MODULE_CSS_FILES = [
|
||||
'07-managers.css',
|
||||
'08-review.css',
|
||||
'09-explorer.css',
|
||||
'10-cli.css'
|
||||
'10-cli.css',
|
||||
'11-memory.css',
|
||||
'11-prompt-history.css',
|
||||
'12-skills-rules.css',
|
||||
'13-claude-manager.css',
|
||||
'14-graph-explorer.css',
|
||||
'15-mcp-manager.css'
|
||||
];
|
||||
|
||||
const MODULE_FILES = [
|
||||
@@ -57,6 +63,7 @@ const MODULE_FILES = [
|
||||
'views/lite-tasks.js',
|
||||
'views/fix-session.js',
|
||||
'views/cli-manager.js',
|
||||
'views/codexlens-manager.js',
|
||||
'views/explorer.js',
|
||||
'views/mcp-manager.js',
|
||||
'views/hook-manager.js',
|
||||
|
||||
@@ -104,45 +104,45 @@ export class HistoryImporter {
|
||||
|
||||
/**
|
||||
* Initialize database schema for conversation history
|
||||
* NOTE: Schema aligned with MemoryStore for seamless importing
|
||||
*/
|
||||
private initSchema(): void {
|
||||
this.db.exec(`
|
||||
-- Conversations table
|
||||
-- Conversations table (aligned with MemoryStore schema)
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
project_path TEXT,
|
||||
source TEXT DEFAULT 'ccw',
|
||||
external_id TEXT,
|
||||
project_name TEXT,
|
||||
git_branch TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
message_count INTEGER DEFAULT 0,
|
||||
total_tokens INTEGER DEFAULT 0,
|
||||
metadata TEXT
|
||||
quality_score INTEGER,
|
||||
turn_count INTEGER DEFAULT 0,
|
||||
prompt_preview TEXT
|
||||
);
|
||||
|
||||
-- Messages table
|
||||
-- Messages table (aligned with MemoryStore schema)
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
|
||||
content_text TEXT,
|
||||
content_json TEXT,
|
||||
timestamp TEXT NOT NULL,
|
||||
model TEXT,
|
||||
input_tokens INTEGER DEFAULT 0,
|
||||
output_tokens INTEGER DEFAULT 0,
|
||||
cwd TEXT,
|
||||
git_branch TEXT,
|
||||
token_count INTEGER,
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Tool calls table
|
||||
-- Tool calls table (aligned with MemoryStore schema)
|
||||
CREATE TABLE IF NOT EXISTS tool_calls (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT NOT NULL,
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id INTEGER NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
tool_input TEXT,
|
||||
tool_result TEXT,
|
||||
timestamp TEXT NOT NULL,
|
||||
tool_args TEXT,
|
||||
tool_output TEXT,
|
||||
status TEXT,
|
||||
duration_ms INTEGER,
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
@@ -160,13 +160,11 @@ export class HistoryImporter {
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_session ON conversations(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_project ON conversations(project_path);
|
||||
-- Indexes (aligned with MemoryStore)
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_created ON conversations(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_updated ON conversations(updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_calls_message ON tool_calls(message_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_calls_name ON tool_calls(tool_name);
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -332,17 +330,17 @@ export class HistoryImporter {
|
||||
const result: ImportResult = { imported: 0, skipped: 0, errors: 0 };
|
||||
|
||||
const upsertConversation = this.db.prepare(`
|
||||
INSERT INTO conversations (id, session_id, project_path, created_at, updated_at, message_count, metadata)
|
||||
VALUES (@id, @session_id, @project_path, @created_at, @updated_at, 1, @metadata)
|
||||
INSERT INTO conversations (id, source, external_id, project_name, created_at, updated_at, turn_count, prompt_preview)
|
||||
VALUES (@id, @source, @external_id, @project_name, @created_at, @updated_at, 1, @prompt_preview)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
updated_at = @updated_at,
|
||||
message_count = message_count + 1
|
||||
turn_count = turn_count + 1,
|
||||
prompt_preview = @prompt_preview
|
||||
`);
|
||||
|
||||
const upsertMessage = this.db.prepare(`
|
||||
INSERT INTO messages (id, conversation_id, role, content, timestamp, cwd)
|
||||
VALUES (@id, @conversation_id, 'user', @content, @timestamp, @cwd)
|
||||
ON CONFLICT(id) DO NOTHING
|
||||
INSERT INTO messages (conversation_id, role, content_text, timestamp)
|
||||
VALUES (@conversation_id, 'user', @content_text, @timestamp)
|
||||
`);
|
||||
|
||||
const insertHash = this.db.prepare(`
|
||||
@@ -354,7 +352,6 @@ export class HistoryImporter {
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
const timestamp = new Date(entry.timestamp).toISOString();
|
||||
const messageId = `${entry.sessionId}-${entry.timestamp}`;
|
||||
const hash = this.generateHash(entry.sessionId, timestamp, entry.display);
|
||||
|
||||
// Check if hash exists
|
||||
@@ -364,29 +361,28 @@ export class HistoryImporter {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Insert conversation
|
||||
// Insert conversation (using MemoryStore-compatible fields)
|
||||
upsertConversation.run({
|
||||
id: entry.sessionId,
|
||||
session_id: entry.sessionId,
|
||||
project_path: entry.project,
|
||||
source: 'global_history',
|
||||
external_id: entry.sessionId,
|
||||
project_name: entry.project,
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
metadata: JSON.stringify({ source: 'global_history' })
|
||||
prompt_preview: entry.display.substring(0, 100)
|
||||
});
|
||||
|
||||
// Insert message
|
||||
upsertMessage.run({
|
||||
id: messageId,
|
||||
// Insert message (using MemoryStore-compatible fields)
|
||||
const insertResult = upsertMessage.run({
|
||||
conversation_id: entry.sessionId,
|
||||
content: entry.display,
|
||||
timestamp,
|
||||
cwd: entry.project
|
||||
content_text: entry.display,
|
||||
timestamp
|
||||
});
|
||||
|
||||
// Insert hash
|
||||
// Insert hash (using actual message ID from insert)
|
||||
insertHash.run({
|
||||
hash,
|
||||
message_id: messageId,
|
||||
message_id: String(insertResult.lastInsertRowid),
|
||||
created_at: timestamp
|
||||
});
|
||||
|
||||
@@ -413,24 +409,22 @@ export class HistoryImporter {
|
||||
const result: ImportResult = { imported: 0, skipped: 0, errors: 0 };
|
||||
|
||||
const upsertConversation = this.db.prepare(`
|
||||
INSERT INTO conversations (id, session_id, project_path, created_at, updated_at, message_count, total_tokens, metadata)
|
||||
VALUES (@id, @session_id, @project_path, @created_at, @updated_at, @message_count, @total_tokens, @metadata)
|
||||
INSERT INTO conversations (id, source, external_id, project_name, git_branch, created_at, updated_at, turn_count, prompt_preview)
|
||||
VALUES (@id, @source, @external_id, @project_name, @git_branch, @created_at, @updated_at, @turn_count, @prompt_preview)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
updated_at = @updated_at,
|
||||
message_count = @message_count,
|
||||
total_tokens = @total_tokens
|
||||
turn_count = @turn_count,
|
||||
prompt_preview = @prompt_preview
|
||||
`);
|
||||
|
||||
const upsertMessage = this.db.prepare(`
|
||||
INSERT INTO messages (id, conversation_id, parent_id, role, content, timestamp, model, input_tokens, output_tokens, cwd, git_branch)
|
||||
VALUES (@id, @conversation_id, @parent_id, @role, @content, @timestamp, @model, @input_tokens, @output_tokens, @cwd, @git_branch)
|
||||
ON CONFLICT(id) DO NOTHING
|
||||
INSERT INTO messages (conversation_id, role, content_text, content_json, timestamp, token_count)
|
||||
VALUES (@conversation_id, @role, @content_text, @content_json, @timestamp, @token_count)
|
||||
`);
|
||||
|
||||
const insertToolCall = this.db.prepare(`
|
||||
INSERT INTO tool_calls (id, message_id, tool_name, tool_input, tool_result, timestamp)
|
||||
VALUES (@id, @message_id, @tool_name, @tool_input, @tool_result, @timestamp)
|
||||
ON CONFLICT(id) DO NOTHING
|
||||
INSERT INTO tool_calls (message_id, tool_name, tool_args, tool_output, status)
|
||||
VALUES (@message_id, @tool_name, @tool_args, @tool_output, @status)
|
||||
`);
|
||||
|
||||
const insertHash = this.db.prepare(`
|
||||
@@ -439,27 +433,29 @@ export class HistoryImporter {
|
||||
`);
|
||||
|
||||
const transaction = this.db.transaction(() => {
|
||||
let totalTokens = 0;
|
||||
const firstMessage = messages[0];
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const promptPreview = firstMessage?.message
|
||||
? this.extractTextContent(firstMessage.message.content).substring(0, 100)
|
||||
: '';
|
||||
|
||||
// Insert conversation FIRST (before messages, for foreign key constraint)
|
||||
upsertConversation.run({
|
||||
id: sessionId,
|
||||
session_id: sessionId,
|
||||
project_path: metadata.cwd || null,
|
||||
source: 'session_file',
|
||||
external_id: sessionId,
|
||||
project_name: metadata.cwd || null,
|
||||
git_branch: metadata.gitBranch || null,
|
||||
created_at: firstMessage.timestamp,
|
||||
updated_at: lastMessage.timestamp,
|
||||
message_count: 0,
|
||||
total_tokens: 0,
|
||||
metadata: JSON.stringify({ ...metadata, source: 'session_file' })
|
||||
turn_count: 0,
|
||||
prompt_preview: promptPreview
|
||||
});
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.message) continue;
|
||||
|
||||
try {
|
||||
const messageId = msg.uuid || `${sessionId}-${msg.timestamp}`;
|
||||
const content = this.extractTextContent(msg.message.content);
|
||||
const hash = this.generateHash(sessionId, msg.timestamp, content);
|
||||
|
||||
@@ -470,43 +466,44 @@ export class HistoryImporter {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate tokens
|
||||
// Calculate total tokens
|
||||
const inputTokens = msg.message.usage?.input_tokens || 0;
|
||||
const outputTokens = msg.message.usage?.output_tokens || 0;
|
||||
totalTokens += inputTokens + outputTokens;
|
||||
const totalTokens = inputTokens + outputTokens;
|
||||
|
||||
// Insert message
|
||||
upsertMessage.run({
|
||||
id: messageId,
|
||||
// Store content as JSON if complex, otherwise as text
|
||||
const contentJson = typeof msg.message.content === 'object'
|
||||
? JSON.stringify(msg.message.content)
|
||||
: null;
|
||||
|
||||
// Insert message (using MemoryStore-compatible fields)
|
||||
const insertResult = upsertMessage.run({
|
||||
conversation_id: sessionId,
|
||||
parent_id: msg.parentUuid || null,
|
||||
role: msg.message.role,
|
||||
content,
|
||||
content_text: content,
|
||||
content_json: contentJson,
|
||||
timestamp: msg.timestamp,
|
||||
model: msg.message.model || null,
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
cwd: msg.cwd || metadata.cwd || null,
|
||||
git_branch: msg.gitBranch || metadata.gitBranch || null
|
||||
token_count: totalTokens
|
||||
});
|
||||
|
||||
const messageId = insertResult.lastInsertRowid as number;
|
||||
|
||||
// Extract and insert tool calls
|
||||
const toolCalls = this.extractToolCalls(msg.message.content);
|
||||
for (const tool of toolCalls) {
|
||||
insertToolCall.run({
|
||||
id: tool.id || `${messageId}-${tool.name}`,
|
||||
message_id: messageId,
|
||||
tool_name: tool.name,
|
||||
tool_input: JSON.stringify(tool.input),
|
||||
tool_result: tool.result || null,
|
||||
timestamp: msg.timestamp
|
||||
tool_args: JSON.stringify(tool.input),
|
||||
tool_output: tool.result || null,
|
||||
status: 'success'
|
||||
});
|
||||
}
|
||||
|
||||
// Insert hash
|
||||
// Insert hash (using actual message ID from insert)
|
||||
insertHash.run({
|
||||
hash,
|
||||
message_id: messageId,
|
||||
message_id: String(messageId),
|
||||
created_at: msg.timestamp
|
||||
});
|
||||
|
||||
@@ -520,13 +517,14 @@ export class HistoryImporter {
|
||||
// Update conversation with final counts
|
||||
upsertConversation.run({
|
||||
id: sessionId,
|
||||
session_id: sessionId,
|
||||
project_path: metadata.cwd || null,
|
||||
source: 'session_file',
|
||||
external_id: sessionId,
|
||||
project_name: metadata.cwd || null,
|
||||
git_branch: metadata.gitBranch || null,
|
||||
created_at: firstMessage.timestamp,
|
||||
updated_at: lastMessage.timestamp,
|
||||
message_count: result.imported,
|
||||
total_tokens: totalTokens,
|
||||
metadata: JSON.stringify({ ...metadata, source: 'session_file' })
|
||||
turn_count: result.imported,
|
||||
prompt_preview: promptPreview
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -90,6 +90,8 @@ export interface ToolCall {
|
||||
id?: number;
|
||||
message_id: number;
|
||||
tool_name: string;
|
||||
// NOTE: Naming inconsistency - using tool_args/tool_output vs tool_input/tool_result in HistoryImporter
|
||||
// Kept for backward compatibility with existing databases
|
||||
tool_args?: string;
|
||||
tool_output?: string;
|
||||
status?: string;
|
||||
@@ -114,8 +116,10 @@ export interface EntityWithAssociations extends Entity {
|
||||
export class MemoryStore {
|
||||
private db: Database.Database;
|
||||
private dbPath: string;
|
||||
private projectPath: string;
|
||||
|
||||
constructor(projectPath: string) {
|
||||
this.projectPath = projectPath;
|
||||
// Use centralized storage path
|
||||
const paths = StoragePaths.project(projectPath);
|
||||
const memoryDir = paths.memory;
|
||||
@@ -315,6 +319,22 @@ export class MemoryStore {
|
||||
`);
|
||||
console.log('[Memory Store] Migration complete: relative_path column added');
|
||||
}
|
||||
|
||||
// Add missing timestamp index for messages table (for time-based queries)
|
||||
try {
|
||||
const indexExists = this.db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='index' AND name='idx_messages_timestamp'
|
||||
`).get();
|
||||
|
||||
if (!indexExists) {
|
||||
console.log('[Memory Store] Adding missing timestamp index to messages table...');
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp DESC);`);
|
||||
console.log('[Memory Store] Migration complete: messages timestamp index added');
|
||||
}
|
||||
} catch (indexErr) {
|
||||
console.warn('[Memory Store] Messages timestamp index creation warning:', (indexErr as Error).message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Memory Store] Migration error:', (err as Error).message);
|
||||
// Don't throw - allow the store to continue working with existing schema
|
||||
@@ -597,13 +617,15 @@ export class MemoryStore {
|
||||
*/
|
||||
saveConversation(conversation: Conversation): void {
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO conversations (id, source, external_id, project_name, git_branch, created_at, updated_at, quality_score, turn_count, prompt_preview)
|
||||
VALUES (@id, @source, @external_id, @project_name, @git_branch, @created_at, @updated_at, @quality_score, @turn_count, @prompt_preview)
|
||||
INSERT INTO conversations (id, source, external_id, project_name, git_branch, created_at, updated_at, quality_score, turn_count, prompt_preview, project_root, relative_path)
|
||||
VALUES (@id, @source, @external_id, @project_name, @git_branch, @created_at, @updated_at, @quality_score, @turn_count, @prompt_preview, @project_root, @relative_path)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
updated_at = @updated_at,
|
||||
quality_score = @quality_score,
|
||||
turn_count = @turn_count,
|
||||
prompt_preview = @prompt_preview
|
||||
prompt_preview = @prompt_preview,
|
||||
project_root = @project_root,
|
||||
relative_path = @relative_path
|
||||
`);
|
||||
|
||||
stmt.run({
|
||||
@@ -616,7 +638,9 @@ export class MemoryStore {
|
||||
updated_at: conversation.updated_at,
|
||||
quality_score: conversation.quality_score || null,
|
||||
turn_count: conversation.turn_count,
|
||||
prompt_preview: conversation.prompt_preview || null
|
||||
prompt_preview: conversation.prompt_preview || null,
|
||||
project_root: this.projectPath,
|
||||
relative_path: null // For future hierarchical tracking
|
||||
});
|
||||
}
|
||||
|
||||
@@ -737,15 +761,15 @@ export function getMemoryStore(projectPath: string): MemoryStore {
|
||||
* @param projectPath - Parent project path
|
||||
* @returns Aggregated statistics from all projects
|
||||
*/
|
||||
export function getAggregatedStats(projectPath: string): {
|
||||
export async function getAggregatedStats(projectPath: string): Promise<{
|
||||
entities: number;
|
||||
prompts: number;
|
||||
conversations: number;
|
||||
total: number;
|
||||
projects: Array<{ path: string; stats: { entities: number; prompts: number; conversations: number } }>;
|
||||
} {
|
||||
const { scanChildProjects } = require('../config/storage-paths.js');
|
||||
const childProjects = scanChildProjects(projectPath);
|
||||
}> {
|
||||
const { scanChildProjectsAsync } = await import('../config/storage-paths.js');
|
||||
const childProjects = await scanChildProjectsAsync(projectPath);
|
||||
|
||||
const projectStats: Array<{ path: string; stats: { entities: number; prompts: number; conversations: number } }> = [];
|
||||
let totalEntities = 0;
|
||||
@@ -813,12 +837,12 @@ export function getAggregatedStats(projectPath: string): {
|
||||
* @param options - Query options
|
||||
* @returns Combined entities from all projects with source information
|
||||
*/
|
||||
export function getAggregatedEntities(
|
||||
export async function getAggregatedEntities(
|
||||
projectPath: string,
|
||||
options: { type?: string; limit?: number; offset?: number } = {}
|
||||
): Array<HotEntity & { sourceProject?: string }> {
|
||||
const { scanChildProjects } = require('../config/storage-paths.js');
|
||||
const childProjects = scanChildProjects(projectPath);
|
||||
): Promise<Array<HotEntity & { sourceProject?: string }>> {
|
||||
const { scanChildProjectsAsync } = await import('../config/storage-paths.js');
|
||||
const childProjects = await scanChildProjectsAsync(projectPath);
|
||||
|
||||
const limit = options.limit || 50;
|
||||
const offset = options.offset || 0;
|
||||
@@ -892,12 +916,12 @@ export function getAggregatedEntities(
|
||||
* @param limit - Maximum number of prompts to return
|
||||
* @returns Combined prompts from all projects with source information
|
||||
*/
|
||||
export function getAggregatedPrompts(
|
||||
export async function getAggregatedPrompts(
|
||||
projectPath: string,
|
||||
limit: number = 50
|
||||
): Array<PromptHistory & { sourceProject?: string }> {
|
||||
const { scanChildProjects } = require('../config/storage-paths.js');
|
||||
const childProjects = scanChildProjects(projectPath);
|
||||
): Promise<Array<PromptHistory & { sourceProject?: string }>> {
|
||||
const { scanChildProjectsAsync } = await import('../config/storage-paths.js');
|
||||
const childProjects = await scanChildProjectsAsync(projectPath);
|
||||
|
||||
const allPrompts: Array<PromptHistory & { sourceProject?: string }> = [];
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const status = url.searchParams.get('status') || null;
|
||||
const category = url.searchParams.get('category') as 'user' | 'internal' | 'insight' | null;
|
||||
const search = url.searchParams.get('search') || null;
|
||||
const recursive = url.searchParams.get('recursive') === 'true';
|
||||
const recursive = url.searchParams.get('recursive') !== 'false';
|
||||
|
||||
getExecutionHistoryAsync(projectPath, { limit, tool, status, category, search, recursive })
|
||||
.then(history => {
|
||||
|
||||
@@ -23,6 +23,37 @@ export interface RouteContext {
|
||||
broadcastToClients: (data: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ANSI color codes from string
|
||||
* Rich library adds color codes even with --json flag
|
||||
*/
|
||||
function stripAnsiCodes(str: string): string {
|
||||
// ANSI escape code pattern: \x1b[...m or \x1b]...
|
||||
return str.replace(/\x1b\[[0-9;]*m/g, '')
|
||||
.replace(/\x1b\][0-9;]*\x07/g, '')
|
||||
.replace(/\x1b\][^\x07]*\x07/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract JSON from CLI output that may contain logging messages
|
||||
* CodexLens CLI outputs logs like "INFO ..." before the JSON
|
||||
* Also strips ANSI color codes that Rich library adds
|
||||
*/
|
||||
function extractJSON(output: string): any {
|
||||
// Strip ANSI color codes first
|
||||
const cleanOutput = stripAnsiCodes(output);
|
||||
|
||||
// Find the first { or [ character (start of JSON)
|
||||
const jsonStart = cleanOutput.search(/[{\[]/);
|
||||
if (jsonStart === -1) {
|
||||
throw new Error('No JSON found in output');
|
||||
}
|
||||
|
||||
// Extract everything from the first { or [ onwards
|
||||
const jsonString = cleanOutput.substring(jsonStart);
|
||||
return JSON.parse(jsonString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle CodexLens routes
|
||||
* @returns true if route was handled, false otherwise
|
||||
@@ -83,23 +114,45 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: CodexLens Config - GET (Get current configuration)
|
||||
// API: CodexLens Config - GET (Get current configuration with index count)
|
||||
if (pathname === '/api/codexlens/config' && req.method === 'GET') {
|
||||
try {
|
||||
const result = await executeCodexLens(['config-show', '--json']);
|
||||
if (result.success) {
|
||||
// Fetch both config and status to merge index_count
|
||||
const [configResult, statusResult] = await Promise.all([
|
||||
executeCodexLens(['config', '--json']),
|
||||
executeCodexLens(['status', '--json'])
|
||||
]);
|
||||
|
||||
let responseData = { index_dir: '~/.codexlens/indexes', index_count: 0 };
|
||||
|
||||
// Parse config (extract JSON from output that may contain log messages)
|
||||
if (configResult.success) {
|
||||
try {
|
||||
const config = JSON.parse(result.output);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(config));
|
||||
} catch {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ index_dir: '~/.codexlens/indexes', index_count: 0 }));
|
||||
const config = extractJSON(configResult.output);
|
||||
if (config.success && config.result) {
|
||||
responseData.index_dir = config.result.index_root || responseData.index_dir;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[CodexLens] Failed to parse config:', e.message);
|
||||
console.error('[CodexLens] Config output:', configResult.output.substring(0, 200));
|
||||
}
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ index_dir: '~/.codexlens/indexes', index_count: 0 }));
|
||||
}
|
||||
|
||||
// Parse status to get index_count (projects_count)
|
||||
if (statusResult.success) {
|
||||
try {
|
||||
const status = extractJSON(statusResult.output);
|
||||
if (status.success && status.result) {
|
||||
responseData.index_count = status.result.projects_count || 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[CodexLens] Failed to parse status:', e.message);
|
||||
console.error('[CodexLens] Status output:', statusResult.output.substring(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(responseData));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
@@ -168,7 +221,7 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
|
||||
const result = await executeCodexLens(['init', targetPath, '--json'], { cwd: targetPath });
|
||||
if (result.success) {
|
||||
try {
|
||||
const parsed = JSON.parse(result.output);
|
||||
const parsed = extractJSON(result.output);
|
||||
return { success: true, result: parsed };
|
||||
} catch {
|
||||
return { success: true, output: result.output };
|
||||
@@ -237,7 +290,7 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
|
||||
const result = await executeCodexLens(args, { cwd: targetPath, timeout: timeoutMs + 30000 });
|
||||
if (result.success) {
|
||||
try {
|
||||
const parsed = JSON.parse(result.output);
|
||||
const parsed = extractJSON(result.output);
|
||||
return { success: true, result: parsed };
|
||||
} catch {
|
||||
return { success: true, output: result.output };
|
||||
@@ -253,10 +306,11 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
|
||||
}
|
||||
|
||||
|
||||
// API: CodexLens Search (FTS5 text search)
|
||||
// API: CodexLens Search (FTS5 text search with mode support)
|
||||
if (pathname === '/api/codexlens/search') {
|
||||
const query = url.searchParams.get('query') || '';
|
||||
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
|
||||
const mode = url.searchParams.get('mode') || 'exact'; // exact, fuzzy, hybrid, vector
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
|
||||
if (!query) {
|
||||
@@ -266,13 +320,13 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
|
||||
}
|
||||
|
||||
try {
|
||||
const args = ['search', query, '--path', projectPath, '--limit', limit.toString(), '--json'];
|
||||
const args = ['search', query, '--path', projectPath, '--limit', limit.toString(), '--mode', mode, '--json'];
|
||||
|
||||
const result = await executeCodexLens(args, { cwd: projectPath });
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
const parsed = JSON.parse(result.output);
|
||||
const parsed = extractJSON(result.output);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, ...parsed.result }));
|
||||
} catch {
|
||||
@@ -290,10 +344,11 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: CodexLens Search Files Only (return file paths only)
|
||||
// API: CodexLens Search Files Only (return file paths only, with mode support)
|
||||
if (pathname === '/api/codexlens/search_files') {
|
||||
const query = url.searchParams.get('query') || '';
|
||||
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
|
||||
const mode = url.searchParams.get('mode') || 'exact'; // exact, fuzzy, hybrid, vector
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
|
||||
if (!query) {
|
||||
@@ -303,13 +358,13 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
|
||||
}
|
||||
|
||||
try {
|
||||
const args = ['search', query, '--path', projectPath, '--limit', limit.toString(), '--files-only', '--json'];
|
||||
const args = ['search', query, '--path', projectPath, '--limit', limit.toString(), '--mode', mode, '--files-only', '--json'];
|
||||
|
||||
const result = await executeCodexLens(args, { cwd: projectPath });
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
const parsed = JSON.parse(result.output);
|
||||
const parsed = extractJSON(result.output);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, ...parsed.result }));
|
||||
} catch {
|
||||
@@ -327,6 +382,51 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: CodexLens Symbol Search (search for symbols by name)
|
||||
if (pathname === '/api/codexlens/symbol') {
|
||||
const query = url.searchParams.get('query') || '';
|
||||
const file = url.searchParams.get('file');
|
||||
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
|
||||
if (!query && !file) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'Either query or file parameter is required' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
let args;
|
||||
if (file) {
|
||||
// Get symbols from a specific file
|
||||
args = ['symbol', '--file', file, '--json'];
|
||||
} else {
|
||||
// Search for symbols by name
|
||||
args = ['symbol', query, '--path', projectPath, '--limit', limit.toString(), '--json'];
|
||||
}
|
||||
|
||||
const result = await executeCodexLens(args, { cwd: projectPath });
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
const parsed = extractJSON(result.output);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, ...parsed.result }));
|
||||
} catch {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, symbols: [], output: result.output }));
|
||||
}
|
||||
} else {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: result.error }));
|
||||
}
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: err.message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// API: CodexLens Semantic Search Install (fastembed, ONNX-based, ~200MB)
|
||||
if (pathname === '/api/codexlens/semantic/install' && req.method === 'POST') {
|
||||
@@ -350,5 +450,117 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: CodexLens Model List (list available embedding models)
|
||||
if (pathname === '/api/codexlens/models' && req.method === 'GET') {
|
||||
try {
|
||||
const result = await executeCodexLens(['model-list', '--json']);
|
||||
if (result.success) {
|
||||
try {
|
||||
const parsed = extractJSON(result.output);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(parsed));
|
||||
} catch {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, result: { models: [] }, output: result.output }));
|
||||
}
|
||||
} else {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: result.error }));
|
||||
}
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: err.message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: CodexLens Model Download (download embedding model by profile)
|
||||
if (pathname === '/api/codexlens/models/download' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { profile } = body;
|
||||
|
||||
if (!profile) {
|
||||
return { success: false, error: 'profile is required', status: 400 };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeCodexLens(['model-download', profile, '--json'], { timeout: 600000 }); // 10 min for download
|
||||
if (result.success) {
|
||||
try {
|
||||
const parsed = extractJSON(result.output);
|
||||
return { success: true, ...parsed };
|
||||
} catch {
|
||||
return { success: true, output: result.output };
|
||||
}
|
||||
} else {
|
||||
return { success: false, error: result.error, status: 500 };
|
||||
}
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message, status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: CodexLens Model Delete (delete embedding model by profile)
|
||||
if (pathname === '/api/codexlens/models/delete' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { profile } = body;
|
||||
|
||||
if (!profile) {
|
||||
return { success: false, error: 'profile is required', status: 400 };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeCodexLens(['model-delete', profile, '--json']);
|
||||
if (result.success) {
|
||||
try {
|
||||
const parsed = extractJSON(result.output);
|
||||
return { success: true, ...parsed };
|
||||
} catch {
|
||||
return { success: true, output: result.output };
|
||||
}
|
||||
} else {
|
||||
return { success: false, error: result.error, status: 500 };
|
||||
}
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message, status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: CodexLens Model Info (get model info by profile)
|
||||
if (pathname === '/api/codexlens/models/info' && req.method === 'GET') {
|
||||
const profile = url.searchParams.get('profile');
|
||||
|
||||
if (!profile) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'profile parameter is required' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeCodexLens(['model-info', profile, '--json']);
|
||||
if (result.success) {
|
||||
try {
|
||||
const parsed = extractJSON(result.output);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(parsed));
|
||||
} catch {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'Failed to parse response' }));
|
||||
}
|
||||
} else {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: result.error }));
|
||||
}
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: err.message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -20,9 +20,7 @@ Query all symbols from the CodexLens SQLite database and return them as graph no
|
||||
"name": "functionName",
|
||||
"type": "FUNCTION",
|
||||
"file": "src/file.ts",
|
||||
"line": 10,
|
||||
"docstring": "function_type",
|
||||
"tokenCount": 45
|
||||
"line": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -98,7 +96,7 @@ Maps source code paths to CodexLens index database paths following the storage s
|
||||
### Database Schema
|
||||
Queries two main tables:
|
||||
1. **symbols** - Code symbol definitions
|
||||
- `id`, `file_id`, `name`, `kind`, `start_line`, `end_line`, `token_count`, `symbol_type`
|
||||
- `id`, `file_id`, `name`, `kind`, `start_line`, `end_line`
|
||||
2. **code_relationships** - Inter-symbol dependencies
|
||||
- `id`, `source_symbol_id`, `target_qualified_name`, `relationship_type`, `source_line`, `target_file`
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { homedir } from 'os';
|
||||
import { join, resolve, normalize } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { existsSync, readdirSync } from 'fs';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
export interface RouteContext {
|
||||
@@ -63,8 +63,6 @@ interface GraphNode {
|
||||
type: string;
|
||||
file: string;
|
||||
line: number;
|
||||
docstring?: string;
|
||||
tokenCount?: number;
|
||||
}
|
||||
|
||||
interface GraphEdge {
|
||||
@@ -108,6 +106,36 @@ function validateProjectPath(projectPath: string, initialPath: string): string |
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all _index.db files recursively in a directory
|
||||
* @param dir Directory to search
|
||||
* @returns Array of absolute paths to _index.db files
|
||||
*/
|
||||
function findAllIndexDbs(dir: string): string[] {
|
||||
const dbs: string[] = [];
|
||||
|
||||
function traverse(currentDir: string): void {
|
||||
const dbPath = join(currentDir, '_index.db');
|
||||
if (existsSync(dbPath)) {
|
||||
dbs.push(dbPath);
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = readdirSync(currentDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
traverse(join(currentDir, entry.name));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently skip directories we can't read
|
||||
}
|
||||
}
|
||||
|
||||
traverse(dir);
|
||||
return dbs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map codex-lens symbol kinds to graph node types
|
||||
*/
|
||||
@@ -138,93 +166,117 @@ function mapRelationType(relType: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Query symbols from codex-lens database
|
||||
* Query symbols from all codex-lens databases (hierarchical structure)
|
||||
*/
|
||||
async function querySymbols(projectPath: string): Promise<GraphNode[]> {
|
||||
const mapper = new PathMapper();
|
||||
const dbPath = mapper.sourceToIndexDb(projectPath);
|
||||
const rootDbPath = mapper.sourceToIndexDb(projectPath);
|
||||
const indexRoot = rootDbPath.replace(/[\\/]_index\.db$/, '');
|
||||
|
||||
if (!existsSync(dbPath)) {
|
||||
if (!existsSync(indexRoot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const db = Database(dbPath, { readonly: true });
|
||||
// Find all _index.db files recursively
|
||||
const dbPaths = findAllIndexDbs(indexRoot);
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
s.id,
|
||||
s.name,
|
||||
s.kind,
|
||||
s.start_line,
|
||||
s.token_count,
|
||||
s.symbol_type,
|
||||
f.path as file
|
||||
FROM symbols s
|
||||
JOIN files f ON s.file_id = f.id
|
||||
ORDER BY f.path, s.start_line
|
||||
`).all();
|
||||
|
||||
db.close();
|
||||
|
||||
return rows.map((row: any) => ({
|
||||
id: `${row.file}:${row.name}:${row.start_line}`,
|
||||
name: row.name,
|
||||
type: mapSymbolKind(row.kind),
|
||||
file: row.file,
|
||||
line: row.start_line,
|
||||
docstring: row.symbol_type || undefined,
|
||||
tokenCount: row.token_count || undefined,
|
||||
}));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[Graph] Failed to query symbols: ${message}`);
|
||||
if (dbPaths.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allNodes: GraphNode[] = [];
|
||||
|
||||
for (const dbPath of dbPaths) {
|
||||
try {
|
||||
const db = Database(dbPath, { readonly: true });
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
s.id,
|
||||
s.name,
|
||||
s.kind,
|
||||
s.start_line,
|
||||
f.full_path as file
|
||||
FROM symbols s
|
||||
JOIN files f ON s.file_id = f.id
|
||||
ORDER BY f.full_path, s.start_line
|
||||
`).all();
|
||||
|
||||
db.close();
|
||||
|
||||
allNodes.push(...rows.map((row: any) => ({
|
||||
id: `${row.file}:${row.name}:${row.start_line}`,
|
||||
name: row.name,
|
||||
type: mapSymbolKind(row.kind),
|
||||
file: row.file,
|
||||
line: row.start_line,
|
||||
})));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[Graph] Failed to query symbols from ${dbPath}: ${message}`);
|
||||
// Continue with other databases even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
return allNodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query code relationships from codex-lens database
|
||||
* Query code relationships from all codex-lens databases (hierarchical structure)
|
||||
*/
|
||||
async function queryRelationships(projectPath: string): Promise<GraphEdge[]> {
|
||||
const mapper = new PathMapper();
|
||||
const dbPath = mapper.sourceToIndexDb(projectPath);
|
||||
const rootDbPath = mapper.sourceToIndexDb(projectPath);
|
||||
const indexRoot = rootDbPath.replace(/[\\/]_index\.db$/, '');
|
||||
|
||||
if (!existsSync(dbPath)) {
|
||||
if (!existsSync(indexRoot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const db = Database(dbPath, { readonly: true });
|
||||
// Find all _index.db files recursively
|
||||
const dbPaths = findAllIndexDbs(indexRoot);
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
s.name as source_name,
|
||||
s.start_line as source_line,
|
||||
f.path as source_file,
|
||||
r.target_qualified_name,
|
||||
r.relationship_type,
|
||||
r.target_file
|
||||
FROM code_relationships r
|
||||
JOIN symbols s ON r.source_symbol_id = s.id
|
||||
JOIN files f ON s.file_id = f.id
|
||||
ORDER BY f.path, s.start_line
|
||||
`).all();
|
||||
|
||||
db.close();
|
||||
|
||||
return rows.map((row: any) => ({
|
||||
source: `${row.source_file}:${row.source_name}:${row.source_line}`,
|
||||
target: row.target_qualified_name,
|
||||
type: mapRelationType(row.relationship_type),
|
||||
sourceLine: row.source_line,
|
||||
sourceFile: row.source_file,
|
||||
}));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[Graph] Failed to query relationships: ${message}`);
|
||||
if (dbPaths.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allEdges: GraphEdge[] = [];
|
||||
|
||||
for (const dbPath of dbPaths) {
|
||||
try {
|
||||
const db = Database(dbPath, { readonly: true });
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
s.name as source_name,
|
||||
s.start_line as source_line,
|
||||
f.full_path as source_file,
|
||||
r.target_qualified_name,
|
||||
r.relationship_type,
|
||||
r.target_file
|
||||
FROM code_relationships r
|
||||
JOIN symbols s ON r.source_symbol_id = s.id
|
||||
JOIN files f ON s.file_id = f.id
|
||||
ORDER BY f.full_path, s.start_line
|
||||
`).all();
|
||||
|
||||
db.close();
|
||||
|
||||
allEdges.push(...rows.map((row: any) => ({
|
||||
source: `${row.source_file}:${row.source_name}:${row.source_line}`,
|
||||
target: row.target_qualified_name,
|
||||
type: mapRelationType(row.relationship_type),
|
||||
sourceLine: row.source_line,
|
||||
sourceFile: row.source_file,
|
||||
})));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[Graph] Failed to query relationships from ${dbPath}: ${message}`);
|
||||
// Continue with other databases even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
return allEdges;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -292,7 +344,7 @@ async function analyzeImpact(projectPath: string, symbolId: string): Promise<Imp
|
||||
const rows = db.prepare(`
|
||||
SELECT DISTINCT
|
||||
s.name as dependent_name,
|
||||
f.path as dependent_file,
|
||||
f.full_path as dependent_file,
|
||||
s.start_line as dependent_line
|
||||
FROM code_relationships r
|
||||
JOIN symbols s ON r.source_symbol_id = s.id
|
||||
@@ -330,6 +382,8 @@ export async function handleGraphRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
if (pathname === '/api/graph/nodes') {
|
||||
const rawPath = url.searchParams.get('path') || initialPath;
|
||||
const projectPath = validateProjectPath(rawPath, initialPath);
|
||||
const limitStr = url.searchParams.get('limit') || '1000';
|
||||
const limit = Math.min(parseInt(limitStr, 10) || 1000, 5000); // Max 5000 nodes
|
||||
|
||||
if (!projectPath) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
@@ -338,9 +392,15 @@ export async function handleGraphRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
}
|
||||
|
||||
try {
|
||||
const nodes = await querySymbols(projectPath);
|
||||
const allNodes = await querySymbols(projectPath);
|
||||
const nodes = allNodes.slice(0, limit);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ nodes }));
|
||||
res.end(JSON.stringify({
|
||||
nodes,
|
||||
total: allNodes.length,
|
||||
limit,
|
||||
hasMore: allNodes.length > limit
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(`[Graph] Error fetching nodes:`, err);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
@@ -353,6 +413,8 @@ export async function handleGraphRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
if (pathname === '/api/graph/edges') {
|
||||
const rawPath = url.searchParams.get('path') || initialPath;
|
||||
const projectPath = validateProjectPath(rawPath, initialPath);
|
||||
const limitStr = url.searchParams.get('limit') || '2000';
|
||||
const limit = Math.min(parseInt(limitStr, 10) || 2000, 10000); // Max 10000 edges
|
||||
|
||||
if (!projectPath) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
@@ -361,9 +423,15 @@ export async function handleGraphRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
}
|
||||
|
||||
try {
|
||||
const edges = await queryRelationships(projectPath);
|
||||
const allEdges = await queryRelationships(projectPath);
|
||||
const edges = allEdges.slice(0, limit);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ edges }));
|
||||
res.end(JSON.stringify({
|
||||
edges,
|
||||
total: allEdges.length,
|
||||
limit,
|
||||
hasMore: allEdges.length > limit
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(`[Graph] Error fetching edges:`, err);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-nocheck
|
||||
import http from 'http';
|
||||
import { URL } from 'url';
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, unlinkSync } from 'fs';
|
||||
@@ -222,7 +221,7 @@ export async function handleMemoryRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
||||
const search = url.searchParams.get('search') || null;
|
||||
const recursive = url.searchParams.get('recursive') === 'true';
|
||||
const recursive = url.searchParams.get('recursive') !== 'false';
|
||||
|
||||
try {
|
||||
let prompts;
|
||||
@@ -230,7 +229,7 @@ export async function handleMemoryRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
// Recursive mode: aggregate prompts from parent and child projects
|
||||
if (recursive && !search) {
|
||||
const { getAggregatedPrompts } = await import('../memory-store.js');
|
||||
prompts = getAggregatedPrompts(projectPath, limit);
|
||||
prompts = await getAggregatedPrompts(projectPath, limit);
|
||||
} else {
|
||||
// Non-recursive mode or search mode: query only current project
|
||||
const memoryStore = getMemoryStore(projectPath);
|
||||
@@ -390,11 +389,11 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p
|
||||
mode: 'analysis',
|
||||
timeout: 120000,
|
||||
cd: projectPath,
|
||||
category: 'insights'
|
||||
category: 'insight'
|
||||
});
|
||||
|
||||
// Try to parse JSON from response
|
||||
let insights = { patterns: [], suggestions: [] };
|
||||
let insights: { patterns: any[]; suggestions: any[] } = { patterns: [], suggestions: [] };
|
||||
if (result.stdout) {
|
||||
let outputText = result.stdout;
|
||||
|
||||
@@ -515,13 +514,13 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
const filter = url.searchParams.get('filter') || 'all'; // today, week, all
|
||||
const limit = parseInt(url.searchParams.get('limit') || '10', 10);
|
||||
const recursive = url.searchParams.get('recursive') === 'true';
|
||||
const recursive = url.searchParams.get('recursive') !== 'false';
|
||||
|
||||
try {
|
||||
// If requesting aggregated stats, use the aggregated function
|
||||
if (url.searchParams.has('aggregated') || recursive) {
|
||||
const { getAggregatedStats } = await import('../memory-store.js');
|
||||
const aggregatedStats = getAggregatedStats(projectPath);
|
||||
const aggregatedStats = await getAggregatedStats(projectPath);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
|
||||
57
ccw/src/core/routes/status-routes.ts
Normal file
57
ccw/src/core/routes/status-routes.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* Status Routes Module
|
||||
* Aggregated status endpoint for faster dashboard loading
|
||||
*/
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { getCliToolsStatus } from '../../tools/cli-executor.js';
|
||||
import { checkVenvStatus, checkSemanticStatus } from '../../tools/codex-lens.js';
|
||||
|
||||
export interface RouteContext {
|
||||
pathname: string;
|
||||
url: URL;
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
initialPath: string;
|
||||
handlePostRequest: (req: IncomingMessage, res: ServerResponse, handler: (body: unknown) => Promise<any>) => void;
|
||||
broadcastToClients: (data: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle status routes
|
||||
* @returns true if route was handled, false otherwise
|
||||
*/
|
||||
export async function handleStatusRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, res } = ctx;
|
||||
|
||||
// API: Aggregated Status (all statuses in one call)
|
||||
if (pathname === '/api/status/all') {
|
||||
try {
|
||||
// Execute all status checks in parallel
|
||||
const [cliStatus, codexLensStatus, semanticStatus] = await Promise.all([
|
||||
getCliToolsStatus(),
|
||||
checkVenvStatus(),
|
||||
// Always check semantic status (will return available: false if CodexLens not ready)
|
||||
checkSemanticStatus().catch(() => ({ available: false, backend: null }))
|
||||
]);
|
||||
|
||||
const response = {
|
||||
cli: cliStatus,
|
||||
codexLens: codexLensStatus,
|
||||
semantic: semanticStatus,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(response));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Status Routes] Error fetching aggregated status:', error);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (error as Error).message }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { join } from 'path';
|
||||
import { resolvePath, getRecentPaths, normalizePathForDisplay } from '../utils/path-resolver.js';
|
||||
|
||||
// Import route handlers
|
||||
import { handleStatusRoutes } from './routes/status-routes.js';
|
||||
import { handleCliRoutes } from './routes/cli-routes.js';
|
||||
import { handleMemoryRoutes } from './routes/memory-routes.js';
|
||||
import { handleMcpRoutes } from './routes/mcp-routes.js';
|
||||
@@ -243,6 +244,11 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
// Try each route handler in order
|
||||
// Order matters: more specific routes should come before general ones
|
||||
|
||||
// Status routes (/api/status/*) - Aggregated endpoint for faster loading
|
||||
if (pathname.startsWith('/api/status/')) {
|
||||
if (await handleStatusRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// CLI routes (/api/cli/*)
|
||||
if (pathname.startsWith('/api/cli/')) {
|
||||
if (await handleCliRoutes(routeContext)) return;
|
||||
|
||||
375
ccw/src/templates/dashboard-css/15-mcp-manager.css
Normal file
375
ccw/src/templates/dashboard-css/15-mcp-manager.css
Normal file
@@ -0,0 +1,375 @@
|
||||
/* ==========================================
|
||||
MCP MANAGER - ORANGE THEME ENHANCEMENTS
|
||||
========================================== */
|
||||
|
||||
/* MCP CLI Mode Toggle - Orange for Codex */
|
||||
.mcp-cli-toggle .cli-mode-btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mcp-cli-toggle .cli-mode-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, transparent 30%, rgba(255, 255, 255, 0.1) 50%, transparent 70%);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.6s;
|
||||
}
|
||||
|
||||
.mcp-cli-toggle .cli-mode-btn:hover::before {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
/* CCW Tools Card - Enhanced Orange Gradient */
|
||||
.ccw-tools-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.ccw-tools-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(249, 115, 22, 0.1) 0%, transparent 70%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.ccw-tools-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ccw-tools-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 30px rgba(249, 115, 22, 0.2);
|
||||
}
|
||||
|
||||
/* Orange-themed buttons and badges */
|
||||
.bg-orange-500 {
|
||||
background-color: #f97316;
|
||||
}
|
||||
|
||||
.text-orange-500 {
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.text-orange-600 {
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.text-orange-700 {
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.text-orange-800 {
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.bg-orange-50 {
|
||||
background-color: #fff7ed;
|
||||
}
|
||||
|
||||
.bg-orange-100 {
|
||||
background-color: #ffedd5;
|
||||
}
|
||||
|
||||
.border-orange-200 {
|
||||
border-color: #fed7aa;
|
||||
}
|
||||
|
||||
.border-orange-500\/20 {
|
||||
border-color: rgba(249, 115, 22, 0.2);
|
||||
}
|
||||
|
||||
.border-orange-500\/30 {
|
||||
border-color: rgba(249, 115, 22, 0.3);
|
||||
}
|
||||
|
||||
.border-orange-800 {
|
||||
border-color: #9a3412;
|
||||
}
|
||||
|
||||
/* Dark mode orange colors */
|
||||
.dark .bg-orange-50 {
|
||||
background-color: rgba(249, 115, 22, 0.05);
|
||||
}
|
||||
|
||||
.dark .bg-orange-100 {
|
||||
background-color: rgba(249, 115, 22, 0.1);
|
||||
}
|
||||
|
||||
.dark .bg-orange-900\/30 {
|
||||
background-color: rgba(124, 45, 18, 0.3);
|
||||
}
|
||||
|
||||
.dark .text-orange-200 {
|
||||
color: #fed7aa;
|
||||
}
|
||||
|
||||
.dark .text-orange-300 {
|
||||
color: #fdba74;
|
||||
}
|
||||
|
||||
.dark .text-orange-400 {
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
.dark .border-orange-800 {
|
||||
border-color: #9a3412;
|
||||
}
|
||||
|
||||
.dark .border-orange-950\/30 {
|
||||
background-color: rgba(67, 20, 7, 0.3);
|
||||
}
|
||||
|
||||
/* Codex MCP Server Cards - Orange Borders */
|
||||
.mcp-server-card[data-cli-type="codex"] {
|
||||
border-left: 3px solid #f97316;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.mcp-server-card[data-cli-type="codex"]:hover {
|
||||
border-left-width: 4px;
|
||||
box-shadow: 0 4px 16px rgba(249, 115, 22, 0.15);
|
||||
}
|
||||
|
||||
/* Toggle switches - Orange for Codex */
|
||||
.mcp-toggle input:checked + div.peer-checked\:bg-orange-500 {
|
||||
background: #f97316;
|
||||
}
|
||||
|
||||
/* Installation buttons - Enhanced Orange */
|
||||
.bg-orange-500:hover {
|
||||
background-color: #ea580c;
|
||||
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
|
||||
}
|
||||
|
||||
/* Info panels - Orange accent */
|
||||
.bg-orange-50.dark\:bg-orange-950\/30 {
|
||||
border-left: 3px solid #f97316;
|
||||
}
|
||||
|
||||
/* Codex section headers */
|
||||
.text-orange-500 svg {
|
||||
filter: drop-shadow(0 2px 4px rgba(249, 115, 22, 0.3));
|
||||
}
|
||||
|
||||
/* Animated pulse for available/install states */
|
||||
.border-orange-500\/30 {
|
||||
animation: orangePulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes orangePulse {
|
||||
0%, 100% {
|
||||
border-color: rgba(249, 115, 22, 0.3);
|
||||
box-shadow: 0 0 0 0 rgba(249, 115, 22, 0);
|
||||
}
|
||||
50% {
|
||||
border-color: rgba(249, 115, 22, 0.6);
|
||||
box-shadow: 0 0 0 4px rgba(249, 115, 22, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Server badges with orange accents */
|
||||
.text-xs.px-2.py-0\.5.bg-orange-100 {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Codex server list enhancements */
|
||||
.mcp-section h3.text-orange-500 {
|
||||
background: linear-gradient(90deg, #f97316 0%, #ea580c 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Install button hover effects */
|
||||
.bg-orange-500.rounded-lg {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bg-orange-500.rounded-lg::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.3s, height 0.3s;
|
||||
}
|
||||
|
||||
.bg-orange-500.rounded-lg:active::after {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
/* MCP Server Grid - Enhanced spacing for orange theme */
|
||||
.mcp-server-grid {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
/* Available servers - Dashed border with orange hints */
|
||||
.mcp-server-available {
|
||||
border-style: dashed;
|
||||
border-width: 2px;
|
||||
border-color: hsl(var(--border));
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.mcp-server-available:hover {
|
||||
border-style: solid;
|
||||
border-color: #f97316;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Status indicators with orange */
|
||||
.inline-flex.items-center.gap-1.bg-orange-500\/20 {
|
||||
animation: availablePulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes availablePulse {
|
||||
0%, 100% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Section dividers with orange accents */
|
||||
.mcp-section {
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
padding-bottom: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mcp-section::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
width: 60px;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, #f97316 0%, transparent 100%);
|
||||
}
|
||||
|
||||
/* Empty state icons with orange */
|
||||
.mcp-empty-state i {
|
||||
color: #f97316;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* Enhanced focus states for orange buttons */
|
||||
.bg-orange-500:focus-visible {
|
||||
outline: 2px solid #f97316;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Tooltip styles for orange theme */
|
||||
[title]:hover::after {
|
||||
content: attr(title);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 4px 8px;
|
||||
background: #1f2937;
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* Orange-themed success badges */
|
||||
.bg-success-light .inline-flex.items-center.gap-1 {
|
||||
background: linear-gradient(135deg, hsl(var(--success-light)) 0%, rgba(249, 115, 22, 0.1) 100%);
|
||||
}
|
||||
|
||||
/* Config file status badges */
|
||||
.inline-flex.items-center.gap-1\.5.bg-success\/10 {
|
||||
border-left: 2px solid hsl(var(--success));
|
||||
}
|
||||
|
||||
.inline-flex.items-center.gap-1\.5.bg-muted {
|
||||
border-left: 2px solid #f97316;
|
||||
}
|
||||
|
||||
/* Responsive adjustments for orange theme */
|
||||
@media (max-width: 768px) {
|
||||
.ccw-tools-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.mcp-server-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading states with orange */
|
||||
@keyframes orangeGlow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 10px rgba(249, 115, 22, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(249, 115, 22, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-orange {
|
||||
animation: orangeGlow 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Button group for install options */
|
||||
.flex.gap-2 button.bg-primary,
|
||||
.flex.gap-2 button.bg-success {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.flex.gap-2 button.bg-primary:hover,
|
||||
.flex.gap-2 button.bg-success:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Enhanced card shadows for depth */
|
||||
.mcp-server-card {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.mcp-server-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Orange accent for project server headers */
|
||||
.mcp-section .flex.items-center.gap-3 button {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mcp-section .flex.items-center.gap-3 button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.5s;
|
||||
}
|
||||
|
||||
.mcp-section .flex.items-center.gap-3 button:hover::before {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
@@ -15,6 +15,9 @@ let smartContextMaxFiles = parseInt(localStorage.getItem('ccw-smart-context-max-
|
||||
// Native Resume settings
|
||||
let nativeResumeEnabled = localStorage.getItem('ccw-native-resume') !== 'false'; // default true
|
||||
|
||||
// Recursive Query settings (for hierarchical storage aggregation)
|
||||
let recursiveQueryEnabled = localStorage.getItem('ccw-recursive-query') !== 'false'; // default true
|
||||
|
||||
// LLM Enhancement settings for Semantic Search
|
||||
let llmEnhancementSettings = {
|
||||
enabled: localStorage.getItem('ccw-llm-enhancement-enabled') === 'true',
|
||||
@@ -26,12 +29,51 @@ let llmEnhancementSettings = {
|
||||
|
||||
// ========== Initialization ==========
|
||||
function initCliStatus() {
|
||||
// Load CLI status on init
|
||||
loadCliToolStatus();
|
||||
loadCodexLensStatus();
|
||||
// Load all statuses in one call using aggregated endpoint
|
||||
loadAllStatuses();
|
||||
}
|
||||
|
||||
// ========== Data Loading ==========
|
||||
/**
|
||||
* Load all statuses using aggregated endpoint (single API call)
|
||||
*/
|
||||
async function loadAllStatuses() {
|
||||
try {
|
||||
const response = await fetch('/api/status/all');
|
||||
if (!response.ok) throw new Error('Failed to load status');
|
||||
const data = await response.json();
|
||||
|
||||
// Update all status data
|
||||
cliToolStatus = data.cli || { gemini: {}, qwen: {}, codex: {}, claude: {} };
|
||||
codexLensStatus = data.codexLens || { ready: false };
|
||||
semanticStatus = data.semantic || { available: false };
|
||||
|
||||
// Update badges
|
||||
updateCliBadge();
|
||||
updateCodexLensBadge();
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('Failed to load aggregated status:', err);
|
||||
// Fallback to individual calls if aggregated endpoint fails
|
||||
return await loadAllStatusesFallback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: Load statuses individually if aggregated endpoint fails
|
||||
*/
|
||||
async function loadAllStatusesFallback() {
|
||||
console.warn('[CLI Status] Using fallback individual API calls');
|
||||
await Promise.all([
|
||||
loadCliToolStatus(),
|
||||
loadCodexLensStatus()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy: Load CLI tool status individually
|
||||
*/
|
||||
async function loadCliToolStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/cli/status');
|
||||
@@ -49,6 +91,9 @@ async function loadCliToolStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy: Load CodexLens status individually
|
||||
*/
|
||||
async function loadCodexLensStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/codexlens/status');
|
||||
@@ -71,6 +116,9 @@ async function loadCodexLensStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy: Load semantic status individually
|
||||
*/
|
||||
async function loadSemanticStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/codexlens/semantic/status');
|
||||
@@ -223,7 +271,7 @@ function renderCliStatus() {
|
||||
<div class="flex items-center justify-between w-full mt-1">
|
||||
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<i data-lucide="hard-drive" class="w-3 h-3"></i>
|
||||
<span>~500MB</span>
|
||||
<span>~130MB</span>
|
||||
</div>
|
||||
<button class="btn-sm btn-outline flex items-center gap-1" onclick="event.stopPropagation(); openSemanticSettingsModal()">
|
||||
<i data-lucide="settings" class="w-3 h-3"></i>
|
||||
@@ -377,8 +425,14 @@ function setNativeResumeEnabled(enabled) {
|
||||
showRefreshToast(`Native Resume ${enabled ? 'enabled' : 'disabled'}`, 'success');
|
||||
}
|
||||
|
||||
function setRecursiveQueryEnabled(enabled) {
|
||||
recursiveQueryEnabled = enabled;
|
||||
localStorage.setItem('ccw-recursive-query', enabled.toString());
|
||||
showRefreshToast(`Recursive Query ${enabled ? 'enabled' : 'disabled'}`, 'success');
|
||||
}
|
||||
|
||||
async function refreshAllCliStatus() {
|
||||
await Promise.all([loadCliToolStatus(), loadCodexLensStatus()]);
|
||||
await loadAllStatuses();
|
||||
renderCliStatus();
|
||||
}
|
||||
|
||||
@@ -779,6 +833,9 @@ async function initCodexLensIndex() {
|
||||
} else {
|
||||
showRefreshToast(`Index created: ${files} files, ${dirs} directories`, 'success');
|
||||
console.log('[CodexLens] Index created successfully');
|
||||
|
||||
// Reload CodexLens status and refresh the view
|
||||
loadCodexLensStatus().then(() => renderCliStatus());
|
||||
}
|
||||
} else {
|
||||
showRefreshToast(`Init failed: ${result.error}`, 'error');
|
||||
@@ -820,19 +877,15 @@ function openSemanticInstallWizard() {
|
||||
<i data-lucide="check" class="w-4 h-4 text-success mt-0.5"></i>
|
||||
<span><strong>bge-small-en-v1.5</strong> - Embedding model (~130MB)</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i data-lucide="check" class="w-4 h-4 text-success mt-0.5"></i>
|
||||
<span><strong>PyTorch</strong> - Deep learning backend (~300MB)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bg-warning/10 border border-warning/20 rounded-lg p-3">
|
||||
<div class="bg-primary/10 border border-primary/20 rounded-lg p-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<i data-lucide="alert-triangle" class="w-4 h-4 text-warning mt-0.5"></i>
|
||||
<i data-lucide="info" class="w-4 h-4 text-primary mt-0.5"></i>
|
||||
<div class="text-sm">
|
||||
<p class="font-medium text-warning">Large Download</p>
|
||||
<p class="text-muted-foreground">Total size: ~500MB. First-time model loading may take a few minutes.</p>
|
||||
<p class="font-medium text-primary">Download Size</p>
|
||||
<p class="text-muted-foreground">Total size: ~130MB. First-time model loading may take a few minutes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -887,11 +940,10 @@ async function startSemanticInstall() {
|
||||
|
||||
// Simulate progress stages
|
||||
const stages = [
|
||||
{ progress: 10, text: 'Installing numpy...' },
|
||||
{ progress: 30, text: 'Installing sentence-transformers...' },
|
||||
{ progress: 50, text: 'Installing PyTorch dependencies...' },
|
||||
{ progress: 70, text: 'Downloading embedding model...' },
|
||||
{ progress: 90, text: 'Finalizing installation...' }
|
||||
{ progress: 20, text: 'Installing sentence-transformers...' },
|
||||
{ progress: 50, text: 'Downloading embedding model...' },
|
||||
{ progress: 80, text: 'Setting up model cache...' },
|
||||
{ progress: 95, text: 'Finalizing installation...' }
|
||||
];
|
||||
|
||||
let currentStage = 0;
|
||||
|
||||
@@ -235,6 +235,35 @@ async function loadHookConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAvailableSkills() {
|
||||
try {
|
||||
const response = await fetch('/api/skills?path=' + encodeURIComponent(projectPath));
|
||||
if (!response.ok) throw new Error('Failed to load skills');
|
||||
const data = await response.json();
|
||||
|
||||
// Combine project and user skills
|
||||
const projectSkills = (data.projectSkills || []).map(s => ({
|
||||
name: s.name,
|
||||
path: s.path,
|
||||
scope: 'project'
|
||||
}));
|
||||
const userSkills = (data.userSkills || []).map(s => ({
|
||||
name: s.name,
|
||||
path: s.path,
|
||||
scope: 'user'
|
||||
}));
|
||||
|
||||
// Store in window for access by wizard
|
||||
window.availableSkills = [...projectSkills, ...userSkills];
|
||||
|
||||
return window.availableSkills;
|
||||
} catch (err) {
|
||||
console.error('Failed to load available skills:', err);
|
||||
window.availableSkills = [];
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert internal hook format to Claude Code format
|
||||
* Internal: { command, args, matcher, timeout }
|
||||
@@ -510,7 +539,7 @@ function getHookEventIconLucide(event) {
|
||||
let currentWizardTemplate = null;
|
||||
let wizardConfig = {};
|
||||
|
||||
function openHookWizardModal(wizardId) {
|
||||
async function openHookWizardModal(wizardId) {
|
||||
const wizard = WIZARD_TEMPLATES[wizardId];
|
||||
if (!wizard) {
|
||||
showRefreshToast('Wizard template not found', 'error');
|
||||
@@ -530,6 +559,11 @@ function openHookWizardModal(wizardId) {
|
||||
wizardConfig.selectedOptions = [];
|
||||
}
|
||||
|
||||
// Ensure available skills are loaded for SKILL context wizard
|
||||
if (wizardId === 'skill-context' && typeof window.availableSkills === 'undefined') {
|
||||
await loadAvailableSkills();
|
||||
}
|
||||
|
||||
const modal = document.getElementById('hookWizardModal');
|
||||
if (modal) {
|
||||
renderWizardModalContent();
|
||||
@@ -792,9 +826,19 @@ function renderSkillContextConfig() {
|
||||
const availableSkills = window.availableSkills || [];
|
||||
|
||||
if (selectedOption === 'auto') {
|
||||
const skillBadges = availableSkills.map(function(s) {
|
||||
return '<span class="px-1.5 py-0.5 bg-emerald-500/10 text-emerald-500 rounded text-xs">' + escapeHtml(s.name) + '</span>';
|
||||
}).join(' ');
|
||||
let skillBadges = '';
|
||||
if (typeof window.availableSkills === 'undefined') {
|
||||
// Still loading
|
||||
skillBadges = '<span class="px-1.5 py-0.5 bg-muted text-muted-foreground rounded text-xs">' + t('common.loading') + '...</span>';
|
||||
} else if (availableSkills.length === 0) {
|
||||
// No skills found
|
||||
skillBadges = '<span class="px-1.5 py-0.5 bg-warning/10 text-warning rounded text-xs">' + t('hook.wizard.noSkillsFound') + '</span>';
|
||||
} else {
|
||||
// Skills found
|
||||
skillBadges = availableSkills.map(function(s) {
|
||||
return '<span class="px-1.5 py-0.5 bg-emerald-500/10 text-emerald-500 rounded text-xs">' + escapeHtml(s.name) + '</span>';
|
||||
}).join(' ');
|
||||
}
|
||||
return '<div class="bg-muted/30 rounded-lg p-4 text-sm text-muted-foreground">' +
|
||||
'<div class="flex items-center gap-2 mb-2">' +
|
||||
'<i data-lucide="info" class="w-4 h-4"></i>' +
|
||||
@@ -814,10 +858,15 @@ function renderSkillContextConfig() {
|
||||
'</div>';
|
||||
} else {
|
||||
configListHtml = skillConfigs.map(function(config, idx) {
|
||||
var skillOptions = availableSkills.map(function(s) {
|
||||
var selected = config.skill === s.id ? 'selected' : '';
|
||||
return '<option value="' + s.id + '" ' + selected + '>' + escapeHtml(s.name) + '</option>';
|
||||
}).join('');
|
||||
var skillOptions = '';
|
||||
if (availableSkills.length === 0) {
|
||||
skillOptions = '<option value="" disabled>' + t('hook.wizard.noSkillsFound') + '</option>';
|
||||
} else {
|
||||
skillOptions = availableSkills.map(function(s) {
|
||||
var selected = config.skill === s.name ? 'selected' : '';
|
||||
return '<option value="' + escapeHtml(s.name) + '" ' + selected + '>' + escapeHtml(s.name) + '</option>';
|
||||
}).join('');
|
||||
}
|
||||
return '<div class="border border-border rounded-lg p-3 bg-card">' +
|
||||
'<div class="flex items-center justify-between mb-2">' +
|
||||
'<select onchange="updateSkillConfig(' + idx + ', \'skill\', this.value)" ' +
|
||||
|
||||
@@ -1113,6 +1113,10 @@ async function installCcwToolsMcpToCodex() {
|
||||
|
||||
await addCodexMcpServer('ccw-tools', ccwToolsConfig);
|
||||
|
||||
// Reload MCP configuration and refresh the view
|
||||
await loadMcpConfig();
|
||||
renderMcpManager();
|
||||
|
||||
const resultLabel = isUpdate ? 'updated in' : 'installed to';
|
||||
showRefreshToast(`CCW Tools ${resultLabel} Codex (${selectedTools.length} tools)`, 'success');
|
||||
} catch (err) {
|
||||
|
||||
@@ -293,8 +293,9 @@ function showRefreshToast(message, type) {
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// Increase display time to 3.5 seconds for better visibility
|
||||
setTimeout(() => {
|
||||
toast.classList.add('fade-out');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 2000);
|
||||
}, 3500);
|
||||
}
|
||||
|
||||
@@ -233,6 +233,10 @@ const i18n = {
|
||||
'codexlens.textSearch': 'Text Search',
|
||||
'codexlens.fileSearch': 'File Search',
|
||||
'codexlens.symbolSearch': 'Symbol Search',
|
||||
'codexlens.exactMode': 'Exact',
|
||||
'codexlens.fuzzyMode': 'Fuzzy (Trigram)',
|
||||
'codexlens.hybridMode': 'Hybrid (RRF)',
|
||||
'codexlens.vectorMode': 'Vector (Semantic)',
|
||||
'codexlens.searchPlaceholder': 'Enter search query (e.g., function name, file path, code snippet)',
|
||||
'codexlens.runSearch': 'Run Search',
|
||||
'codexlens.results': 'Results',
|
||||
@@ -250,6 +254,27 @@ const i18n = {
|
||||
'codexlens.cleanFailed': 'Failed to clean indexes',
|
||||
'codexlens.loadingConfig': 'Loading configuration...',
|
||||
|
||||
// Model Management
|
||||
'codexlens.semanticDeps': 'Semantic Dependencies',
|
||||
'codexlens.checkingDeps': 'Checking dependencies...',
|
||||
'codexlens.semanticInstalled': 'Semantic dependencies installed',
|
||||
'codexlens.semanticNotInstalled': 'Semantic dependencies not installed',
|
||||
'codexlens.installDeps': 'Install Dependencies',
|
||||
'codexlens.installingDeps': 'Installing dependencies...',
|
||||
'codexlens.depsInstalled': 'Dependencies installed successfully',
|
||||
'codexlens.depsInstallFailed': 'Failed to install dependencies',
|
||||
'codexlens.modelManagement': 'Model Management',
|
||||
'codexlens.loadingModels': 'Loading models...',
|
||||
'codexlens.downloadModel': 'Download',
|
||||
'codexlens.deleteModel': 'Delete',
|
||||
'codexlens.downloading': 'Downloading...',
|
||||
'codexlens.deleting': 'Deleting...',
|
||||
'codexlens.modelDownloaded': 'Model downloaded',
|
||||
'codexlens.modelDownloadFailed': 'Model download failed',
|
||||
'codexlens.modelDeleted': 'Model deleted',
|
||||
'codexlens.modelDeleteFailed': 'Model deletion failed',
|
||||
'codexlens.deleteModelConfirm': 'Are you sure you want to delete model',
|
||||
|
||||
// Semantic Search Configuration
|
||||
'semantic.settings': 'Semantic Search Settings',
|
||||
'semantic.configDesc': 'Configure LLM enhancement for semantic indexing',
|
||||
@@ -291,6 +316,8 @@ const i18n = {
|
||||
'cli.smartContextDesc': 'Auto-analyze prompt and add relevant file paths',
|
||||
'cli.nativeResume': 'Native Resume',
|
||||
'cli.nativeResumeDesc': 'Use native tool resume (gemini -r, qwen --resume, codex resume)',
|
||||
'cli.recursiveQuery': 'Recursive Query',
|
||||
'cli.recursiveQueryDesc': 'Aggregate CLI history and memory data from parent and child projects',
|
||||
'cli.maxContextFiles': 'Max Context Files',
|
||||
'cli.maxContextFilesDesc': 'Maximum files to include in smart context',
|
||||
|
||||
@@ -459,6 +486,48 @@ const i18n = {
|
||||
'mcp.claudeJsonDesc': 'Save in root .claude.json projects section (shared config)',
|
||||
'mcp.mcpJsonDesc': 'Save in project .mcp.json file (recommended for version control)',
|
||||
|
||||
// New MCP Manager UI
|
||||
'mcp.title': 'MCP Server Management',
|
||||
'mcp.subtitle': 'Manage MCP servers for Claude, Codex, and project-level configurations',
|
||||
'mcp.createNew': 'Create New',
|
||||
'mcp.createFirst': 'Create Your First Server',
|
||||
'mcp.noServers': 'No MCP Servers Configured',
|
||||
'mcp.noServersDesc': 'Get started by creating a new MCP server or installing from templates',
|
||||
'mcp.totalServers': 'Total Servers',
|
||||
'mcp.enabled': 'Enabled',
|
||||
'mcp.viewServer': 'View Server',
|
||||
'mcp.editServer': 'Edit Server',
|
||||
'mcp.createServer': 'Create Server',
|
||||
'mcp.updateServer': 'Update Server',
|
||||
'mcp.close': 'Close',
|
||||
'mcp.cancel': 'Cancel',
|
||||
'mcp.update': 'Update',
|
||||
'mcp.install': 'Install',
|
||||
'mcp.save': 'Save',
|
||||
'mcp.delete': 'Delete',
|
||||
'mcp.optional': 'Optional',
|
||||
'mcp.description': 'Description',
|
||||
'mcp.category': 'Category',
|
||||
'mcp.installTo': 'Install To',
|
||||
'mcp.cwd': 'Working Directory',
|
||||
'mcp.httpHeaders': 'HTTP Headers',
|
||||
'mcp.error': 'Error',
|
||||
'mcp.success': 'Success',
|
||||
'mcp.nameRequired': 'Server name is required',
|
||||
'mcp.commandRequired': 'Command is required',
|
||||
'mcp.urlRequired': 'URL is required',
|
||||
'mcp.invalidArgsJson': 'Invalid JSON format for arguments',
|
||||
'mcp.invalidEnvJson': 'Invalid JSON format for environment variables',
|
||||
'mcp.invalidHeadersJson': 'Invalid JSON format for HTTP headers',
|
||||
'mcp.serverInstalled': 'Server installed successfully',
|
||||
'mcp.serverEnabled': 'Server enabled successfully',
|
||||
'mcp.serverDisabled': 'Server disabled successfully',
|
||||
'mcp.serverDeleted': 'Server deleted successfully',
|
||||
'mcp.backToManager': 'Back to Manager',
|
||||
'mcp.noTemplates': 'No Templates Available',
|
||||
'mcp.noTemplatesDesc': 'Create templates from existing servers or add new ones',
|
||||
'mcp.templatesDesc': 'Browse and install pre-configured MCP server templates',
|
||||
|
||||
// MCP Templates
|
||||
'mcp.templates': 'MCP Templates',
|
||||
'mcp.savedTemplates': 'saved templates',
|
||||
@@ -500,6 +569,7 @@ const i18n = {
|
||||
'mcp.codex.removeConfirm': 'Remove Codex MCP server "{name}"?',
|
||||
'mcp.codex.copyToClaude': 'Copy to Claude',
|
||||
'mcp.codex.copyToCodex': 'Copy to Codex',
|
||||
'mcp.codex.install': 'Install to Codex',
|
||||
'mcp.codex.copyFromClaude': 'Copy Claude Servers to Codex',
|
||||
'mcp.codex.alreadyAdded': 'Already in Codex',
|
||||
'mcp.codex.scopeCodex': 'Codex - Global (~/.codex/config.toml)',
|
||||
@@ -510,6 +580,7 @@ const i18n = {
|
||||
'mcp.claude.copyFromCodex': 'Copy Codex Servers to Claude',
|
||||
'mcp.claude.alreadyAdded': 'Already in Claude',
|
||||
'mcp.claude.copyToClaude': 'Copy to Claude Global',
|
||||
'mcp.claude.copyToCodex': 'Copy to Codex',
|
||||
|
||||
// MCP Edit Modal
|
||||
'mcp.editModal.title': 'Edit MCP Server',
|
||||
@@ -1292,6 +1363,10 @@ const i18n = {
|
||||
'codexlens.textSearch': '文本搜索',
|
||||
'codexlens.fileSearch': '文件搜索',
|
||||
'codexlens.symbolSearch': '符号搜索',
|
||||
'codexlens.exactMode': '精确模式',
|
||||
'codexlens.fuzzyMode': '模糊模式 (Trigram)',
|
||||
'codexlens.hybridMode': '混合模式 (RRF)',
|
||||
'codexlens.vectorMode': '向量模式 (语义搜索)',
|
||||
'codexlens.searchPlaceholder': '输入搜索查询(例如:函数名、文件路径、代码片段)',
|
||||
'codexlens.runSearch': '运行搜索',
|
||||
'codexlens.results': '结果',
|
||||
@@ -1309,6 +1384,27 @@ const i18n = {
|
||||
'codexlens.cleanFailed': '清理索引失败',
|
||||
'codexlens.loadingConfig': '加载配置中...',
|
||||
|
||||
// 模型管理
|
||||
'codexlens.semanticDeps': '语义搜索依赖',
|
||||
'codexlens.checkingDeps': '检查依赖中...',
|
||||
'codexlens.semanticInstalled': '语义搜索依赖已安装',
|
||||
'codexlens.semanticNotInstalled': '语义搜索依赖未安装',
|
||||
'codexlens.installDeps': '安装依赖',
|
||||
'codexlens.installingDeps': '安装依赖中...',
|
||||
'codexlens.depsInstalled': '依赖安装成功',
|
||||
'codexlens.depsInstallFailed': '依赖安装失败',
|
||||
'codexlens.modelManagement': '模型管理',
|
||||
'codexlens.loadingModels': '加载模型中...',
|
||||
'codexlens.downloadModel': '下载',
|
||||
'codexlens.deleteModel': '删除',
|
||||
'codexlens.downloading': '下载中...',
|
||||
'codexlens.deleting': '删除中...',
|
||||
'codexlens.modelDownloaded': '模型已下载',
|
||||
'codexlens.modelDownloadFailed': '模型下载失败',
|
||||
'codexlens.modelDeleted': '模型已删除',
|
||||
'codexlens.modelDeleteFailed': '模型删除失败',
|
||||
'codexlens.deleteModelConfirm': '确定要删除模型',
|
||||
|
||||
// Semantic Search 配置
|
||||
'semantic.settings': '语义搜索设置',
|
||||
'semantic.configDesc': '配置语义索引的 LLM 增强功能',
|
||||
@@ -1350,6 +1446,8 @@ const i18n = {
|
||||
'cli.smartContextDesc': '自动分析提示词并添加相关文件路径',
|
||||
'cli.nativeResume': '原生恢复',
|
||||
'cli.nativeResumeDesc': '使用工具原生恢复命令 (gemini -r, qwen --resume, codex resume)',
|
||||
'cli.recursiveQuery': '递归查询',
|
||||
'cli.recursiveQueryDesc': '聚合显示父项目和子项目的 CLI 历史与内存数据',
|
||||
'cli.maxContextFiles': '最大上下文文件数',
|
||||
'cli.maxContextFilesDesc': '智能上下文包含的最大文件数',
|
||||
|
||||
@@ -1515,6 +1613,48 @@ const i18n = {
|
||||
'mcp.claudeJsonDesc': '保存在根目录 .claude.json projects 字段下(共享配置)',
|
||||
'mcp.mcpJsonDesc': '保存在项目 .mcp.json 文件中(推荐用于版本控制)',
|
||||
|
||||
// New MCP Manager UI
|
||||
'mcp.title': 'MCP 服务器管理',
|
||||
'mcp.subtitle': '管理 Claude、Codex 和项目级别的 MCP 服务器配置',
|
||||
'mcp.createNew': '创建新服务器',
|
||||
'mcp.createFirst': '创建第一个服务器',
|
||||
'mcp.noServers': '未配置 MCP 服务器',
|
||||
'mcp.noServersDesc': '开始创建新的 MCP 服务器或从模板安装',
|
||||
'mcp.totalServers': '总服务器数',
|
||||
'mcp.enabled': '已启用',
|
||||
'mcp.viewServer': '查看服务器',
|
||||
'mcp.editServer': '编辑服务器',
|
||||
'mcp.createServer': '创建服务器',
|
||||
'mcp.updateServer': '更新服务器',
|
||||
'mcp.close': '关闭',
|
||||
'mcp.cancel': '取消',
|
||||
'mcp.update': '更新',
|
||||
'mcp.install': '安装',
|
||||
'mcp.save': '保存',
|
||||
'mcp.delete': '删除',
|
||||
'mcp.optional': '可选',
|
||||
'mcp.description': '描述',
|
||||
'mcp.category': '分类',
|
||||
'mcp.installTo': '安装到',
|
||||
'mcp.cwd': '工作目录',
|
||||
'mcp.httpHeaders': 'HTTP 头',
|
||||
'mcp.error': '错误',
|
||||
'mcp.success': '成功',
|
||||
'mcp.nameRequired': '服务器名称为必填项',
|
||||
'mcp.commandRequired': '命令为必填项',
|
||||
'mcp.urlRequired': 'URL 为必填项',
|
||||
'mcp.invalidArgsJson': '参数 JSON 格式无效',
|
||||
'mcp.invalidEnvJson': '环境变量 JSON 格式无效',
|
||||
'mcp.invalidHeadersJson': 'HTTP 头 JSON 格式无效',
|
||||
'mcp.serverInstalled': '服务器安装成功',
|
||||
'mcp.serverEnabled': '服务器启用成功',
|
||||
'mcp.serverDisabled': '服务器禁用成功',
|
||||
'mcp.serverDeleted': '服务器删除成功',
|
||||
'mcp.backToManager': '返回管理器',
|
||||
'mcp.noTemplates': '无可用模板',
|
||||
'mcp.noTemplatesDesc': '从现有服务器创建模板或添加新模板',
|
||||
'mcp.templatesDesc': '浏览并安装预配置的 MCP 服务器模板',
|
||||
|
||||
// MCP CLI Mode
|
||||
'mcp.cliMode': 'CLI 模式',
|
||||
'mcp.claudeMode': 'Claude 模式',
|
||||
@@ -1537,6 +1677,7 @@ const i18n = {
|
||||
'mcp.codex.removeConfirm': '移除 Codex MCP 服务器 "{name}"?',
|
||||
'mcp.codex.copyToClaude': '复制到 Claude',
|
||||
'mcp.codex.copyToCodex': '复制到 Codex',
|
||||
'mcp.codex.install': '安装到 Codex',
|
||||
'mcp.codex.copyFromClaude': '从 Claude 复制服务器到 Codex',
|
||||
'mcp.codex.alreadyAdded': '已在 Codex 中',
|
||||
'mcp.codex.scopeCodex': 'Codex - 全局 (~/.codex/config.toml)',
|
||||
@@ -1547,6 +1688,7 @@ const i18n = {
|
||||
'mcp.claude.copyFromCodex': '从 Codex 复制服务器到 Claude',
|
||||
'mcp.claude.alreadyAdded': '已在 Claude 中',
|
||||
'mcp.claude.copyToClaude': '复制到 Claude 全局',
|
||||
'mcp.claude.copyToCodex': '复制到 Codex',
|
||||
|
||||
// MCP Edit Modal
|
||||
'mcp.editModal.title': '编辑 MCP 服务器',
|
||||
|
||||
@@ -567,6 +567,19 @@ function renderCliSettingsSection() {
|
||||
'</div>' +
|
||||
'<p class="cli-setting-desc">' + t('cli.nativeResumeDesc') + '</p>' +
|
||||
'</div>' +
|
||||
'<div class="cli-setting-item">' +
|
||||
'<label class="cli-setting-label">' +
|
||||
'<i data-lucide="git-branch" class="w-3 h-3"></i>' +
|
||||
t('cli.recursiveQuery') +
|
||||
'</label>' +
|
||||
'<div class="cli-setting-control">' +
|
||||
'<label class="cli-toggle">' +
|
||||
'<input type="checkbox"' + (recursiveQueryEnabled ? ' checked' : '') + ' onchange="setRecursiveQueryEnabled(this.checked)">' +
|
||||
'<span class="cli-toggle-slider"></span>' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'<p class="cli-setting-desc">' + t('cli.recursiveQueryDesc') + '</p>' +
|
||||
'</div>' +
|
||||
'<div class="cli-setting-item' + (!smartContextEnabled ? ' disabled' : '') + '">' +
|
||||
'<label class="cli-setting-label">' +
|
||||
'<i data-lucide="files" class="w-3 h-3"></i>' +
|
||||
@@ -1614,6 +1627,26 @@ function buildCodexLensConfigContent(config) {
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
// Semantic Dependencies Section
|
||||
(isInstalled
|
||||
? '<div class="tool-config-section">' +
|
||||
'<h4>' + t('codexlens.semanticDeps') + '</h4>' +
|
||||
'<div id="semanticDepsStatus" class="space-y-2">' +
|
||||
'<div class="text-sm text-muted-foreground">' + t('codexlens.checkingDeps') + '</div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
: '') +
|
||||
|
||||
// Model Management Section
|
||||
(isInstalled
|
||||
? '<div class="tool-config-section">' +
|
||||
'<h4>' + t('codexlens.modelManagement') + '</h4>' +
|
||||
'<div id="modelListContainer" class="space-y-2">' +
|
||||
'<div class="text-sm text-muted-foreground">' + t('codexlens.loadingModels') + '</div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
: '') +
|
||||
|
||||
// Test Search Section
|
||||
(isInstalled
|
||||
? '<div class="tool-config-section">' +
|
||||
@@ -1625,6 +1658,12 @@ function buildCodexLensConfigContent(config) {
|
||||
'<option value="search_files">' + t('codexlens.fileSearch') + '</option>' +
|
||||
'<option value="symbol">' + t('codexlens.symbolSearch') + '</option>' +
|
||||
'</select>' +
|
||||
'<select id="searchModeSelect" class="tool-config-select flex-1">' +
|
||||
'<option value="exact">' + t('codexlens.exactMode') + '</option>' +
|
||||
'<option value="fuzzy">' + t('codexlens.fuzzyMode') + '</option>' +
|
||||
'<option value="hybrid">' + t('codexlens.hybridMode') + '</option>' +
|
||||
'<option value="vector">' + t('codexlens.vectorMode') + '</option>' +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
'<div>' +
|
||||
'<input type="text" id="searchQueryInput" class="tool-config-input w-full" ' +
|
||||
@@ -1717,6 +1756,7 @@ function initCodexLensConfigEvents(currentConfig) {
|
||||
if (runSearchBtn) {
|
||||
runSearchBtn.onclick = async function() {
|
||||
var searchType = document.getElementById('searchTypeSelect').value;
|
||||
var searchMode = document.getElementById('searchModeSelect').value;
|
||||
var query = document.getElementById('searchQueryInput').value.trim();
|
||||
var resultsDiv = document.getElementById('searchResults');
|
||||
var resultCount = document.getElementById('searchResultCount');
|
||||
@@ -1734,6 +1774,10 @@ function initCodexLensConfigEvents(currentConfig) {
|
||||
try {
|
||||
var endpoint = '/api/codexlens/' + searchType;
|
||||
var params = new URLSearchParams({ query: query, limit: '20' });
|
||||
// Add mode parameter for search and search_files (not for symbol search)
|
||||
if (searchType === 'search' || searchType === 'search_files') {
|
||||
params.append('mode', searchMode);
|
||||
}
|
||||
|
||||
var response = await fetch(endpoint + '?' + params.toString());
|
||||
var result = await response.json();
|
||||
@@ -1766,6 +1810,211 @@ function initCodexLensConfigEvents(currentConfig) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Load semantic dependencies status
|
||||
loadSemanticDepsStatus();
|
||||
|
||||
// Load model list
|
||||
loadModelList();
|
||||
}
|
||||
|
||||
// Load semantic dependencies status
|
||||
async function loadSemanticDepsStatus() {
|
||||
var container = document.getElementById('semanticDepsStatus');
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
var response = await fetch('/api/codexlens/semantic/status');
|
||||
var result = await response.json();
|
||||
|
||||
if (result.available) {
|
||||
container.innerHTML =
|
||||
'<div class="flex items-center gap-2 text-sm">' +
|
||||
'<i data-lucide="check-circle" class="w-4 h-4 text-success"></i>' +
|
||||
'<span>' + t('codexlens.semanticInstalled') + '</span>' +
|
||||
'<span class="text-muted-foreground">(' + (result.backend || 'fastembed') + ')</span>' +
|
||||
'</div>';
|
||||
} else {
|
||||
container.innerHTML =
|
||||
'<div class="space-y-2">' +
|
||||
'<div class="flex items-center gap-2 text-sm text-muted-foreground">' +
|
||||
'<i data-lucide="alert-circle" class="w-4 h-4"></i>' +
|
||||
'<span>' + t('codexlens.semanticNotInstalled') + '</span>' +
|
||||
'</div>' +
|
||||
'<button class="btn-sm btn-outline" onclick="installSemanticDeps()">' +
|
||||
'<i data-lucide="download" class="w-3 h-3"></i> ' + t('codexlens.installDeps') +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
}
|
||||
if (window.lucide) lucide.createIcons();
|
||||
} catch (err) {
|
||||
container.innerHTML =
|
||||
'<div class="text-sm text-error">' + t('common.error') + ': ' + err.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Install semantic dependencies
|
||||
async function installSemanticDeps() {
|
||||
var container = document.getElementById('semanticDepsStatus');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML =
|
||||
'<div class="text-sm text-muted-foreground animate-pulse">' + t('codexlens.installingDeps') + '</div>';
|
||||
|
||||
try {
|
||||
var response = await fetch('/api/codexlens/semantic/install', { method: 'POST' });
|
||||
var result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showRefreshToast(t('codexlens.depsInstalled'), 'success');
|
||||
await loadSemanticDepsStatus();
|
||||
await loadModelList();
|
||||
} else {
|
||||
showRefreshToast(t('codexlens.depsInstallFailed') + ': ' + result.error, 'error');
|
||||
await loadSemanticDepsStatus();
|
||||
}
|
||||
} catch (err) {
|
||||
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
|
||||
await loadSemanticDepsStatus();
|
||||
}
|
||||
}
|
||||
|
||||
// Load model list
|
||||
async function loadModelList() {
|
||||
var container = document.getElementById('modelListContainer');
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
var response = await fetch('/api/codexlens/models');
|
||||
var result = await response.json();
|
||||
|
||||
if (!result.success || !result.result || !result.result.models) {
|
||||
container.innerHTML =
|
||||
'<div class="text-sm text-muted-foreground">' + t('codexlens.semanticNotInstalled') + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var models = result.result.models;
|
||||
var html = '<div class="space-y-2">';
|
||||
|
||||
models.forEach(function(model) {
|
||||
var statusIcon = model.installed
|
||||
? '<i data-lucide="check-circle" class="w-4 h-4 text-success"></i>'
|
||||
: '<i data-lucide="circle" class="w-4 h-4 text-muted"></i>';
|
||||
|
||||
var sizeText = model.installed
|
||||
? model.actual_size_mb.toFixed(1) + ' MB'
|
||||
: '~' + model.estimated_size_mb + ' MB';
|
||||
|
||||
var actionBtn = model.installed
|
||||
? '<button class="btn-sm btn-outline btn-danger" onclick="deleteModel(\'' + model.profile + '\')">' +
|
||||
'<i data-lucide="trash-2" class="w-3 h-3"></i> ' + t('codexlens.deleteModel') +
|
||||
'</button>'
|
||||
: '<button class="btn-sm btn-outline" onclick="downloadModel(\'' + model.profile + '\')">' +
|
||||
'<i data-lucide="download" class="w-3 h-3"></i> ' + t('codexlens.downloadModel') +
|
||||
'</button>';
|
||||
|
||||
html +=
|
||||
'<div class="border rounded-lg p-3 space-y-2" id="model-' + model.profile + '">' +
|
||||
'<div class="flex items-start justify-between">' +
|
||||
'<div class="flex-1">' +
|
||||
'<div class="flex items-center gap-2 mb-1">' +
|
||||
statusIcon +
|
||||
'<span class="font-medium">' + model.profile + '</span>' +
|
||||
'<span class="text-xs text-muted-foreground">(' + model.dimensions + ' dims)</span>' +
|
||||
'</div>' +
|
||||
'<div class="text-xs text-muted-foreground mb-1">' + model.model_name + '</div>' +
|
||||
'<div class="text-xs text-muted-foreground">' + model.use_case + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="text-right">' +
|
||||
'<div class="text-xs text-muted-foreground mb-2">' + sizeText + '</div>' +
|
||||
actionBtn +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
} catch (err) {
|
||||
container.innerHTML =
|
||||
'<div class="text-sm text-error">' + t('common.error') + ': ' + err.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Download model
|
||||
async function downloadModel(profile) {
|
||||
var modelCard = document.getElementById('model-' + profile);
|
||||
if (!modelCard) return;
|
||||
|
||||
var originalHTML = modelCard.innerHTML;
|
||||
modelCard.innerHTML =
|
||||
'<div class="flex items-center justify-center p-3">' +
|
||||
'<span class="text-sm text-muted-foreground animate-pulse">' + t('codexlens.downloading') + '</span>' +
|
||||
'</div>';
|
||||
|
||||
try {
|
||||
var response = await fetch('/api/codexlens/models/download', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profile: profile })
|
||||
});
|
||||
|
||||
var result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showRefreshToast(t('codexlens.modelDownloaded') + ': ' + profile, 'success');
|
||||
await loadModelList();
|
||||
} else {
|
||||
showRefreshToast(t('codexlens.modelDownloadFailed') + ': ' + result.error, 'error');
|
||||
modelCard.innerHTML = originalHTML;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
} catch (err) {
|
||||
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
|
||||
modelCard.innerHTML = originalHTML;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
// Delete model
|
||||
async function deleteModel(profile) {
|
||||
if (!confirm(t('codexlens.deleteModelConfirm') + ' ' + profile + '?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
var modelCard = document.getElementById('model-' + profile);
|
||||
if (!modelCard) return;
|
||||
|
||||
var originalHTML = modelCard.innerHTML;
|
||||
modelCard.innerHTML =
|
||||
'<div class="flex items-center justify-center p-3">' +
|
||||
'<span class="text-sm text-muted-foreground animate-pulse">' + t('codexlens.deleting') + '</span>' +
|
||||
'</div>';
|
||||
|
||||
try {
|
||||
var response = await fetch('/api/codexlens/models/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profile: profile })
|
||||
});
|
||||
|
||||
var result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showRefreshToast(t('codexlens.modelDeleted') + ': ' + profile, 'success');
|
||||
await loadModelList();
|
||||
} else {
|
||||
showRefreshToast(t('codexlens.modelDeleteFailed') + ': ' + result.error, 'error');
|
||||
modelCard.innerHTML = originalHTML;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
} catch (err) {
|
||||
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
|
||||
modelCard.innerHTML = originalHTML;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanCodexLensIndexes() {
|
||||
|
||||
596
ccw/src/templates/dashboard-js/views/codexlens-manager.js
Normal file
596
ccw/src/templates/dashboard-js/views/codexlens-manager.js
Normal file
@@ -0,0 +1,596 @@
|
||||
// CodexLens Manager - Configuration, Model Management, and Semantic Dependencies
|
||||
// Extracted from cli-manager.js for better maintainability
|
||||
|
||||
// ============================================================
|
||||
// CODEXLENS CONFIGURATION MODAL
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Show CodexLens configuration modal
|
||||
*/
|
||||
async function showCodexLensConfigModal() {
|
||||
try {
|
||||
showRefreshToast(t('codexlens.loadingConfig'), 'info');
|
||||
|
||||
// Fetch current config
|
||||
const response = await fetch('/api/codexlens/config');
|
||||
const config = await response.json();
|
||||
|
||||
const modalHtml = buildCodexLensConfigContent(config);
|
||||
|
||||
// Create and show modal
|
||||
const modalContainer = document.createElement('div');
|
||||
modalContainer.innerHTML = modalHtml;
|
||||
document.body.appendChild(modalContainer);
|
||||
|
||||
// Initialize icons
|
||||
if (window.lucide) lucide.createIcons();
|
||||
|
||||
// Initialize event handlers
|
||||
initCodexLensConfigEvents(config);
|
||||
} catch (err) {
|
||||
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build CodexLens configuration modal content
|
||||
*/
|
||||
function buildCodexLensConfigContent(config) {
|
||||
const indexDir = config.index_dir || '~/.codexlens/indexes';
|
||||
const indexCount = config.index_count || 0;
|
||||
const isInstalled = window.cliToolsStatus?.codexlens?.installed || false;
|
||||
|
||||
return '<div class="modal-backdrop" id="codexlensConfigModal">' +
|
||||
'<div class="modal-container">' +
|
||||
'<div class="modal-header">' +
|
||||
'<div class="flex items-center gap-3">' +
|
||||
'<div class="modal-icon">' +
|
||||
'<i data-lucide="database" class="w-5 h-5"></i>' +
|
||||
'</div>' +
|
||||
'<div>' +
|
||||
'<h2 class="text-lg font-bold">' + t('codexlens.config') + '</h2>' +
|
||||
'<p class="text-xs text-muted-foreground">' + t('codexlens.whereIndexesStored') + '</p>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<button onclick="closeModal()" class="text-muted-foreground hover:text-foreground">' +
|
||||
'<i data-lucide="x" class="w-5 h-5"></i>' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
|
||||
'<div class="modal-body">' +
|
||||
// Status Section
|
||||
'<div class="tool-config-section">' +
|
||||
'<h4>' + t('codexlens.status') + '</h4>' +
|
||||
'<div class="flex items-center gap-4 text-sm">' +
|
||||
'<div class="flex items-center gap-2">' +
|
||||
'<span class="text-muted-foreground">' + t('codexlens.currentWorkspace') + ':</span>' +
|
||||
'<span class="font-medium">' + (isInstalled ? t('codexlens.installed') : t('codexlens.notInstalled')) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="flex items-center gap-2">' +
|
||||
'<span class="text-muted-foreground">' + t('codexlens.indexes') + ':</span>' +
|
||||
'<span class="font-medium">' + indexCount + '</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
// Index Storage Path Section
|
||||
'<div class="tool-config-section">' +
|
||||
'<h4>' + t('codexlens.indexStoragePath') + '</h4>' +
|
||||
'<div class="space-y-3">' +
|
||||
'<div>' +
|
||||
'<label class="block text-sm font-medium mb-1.5">' + t('codexlens.currentPath') + '</label>' +
|
||||
'<div class="text-sm text-muted-foreground bg-muted/30 rounded-lg px-3 py-2 font-mono">' +
|
||||
indexDir +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div>' +
|
||||
'<label class="block text-sm font-medium mb-1.5">' + t('codexlens.newStoragePath') + '</label>' +
|
||||
'<input type="text" id="indexDirInput" value="' + indexDir + '" ' +
|
||||
'placeholder="' + t('codexlens.pathPlaceholder') + '" ' +
|
||||
'class="tool-config-input w-full" />' +
|
||||
'<p class="text-xs text-muted-foreground mt-1">' + t('codexlens.pathInfo') + '</p>' +
|
||||
'</div>' +
|
||||
'<div class="flex items-start gap-2 bg-warning/10 border border-warning/30 rounded-lg p-3">' +
|
||||
'<i data-lucide="alert-triangle" class="w-4 h-4 text-warning mt-0.5"></i>' +
|
||||
'<div class="text-sm">' +
|
||||
'<p class="font-medium text-warning">' + t('codexlens.migrationRequired') + '</p>' +
|
||||
'<p class="text-muted-foreground mt-1">' + t('codexlens.migrationWarning') + '</p>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
// Actions Section
|
||||
'<div class="tool-config-section">' +
|
||||
'<h4>' + t('codexlens.actions') + '</h4>' +
|
||||
'<div class="tool-config-actions">' +
|
||||
(isInstalled
|
||||
? '<button class="btn-sm btn-outline" onclick="initCodexLensIndex()">' +
|
||||
'<i data-lucide="database" class="w-3 h-3"></i> ' + t('codexlens.initializeIndex') +
|
||||
'</button>' +
|
||||
'<button class="btn-sm btn-outline" onclick="cleanCodexLensIndexes()">' +
|
||||
'<i data-lucide="trash" class="w-3 h-3"></i> ' + t('codexlens.cleanAllIndexes') +
|
||||
'</button>' +
|
||||
'<button class="btn-sm btn-outline btn-danger" onclick="uninstallCodexLens()">' +
|
||||
'<i data-lucide="trash-2" class="w-3 h-3"></i> ' + t('cli.uninstall') +
|
||||
'</button>'
|
||||
: '<button class="btn-sm btn-primary" onclick="installCodexLens()">' +
|
||||
'<i data-lucide="download" class="w-3 h-3"></i> ' + t('codexlens.installCodexLens') +
|
||||
'</button>') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
// Semantic Dependencies Section
|
||||
(isInstalled
|
||||
? '<div class="tool-config-section">' +
|
||||
'<h4>' + t('codexlens.semanticDeps') + '</h4>' +
|
||||
'<div id="semanticDepsStatus" class="space-y-2">' +
|
||||
'<div class="text-sm text-muted-foreground">' + t('codexlens.checkingDeps') + '</div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
: '') +
|
||||
|
||||
// Model Management Section
|
||||
(isInstalled
|
||||
? '<div class="tool-config-section">' +
|
||||
'<h4>' + t('codexlens.modelManagement') + '</h4>' +
|
||||
'<div id="modelListContainer" class="space-y-2">' +
|
||||
'<div class="text-sm text-muted-foreground">' + t('codexlens.loadingModels') + '</div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
: '') +
|
||||
|
||||
// Test Search Section
|
||||
(isInstalled
|
||||
? '<div class="tool-config-section">' +
|
||||
'<h4>' + t('codexlens.testSearch') + ' <span class="text-muted">(' + t('codexlens.testFunctionality') + ')</span></h4>' +
|
||||
'<div class="space-y-3">' +
|
||||
'<div class="flex gap-2">' +
|
||||
'<select id="searchTypeSelect" class="tool-config-select flex-1">' +
|
||||
'<option value="search">' + t('codexlens.textSearch') + '</option>' +
|
||||
'<option value="search_files">' + t('codexlens.fileSearch') + '</option>' +
|
||||
'<option value="symbol">' + t('codexlens.symbolSearch') + '</option>' +
|
||||
'</select>' +
|
||||
'<select id="searchModeSelect" class="tool-config-select flex-1">' +
|
||||
'<option value="exact">' + t('codexlens.exactMode') + '</option>' +
|
||||
'<option value="fuzzy">' + t('codexlens.fuzzyMode') + '</option>' +
|
||||
'<option value="hybrid">' + t('codexlens.hybridMode') + '</option>' +
|
||||
'<option value="vector">' + t('codexlens.vectorMode') + '</option>' +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
'<div>' +
|
||||
'<input type="text" id="searchQueryInput" class="tool-config-input w-full" ' +
|
||||
'placeholder="' + t('codexlens.searchPlaceholder') + '" />' +
|
||||
'</div>' +
|
||||
'<div>' +
|
||||
'<button class="btn-sm btn-primary w-full" id="runSearchBtn">' +
|
||||
'<i data-lucide="search" class="w-3 h-3"></i> ' + t('codexlens.runSearch') +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'<div id="searchResults" class="hidden">' +
|
||||
'<div class="bg-muted/30 rounded-lg p-3 max-h-64 overflow-y-auto">' +
|
||||
'<div class="flex items-center justify-between mb-2">' +
|
||||
'<p class="text-sm font-medium">' + t('codexlens.results') + ':</p>' +
|
||||
'<span id="searchResultCount" class="text-xs text-muted-foreground"></span>' +
|
||||
'</div>' +
|
||||
'<pre id="searchResultContent" class="text-xs font-mono whitespace-pre-wrap break-all"></pre>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
: '') +
|
||||
'</div>' +
|
||||
|
||||
// Footer
|
||||
'<div class="tool-config-footer">' +
|
||||
'<button class="btn btn-outline" onclick="closeModal()">' + t('common.cancel') + '</button>' +
|
||||
'<button class="btn btn-primary" id="saveCodexLensConfigBtn">' +
|
||||
'<i data-lucide="save" class="w-3.5 h-3.5"></i> ' + t('codexlens.saveConfig') +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize CodexLens config modal event handlers
|
||||
*/
|
||||
function initCodexLensConfigEvents(currentConfig) {
|
||||
// Save button
|
||||
var saveBtn = document.getElementById('saveCodexLensConfigBtn');
|
||||
if (saveBtn) {
|
||||
saveBtn.onclick = async function() {
|
||||
var indexDirInput = document.getElementById('indexDirInput');
|
||||
var newIndexDir = indexDirInput ? indexDirInput.value.trim() : '';
|
||||
|
||||
if (!newIndexDir) {
|
||||
showRefreshToast(t('codexlens.pathEmpty'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newIndexDir === currentConfig.index_dir) {
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.innerHTML = '<span class="animate-pulse">' + t('common.saving') + '</span>';
|
||||
|
||||
try {
|
||||
var response = await fetch('/api/codexlens/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ index_dir: newIndexDir })
|
||||
});
|
||||
|
||||
var result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showRefreshToast(t('codexlens.configSaved'), 'success');
|
||||
closeModal();
|
||||
|
||||
// Refresh CodexLens status
|
||||
if (typeof loadCodexLensStatus === 'function') {
|
||||
await loadCodexLensStatus();
|
||||
renderToolsSection();
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
} else {
|
||||
showRefreshToast(t('common.saveFailed') + ': ' + result.error, 'error');
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.innerHTML = '<i data-lucide="save" class="w-3.5 h-3.5"></i> ' + t('codexlens.saveConfig');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
} catch (err) {
|
||||
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.innerHTML = '<i data-lucide="save" class="w-3.5 h-3.5"></i> ' + t('codexlens.saveConfig');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Test Search Button
|
||||
var runSearchBtn = document.getElementById('runSearchBtn');
|
||||
if (runSearchBtn) {
|
||||
runSearchBtn.onclick = async function() {
|
||||
var searchType = document.getElementById('searchTypeSelect').value;
|
||||
var searchMode = document.getElementById('searchModeSelect').value;
|
||||
var query = document.getElementById('searchQueryInput').value.trim();
|
||||
var resultsDiv = document.getElementById('searchResults');
|
||||
var resultCount = document.getElementById('searchResultCount');
|
||||
var resultContent = document.getElementById('searchResultContent');
|
||||
|
||||
if (!query) {
|
||||
showRefreshToast(t('codexlens.enterQuery'), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
runSearchBtn.disabled = true;
|
||||
runSearchBtn.innerHTML = '<span class="animate-pulse">' + t('codexlens.searching') + '</span>';
|
||||
resultsDiv.classList.add('hidden');
|
||||
|
||||
try {
|
||||
var endpoint = '/api/codexlens/' + searchType;
|
||||
var params = new URLSearchParams({ query: query, limit: '20' });
|
||||
// Add mode parameter for search and search_files (not for symbol search)
|
||||
if (searchType === 'search' || searchType === 'search_files') {
|
||||
params.append('mode', searchMode);
|
||||
}
|
||||
|
||||
var response = await fetch(endpoint + '?' + params.toString());
|
||||
var result = await response.json();
|
||||
|
||||
console.log('[CodexLens Test] Search result:', result);
|
||||
|
||||
if (result.success) {
|
||||
var results = result.results || result.files || [];
|
||||
resultCount.textContent = results.length + ' ' + t('codexlens.resultsCount');
|
||||
resultContent.textContent = JSON.stringify(results, null, 2);
|
||||
resultsDiv.classList.remove('hidden');
|
||||
showRefreshToast(t('codexlens.searchCompleted') + ': ' + results.length + ' ' + t('codexlens.resultsCount'), 'success');
|
||||
} else {
|
||||
resultContent.textContent = t('common.error') + ': ' + (result.error || t('common.unknownError'));
|
||||
resultsDiv.classList.remove('hidden');
|
||||
showRefreshToast(t('codexlens.searchFailed') + ': ' + result.error, 'error');
|
||||
}
|
||||
|
||||
runSearchBtn.disabled = false;
|
||||
runSearchBtn.innerHTML = '<i data-lucide="search" class="w-3 h-3"></i> ' + t('codexlens.runSearch');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
} catch (err) {
|
||||
console.error('[CodexLens Test] Error:', err);
|
||||
resultContent.textContent = t('common.exception') + ': ' + err.message;
|
||||
resultsDiv.classList.remove('hidden');
|
||||
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
|
||||
runSearchBtn.disabled = false;
|
||||
runSearchBtn.innerHTML = '<i data-lucide="search" class="w-3 h-3"></i> ' + t('codexlens.runSearch');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Load semantic dependencies status
|
||||
loadSemanticDepsStatus();
|
||||
|
||||
// Load model list
|
||||
loadModelList();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SEMANTIC DEPENDENCIES MANAGEMENT
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Load semantic dependencies status
|
||||
*/
|
||||
async function loadSemanticDepsStatus() {
|
||||
var container = document.getElementById('semanticDepsStatus');
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
var response = await fetch('/api/codexlens/semantic/status');
|
||||
var result = await response.json();
|
||||
|
||||
if (result.available) {
|
||||
container.innerHTML =
|
||||
'<div class="flex items-center gap-2 text-sm">' +
|
||||
'<i data-lucide="check-circle" class="w-4 h-4 text-success"></i>' +
|
||||
'<span>' + t('codexlens.semanticInstalled') + '</span>' +
|
||||
'<span class="text-muted-foreground">(' + (result.backend || 'fastembed') + ')</span>' +
|
||||
'</div>';
|
||||
} else {
|
||||
container.innerHTML =
|
||||
'<div class="space-y-2">' +
|
||||
'<div class="flex items-center gap-2 text-sm text-muted-foreground">' +
|
||||
'<i data-lucide="alert-circle" class="w-4 h-4"></i>' +
|
||||
'<span>' + t('codexlens.semanticNotInstalled') + '</span>' +
|
||||
'</div>' +
|
||||
'<button class="btn-sm btn-outline" onclick="installSemanticDeps()">' +
|
||||
'<i data-lucide="download" class="w-3 h-3"></i> ' + t('codexlens.installDeps') +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
}
|
||||
if (window.lucide) lucide.createIcons();
|
||||
} catch (err) {
|
||||
container.innerHTML =
|
||||
'<div class="text-sm text-error">' + t('common.error') + ': ' + err.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install semantic dependencies
|
||||
*/
|
||||
async function installSemanticDeps() {
|
||||
var container = document.getElementById('semanticDepsStatus');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML =
|
||||
'<div class="text-sm text-muted-foreground animate-pulse">' + t('codexlens.installingDeps') + '</div>';
|
||||
|
||||
try {
|
||||
var response = await fetch('/api/codexlens/semantic/install', { method: 'POST' });
|
||||
var result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showRefreshToast(t('codexlens.depsInstalled'), 'success');
|
||||
await loadSemanticDepsStatus();
|
||||
await loadModelList();
|
||||
} else {
|
||||
showRefreshToast(t('codexlens.depsInstallFailed') + ': ' + result.error, 'error');
|
||||
await loadSemanticDepsStatus();
|
||||
}
|
||||
} catch (err) {
|
||||
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
|
||||
await loadSemanticDepsStatus();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MODEL MANAGEMENT
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Load model list
|
||||
*/
|
||||
async function loadModelList() {
|
||||
var container = document.getElementById('modelListContainer');
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
var response = await fetch('/api/codexlens/models');
|
||||
var result = await response.json();
|
||||
|
||||
if (!result.success || !result.result || !result.result.models) {
|
||||
container.innerHTML =
|
||||
'<div class="text-sm text-muted-foreground">' + t('codexlens.semanticNotInstalled') + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var models = result.result.models;
|
||||
var html = '<div class="space-y-2">';
|
||||
|
||||
models.forEach(function(model) {
|
||||
var statusIcon = model.installed
|
||||
? '<i data-lucide="check-circle" class="w-4 h-4 text-success"></i>'
|
||||
: '<i data-lucide="circle" class="w-4 h-4 text-muted"></i>';
|
||||
|
||||
var sizeText = model.installed
|
||||
? model.actual_size_mb.toFixed(1) + ' MB'
|
||||
: '~' + model.estimated_size_mb + ' MB';
|
||||
|
||||
var actionBtn = model.installed
|
||||
? '<button class="btn-sm btn-outline btn-danger" onclick="deleteModel(\'' + model.profile + '\')">' +
|
||||
'<i data-lucide="trash-2" class="w-3 h-3"></i> ' + t('codexlens.deleteModel') +
|
||||
'</button>'
|
||||
: '<button class="btn-sm btn-outline" onclick="downloadModel(\'' + model.profile + '\')">' +
|
||||
'<i data-lucide="download" class="w-3 h-3"></i> ' + t('codexlens.downloadModel') +
|
||||
'</button>';
|
||||
|
||||
html +=
|
||||
'<div class="border rounded-lg p-3 space-y-2" id="model-' + model.profile + '">' +
|
||||
'<div class="flex items-start justify-between">' +
|
||||
'<div class="flex-1">' +
|
||||
'<div class="flex items-center gap-2 mb-1">' +
|
||||
statusIcon +
|
||||
'<span class="font-medium">' + model.profile + '</span>' +
|
||||
'<span class="text-xs text-muted-foreground">(' + model.dimensions + ' dims)</span>' +
|
||||
'</div>' +
|
||||
'<div class="text-xs text-muted-foreground mb-1">' + model.model_name + '</div>' +
|
||||
'<div class="text-xs text-muted-foreground">' + model.use_case + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="text-right">' +
|
||||
'<div class="text-xs text-muted-foreground mb-2">' + sizeText + '</div>' +
|
||||
actionBtn +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
} catch (err) {
|
||||
container.innerHTML =
|
||||
'<div class="text-sm text-error">' + t('common.error') + ': ' + err.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download model
|
||||
*/
|
||||
async function downloadModel(profile) {
|
||||
var modelCard = document.getElementById('model-' + profile);
|
||||
if (!modelCard) return;
|
||||
|
||||
var originalHTML = modelCard.innerHTML;
|
||||
modelCard.innerHTML =
|
||||
'<div class="flex items-center justify-center p-3">' +
|
||||
'<span class="text-sm text-muted-foreground animate-pulse">' + t('codexlens.downloading') + '</span>' +
|
||||
'</div>';
|
||||
|
||||
try {
|
||||
var response = await fetch('/api/codexlens/models/download', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profile: profile })
|
||||
});
|
||||
|
||||
var result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showRefreshToast(t('codexlens.modelDownloaded') + ': ' + profile, 'success');
|
||||
await loadModelList();
|
||||
} else {
|
||||
showRefreshToast(t('codexlens.modelDownloadFailed') + ': ' + result.error, 'error');
|
||||
modelCard.innerHTML = originalHTML;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
} catch (err) {
|
||||
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
|
||||
modelCard.innerHTML = originalHTML;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete model
|
||||
*/
|
||||
async function deleteModel(profile) {
|
||||
if (!confirm(t('codexlens.deleteModelConfirm') + ' ' + profile + '?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
var modelCard = document.getElementById('model-' + profile);
|
||||
if (!modelCard) return;
|
||||
|
||||
var originalHTML = modelCard.innerHTML;
|
||||
modelCard.innerHTML =
|
||||
'<div class="flex items-center justify-center p-3">' +
|
||||
'<span class="text-sm text-muted-foreground animate-pulse">' + t('codexlens.deleting') + '</span>' +
|
||||
'</div>';
|
||||
|
||||
try {
|
||||
var response = await fetch('/api/codexlens/models/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profile: profile })
|
||||
});
|
||||
|
||||
var result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showRefreshToast(t('codexlens.modelDeleted') + ': ' + profile, 'success');
|
||||
await loadModelList();
|
||||
} else {
|
||||
showRefreshToast(t('codexlens.modelDeleteFailed') + ': ' + result.error, 'error');
|
||||
modelCard.innerHTML = originalHTML;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
} catch (err) {
|
||||
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
|
||||
modelCard.innerHTML = originalHTML;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CODEXLENS ACTIONS
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Initialize CodexLens index
|
||||
*/
|
||||
function initCodexLensIndex() {
|
||||
openCliInstallWizard('codexlens');
|
||||
}
|
||||
|
||||
/**
|
||||
* Install CodexLens
|
||||
*/
|
||||
function installCodexLens() {
|
||||
openCliInstallWizard('codexlens');
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall CodexLens
|
||||
*/
|
||||
function uninstallCodexLens() {
|
||||
openCliUninstallWizard('codexlens');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean all CodexLens indexes
|
||||
*/
|
||||
async function cleanCodexLensIndexes() {
|
||||
if (!confirm(t('codexlens.cleanConfirm'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showRefreshToast(t('codexlens.cleaning'), 'info');
|
||||
|
||||
var response = await fetch('/api/codexlens/clean', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ all: true })
|
||||
});
|
||||
|
||||
var result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showRefreshToast(t('codexlens.cleanSuccess'), 'success');
|
||||
|
||||
// Refresh status
|
||||
if (typeof loadCodexLensStatus === 'function') {
|
||||
await loadCodexLensStatus();
|
||||
renderToolsSection();
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
} else {
|
||||
showRefreshToast(t('codexlens.cleanFailed') + ': ' + result.error, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,11 @@ async function renderHookManager() {
|
||||
if (statsGrid) statsGrid.style.display = 'none';
|
||||
if (searchInput) searchInput.parentElement.style.display = 'none';
|
||||
|
||||
// Always reload hook config to get latest data
|
||||
await loadHookConfig();
|
||||
// Always reload hook config and available skills to get latest data
|
||||
await Promise.all([
|
||||
loadHookConfig(),
|
||||
loadAvailableSkills()
|
||||
]);
|
||||
|
||||
const globalHooks = hookConfig.global?.hooks || {};
|
||||
const projectHooks = hookConfig.project?.hooks || {};
|
||||
|
||||
@@ -139,6 +139,27 @@ async function renderMcpManager() {
|
||||
const codexConfigExists = codexMcpConfig?.exists || false;
|
||||
const codexConfigPath = codexMcpConfig?.configPath || '~/.codex/config.toml';
|
||||
|
||||
// Collect cross-CLI servers (servers from other CLI not yet in current CLI)
|
||||
const crossCliServers = [];
|
||||
if (currentCliMode === 'claude') {
|
||||
// In Claude mode, show Codex servers that aren't in Claude
|
||||
for (const [name, config] of Object.entries(codexMcpServers || {})) {
|
||||
const existsInClaude = currentProjectServerNames.includes(name) || globalServerNames.includes(name);
|
||||
if (!existsInClaude) {
|
||||
crossCliServers.push({ name, config, fromCli: 'codex' });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// In Codex mode, show Claude servers that aren't in Codex
|
||||
const allClaudeServers = { ...mcpUserServers, ...projectServers };
|
||||
for (const [name, config] of Object.entries(allClaudeServers)) {
|
||||
const existsInCodex = codexMcpServers && codexMcpServers[name];
|
||||
if (!existsInCodex) {
|
||||
crossCliServers.push({ name, config, fromCli: 'claude' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="mcp-manager">
|
||||
<!-- CLI Mode Toggle -->
|
||||
@@ -321,7 +342,7 @@ async function renderMcpManager() {
|
||||
` : ''}
|
||||
|
||||
<!-- Available MCP Servers from Other Projects (Codex mode) -->
|
||||
<div class="mcp-section">
|
||||
<div class="mcp-section mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-foreground">${t('mcp.availableOther')}</h3>
|
||||
<span class="text-sm text-muted-foreground">${otherProjectServers.length} ${t('mcp.serversAvailable')}</span>
|
||||
@@ -339,14 +360,30 @@ async function renderMcpManager() {
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- Cross-CLI Servers: Available from Claude (Codex mode) -->
|
||||
${crossCliServers.length > 0 ? `
|
||||
<div class="mcp-section">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<i data-lucide="circle" class="w-5 h-5 text-blue-500"></i>
|
||||
${t('mcp.codex.copyFromClaude')}
|
||||
</h3>
|
||||
<span class="text-sm text-muted-foreground">${crossCliServers.length} ${t('mcp.serversAvailable')}</span>
|
||||
</div>
|
||||
<div class="mcp-server-grid grid gap-3">
|
||||
${crossCliServers.map(server => renderCrossCliServerCard(server, false)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
` : `
|
||||
<!-- CCW Tools MCP Server Card -->
|
||||
<div class="mcp-section mb-6">
|
||||
<div class="ccw-tools-card bg-gradient-to-br from-primary/10 to-primary/5 border-2 ${isCcwToolsInstalled ? 'border-success' : 'border-primary/30'} rounded-lg p-6 hover:shadow-lg transition-all">
|
||||
<div class="ccw-tools-card bg-gradient-to-br from-orange-500/10 to-orange-500/5 border-2 ${isCcwToolsInstalled ? 'border-success' : 'border-orange-500/30'} rounded-lg p-6 hover:shadow-lg transition-all">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-start gap-4 flex-1">
|
||||
<div class="shrink-0 w-12 h-12 bg-primary rounded-lg flex items-center justify-center">
|
||||
<i data-lucide="wrench" class="w-6 h-6 text-primary-foreground"></i>
|
||||
<div class="shrink-0 w-12 h-12 bg-orange-500 rounded-lg flex items-center justify-center">
|
||||
<i data-lucide="wrench" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
@@ -357,7 +394,7 @@ async function renderMcpManager() {
|
||||
${enabledTools.length} tools
|
||||
</span>
|
||||
` : `
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-semibold rounded-full bg-primary/20 text-primary">
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-semibold rounded-full bg-orange-500/20 text-orange-600 dark:text-orange-400">
|
||||
<i data-lucide="package" class="w-3 h-3"></i>
|
||||
Available
|
||||
</span>
|
||||
@@ -375,15 +412,15 @@ async function renderMcpManager() {
|
||||
`).join('')}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<button class="text-primary hover:underline" onclick="selectCcwTools('core')">Core only</button>
|
||||
<button class="text-primary hover:underline" onclick="selectCcwTools('all')">All</button>
|
||||
<button class="text-orange-500 hover:underline" onclick="selectCcwTools('core')">Core only</button>
|
||||
<button class="text-orange-500 hover:underline" onclick="selectCcwTools('all')">All</button>
|
||||
<button class="text-muted-foreground hover:underline" onclick="selectCcwTools('none')">None</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 flex gap-2">
|
||||
${isCcwToolsInstalled ? `
|
||||
<button class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1"
|
||||
<button class="px-4 py-2 text-sm bg-orange-500 text-white rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1"
|
||||
onclick="updateCcwToolsMcp('workspace')"
|
||||
title="${t('mcp.updateInWorkspace')}">
|
||||
<i data-lucide="folder" class="w-4 h-4"></i>
|
||||
@@ -396,7 +433,7 @@ async function renderMcpManager() {
|
||||
${t('mcp.updateInGlobal')}
|
||||
</button>
|
||||
` : `
|
||||
<button class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1"
|
||||
<button class="px-4 py-2 text-sm bg-orange-500 text-white rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1"
|
||||
onclick="installCcwToolsMcp('workspace')"
|
||||
title="${t('mcp.installToWorkspace')}">
|
||||
<i data-lucide="folder" class="w-4 h-4"></i>
|
||||
@@ -485,7 +522,7 @@ async function renderMcpManager() {
|
||||
</div>
|
||||
|
||||
<!-- Available MCP Servers from Other Projects -->
|
||||
<div class="mcp-section">
|
||||
<div class="mcp-section mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-foreground">${t('mcp.availableOther')}</h3>
|
||||
<span class="text-sm text-muted-foreground">${otherProjectServers.length} ${t('mcp.serversAvailable')}</span>
|
||||
@@ -504,6 +541,22 @@ async function renderMcpManager() {
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- Cross-CLI Servers: Available from Codex (Claude mode) -->
|
||||
${crossCliServers.length > 0 ? `
|
||||
<div class="mcp-section mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<i data-lucide="circle-dashed" class="w-5 h-5 text-orange-500"></i>
|
||||
${t('mcp.claude.copyFromCodex')}
|
||||
</h3>
|
||||
<span class="text-sm text-muted-foreground">${crossCliServers.length} ${t('mcp.serversAvailable')}</span>
|
||||
</div>
|
||||
<div class="mcp-server-grid grid gap-3">
|
||||
${crossCliServers.map(server => renderCrossCliServerCard(server, true)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- MCP Templates Section -->
|
||||
${mcpTemplates.length > 0 ? `
|
||||
<div class="mcp-section mt-6">
|
||||
@@ -1010,6 +1063,15 @@ function renderAvailableServerCardForCodex(serverName, serverInfo) {
|
||||
${sourceProjectName ? `<span class="text-xs text-muted-foreground/70">• ${t('mcp.from')} ${escapeHtml(sourceProjectName)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 pt-3 border-t border-border flex items-center gap-2">
|
||||
<button class="text-xs text-orange-500 hover:text-orange-600 transition-colors flex items-center gap-1"
|
||||
onclick="copyClaudeServerToCodex('${escapeHtml(originalName)}', ${JSON.stringify(serverConfig).replace(/'/g, "'")})"
|
||||
title="${t('mcp.codex.copyToCodex')}">
|
||||
<i data-lucide="download" class="w-3 h-3"></i>
|
||||
${t('mcp.codex.install')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1098,6 +1160,104 @@ function renderCodexServerCard(serverName, serverConfig) {
|
||||
`;
|
||||
}
|
||||
|
||||
// Render card for cross-CLI servers (servers from other CLI not in current CLI)
|
||||
function renderCrossCliServerCard(server, isClaude) {
|
||||
const { name, config, fromCli } = server;
|
||||
const isStdio = !!config.command;
|
||||
const isHttp = !!config.url;
|
||||
const command = config.command || config.url || 'N/A';
|
||||
const args = config.args || [];
|
||||
|
||||
// Icon and color based on source CLI
|
||||
const icon = fromCli === 'codex' ? 'circle-dashed' : 'circle';
|
||||
const iconColor = fromCli === 'codex' ? 'orange' : 'blue';
|
||||
const sourceBadgeColor = fromCli === 'codex' ? 'orange' : 'primary';
|
||||
const targetCli = isClaude ? 'project' : 'codex';
|
||||
const buttonText = isClaude ? t('mcp.codex.copyToClaude') : t('mcp.claude.copyToCodex');
|
||||
const typeBadge = isHttp
|
||||
? `<span class="text-xs px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">HTTP</span>`
|
||||
: `<span class="text-xs px-2 py-0.5 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 rounded-full">STDIO</span>`;
|
||||
|
||||
return `
|
||||
<div class="mcp-server-card bg-card border border-dashed border-${iconColor}-200 dark:border-${iconColor}-800 rounded-lg p-4 hover:shadow-md hover:border-solid transition-all">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="shrink-0">
|
||||
<i data-lucide="${icon}" class="w-5 h-5 text-${iconColor}-500"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2 flex-wrap mb-1">
|
||||
<h4 class="font-semibold text-foreground">${escapeHtml(name)}</h4>
|
||||
<span class="text-xs px-2 py-0.5 bg-${sourceBadgeColor}/10 text-${sourceBadgeColor} rounded-full">
|
||||
${fromCli === 'codex' ? 'Codex' : 'Claude'}
|
||||
</span>
|
||||
${typeBadge}
|
||||
</div>
|
||||
<div class="text-sm space-y-1 text-muted-foreground">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">${isHttp ? t('mcp.url') : t('mcp.cmd')}</span>
|
||||
<span class="truncate text-xs" title="${escapeHtml(command)}">${escapeHtml(command)}</span>
|
||||
</div>
|
||||
${args.length > 0 ? `
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">${t('mcp.args')}</span>
|
||||
<span class="text-xs font-mono truncate" title="${escapeHtml(args.join(' '))}">${escapeHtml(args.slice(0, 3).join(' '))}${args.length > 3 ? '...' : ''}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 pt-3 border-t border-border">
|
||||
<button class="w-full px-3 py-2 text-sm font-medium bg-${iconColor}-500 hover:bg-${iconColor}-600 text-white rounded-lg transition-colors flex items-center justify-center gap-1.5"
|
||||
onclick="copyCrossCliServer('${escapeHtml(name)}', ${JSON.stringify(config).replace(/'/g, "'")}, '${fromCli}', '${targetCli}')">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
${buttonText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Copy server from one CLI to another
|
||||
async function copyCrossCliServer(name, config, fromCli, targetCli) {
|
||||
try {
|
||||
let endpoint, body;
|
||||
|
||||
if (targetCli === 'codex') {
|
||||
// Copy from Claude to Codex
|
||||
endpoint = '/api/codex-mcp-add';
|
||||
body = { serverName: name, serverConfig: config };
|
||||
} else if (targetCli === 'project') {
|
||||
// Copy from Codex to Claude project
|
||||
endpoint = '/api/mcp-copy-server';
|
||||
body = { projectPath, serverName: name, serverConfig: config, configType: 'mcp' };
|
||||
} else if (targetCli === 'global') {
|
||||
// Copy to Claude global
|
||||
endpoint = '/api/mcp-add-global-server';
|
||||
body = { serverName: name, serverConfig: config };
|
||||
}
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const targetName = targetCli === 'codex' ? 'Codex' : 'Claude';
|
||||
showToast(t('mcp.success'), `${t('mcp.serverInstalled')} (${targetName})`, 'success');
|
||||
await loadMcpConfig();
|
||||
renderMcpManager();
|
||||
} else {
|
||||
showToast(t('mcp.error'), data.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('mcp.error'), error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Codex MCP Create Modal
|
||||
// ========================================
|
||||
|
||||
1729
ccw/src/templates/dashboard-js/views/mcp-manager.js.backup
Normal file
1729
ccw/src/templates/dashboard-js/views/mcp-manager.js.backup
Normal file
File diff suppressed because it is too large
Load Diff
928
ccw/src/templates/dashboard-js/views/mcp-manager.js.new
Normal file
928
ccw/src/templates/dashboard-js/views/mcp-manager.js.new
Normal file
@@ -0,0 +1,928 @@
|
||||
// MCP Manager View - Redesigned with Sectioned Layout
|
||||
// Comprehensive MCP management for Claude and Codex with clear section separation
|
||||
|
||||
// ============================================================
|
||||
// CONSTANTS & CONFIGURATION
|
||||
// ============================================================
|
||||
|
||||
const CCW_MCP_TOOLS = [
|
||||
{ name: 'write_file', desc: 'Write/create files', core: true },
|
||||
{ name: 'edit_file', desc: 'Edit/replace content', core: true },
|
||||
{ name: 'codex_lens', desc: 'Code index & search', core: true },
|
||||
{ name: 'smart_search', desc: 'Quick regex/NL search', core: true },
|
||||
{ name: 'session_manager', desc: 'Workflow sessions', core: false },
|
||||
{ name: 'generate_module_docs', desc: 'Generate docs', core: false },
|
||||
{ name: 'update_module_claude', desc: 'Update CLAUDE.md', core: false },
|
||||
{ name: 'cli_executor', desc: 'Gemini/Qwen/Codex CLI', core: false },
|
||||
];
|
||||
|
||||
const MCP_CATEGORIES = [
|
||||
'Development Tools',
|
||||
'Data & APIs',
|
||||
'Files & Storage',
|
||||
'AI & ML',
|
||||
'DevOps',
|
||||
'Custom'
|
||||
];
|
||||
|
||||
// Get currently enabled tools from installed config (Claude)
|
||||
function getCcwEnabledTools() {
|
||||
const currentPath = projectPath;
|
||||
const projectData = mcpAllProjects[currentPath] || {};
|
||||
const ccwConfig = projectData.mcpServers?.['ccw-tools'];
|
||||
if (ccwConfig?.env?.CCW_ENABLED_TOOLS) {
|
||||
const val = ccwConfig.env.CCW_ENABLED_TOOLS;
|
||||
if (val.toLowerCase() === 'all') return CCW_MCP_TOOLS.map(t => t.name);
|
||||
return val.split(',').map(t => t.trim());
|
||||
}
|
||||
return CCW_MCP_TOOLS.filter(t => t.core).map(t => t.name);
|
||||
}
|
||||
|
||||
// Get currently enabled tools from Codex config
|
||||
function getCcwEnabledToolsCodex() {
|
||||
const ccwConfig = codexMcpServers?.['ccw-tools'];
|
||||
if (ccwConfig?.env?.CCW_ENABLED_TOOLS) {
|
||||
const val = ccwConfig.env.CCW_ENABLED_TOOLS;
|
||||
if (val.toLowerCase() === 'all') return CCW_MCP_TOOLS.map(t => t.name);
|
||||
return val.split(',').map(t => t.trim());
|
||||
}
|
||||
return CCW_MCP_TOOLS.filter(t => t.core).map(t => t.name);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MODAL DIALOG COMPONENT
|
||||
// ============================================================
|
||||
|
||||
function showMcpEditorModal(options = {}) {
|
||||
const {
|
||||
mode = 'create',
|
||||
serverName = '',
|
||||
serverConfig = {},
|
||||
template = null,
|
||||
cliMode = currentCliMode, // 'claude' or 'codex'
|
||||
installTargets = cliMode === 'codex' ? ['codex'] : ['project', 'global']
|
||||
} = options;
|
||||
|
||||
const isView = mode === 'view';
|
||||
const isEdit = mode === 'edit';
|
||||
const title = isView ? t('mcp.viewServer') : isEdit ? t('mcp.editServer') : t('mcp.createServer');
|
||||
|
||||
const initialName = serverName || template?.name || '';
|
||||
const initialDesc = template?.description || '';
|
||||
const initialCategory = template?.category || 'Development Tools';
|
||||
const initialConfig = serverConfig || template?.serverConfig || {
|
||||
command: '',
|
||||
args: [],
|
||||
env: {},
|
||||
url: '',
|
||||
cwd: ''
|
||||
};
|
||||
|
||||
const modalHtml = `
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" id="mcpEditorModal" style="backdrop-filter: blur(4px);">
|
||||
<div class="bg-card border border-border rounded-xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-border bg-gradient-to-r from-primary/5 to-transparent">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<i data-lucide="${isView ? 'eye' : isEdit ? 'edit-3' : 'plus-circle'}" class="w-5 h-5 text-primary"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-foreground">${title}</h2>
|
||||
<p class="text-xs text-muted-foreground">${cliMode === 'codex' ? 'Codex MCP Server' : 'Claude MCP Server'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="closeMcpEditorModal()" class="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div class="space-y-4 mb-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1.5">${t('mcp.serverName')}</label>
|
||||
<input type="text" id="mcpModalName" value="${initialName}" ${isView ? 'disabled' : ''}
|
||||
placeholder="my-mcp-server"
|
||||
class="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50 disabled:cursor-not-allowed" />
|
||||
</div>
|
||||
|
||||
${!isView && cliMode !== 'codex' ? `
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1.5">${t('mcp.description')} (${t('mcp.optional')})</label>
|
||||
<input type="text" id="mcpModalDesc" value="${initialDesc}" placeholder="Brief description"
|
||||
class="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50" />
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center gap-2 border-b border-border">
|
||||
<button class="px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary"
|
||||
onclick="switchMcpServerType('stdio')" id="mcpTypeStdio" ${isView ? 'disabled' : ''}>
|
||||
<i data-lucide="terminal" class="w-4 h-4 inline mr-1.5"></i>
|
||||
STDIO (Command)
|
||||
</button>
|
||||
<button class="px-4 py-2 text-sm font-medium border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
||||
onclick="switchMcpServerType('http')" id="mcpTypeHttp" ${isView ? 'disabled' : ''}>
|
||||
<i data-lucide="globe" class="w-4 h-4 inline mr-1.5"></i>
|
||||
HTTP (URL)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mcpStdioConfig" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1.5">${t('mcp.command')}</label>
|
||||
<input type="text" id="mcpModalCommand" value="${initialConfig.command || ''}" ${isView ? 'disabled' : ''}
|
||||
placeholder="node" class="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50 disabled:cursor-not-allowed font-mono text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1.5">${t('mcp.args')} (${t('mcp.optional')})</label>
|
||||
<textarea id="mcpModalArgs" ${isView ? 'disabled' : ''} rows="3" placeholder='["/path/to/server.js"]'
|
||||
class="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50 disabled:cursor-not-allowed font-mono text-sm">${JSON.stringify(initialConfig.args || [], null, 2)}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1.5">${t('mcp.env')} (${t('mcp.optional')})</label>
|
||||
<textarea id="mcpModalEnv" ${isView ? 'disabled' : ''} rows="4" placeholder='{"API_KEY": "your-key"}'
|
||||
class="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50 disabled:cursor-not-allowed font-mono text-sm">${JSON.stringify(initialConfig.env || {}, null, 2)}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1.5">${t('mcp.cwd')} (${t('mcp.optional')})</label>
|
||||
<input type="text" id="mcpModalCwd" value="${initialConfig.cwd || ''}" ${isView ? 'disabled' : ''}
|
||||
placeholder="/path/to/working/directory" class="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50 disabled:cursor-not-allowed font-mono text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mcpHttpConfig" class="space-y-4 hidden">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1.5">${t('mcp.url')}</label>
|
||||
<input type="text" id="mcpModalUrl" value="${initialConfig.url || ''}" ${isView ? 'disabled' : ''}
|
||||
placeholder="https://api.example.com/mcp" class="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50 disabled:cursor-not-allowed font-mono text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1.5">${t('mcp.httpHeaders')} (${t('mcp.optional')})</label>
|
||||
<textarea id="mcpModalHttpHeaders" ${isView ? 'disabled' : ''} rows="4" placeholder='{"Authorization": "Bearer token"}'
|
||||
class="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50 disabled:cursor-not-allowed font-mono text-sm">${JSON.stringify(initialConfig.http_headers || initialConfig.httpHeaders || {}, null, 2)}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${!isView && cliMode !== 'codex' ? `
|
||||
<div class="mt-6 pt-6 border-t border-border">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" id="mcpModalSaveTemplate" class="w-4 h-4 rounded border-border text-primary focus:ring-2 focus:ring-primary/50" />
|
||||
<span class="text-sm font-medium text-foreground">${t('mcp.saveAsTemplate')}</span>
|
||||
</label>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between px-6 py-4 border-t border-border bg-muted/30">
|
||||
${!isView ? `
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-foreground">${t('mcp.installTo')}:</span>
|
||||
<select id="mcpModalTarget" class="px-3 py-1.5 bg-background border border-border rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50">
|
||||
${installTargets.map(target => {
|
||||
const labels = {
|
||||
project: 'Project (.mcp.json)',
|
||||
global: 'Global (~/.claude.json)',
|
||||
codex: 'Codex (~/.codex/config.toml)'
|
||||
};
|
||||
return `<option value="${target}">${labels[target]}</option>`;
|
||||
}).join('')}
|
||||
</select>
|
||||
</div>
|
||||
` : '<div></div>'}
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="closeMcpEditorModal()" class="px-4 py-2 text-sm font-medium text-foreground hover:bg-muted rounded-lg transition-colors">
|
||||
${isView ? t('mcp.close') : t('mcp.cancel')}
|
||||
</button>
|
||||
${!isView ? `
|
||||
<button onclick="saveMcpFromModal('${cliMode}')" class="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center gap-2">
|
||||
<i data-lucide="save" class="w-4 h-4"></i>
|
||||
${isEdit ? t('mcp.update') : t('mcp.install')}
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const existingModal = document.getElementById('mcpEditorModal');
|
||||
if (existingModal) existingModal.remove();
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
if (initialConfig.url) switchMcpServerType('http');
|
||||
}
|
||||
|
||||
function switchMcpServerType(type) {
|
||||
const stdioConfig = document.getElementById('mcpStdioConfig');
|
||||
const httpConfig = document.getElementById('mcpHttpConfig');
|
||||
const stdioBtn = document.getElementById('mcpTypeStdio');
|
||||
const httpBtn = document.getElementById('mcpTypeHttp');
|
||||
|
||||
if (type === 'stdio') {
|
||||
stdioConfig.classList.remove('hidden');
|
||||
httpConfig.classList.add('hidden');
|
||||
stdioBtn.classList.add('border-primary', 'text-primary');
|
||||
stdioBtn.classList.remove('border-transparent', 'text-muted-foreground');
|
||||
httpBtn.classList.remove('border-primary', 'text-primary');
|
||||
httpBtn.classList.add('border-transparent', 'text-muted-foreground');
|
||||
} else {
|
||||
stdioConfig.classList.add('hidden');
|
||||
httpConfig.classList.remove('hidden');
|
||||
httpBtn.classList.add('border-primary', 'text-primary');
|
||||
httpBtn.classList.remove('border-transparent', 'text-muted-foreground');
|
||||
stdioBtn.classList.remove('border-primary', 'text-primary');
|
||||
stdioBtn.classList.add('border-transparent', 'text-muted-foreground');
|
||||
}
|
||||
}
|
||||
|
||||
function closeMcpEditorModal() {
|
||||
const modal = document.getElementById('mcpEditorModal');
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
|
||||
async function saveMcpFromModal(cliMode) {
|
||||
const name = document.getElementById('mcpModalName').value.trim();
|
||||
const desc = document.getElementById('mcpModalDesc')?.value.trim() || '';
|
||||
const target = document.getElementById('mcpModalTarget')?.value || (cliMode === 'codex' ? 'codex' : 'project');
|
||||
const saveAsTemplate = document.getElementById('mcpModalSaveTemplate')?.checked || false;
|
||||
|
||||
if (!name) {
|
||||
showToast(t('mcp.error'), t('mcp.nameRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const isStdio = !document.getElementById('mcpStdioConfig').classList.contains('hidden');
|
||||
let serverConfig = {};
|
||||
|
||||
if (isStdio) {
|
||||
const command = document.getElementById('mcpModalCommand').value.trim();
|
||||
if (!command) {
|
||||
showToast(t('mcp.error'), t('mcp.commandRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
serverConfig.command = command;
|
||||
|
||||
const argsText = document.getElementById('mcpModalArgs').value.trim();
|
||||
if (argsText) {
|
||||
try {
|
||||
serverConfig.args = JSON.parse(argsText);
|
||||
if (!Array.isArray(serverConfig.args)) throw new Error('Args must be an array');
|
||||
} catch (e) {
|
||||
showToast(t('mcp.error'), t('mcp.invalidArgsJson'), 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envText = document.getElementById('mcpModalEnv').value.trim();
|
||||
if (envText) {
|
||||
try {
|
||||
serverConfig.env = JSON.parse(envText);
|
||||
if (typeof serverConfig.env !== 'object' || Array.isArray(serverConfig.env)) throw new Error('Env must be an object');
|
||||
} catch (e) {
|
||||
showToast(t('mcp.error'), t('mcp.invalidEnvJson'), 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const cwd = document.getElementById('mcpModalCwd').value.trim();
|
||||
if (cwd) serverConfig.cwd = cwd;
|
||||
} else {
|
||||
const url = document.getElementById('mcpModalUrl').value.trim();
|
||||
if (!url) {
|
||||
showToast(t('mcp.error'), t('mcp.urlRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
serverConfig.url = url;
|
||||
|
||||
const headersText = document.getElementById('mcpModalHttpHeaders').value.trim();
|
||||
if (headersText) {
|
||||
try {
|
||||
const headers = JSON.parse(headersText);
|
||||
if (typeof headers !== 'object' || Array.isArray(headers)) throw new Error('Headers must be an object');
|
||||
serverConfig.http_headers = headers;
|
||||
} catch (e) {
|
||||
showToast(t('mcp.error'), t('mcp.invalidHeadersJson'), 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (saveAsTemplate && cliMode !== 'codex') {
|
||||
try {
|
||||
await fetch('/api/mcp-templates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description: desc, serverConfig, category: 'Custom' })
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving template:', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let endpoint = '';
|
||||
let body = {};
|
||||
|
||||
if (cliMode === 'codex') {
|
||||
endpoint = '/api/codex-mcp-add';
|
||||
body = { serverName: name, serverConfig };
|
||||
} else {
|
||||
if (target === 'global') {
|
||||
endpoint = '/api/mcp-add-global-server';
|
||||
body = { serverName: name, serverConfig };
|
||||
} else {
|
||||
endpoint = '/api/mcp-copy-server';
|
||||
body = { projectPath, serverName: name, serverConfig, configType: 'mcp' };
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success || data.serverName) {
|
||||
showToast(t('mcp.success'), t('mcp.serverInstalled'), 'success');
|
||||
closeMcpEditorModal();
|
||||
await loadMcpConfig();
|
||||
renderMcpManager();
|
||||
} else {
|
||||
showToast(t('mcp.error'), data.error || 'Installation failed', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error installing MCP server:', error);
|
||||
showToast(t('mcp.error'), error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MAIN RENDER FUNCTION
|
||||
// ============================================================
|
||||
|
||||
async function renderMcpManager() {
|
||||
const container = document.getElementById('mainContent');
|
||||
if (!container) return;
|
||||
|
||||
const statsGrid = document.getElementById('statsGrid');
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (statsGrid) statsGrid.style.display = 'none';
|
||||
if (searchInput) searchInput.parentElement.style.display = 'none';
|
||||
|
||||
if (!mcpConfig) await loadMcpConfig();
|
||||
await loadMcpTemplates();
|
||||
|
||||
const currentPath = projectPath;
|
||||
const projectData = mcpAllProjects[currentPath] || {};
|
||||
const projectServers = projectData.mcpServers || {};
|
||||
const disabledServers = projectData.disabledMcpServers || [];
|
||||
const codexServers = codexMcpServers || {};
|
||||
const isClaude = currentCliMode === 'claude';
|
||||
|
||||
// Section 1: Project Available (Enterprise + Global + Project-specific)
|
||||
const projectAvailable = [];
|
||||
|
||||
if (isClaude) {
|
||||
// Enterprise servers
|
||||
for (const [name, config] of Object.entries(mcpEnterpriseServers || {})) {
|
||||
projectAvailable.push({ name, config, source: 'enterprise', enabled: true, canRemove: false, canToggle: false });
|
||||
}
|
||||
// Global servers
|
||||
for (const [name, config] of Object.entries(mcpUserServers || {})) {
|
||||
if (!mcpEnterpriseServers?.[name]) {
|
||||
projectAvailable.push({ name, config, source: 'global', enabled: !disabledServers.includes(name), canRemove: false, canToggle: true });
|
||||
}
|
||||
}
|
||||
// Project servers
|
||||
for (const [name, config] of Object.entries(projectServers)) {
|
||||
if (!mcpEnterpriseServers?.[name] && !mcpUserServers?.[name]) {
|
||||
projectAvailable.push({ name, config, source: 'project', enabled: !disabledServers.includes(name), canRemove: true, canToggle: true });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Codex servers
|
||||
for (const [name, config] of Object.entries(codexServers)) {
|
||||
projectAvailable.push({ name, config, source: 'codex', enabled: config.enabled !== false, canRemove: true, canToggle: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Section 2: Global Management (for Claude only)
|
||||
const globalManagement = isClaude ? Object.entries(mcpUserServers || {}) : [];
|
||||
|
||||
// Section 3: Other Projects (for Claude only)
|
||||
const allAvailableServers = isClaude ? getAllAvailableMcpServers() : {};
|
||||
const currentProjectServerNames = Object.keys(projectServers);
|
||||
const otherProjects = isClaude ? Object.entries(allAvailableServers).filter(([name, info]) => !currentProjectServerNames.includes(name) && !info.isGlobal) : [];
|
||||
|
||||
// Section 4: Cross-CLI servers (Available from other CLI)
|
||||
const crossCliServers = [];
|
||||
if (isClaude) {
|
||||
// Show Codex servers when in Claude mode
|
||||
for (const [name, config] of Object.entries(codexServers)) {
|
||||
// Check if already exists in Claude (project or global)
|
||||
const existsInClaude = currentProjectServerNames.includes(name) || mcpUserServers?.[name];
|
||||
if (!existsInClaude) {
|
||||
crossCliServers.push({ name, config, fromCli: 'codex' });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Show Claude servers when in Codex mode
|
||||
// Collect all Claude servers (global + project)
|
||||
const allClaudeServers = { ...mcpUserServers, ...projectServers };
|
||||
for (const [name, config] of Object.entries(allClaudeServers)) {
|
||||
// Check if already exists in Codex
|
||||
const existsInCodex = codexServers[name];
|
||||
if (!existsInCodex) {
|
||||
crossCliServers.push({ name, config, fromCli: 'claude' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="mcp-manager">
|
||||
<!-- CLI Mode Toggle -->
|
||||
<div class="mcp-cli-toggle mb-6">
|
||||
<div class="flex items-center justify-between bg-card border border-border rounded-lg p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium text-foreground">${t('mcp.cliMode')}</span>
|
||||
<div class="flex items-center bg-muted rounded-lg p-1">
|
||||
<button class="cli-mode-btn px-4 py-2 text-sm font-medium rounded-md transition-all ${isClaude ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}"
|
||||
onclick="setCliMode('claude')">
|
||||
<i data-lucide="bot" class="w-4 h-4 inline mr-1.5"></i>
|
||||
Claude
|
||||
</button>
|
||||
<button class="cli-mode-btn px-4 py-2 text-sm font-medium rounded-md transition-all ${!isClaude ? 'shadow-sm' : 'text-muted-foreground hover:text-foreground'}"
|
||||
onclick="setCliMode('codex')"
|
||||
style="${!isClaude ? 'background-color: #f97316; color: white;' : ''}">
|
||||
<i data-lucide="code-2" class="w-4 h-4 inline mr-1.5"></i>
|
||||
Codex
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick="renderMcpTemplates()" class="px-4 py-2 text-sm font-medium bg-muted hover:bg-muted/80 text-foreground rounded-lg transition-colors flex items-center gap-2">
|
||||
<i data-lucide="bookmark" class="w-4 h-4"></i>
|
||||
${t('mcp.templates')}
|
||||
</button>
|
||||
<button onclick="showMcpEditorModal({ mode: 'create', cliMode: '${currentCliMode}' })"
|
||||
class="px-4 py-2 text-sm font-medium ${isClaude ? 'bg-primary hover:bg-primary/90 text-primary-foreground' : 'bg-orange-500 hover:bg-orange-600 text-white'} rounded-lg transition-colors flex items-center gap-2">
|
||||
<i data-lucide="plus-circle" class="w-4 h-4"></i>
|
||||
${t('mcp.newServer')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 1: Current Project Available -->
|
||||
<div class="mcp-section mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<i data-lucide="folder-check" class="w-5 h-5"></i>
|
||||
${isClaude ? t('mcp.projectAvailable') : 'Codex Global MCP Servers'}
|
||||
<span class="text-sm text-muted-foreground font-normal">(${projectAvailable.length})</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
${projectAvailable.length === 0 ? `
|
||||
<div class="bg-card border border-dashed border-border rounded-lg p-8 text-center">
|
||||
<i data-lucide="inbox" class="w-12 h-12 text-muted-foreground mx-auto mb-3"></i>
|
||||
<p class="text-sm text-muted-foreground">${isClaude ? t('mcp.noMcpServers') : 'No Codex MCP servers configured'}</p>
|
||||
</div>
|
||||
` : projectAvailable.map(server => renderMcpServerCard(server, isClaude ? 'claude' : 'codex')).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${isClaude ? `
|
||||
<!-- Section 2: Global Management -->
|
||||
${globalManagement.length > 0 ? `
|
||||
<div class="mcp-section mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<i data-lucide="globe" class="w-5 h-5"></i>
|
||||
${t('mcp.user')}
|
||||
<span class="text-sm text-muted-foreground font-normal">(${globalManagement.length})</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
${globalManagement.map(([name, config]) => renderMcpServerCard({ name, config, source: 'global-manage', enabled: true, canRemove: true, canToggle: false }, 'claude')).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Section 3: Other Projects -->
|
||||
${otherProjects.length > 0 ? `
|
||||
<div class="mcp-section mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<i data-lucide="folder-open" class="w-5 h-5"></i>
|
||||
${t('mcp.availableOther')}
|
||||
<span class="text-sm text-muted-foreground font-normal">(${otherProjects.length})</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
${otherProjects.map(([name, info]) => renderMcpServerCardAvailable(name, info)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Section 4: Cross-CLI Servers -->
|
||||
${crossCliServers.length > 0 ? `
|
||||
<div class="mcp-section mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
${isClaude ? `
|
||||
<i data-lucide="circle-dashed" class="w-5 h-5 text-orange-500"></i>
|
||||
${t('mcp.claude.copyFromCodex')}
|
||||
` : `
|
||||
<i data-lucide="circle" class="w-5 h-5 text-blue-500"></i>
|
||||
${t('mcp.codex.copyFromClaude')}
|
||||
`}
|
||||
<span class="text-sm text-muted-foreground font-normal">(${crossCliServers.length})</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
${crossCliServers.map(server => renderCrossCliServerCard(server, isClaude)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
|
||||
function renderMcpServerCard(server, cliMode) {
|
||||
const { name, config, source, enabled, canRemove, canToggle } = server;
|
||||
|
||||
const sourceInfo = {
|
||||
enterprise: { icon: 'shield', color: 'purple', label: 'Enterprise' },
|
||||
global: { icon: 'globe', color: 'green', label: 'Global' },
|
||||
'global-manage': { icon: 'globe', color: 'green', label: 'Global' },
|
||||
project: { icon: 'folder', color: 'blue', label: 'Project' },
|
||||
codex: { icon: 'code-2', color: 'orange', label: 'Codex' }
|
||||
};
|
||||
|
||||
const info = sourceInfo[source] || sourceInfo.project;
|
||||
const isStdio = !!config.command;
|
||||
const isHttp = !!config.url;
|
||||
|
||||
return `
|
||||
<div class="bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all ${!enabled ? 'opacity-60' : ''}">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-start gap-3 flex-1 min-w-0">
|
||||
<div class="shrink-0 w-10 h-10 rounded-lg bg-${info.color}-500/10 flex items-center justify-center">
|
||||
<i data-lucide="${info.icon}" class="w-5 h-5 text-${info.color}-500"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h3 class="font-semibold text-foreground truncate">${name}</h3>
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-${info.color}-500/10 text-${info.color}-600 dark:text-${info.color}-400">
|
||||
${info.label}
|
||||
</span>
|
||||
${enabled ? `
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-success/10 text-success">
|
||||
<i data-lucide="check" class="w-3 h-3"></i>
|
||||
Enabled
|
||||
</span>
|
||||
` : `
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-muted text-muted-foreground">
|
||||
<i data-lucide="x" class="w-3 h-3"></i>
|
||||
Disabled
|
||||
</span>
|
||||
`}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground space-y-1">
|
||||
${isStdio ? `
|
||||
<div class="flex items-center gap-2">
|
||||
<i data-lucide="terminal" class="w-3 h-3"></i>
|
||||
<code class="text-xs">${config.command} ${(config.args || []).slice(0, 2).join(' ')}</code>
|
||||
</div>
|
||||
` : ''}
|
||||
${isHttp ? `
|
||||
<div class="flex items-center gap-2">
|
||||
<i data-lucide="globe" class="w-3 h-3"></i>
|
||||
<code class="text-xs truncate">${config.url}</code>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
${canToggle ? `
|
||||
<button onclick="toggleMcpServer('${name}', '${cliMode}', ${!enabled})" class="p-2 rounded-lg hover:bg-muted transition-colors" title="${enabled ? 'Disable' : 'Enable'}">
|
||||
<i data-lucide="${enabled ? 'toggle-right' : 'toggle-left'}" class="w-4 h-4 text-${enabled ? 'success' : 'muted-foreground'}"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<button onclick="showMcpEditorModal({ mode: 'view', serverName: '${name}', serverConfig: ${JSON.stringify(config).replace(/"/g, '"')}, cliMode: '${cliMode}' })" class="p-2 rounded-lg hover:bg-muted transition-colors">
|
||||
<i data-lucide="eye" class="w-4 h-4 text-foreground"></i>
|
||||
</button>
|
||||
${canRemove && source !== 'global-manage' ? `
|
||||
<button onclick="showMcpEditorModal({ mode: 'edit', serverName: '${name}', serverConfig: ${JSON.stringify(config).replace(/"/g, '"')}, cliMode: '${cliMode}' })" class="p-2 rounded-lg hover:bg-muted transition-colors">
|
||||
<i data-lucide="edit-3" class="w-4 h-4 text-foreground"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
${canRemove ? `
|
||||
<button onclick="deleteMcpServer('${name}', '${source}', '${cliMode}')" class="p-2 rounded-lg hover:bg-destructive/10 transition-colors">
|
||||
<i data-lucide="trash-2" class="w-4 h-4 text-destructive"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMcpServerCardAvailable(name, info) {
|
||||
return `
|
||||
<div class="bg-card border border-dashed border-border rounded-lg p-4 hover:shadow-md hover:border-solid transition-all">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-start gap-3 flex-1">
|
||||
<div class="shrink-0 w-10 h-10 rounded-lg bg-muted flex items-center justify-center">
|
||||
<i data-lucide="folder" class="w-5 h-5 text-muted-foreground"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h3 class="font-semibold text-foreground">${name}</h3>
|
||||
<span class="text-xs px-2 py-0.5 bg-muted rounded-full text-muted-foreground">${t('mcp.available')}</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">${t('mcp.from')} ${info.projectName || info.source}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="installServerFromOther('${name}', ${JSON.stringify(info.config).replace(/"/g, '"')})" class="px-3 py-1.5 text-sm font-medium bg-primary hover:bg-primary/90 text-primary-foreground rounded-lg transition-colors">
|
||||
${t('mcp.addToProject')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCrossCliServerCard(server, isClaude) {
|
||||
const { name, config, fromCli } = server;
|
||||
const isStdio = !!config.command;
|
||||
const isHttp = !!config.url;
|
||||
|
||||
// Use solid circle for Claude, dashed circle for Codex
|
||||
const icon = fromCli === 'codex' ? 'circle-dashed' : 'circle';
|
||||
const iconColor = fromCli === 'codex' ? 'orange' : 'blue';
|
||||
const targetCli = isClaude ? 'project' : 'codex';
|
||||
const buttonText = isClaude ? t('mcp.codex.copyToClaude') : t('mcp.claude.copyToCodex');
|
||||
|
||||
return `
|
||||
<div class="bg-card border border-dashed border-${iconColor}-200 dark:border-${iconColor}-800 rounded-lg p-4 hover:shadow-md hover:border-solid transition-all">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-start gap-3 flex-1 min-w-0">
|
||||
<div class="shrink-0 w-10 h-10 rounded-full bg-${iconColor}-50 dark:bg-${iconColor}-950/30 flex items-center justify-center">
|
||||
<i data-lucide="${icon}" class="w-5 h-5 text-${iconColor}-500"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h3 class="font-semibold text-foreground truncate">${name}</h3>
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-${iconColor}-50 dark:bg-${iconColor}-950/30 text-${iconColor}-600 dark:text-${iconColor}-400">
|
||||
<i data-lucide="${icon}" class="w-3 h-3"></i>
|
||||
${fromCli === 'codex' ? 'Codex' : 'Claude'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground space-y-1">
|
||||
${isStdio ? `
|
||||
<div class="flex items-center gap-2">
|
||||
<i data-lucide="terminal" class="w-3 h-3"></i>
|
||||
<code class="text-xs truncate">${config.command} ${(config.args || []).slice(0, 2).join(' ')}</code>
|
||||
</div>
|
||||
` : ''}
|
||||
${isHttp ? `
|
||||
<div class="flex items-center gap-2">
|
||||
<i data-lucide="globe" class="w-3 h-3"></i>
|
||||
<code class="text-xs truncate">${config.url}</code>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="copyCrossCliServer('${name}', ${JSON.stringify(config).replace(/"/g, '"')}, '${fromCli}', '${targetCli}')" class="px-3 py-1.5 text-sm font-medium bg-${iconColor}-500 hover:bg-${iconColor}-600 text-white rounded-lg transition-colors flex items-center gap-1.5">
|
||||
<i data-lucide="copy" class="w-3.5 h-3.5"></i>
|
||||
${buttonText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function copyCrossCliServer(name, config, fromCli, targetCli) {
|
||||
try {
|
||||
let endpoint, body;
|
||||
|
||||
if (targetCli === 'codex') {
|
||||
// Copy from Claude to Codex
|
||||
endpoint = '/api/codex-mcp-add';
|
||||
body = { serverName: name, serverConfig: config };
|
||||
} else if (targetCli === 'project') {
|
||||
// Copy from Codex to Claude project
|
||||
endpoint = '/api/mcp-copy-server';
|
||||
body = { projectPath, serverName: name, serverConfig: config, configType: 'mcp' };
|
||||
} else if (targetCli === 'global') {
|
||||
// Copy to Claude global
|
||||
endpoint = '/api/mcp-add-global-server';
|
||||
body = { serverName: name, serverConfig: config };
|
||||
}
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const targetName = targetCli === 'codex' ? 'Codex' : 'Claude';
|
||||
showToast(t('mcp.success'), `${t('mcp.serverInstalled')} (${targetName})`, 'success');
|
||||
await loadMcpConfig();
|
||||
renderMcpManager();
|
||||
} else {
|
||||
showToast(t('mcp.error'), data.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('mcp.error'), error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function installServerFromOther(name, config) {
|
||||
try {
|
||||
const res = await fetch('/api/mcp-copy-server', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ projectPath, serverName: name, serverConfig: config, configType: 'mcp' })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showToast(t('mcp.success'), t('mcp.serverInstalled'), 'success');
|
||||
await loadMcpConfig();
|
||||
renderMcpManager();
|
||||
} else {
|
||||
showToast(t('mcp.error'), data.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('mcp.error'), error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMcpServer(serverName, cliMode, enable) {
|
||||
try {
|
||||
let endpoint = cliMode === 'codex' ? '/api/codex-mcp-toggle' : '/api/mcp-toggle';
|
||||
let body = cliMode === 'codex' ? { serverName, enabled: enable } : { projectPath, serverName, enable };
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success || data.serverName) {
|
||||
showToast(t('mcp.success'), enable ? t('mcp.serverEnabled') : t('mcp.serverDisabled'), 'success');
|
||||
await loadMcpConfig();
|
||||
renderMcpManager();
|
||||
} else {
|
||||
showToast(t('mcp.error'), data.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('mcp.error'), error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMcpServer(serverName, source, cliMode) {
|
||||
if (!confirm(`Are you sure you want to delete "${serverName}"?`)) return;
|
||||
|
||||
try {
|
||||
let endpoint = '';
|
||||
let body = {};
|
||||
|
||||
if (cliMode === 'codex') {
|
||||
endpoint = '/api/codex-mcp-remove';
|
||||
body = { serverName };
|
||||
} else if (source === 'global-manage') {
|
||||
endpoint = '/api/mcp-remove-global-server';
|
||||
body = { serverName };
|
||||
} else if (source === 'project') {
|
||||
endpoint = '/api/mcp-remove-server';
|
||||
body = { projectPath, serverName };
|
||||
} else {
|
||||
endpoint = '/api/mcp-remove-global-server';
|
||||
body = { serverName };
|
||||
}
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success || data.removed) {
|
||||
showToast(t('mcp.success'), t('mcp.serverDeleted'), 'success');
|
||||
await loadMcpConfig();
|
||||
renderMcpManager();
|
||||
} else {
|
||||
showToast(t('mcp.error'), data.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('mcp.error'), error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function renderMcpTemplates() {
|
||||
const container = document.getElementById('mainContent');
|
||||
if (!container) return;
|
||||
|
||||
if (!mcpTemplates || mcpTemplates.length === 0) await loadMcpTemplates();
|
||||
const categories = [...new Set(mcpTemplates.map(t => t.category || 'Custom'))];
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="mcp-templates-view">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<button onclick="renderMcpManager()" class="text-sm text-primary hover:underline flex items-center gap-1 mb-2">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
${t('mcp.backToManager')}
|
||||
</button>
|
||||
<h1 class="text-2xl font-bold text-foreground">${t('mcp.templates')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
${categories.map(category => {
|
||||
const templates = mcpTemplates.filter(t => (t.category || 'Custom') === category);
|
||||
return `
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-foreground mb-3">${category} (${templates.length})</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
${templates.map(template => `
|
||||
<div class="bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all">
|
||||
<h3 class="font-semibold text-foreground mb-2">${template.name}</h3>
|
||||
<p class="text-sm text-muted-foreground mb-4">${template.description || 'No description'}</p>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showMcpEditorModal({ mode: 'create', template: ${JSON.stringify(template).replace(/"/g, '"')} })" class="flex-1 px-3 py-2 text-sm bg-primary hover:bg-primary/90 text-primary-foreground rounded-lg">Install</button>
|
||||
<button onclick="deleteTemplate('${template.name}')" class="px-3 py-2 text-sm bg-destructive/10 hover:bg-destructive/20 text-destructive rounded-lg">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
|
||||
async function deleteTemplate(name) {
|
||||
if (!confirm(`Delete template "${name}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/mcp-templates/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showToast(t('mcp.success'), t('mcp.templateDeleted'), 'success');
|
||||
await loadMcpTemplates();
|
||||
renderMcpTemplates();
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('mcp.error'), error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(title, message, type = 'info') {
|
||||
console.log(`[${type.toUpperCase()}] ${title}: ${message}`);
|
||||
if (typeof window.showNotification === 'function') {
|
||||
window.showNotification(title, message, type);
|
||||
} else {
|
||||
alert(`${title}\n${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMcpTemplates() {
|
||||
try {
|
||||
const res = await fetch('/api/mcp-templates');
|
||||
const data = await res.json();
|
||||
if (data.success) mcpTemplates = data.templates || [];
|
||||
} catch (error) {
|
||||
console.error('Error loading MCP templates:', error);
|
||||
mcpTemplates = [];
|
||||
}
|
||||
}
|
||||
|
||||
window.renderMcpManager = renderMcpManager;
|
||||
window.renderMcpTemplates = renderMcpTemplates;
|
||||
window.showMcpEditorModal = showMcpEditorModal;
|
||||
window.closeMcpEditorModal = closeMcpEditorModal;
|
||||
window.saveMcpFromModal = saveMcpFromModal;
|
||||
window.switchMcpServerType = switchMcpServerType;
|
||||
window.toggleMcpServer = toggleMcpServer;
|
||||
window.deleteMcpServer = deleteMcpServer;
|
||||
window.deleteTemplate = deleteTemplate;
|
||||
window.installServerFromOther = installServerFromOther;
|
||||
@@ -1130,8 +1130,8 @@ export async function getExecutionHistoryAsync(baseDir: string, options: {
|
||||
|
||||
// Recursive mode: aggregate data from parent and all child projects
|
||||
if (recursive) {
|
||||
const { scanChildProjects } = await import('../config/storage-paths.js');
|
||||
const childProjects = scanChildProjects(baseDir);
|
||||
const { scanChildProjectsAsync } = await import('../config/storage-paths.js');
|
||||
const childProjects = await scanChildProjectsAsync(baseDir);
|
||||
|
||||
let allExecutions: (HistoryIndex['executions'][0] & { sourceDir?: string })[] = [];
|
||||
let totalCount = 0;
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface ConversationTurn {
|
||||
duration_ms: number;
|
||||
status: 'success' | 'error' | 'timeout';
|
||||
exit_code: number | null;
|
||||
// NOTE: Naming inconsistency - using prompt/stdout vs tool_args/tool_output in MemoryStore
|
||||
// This reflects CLI-specific semantics (prompt -> execution -> output)
|
||||
output: {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
@@ -96,8 +98,11 @@ export interface ReviewRecord {
|
||||
export class CliHistoryStore {
|
||||
private db: Database.Database;
|
||||
private dbPath: string;
|
||||
private projectPath: string;
|
||||
|
||||
constructor(baseDir: string) {
|
||||
this.projectPath = baseDir;
|
||||
|
||||
// Use centralized storage path
|
||||
const paths = StoragePaths.project(baseDir);
|
||||
const historyDir = paths.cliHistory;
|
||||
@@ -294,6 +299,22 @@ export class CliHistoryStore {
|
||||
`);
|
||||
console.log('[CLI History] Migration complete: relative_path column added');
|
||||
}
|
||||
|
||||
// Add missing timestamp index for turns table (for time-based queries)
|
||||
try {
|
||||
const indexExists = this.db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='index' AND name='idx_turns_timestamp'
|
||||
`).get();
|
||||
|
||||
if (!indexExists) {
|
||||
console.log('[CLI History] Adding missing timestamp index to turns table...');
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_turns_timestamp ON turns(timestamp DESC);`);
|
||||
console.log('[CLI History] Migration complete: turns timestamp index added');
|
||||
}
|
||||
} catch (indexErr) {
|
||||
console.warn('[CLI History] Turns timestamp index creation warning:', (indexErr as Error).message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CLI History] Migration error:', (err as Error).message);
|
||||
// Don't throw - allow the store to continue working with existing schema
|
||||
@@ -387,14 +408,16 @@ export class CliHistoryStore {
|
||||
: '';
|
||||
|
||||
const upsertConversation = this.db.prepare(`
|
||||
INSERT INTO conversations (id, created_at, updated_at, tool, model, mode, category, total_duration_ms, turn_count, latest_status, prompt_preview, parent_execution_id)
|
||||
VALUES (@id, @created_at, @updated_at, @tool, @model, @mode, @category, @total_duration_ms, @turn_count, @latest_status, @prompt_preview, @parent_execution_id)
|
||||
INSERT INTO conversations (id, created_at, updated_at, tool, model, mode, category, total_duration_ms, turn_count, latest_status, prompt_preview, parent_execution_id, project_root, relative_path)
|
||||
VALUES (@id, @created_at, @updated_at, @tool, @model, @mode, @category, @total_duration_ms, @turn_count, @latest_status, @prompt_preview, @parent_execution_id, @project_root, @relative_path)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
updated_at = @updated_at,
|
||||
total_duration_ms = @total_duration_ms,
|
||||
turn_count = @turn_count,
|
||||
latest_status = @latest_status,
|
||||
prompt_preview = @prompt_preview
|
||||
prompt_preview = @prompt_preview,
|
||||
project_root = @project_root,
|
||||
relative_path = @relative_path
|
||||
`);
|
||||
|
||||
const upsertTurn = this.db.prepare(`
|
||||
@@ -424,7 +447,9 @@ export class CliHistoryStore {
|
||||
turn_count: conversation.turn_count,
|
||||
latest_status: conversation.latest_status,
|
||||
prompt_preview: promptPreview,
|
||||
parent_execution_id: conversation.parent_execution_id || null
|
||||
parent_execution_id: conversation.parent_execution_id || null,
|
||||
project_root: this.projectPath,
|
||||
relative_path: null // For future hierarchical tracking
|
||||
});
|
||||
|
||||
for (const turn of conversation.turns) {
|
||||
|
||||
Reference in New Issue
Block a user