mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
Refactor search modes and optimize embedding generation
- Updated the dashboard template to hide the Code Graph Explorer feature. - Enhanced the `executeCodexLens` function to use `exec` for better cross-platform compatibility and improved command execution. - Changed the default `maxResults` and `limit` parameters in the smart search tool to 10 for better performance. - Introduced a new `priority` search mode in the smart search tool, replacing the previous `parallel` mode, which now follows a fallback strategy: hybrid -> exact -> ripgrep. - Optimized the embedding generation process in the embedding manager by batching operations and using a cached embedder instance to reduce model loading overhead. - Implemented a thread-safe singleton pattern for the embedder to improve performance across multiple searches.
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
smart_search(query="authentication logic")
|
||||
|
||||
// Step 2: Only if search warns "No CodexLens index found", then init
|
||||
smart_search(action="init", path=".") // Creates FTS index only
|
||||
smart_search(action="init", path=".") // Creates FTS index only
|
||||
|
||||
// Note: For semantic/vector search, use "ccw view" dashboard to create vector index
|
||||
```
|
||||
|
||||
@@ -173,6 +173,10 @@ function generateFromUnifiedTemplate(data: unknown): string {
|
||||
jsContent = jsContent.replace(/\{\{PROJECT_PATH\}\}/g, projectPath.replace(/\\/g, '/'));
|
||||
jsContent = jsContent.replace('{{RECENT_PATHS}}', JSON.stringify(recentPaths));
|
||||
|
||||
// Inject platform information for cross-platform MCP command generation
|
||||
// 'win32' for Windows, 'darwin' for macOS, 'linux' for Linux
|
||||
jsContent = jsContent.replace(/\{\{SERVER_PLATFORM\}\}/g, process.platform);
|
||||
|
||||
// Inject JS and CSS into HTML template
|
||||
html = html.replace('{{JS_CONTENT}}', jsContent);
|
||||
html = html.replace('{{CSS_CONTENT}}', cssContent);
|
||||
|
||||
@@ -224,6 +224,13 @@
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
/* Fixed card dimensions */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 280px;
|
||||
min-height: 280px;
|
||||
max-height: 280px;
|
||||
}
|
||||
|
||||
.memory-card:hover {
|
||||
@@ -239,8 +246,9 @@
|
||||
.memory-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.memory-id {
|
||||
@@ -248,7 +256,8 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
color: hsl(var(--foreground));
|
||||
font-weight: 600;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -257,6 +266,10 @@
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.memory-id i[data-lucide="star"] {
|
||||
color: hsl(38 92% 50%);
|
||||
}
|
||||
|
||||
.memory-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -282,34 +295,83 @@
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.icon-btn.favorite-active {
|
||||
color: hsl(38 92% 50%);
|
||||
}
|
||||
|
||||
.icon-btn.favorite-active:hover {
|
||||
color: hsl(38 92% 40%);
|
||||
}
|
||||
|
||||
.icon-btn i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Copy ID Button */
|
||||
.copy-id-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.125rem 0.25rem;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--muted-foreground));
|
||||
transition: color 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 0.25rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.copy-id-btn:hover {
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.copy-id-btn i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.copy-id-btn.copied {
|
||||
color: hsl(var(--success, 142 76% 36%));
|
||||
}
|
||||
|
||||
/* Memory Content */
|
||||
.memory-content {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.memory-summary {
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
color: hsl(var(--foreground));
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.memory-summary,
|
||||
.memory-preview {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--muted-foreground));
|
||||
/* Fixed height with ellipsis */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-height: 6em;
|
||||
}
|
||||
|
||||
.memory-summary {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* Memory Tags */
|
||||
.memory-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
gap: 0.375rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
max-height: 2rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tag {
|
||||
@@ -321,13 +383,14 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Memory Footer */
|
||||
/* Memory Footer - Fixed at bottom */
|
||||
.memory-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding-top: 1rem;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.memory-meta {
|
||||
@@ -850,6 +913,89 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Relations Detail Styles */
|
||||
.relations-detail h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.clusters-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.relation-cluster-item {
|
||||
padding: 1rem;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.relation-cluster-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.relation-cluster-header i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.relation-cluster-header .cluster-name {
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.cluster-relations-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.relations-label {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.relation-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
background: hsl(var(--accent));
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.relation-tag i {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.relation-tag .relation-type {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: hsl(var(--primary) / 0.15);
|
||||
color: hsl(var(--primary));
|
||||
border-radius: 3px;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Dark Mode Adjustments */
|
||||
[data-theme="dark"] .memory-card {
|
||||
background: #1e293b;
|
||||
|
||||
@@ -928,11 +928,19 @@ function selectCcwTools(type) {
|
||||
}
|
||||
|
||||
// Build CCW Tools config with selected tools
|
||||
// Uses isWindowsPlatform from state.js to generate platform-appropriate commands
|
||||
function buildCcwToolsConfig(selectedTools) {
|
||||
const config = {
|
||||
command: "cmd",
|
||||
args: ["/c", "npx", "-y", "ccw-mcp"]
|
||||
};
|
||||
// Windows requires 'cmd /c' wrapper to execute npx
|
||||
// Other platforms (macOS, Linux) can run npx directly
|
||||
const config = isWindowsPlatform
|
||||
? {
|
||||
command: "cmd",
|
||||
args: ["/c", "npx", "-y", "ccw-mcp"]
|
||||
}
|
||||
: {
|
||||
command: "npx",
|
||||
args: ["-y", "ccw-mcp"]
|
||||
};
|
||||
|
||||
// Add env if not all tools or not default 4 core tools
|
||||
const coreTools = ['write_file', 'edit_file', 'codex_lens', 'smart_search'];
|
||||
|
||||
@@ -1291,8 +1291,27 @@ const i18n = {
|
||||
'coreMemory.clusterUpdateError': 'Failed to update cluster',
|
||||
'coreMemory.memberRemoved': 'Member removed',
|
||||
'coreMemory.memberRemoveError': 'Failed to remove member',
|
||||
'coreMemory.favorites': 'Favorites',
|
||||
'coreMemory.totalFavorites': 'Total Favorites',
|
||||
'coreMemory.noFavorites': 'No favorites yet',
|
||||
'coreMemory.toggleFavorite': 'Toggle Favorite',
|
||||
'coreMemory.addedToFavorites': 'Added to favorites',
|
||||
'coreMemory.removedFromFavorites': 'Removed from favorites',
|
||||
'coreMemory.favoriteError': 'Failed to update favorite',
|
||||
'coreMemory.relations': 'Relations',
|
||||
'coreMemory.showRelations': 'Show Relations',
|
||||
'coreMemory.relationsFor': 'Relations',
|
||||
'coreMemory.noRelations': 'No cluster relations found',
|
||||
'coreMemory.noRelationsHint': 'Use Auto Cluster in the Clusters tab to create relations',
|
||||
'coreMemory.belongsToClusters': 'Belongs to Clusters',
|
||||
'coreMemory.relationsError': 'Failed to load relations',
|
||||
|
||||
// Common additions
|
||||
'common.copyId': 'Copy ID',
|
||||
'common.copied': 'Copied!',
|
||||
'common.copyError': 'Failed to copy',
|
||||
},
|
||||
|
||||
|
||||
zh: {
|
||||
// App title and brand
|
||||
'app.title': 'CCW 控制面板',
|
||||
@@ -1589,13 +1608,13 @@ const i18n = {
|
||||
'index.projects': '项目数',
|
||||
'index.totalSize': '总大小',
|
||||
'index.vectorIndexes': '向量',
|
||||
'index.ftsIndexes': '全文',
|
||||
'index.ftsIndexes': 'FTS',
|
||||
'index.projectId': '项目 ID',
|
||||
'index.size': '大小',
|
||||
'index.type': '类型',
|
||||
'index.lastModified': '修改时间',
|
||||
'index.vector': '向量',
|
||||
'index.fts': '全文',
|
||||
'index.fts': 'FTS',
|
||||
'index.noIndexes': '暂无索引',
|
||||
'index.notConfigured': '未配置',
|
||||
'index.initCurrent': '索引当前项目',
|
||||
@@ -1608,7 +1627,7 @@ const i18n = {
|
||||
'index.cleanAllConfirm': '确定要清理所有索引吗?此操作无法撤销。',
|
||||
'index.cleanAllSuccess': '所有索引已清理',
|
||||
'index.vectorIndex': '向量索引',
|
||||
'index.normalIndex': '全文索引',
|
||||
'index.normalIndex': 'FTS索引',
|
||||
'index.vectorDesc': '语义搜索(含嵌入向量)',
|
||||
'index.normalDesc': '快速全文搜索',
|
||||
|
||||
@@ -2585,6 +2604,25 @@ const i18n = {
|
||||
'coreMemory.clusterUpdateError': '更新聚类失败',
|
||||
'coreMemory.memberRemoved': '成员已移除',
|
||||
'coreMemory.memberRemoveError': '移除成员失败',
|
||||
'coreMemory.favorites': '收藏',
|
||||
'coreMemory.totalFavorites': '收藏总数',
|
||||
'coreMemory.noFavorites': '暂无收藏',
|
||||
'coreMemory.toggleFavorite': '切换收藏',
|
||||
'coreMemory.addedToFavorites': '已添加到收藏',
|
||||
'coreMemory.removedFromFavorites': '已从收藏移除',
|
||||
'coreMemory.favoriteError': '更新收藏失败',
|
||||
'coreMemory.relations': '关联',
|
||||
'coreMemory.showRelations': '显示关联',
|
||||
'coreMemory.relationsFor': '关联关系',
|
||||
'coreMemory.noRelations': '未找到聚类关联',
|
||||
'coreMemory.noRelationsHint': '在聚类 Tab 中使用自动聚类来创建关联',
|
||||
'coreMemory.belongsToClusters': '所属聚类',
|
||||
'coreMemory.relationsError': '加载关联失败',
|
||||
|
||||
// Common additions
|
||||
'common.copyId': '复制 ID',
|
||||
'common.copied': '已复制!',
|
||||
'common.copyError': '复制失败',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,11 @@ let workflowData = {{WORKFLOW_DATA}};
|
||||
let projectPath = '{{PROJECT_PATH}}';
|
||||
let recentPaths = {{RECENT_PATHS}};
|
||||
|
||||
// Platform detection for cross-platform MCP command generation
|
||||
// 'win32' for Windows, 'darwin' for macOS, 'linux' for Linux
|
||||
const serverPlatform = '{{SERVER_PLATFORM}}';
|
||||
const isWindowsPlatform = serverPlatform === 'win32';
|
||||
|
||||
// ========== Application State ==========
|
||||
// Current filter for session list view ('all', 'active', 'archived')
|
||||
let currentFilter = 'all';
|
||||
|
||||
@@ -5,6 +5,37 @@
|
||||
|
||||
// ========== HTML/Text Processing ==========
|
||||
|
||||
/**
|
||||
* Encode JSON config data to Base64 for safe HTML attribute storage
|
||||
* @param {Object} config - Configuration object to encode
|
||||
* @returns {string} Base64 encoded JSON string
|
||||
*/
|
||||
function encodeConfigData(config) {
|
||||
try {
|
||||
const jsonStr = JSON.stringify(config);
|
||||
return btoa(encodeURIComponent(jsonStr));
|
||||
} catch (err) {
|
||||
console.error('[Utils] Error encoding config data:', err);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode Base64 config data back to JSON object
|
||||
* @param {string} encoded - Base64 encoded string
|
||||
* @returns {Object|null} Decoded configuration object or null on error
|
||||
*/
|
||||
function decodeConfigData(encoded) {
|
||||
try {
|
||||
if (!encoded) return null;
|
||||
const jsonStr = decodeURIComponent(atob(encoded));
|
||||
return JSON.parse(jsonStr);
|
||||
} catch (err) {
|
||||
console.error('[Utils] Error decoding config data:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters to prevent XSS attacks
|
||||
* @param {string} str - String to escape
|
||||
|
||||
@@ -392,7 +392,8 @@ function renderToolsSection() {
|
||||
'<div class="tool-item-right">' +
|
||||
(codexLensStatus.ready
|
||||
? '<span class="tool-status-text success"><i data-lucide="check-circle" class="w-3.5 h-3.5"></i> v' + (codexLensStatus.version || 'installed') + '</span>' +
|
||||
'<button class="btn-sm btn-outline" onclick="event.stopPropagation(); initCodexLensIndex()"><i data-lucide="database" class="w-3 h-3"></i> ' + t('cli.initIndex') + '</button>' +
|
||||
'<button class="btn-sm btn-outline" onclick="event.stopPropagation(); initCodexLensIndex(\'vector\')" title="' + (t('index.vectorDesc') || 'Semantic search with embeddings') + '"><i data-lucide="sparkles" class="w-3 h-3"></i> ' + (t('index.vectorIndex') || 'Vector') + '</button>' +
|
||||
'<button class="btn-sm btn-outline" onclick="event.stopPropagation(); initCodexLensIndex(\'normal\')" title="' + (t('index.normalDesc') || 'Fast full-text search only') + '"><i data-lucide="file-text" class="w-3 h-3"></i> ' + (t('index.normalIndex') || 'FTS') + '</button>' +
|
||||
'<button class="btn-sm btn-outline btn-danger" onclick="event.stopPropagation(); uninstallCodexLens()"><i data-lucide="trash-2" class="w-3 h-3"></i> ' + t('cli.uninstall') + '</button>'
|
||||
: '<span class="tool-status-text muted"><i data-lucide="circle-dashed" class="w-3.5 h-3.5"></i> ' + t('cli.notInstalled') + '</span>' +
|
||||
'<button class="btn-sm btn-primary" onclick="event.stopPropagation(); installCodexLens()"><i data-lucide="download" class="w-3 h-3"></i> ' + t('cli.install') + '</button>') +
|
||||
|
||||
@@ -66,6 +66,10 @@ async function renderCoreMemoryView() {
|
||||
<i data-lucide="brain"></i>
|
||||
${t('coreMemory.memories')}
|
||||
</button>
|
||||
<button class="tab-btn" id="favoritesViewBtn" onclick="showFavoritesView()">
|
||||
<i data-lucide="star"></i>
|
||||
${t('coreMemory.favorites') || 'Favorites'}
|
||||
</button>
|
||||
<button class="tab-btn" id="clustersViewBtn" onclick="showClustersView()">
|
||||
<i data-lucide="folder-tree"></i>
|
||||
${t('coreMemory.clusters')}
|
||||
@@ -106,6 +110,22 @@ async function renderCoreMemoryView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Favorites Tab Content (hidden by default) -->
|
||||
<div class="cm-tab-panel" id="favoritesGrid" style="display: none;">
|
||||
<div class="memory-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">${t('coreMemory.totalFavorites') || 'Total Favorites'}</span>
|
||||
<span class="stat-value" id="totalFavoritesCount">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="memories-grid" id="favoritesGridContent">
|
||||
<div class="empty-state">
|
||||
<i data-lucide="star"></i>
|
||||
<p>${t('coreMemory.noFavorites') || 'No favorites yet'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clusters Tab Content (hidden by default) -->
|
||||
<div class="cm-tab-panel clusters-container" id="clustersContainer" style="display: none;">
|
||||
<div class="clusters-layout">
|
||||
@@ -213,21 +233,21 @@ function renderMemoryCard(memory) {
|
||||
const priority = metadata.priority || 'medium';
|
||||
|
||||
return `
|
||||
<div class="memory-card ${isArchived ? 'archived' : ''}" data-memory-id="${memory.id}">
|
||||
<div class="memory-card ${isArchived ? 'archived' : ''}" data-memory-id="${memory.id}" onclick="viewMemoryDetail('${memory.id}')">
|
||||
<div class="memory-card-header">
|
||||
<div class="memory-id">
|
||||
<i data-lucide="bookmark"></i>
|
||||
${metadata.favorite ? '<i data-lucide="star"></i>' : ''}
|
||||
<span>${memory.id}</span>
|
||||
${isArchived ? `<span class="badge badge-archived">${t('common.archived')}</span>` : ''}
|
||||
${priority !== 'medium' ? `<span class="badge badge-priority-${priority}">${priority}</span>` : ''}
|
||||
</div>
|
||||
<div class="memory-actions">
|
||||
<button class="icon-btn" onclick="viewMemoryDetail('${memory.id}')" title="${t('common.view')}">
|
||||
<i data-lucide="eye"></i>
|
||||
</button>
|
||||
<div class="memory-actions" onclick="event.stopPropagation()">
|
||||
<button class="icon-btn" onclick="editMemory('${memory.id}')" title="${t('common.edit')}">
|
||||
<i data-lucide="edit"></i>
|
||||
</button>
|
||||
<button class="icon-btn ${metadata.favorite ? 'favorite-active' : ''}" onclick="toggleFavorite('${memory.id}')" title="${t('coreMemory.toggleFavorite') || 'Toggle Favorite'}">
|
||||
<i data-lucide="star"></i>
|
||||
</button>
|
||||
${!isArchived
|
||||
? `<button class="icon-btn" onclick="archiveMemory('${memory.id}')" title="${t('common.archive')}">
|
||||
<i data-lucide="archive"></i>
|
||||
@@ -270,11 +290,19 @@ function renderMemoryCard(memory) {
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
<div class="memory-features">
|
||||
<div class="memory-features" onclick="event.stopPropagation()">
|
||||
<button class="feature-btn" onclick="generateMemorySummary('${memory.id}')" title="${t('coreMemory.generateSummary')}">
|
||||
<i data-lucide="sparkles"></i>
|
||||
${t('coreMemory.summary')}
|
||||
</button>
|
||||
<button class="feature-btn" onclick="copyMemoryId('${memory.id}')" title="${t('common.copyId') || 'Copy ID'}">
|
||||
<i data-lucide="copy"></i>
|
||||
${t('common.copyId') || 'Copy ID'}
|
||||
</button>
|
||||
<button class="feature-btn" onclick="showMemoryRelations('${memory.id}')" title="${t('coreMemory.showRelations') || 'Show Relations'}">
|
||||
<i data-lucide="git-branch"></i>
|
||||
${t('coreMemory.relations') || 'Relations'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -565,18 +593,44 @@ function escapeHtml(text) {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async function copyMemoryId(memoryId) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(memoryId);
|
||||
showNotification(t('common.copied') || 'Copied!', 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error);
|
||||
showNotification(t('common.copyError') || 'Failed to copy', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// View Toggle Functions
|
||||
function showMemoriesView() {
|
||||
document.getElementById('memoriesGrid').style.display = '';
|
||||
document.getElementById('favoritesGrid').style.display = 'none';
|
||||
document.getElementById('clustersContainer').style.display = 'none';
|
||||
document.getElementById('memoriesViewBtn').classList.add('active');
|
||||
document.getElementById('favoritesViewBtn').classList.remove('active');
|
||||
document.getElementById('clustersViewBtn').classList.remove('active');
|
||||
}
|
||||
|
||||
async function showFavoritesView() {
|
||||
document.getElementById('memoriesGrid').style.display = 'none';
|
||||
document.getElementById('favoritesGrid').style.display = '';
|
||||
document.getElementById('clustersContainer').style.display = 'none';
|
||||
document.getElementById('memoriesViewBtn').classList.remove('active');
|
||||
document.getElementById('favoritesViewBtn').classList.add('active');
|
||||
document.getElementById('clustersViewBtn').classList.remove('active');
|
||||
|
||||
// Load favorites
|
||||
await refreshFavorites();
|
||||
}
|
||||
|
||||
function showClustersView() {
|
||||
document.getElementById('memoriesGrid').style.display = 'none';
|
||||
document.getElementById('favoritesGrid').style.display = 'none';
|
||||
document.getElementById('clustersContainer').style.display = '';
|
||||
document.getElementById('memoriesViewBtn').classList.remove('active');
|
||||
document.getElementById('favoritesViewBtn').classList.remove('active');
|
||||
document.getElementById('clustersViewBtn').classList.add('active');
|
||||
|
||||
// Load clusters from core-memory-clusters.js
|
||||
@@ -586,3 +640,143 @@ function showClustersView() {
|
||||
console.error('loadClusters is not available. Make sure core-memory-clusters.js is loaded.');
|
||||
}
|
||||
}
|
||||
|
||||
// Favorites Functions
|
||||
async function refreshFavorites() {
|
||||
const allMemories = await fetchCoreMemories(false);
|
||||
const favorites = allMemories.filter(m => m.metadata && m.metadata.favorite);
|
||||
|
||||
const countEl = document.getElementById('totalFavoritesCount');
|
||||
const gridEl = document.getElementById('favoritesGridContent');
|
||||
|
||||
if (countEl) countEl.textContent = favorites.length;
|
||||
|
||||
if (gridEl) {
|
||||
if (favorites.length === 0) {
|
||||
gridEl.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i data-lucide="star"></i>
|
||||
<p>${t('coreMemory.noFavorites') || 'No favorites yet'}</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
gridEl.innerHTML = favorites.map(memory => renderMemoryCard(memory)).join('');
|
||||
}
|
||||
}
|
||||
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
async function showMemoryRelations(memoryId) {
|
||||
try {
|
||||
// Fetch all clusters
|
||||
const response = await fetch(`/api/core-memory/clusters?path=${encodeURIComponent(projectPath)}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const result = await response.json();
|
||||
const clusters = result.clusters || [];
|
||||
|
||||
// Find clusters containing this memory
|
||||
const relatedClusters = [];
|
||||
for (const cluster of clusters) {
|
||||
const detailRes = await fetch(`/api/core-memory/clusters/${cluster.id}?path=${encodeURIComponent(projectPath)}`);
|
||||
if (detailRes.ok) {
|
||||
const detail = await detailRes.json();
|
||||
const members = detail.members || [];
|
||||
if (members.some(m => m.session_id === memoryId || m.id === memoryId)) {
|
||||
relatedClusters.push({
|
||||
...cluster,
|
||||
relations: detail.relations || []
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show in modal
|
||||
const modal = document.getElementById('memoryDetailModal');
|
||||
document.getElementById('memoryDetailTitle').textContent = t('coreMemory.relationsFor') || `Relations: ${memoryId}`;
|
||||
|
||||
const body = document.getElementById('memoryDetailBody');
|
||||
if (relatedClusters.length === 0) {
|
||||
body.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i data-lucide="git-branch"></i>
|
||||
<p>${t('coreMemory.noRelations') || 'No cluster relations found'}</p>
|
||||
<p class="text-muted">${t('coreMemory.noRelationsHint') || 'Use Auto Cluster in the Clusters tab to create relations'}</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
body.innerHTML = `
|
||||
<div class="relations-detail">
|
||||
<h4>${t('coreMemory.belongsToClusters') || 'Belongs to Clusters'}</h4>
|
||||
<div class="clusters-list">
|
||||
${relatedClusters.map(cluster => `
|
||||
<div class="relation-cluster-item">
|
||||
<div class="relation-cluster-header">
|
||||
<i data-lucide="folder"></i>
|
||||
<span class="cluster-name">${escapeHtml(cluster.name)}</span>
|
||||
<span class="badge badge-${cluster.status}">${cluster.status}</span>
|
||||
</div>
|
||||
${cluster.relations && cluster.relations.length > 0 ? `
|
||||
<div class="cluster-relations-list">
|
||||
<span class="relations-label">${t('coreMemory.relatedClusters')}:</span>
|
||||
${cluster.relations.map(rel => `
|
||||
<span class="relation-tag">
|
||||
<i data-lucide="link"></i>
|
||||
${escapeHtml(rel.target_name || rel.target_id)}
|
||||
<span class="relation-type">${rel.relation_type}</span>
|
||||
</span>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
modal.style.display = 'flex';
|
||||
lucide.createIcons();
|
||||
} catch (error) {
|
||||
console.error('Failed to load relations:', error);
|
||||
showNotification(t('coreMemory.relationsError') || 'Failed to load relations', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFavorite(memoryId) {
|
||||
try {
|
||||
const memory = await fetchMemoryById(memoryId);
|
||||
if (!memory) return;
|
||||
|
||||
const metadata = memory.metadata || {};
|
||||
metadata.favorite = !metadata.favorite;
|
||||
|
||||
const response = await fetch('/api/core-memory/memories', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...memory, metadata, path: projectPath })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
showNotification(
|
||||
metadata.favorite
|
||||
? (t('coreMemory.addedToFavorites') || 'Added to favorites')
|
||||
: (t('coreMemory.removedFromFavorites') || 'Removed from favorites'),
|
||||
'success'
|
||||
);
|
||||
|
||||
// Refresh current view
|
||||
await refreshCoreMemories();
|
||||
|
||||
// Also refresh favorites if visible
|
||||
const favoritesGrid = document.getElementById('favoritesGrid');
|
||||
if (favoritesGrid && favoritesGrid.style.display !== 'none') {
|
||||
await refreshFavorites();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle favorite:', error);
|
||||
showNotification(t('coreMemory.favoriteError') || 'Failed to update favorite', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,7 +324,7 @@ async function renderMcpManager() {
|
||||
<button class="px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
|
||||
data-action="copy-to-codex"
|
||||
data-server-name="${escapeHtml(serverName)}"
|
||||
data-server-config="${escapeHtml(JSON.stringify(serverConfig))}"
|
||||
data-server-config="${encodeConfigData(serverConfig)}"
|
||||
title="${t('mcp.codex.copyToCodex')}">
|
||||
<i data-lucide="arrow-right" class="w-3.5 h-3.5 inline"></i> Codex
|
||||
</button>
|
||||
@@ -684,7 +684,7 @@ async function renderMcpManager() {
|
||||
<button class="px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
|
||||
data-action="copy-codex-to-claude"
|
||||
data-server-name="${escapeHtml(serverName)}"
|
||||
data-server-config='${JSON.stringify(serverConfig).replace(/'/g, "'")}'
|
||||
data-server-config="${encodeConfigData(serverConfig)}"
|
||||
title="${t('mcp.claude.copyToClaude')}">
|
||||
<i data-lucide="arrow-right" class="w-3.5 h-3.5 inline"></i> Claude
|
||||
</button>
|
||||
@@ -823,7 +823,7 @@ function renderProjectAvailableServerCard(entry) {
|
||||
return `
|
||||
<div class="mcp-server-card bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all cursor-pointer ${canToggle && !isEnabled ? 'opacity-60' : ''}"
|
||||
data-server-name="${escapeHtml(name)}"
|
||||
data-server-config="${escapeHtml(JSON.stringify(config))}"
|
||||
data-server-config="${encodeConfigData(config)}"
|
||||
data-server-source="${source}"
|
||||
data-action="view-details"
|
||||
title="${t('mcp.clickToViewDetails')}">
|
||||
@@ -867,7 +867,7 @@ function renderProjectAvailableServerCard(entry) {
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="text-xs text-success hover:text-success/80 transition-colors flex items-center gap-1"
|
||||
data-server-name="${escapeHtml(name)}"
|
||||
data-server-config="${escapeHtml(JSON.stringify(config))}"
|
||||
data-server-config="${encodeConfigData(config)}"
|
||||
data-action="save-as-template"
|
||||
onclick="event.stopPropagation()"
|
||||
title="${t('mcp.saveAsTemplate')}">
|
||||
@@ -898,7 +898,7 @@ function renderGlobalManagementCard(serverName, serverConfig) {
|
||||
return `
|
||||
<div class="mcp-server-card mcp-server-global bg-card border border-success/30 rounded-lg p-4 hover:shadow-md transition-all cursor-pointer"
|
||||
data-server-name="${escapeHtml(serverName)}"
|
||||
data-server-config="${escapeHtml(JSON.stringify(serverConfig))}"
|
||||
data-server-config="${encodeConfigData(serverConfig)}"
|
||||
data-server-source="global"
|
||||
data-action="view-details"
|
||||
title="${t('mcp.clickToEdit')}">
|
||||
@@ -976,7 +976,7 @@ function renderAvailableServerCard(serverName, serverInfo) {
|
||||
<button class="px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
|
||||
data-server-name="${escapeHtml(originalName)}"
|
||||
data-server-key="${escapeHtml(serverName)}"
|
||||
data-server-config='${JSON.stringify(serverConfig).replace(/'/g, "'")}'
|
||||
data-server-config="${encodeConfigData(serverConfig)}"
|
||||
data-scope="project"
|
||||
data-action="add-from-other"
|
||||
title="${t('mcp.installToProject')}">
|
||||
@@ -985,7 +985,7 @@ function renderAvailableServerCard(serverName, serverInfo) {
|
||||
<button class="px-3 py-1 text-xs bg-success text-success-foreground rounded hover:opacity-90 transition-opacity"
|
||||
data-server-name="${escapeHtml(originalName)}"
|
||||
data-server-key="${escapeHtml(serverName)}"
|
||||
data-server-config='${JSON.stringify(serverConfig).replace(/'/g, "'")}'
|
||||
data-server-config="${encodeConfigData(serverConfig)}"
|
||||
data-scope="global"
|
||||
data-action="add-from-other"
|
||||
title="${t('mcp.installToGlobal')}">
|
||||
@@ -1014,7 +1014,7 @@ function renderAvailableServerCard(serverName, serverInfo) {
|
||||
<div class="mt-3 pt-3 border-t border-border flex items-center gap-2">
|
||||
<button class="text-xs text-primary hover:text-primary/80 transition-colors flex items-center gap-1"
|
||||
data-server-name="${escapeHtml(originalName)}"
|
||||
data-server-config="${escapeHtml(JSON.stringify(serverConfig))}"
|
||||
data-server-config="${encodeConfigData(serverConfig)}"
|
||||
data-action="install-to-project"
|
||||
title="${t('mcp.installToProject')}">
|
||||
<i data-lucide="download" class="w-3 h-3"></i>
|
||||
@@ -1022,7 +1022,7 @@ function renderAvailableServerCard(serverName, serverInfo) {
|
||||
</button>
|
||||
<button class="text-xs text-success hover:text-success/80 transition-colors flex items-center gap-1"
|
||||
data-server-name="${escapeHtml(originalName)}"
|
||||
data-server-config="${escapeHtml(JSON.stringify(serverConfig))}"
|
||||
data-server-config="${encodeConfigData(serverConfig)}"
|
||||
data-action="install-to-global"
|
||||
title="${t('mcp.installToGlobal')}">
|
||||
<i data-lucide="globe" class="w-3 h-3"></i>
|
||||
@@ -1072,7 +1072,7 @@ function renderAvailableServerCardForCodex(serverName, serverInfo) {
|
||||
<button class="px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
|
||||
data-action="copy-to-codex"
|
||||
data-server-name="${escapeHtml(originalName)}"
|
||||
data-server-config="${escapeHtml(JSON.stringify(serverConfig))}"
|
||||
data-server-config="${encodeConfigData(serverConfig)}"
|
||||
title="${t('mcp.codex.copyToCodex')}">
|
||||
<i data-lucide="arrow-right" class="w-3.5 h-3.5 inline"></i> Codex
|
||||
</button>
|
||||
@@ -1100,7 +1100,7 @@ function renderAvailableServerCardForCodex(serverName, serverInfo) {
|
||||
<button class="text-xs text-primary hover:text-primary/80 transition-colors flex items-center gap-1"
|
||||
data-action="copy-to-codex"
|
||||
data-server-name="${escapeHtml(originalName)}"
|
||||
data-server-config="${escapeHtml(JSON.stringify(serverConfig))}"
|
||||
data-server-config="${encodeConfigData(serverConfig)}"
|
||||
title="${t('mcp.codex.copyToCodex')}">
|
||||
<i data-lucide="download" class="w-3 h-3"></i>
|
||||
${t('mcp.codex.install')}
|
||||
@@ -1130,7 +1130,7 @@ function renderCodexServerCard(serverName, serverConfig) {
|
||||
return `
|
||||
<div class="mcp-server-card bg-card border border-primary/20 rounded-lg p-4 hover:shadow-md transition-all cursor-pointer ${!isEnabled ? 'opacity-60' : ''}"
|
||||
data-server-name="${escapeHtml(serverName)}"
|
||||
data-server-config="${escapeHtml(JSON.stringify(serverConfig))}"
|
||||
data-server-config="${encodeConfigData(serverConfig)}"
|
||||
data-cli-type="codex"
|
||||
data-action="view-details-codex"
|
||||
title="${t('mcp.clickToEdit')}">
|
||||
@@ -1180,7 +1180,7 @@ function renderCodexServerCard(serverName, serverConfig) {
|
||||
<button class="text-xs text-primary hover:text-primary/80 transition-colors flex items-center gap-1"
|
||||
data-action="copy-codex-to-claude"
|
||||
data-server-name="${escapeHtml(serverName)}"
|
||||
data-server-config='${JSON.stringify(serverConfig).replace(/'/g, "'")}'
|
||||
data-server-config="${encodeConfigData(serverConfig)}"
|
||||
title="${t('mcp.codex.copyToClaude')}">
|
||||
<i data-lucide="copy" class="w-3 h-3"></i>
|
||||
${t('mcp.codex.copyToClaude')}
|
||||
@@ -1250,7 +1250,7 @@ function renderCrossCliServerCard(server, isClaude) {
|
||||
<button class="w-full px-3 py-2 text-sm font-medium bg-primary hover:bg-primary/90 text-primary-foreground rounded-lg transition-colors flex items-center justify-center gap-1.5"
|
||||
data-action="copy-cross-cli"
|
||||
data-server-name="${escapeHtml(name)}"
|
||||
data-server-config='${JSON.stringify(config).replace(/'/g, "'")}'
|
||||
data-server-config="${encodeConfigData(config)}"
|
||||
data-from-cli="${fromCli}"
|
||||
data-target-cli="${targetCli}">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
@@ -1357,14 +1357,18 @@ function attachMcpEventListeners() {
|
||||
// Add from other projects (with scope selection)
|
||||
document.querySelectorAll('.mcp-server-card button[data-action="add-from-other"]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
const serverName = btn.dataset.serverName;
|
||||
const serverConfig = JSON.parse(btn.dataset.serverConfig);
|
||||
const scope = btn.dataset.scope; // 'project' or 'global'
|
||||
try {
|
||||
const serverName = btn.dataset.serverName;
|
||||
const serverConfig = decodeConfigData(btn.dataset.serverConfig);
|
||||
const scope = btn.dataset.scope; // 'project' or 'global'
|
||||
|
||||
if (scope === 'global') {
|
||||
await addGlobalMcpServer(serverName, serverConfig);
|
||||
} else {
|
||||
await copyMcpServerToProject(serverName, serverConfig);
|
||||
if (scope === 'global') {
|
||||
await addGlobalMcpServer(serverName, serverConfig);
|
||||
} else {
|
||||
await copyMcpServerToProject(serverName, serverConfig);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MCP] Error adding server from other project:', err);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1392,27 +1396,39 @@ function attachMcpEventListeners() {
|
||||
// Install to project buttons
|
||||
document.querySelectorAll('.mcp-server-card button[data-action="install-to-project"]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
const serverName = btn.dataset.serverName;
|
||||
const serverConfig = JSON.parse(btn.dataset.serverConfig);
|
||||
await copyMcpServerToProject(serverName, serverConfig);
|
||||
try {
|
||||
const serverName = btn.dataset.serverName;
|
||||
const serverConfig = decodeConfigData(btn.dataset.serverConfig);
|
||||
await copyMcpServerToProject(serverName, serverConfig);
|
||||
} catch (err) {
|
||||
console.error('[MCP] Error installing to project:', err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Install to global buttons
|
||||
document.querySelectorAll('.mcp-server-card button[data-action="install-to-global"]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
const serverName = btn.dataset.serverName;
|
||||
const serverConfig = JSON.parse(btn.dataset.serverConfig);
|
||||
await addGlobalMcpServer(serverName, serverConfig);
|
||||
try {
|
||||
const serverName = btn.dataset.serverName;
|
||||
const serverConfig = decodeConfigData(btn.dataset.serverConfig);
|
||||
await addGlobalMcpServer(serverName, serverConfig);
|
||||
} catch (err) {
|
||||
console.error('[MCP] Error installing to global:', err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Save as template buttons
|
||||
document.querySelectorAll('.mcp-server-card button[data-action="save-as-template"]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
const serverName = btn.dataset.serverName;
|
||||
const serverConfig = JSON.parse(btn.dataset.serverConfig);
|
||||
await saveMcpAsTemplate(serverName, serverConfig);
|
||||
try {
|
||||
const serverName = btn.dataset.serverName;
|
||||
const serverConfig = decodeConfigData(btn.dataset.serverConfig);
|
||||
await saveMcpAsTemplate(serverName, serverConfig);
|
||||
} catch (err) {
|
||||
console.error('[MCP] Error saving as template:', err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1507,10 +1523,14 @@ function attachMcpEventListeners() {
|
||||
document.querySelectorAll('button[data-action="copy-to-codex"]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const serverName = btn.dataset.serverName;
|
||||
const serverConfig = JSON.parse(btn.dataset.serverConfig);
|
||||
console.log('[MCP] Copying to Codex:', serverName);
|
||||
await copyClaudeServerToCodex(serverName, serverConfig);
|
||||
try {
|
||||
const serverName = btn.dataset.serverName;
|
||||
const serverConfig = decodeConfigData(btn.dataset.serverConfig);
|
||||
console.log('[MCP] Copying to Codex:', serverName);
|
||||
await copyClaudeServerToCodex(serverName, serverConfig);
|
||||
} catch (err) {
|
||||
console.error('[MCP] Error copying to Codex:', err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1522,7 +1542,7 @@ function attachMcpEventListeners() {
|
||||
const serverName = btn.dataset.serverName;
|
||||
let serverConfig;
|
||||
try {
|
||||
serverConfig = JSON.parse(btn.dataset.serverConfig);
|
||||
serverConfig = decodeConfigData(btn.dataset.serverConfig);
|
||||
} catch (err) {
|
||||
console.error('[MCP] JSON Parse Error:', err);
|
||||
if (typeof showRefreshToast === 'function') {
|
||||
@@ -1543,7 +1563,7 @@ function attachMcpEventListeners() {
|
||||
const serverName = btn.dataset.serverName;
|
||||
let serverConfig;
|
||||
try {
|
||||
serverConfig = JSON.parse(btn.dataset.serverConfig);
|
||||
serverConfig = decodeConfigData(btn.dataset.serverConfig);
|
||||
} catch (err) {
|
||||
console.error('[MCP] JSON Parse Error:', err);
|
||||
if (typeof showRefreshToast === 'function') {
|
||||
@@ -1567,14 +1587,21 @@ function attachMcpEventListeners() {
|
||||
}
|
||||
try {
|
||||
const serverName = card.dataset.serverName;
|
||||
// Decode HTML entities before parsing JSON
|
||||
const configStr = unescapeHtml(card.dataset.serverConfig);
|
||||
const serverConfig = JSON.parse(configStr);
|
||||
const configData = card.dataset.serverConfig;
|
||||
if (!configData) {
|
||||
console.error('[MCP] Missing server config for:', serverName);
|
||||
return;
|
||||
}
|
||||
const serverConfig = decodeConfigData(configData);
|
||||
if (!serverConfig) {
|
||||
console.error('[MCP] Failed to decode server config for:', serverName);
|
||||
return;
|
||||
}
|
||||
const serverSource = card.dataset.serverSource;
|
||||
console.log('[MCP] Card clicked:', serverName, serverSource);
|
||||
showMcpEditModal(serverName, serverConfig, serverSource, 'claude');
|
||||
} catch (err) {
|
||||
console.error('[MCP] Error handling card click:', err, card.dataset.serverConfig);
|
||||
console.error('[MCP] Error handling card click:', err);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1588,13 +1615,20 @@ function attachMcpEventListeners() {
|
||||
}
|
||||
try {
|
||||
const serverName = card.dataset.serverName;
|
||||
// Decode HTML entities before parsing JSON
|
||||
const configStr = unescapeHtml(card.dataset.serverConfig);
|
||||
const serverConfig = JSON.parse(configStr);
|
||||
const configData = card.dataset.serverConfig;
|
||||
if (!configData) {
|
||||
console.error('[MCP] Missing server config for:', serverName);
|
||||
return;
|
||||
}
|
||||
const serverConfig = decodeConfigData(configData);
|
||||
if (!serverConfig) {
|
||||
console.error('[MCP] Failed to decode server config for:', serverName);
|
||||
return;
|
||||
}
|
||||
console.log('[MCP] Codex card clicked:', serverName);
|
||||
showMcpEditModal(serverName, serverConfig, 'codex', 'codex');
|
||||
} catch (err) {
|
||||
console.error('[MCP] Error handling Codex card click:', err, card.dataset.serverConfig);
|
||||
console.error('[MCP] Error handling Codex card click:', err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -331,10 +331,12 @@
|
||||
<i data-lucide="history" class="nav-icon"></i>
|
||||
<span class="nav-text flex-1" data-i18n="nav.history">History</span>
|
||||
</li>
|
||||
<!-- Hidden: Code Graph Explorer (feature disabled)
|
||||
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-view="graph-explorer" data-tooltip="Code Graph Explorer">
|
||||
<i data-lucide="git-branch" class="nav-icon"></i>
|
||||
<span class="nav-text flex-1" data-i18n="nav.graphExplorer">Graph</span>
|
||||
</li>
|
||||
-->
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ToolSchema, ToolResult } from '../types/tool.js';
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import { spawn, execSync, exec } from 'child_process';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
@@ -443,22 +443,44 @@ async function executeCodexLens(args: string[], options: ExecuteOptions = {}): P
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(VENV_PYTHON, ['-m', 'codexlens', ...args], {
|
||||
cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
// Build command string - quote paths for shell execution
|
||||
const quotedPython = `"${VENV_PYTHON}"`;
|
||||
const cmdArgs = args.map(arg => {
|
||||
// Quote arguments that contain spaces or special characters
|
||||
if (arg.includes(' ') || arg.includes('\\')) {
|
||||
return `"${arg}"`;
|
||||
}
|
||||
return arg;
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timedOut = false;
|
||||
// Build full command - on Windows, prepend cd to handle different drives
|
||||
let fullCmd: string;
|
||||
if (process.platform === 'win32' && cwd) {
|
||||
// Use cd /d to change drive and directory, then run command
|
||||
fullCmd = `cd /d "${cwd}" && ${quotedPython} -m codexlens ${cmdArgs.join(' ')}`;
|
||||
} else {
|
||||
fullCmd = `${quotedPython} -m codexlens ${cmdArgs.join(' ')}`;
|
||||
}
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
stdout += chunk;
|
||||
// Use exec with shell option for cross-platform compatibility
|
||||
exec(fullCmd, {
|
||||
cwd: process.platform === 'win32' ? undefined : cwd, // Don't use cwd on Windows, use cd command instead
|
||||
timeout,
|
||||
maxBuffer: 50 * 1024 * 1024, // 50MB buffer for large outputs
|
||||
shell: process.platform === 'win32' ? process.env.ComSpec : undefined,
|
||||
}, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
if (error.killed) {
|
||||
resolve({ success: false, error: 'Command timed out' });
|
||||
} else {
|
||||
resolve({ success: false, error: stderr || error.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Report progress if callback provided
|
||||
if (onProgress) {
|
||||
const lines = chunk.split('\n');
|
||||
// Report final progress if callback provided
|
||||
if (onProgress && stdout) {
|
||||
const lines = stdout.split('\n');
|
||||
for (const line of lines) {
|
||||
const progress = parseProgressLine(line.trim());
|
||||
if (progress) {
|
||||
@@ -466,44 +488,8 @@ async function executeCodexLens(args: string[], options: ExecuteOptions = {}): P
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
stderr += chunk;
|
||||
|
||||
// Also check stderr for progress (some tools output there)
|
||||
if (onProgress) {
|
||||
const lines = chunk.split('\n');
|
||||
for (const line of lines) {
|
||||
const progress = parseProgressLine(line.trim());
|
||||
if (progress) {
|
||||
onProgress(progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGTERM');
|
||||
}, timeout);
|
||||
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (timedOut) {
|
||||
resolve({ success: false, error: 'Command timed out' });
|
||||
} else if (code === 0) {
|
||||
resolve({ success: true, output: stdout.trim() });
|
||||
} else {
|
||||
resolve({ success: false, error: stderr || `Exit code: ${code}` });
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve({ success: false, error: `Spawn failed: ${err.message}` });
|
||||
resolve({ success: true, output: stdout.trim() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,26 +27,21 @@ import type { ProgressInfo } from './codex-lens.js';
|
||||
const ParamsSchema = z.object({
|
||||
action: z.enum(['init', 'search', 'search_files', 'status']).default('search'),
|
||||
query: z.string().optional(),
|
||||
mode: z.enum(['auto', 'hybrid', 'exact', 'ripgrep', 'parallel']).default('auto'),
|
||||
mode: z.enum(['auto', 'hybrid', 'exact', 'ripgrep', 'priority']).default('auto'),
|
||||
output_mode: z.enum(['full', 'files_only', 'count']).default('full'),
|
||||
path: z.string().optional(),
|
||||
paths: z.array(z.string()).default([]),
|
||||
contextLines: z.number().default(0),
|
||||
maxResults: z.number().default(100),
|
||||
maxResults: z.number().default(10),
|
||||
includeHidden: z.boolean().default(false),
|
||||
languages: z.array(z.string()).optional(),
|
||||
limit: z.number().default(100),
|
||||
parallelWeights: z.object({
|
||||
hybrid: z.number().default(0.5),
|
||||
exact: z.number().default(0.3),
|
||||
ripgrep: z.number().default(0.2),
|
||||
}).optional(),
|
||||
limit: z.number().default(10),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof ParamsSchema>;
|
||||
|
||||
// Search mode constants
|
||||
const SEARCH_MODES = ['auto', 'hybrid', 'exact', 'ripgrep', 'parallel'] as const;
|
||||
const SEARCH_MODES = ['auto', 'hybrid', 'exact', 'ripgrep', 'priority'] as const;
|
||||
|
||||
// Classification confidence threshold
|
||||
const CONFIDENCE_THRESHOLD = 0.7;
|
||||
@@ -89,6 +84,7 @@ interface SearchMetadata {
|
||||
warning?: string;
|
||||
note?: string;
|
||||
index_status?: 'indexed' | 'not_indexed' | 'partial';
|
||||
fallback_history?: string[];
|
||||
// Init action specific
|
||||
action?: string;
|
||||
path?: string;
|
||||
@@ -120,6 +116,13 @@ interface IndexStatus {
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ANSI color codes from string (for JSON parsing)
|
||||
*/
|
||||
function stripAnsi(str: string): string {
|
||||
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CodexLens index exists for current directory
|
||||
* @param path - Directory path to check
|
||||
@@ -140,7 +143,7 @@ async function checkIndexStatus(path: string = '.'): Promise<IndexStatus> {
|
||||
// Parse status output
|
||||
try {
|
||||
// Strip ANSI color codes from JSON output
|
||||
const cleanOutput = (result.output || '{}').replace(/\x1b\[[0-9;]*m/g, '');
|
||||
const cleanOutput = stripAnsi(result.output || '{}');
|
||||
const parsed = JSON.parse(cleanOutput);
|
||||
// Handle both direct and nested response formats (status returns {success, result: {...}})
|
||||
const status = parsed.result || parsed;
|
||||
@@ -293,7 +296,7 @@ function buildRipgrepCommand(params: {
|
||||
maxResults: number;
|
||||
includeHidden: boolean;
|
||||
}): { command: string; args: string[] } {
|
||||
const { query, paths = ['.'], contextLines = 0, maxResults = 100, includeHidden = false } = params;
|
||||
const { query, paths = ['.'], contextLines = 0, maxResults = 10, includeHidden = false } = params;
|
||||
|
||||
const args = [
|
||||
'-n', // Show line numbers
|
||||
@@ -478,7 +481,7 @@ async function executeAutoMode(params: Params): Promise<SearchResult> {
|
||||
* No index required, fallback to CodexLens if ripgrep unavailable
|
||||
*/
|
||||
async function executeRipgrepMode(params: Params): Promise<SearchResult> {
|
||||
const { query, paths = [], contextLines = 0, maxResults = 100, includeHidden = false, path = '.' } = params;
|
||||
const { query, paths = [], contextLines = 0, maxResults = 10, includeHidden = false, path = '.' } = params;
|
||||
|
||||
if (!query) {
|
||||
return {
|
||||
@@ -520,8 +523,8 @@ async function executeRipgrepMode(params: Params): Promise<SearchResult> {
|
||||
// Parse results
|
||||
let results: SemanticMatch[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(result.output || '{}');
|
||||
const data = parsed.results || parsed;
|
||||
const parsed = JSON.parse(stripAnsi(result.output || '{}'));
|
||||
const data = parsed.result?.results || parsed.results || parsed;
|
||||
results = (Array.isArray(data) ? data : []).map((item: any) => ({
|
||||
file: item.path || item.file,
|
||||
score: item.score || 0,
|
||||
@@ -632,7 +635,7 @@ async function executeRipgrepMode(params: Params): Promise<SearchResult> {
|
||||
* Requires index
|
||||
*/
|
||||
async function executeCodexLensExactMode(params: Params): Promise<SearchResult> {
|
||||
const { query, path = '.', limit = 100 } = params;
|
||||
const { query, path = '.', maxResults = 10 } = params;
|
||||
|
||||
if (!query) {
|
||||
return {
|
||||
@@ -653,7 +656,7 @@ async function executeCodexLensExactMode(params: Params): Promise<SearchResult>
|
||||
// Check index status
|
||||
const indexStatus = await checkIndexStatus(path);
|
||||
|
||||
const args = ['search', query, '--limit', limit.toString(), '--mode', 'exact', '--json'];
|
||||
const args = ['search', query, '--limit', maxResults.toString(), '--mode', 'exact', '--json'];
|
||||
const result = await executeCodexLens(args, { cwd: path });
|
||||
|
||||
if (!result.success) {
|
||||
@@ -673,8 +676,8 @@ async function executeCodexLensExactMode(params: Params): Promise<SearchResult>
|
||||
// Parse results
|
||||
let results: SemanticMatch[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(result.output || '{}');
|
||||
const data = parsed.results || parsed;
|
||||
const parsed = JSON.parse(stripAnsi(result.output || '{}'));
|
||||
const data = parsed.result?.results || parsed.results || parsed;
|
||||
results = (Array.isArray(data) ? data : []).map((item: any) => ({
|
||||
file: item.path || item.file,
|
||||
score: item.score || 0,
|
||||
@@ -704,7 +707,7 @@ async function executeCodexLensExactMode(params: Params): Promise<SearchResult>
|
||||
* Requires index with embeddings
|
||||
*/
|
||||
async function executeHybridMode(params: Params): Promise<SearchResult> {
|
||||
const { query, path = '.', limit = 100 } = params;
|
||||
const { query, path = '.', maxResults = 10 } = params;
|
||||
|
||||
if (!query) {
|
||||
return {
|
||||
@@ -725,7 +728,7 @@ async function executeHybridMode(params: Params): Promise<SearchResult> {
|
||||
// Check index status
|
||||
const indexStatus = await checkIndexStatus(path);
|
||||
|
||||
const args = ['search', query, '--limit', limit.toString(), '--mode', 'hybrid', '--json'];
|
||||
const args = ['search', query, '--limit', maxResults.toString(), '--mode', 'hybrid', '--json'];
|
||||
const result = await executeCodexLens(args, { cwd: path });
|
||||
|
||||
if (!result.success) {
|
||||
@@ -745,8 +748,8 @@ async function executeHybridMode(params: Params): Promise<SearchResult> {
|
||||
// Parse results
|
||||
let results: SemanticMatch[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(result.output || '{}');
|
||||
const data = parsed.results || parsed;
|
||||
const parsed = JSON.parse(stripAnsi(result.output || '{}'));
|
||||
const data = parsed.result?.results || parsed.results || parsed;
|
||||
results = (Array.isArray(data) ? data : []).map((item: any) => ({
|
||||
file: item.path || item.file,
|
||||
score: item.score || 0,
|
||||
@@ -828,94 +831,114 @@ function applyRRFFusion(
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode: parallel - Run all backends simultaneously with RRF fusion
|
||||
* Returns best results from hybrid + exact + ripgrep combined
|
||||
* Promise wrapper with timeout support
|
||||
* @param promise - The promise to wrap
|
||||
* @param ms - Timeout in milliseconds
|
||||
* @param modeName - Name of the mode for error message
|
||||
* @returns A new promise that rejects on timeout
|
||||
*/
|
||||
async function executeParallelMode(params: Params): Promise<SearchResult> {
|
||||
const { query, path = '.', limit = 100, parallelWeights } = params;
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, modeName: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`'${modeName}' search timed out after ${ms}ms`));
|
||||
}, ms);
|
||||
|
||||
promise
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
.finally(() => clearTimeout(timer));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode: priority - Fallback search strategy: hybrid -> exact -> ripgrep
|
||||
* Returns results from the first backend that succeeds and provides results.
|
||||
* More efficient than parallel mode - stops as soon as valid results are found.
|
||||
*/
|
||||
async function executePriorityFallbackMode(params: Params): Promise<SearchResult> {
|
||||
const { query, path = '.' } = params;
|
||||
const fallbackHistory: string[] = [];
|
||||
|
||||
if (!query) {
|
||||
return { success: false, error: 'Query is required for search' };
|
||||
}
|
||||
|
||||
// Check index status first
|
||||
const indexStatus = await checkIndexStatus(path);
|
||||
|
||||
// 1. Try Hybrid search (highest priority) - 90s timeout for large indexes
|
||||
if (indexStatus.indexed && indexStatus.has_embeddings) {
|
||||
try {
|
||||
const hybridResult = await withTimeout(executeHybridMode(params), 90000, 'hybrid');
|
||||
if (hybridResult.success && hybridResult.results && (hybridResult.results as any[]).length > 0) {
|
||||
fallbackHistory.push('hybrid: success');
|
||||
return {
|
||||
...hybridResult,
|
||||
metadata: {
|
||||
...hybridResult.metadata,
|
||||
mode: 'priority',
|
||||
note: 'Result from hybrid search (semantic + vector).',
|
||||
fallback_history: fallbackHistory,
|
||||
},
|
||||
};
|
||||
}
|
||||
fallbackHistory.push('hybrid: no results');
|
||||
} catch (error) {
|
||||
fallbackHistory.push(`hybrid: ${(error as Error).message}`);
|
||||
}
|
||||
} else {
|
||||
fallbackHistory.push(`hybrid: skipped (${!indexStatus.indexed ? 'no index' : 'no embeddings'})`);
|
||||
}
|
||||
|
||||
// 2. Fallback to Exact search - 10s timeout
|
||||
if (indexStatus.indexed) {
|
||||
try {
|
||||
const exactResult = await withTimeout(executeCodexLensExactMode(params), 10000, 'exact');
|
||||
if (exactResult.success && exactResult.results && (exactResult.results as any[]).length > 0) {
|
||||
fallbackHistory.push('exact: success');
|
||||
return {
|
||||
...exactResult,
|
||||
metadata: {
|
||||
...exactResult.metadata,
|
||||
mode: 'priority',
|
||||
note: 'Result from exact/FTS search (fallback from hybrid).',
|
||||
fallback_history: fallbackHistory,
|
||||
},
|
||||
};
|
||||
}
|
||||
fallbackHistory.push('exact: no results');
|
||||
} catch (error) {
|
||||
fallbackHistory.push(`exact: ${(error as Error).message}`);
|
||||
}
|
||||
} else {
|
||||
fallbackHistory.push('exact: skipped (no index)');
|
||||
}
|
||||
|
||||
// 3. Final fallback to Ripgrep - 5s timeout
|
||||
try {
|
||||
const ripgrepResult = await withTimeout(executeRipgrepMode(params), 5000, 'ripgrep');
|
||||
fallbackHistory.push(ripgrepResult.success ? 'ripgrep: success' : 'ripgrep: failed');
|
||||
return {
|
||||
success: false,
|
||||
error: 'Query is required for search',
|
||||
};
|
||||
}
|
||||
|
||||
// Default weights if not provided
|
||||
const weights = parallelWeights || {
|
||||
hybrid: 0.5,
|
||||
exact: 0.3,
|
||||
ripgrep: 0.2,
|
||||
};
|
||||
|
||||
// Run all backends in parallel
|
||||
const [hybridResult, exactResult, ripgrepResult] = await Promise.allSettled([
|
||||
executeHybridMode(params),
|
||||
executeCodexLensExactMode(params),
|
||||
executeRipgrepMode(params),
|
||||
]);
|
||||
|
||||
// Collect successful results
|
||||
const resultsMap = new Map<string, any[]>();
|
||||
const backendStatus: Record<string, string> = {};
|
||||
|
||||
if (hybridResult.status === 'fulfilled' && hybridResult.value.success) {
|
||||
resultsMap.set('hybrid', hybridResult.value.results as any[]);
|
||||
backendStatus.hybrid = 'success';
|
||||
} else {
|
||||
backendStatus.hybrid = hybridResult.status === 'rejected'
|
||||
? `error: ${hybridResult.reason}`
|
||||
: `failed: ${(hybridResult as PromiseFulfilledResult<SearchResult>).value.error}`;
|
||||
}
|
||||
|
||||
if (exactResult.status === 'fulfilled' && exactResult.value.success) {
|
||||
resultsMap.set('exact', exactResult.value.results as any[]);
|
||||
backendStatus.exact = 'success';
|
||||
} else {
|
||||
backendStatus.exact = exactResult.status === 'rejected'
|
||||
? `error: ${exactResult.reason}`
|
||||
: `failed: ${(exactResult as PromiseFulfilledResult<SearchResult>).value.error}`;
|
||||
}
|
||||
|
||||
if (ripgrepResult.status === 'fulfilled' && ripgrepResult.value.success) {
|
||||
resultsMap.set('ripgrep', ripgrepResult.value.results as any[]);
|
||||
backendStatus.ripgrep = 'success';
|
||||
} else {
|
||||
backendStatus.ripgrep = ripgrepResult.status === 'rejected'
|
||||
? `error: ${ripgrepResult.reason}`
|
||||
: `failed: ${(ripgrepResult as PromiseFulfilledResult<SearchResult>).value.error}`;
|
||||
}
|
||||
|
||||
// If no results from any backend
|
||||
if (resultsMap.size === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'All search backends failed',
|
||||
...ripgrepResult,
|
||||
metadata: {
|
||||
mode: 'parallel',
|
||||
backend: 'multi-backend',
|
||||
count: 0,
|
||||
query,
|
||||
backend_status: backendStatus,
|
||||
} as any,
|
||||
...ripgrepResult.metadata,
|
||||
mode: 'priority',
|
||||
note: 'Result from ripgrep search (final fallback).',
|
||||
fallback_history: fallbackHistory,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
fallbackHistory.push(`ripgrep: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
// Apply RRF fusion
|
||||
const fusedResults = applyRRFFusion(resultsMap, weights, limit);
|
||||
|
||||
// All modes failed
|
||||
return {
|
||||
success: true,
|
||||
results: fusedResults,
|
||||
success: false,
|
||||
error: 'All search backends in priority mode failed or returned no results.',
|
||||
metadata: {
|
||||
mode: 'parallel',
|
||||
backend: 'multi-backend',
|
||||
count: fusedResults.length,
|
||||
mode: 'priority',
|
||||
query,
|
||||
backends_used: Array.from(resultsMap.keys()),
|
||||
backend_status: backendStatus,
|
||||
weights,
|
||||
note: 'Parallel mode runs hybrid + exact + ripgrep simultaneously with RRF fusion',
|
||||
fallback_history: fallbackHistory,
|
||||
} as any,
|
||||
};
|
||||
}
|
||||
@@ -923,11 +946,11 @@ async function executeParallelMode(params: Params): Promise<SearchResult> {
|
||||
// Tool schema for MCP
|
||||
export const schema: ToolSchema = {
|
||||
name: 'smart_search',
|
||||
description: `Intelligent code search with five modes: auto, hybrid, exact, ripgrep, parallel.
|
||||
description: `Intelligent code search with five modes: auto, hybrid, exact, ripgrep, priority.
|
||||
|
||||
**Quick Start:**
|
||||
smart_search(query="authentication logic") # Auto mode (intelligent routing)
|
||||
smart_search(action="init", path=".") # Initialize FTS index (fast, no embeddings)
|
||||
smart_search(action="init", path=".") # Initialize index (required for hybrid)
|
||||
smart_search(action="status") # Check index status
|
||||
|
||||
**Five Modes:**
|
||||
@@ -938,7 +961,7 @@ export const schema: ToolSchema = {
|
||||
|
||||
2. hybrid: CodexLens RRF fusion (exact + fuzzy + vector)
|
||||
- Best quality, semantic understanding
|
||||
- Requires index with embeddings (create via "ccw view" dashboard)
|
||||
- Requires index with embeddings
|
||||
|
||||
3. exact: CodexLens FTS (full-text search)
|
||||
- Precise keyword matching
|
||||
@@ -948,20 +971,21 @@ export const schema: ToolSchema = {
|
||||
- Fast, no index required
|
||||
- Literal string matching
|
||||
|
||||
5. parallel: Run all backends simultaneously
|
||||
- Highest recall, runs hybrid + exact + ripgrep in parallel
|
||||
- Results merged using RRF fusion with configurable weights
|
||||
5. priority: Fallback strategy for best balance of speed and recall
|
||||
- Tries searches in order: hybrid -> exact -> ripgrep
|
||||
- Returns results from the first successful search with results
|
||||
- More efficient than running all backends in parallel
|
||||
|
||||
**Actions:**
|
||||
- search (default): Intelligent search with auto routing
|
||||
- init: Create FTS index only (no embeddings, faster). For vector/semantic search, use "ccw view" dashboard
|
||||
- init: Create CodexLens index
|
||||
- status: Check index and embedding availability
|
||||
- search_files: Return file paths only
|
||||
|
||||
**Workflow:**
|
||||
1. Run action="init" to create FTS index (fast)
|
||||
2. For semantic search: create vector index via "ccw view" dashboard or "codexlens init <path>"
|
||||
3. Use auto mode - it routes to hybrid for NL queries, exact for simple queries`,
|
||||
1. Run action="init" to create index
|
||||
2. Use auto mode - it routes to hybrid for NL queries, exact for simple queries
|
||||
3. Use priority mode for comprehensive fallback search`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -978,7 +1002,7 @@ export const schema: ToolSchema = {
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: SEARCH_MODES,
|
||||
description: 'Search mode: auto (default), hybrid (best quality), exact (CodexLens FTS), ripgrep (fast, no index), parallel (all backends with RRF fusion)',
|
||||
description: 'Search mode: auto (default), hybrid (best quality), exact (CodexLens FTS), ripgrep (fast, no index), priority (fallback: hybrid->exact->ripgrep)',
|
||||
default: 'auto',
|
||||
},
|
||||
output_mode: {
|
||||
@@ -1006,13 +1030,13 @@ export const schema: ToolSchema = {
|
||||
},
|
||||
maxResults: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of results (default: 100)',
|
||||
default: 100,
|
||||
description: 'Maximum number of results (default: 10)',
|
||||
default: 10,
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Alias for maxResults',
|
||||
default: 100,
|
||||
default: 10,
|
||||
},
|
||||
includeHidden: {
|
||||
type: 'boolean',
|
||||
@@ -1024,15 +1048,6 @@ export const schema: ToolSchema = {
|
||||
items: { type: 'string' },
|
||||
description: 'Languages to index (for init action). Example: ["javascript", "typescript"]',
|
||||
},
|
||||
parallelWeights: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
hybrid: { type: 'number', default: 0.5 },
|
||||
exact: { type: 'number', default: 0.3 },
|
||||
ripgrep: { type: 'number', default: 0.2 },
|
||||
},
|
||||
description: 'RRF weights for parallel mode. Weights should sum to 1.0. Default: {hybrid: 0.5, exact: 0.3, ripgrep: 0.2}',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
@@ -1082,12 +1097,13 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
|
||||
return { success: false, error: `Invalid params: ${parsed.error.message}` };
|
||||
}
|
||||
|
||||
const { action, mode, output_mode, limit, maxResults } = parsed.data;
|
||||
const { action, mode, output_mode } = parsed.data;
|
||||
|
||||
// Use limit if maxResults not provided
|
||||
if (limit && !maxResults) {
|
||||
parsed.data.maxResults = limit;
|
||||
}
|
||||
// Sync limit and maxResults - use the larger of the two if both provided
|
||||
// This ensures user-provided values take precedence over defaults
|
||||
const effectiveLimit = Math.max(parsed.data.limit || 10, parsed.data.maxResults || 10);
|
||||
parsed.data.maxResults = effectiveLimit;
|
||||
parsed.data.limit = effectiveLimit;
|
||||
|
||||
try {
|
||||
let result: SearchResult;
|
||||
@@ -1109,7 +1125,7 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
|
||||
|
||||
case 'search':
|
||||
default:
|
||||
// Handle search modes: auto | hybrid | exact | ripgrep | parallel
|
||||
// Handle search modes: auto | hybrid | exact | ripgrep | priority
|
||||
switch (mode) {
|
||||
case 'auto':
|
||||
result = await executeAutoMode(parsed.data);
|
||||
@@ -1123,11 +1139,11 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
|
||||
case 'ripgrep':
|
||||
result = await executeRipgrepMode(parsed.data);
|
||||
break;
|
||||
case 'parallel':
|
||||
result = await executeParallelMode(parsed.data);
|
||||
case 'priority':
|
||||
result = await executePriorityFallbackMode(parsed.data);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported mode: ${mode}. Use: auto, hybrid, exact, ripgrep, or parallel`);
|
||||
throw new Error(`Unsupported mode: ${mode}. Use: auto, hybrid, exact, ripgrep, or priority`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Dict, List, Optional
|
||||
try:
|
||||
from codexlens.semantic import SEMANTIC_AVAILABLE
|
||||
if SEMANTIC_AVAILABLE:
|
||||
from codexlens.semantic.embedder import Embedder
|
||||
from codexlens.semantic.embedder import Embedder, get_embedder
|
||||
from codexlens.semantic.vector_store import VectorStore
|
||||
from codexlens.semantic.chunker import Chunker, ChunkConfig
|
||||
except ImportError:
|
||||
@@ -167,7 +167,8 @@ def generate_embeddings(
|
||||
|
||||
# Initialize components
|
||||
try:
|
||||
embedder = Embedder(profile=model_profile)
|
||||
# Use cached embedder (singleton) for performance
|
||||
embedder = get_embedder(profile=model_profile)
|
||||
vector_store = VectorStore(index_path)
|
||||
chunker = Chunker(config=ChunkConfig(max_chunk_size=chunk_size))
|
||||
|
||||
@@ -201,10 +202,16 @@ def generate_embeddings(
|
||||
if progress_callback:
|
||||
progress_callback(f"Processing {len(files)} files...")
|
||||
|
||||
# Process each file
|
||||
total_chunks = 0
|
||||
failed_files = []
|
||||
# Process all files using batch operations for optimal performance
|
||||
start_time = time.time()
|
||||
failed_files = []
|
||||
|
||||
# --- OPTIMIZATION Step 1: Collect all chunks from all files ---
|
||||
if progress_callback:
|
||||
progress_callback(f"Step 1/4: Chunking {len(files)} files...")
|
||||
|
||||
all_chunks_with_paths = [] # List of (chunk, file_path) tuples
|
||||
files_with_chunks = set()
|
||||
|
||||
for idx, file_row in enumerate(files, 1):
|
||||
file_path = file_row["full_path"]
|
||||
@@ -212,39 +219,88 @@ def generate_embeddings(
|
||||
language = file_row["language"] or "python"
|
||||
|
||||
try:
|
||||
# Create chunks
|
||||
chunks = chunker.chunk_sliding_window(
|
||||
content,
|
||||
file_path=file_path,
|
||||
language=language
|
||||
)
|
||||
|
||||
if not chunks:
|
||||
continue
|
||||
|
||||
# Generate embeddings
|
||||
for chunk in chunks:
|
||||
embedding = embedder.embed_single(chunk.content)
|
||||
chunk.embedding = embedding
|
||||
|
||||
# Store chunks
|
||||
vector_store.add_chunks(chunks, file_path)
|
||||
total_chunks += len(chunks)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(f"[{idx}/{len(files)}] {file_path}: {len(chunks)} chunks")
|
||||
|
||||
if chunks:
|
||||
for chunk in chunks:
|
||||
all_chunks_with_paths.append((chunk, file_path))
|
||||
files_with_chunks.add(file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process {file_path}: {e}")
|
||||
logger.error(f"Failed to chunk {file_path}: {e}")
|
||||
failed_files.append((file_path, str(e)))
|
||||
|
||||
if not all_chunks_with_paths:
|
||||
elapsed_time = time.time() - start_time
|
||||
return {
|
||||
"success": True,
|
||||
"result": {
|
||||
"chunks_created": 0,
|
||||
"files_processed": len(files) - len(failed_files),
|
||||
"files_failed": len(failed_files),
|
||||
"elapsed_time": elapsed_time,
|
||||
"model_profile": model_profile,
|
||||
"model_name": embedder.model_name,
|
||||
"failed_files": failed_files[:5],
|
||||
"index_path": str(index_path),
|
||||
},
|
||||
}
|
||||
|
||||
total_chunks = len(all_chunks_with_paths)
|
||||
|
||||
# --- OPTIMIZATION Step 2: Batch generate embeddings with memory-safe batching ---
|
||||
# Use smaller batches to avoid OOM errors while still benefiting from batch processing
|
||||
# jina-embeddings-v2-base-code with long chunks needs small batches
|
||||
BATCH_SIZE = 8 # Conservative batch size for memory efficiency
|
||||
|
||||
if progress_callback:
|
||||
num_batches = (total_chunks + BATCH_SIZE - 1) // BATCH_SIZE
|
||||
progress_callback(f"Step 2/4: Generating embeddings for {total_chunks} chunks ({num_batches} batches)...")
|
||||
|
||||
try:
|
||||
all_embeddings = []
|
||||
for batch_start in range(0, total_chunks, BATCH_SIZE):
|
||||
batch_end = min(batch_start + BATCH_SIZE, total_chunks)
|
||||
batch_contents = [chunk.content for chunk, _ in all_chunks_with_paths[batch_start:batch_end]]
|
||||
batch_embeddings = embedder.embed(batch_contents)
|
||||
all_embeddings.extend(batch_embeddings)
|
||||
|
||||
if progress_callback and total_chunks > BATCH_SIZE:
|
||||
progress_callback(f" Batch {batch_start // BATCH_SIZE + 1}/{(total_chunks + BATCH_SIZE - 1) // BATCH_SIZE}: {len(batch_embeddings)} embeddings")
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to generate embeddings: {str(e)}",
|
||||
}
|
||||
|
||||
# --- OPTIMIZATION Step 3: Assign embeddings back to chunks ---
|
||||
if progress_callback:
|
||||
progress_callback(f"Step 3/4: Assigning {len(all_embeddings)} embeddings...")
|
||||
|
||||
for (chunk, _), embedding in zip(all_chunks_with_paths, all_embeddings):
|
||||
chunk.embedding = embedding
|
||||
|
||||
# --- OPTIMIZATION Step 4: Batch store all chunks in single transaction ---
|
||||
if progress_callback:
|
||||
progress_callback(f"Step 4/4: Storing {total_chunks} chunks to database...")
|
||||
|
||||
try:
|
||||
vector_store.add_chunks_batch(all_chunks_with_paths)
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to store chunks: {str(e)}",
|
||||
}
|
||||
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"result": {
|
||||
"chunks_created": total_chunks,
|
||||
"files_processed": len(files) - len(failed_files),
|
||||
"files_processed": len(files_with_chunks),
|
||||
"files_failed": len(failed_files),
|
||||
"elapsed_time": elapsed_time,
|
||||
"model_profile": model_profile,
|
||||
|
||||
@@ -257,7 +257,7 @@ class HybridSearchEngine:
|
||||
return []
|
||||
|
||||
# Initialize embedder and vector store
|
||||
from codexlens.semantic.embedder import Embedder
|
||||
from codexlens.semantic.embedder import get_embedder
|
||||
from codexlens.semantic.vector_store import VectorStore
|
||||
|
||||
vector_store = VectorStore(index_path)
|
||||
@@ -285,7 +285,8 @@ class HybridSearchEngine:
|
||||
else:
|
||||
profile = "code" # Default fallback
|
||||
|
||||
embedder = Embedder(profile=profile)
|
||||
# Use cached embedder (singleton) for performance
|
||||
embedder = get_embedder(profile=profile)
|
||||
|
||||
# Generate query embedding
|
||||
query_embedding = embedder.embed_single(query)
|
||||
|
||||
@@ -2,11 +2,57 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, List
|
||||
import threading
|
||||
from typing import Dict, Iterable, List, Optional
|
||||
|
||||
from . import SEMANTIC_AVAILABLE
|
||||
|
||||
|
||||
# Global embedder cache for singleton pattern
|
||||
_embedder_cache: Dict[str, "Embedder"] = {}
|
||||
_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_embedder(profile: str = "code") -> "Embedder":
|
||||
"""Get or create a cached Embedder instance (thread-safe singleton).
|
||||
|
||||
This function provides significant performance improvement by reusing
|
||||
Embedder instances across multiple searches, avoiding repeated model
|
||||
loading overhead (~0.8s per load).
|
||||
|
||||
Args:
|
||||
profile: Model profile ("fast", "code", "multilingual", "balanced")
|
||||
|
||||
Returns:
|
||||
Cached Embedder instance for the given profile
|
||||
"""
|
||||
global _embedder_cache
|
||||
|
||||
# Fast path: check cache without lock
|
||||
if profile in _embedder_cache:
|
||||
return _embedder_cache[profile]
|
||||
|
||||
# Slow path: acquire lock for initialization
|
||||
with _cache_lock:
|
||||
# Double-check after acquiring lock
|
||||
if profile in _embedder_cache:
|
||||
return _embedder_cache[profile]
|
||||
|
||||
# Create new embedder and cache it
|
||||
embedder = Embedder(profile=profile)
|
||||
# Pre-load model to ensure it's ready
|
||||
embedder._load_model()
|
||||
_embedder_cache[profile] = embedder
|
||||
return embedder
|
||||
|
||||
|
||||
def clear_embedder_cache() -> None:
|
||||
"""Clear the embedder cache (useful for testing or memory management)."""
|
||||
global _embedder_cache
|
||||
with _cache_lock:
|
||||
_embedder_cache.clear()
|
||||
|
||||
|
||||
class Embedder:
|
||||
"""Generate embeddings for code chunks using fastembed (ONNX-based).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user