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:
catlog22
2025-12-14 11:12:48 +08:00
parent 08dc0a0348
commit ac43cf85ec
26 changed files with 3827 additions and 2005 deletions

View File

@@ -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);

View File

@@ -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 };
}
}

View File

@@ -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));
}

View 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);
}

View File

@@ -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');

View File

@@ -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': '创建',

View File

@@ -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();

View File

@@ -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',

View 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()">&times;</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');
}
}

View 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()">&times;</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');
}
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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
*/

View File

@@ -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
View 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,
});
}