mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
feat: Implement Skills Manager View and Notifier Module
- Added `skills-manager.js` for managing Claude Code skills with functionalities for loading, displaying, and editing skills. - Introduced a Notifier module in `notifier.ts` for CLI to server communication, enabling notifications for UI updates on data changes. - Created comprehensive documentation for the Chain Search implementation, including usage examples and performance tips. - Developed a test suite for the Chain Search engine, covering basic search, quick search, symbol search, and files-only search functionalities.
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
import chalk from 'chalk';
|
||||
import { getMemoryStore, type Entity, type HotEntity, type PromptHistory } from '../core/memory-store.js';
|
||||
import { HistoryImporter } from '../core/history-importer.js';
|
||||
import { notifyMemoryUpdate, notifyRefreshRequired } from '../tools/notifier.js';
|
||||
import { join } from 'path';
|
||||
import { existsSync, readdirSync } from 'fs';
|
||||
|
||||
@@ -190,6 +191,13 @@ async function trackAction(options: TrackOptions): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Notify server of memory update (best-effort, non-blocking)
|
||||
notifyMemoryUpdate({
|
||||
entityType: type,
|
||||
entityId: String(entityId),
|
||||
action: action
|
||||
}).catch(() => { /* ignore errors - server may not be running */ });
|
||||
|
||||
if (stdin) {
|
||||
// Silent mode for hooks - just exit successfully
|
||||
process.exit(0);
|
||||
@@ -275,6 +283,11 @@ async function importAction(options: ImportOptions): Promise<void> {
|
||||
console.log(chalk.gray(` Total Skipped: ${totalSkipped}`));
|
||||
console.log(chalk.gray(` Total Errors: ${totalErrors}`));
|
||||
console.log(chalk.gray(` Database: ${dbPath}\n`));
|
||||
|
||||
// Notify server to refresh memory data
|
||||
if (totalImported > 0) {
|
||||
notifyRefreshRequired('memory').catch(() => { /* ignore */ });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`\n Error importing: ${(error as Error).message}\n`));
|
||||
process.exit(1);
|
||||
@@ -612,6 +625,11 @@ async function pruneAction(options: PruneOptions): Promise<void> {
|
||||
console.log(chalk.green(`\n Pruned ${accessResult.changes} access logs`));
|
||||
console.log(chalk.green(` Pruned ${entitiesResult.changes} entities\n`));
|
||||
|
||||
// Notify server to refresh memory data
|
||||
if (accessResult.changes > 0 || entitiesResult.changes > 0) {
|
||||
notifyRefreshRequired('memory').catch(() => { /* ignore */ });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`\n Error: ${(error as Error).message}\n`));
|
||||
process.exit(1);
|
||||
|
||||
@@ -57,7 +57,8 @@ const MODULE_CSS_FILES = [
|
||||
'09-explorer.css',
|
||||
'10-cli.css',
|
||||
'11-memory.css',
|
||||
'11-prompt-history.css'
|
||||
'11-prompt-history.css',
|
||||
'12-skills-rules.css'
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -126,6 +127,8 @@ const MODULE_FILES = [
|
||||
'views/explorer.js',
|
||||
'views/memory.js',
|
||||
'views/prompt-history.js',
|
||||
'views/skills-manager.js',
|
||||
'views/rules-manager.js',
|
||||
'main.js'
|
||||
];
|
||||
/**
|
||||
@@ -420,6 +423,37 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
return;
|
||||
}
|
||||
|
||||
// API: System notify - CLI to Server communication bridge
|
||||
// Allows CLI commands to trigger WebSocket broadcasts for UI updates
|
||||
if (pathname === '/api/system/notify' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { type, scope, data } = body as {
|
||||
type: 'REFRESH_REQUIRED' | 'MEMORY_UPDATED' | 'HISTORY_UPDATED' | 'INSIGHT_GENERATED';
|
||||
scope: 'memory' | 'history' | 'insights' | 'all';
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
if (!type || !scope) {
|
||||
return { error: 'type and scope are required', status: 400 };
|
||||
}
|
||||
|
||||
// Map CLI notification types to WebSocket broadcast format
|
||||
const notification = {
|
||||
type,
|
||||
payload: {
|
||||
scope,
|
||||
timestamp: new Date().toISOString(),
|
||||
...data
|
||||
}
|
||||
};
|
||||
|
||||
broadcastToClients(notification);
|
||||
|
||||
return { success: true, broadcast: true, clientCount: wsClients.size };
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// API: Get hooks configuration
|
||||
if (pathname === '/api/hooks' && req.method === 'GET') {
|
||||
const projectPathParam = url.searchParams.get('path');
|
||||
@@ -462,12 +496,12 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
return;
|
||||
}
|
||||
|
||||
// API: Discover SKILL packages in project
|
||||
// API: Get all skills (project and user)
|
||||
if (pathname === '/api/skills') {
|
||||
const projectPathParam = url.searchParams.get('path') || initialPath;
|
||||
const skills = await discoverSkillPackages(projectPathParam);
|
||||
const skillsData = getSkillsConfig(projectPathParam);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(skills));
|
||||
res.end(JSON.stringify(skillsData));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -821,7 +855,7 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
// API: Execute CLI Tool
|
||||
if (pathname === '/api/cli/execute' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { tool, prompt, mode, format, model, dir, includeDirs, timeout, smartContext } = body;
|
||||
const { tool, prompt, mode, format, model, dir, includeDirs, timeout, smartContext, parentExecutionId, category } = body;
|
||||
|
||||
if (!tool || !prompt) {
|
||||
return { error: 'tool and prompt are required', status: 400 };
|
||||
@@ -857,6 +891,7 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
executionId,
|
||||
tool,
|
||||
mode: mode || 'analysis',
|
||||
parentExecutionId,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
@@ -872,6 +907,8 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
cd: dir || initialPath,
|
||||
includeDirs,
|
||||
timeout: timeout || 300000,
|
||||
category: category || 'user',
|
||||
parentExecutionId,
|
||||
stream: true
|
||||
}, (chunk) => {
|
||||
// Broadcast output chunks via WebSocket
|
||||
@@ -917,6 +954,94 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
return;
|
||||
}
|
||||
|
||||
// API: CLI Review - Submit review for an execution
|
||||
if (pathname.startsWith('/api/cli/review/') && req.method === 'POST') {
|
||||
const executionId = pathname.replace('/api/cli/review/', '');
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { status, rating, comments, reviewer } = body as {
|
||||
status: 'pending' | 'approved' | 'rejected' | 'changes_requested';
|
||||
rating?: number;
|
||||
comments?: string;
|
||||
reviewer?: string;
|
||||
};
|
||||
|
||||
if (!status) {
|
||||
return { error: 'status is required', status: 400 };
|
||||
}
|
||||
|
||||
try {
|
||||
const historyStore = await import('../tools/cli-history-store.js').then(m => m.getHistoryStore(initialPath));
|
||||
|
||||
// Verify execution exists
|
||||
const execution = historyStore.getConversation(executionId);
|
||||
if (!execution) {
|
||||
return { error: 'Execution not found', status: 404 };
|
||||
}
|
||||
|
||||
// Save review
|
||||
const review = historyStore.saveReview({
|
||||
execution_id: executionId,
|
||||
status,
|
||||
rating,
|
||||
comments,
|
||||
reviewer
|
||||
});
|
||||
|
||||
// Broadcast review update
|
||||
broadcastToClients({
|
||||
type: 'CLI_REVIEW_UPDATED',
|
||||
payload: {
|
||||
executionId,
|
||||
review,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, review };
|
||||
} catch (error: unknown) {
|
||||
return { error: (error as Error).message, status: 500 };
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// API: CLI Review - Get review for an execution
|
||||
if (pathname.startsWith('/api/cli/review/') && req.method === 'GET') {
|
||||
const executionId = pathname.replace('/api/cli/review/', '');
|
||||
try {
|
||||
const historyStore = await import('../tools/cli-history-store.js').then(m => m.getHistoryStore(initialPath));
|
||||
const review = historyStore.getReview(executionId);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ review }));
|
||||
} catch (error: unknown) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (error as Error).message }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// API: CLI Reviews - List all reviews
|
||||
if (pathname === '/api/cli/reviews' && req.method === 'GET') {
|
||||
try {
|
||||
const historyStore = await import('../tools/cli-history-store.js').then(m => m.getHistoryStore(initialPath));
|
||||
const statusFilter = url.searchParams.get('status') as 'pending' | 'approved' | 'rejected' | 'changes_requested' | null;
|
||||
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
||||
|
||||
const reviews = historyStore.getReviews({
|
||||
status: statusFilter || undefined,
|
||||
limit
|
||||
});
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ reviews, count: reviews.length }));
|
||||
} catch (error: unknown) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (error as Error).message }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// API: Memory Module - Track entity access
|
||||
if (pathname === '/api/memory/track' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
@@ -1967,6 +2092,69 @@ RULES: Be concise. Focus on practical understanding. Include function signatures
|
||||
return;
|
||||
}
|
||||
|
||||
// ========== Skills & Rules API Routes ==========
|
||||
|
||||
// API: Get single skill detail
|
||||
if (pathname.startsWith('/api/skills/') && req.method === 'GET' && !pathname.endsWith('/skills/')) {
|
||||
const skillName = decodeURIComponent(pathname.replace('/api/skills/', ''));
|
||||
const location = url.searchParams.get('location') || 'project';
|
||||
const projectPathParam = url.searchParams.get('path') || initialPath;
|
||||
const skillDetail = getSkillDetail(skillName, location, projectPathParam);
|
||||
if (skillDetail.error) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(skillDetail));
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(skillDetail));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// API: Delete skill
|
||||
if (pathname.startsWith('/api/skills/') && req.method === 'DELETE') {
|
||||
const skillName = decodeURIComponent(pathname.replace('/api/skills/', ''));
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { location, projectPath: projectPathParam } = body;
|
||||
return deleteSkill(skillName, location, projectPathParam || initialPath);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// API: Get all rules
|
||||
if (pathname === '/api/rules') {
|
||||
const projectPathParam = url.searchParams.get('path') || initialPath;
|
||||
const rulesData = getRulesConfig(projectPathParam);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(rulesData));
|
||||
return;
|
||||
}
|
||||
|
||||
// API: Get single rule detail
|
||||
if (pathname.startsWith('/api/rules/') && req.method === 'GET' && !pathname.endsWith('/rules/')) {
|
||||
const ruleName = decodeURIComponent(pathname.replace('/api/rules/', ''));
|
||||
const location = url.searchParams.get('location') || 'project';
|
||||
const projectPathParam = url.searchParams.get('path') || initialPath;
|
||||
const ruleDetail = getRuleDetail(ruleName, location, projectPathParam);
|
||||
if (ruleDetail.error) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(ruleDetail));
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(ruleDetail));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// API: Delete rule
|
||||
if (pathname.startsWith('/api/rules/') && req.method === 'DELETE') {
|
||||
const ruleName = decodeURIComponent(pathname.replace('/api/rules/', ''));
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { location, projectPath: projectPathParam } = body;
|
||||
return deleteRule(ruleName, location, projectPathParam || initialPath);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Serve dashboard HTML
|
||||
if (pathname === '/' || pathname === '/index.html') {
|
||||
const html = generateServerDashboard(initialPath);
|
||||
@@ -3704,3 +3892,441 @@ function compareVersions(v1, v2) {
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ========== Skills Helper Functions ==========
|
||||
|
||||
/**
|
||||
* Parse SKILL.md file to extract frontmatter and content
|
||||
* @param {string} content - File content
|
||||
* @returns {Object} Parsed frontmatter and content
|
||||
*/
|
||||
function parseSkillFrontmatter(content) {
|
||||
const result = {
|
||||
name: '',
|
||||
description: '',
|
||||
version: null,
|
||||
allowedTools: [],
|
||||
content: ''
|
||||
};
|
||||
|
||||
// Check for YAML frontmatter
|
||||
if (content.startsWith('---')) {
|
||||
const endIndex = content.indexOf('---', 3);
|
||||
if (endIndex > 0) {
|
||||
const frontmatter = content.substring(3, endIndex).trim();
|
||||
result.content = content.substring(endIndex + 3).trim();
|
||||
|
||||
// Parse frontmatter lines
|
||||
const lines = frontmatter.split('\n');
|
||||
for (const line of lines) {
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
const key = line.substring(0, colonIndex).trim().toLowerCase();
|
||||
const value = line.substring(colonIndex + 1).trim();
|
||||
|
||||
if (key === 'name') {
|
||||
result.name = value.replace(/^["']|["']$/g, '');
|
||||
} else if (key === 'description') {
|
||||
result.description = value.replace(/^["']|["']$/g, '');
|
||||
} else if (key === 'version') {
|
||||
result.version = value.replace(/^["']|["']$/g, '');
|
||||
} else if (key === 'allowed-tools' || key === 'allowedtools') {
|
||||
// Parse as comma-separated or YAML array
|
||||
result.allowedTools = value.replace(/^\[|\]$/g, '').split(',').map(t => t.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.content = content;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skills configuration from project and user directories
|
||||
* @param {string} projectPath
|
||||
* @returns {Object}
|
||||
*/
|
||||
function getSkillsConfig(projectPath) {
|
||||
const result = {
|
||||
projectSkills: [],
|
||||
userSkills: []
|
||||
};
|
||||
|
||||
try {
|
||||
// Project skills: .claude/skills/
|
||||
const projectSkillsDir = join(projectPath, '.claude', 'skills');
|
||||
if (existsSync(projectSkillsDir)) {
|
||||
const skills = readdirSync(projectSkillsDir, { withFileTypes: true });
|
||||
for (const skill of skills) {
|
||||
if (skill.isDirectory()) {
|
||||
const skillMdPath = join(projectSkillsDir, skill.name, 'SKILL.md');
|
||||
if (existsSync(skillMdPath)) {
|
||||
const content = readFileSync(skillMdPath, 'utf8');
|
||||
const parsed = parseSkillFrontmatter(content);
|
||||
|
||||
// Get supporting files
|
||||
const skillDir = join(projectSkillsDir, skill.name);
|
||||
const supportingFiles = getSupportingFiles(skillDir);
|
||||
|
||||
result.projectSkills.push({
|
||||
name: parsed.name || skill.name,
|
||||
description: parsed.description,
|
||||
version: parsed.version,
|
||||
allowedTools: parsed.allowedTools,
|
||||
location: 'project',
|
||||
path: skillDir,
|
||||
supportingFiles
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// User skills: ~/.claude/skills/
|
||||
const userSkillsDir = join(homedir(), '.claude', 'skills');
|
||||
if (existsSync(userSkillsDir)) {
|
||||
const skills = readdirSync(userSkillsDir, { withFileTypes: true });
|
||||
for (const skill of skills) {
|
||||
if (skill.isDirectory()) {
|
||||
const skillMdPath = join(userSkillsDir, skill.name, 'SKILL.md');
|
||||
if (existsSync(skillMdPath)) {
|
||||
const content = readFileSync(skillMdPath, 'utf8');
|
||||
const parsed = parseSkillFrontmatter(content);
|
||||
|
||||
// Get supporting files
|
||||
const skillDir = join(userSkillsDir, skill.name);
|
||||
const supportingFiles = getSupportingFiles(skillDir);
|
||||
|
||||
result.userSkills.push({
|
||||
name: parsed.name || skill.name,
|
||||
description: parsed.description,
|
||||
version: parsed.version,
|
||||
allowedTools: parsed.allowedTools,
|
||||
location: 'user',
|
||||
path: skillDir,
|
||||
supportingFiles
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading skills config:', error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of supporting files for a skill
|
||||
* @param {string} skillDir
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function getSupportingFiles(skillDir) {
|
||||
const files = [];
|
||||
try {
|
||||
const entries = readdirSync(skillDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.name !== 'SKILL.md') {
|
||||
if (entry.isFile()) {
|
||||
files.push(entry.name);
|
||||
} else if (entry.isDirectory()) {
|
||||
files.push(entry.name + '/');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single skill detail
|
||||
* @param {string} skillName
|
||||
* @param {string} location - 'project' or 'user'
|
||||
* @param {string} projectPath
|
||||
* @returns {Object}
|
||||
*/
|
||||
function getSkillDetail(skillName, location, projectPath) {
|
||||
try {
|
||||
const baseDir = location === 'project'
|
||||
? join(projectPath, '.claude', 'skills')
|
||||
: join(homedir(), '.claude', 'skills');
|
||||
|
||||
const skillDir = join(baseDir, skillName);
|
||||
const skillMdPath = join(skillDir, 'SKILL.md');
|
||||
|
||||
if (!existsSync(skillMdPath)) {
|
||||
return { error: 'Skill not found' };
|
||||
}
|
||||
|
||||
const content = readFileSync(skillMdPath, 'utf8');
|
||||
const parsed = parseSkillFrontmatter(content);
|
||||
const supportingFiles = getSupportingFiles(skillDir);
|
||||
|
||||
return {
|
||||
skill: {
|
||||
name: parsed.name || skillName,
|
||||
description: parsed.description,
|
||||
version: parsed.version,
|
||||
allowedTools: parsed.allowedTools,
|
||||
content: parsed.content,
|
||||
location,
|
||||
path: skillDir,
|
||||
supportingFiles
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return { error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a skill
|
||||
* @param {string} skillName
|
||||
* @param {string} location
|
||||
* @param {string} projectPath
|
||||
* @returns {Object}
|
||||
*/
|
||||
function deleteSkill(skillName, location, projectPath) {
|
||||
try {
|
||||
const baseDir = location === 'project'
|
||||
? join(projectPath, '.claude', 'skills')
|
||||
: join(homedir(), '.claude', 'skills');
|
||||
|
||||
const skillDir = join(baseDir, skillName);
|
||||
|
||||
if (!existsSync(skillDir)) {
|
||||
return { error: 'Skill not found' };
|
||||
}
|
||||
|
||||
// Recursively delete directory
|
||||
const deleteRecursive = (dirPath) => {
|
||||
if (existsSync(dirPath)) {
|
||||
readdirSync(dirPath).forEach((file) => {
|
||||
const curPath = join(dirPath, file);
|
||||
if (statSync(curPath).isDirectory()) {
|
||||
deleteRecursive(curPath);
|
||||
} else {
|
||||
unlinkSync(curPath);
|
||||
}
|
||||
});
|
||||
fsPromises.rmdir(dirPath);
|
||||
}
|
||||
};
|
||||
|
||||
deleteRecursive(skillDir);
|
||||
|
||||
return { success: true, skillName, location };
|
||||
} catch (error) {
|
||||
return { error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Rules Helper Functions ==========
|
||||
|
||||
/**
|
||||
* Parse rule file to extract frontmatter (paths) and content
|
||||
* @param {string} content - File content
|
||||
* @returns {Object} Parsed frontmatter and content
|
||||
*/
|
||||
function parseRuleFrontmatter(content) {
|
||||
const result = {
|
||||
paths: [],
|
||||
content: content
|
||||
};
|
||||
|
||||
// Check for YAML frontmatter
|
||||
if (content.startsWith('---')) {
|
||||
const endIndex = content.indexOf('---', 3);
|
||||
if (endIndex > 0) {
|
||||
const frontmatter = content.substring(3, endIndex).trim();
|
||||
result.content = content.substring(endIndex + 3).trim();
|
||||
|
||||
// Parse frontmatter lines
|
||||
const lines = frontmatter.split('\n');
|
||||
for (const line of lines) {
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
const key = line.substring(0, colonIndex).trim().toLowerCase();
|
||||
const value = line.substring(colonIndex + 1).trim();
|
||||
|
||||
if (key === 'paths') {
|
||||
// Parse as comma-separated or YAML array
|
||||
result.paths = value.replace(/^\[|\]$/g, '').split(',').map(t => t.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rules configuration from project and user directories
|
||||
* @param {string} projectPath
|
||||
* @returns {Object}
|
||||
*/
|
||||
function getRulesConfig(projectPath) {
|
||||
const result = {
|
||||
projectRules: [],
|
||||
userRules: []
|
||||
};
|
||||
|
||||
try {
|
||||
// Project rules: .claude/rules/
|
||||
const projectRulesDir = join(projectPath, '.claude', 'rules');
|
||||
if (existsSync(projectRulesDir)) {
|
||||
const rules = scanRulesDirectory(projectRulesDir, 'project', '');
|
||||
result.projectRules = rules;
|
||||
}
|
||||
|
||||
// User rules: ~/.claude/rules/
|
||||
const userRulesDir = join(homedir(), '.claude', 'rules');
|
||||
if (existsSync(userRulesDir)) {
|
||||
const rules = scanRulesDirectory(userRulesDir, 'user', '');
|
||||
result.userRules = rules;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading rules config:', error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scan rules directory for .md files
|
||||
* @param {string} dirPath
|
||||
* @param {string} location
|
||||
* @param {string} subdirectory
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
function scanRulesDirectory(dirPath, location, subdirectory) {
|
||||
const rules = [];
|
||||
|
||||
try {
|
||||
const entries = readdirSync(dirPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dirPath, entry.name);
|
||||
|
||||
if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
const content = readFileSync(fullPath, 'utf8');
|
||||
const parsed = parseRuleFrontmatter(content);
|
||||
|
||||
rules.push({
|
||||
name: entry.name,
|
||||
paths: parsed.paths,
|
||||
content: parsed.content,
|
||||
location,
|
||||
path: fullPath,
|
||||
subdirectory: subdirectory || null
|
||||
});
|
||||
} else if (entry.isDirectory()) {
|
||||
// Recursively scan subdirectories
|
||||
const subRules = scanRulesDirectory(fullPath, location, subdirectory ? `${subdirectory}/${entry.name}` : entry.name);
|
||||
rules.push(...subRules);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single rule detail
|
||||
* @param {string} ruleName
|
||||
* @param {string} location - 'project' or 'user'
|
||||
* @param {string} projectPath
|
||||
* @returns {Object}
|
||||
*/
|
||||
function getRuleDetail(ruleName, location, projectPath) {
|
||||
try {
|
||||
const baseDir = location === 'project'
|
||||
? join(projectPath, '.claude', 'rules')
|
||||
: join(homedir(), '.claude', 'rules');
|
||||
|
||||
// Find the rule file (could be in subdirectory)
|
||||
const rulePath = findRuleFile(baseDir, ruleName);
|
||||
|
||||
if (!rulePath) {
|
||||
return { error: 'Rule not found' };
|
||||
}
|
||||
|
||||
const content = readFileSync(rulePath, 'utf8');
|
||||
const parsed = parseRuleFrontmatter(content);
|
||||
|
||||
return {
|
||||
rule: {
|
||||
name: ruleName,
|
||||
paths: parsed.paths,
|
||||
content: parsed.content,
|
||||
location,
|
||||
path: rulePath
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return { error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find rule file in directory (including subdirectories)
|
||||
* @param {string} baseDir
|
||||
* @param {string} ruleName
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function findRuleFile(baseDir, ruleName) {
|
||||
try {
|
||||
// Direct path
|
||||
const directPath = join(baseDir, ruleName);
|
||||
if (existsSync(directPath)) {
|
||||
return directPath;
|
||||
}
|
||||
|
||||
// Search in subdirectories
|
||||
const entries = readdirSync(baseDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const subPath = findRuleFile(join(baseDir, entry.name), ruleName);
|
||||
if (subPath) return subPath;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a rule
|
||||
* @param {string} ruleName
|
||||
* @param {string} location
|
||||
* @param {string} projectPath
|
||||
* @returns {Object}
|
||||
*/
|
||||
function deleteRule(ruleName, location, projectPath) {
|
||||
try {
|
||||
const baseDir = location === 'project'
|
||||
? join(projectPath, '.claude', 'rules')
|
||||
: join(homedir(), '.claude', 'rules');
|
||||
|
||||
const rulePath = findRuleFile(baseDir, ruleName);
|
||||
|
||||
if (!rulePath) {
|
||||
return { error: 'Rule not found' };
|
||||
}
|
||||
|
||||
unlinkSync(rulePath);
|
||||
|
||||
return { success: true, ruleName, location };
|
||||
} catch (error) {
|
||||
return { error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
gap: 1rem;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.prompt-timeline-header h3 {
|
||||
@@ -125,6 +126,7 @@
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.prompt-timeline-filters {
|
||||
@@ -133,12 +135,16 @@
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.prompt-search-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
min-width: 120px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
@@ -149,6 +155,7 @@
|
||||
transform: translateY(-50%);
|
||||
color: hsl(var(--muted-foreground));
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.prompt-search-input {
|
||||
@@ -225,28 +232,102 @@
|
||||
.prompt-session-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: 0;
|
||||
position: relative;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
/* Prompt Items */
|
||||
/* Timeline axis - subtle vertical line */
|
||||
.prompt-session-items::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.4375rem;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: hsl(var(--border));
|
||||
}
|
||||
|
||||
/* Prompt Items - Card style matching memory timeline */
|
||||
.prompt-item {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem;
|
||||
background: hsl(var(--card));
|
||||
margin-bottom: 0.625rem;
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.15s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Timeline dot - clean circle */
|
||||
.prompt-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -2rem;
|
||||
top: 1rem;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: hsl(var(--background));
|
||||
border: 2px solid hsl(var(--muted-foreground) / 0.4);
|
||||
border-radius: 50%;
|
||||
transform: translateX(50%);
|
||||
z-index: 1;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Timeline connector line to card */
|
||||
.prompt-item::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -1.25rem;
|
||||
top: 1.25rem;
|
||||
width: 1rem;
|
||||
height: 2px;
|
||||
background: hsl(var(--border));
|
||||
}
|
||||
|
||||
.prompt-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prompt-item:hover {
|
||||
border-color: hsl(var(--primary) / 0.3);
|
||||
box-shadow: 0 2px 8px hsl(var(--primary) / 0.1);
|
||||
background: hsl(var(--hover));
|
||||
}
|
||||
|
||||
.prompt-item:hover::before {
|
||||
border-color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.prompt-item:hover::after {
|
||||
background: hsl(var(--primary) / 0.3);
|
||||
}
|
||||
|
||||
.prompt-item-expanded {
|
||||
max-height: none;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
border-color: hsl(var(--primary) / 0.5);
|
||||
}
|
||||
|
||||
.prompt-item-expanded::before {
|
||||
background: hsl(var(--primary));
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.prompt-item-expanded::after {
|
||||
background: hsl(var(--primary) / 0.5);
|
||||
}
|
||||
|
||||
/* Inner content layout */
|
||||
.prompt-item-inner {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.prompt-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -665,3 +746,322 @@
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* ========== Insights History Cards ========== */
|
||||
.insights-history-container {
|
||||
padding: 0.75rem;
|
||||
max-height: calc(100vh - 300px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.insights-history-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.insight-history-card {
|
||||
padding: 0.875rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-left-width: 3px;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.insight-history-card:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px hsl(var(--foreground) / 0.08);
|
||||
}
|
||||
|
||||
.insight-history-card.high {
|
||||
border-left-color: hsl(0, 84%, 60%);
|
||||
}
|
||||
|
||||
.insight-history-card.medium {
|
||||
border-left-color: hsl(48, 96%, 53%);
|
||||
}
|
||||
|
||||
.insight-history-card.low {
|
||||
border-left-color: hsl(142, 71%, 45%);
|
||||
}
|
||||
|
||||
.insight-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.insight-card-tool {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.insight-card-tool i {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.insight-card-time {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.insight-card-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.insight-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.insight-stat-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.insight-stat-label {
|
||||
font-size: 0.625rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.insight-card-preview {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.pattern-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pattern-preview.high {
|
||||
background: hsl(0, 84%, 95%);
|
||||
}
|
||||
|
||||
.pattern-preview.medium {
|
||||
background: hsl(48, 96%, 95%);
|
||||
}
|
||||
|
||||
.pattern-preview.low {
|
||||
background: hsl(142, 71%, 95%);
|
||||
}
|
||||
|
||||
.pattern-preview .pattern-type {
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pattern-preview .pattern-desc {
|
||||
color: hsl(var(--muted-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Insight Detail Panel */
|
||||
.insight-detail-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
height: 100vh;
|
||||
background: hsl(var(--card));
|
||||
border-left: 1px solid hsl(var(--border));
|
||||
box-shadow: -4px 0 16px hsl(var(--foreground) / 0.1);
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
animation: slideInRight 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.insight-detail {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.insight-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.insight-detail-header h4 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.insight-detail-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.insight-detail-meta span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.insight-patterns,
|
||||
.insight-suggestions {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.insight-patterns h5,
|
||||
.insight-suggestions h5 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.patterns-list,
|
||||
.suggestions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.pattern-item {
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-left-width: 3px;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.pattern-item.high {
|
||||
border-left-color: hsl(0, 84%, 60%);
|
||||
}
|
||||
|
||||
.pattern-item.medium {
|
||||
border-left-color: hsl(48, 96%, 53%);
|
||||
}
|
||||
|
||||
.pattern-item.low {
|
||||
border-left-color: hsl(142, 71%, 45%);
|
||||
}
|
||||
|
||||
.pattern-item .pattern-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.pattern-type-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pattern-severity {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.pattern-occurrences {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.pattern-item .pattern-description {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.pattern-item .pattern-suggestion {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.suggestion-item .suggestion-title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.suggestion-item .suggestion-description {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.suggestion-item .suggestion-example {
|
||||
padding: 0.5rem;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.suggestion-item .suggestion-example code {
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.insight-detail-actions {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
216
ccw/src/templates/dashboard-css/12-skills-rules.css
Normal file
216
ccw/src/templates/dashboard-css/12-skills-rules.css
Normal file
@@ -0,0 +1,216 @@
|
||||
/* ==========================================
|
||||
SKILLS & RULES MANAGER STYLES
|
||||
========================================== */
|
||||
|
||||
/* Skills Manager */
|
||||
.skills-manager {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skills-manager.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.skills-section {
|
||||
margin-bottom: 2rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skills-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skill-card {
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.skill-card:hover {
|
||||
border-color: hsl(var(--primary));
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.skills-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
/* Skill Detail Panel */
|
||||
.skill-detail-panel {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.skill-detail-overlay {
|
||||
animation: fadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
/* Rules Manager */
|
||||
.rules-manager {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rules-manager.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.rules-section {
|
||||
margin-bottom: 2rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rules-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rule-card {
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.rule-card:hover {
|
||||
border-color: hsl(var(--success));
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.rules-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
/* Rule Detail Panel */
|
||||
.rule-detail-panel {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.rule-detail-overlay {
|
||||
animation: fadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
/* Shared Animations */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Line clamp utility for card descriptions */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.skills-grid,
|
||||
.rules-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.skill-detail-panel,
|
||||
.rule-detail-panel {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Badge styles for skills and rules */
|
||||
.skill-card .badge,
|
||||
.rule-card .badge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Code preview in rule cards */
|
||||
.rule-card pre {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Create modal styles (shared) */
|
||||
.skill-modal,
|
||||
.rule-modal {
|
||||
animation: fadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
.skill-modal-backdrop,
|
||||
.rule-modal-backdrop {
|
||||
animation: fadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
.skill-modal-content,
|
||||
.rule-modal-content {
|
||||
animation: slideUp 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.skill-modal.hidden,
|
||||
.rule-modal.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Form group styles */
|
||||
.skill-modal .form-group label,
|
||||
.rule-modal .form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.skill-modal input,
|
||||
.skill-modal textarea,
|
||||
.rule-modal input,
|
||||
.rule-modal textarea {
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.skill-modal input:focus,
|
||||
.skill-modal textarea:focus,
|
||||
.rule-modal input:focus,
|
||||
.rule-modal textarea:focus {
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.2);
|
||||
}
|
||||
@@ -108,6 +108,10 @@ function initNavigation() {
|
||||
renderMemoryView();
|
||||
} else if (currentView === 'prompt-history') {
|
||||
renderPromptHistoryView();
|
||||
} else if (currentView === 'skills-manager') {
|
||||
renderSkillsManager();
|
||||
} else if (currentView === 'rules-manager') {
|
||||
renderRulesManager();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -136,6 +140,10 @@ function updateContentTitle() {
|
||||
titleEl.textContent = t('title.memoryModule');
|
||||
} else if (currentView === 'prompt-history') {
|
||||
titleEl.textContent = t('title.promptHistory');
|
||||
} else if (currentView === 'skills-manager') {
|
||||
titleEl.textContent = t('title.skillsManager');
|
||||
} else if (currentView === 'rules-manager') {
|
||||
titleEl.textContent = t('title.rulesManager');
|
||||
} else if (currentView === 'liteTasks') {
|
||||
const names = { 'lite-plan': t('title.litePlanSessions'), 'lite-fix': t('title.liteFixSessions') };
|
||||
titleEl.textContent = names[currentLiteType] || t('title.liteTasks');
|
||||
|
||||
@@ -624,6 +624,62 @@ const i18n = {
|
||||
'memory.prompts': 'prompts',
|
||||
'memory.refreshInsights': 'Refresh',
|
||||
|
||||
// Skills
|
||||
'nav.skills': 'Skills',
|
||||
'title.skillsManager': 'Skills Manager',
|
||||
'skills.title': 'Skills Manager',
|
||||
'skills.description': 'Manage Claude Code skills and capabilities',
|
||||
'skills.create': 'Create Skill',
|
||||
'skills.projectSkills': 'Project Skills',
|
||||
'skills.userSkills': 'User Skills',
|
||||
'skills.skillsCount': 'skills',
|
||||
'skills.noProjectSkills': 'No project skills found',
|
||||
'skills.createHint': 'Create a skill in .claude/skills/ to add capabilities',
|
||||
'skills.noUserSkills': 'No user skills found',
|
||||
'skills.userSkillsHint': 'User skills apply to all your projects',
|
||||
'skills.noDescription': 'No description provided',
|
||||
'skills.tools': 'tools',
|
||||
'skills.files': 'files',
|
||||
'skills.descriptionLabel': 'Description',
|
||||
'skills.metadata': 'Metadata',
|
||||
'skills.location': 'Location',
|
||||
'skills.version': 'Version',
|
||||
'skills.allowedTools': 'Allowed Tools',
|
||||
'skills.supportingFiles': 'Supporting Files',
|
||||
'skills.path': 'Path',
|
||||
'skills.loadError': 'Failed to load skill details',
|
||||
'skills.deleteConfirm': 'Are you sure you want to delete the skill "{name}"?',
|
||||
'skills.deleted': 'Skill deleted successfully',
|
||||
'skills.deleteError': 'Failed to delete skill',
|
||||
'skills.editNotImplemented': 'Edit feature coming soon',
|
||||
'skills.createNotImplemented': 'Create feature coming soon',
|
||||
|
||||
// Rules
|
||||
'nav.rules': 'Rules',
|
||||
'title.rulesManager': 'Rules Manager',
|
||||
'rules.title': 'Rules Manager',
|
||||
'rules.description': 'Manage project and user rules for Claude Code',
|
||||
'rules.create': 'Create Rule',
|
||||
'rules.projectRules': 'Project Rules',
|
||||
'rules.userRules': 'User Rules',
|
||||
'rules.rulesCount': 'rules',
|
||||
'rules.noProjectRules': 'No project rules found',
|
||||
'rules.createHint': 'Create rules in .claude/rules/ for project-specific instructions',
|
||||
'rules.noUserRules': 'No user rules found',
|
||||
'rules.userRulesHint': 'User rules apply to all your projects',
|
||||
'rules.typeLabel': 'Type',
|
||||
'rules.conditional': 'Conditional',
|
||||
'rules.global': 'Global',
|
||||
'rules.pathConditions': 'Path Conditions',
|
||||
'rules.content': 'Content',
|
||||
'rules.filePath': 'File Path',
|
||||
'rules.loadError': 'Failed to load rule details',
|
||||
'rules.deleteConfirm': 'Are you sure you want to delete the rule "{name}"?',
|
||||
'rules.deleted': 'Rule deleted successfully',
|
||||
'rules.deleteError': 'Failed to delete rule',
|
||||
'rules.editNotImplemented': 'Edit feature coming soon',
|
||||
'rules.createNotImplemented': 'Create feature coming soon',
|
||||
|
||||
// Common
|
||||
'common.cancel': 'Cancel',
|
||||
'common.create': 'Create',
|
||||
@@ -1258,6 +1314,62 @@ const i18n = {
|
||||
'memory.prompts': '提示',
|
||||
'memory.refreshInsights': '刷新',
|
||||
|
||||
// Skills
|
||||
'nav.skills': '技能',
|
||||
'title.skillsManager': '技能管理',
|
||||
'skills.title': '技能管理',
|
||||
'skills.description': '管理 Claude Code 的技能和能力',
|
||||
'skills.create': '创建技能',
|
||||
'skills.projectSkills': '项目技能',
|
||||
'skills.userSkills': '用户技能',
|
||||
'skills.skillsCount': '个技能',
|
||||
'skills.noProjectSkills': '未找到项目技能',
|
||||
'skills.createHint': '在 .claude/skills/ 中创建技能以添加功能',
|
||||
'skills.noUserSkills': '未找到用户技能',
|
||||
'skills.userSkillsHint': '用户技能适用于所有项目',
|
||||
'skills.noDescription': '无描述',
|
||||
'skills.tools': '工具',
|
||||
'skills.files': '文件',
|
||||
'skills.descriptionLabel': '描述',
|
||||
'skills.metadata': '元数据',
|
||||
'skills.location': '位置',
|
||||
'skills.version': '版本',
|
||||
'skills.allowedTools': '允许的工具',
|
||||
'skills.supportingFiles': '支持文件',
|
||||
'skills.path': '路径',
|
||||
'skills.loadError': '加载技能详情失败',
|
||||
'skills.deleteConfirm': '确定要删除技能 "{name}" 吗?',
|
||||
'skills.deleted': '技能删除成功',
|
||||
'skills.deleteError': '删除技能失败',
|
||||
'skills.editNotImplemented': '编辑功能即将推出',
|
||||
'skills.createNotImplemented': '创建功能即将推出',
|
||||
|
||||
// Rules
|
||||
'nav.rules': '规则',
|
||||
'title.rulesManager': '规则管理',
|
||||
'rules.title': '规则管理',
|
||||
'rules.description': '管理 Claude Code 的项目和用户规则',
|
||||
'rules.create': '创建规则',
|
||||
'rules.projectRules': '项目规则',
|
||||
'rules.userRules': '用户规则',
|
||||
'rules.rulesCount': '条规则',
|
||||
'rules.noProjectRules': '未找到项目规则',
|
||||
'rules.createHint': '在 .claude/rules/ 中创建规则以设置项目特定指令',
|
||||
'rules.noUserRules': '未找到用户规则',
|
||||
'rules.userRulesHint': '用户规则适用于所有项目',
|
||||
'rules.typeLabel': '类型',
|
||||
'rules.conditional': '条件规则',
|
||||
'rules.global': '全局规则',
|
||||
'rules.pathConditions': '路径条件',
|
||||
'rules.content': '内容',
|
||||
'rules.filePath': '文件路径',
|
||||
'rules.loadError': '加载规则详情失败',
|
||||
'rules.deleteConfirm': '确定要删除规则 "{name}" 吗?',
|
||||
'rules.deleted': '规则删除成功',
|
||||
'rules.deleteError': '删除规则失败',
|
||||
'rules.editNotImplemented': '编辑功能即将推出',
|
||||
'rules.createNotImplemented': '创建功能即将推出',
|
||||
|
||||
// Common
|
||||
'common.cancel': '取消',
|
||||
'common.create': '创建',
|
||||
|
||||
@@ -58,14 +58,12 @@ async function renderMemoryView() {
|
||||
'<div class="memory-column center" id="memory-graph"></div>' +
|
||||
'<div class="memory-column right" id="memory-context"></div>' +
|
||||
'</div>' +
|
||||
'<div class="memory-insights-section" id="memory-insights"></div>' +
|
||||
'</div>';
|
||||
|
||||
// Render each column
|
||||
renderHotspotsColumn();
|
||||
renderGraphColumn();
|
||||
renderContextColumn();
|
||||
renderInsightsSection();
|
||||
|
||||
// Initialize Lucide icons
|
||||
if (window.lucide) lucide.createIcons();
|
||||
|
||||
@@ -8,6 +8,8 @@ var promptHistorySearch = '';
|
||||
var promptHistoryDateFilter = null;
|
||||
var promptHistoryProjectFilter = null;
|
||||
var selectedPromptId = null;
|
||||
var promptInsightsHistory = []; // Insights analysis history
|
||||
var selectedPromptInsight = null; // Currently selected insight for detail view
|
||||
|
||||
// ========== Data Loading ==========
|
||||
async function loadPromptHistory() {
|
||||
@@ -40,6 +42,20 @@ async function loadPromptInsights() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPromptInsightsHistory() {
|
||||
try {
|
||||
var response = await fetch('/api/memory/insights?limit=20');
|
||||
if (!response.ok) throw new Error('Failed to load insights history');
|
||||
var data = await response.json();
|
||||
promptInsightsHistory = data.insights || [];
|
||||
return promptInsightsHistory;
|
||||
} catch (err) {
|
||||
console.error('Failed to load insights history:', err);
|
||||
promptInsightsHistory = [];
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Rendering ==========
|
||||
async function renderPromptHistoryView() {
|
||||
var container = document.getElementById('mainContent');
|
||||
@@ -52,7 +68,7 @@ async function renderPromptHistoryView() {
|
||||
if (searchInput) searchInput.parentElement.style.display = 'none';
|
||||
|
||||
// Load data
|
||||
await Promise.all([loadPromptHistory(), loadPromptInsights()]);
|
||||
await Promise.all([loadPromptHistory(), loadPromptInsights(), loadPromptInsightsHistory()]);
|
||||
|
||||
// Calculate stats
|
||||
var totalPrompts = promptHistoryData.length;
|
||||
@@ -232,51 +248,207 @@ function renderInsightsPanel() {
|
||||
return html;
|
||||
}
|
||||
|
||||
if (!promptInsights || !promptInsights.patterns || promptInsights.patterns.length === 0) {
|
||||
html += '<div class="insights-empty-state">' +
|
||||
// Show insights history cards
|
||||
html += '<div class="insights-history-container">' +
|
||||
renderPromptInsightsHistory() +
|
||||
'</div>';
|
||||
|
||||
// Show detail panel if an insight is selected
|
||||
if (selectedPromptInsight) {
|
||||
html += '<div class="insight-detail-panel" id="promptInsightDetailPanel">' +
|
||||
renderPromptInsightDetail(selectedPromptInsight) +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderPromptInsightsHistory() {
|
||||
if (!promptInsightsHistory || promptInsightsHistory.length === 0) {
|
||||
return '<div class="insights-empty-state">' +
|
||||
'<i data-lucide="brain" class="w-10 h-10"></i>' +
|
||||
'<p>' + t('prompt.noInsights') + '</p>' +
|
||||
'<p class="insights-hint">' + t('prompt.noInsightsText') + '</p>' +
|
||||
'</div>';
|
||||
} else {
|
||||
html += '<div class="insights-list">';
|
||||
|
||||
// Render detected patterns
|
||||
if (promptInsights.patterns && promptInsights.patterns.length > 0) {
|
||||
html += '<div class="insights-section">' +
|
||||
'<h4><i data-lucide="alert-circle" class="w-4 h-4"></i> Detected Patterns</h4>';
|
||||
for (var i = 0; i < promptInsights.patterns.length; i++) {
|
||||
html += renderPatternCard(promptInsights.patterns[i]);
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// Render suggestions
|
||||
if (promptInsights.suggestions && promptInsights.suggestions.length > 0) {
|
||||
html += '<div class="insights-section">' +
|
||||
'<h4><i data-lucide="zap" class="w-4 h-4"></i> Optimization Suggestions</h4>';
|
||||
for (var j = 0; j < promptInsights.suggestions.length; j++) {
|
||||
html += renderSuggestionCard(promptInsights.suggestions[j]);
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// Render similar successful prompts
|
||||
if (promptInsights.similar_prompts && promptInsights.similar_prompts.length > 0) {
|
||||
html += '<div class="insights-section">' +
|
||||
'<h4><i data-lucide="stars" class="w-4 h-4"></i> Similar Successful Prompts</h4>';
|
||||
for (var k = 0; k < promptInsights.similar_prompts.length; k++) {
|
||||
html += renderSimilarPromptCard(promptInsights.similar_prompts[k]);
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
return '<div class="insights-history-cards">' +
|
||||
promptInsightsHistory.map(function(insight) {
|
||||
var patternCount = (insight.patterns || []).length;
|
||||
var suggestionCount = (insight.suggestions || []).length;
|
||||
var severity = getPromptInsightSeverity(insight.patterns);
|
||||
var timeAgo = formatPromptTimestamp(insight.created_at);
|
||||
|
||||
return '<div class="insight-history-card ' + severity + '" onclick="showPromptInsightDetail(\'' + insight.id + '\')">' +
|
||||
'<div class="insight-card-header">' +
|
||||
'<div class="insight-card-tool">' +
|
||||
'<i data-lucide="' + getPromptToolIcon(insight.tool) + '" class="w-4 h-4"></i>' +
|
||||
'<span>' + (insight.tool || 'CLI') + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="insight-card-time">' + timeAgo + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="insight-card-stats">' +
|
||||
'<div class="insight-stat">' +
|
||||
'<span class="insight-stat-value">' + patternCount + '</span>' +
|
||||
'<span class="insight-stat-label">' + (isZh() ? '模式' : 'Patterns') + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="insight-stat">' +
|
||||
'<span class="insight-stat-value">' + suggestionCount + '</span>' +
|
||||
'<span class="insight-stat-label">' + (isZh() ? '建议' : 'Suggestions') + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="insight-stat">' +
|
||||
'<span class="insight-stat-value">' + (insight.prompt_count || 0) + '</span>' +
|
||||
'<span class="insight-stat-label">' + (isZh() ? '提示' : 'Prompts') + '</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
(insight.patterns && insight.patterns.length > 0 ?
|
||||
'<div class="insight-card-preview">' +
|
||||
'<div class="pattern-preview ' + (insight.patterns[0].severity || 'low') + '">' +
|
||||
'<span class="pattern-type">' + escapeHtml(insight.patterns[0].type || 'pattern') + '</span>' +
|
||||
'<span class="pattern-desc">' + escapeHtml((insight.patterns[0].description || '').substring(0, 60)) + '...</span>' +
|
||||
'</div>' +
|
||||
'</div>' : '') +
|
||||
'</div>';
|
||||
}).join('') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function getPromptInsightSeverity(patterns) {
|
||||
if (!patterns || patterns.length === 0) return 'low';
|
||||
var hasHigh = patterns.some(function(p) { return p.severity === 'high'; });
|
||||
var hasMedium = patterns.some(function(p) { return p.severity === 'medium'; });
|
||||
return hasHigh ? 'high' : (hasMedium ? 'medium' : 'low');
|
||||
}
|
||||
|
||||
function getPromptToolIcon(tool) {
|
||||
switch(tool) {
|
||||
case 'gemini': return 'sparkles';
|
||||
case 'qwen': return 'bot';
|
||||
case 'codex': return 'code-2';
|
||||
default: return 'cpu';
|
||||
}
|
||||
}
|
||||
|
||||
function formatPromptTimestamp(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
var date = new Date(timestamp);
|
||||
var now = new Date();
|
||||
var diff = now - date;
|
||||
var minutes = Math.floor(diff / 60000);
|
||||
var hours = Math.floor(diff / 3600000);
|
||||
var days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return isZh() ? '刚刚' : 'Just now';
|
||||
if (minutes < 60) return minutes + (isZh() ? ' 分钟前' : 'm ago');
|
||||
if (hours < 24) return hours + (isZh() ? ' 小时前' : 'h ago');
|
||||
if (days < 7) return days + (isZh() ? ' 天前' : 'd ago');
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
async function showPromptInsightDetail(insightId) {
|
||||
try {
|
||||
var response = await fetch('/api/memory/insights/' + insightId);
|
||||
if (!response.ok) throw new Error('Failed to load insight detail');
|
||||
var data = await response.json();
|
||||
selectedPromptInsight = data.insight;
|
||||
renderPromptHistoryView();
|
||||
} catch (err) {
|
||||
console.error('Failed to load insight detail:', err);
|
||||
if (window.showToast) {
|
||||
showToast(isZh() ? '加载洞察详情失败' : 'Failed to load insight detail', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closePromptInsightDetail() {
|
||||
selectedPromptInsight = null;
|
||||
renderPromptHistoryView();
|
||||
}
|
||||
|
||||
function renderPromptInsightDetail(insight) {
|
||||
if (!insight) return '';
|
||||
|
||||
var html = '<div class="insight-detail">' +
|
||||
'<div class="insight-detail-header">' +
|
||||
'<h4><i data-lucide="lightbulb" class="w-4 h-4"></i> ' + (isZh() ? '洞察详情' : 'Insight Detail') + '</h4>' +
|
||||
'<button class="btn-icon" onclick="closePromptInsightDetail()" title="' + t('common.close') + '">' +
|
||||
'<i data-lucide="x" class="w-4 h-4"></i>' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'<div class="insight-detail-meta">' +
|
||||
'<span><i data-lucide="' + getPromptToolIcon(insight.tool) + '" class="w-3 h-3"></i> ' + (insight.tool || 'CLI') + '</span>' +
|
||||
'<span><i data-lucide="clock" class="w-3 h-3"></i> ' + formatPromptTimestamp(insight.created_at) + '</span>' +
|
||||
'<span><i data-lucide="file-text" class="w-3 h-3"></i> ' + (insight.prompt_count || 0) + ' ' + (isZh() ? '个提示已分析' : 'prompts analyzed') + '</span>' +
|
||||
'</div>';
|
||||
|
||||
// Patterns
|
||||
if (insight.patterns && insight.patterns.length > 0) {
|
||||
html += '<div class="insight-patterns">' +
|
||||
'<h5><i data-lucide="alert-triangle" class="w-3.5 h-3.5"></i> ' + (isZh() ? '发现的模式' : 'Patterns Found') + ' (' + insight.patterns.length + ')</h5>' +
|
||||
'<div class="patterns-list">' +
|
||||
insight.patterns.map(function(p) {
|
||||
return '<div class="pattern-item ' + (p.severity || 'low') + '">' +
|
||||
'<div class="pattern-header">' +
|
||||
'<span class="pattern-type-badge">' + escapeHtml(p.type || 'pattern') + '</span>' +
|
||||
'<span class="pattern-severity">' + (p.severity || 'low') + '</span>' +
|
||||
(p.occurrences ? '<span class="pattern-occurrences">' + p.occurrences + 'x</span>' : '') +
|
||||
'</div>' +
|
||||
'<div class="pattern-description">' + escapeHtml(p.description || '') + '</div>' +
|
||||
(p.suggestion ? '<div class="pattern-suggestion"><i data-lucide="arrow-right" class="w-3 h-3"></i> ' + escapeHtml(p.suggestion) + '</div>' : '') +
|
||||
'</div>';
|
||||
}).join('') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Suggestions
|
||||
if (insight.suggestions && insight.suggestions.length > 0) {
|
||||
html += '<div class="insight-suggestions">' +
|
||||
'<h5><i data-lucide="lightbulb" class="w-3.5 h-3.5"></i> ' + (isZh() ? '提供的建议' : 'Suggestions') + ' (' + insight.suggestions.length + ')</h5>' +
|
||||
'<div class="suggestions-list">' +
|
||||
insight.suggestions.map(function(s) {
|
||||
return '<div class="suggestion-item">' +
|
||||
'<div class="suggestion-title">' + escapeHtml(s.title || '') + '</div>' +
|
||||
'<div class="suggestion-description">' + escapeHtml(s.description || '') + '</div>' +
|
||||
(s.example ? '<div class="suggestion-example"><code>' + escapeHtml(s.example) + '</code></div>' : '') +
|
||||
'</div>';
|
||||
}).join('') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
html += '<div class="insight-detail-actions">' +
|
||||
'<button class="btn btn-sm btn-danger" onclick="deletePromptInsight(\'' + insight.id + '\')">' +
|
||||
'<i data-lucide="trash-2" class="w-3.5 h-3.5"></i> ' + t('common.delete') +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
async function deletePromptInsight(insightId) {
|
||||
if (!confirm(isZh() ? '确定要删除这条洞察记录吗?' : 'Are you sure you want to delete this insight?')) return;
|
||||
|
||||
try {
|
||||
var response = await fetch('/api/memory/insights/' + insightId, { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('Failed to delete insight');
|
||||
|
||||
selectedPromptInsight = null;
|
||||
await loadPromptInsightsHistory();
|
||||
renderPromptHistoryView();
|
||||
|
||||
if (window.showToast) {
|
||||
showToast(isZh() ? '洞察已删除' : 'Insight deleted', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete insight:', err);
|
||||
if (window.showToast) {
|
||||
showToast(isZh() ? '删除洞察失败' : 'Failed to delete insight', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderPatternCard(pattern) {
|
||||
var iconMap = {
|
||||
'vague': 'help-circle',
|
||||
|
||||
343
ccw/src/templates/dashboard-js/views/rules-manager.js
Normal file
343
ccw/src/templates/dashboard-js/views/rules-manager.js
Normal file
@@ -0,0 +1,343 @@
|
||||
// Rules Manager View
|
||||
// Manages Claude Code rules (.claude/rules/)
|
||||
|
||||
// ========== Rules State ==========
|
||||
var rulesData = {
|
||||
projectRules: [],
|
||||
userRules: []
|
||||
};
|
||||
var selectedRule = null;
|
||||
var rulesLoading = false;
|
||||
|
||||
// ========== Main Render Function ==========
|
||||
async function renderRulesManager() {
|
||||
const container = document.getElementById('mainContent');
|
||||
if (!container) return;
|
||||
|
||||
// Hide stats grid and search
|
||||
const statsGrid = document.getElementById('statsGrid');
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (statsGrid) statsGrid.style.display = 'none';
|
||||
if (searchInput) searchInput.parentElement.style.display = 'none';
|
||||
|
||||
// Show loading state
|
||||
container.innerHTML = '<div class="rules-manager loading">' +
|
||||
'<div class="loading-spinner"><i data-lucide="loader-2" class="w-8 h-8 animate-spin"></i></div>' +
|
||||
'<p>' + t('common.loading') + '</p>' +
|
||||
'</div>';
|
||||
|
||||
// Load rules data
|
||||
await loadRulesData();
|
||||
|
||||
// Render the main view
|
||||
renderRulesView();
|
||||
}
|
||||
|
||||
async function loadRulesData() {
|
||||
rulesLoading = true;
|
||||
try {
|
||||
const response = await fetch('/api/rules?path=' + encodeURIComponent(projectPath));
|
||||
if (!response.ok) throw new Error('Failed to load rules');
|
||||
const data = await response.json();
|
||||
rulesData = {
|
||||
projectRules: data.projectRules || [],
|
||||
userRules: data.userRules || []
|
||||
};
|
||||
// Update badge
|
||||
updateRulesBadge();
|
||||
} catch (err) {
|
||||
console.error('Failed to load rules:', err);
|
||||
rulesData = { projectRules: [], userRules: [] };
|
||||
} finally {
|
||||
rulesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateRulesBadge() {
|
||||
const badge = document.getElementById('badgeRules');
|
||||
if (badge) {
|
||||
const total = rulesData.projectRules.length + rulesData.userRules.length;
|
||||
badge.textContent = total;
|
||||
}
|
||||
}
|
||||
|
||||
function renderRulesView() {
|
||||
const container = document.getElementById('mainContent');
|
||||
if (!container) return;
|
||||
|
||||
const projectRules = rulesData.projectRules || [];
|
||||
const userRules = rulesData.userRules || [];
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="rules-manager">
|
||||
<!-- Header -->
|
||||
<div class="rules-header mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-success/10 rounded-lg flex items-center justify-center">
|
||||
<i data-lucide="book-open" class="w-5 h-5 text-success"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-foreground">${t('rules.title')}</h2>
|
||||
<p class="text-sm text-muted-foreground">${t('rules.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity flex items-center gap-2"
|
||||
onclick="openRuleCreateModal()">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
${t('rules.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Rules Section -->
|
||||
<div class="rules-section mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i data-lucide="folder" class="w-5 h-5 text-success"></i>
|
||||
<h3 class="text-lg font-semibold text-foreground">${t('rules.projectRules')}</h3>
|
||||
<span class="text-xs px-2 py-0.5 bg-success/10 text-success rounded-full">.claude/rules/</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">${projectRules.length} ${t('rules.rulesCount')}</span>
|
||||
</div>
|
||||
|
||||
${projectRules.length === 0 ? `
|
||||
<div class="rules-empty-state bg-card border border-border rounded-lg p-6 text-center">
|
||||
<div class="text-muted-foreground mb-3"><i data-lucide="book-open" class="w-10 h-10 mx-auto"></i></div>
|
||||
<p class="text-muted-foreground">${t('rules.noProjectRules')}</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">${t('rules.createHint')}</p>
|
||||
</div>
|
||||
` : `
|
||||
<div class="rules-grid grid gap-3">
|
||||
${projectRules.map(rule => renderRuleCard(rule, 'project')).join('')}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- User Rules Section -->
|
||||
<div class="rules-section mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i data-lucide="user" class="w-5 h-5 text-orange"></i>
|
||||
<h3 class="text-lg font-semibold text-foreground">${t('rules.userRules')}</h3>
|
||||
<span class="text-xs px-2 py-0.5 bg-orange/10 text-orange rounded-full">~/.claude/rules/</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">${userRules.length} ${t('rules.rulesCount')}</span>
|
||||
</div>
|
||||
|
||||
${userRules.length === 0 ? `
|
||||
<div class="rules-empty-state bg-card border border-border rounded-lg p-6 text-center">
|
||||
<div class="text-muted-foreground mb-3"><i data-lucide="user" class="w-10 h-10 mx-auto"></i></div>
|
||||
<p class="text-muted-foreground">${t('rules.noUserRules')}</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">${t('rules.userRulesHint')}</p>
|
||||
</div>
|
||||
` : `
|
||||
<div class="rules-grid grid gap-3">
|
||||
${userRules.map(rule => renderRuleCard(rule, 'user')).join('')}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- Rule Detail Panel -->
|
||||
${selectedRule ? renderRuleDetailPanel(selectedRule) : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Initialize Lucide icons
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
|
||||
function renderRuleCard(rule, location) {
|
||||
const hasPathCondition = rule.paths && rule.paths.length > 0;
|
||||
const isGlobal = !hasPathCondition;
|
||||
const locationIcon = location === 'project' ? 'folder' : 'user';
|
||||
const locationClass = location === 'project' ? 'text-success' : 'text-orange';
|
||||
const locationBg = location === 'project' ? 'bg-success/10' : 'bg-orange/10';
|
||||
|
||||
// Get preview of content (first 100 chars)
|
||||
const contentPreview = rule.content ? rule.content.substring(0, 100).replace(/\n/g, ' ') + (rule.content.length > 100 ? '...' : '') : '';
|
||||
|
||||
return `
|
||||
<div class="rule-card bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all cursor-pointer"
|
||||
onclick="showRuleDetail('${escapeHtml(rule.name)}', '${location}')">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 ${locationBg} rounded-lg flex items-center justify-center">
|
||||
<i data-lucide="file-text" class="w-5 h-5 ${locationClass}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-foreground">${escapeHtml(rule.name)}</h4>
|
||||
${rule.subdirectory ? `<span class="text-xs text-muted-foreground">${escapeHtml(rule.subdirectory)}/</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
${isGlobal ? `
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-primary/10 text-primary">
|
||||
<i data-lucide="globe" class="w-3 h-3 mr-1"></i>
|
||||
global
|
||||
</span>
|
||||
` : `
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-warning/10 text-warning">
|
||||
<i data-lucide="filter" class="w-3 h-3 mr-1"></i>
|
||||
conditional
|
||||
</span>
|
||||
`}
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full ${locationBg} ${locationClass}">
|
||||
<i data-lucide="${locationIcon}" class="w-3 h-3 mr-1"></i>
|
||||
${location}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${contentPreview ? `
|
||||
<p class="text-sm text-muted-foreground mb-3 line-clamp-2 font-mono">${escapeHtml(contentPreview)}</p>
|
||||
` : ''}
|
||||
|
||||
${hasPathCondition ? `
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground mt-2">
|
||||
<i data-lucide="filter" class="w-3 h-3"></i>
|
||||
<span class="font-mono">${escapeHtml(rule.paths.join(', '))}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRuleDetailPanel(rule) {
|
||||
const hasPathCondition = rule.paths && rule.paths.length > 0;
|
||||
|
||||
return `
|
||||
<div class="rule-detail-panel fixed top-0 right-0 w-1/2 max-w-xl h-full bg-card border-l border-border shadow-lg z-50 flex flex-col">
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<h3 class="text-lg font-semibold text-foreground">${escapeHtml(rule.name)}</h3>
|
||||
<button class="w-8 h-8 flex items-center justify-center text-xl text-muted-foreground hover:text-foreground hover:bg-hover rounded"
|
||||
onclick="closeRuleDetail()">×</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-5">
|
||||
<div class="space-y-6">
|
||||
<!-- Type -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-foreground mb-2">${t('rules.typeLabel')}</h4>
|
||||
<div class="flex items-center gap-2">
|
||||
${hasPathCondition ? `
|
||||
<span class="inline-flex items-center px-3 py-1 text-sm font-medium rounded-lg bg-warning/10 text-warning">
|
||||
<i data-lucide="filter" class="w-4 h-4 mr-2"></i>
|
||||
${t('rules.conditional')}
|
||||
</span>
|
||||
` : `
|
||||
<span class="inline-flex items-center px-3 py-1 text-sm font-medium rounded-lg bg-primary/10 text-primary">
|
||||
<i data-lucide="globe" class="w-4 h-4 mr-2"></i>
|
||||
${t('rules.global')}
|
||||
</span>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Path Conditions -->
|
||||
${hasPathCondition ? `
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-foreground mb-2">${t('rules.pathConditions')}</h4>
|
||||
<div class="space-y-2">
|
||||
${rule.paths.map(path => `
|
||||
<div class="flex items-center gap-2 p-2 bg-muted/50 rounded-lg">
|
||||
<i data-lucide="file-code" class="w-4 h-4 text-muted-foreground"></i>
|
||||
<code class="text-sm font-mono text-foreground">${escapeHtml(path)}</code>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Content -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-foreground mb-2">${t('rules.content')}</h4>
|
||||
<div class="bg-muted rounded-lg p-4 max-h-96 overflow-y-auto">
|
||||
<pre class="text-sm font-mono text-foreground whitespace-pre-wrap">${escapeHtml(rule.content || '')}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Path -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-foreground mb-2">${t('rules.filePath')}</h4>
|
||||
<code class="block p-3 bg-muted rounded-lg text-xs font-mono text-muted-foreground break-all">${escapeHtml(rule.path)}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="px-5 py-4 border-t border-border flex justify-between">
|
||||
<button class="px-4 py-2 text-sm text-destructive hover:bg-destructive/10 rounded-lg transition-colors flex items-center gap-2"
|
||||
onclick="deleteRule('${escapeHtml(rule.name)}', '${rule.location}')">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
${t('common.delete')}
|
||||
</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-2"
|
||||
onclick="editRule('${escapeHtml(rule.name)}', '${rule.location}')">
|
||||
<i data-lucide="edit" class="w-4 h-4"></i>
|
||||
${t('common.edit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rule-detail-overlay fixed inset-0 bg-black/50 z-40" onclick="closeRuleDetail()"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function showRuleDetail(ruleName, location) {
|
||||
try {
|
||||
const response = await fetch('/api/rules/' + encodeURIComponent(ruleName) + '?location=' + location + '&path=' + encodeURIComponent(projectPath));
|
||||
if (!response.ok) throw new Error('Failed to load rule detail');
|
||||
const data = await response.json();
|
||||
selectedRule = data.rule;
|
||||
renderRulesView();
|
||||
} catch (err) {
|
||||
console.error('Failed to load rule detail:', err);
|
||||
if (window.showToast) {
|
||||
showToast(t('rules.loadError'), 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeRuleDetail() {
|
||||
selectedRule = null;
|
||||
renderRulesView();
|
||||
}
|
||||
|
||||
async function deleteRule(ruleName, location) {
|
||||
if (!confirm(t('rules.deleteConfirm', { name: ruleName }))) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/rules/' + encodeURIComponent(ruleName), {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ location, projectPath })
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete rule');
|
||||
|
||||
selectedRule = null;
|
||||
await loadRulesData();
|
||||
renderRulesView();
|
||||
|
||||
if (window.showToast) {
|
||||
showToast(t('rules.deleted'), 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete rule:', err);
|
||||
if (window.showToast) {
|
||||
showToast(t('rules.deleteError'), 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function editRule(ruleName, location) {
|
||||
// Open edit modal (to be implemented with modal)
|
||||
if (window.showToast) {
|
||||
showToast(t('rules.editNotImplemented'), 'info');
|
||||
}
|
||||
}
|
||||
|
||||
function openRuleCreateModal() {
|
||||
// Open create modal (to be implemented with modal)
|
||||
if (window.showToast) {
|
||||
showToast(t('rules.createNotImplemented'), 'info');
|
||||
}
|
||||
}
|
||||
345
ccw/src/templates/dashboard-js/views/skills-manager.js
Normal file
345
ccw/src/templates/dashboard-js/views/skills-manager.js
Normal file
@@ -0,0 +1,345 @@
|
||||
// Skills Manager View
|
||||
// Manages Claude Code skills (.claude/skills/)
|
||||
|
||||
// ========== Skills State ==========
|
||||
var skillsData = {
|
||||
projectSkills: [],
|
||||
userSkills: []
|
||||
};
|
||||
var selectedSkill = null;
|
||||
var skillsLoading = false;
|
||||
|
||||
// ========== Main Render Function ==========
|
||||
async function renderSkillsManager() {
|
||||
const container = document.getElementById('mainContent');
|
||||
if (!container) return;
|
||||
|
||||
// Hide stats grid and search
|
||||
const statsGrid = document.getElementById('statsGrid');
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (statsGrid) statsGrid.style.display = 'none';
|
||||
if (searchInput) searchInput.parentElement.style.display = 'none';
|
||||
|
||||
// Show loading state
|
||||
container.innerHTML = '<div class="skills-manager loading">' +
|
||||
'<div class="loading-spinner"><i data-lucide="loader-2" class="w-8 h-8 animate-spin"></i></div>' +
|
||||
'<p>' + t('common.loading') + '</p>' +
|
||||
'</div>';
|
||||
|
||||
// Load skills data
|
||||
await loadSkillsData();
|
||||
|
||||
// Render the main view
|
||||
renderSkillsView();
|
||||
}
|
||||
|
||||
async function loadSkillsData() {
|
||||
skillsLoading = true;
|
||||
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();
|
||||
skillsData = {
|
||||
projectSkills: data.projectSkills || [],
|
||||
userSkills: data.userSkills || []
|
||||
};
|
||||
// Update badge
|
||||
updateSkillsBadge();
|
||||
} catch (err) {
|
||||
console.error('Failed to load skills:', err);
|
||||
skillsData = { projectSkills: [], userSkills: [] };
|
||||
} finally {
|
||||
skillsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateSkillsBadge() {
|
||||
const badge = document.getElementById('badgeSkills');
|
||||
if (badge) {
|
||||
const total = skillsData.projectSkills.length + skillsData.userSkills.length;
|
||||
badge.textContent = total;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSkillsView() {
|
||||
const container = document.getElementById('mainContent');
|
||||
if (!container) return;
|
||||
|
||||
const projectSkills = skillsData.projectSkills || [];
|
||||
const userSkills = skillsData.userSkills || [];
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="skills-manager">
|
||||
<!-- Header -->
|
||||
<div class="skills-header mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<i data-lucide="sparkles" class="w-5 h-5 text-primary"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-foreground">${t('skills.title')}</h2>
|
||||
<p class="text-sm text-muted-foreground">${t('skills.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity flex items-center gap-2"
|
||||
onclick="openSkillCreateModal()">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
${t('skills.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Skills Section -->
|
||||
<div class="skills-section mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i data-lucide="folder" class="w-5 h-5 text-primary"></i>
|
||||
<h3 class="text-lg font-semibold text-foreground">${t('skills.projectSkills')}</h3>
|
||||
<span class="text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full">.claude/skills/</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">${projectSkills.length} ${t('skills.skillsCount')}</span>
|
||||
</div>
|
||||
|
||||
${projectSkills.length === 0 ? `
|
||||
<div class="skills-empty-state bg-card border border-border rounded-lg p-6 text-center">
|
||||
<div class="text-muted-foreground mb-3"><i data-lucide="sparkles" class="w-10 h-10 mx-auto"></i></div>
|
||||
<p class="text-muted-foreground">${t('skills.noProjectSkills')}</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">${t('skills.createHint')}</p>
|
||||
</div>
|
||||
` : `
|
||||
<div class="skills-grid grid gap-3">
|
||||
${projectSkills.map(skill => renderSkillCard(skill, 'project')).join('')}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- User Skills Section -->
|
||||
<div class="skills-section mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i data-lucide="user" class="w-5 h-5 text-indigo"></i>
|
||||
<h3 class="text-lg font-semibold text-foreground">${t('skills.userSkills')}</h3>
|
||||
<span class="text-xs px-2 py-0.5 bg-indigo/10 text-indigo rounded-full">~/.claude/skills/</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">${userSkills.length} ${t('skills.skillsCount')}</span>
|
||||
</div>
|
||||
|
||||
${userSkills.length === 0 ? `
|
||||
<div class="skills-empty-state bg-card border border-border rounded-lg p-6 text-center">
|
||||
<div class="text-muted-foreground mb-3"><i data-lucide="user" class="w-10 h-10 mx-auto"></i></div>
|
||||
<p class="text-muted-foreground">${t('skills.noUserSkills')}</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">${t('skills.userSkillsHint')}</p>
|
||||
</div>
|
||||
` : `
|
||||
<div class="skills-grid grid gap-3">
|
||||
${userSkills.map(skill => renderSkillCard(skill, 'user')).join('')}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- Skill Detail Panel -->
|
||||
${selectedSkill ? renderSkillDetailPanel(selectedSkill) : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Initialize Lucide icons
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
|
||||
function renderSkillCard(skill, location) {
|
||||
const hasAllowedTools = skill.allowedTools && skill.allowedTools.length > 0;
|
||||
const hasSupportingFiles = skill.supportingFiles && skill.supportingFiles.length > 0;
|
||||
const locationIcon = location === 'project' ? 'folder' : 'user';
|
||||
const locationClass = location === 'project' ? 'text-primary' : 'text-indigo';
|
||||
const locationBg = location === 'project' ? 'bg-primary/10' : 'bg-indigo/10';
|
||||
|
||||
return `
|
||||
<div class="skill-card bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all cursor-pointer"
|
||||
onclick="showSkillDetail('${escapeHtml(skill.name)}', '${location}')">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 ${locationBg} rounded-lg flex items-center justify-center">
|
||||
<i data-lucide="sparkles" class="w-5 h-5 ${locationClass}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-foreground">${escapeHtml(skill.name)}</h4>
|
||||
${skill.version ? `<span class="text-xs text-muted-foreground">v${escapeHtml(skill.version)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full ${locationBg} ${locationClass}">
|
||||
<i data-lucide="${locationIcon}" class="w-3 h-3 mr-1"></i>
|
||||
${location}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-muted-foreground mb-3 line-clamp-2">${escapeHtml(skill.description || t('skills.noDescription'))}</p>
|
||||
|
||||
<div class="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
${hasAllowedTools ? `
|
||||
<span class="flex items-center gap-1">
|
||||
<i data-lucide="lock" class="w-3 h-3"></i>
|
||||
${skill.allowedTools.length} ${t('skills.tools')}
|
||||
</span>
|
||||
` : ''}
|
||||
${hasSupportingFiles ? `
|
||||
<span class="flex items-center gap-1">
|
||||
<i data-lucide="file-text" class="w-3 h-3"></i>
|
||||
${skill.supportingFiles.length} ${t('skills.files')}
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSkillDetailPanel(skill) {
|
||||
const hasAllowedTools = skill.allowedTools && skill.allowedTools.length > 0;
|
||||
const hasSupportingFiles = skill.supportingFiles && skill.supportingFiles.length > 0;
|
||||
|
||||
return `
|
||||
<div class="skill-detail-panel fixed top-0 right-0 w-1/2 max-w-xl h-full bg-card border-l border-border shadow-lg z-50 flex flex-col">
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<h3 class="text-lg font-semibold text-foreground">${escapeHtml(skill.name)}</h3>
|
||||
<button class="w-8 h-8 flex items-center justify-center text-xl text-muted-foreground hover:text-foreground hover:bg-hover rounded"
|
||||
onclick="closeSkillDetail()">×</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-5">
|
||||
<div class="space-y-6">
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-foreground mb-2">${t('skills.descriptionLabel')}</h4>
|
||||
<p class="text-sm text-muted-foreground">${escapeHtml(skill.description || t('skills.noDescription'))}</p>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-foreground mb-2">${t('skills.metadata')}</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="bg-muted/50 rounded-lg p-3">
|
||||
<span class="text-xs text-muted-foreground">${t('skills.location')}</span>
|
||||
<p class="text-sm font-medium text-foreground">${escapeHtml(skill.location)}</p>
|
||||
</div>
|
||||
${skill.version ? `
|
||||
<div class="bg-muted/50 rounded-lg p-3">
|
||||
<span class="text-xs text-muted-foreground">${t('skills.version')}</span>
|
||||
<p class="text-sm font-medium text-foreground">${escapeHtml(skill.version)}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Allowed Tools -->
|
||||
${hasAllowedTools ? `
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-foreground mb-2">${t('skills.allowedTools')}</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
${skill.allowedTools.map(tool => `
|
||||
<span class="px-2 py-1 text-xs bg-muted rounded-lg font-mono">${escapeHtml(tool)}</span>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Supporting Files -->
|
||||
${hasSupportingFiles ? `
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-foreground mb-2">${t('skills.supportingFiles')}</h4>
|
||||
<div class="space-y-2">
|
||||
${skill.supportingFiles.map(file => `
|
||||
<div class="flex items-center gap-2 p-2 bg-muted/50 rounded-lg">
|
||||
<i data-lucide="file-text" class="w-4 h-4 text-muted-foreground"></i>
|
||||
<span class="text-sm font-mono text-foreground">${escapeHtml(file)}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Path -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-foreground mb-2">${t('skills.path')}</h4>
|
||||
<code class="block p-3 bg-muted rounded-lg text-xs font-mono text-muted-foreground break-all">${escapeHtml(skill.path)}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="px-5 py-4 border-t border-border flex justify-between">
|
||||
<button class="px-4 py-2 text-sm text-destructive hover:bg-destructive/10 rounded-lg transition-colors flex items-center gap-2"
|
||||
onclick="deleteSkill('${escapeHtml(skill.name)}', '${skill.location}')">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
${t('common.delete')}
|
||||
</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-2"
|
||||
onclick="editSkill('${escapeHtml(skill.name)}', '${skill.location}')">
|
||||
<i data-lucide="edit" class="w-4 h-4"></i>
|
||||
${t('common.edit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="skill-detail-overlay fixed inset-0 bg-black/50 z-40" onclick="closeSkillDetail()"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function showSkillDetail(skillName, location) {
|
||||
try {
|
||||
const response = await fetch('/api/skills/' + encodeURIComponent(skillName) + '?location=' + location + '&path=' + encodeURIComponent(projectPath));
|
||||
if (!response.ok) throw new Error('Failed to load skill detail');
|
||||
const data = await response.json();
|
||||
selectedSkill = data.skill;
|
||||
renderSkillsView();
|
||||
} catch (err) {
|
||||
console.error('Failed to load skill detail:', err);
|
||||
if (window.showToast) {
|
||||
showToast(t('skills.loadError'), 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeSkillDetail() {
|
||||
selectedSkill = null;
|
||||
renderSkillsView();
|
||||
}
|
||||
|
||||
async function deleteSkill(skillName, location) {
|
||||
if (!confirm(t('skills.deleteConfirm', { name: skillName }))) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/skills/' + encodeURIComponent(skillName), {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ location, projectPath })
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete skill');
|
||||
|
||||
selectedSkill = null;
|
||||
await loadSkillsData();
|
||||
renderSkillsView();
|
||||
|
||||
if (window.showToast) {
|
||||
showToast(t('skills.deleted'), 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete skill:', err);
|
||||
if (window.showToast) {
|
||||
showToast(t('skills.deleteError'), 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function editSkill(skillName, location) {
|
||||
// Open edit modal (to be implemented with modal)
|
||||
if (window.showToast) {
|
||||
showToast(t('skills.editNotImplemented'), 'info');
|
||||
}
|
||||
}
|
||||
|
||||
function openSkillCreateModal() {
|
||||
// Open create modal (to be implemented with modal)
|
||||
if (window.showToast) {
|
||||
showToast(t('skills.createNotImplemented'), 'info');
|
||||
}
|
||||
}
|
||||
@@ -424,6 +424,16 @@
|
||||
<i data-lucide="message-square" class="nav-icon"></i>
|
||||
<span class="nav-text flex-1" data-i18n="nav.promptHistory">Prompts</span>
|
||||
</li>
|
||||
<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="skills-manager" data-tooltip="Skills Management">
|
||||
<i data-lucide="sparkles" class="nav-icon"></i>
|
||||
<span class="nav-text flex-1" data-i18n="nav.skills">Skills</span>
|
||||
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeSkills">0</span>
|
||||
</li>
|
||||
<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="rules-manager" data-tooltip="Rules Management">
|
||||
<i data-lucide="book-open" class="nav-icon"></i>
|
||||
<span class="nav-text flex-1" data-i18n="nav.rules">Rules</span>
|
||||
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeRules">0</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ const ParamsSchema = z.object({
|
||||
id: z.string().optional(), // Custom execution ID (e.g., IMPL-001-step1)
|
||||
noNative: z.boolean().optional(), // Force prompt concatenation instead of native resume
|
||||
category: z.enum(['user', 'internal', 'insight']).default('user'), // Execution category for tracking
|
||||
parentExecutionId: z.string().optional(), // Parent execution ID for fork/retry scenarios
|
||||
});
|
||||
|
||||
// Execution category types
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface ConversationRecord {
|
||||
turn_count: number;
|
||||
latest_status: 'success' | 'error' | 'timeout';
|
||||
turns: ConversationTurn[];
|
||||
parent_execution_id?: string; // For fork/retry scenarios
|
||||
}
|
||||
|
||||
export interface HistoryQueryOptions {
|
||||
@@ -74,6 +75,20 @@ export interface NativeSessionMapping {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Review record interface
|
||||
export type ReviewStatus = 'pending' | 'approved' | 'rejected' | 'changes_requested';
|
||||
|
||||
export interface ReviewRecord {
|
||||
id?: number;
|
||||
execution_id: string;
|
||||
status: ReviewStatus;
|
||||
rating?: number;
|
||||
comments?: string;
|
||||
reviewer?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI History Store using SQLite
|
||||
*/
|
||||
@@ -113,7 +128,9 @@ export class CliHistoryStore {
|
||||
total_duration_ms INTEGER DEFAULT 0,
|
||||
turn_count INTEGER DEFAULT 0,
|
||||
latest_status TEXT DEFAULT 'success',
|
||||
prompt_preview TEXT
|
||||
prompt_preview TEXT,
|
||||
parent_execution_id TEXT,
|
||||
FOREIGN KEY (parent_execution_id) REFERENCES conversations(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Turns table (individual conversation turns)
|
||||
@@ -193,6 +210,23 @@ export class CliHistoryStore {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_insights_created ON insights(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_insights_tool ON insights(tool);
|
||||
|
||||
-- Reviews table for CLI execution reviews
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
execution_id TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
rating INTEGER,
|
||||
comments TEXT,
|
||||
reviewer TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (execution_id) REFERENCES conversations(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_execution ON reviews(execution_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_status ON reviews(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_created ON reviews(created_at DESC);
|
||||
`);
|
||||
|
||||
// Migration: Add category column if not exists (for existing databases)
|
||||
@@ -207,6 +241,7 @@ export class CliHistoryStore {
|
||||
// Check if category column exists
|
||||
const tableInfo = this.db.prepare('PRAGMA table_info(conversations)').all() as Array<{ name: string }>;
|
||||
const hasCategory = tableInfo.some(col => col.name === 'category');
|
||||
const hasParentExecutionId = tableInfo.some(col => col.name === 'parent_execution_id');
|
||||
|
||||
if (!hasCategory) {
|
||||
console.log('[CLI History] Migrating database: adding category column...');
|
||||
@@ -221,6 +256,19 @@ export class CliHistoryStore {
|
||||
}
|
||||
console.log('[CLI History] Migration complete: category column added');
|
||||
}
|
||||
|
||||
if (!hasParentExecutionId) {
|
||||
console.log('[CLI History] Migrating database: adding parent_execution_id column...');
|
||||
this.db.exec(`
|
||||
ALTER TABLE conversations ADD COLUMN parent_execution_id TEXT;
|
||||
`);
|
||||
try {
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_conversations_parent ON conversations(parent_execution_id);`);
|
||||
} catch (indexErr) {
|
||||
console.warn('[CLI History] Parent execution index creation warning:', (indexErr as Error).message);
|
||||
}
|
||||
console.log('[CLI History] Migration complete: parent_execution_id column added');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CLI History] Migration error:', (err as Error).message);
|
||||
// Don't throw - allow the store to continue working with existing schema
|
||||
@@ -314,8 +362,8 @@ 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)
|
||||
VALUES (@id, @created_at, @updated_at, @tool, @model, @mode, @category, @total_duration_ms, @turn_count, @latest_status, @prompt_preview)
|
||||
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)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
updated_at = @updated_at,
|
||||
total_duration_ms = @total_duration_ms,
|
||||
@@ -350,7 +398,8 @@ export class CliHistoryStore {
|
||||
total_duration_ms: conversation.total_duration_ms,
|
||||
turn_count: conversation.turn_count,
|
||||
latest_status: conversation.latest_status,
|
||||
prompt_preview: promptPreview
|
||||
prompt_preview: promptPreview,
|
||||
parent_execution_id: conversation.parent_execution_id || null
|
||||
});
|
||||
|
||||
for (const turn of conversation.turns) {
|
||||
@@ -397,6 +446,7 @@ export class CliHistoryStore {
|
||||
total_duration_ms: conv.total_duration_ms,
|
||||
turn_count: conv.turn_count,
|
||||
latest_status: conv.latest_status,
|
||||
parent_execution_id: conv.parent_execution_id || undefined,
|
||||
turns: turns.map(t => ({
|
||||
turn: t.turn_number,
|
||||
timestamp: t.timestamp,
|
||||
@@ -935,6 +985,107 @@ export class CliHistoryStore {
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save or update a review for an execution
|
||||
*/
|
||||
saveReview(review: Omit<ReviewRecord, 'id' | 'created_at' | 'updated_at'> & { created_at?: string; updated_at?: string }): ReviewRecord {
|
||||
const now = new Date().toISOString();
|
||||
const created_at = review.created_at || now;
|
||||
const updated_at = review.updated_at || now;
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO reviews (execution_id, status, rating, comments, reviewer, created_at, updated_at)
|
||||
VALUES (@execution_id, @status, @rating, @comments, @reviewer, @created_at, @updated_at)
|
||||
ON CONFLICT(execution_id) DO UPDATE SET
|
||||
status = @status,
|
||||
rating = @rating,
|
||||
comments = @comments,
|
||||
reviewer = @reviewer,
|
||||
updated_at = @updated_at
|
||||
`);
|
||||
|
||||
const result = stmt.run({
|
||||
execution_id: review.execution_id,
|
||||
status: review.status,
|
||||
rating: review.rating ?? null,
|
||||
comments: review.comments ?? null,
|
||||
reviewer: review.reviewer ?? null,
|
||||
created_at,
|
||||
updated_at
|
||||
});
|
||||
|
||||
return {
|
||||
id: result.lastInsertRowid as number,
|
||||
execution_id: review.execution_id,
|
||||
status: review.status,
|
||||
rating: review.rating,
|
||||
comments: review.comments,
|
||||
reviewer: review.reviewer,
|
||||
created_at,
|
||||
updated_at
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get review for an execution
|
||||
*/
|
||||
getReview(executionId: string): ReviewRecord | null {
|
||||
const row = this.db.prepare(
|
||||
'SELECT * FROM reviews WHERE execution_id = ?'
|
||||
).get(executionId) as any;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
execution_id: row.execution_id,
|
||||
status: row.status as ReviewStatus,
|
||||
rating: row.rating,
|
||||
comments: row.comments,
|
||||
reviewer: row.reviewer,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reviews with optional filtering
|
||||
*/
|
||||
getReviews(options: { status?: ReviewStatus; limit?: number } = {}): ReviewRecord[] {
|
||||
const { status, limit = 50 } = options;
|
||||
|
||||
let sql = 'SELECT * FROM reviews';
|
||||
const params: any = { limit };
|
||||
|
||||
if (status) {
|
||||
sql += ' WHERE status = @status';
|
||||
params.status = status;
|
||||
}
|
||||
|
||||
sql += ' ORDER BY updated_at DESC LIMIT @limit';
|
||||
|
||||
const rows = this.db.prepare(sql).all(params) as any[];
|
||||
|
||||
return rows.map(row => ({
|
||||
id: row.id,
|
||||
execution_id: row.execution_id,
|
||||
status: row.status as ReviewStatus,
|
||||
rating: row.rating,
|
||||
comments: row.comments,
|
||||
reviewer: row.reviewer,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a review
|
||||
*/
|
||||
deleteReview(executionId: string): boolean {
|
||||
const result = this.db.prepare('DELETE FROM reviews WHERE execution_id = ?').run(executionId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close database connection
|
||||
*/
|
||||
|
||||
@@ -35,12 +35,27 @@ let bootstrapReady = false;
|
||||
|
||||
// Define Zod schema for validation
|
||||
const ParamsSchema = z.object({
|
||||
action: z.enum(['init', 'search', 'search_files', 'symbol', 'status', 'update', 'bootstrap', 'check']),
|
||||
action: z.enum([
|
||||
'init',
|
||||
'search',
|
||||
'search_files',
|
||||
'symbol',
|
||||
'status',
|
||||
'config_show',
|
||||
'config_set',
|
||||
'config_migrate',
|
||||
'clean',
|
||||
'bootstrap',
|
||||
'check',
|
||||
]),
|
||||
path: z.string().optional(),
|
||||
query: z.string().optional(),
|
||||
mode: z.enum(['text', 'semantic']).default('text'),
|
||||
file: z.string().optional(),
|
||||
files: z.array(z.string()).optional(),
|
||||
key: z.string().optional(), // For config_set action
|
||||
value: z.string().optional(), // For config_set action
|
||||
newPath: z.string().optional(), // For config_migrate action
|
||||
all: z.boolean().optional(), // For clean action
|
||||
languages: z.array(z.string()).optional(),
|
||||
limit: z.number().default(20),
|
||||
format: z.enum(['json', 'table', 'plain']).default('json'),
|
||||
@@ -75,7 +90,8 @@ interface ExecuteResult {
|
||||
files?: unknown;
|
||||
symbols?: unknown;
|
||||
status?: unknown;
|
||||
updateResult?: unknown;
|
||||
config?: unknown;
|
||||
cleanResult?: unknown;
|
||||
ready?: boolean;
|
||||
version?: string;
|
||||
}
|
||||
@@ -534,24 +550,105 @@ async function getStatus(params: Params): Promise<ExecuteResult> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update specific files in the index
|
||||
* Show configuration
|
||||
* @param params - Parameters
|
||||
* @returns Execution result
|
||||
*/
|
||||
async function updateFiles(params: Params): Promise<ExecuteResult> {
|
||||
const { files, path = '.' } = params;
|
||||
|
||||
if (!files || !Array.isArray(files) || files.length === 0) {
|
||||
return { success: false, error: 'files parameter is required and must be a non-empty array' };
|
||||
}
|
||||
|
||||
const args = ['update', ...files, '--json'];
|
||||
|
||||
const result = await executeCodexLens(args, { cwd: path });
|
||||
async function configShow(): Promise<ExecuteResult> {
|
||||
const args = ['config', 'show', '--json'];
|
||||
const result = await executeCodexLens(args);
|
||||
|
||||
if (result.success && result.output) {
|
||||
try {
|
||||
result.updateResult = JSON.parse(result.output);
|
||||
result.config = JSON.parse(result.output);
|
||||
delete result.output;
|
||||
} catch {
|
||||
// Keep raw output if JSON parse fails
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration value
|
||||
* @param params - Parameters
|
||||
* @returns Execution result
|
||||
*/
|
||||
async function configSet(params: Params): Promise<ExecuteResult> {
|
||||
const { key, value } = params;
|
||||
|
||||
if (!key) {
|
||||
return { success: false, error: 'key is required for config_set action' };
|
||||
}
|
||||
if (!value) {
|
||||
return { success: false, error: 'value is required for config_set action' };
|
||||
}
|
||||
|
||||
const args = ['config', 'set', key, value, '--json'];
|
||||
const result = await executeCodexLens(args);
|
||||
|
||||
if (result.success && result.output) {
|
||||
try {
|
||||
result.config = JSON.parse(result.output);
|
||||
delete result.output;
|
||||
} catch {
|
||||
// Keep raw output if JSON parse fails
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate indexes to new location
|
||||
* @param params - Parameters
|
||||
* @returns Execution result
|
||||
*/
|
||||
async function configMigrate(params: Params): Promise<ExecuteResult> {
|
||||
const { newPath } = params;
|
||||
|
||||
if (!newPath) {
|
||||
return { success: false, error: 'newPath is required for config_migrate action' };
|
||||
}
|
||||
|
||||
const args = ['config', 'migrate', newPath, '--json'];
|
||||
const result = await executeCodexLens(args, { timeout: 300000 }); // 5 min for migration
|
||||
|
||||
if (result.success && result.output) {
|
||||
try {
|
||||
result.config = JSON.parse(result.output);
|
||||
delete result.output;
|
||||
} catch {
|
||||
// Keep raw output if JSON parse fails
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean indexes
|
||||
* @param params - Parameters
|
||||
* @returns Execution result
|
||||
*/
|
||||
async function cleanIndexes(params: Params): Promise<ExecuteResult> {
|
||||
const { path, all } = params;
|
||||
|
||||
const args = ['clean'];
|
||||
|
||||
if (all) {
|
||||
args.push('--all');
|
||||
} else if (path) {
|
||||
args.push(path);
|
||||
}
|
||||
|
||||
args.push('--json');
|
||||
const result = await executeCodexLens(args);
|
||||
|
||||
if (result.success && result.output) {
|
||||
try {
|
||||
result.cleanResult = JSON.parse(result.output);
|
||||
delete result.output;
|
||||
} catch {
|
||||
// Keep raw output if JSON parse fails
|
||||
@@ -572,18 +669,35 @@ Usage:
|
||||
codex_lens(action="search_files", query="x") # Search, return paths only
|
||||
codex_lens(action="symbol", file="f.py") # Extract symbols
|
||||
codex_lens(action="status") # Index status
|
||||
codex_lens(action="update", files=["a.js"]) # Update specific files`,
|
||||
codex_lens(action="config_show") # Show configuration
|
||||
codex_lens(action="config_set", key="index_dir", value="/path/to/indexes") # Set config
|
||||
codex_lens(action="config_migrate", newPath="/new/path") # Migrate indexes
|
||||
codex_lens(action="clean") # Show clean status
|
||||
codex_lens(action="clean", path=".") # Clean specific project
|
||||
codex_lens(action="clean", all=true) # Clean all indexes`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['init', 'search', 'search_files', 'symbol', 'status', 'update', 'bootstrap', 'check'],
|
||||
enum: [
|
||||
'init',
|
||||
'search',
|
||||
'search_files',
|
||||
'symbol',
|
||||
'status',
|
||||
'config_show',
|
||||
'config_set',
|
||||
'config_migrate',
|
||||
'clean',
|
||||
'bootstrap',
|
||||
'check',
|
||||
],
|
||||
description: 'Action to perform',
|
||||
},
|
||||
path: {
|
||||
type: 'string',
|
||||
description: 'Target path (for init, search, search_files, status, update)',
|
||||
description: 'Target path (for init, search, search_files, status, clean)',
|
||||
},
|
||||
query: {
|
||||
type: 'string',
|
||||
@@ -599,10 +713,22 @@ Usage:
|
||||
type: 'string',
|
||||
description: 'File path (for symbol action)',
|
||||
},
|
||||
files: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'File paths to update (for update action)',
|
||||
key: {
|
||||
type: 'string',
|
||||
description: 'Config key (for config_set action, e.g., "index_dir")',
|
||||
},
|
||||
value: {
|
||||
type: 'string',
|
||||
description: 'Config value (for config_set action)',
|
||||
},
|
||||
newPath: {
|
||||
type: 'string',
|
||||
description: 'New index path (for config_migrate action)',
|
||||
},
|
||||
all: {
|
||||
type: 'boolean',
|
||||
description: 'Clean all indexes (for clean action)',
|
||||
default: false,
|
||||
},
|
||||
languages: {
|
||||
type: 'array',
|
||||
@@ -658,8 +784,20 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
|
||||
result = await getStatus(parsed.data);
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
result = await updateFiles(parsed.data);
|
||||
case 'config_show':
|
||||
result = await configShow();
|
||||
break;
|
||||
|
||||
case 'config_set':
|
||||
result = await configSet(parsed.data);
|
||||
break;
|
||||
|
||||
case 'config_migrate':
|
||||
result = await configMigrate(parsed.data);
|
||||
break;
|
||||
|
||||
case 'clean':
|
||||
result = await cleanIndexes(parsed.data);
|
||||
break;
|
||||
|
||||
case 'bootstrap': {
|
||||
@@ -686,7 +824,7 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown action: ${action}. Valid actions: init, search, search_files, symbol, status, update, bootstrap, check`
|
||||
`Unknown action: ${action}. Valid actions: init, search, search_files, symbol, status, config_show, config_set, config_migrate, clean, bootstrap, check`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
129
ccw/src/tools/notifier.ts
Normal file
129
ccw/src/tools/notifier.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Notifier Module - CLI to Server Communication
|
||||
* Provides best-effort notification to running CCW Server
|
||||
* when CLI commands modify data that should trigger UI updates
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
|
||||
// Default server configuration
|
||||
const DEFAULT_HOST = 'localhost';
|
||||
const DEFAULT_PORT = 3456;
|
||||
const NOTIFY_TIMEOUT = 2000; // 2 seconds - quick timeout for best-effort
|
||||
|
||||
export type NotifyScope = 'memory' | 'history' | 'insights' | 'all';
|
||||
|
||||
export interface NotifyPayload {
|
||||
type: 'REFRESH_REQUIRED' | 'MEMORY_UPDATED' | 'HISTORY_UPDATED' | 'INSIGHT_GENERATED';
|
||||
scope: NotifyScope;
|
||||
data?: {
|
||||
entityType?: string;
|
||||
entityId?: string | number;
|
||||
action?: string;
|
||||
executionId?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NotifyResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification to CCW Server (best-effort, non-blocking)
|
||||
* If server is not running or unreachable, silently fails
|
||||
*/
|
||||
export async function notifyServer(
|
||||
payload: NotifyPayload,
|
||||
options?: { host?: string; port?: number }
|
||||
): Promise<NotifyResult> {
|
||||
const host = options?.host || DEFAULT_HOST;
|
||||
const port = options?.port || DEFAULT_PORT;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const postData = JSON.stringify(payload);
|
||||
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: host,
|
||||
port: port,
|
||||
path: '/api/system/notify',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
},
|
||||
timeout: NOTIFY_TIMEOUT,
|
||||
},
|
||||
(res) => {
|
||||
// Success if we get a 2xx response
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve({ success: true });
|
||||
} else {
|
||||
resolve({ success: false, error: `HTTP ${res.statusCode}` });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Handle errors silently - server may not be running
|
||||
req.on('error', () => {
|
||||
resolve({ success: false, error: 'Server not reachable' });
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve({ success: false, error: 'Timeout' });
|
||||
});
|
||||
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: Notify memory update
|
||||
*/
|
||||
export async function notifyMemoryUpdate(data?: {
|
||||
entityType?: string;
|
||||
entityId?: string | number;
|
||||
action?: string;
|
||||
}): Promise<NotifyResult> {
|
||||
return notifyServer({
|
||||
type: 'MEMORY_UPDATED',
|
||||
scope: 'memory',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: Notify CLI history update
|
||||
*/
|
||||
export async function notifyHistoryUpdate(executionId?: string): Promise<NotifyResult> {
|
||||
return notifyServer({
|
||||
type: 'HISTORY_UPDATED',
|
||||
scope: 'history',
|
||||
data: executionId ? { executionId } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: Notify insight generated
|
||||
*/
|
||||
export async function notifyInsightGenerated(executionId?: string): Promise<NotifyResult> {
|
||||
return notifyServer({
|
||||
type: 'INSIGHT_GENERATED',
|
||||
scope: 'insights',
|
||||
data: executionId ? { executionId } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: Request full refresh
|
||||
*/
|
||||
export async function notifyRefreshRequired(scope: NotifyScope = 'all'): Promise<NotifyResult> {
|
||||
return notifyServer({
|
||||
type: 'REFRESH_REQUIRED',
|
||||
scope,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user