feat(ui): add skills pane in left nav and wire skills api/service

This commit is contained in:
“BeeRad”
2026-03-07 11:53:55 +11:00
parent 2cdd685c04
commit b67a789740
9 changed files with 563 additions and 202 deletions
+52
View File
@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server';
import { readSkill, deleteSkill } from '@/services/skills/skillService';
export const runtime = 'nodejs';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
try {
const { name } = await params;
const skill = readSkill(name);
if (!skill) {
return NextResponse.json(
{ success: false, error: `Skill "${name}" not found` },
{ status: 404 }
);
}
return NextResponse.json({ success: true, data: skill });
} catch (error) {
console.error('[API /skills/[name]] error:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to read skill' },
{ status: 500 }
);
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
try {
const { name } = await params;
const result = deleteSkill(name);
if (!result.success) {
return NextResponse.json(
{ success: false, error: result.error },
{ status: 400 }
);
}
return NextResponse.json({ success: true, message: `Skill "${name}" deleted` });
} catch (error) {
console.error('[API /skills/[name] DELETE] error:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to delete skill' },
{ status: 500 }
);
}
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { listSkills } from '@/services/skills/skillService';
export const runtime = 'nodejs';
export async function GET() {
try {
const skills = listSkills();
return NextResponse.json({ success: true, data: skills });
} catch (error) {
console.error('[API /skills] error:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to list skills' },
{ status: 500 }
);
}
}
+14 -3
View File
@@ -9,6 +9,7 @@ import {
Map,
Folder,
Table2,
BookOpen,
Settings,
} from 'lucide-react';
import type { PaneType } from '../panes/types';
@@ -30,6 +31,7 @@ const PANE_TYPE_ICONS: Record<string, typeof LayoutList> = {
map: Map,
dimensions: Folder,
table: Table2,
skills: BookOpen,
};
const PANE_TYPE_LABELS: Record<string, string> = {
@@ -37,9 +39,10 @@ const PANE_TYPE_LABELS: Record<string, string> = {
map: 'Map',
dimensions: 'Dimensions',
table: 'Table',
skills: 'Skills',
};
// Pane types shown in the toolbar (excludes 'node', 'chat', and 'guides' which is in settings)
// Pane types shown in the toolbar center section (skills is pinned above settings)
const TOOLBAR_PANE_TYPES: PaneType[] = ['views', 'map', 'dimensions', 'table'];
interface ToolbarButtonProps {
@@ -224,8 +227,16 @@ export default function LeftToolbar({
})}
</div>
{/* Bottom section - Settings */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
{/* Bottom section - Skills + Settings */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<PaneTypeButton
icon={BookOpen}
label="Skills"
paneType="skills"
isOpen={openPaneTypes.has('skills')}
isActivePane={activePaneType === 'skills'}
onClick={() => onPaneTypeClick('skills')}
/>
<ToolbarButton
icon={Settings}
label="Settings"
+14 -2
View File
@@ -34,8 +34,8 @@ export interface PendingNode {
import LeftToolbar from './LeftToolbar';
import SplitHandle from './SplitHandle';
// Pane components (ChatPane removed in rah-light, GuidesPane moved to settings)
import { NodePane, DimensionsPane, MapPane, ViewsPane, TablePane } from '../panes';
// Pane components (ChatPane removed in rah-light)
import { NodePane, DimensionsPane, MapPane, ViewsPane, TablePane, SkillsPane } from '../panes';
import QuickAddInput from '../agents/QuickAddInput';
import type { PaneType, SlotState, PaneAction } from '../panes/types';
@@ -295,6 +295,7 @@ export default function ThreePanelLayout() {
case 'GUIDE_UPDATED':
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('guides:updated', { detail: data.data }));
window.dispatchEvent(new CustomEvent('skills:updated', { detail: data.data }));
}
break;
@@ -960,6 +961,17 @@ export default function ThreePanelLayout() {
/>
);
case 'skills':
return (
<SkillsPane
slot={slot}
isActive={isActive}
onPaneAction={slot === 'A' ? handleSlotAAction : handleSlotBAction}
onCollapse={onCollapse}
onSwapPanes={slotB ? handleSwapPanes : undefined}
/>
);
default:
return null;
}
+219
View File
@@ -0,0 +1,219 @@
"use client";
import { useState, useEffect } from 'react';
import { ArrowLeft, Trash2 } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import PaneHeader from './PaneHeader';
import type { BasePaneProps } from './types';
interface SkillMeta {
name: string;
description: string;
immutable: boolean;
}
interface Skill extends SkillMeta {
content: string;
}
export default function SkillsPane({
slot,
onCollapse,
onSwapPanes,
tabBar,
}: BasePaneProps) {
const [skills, setSkills] = useState<SkillMeta[]>([]);
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState<string | null>(null);
useEffect(() => {
fetchSkills();
const handleSkillUpdated = () => {
fetchSkills();
};
window.addEventListener('skills:updated', handleSkillUpdated);
window.addEventListener('guides:updated', handleSkillUpdated);
return () => {
window.removeEventListener('skills:updated', handleSkillUpdated);
window.removeEventListener('guides:updated', handleSkillUpdated);
};
}, []);
const fetchSkills = async () => {
try {
const res = await fetch('/api/skills');
const data = await res.json();
if (data.success) {
setSkills(data.data);
}
} catch (err) {
console.error('[SkillsPane] Failed to fetch skills:', err);
} finally {
setLoading(false);
}
};
const handleSelectSkill = async (name: string) => {
try {
const res = await fetch(`/api/skills/${encodeURIComponent(name)}`);
const data = await res.json();
if (data.success) {
setSelectedSkill(data.data);
}
} catch (err) {
console.error('[SkillsPane] Failed to fetch skill:', err);
}
};
const handleDeleteSkill = async (name: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm(`Delete skill "${name}"?`)) return;
setDeleting(name);
try {
const res = await fetch(`/api/skills/${encodeURIComponent(name)}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
fetchSkills();
if (selectedSkill?.name === name) {
setSelectedSkill(null);
}
}
} catch (err) {
console.error('[SkillsPane] Failed to delete skill:', err);
} finally {
setDeleting(null);
}
};
return (
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
overflow: 'hidden',
}}
>
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar}>
{selectedSkill ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
onClick={() => setSelectedSkill(null)}
style={{
background: 'none',
border: 'none',
color: '#888',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
padding: '4px',
borderRadius: '4px',
}}
onMouseEnter={e => {
e.currentTarget.style.color = '#ccc';
}}
onMouseLeave={e => {
e.currentTarget.style.color = '#888';
}}
>
<ArrowLeft size={16} />
</button>
<span style={{ color: '#ccc', fontSize: '13px', fontWeight: 500 }}>{selectedSkill.name}</span>
</div>
) : (
<span style={{ color: '#666', fontSize: '11px' }}>{skills.length} skills</span>
)}
</PaneHeader>
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '12px' }}>
{loading ? (
<div style={{ color: '#555', fontSize: '13px', textAlign: 'center', paddingTop: '24px' }}>Loading...</div>
) : selectedSkill ? (
<div className="skill-content" style={{ color: '#ccc', fontSize: '13px', lineHeight: '1.6' }}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{selectedSkill.content}</ReactMarkdown>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{skills.length === 0 ? (
<div style={{ color: '#555', fontSize: '13px', textAlign: 'center', paddingTop: '24px' }}>
No skills found
</div>
) : (
skills.map((skill) => (
<button
key={skill.name}
onClick={() => handleSelectSkill(skill.name)}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '12px',
background: '#161616',
border: '1px solid #222',
borderRadius: '8px',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.15s ease',
}}
onMouseEnter={e => {
e.currentTarget.style.background = '#1a1a1a';
e.currentTarget.style.borderColor = '#333';
}}
onMouseLeave={e => {
e.currentTarget.style.background = '#161616';
e.currentTarget.style.borderColor = '#222';
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ color: '#ddd', fontSize: '13px', fontWeight: 500 }}>{skill.name}</span>
<span
style={{
color: '#777',
fontSize: '12px',
lineHeight: '1.4',
display: 'block',
marginTop: '2px',
}}
>
{skill.description}
</span>
</div>
<button
onClick={e => handleDeleteSkill(skill.name, e)}
disabled={deleting === skill.name}
style={{
background: 'none',
border: 'none',
color: '#555',
cursor: 'pointer',
padding: '4px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
flexShrink: 0,
opacity: deleting === skill.name ? 0.3 : 1,
}}
onMouseEnter={e => {
e.currentTarget.style.color = '#ef4444';
}}
onMouseLeave={e => {
e.currentTarget.style.color = '#555';
}}
>
<Trash2 size={14} />
</button>
</button>
))
)}
</div>
)}
</div>
</div>
);
}
+1
View File
@@ -3,5 +3,6 @@ export { default as DimensionsPane } from './DimensionsPane';
export { default as MapPane } from './MapPane';
export { default as ViewsPane } from './ViewsPane';
export { default as TablePane } from './TablePane';
export { default as SkillsPane } from './SkillsPane';
export { default as PaneHeader } from './PaneHeader';
export * from './types';
+3 -2
View File
@@ -14,8 +14,8 @@ export type AgentDelegation = {
updatedAt: string;
};
// The four pane types (chat removed in rah-light, guides moved to settings)
export type PaneType = 'node' | 'dimensions' | 'map' | 'views' | 'table';
// Pane types (chat removed in rah-light)
export type PaneType = 'node' | 'dimensions' | 'map' | 'views' | 'table' | 'skills';
// State for each slot
export interface SlotState {
@@ -111,6 +111,7 @@ export const PANE_LABELS: Record<PaneType, string> = {
map: 'Map',
views: 'Feed',
table: 'Table',
skills: 'Skills',
};
// Default slot states
+12 -195
View File
@@ -1,196 +1,13 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import matter from 'gray-matter';
export {
listSkills as listGuides,
readSkill as readGuide,
writeSkill as writeGuide,
deleteSkill as deleteGuide,
getUserSkillCount as getUserGuideCount,
getSkillStats as getGuideStats,
} from '@/services/skills/skillService';
export interface GuideMeta {
name: string;
description: string;
immutable: boolean;
}
export interface Guide extends GuideMeta {
content: string;
}
const MAX_USER_GUIDES = 10;
const GUIDES_DIR = path.join(
os.homedir(),
'Library/Application Support/RA-H/guides'
);
const SYSTEM_GUIDES_DIR = path.join(
process.cwd(),
'src/config/guides/system'
);
const USER_GUIDES_DIR = path.join(
process.cwd(),
'src/config/guides'
);
// System guide names (immutable, always re-seeded)
const SYSTEM_GUIDE_NAMES = new Set([
'schema',
'creating-nodes',
'edges',
'dimensions',
'extract',
]);
function ensureGuidesDir(): void {
if (!fs.existsSync(GUIDES_DIR)) {
fs.mkdirSync(GUIDES_DIR, { recursive: true });
}
}
/**
* Seed system guides — always overwritten on app start to stay current.
* User guides are only seeded if they don't already exist.
*/
function seedGuides(): void {
// Always re-seed system guides (immutable, kept up to date)
if (fs.existsSync(SYSTEM_GUIDES_DIR)) {
const systemFiles = fs.readdirSync(SYSTEM_GUIDES_DIR).filter(f => f.endsWith('.md'));
for (const file of systemFiles) {
const dest = path.join(GUIDES_DIR, file);
fs.copyFileSync(path.join(SYSTEM_GUIDES_DIR, file), dest);
}
}
// Seed default user guides only if they don't exist
if (fs.existsSync(USER_GUIDES_DIR)) {
const userFiles = fs.readdirSync(USER_GUIDES_DIR).filter(f => f.endsWith('.md'));
for (const file of userFiles) {
const dest = path.join(GUIDES_DIR, file);
if (!fs.existsSync(dest)) {
fs.copyFileSync(path.join(USER_GUIDES_DIR, file), dest);
}
}
}
}
let initialized = false;
function init(): void {
if (initialized) return;
ensureGuidesDir();
seedGuides();
initialized = true;
}
function isSystemGuide(filename: string): boolean {
const name = filename.replace('.md', '');
return SYSTEM_GUIDE_NAMES.has(name);
}
export function listGuides(): GuideMeta[] {
init();
const files = fs.readdirSync(GUIDES_DIR).filter(f => f.endsWith('.md'));
const guides = files.map(file => {
const raw = fs.readFileSync(path.join(GUIDES_DIR, file), 'utf-8');
const { data } = matter(raw);
const immutable = isSystemGuide(file) || data.immutable === true;
return {
name: data.name || file.replace('.md', ''),
description: data.description || '',
immutable,
};
});
// System guides first, then user guides alphabetically
return guides.sort((a, b) => {
if (a.immutable && !b.immutable) return -1;
if (!a.immutable && b.immutable) return 1;
return a.name.localeCompare(b.name);
});
}
export function readGuide(name: string): Guide | null {
init();
const candidates = [
`${name}.md`,
`${name.toLowerCase()}.md`,
];
for (const filename of candidates) {
const filepath = path.join(GUIDES_DIR, filename);
if (fs.existsSync(filepath)) {
const raw = fs.readFileSync(filepath, 'utf-8');
const { data, content } = matter(raw);
const immutable = isSystemGuide(filename) || data.immutable === true;
return {
name: data.name || name,
description: data.description || '',
immutable,
content: content.trim(),
};
}
}
return null;
}
export function writeGuide(name: string, content: string): { success: boolean; error?: string } {
init();
const filename = `${name.toLowerCase()}.md`;
// Reject writes to immutable guides
if (isSystemGuide(filename)) {
return { success: false, error: `Guide "${name}" is a system guide and cannot be modified.` };
}
// Check user guide cap for new guides
const filepath = path.join(GUIDES_DIR, filename);
if (!fs.existsSync(filepath)) {
const userGuideCount = getUserGuideCount();
if (userGuideCount >= MAX_USER_GUIDES) {
return { success: false, error: `Maximum of ${MAX_USER_GUIDES} custom guides reached. Delete a guide first.` };
}
}
fs.writeFileSync(filepath, content, 'utf-8');
return { success: true };
}
export function deleteGuide(name: string): { success: boolean; error?: string } {
init();
const candidates = [
`${name}.md`,
`${name.toLowerCase()}.md`,
];
for (const filename of candidates) {
// Reject deletes of immutable guides
if (isSystemGuide(filename)) {
return { success: false, error: `Guide "${name}" is a system guide and cannot be deleted.` };
}
const filepath = path.join(GUIDES_DIR, filename);
if (fs.existsSync(filepath)) {
fs.unlinkSync(filepath);
return { success: true };
}
}
return { success: false, error: `Guide "${name}" not found.` };
}
export function getUserGuideCount(): number {
init();
const files = fs.readdirSync(GUIDES_DIR).filter(f => f.endsWith('.md'));
return files.filter(f => !isSystemGuide(f)).length;
}
export function getGuideStats(): { userGuides: number; maxUserGuides: number; systemGuides: number } {
const guides = listGuides();
const systemCount = guides.filter(g => g.immutable).length;
const userCount = guides.filter(g => !g.immutable).length;
return {
userGuides: userCount,
maxUserGuides: MAX_USER_GUIDES,
systemGuides: systemCount,
};
}
export type {
SkillMeta as GuideMeta,
Skill as Guide,
} from '@/services/skills/skillService';
+231
View File
@@ -0,0 +1,231 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import matter from 'gray-matter';
export interface SkillMeta {
name: string;
description: string;
immutable: boolean;
}
export interface Skill extends SkillMeta {
content: string;
}
const SKILLS_DIR = path.join(
os.homedir(),
'Library/Application Support/RA-H/skills'
);
const LEGACY_GUIDES_DIR = path.join(
os.homedir(),
'Library/Application Support/RA-H/guides'
);
const BUNDLED_SKILLS_DIR = path.join(
process.cwd(),
'src/config/skills'
);
const SEEDED_SKILL_IDS = new Set([
'db-operations',
'create-skill',
'audit',
'traverse',
'onboarding',
'persona',
'calibration',
'connect',
]);
function ensureSkillsDir(): void {
if (!fs.existsSync(SKILLS_DIR)) {
fs.mkdirSync(SKILLS_DIR, { recursive: true });
}
}
function stripMdExtension(value: string): string {
return value.replace(/\.md$/i, '');
}
function normalizeSkillId(value: string): string {
return stripMdExtension(value)
.trim()
.toLowerCase()
.replace(/[\s_]+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
function listMarkdownFiles(dir: string): string[] {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir).filter((f) => f.endsWith('.md'));
}
function migrateLegacyGuides(): void {
const guideFiles = listMarkdownFiles(LEGACY_GUIDES_DIR);
for (const file of guideFiles) {
const target = path.join(SKILLS_DIR, file);
if (!fs.existsSync(target)) {
fs.copyFileSync(path.join(LEGACY_GUIDES_DIR, file), target);
}
}
}
function seedSkills(): void {
const bundledFiles = listMarkdownFiles(BUNDLED_SKILLS_DIR);
const bundledById = new Map<string, string>();
for (const file of bundledFiles) {
bundledById.set(normalizeSkillId(file), path.join(BUNDLED_SKILLS_DIR, file));
}
for (const skillId of SEEDED_SKILL_IDS) {
const source = bundledById.get(skillId);
if (!source) continue;
const dest = path.join(SKILLS_DIR, `${skillId}.md`);
if (!fs.existsSync(dest)) {
fs.copyFileSync(source, dest);
}
}
}
function resolveSkillFilename(name: string): string | null {
const files = listMarkdownFiles(SKILLS_DIR);
const normalizedInput = normalizeSkillId(name);
const directCandidates = [
`${name}.md`,
`${name.toLowerCase()}.md`,
normalizedInput ? `${normalizedInput}.md` : '',
].filter(Boolean);
for (const candidate of directCandidates) {
if (files.includes(candidate)) {
return candidate;
}
}
for (const file of files) {
if (normalizeSkillId(file) === normalizedInput) {
return file;
}
const raw = fs.readFileSync(path.join(SKILLS_DIR, file), 'utf-8');
const { data } = matter(raw);
if (typeof data.name === 'string' && normalizeSkillId(data.name) === normalizedInput) {
return file;
}
}
return null;
}
let initialized = false;
function init(): void {
if (initialized) return;
ensureSkillsDir();
migrateLegacyGuides();
seedSkills();
initialized = true;
}
export function listSkills(): SkillMeta[] {
init();
const files = listMarkdownFiles(SKILLS_DIR);
const skills = files.map((file) => {
const raw = fs.readFileSync(path.join(SKILLS_DIR, file), 'utf-8');
const { data } = matter(raw);
return {
name: data.name || file.replace('.md', ''),
description: data.description || '',
immutable: false,
};
});
return skills.sort((a, b) => a.name.localeCompare(b.name));
}
export function readSkill(name: string): Skill | null {
init();
const filename = resolveSkillFilename(name);
if (!filename) return null;
const filepath = path.join(SKILLS_DIR, filename);
const raw = fs.readFileSync(filepath, 'utf-8');
const { data, content } = matter(raw);
return {
name: data.name || stripMdExtension(filename),
description: data.description || '',
immutable: false,
content: content.trim(),
};
}
export function writeSkill(name: string, content: string): { success: boolean; error?: string } {
init();
const normalizedName = normalizeSkillId(name);
const existingFilename = resolveSkillFilename(name);
const filename = existingFilename || `${normalizedName || name.toLowerCase()}.md`;
const filepath = path.join(SKILLS_DIR, filename);
fs.writeFileSync(filepath, content, 'utf-8');
return { success: true };
}
export function deleteSkill(name: string): { success: boolean; error?: string } {
init();
const filename = resolveSkillFilename(name);
if (!filename) {
return { success: false, error: `Skill "${name}" not found.` };
}
const filepath = path.join(SKILLS_DIR, filename);
fs.unlinkSync(filepath);
return { success: true };
}
export function getUserSkillCount(): number {
init();
return listMarkdownFiles(SKILLS_DIR).length;
}
export function getSkillStats(): { userSkills: number; maxUserSkills: number; systemSkills: number } {
const count = getUserSkillCount();
return {
userSkills: count,
maxUserSkills: 9999,
systemSkills: 0,
};
}
// Compatibility aliases for legacy guide-first code paths.
export type GuideMeta = SkillMeta;
export type Guide = Skill;
export const listGuides = listSkills;
export const readGuide = readSkill;
export const writeGuide = writeSkill;
export const deleteGuide = deleteSkill;
export function getUserGuideCount(): number {
return getUserSkillCount();
}
export function getGuideStats(): { userGuides: number; maxUserGuides: number; systemGuides: number } {
const stats = getSkillStats();
return {
userGuides: stats.userSkills,
maxUserGuides: stats.maxUserSkills,
systemGuides: stats.systemSkills,
};
}