feat: sync shared skills contract and graph-first discipline
This commit is contained in:
@@ -702,14 +702,6 @@ export default function ThreePanelLayout() {
|
||||
case 'SKILL_UPDATED':
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('skills:updated'));
|
||||
window.dispatchEvent(new CustomEvent('guides:updated'));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'GUIDE_UPDATED':
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('skills:updated'));
|
||||
window.dispatchEvent(new CustomEvent('guides:updated'));
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ArrowLeft, Lock, Trash2 } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import PaneHeader from './PaneHeader';
|
||||
import type { BasePaneProps } from './types';
|
||||
|
||||
interface GuideMeta {
|
||||
name: string;
|
||||
description: string;
|
||||
immutable: boolean;
|
||||
}
|
||||
|
||||
interface Guide extends GuideMeta {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export default function GuidesPane({
|
||||
slot,
|
||||
isActive,
|
||||
onPaneAction,
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
tabBar,
|
||||
}: BasePaneProps) {
|
||||
const [guides, setGuides] = useState<GuideMeta[]>([]);
|
||||
const [selectedGuide, setSelectedGuide] = useState<Guide | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGuides();
|
||||
|
||||
const handleGuideUpdated = () => { fetchGuides(); };
|
||||
window.addEventListener('guides:updated', handleGuideUpdated);
|
||||
return () => window.removeEventListener('guides:updated', handleGuideUpdated);
|
||||
}, []);
|
||||
|
||||
const fetchGuides = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/guides');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setGuides(data.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[GuidesPane] Failed to fetch guides:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectGuide = async (name: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/guides/${encodeURIComponent(name)}`);
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setSelectedGuide(data.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[GuidesPane] Failed to fetch guide:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteGuide = async (name: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm(`Delete guide "${name}"?`)) return;
|
||||
|
||||
setDeleting(name);
|
||||
try {
|
||||
const res = await fetch(`/api/guides/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
fetchGuides();
|
||||
if (selectedGuide?.name === name) {
|
||||
setSelectedGuide(null);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[GuidesPane] Failed to delete guide:', err);
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setSelectedGuide(null);
|
||||
};
|
||||
|
||||
const systemGuides = guides.filter(g => g.immutable);
|
||||
const userGuides = guides.filter(g => !g.immutable);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar}>
|
||||
{selectedGuide ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button
|
||||
onClick={handleBack}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--rah-text-muted)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = 'var(--rah-text-secondary)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<span style={{ color: 'var(--rah-text-secondary)', fontSize: '13px', fontWeight: 500, display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
{selectedGuide.immutable && <Lock size={12} style={{ color: '#22c55e' }} />}
|
||||
{selectedGuide.name}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: 'var(--rah-text-muted)', fontSize: '11px' }}>
|
||||
{userGuides.length} of 10 custom guides
|
||||
</span>
|
||||
)}
|
||||
</PaneHeader>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '12px' }}>
|
||||
{loading ? (
|
||||
<div style={{ color: 'var(--rah-text-muted)', fontSize: '13px', textAlign: 'center', paddingTop: '24px' }}>
|
||||
Loading...
|
||||
</div>
|
||||
) : selectedGuide ? (
|
||||
<div className="guide-content" style={{ color: 'var(--rah-text-secondary)', fontSize: '13px', lineHeight: '1.6' }}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 style={{ fontSize: '18px', fontWeight: 600, color: 'var(--rah-text-base)', margin: '0 0 16px 0' }}>{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 style={{ fontSize: '15px', fontWeight: 600, color: 'var(--rah-text-base)', margin: '20px 0 8px 0' }}>{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 style={{ fontSize: '14px', fontWeight: 600, color: 'var(--rah-text-secondary)', margin: '16px 0 6px 0' }}>{children}</h3>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p style={{ margin: '0 0 12px 0' }}>{children}</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul style={{ margin: '0 0 12px 0', paddingLeft: '20px' }}>{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol style={{ margin: '0 0 12px 0', paddingLeft: '20px' }}>{children}</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li style={{ margin: '0 0 4px 0' }}>{children}</li>
|
||||
),
|
||||
code: ({ className, children, ...props }) => {
|
||||
const isInline = !className;
|
||||
if (isInline) {
|
||||
return (
|
||||
<code style={{
|
||||
background: 'var(--rah-bg-active)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
color: '#22c55e',
|
||||
}} {...props}>{children}</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code style={{
|
||||
display: 'block',
|
||||
background: 'var(--rah-bg-surface)',
|
||||
padding: '12px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
overflowX: 'auto',
|
||||
margin: '0 0 12px 0',
|
||||
color: 'var(--rah-text-muted)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}} {...props}>{children}</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre style={{ margin: '0 0 12px 0' }}>{children}</pre>
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong style={{ color: 'var(--rah-text-base)', fontWeight: 600 }}>{children}</strong>
|
||||
),
|
||||
hr: () => (
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--rah-border-strong)', margin: '16px 0' }} />
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote style={{
|
||||
borderLeft: '1px solid var(--rah-border-stronger)',
|
||||
paddingLeft: '12px',
|
||||
margin: '0 0 12px 0',
|
||||
color: 'var(--rah-text-muted)',
|
||||
}}>{children}</blockquote>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{selectedGuide.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{guides.length === 0 ? (
|
||||
<div style={{ color: 'var(--rah-text-muted)', fontSize: '13px', textAlign: 'center', paddingTop: '24px' }}>
|
||||
No guides found
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{systemGuides.length > 0 && (
|
||||
<div style={{ color: 'var(--rah-text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '0.5px', padding: '4px 0' }}>
|
||||
System Guides
|
||||
</div>
|
||||
)}
|
||||
{systemGuides.map((guide) => (
|
||||
<GuideCard
|
||||
key={guide.name}
|
||||
guide={guide}
|
||||
onSelect={handleSelectGuide}
|
||||
onDelete={handleDeleteGuide}
|
||||
deleting={deleting}
|
||||
/>
|
||||
))}
|
||||
{userGuides.length > 0 && (
|
||||
<div style={{ color: 'var(--rah-text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '0.5px', padding: '8px 0 4px 0' }}>
|
||||
Custom Guides
|
||||
</div>
|
||||
)}
|
||||
{userGuides.map((guide) => (
|
||||
<GuideCard
|
||||
key={guide.name}
|
||||
guide={guide}
|
||||
onSelect={handleSelectGuide}
|
||||
onDelete={handleDeleteGuide}
|
||||
deleting={deleting}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GuideCard({
|
||||
guide,
|
||||
onSelect,
|
||||
onDelete,
|
||||
deleting,
|
||||
}: {
|
||||
guide: GuideMeta;
|
||||
onSelect: (name: string) => void;
|
||||
onDelete: (name: string, e: React.MouseEvent) => void;
|
||||
deleting: string | null;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(guide.name)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '12px',
|
||||
background: 'var(--rah-bg-elevated)',
|
||||
border: '1px solid var(--rah-bg-active)',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.background = 'var(--rah-bg-active)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.background = 'var(--rah-bg-elevated)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-bg-active)';
|
||||
}}
|
||||
>
|
||||
{guide.immutable && (
|
||||
<Lock size={12} style={{ color: '#22c55e', flexShrink: 0 }} />
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ color: 'var(--rah-text-base)', fontSize: '13px', fontWeight: 500 }}>
|
||||
{guide.name}
|
||||
</span>
|
||||
<span style={{ color: 'var(--rah-text-muted)', fontSize: '12px', lineHeight: '1.4', display: 'block', marginTop: '2px' }}>
|
||||
{guide.description}
|
||||
</span>
|
||||
</div>
|
||||
{!guide.immutable && (
|
||||
<button
|
||||
onClick={(e) => onDelete(guide.name, e)}
|
||||
disabled={deleting === guide.name}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--rah-text-muted)',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
opacity: deleting === guide.name ? 0.3 : 1,
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = '#ef4444'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ArrowLeft, Brain } from 'lucide-react';
|
||||
import PaneHeader from './PaneHeader';
|
||||
import type { BasePaneProps } from './types';
|
||||
import type { FocusedSkill, Skill, SkillMeta } from '@/types/skills';
|
||||
import SkillCard from '@/components/skills/SkillCard';
|
||||
import SkillMarkdown from '@/components/skills/SkillMarkdown';
|
||||
import type { FocusedSkill } from '@/types/skills';
|
||||
|
||||
interface SkillMeta {
|
||||
name: string;
|
||||
description: string;
|
||||
immutable: boolean;
|
||||
}
|
||||
|
||||
interface Skill extends SkillMeta {
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface SkillsPaneProps extends BasePaneProps {
|
||||
focusedSkill?: FocusedSkill | null;
|
||||
@@ -30,35 +20,19 @@ export default function SkillsPane({
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
tabBar,
|
||||
focusedSkill,
|
||||
onFocusSkill,
|
||||
autoOpenSkillName,
|
||||
onAutoOpenHandled,
|
||||
}: SkillsPaneProps) {
|
||||
const [skills, setSkills] = useState<SkillMeta[]>([]);
|
||||
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const detailScrollRef = useRef<HTMLDivElement>(null);
|
||||
const isSelectedSkillFocused = !!selectedSkill && focusedSkill?.name === selectedSkill.name;
|
||||
|
||||
useEffect(() => {
|
||||
void fetchSkills();
|
||||
|
||||
const handleSkillUpdated = () => {
|
||||
void fetchSkills();
|
||||
};
|
||||
window.addEventListener('skills:updated', handleSkillUpdated);
|
||||
window.addEventListener('guides:updated', handleSkillUpdated);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('skills:updated', handleSkillUpdated);
|
||||
window.removeEventListener('guides:updated', handleSkillUpdated);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSkill && detailScrollRef.current) {
|
||||
detailScrollRef.current.scrollTo({ top: 0, behavior: 'auto' });
|
||||
}
|
||||
}, [selectedSkill?.name]);
|
||||
|
||||
const fetchSkills = async () => {
|
||||
const fetchSkills = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/skills');
|
||||
const data = await res.json();
|
||||
@@ -70,18 +44,54 @@ export default function SkillsPane({
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSelectSkill = async (name: string) => {
|
||||
const handleSelectSkill = useCallback(async (name: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/skills/${encodeURIComponent(name)}`);
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setSelectedSkill(data.data);
|
||||
onFocusSkill?.(data.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[SkillsPane] Failed to fetch skill:', err);
|
||||
}
|
||||
}, [onFocusSkill]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSkills();
|
||||
|
||||
const handleSkillUpdated = () => {
|
||||
void fetchSkills();
|
||||
};
|
||||
window.addEventListener('skills:updated', handleSkillUpdated);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('skills:updated', handleSkillUpdated);
|
||||
};
|
||||
}, [fetchSkills]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSkill && detailScrollRef.current) {
|
||||
detailScrollRef.current.scrollTo({ top: 0, behavior: 'auto' });
|
||||
}
|
||||
}, [selectedSkill?.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoOpenSkillName || loading || selectedSkill) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
await handleSelectSkill(autoOpenSkillName);
|
||||
onAutoOpenHandled?.();
|
||||
})();
|
||||
}, [autoOpenSkillName, handleSelectSkill, loading, onAutoOpenHandled, selectedSkill]);
|
||||
|
||||
const handleBack = () => {
|
||||
setSelectedSkill(null);
|
||||
onFocusSkill?.(null);
|
||||
};
|
||||
|
||||
const handleDeleteSkill = async (name: string, e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
@@ -97,6 +107,9 @@ export default function SkillsPane({
|
||||
if (selectedSkill?.name === name) {
|
||||
setSelectedSkill(null);
|
||||
}
|
||||
if (focusedSkill?.name === name) {
|
||||
onFocusSkill?.(null);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[SkillsPane] Failed to delete skill:', err);
|
||||
@@ -120,17 +133,23 @@ export default function SkillsPane({
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedSkill(null)}
|
||||
onClick={handleBack}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
color: 'var(--rah-text-soft)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--rah-text-secondary)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--rah-text-soft)';
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
@@ -149,6 +168,26 @@ export default function SkillsPane({
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ color: 'var(--rah-text-active)', fontSize: '16px', fontWeight: 600 }}>{selectedSkill.name}</div>
|
||||
<div style={{ color: 'var(--rah-text-soft)', fontSize: '13px', lineHeight: 1.4, marginTop: '6px' }}>{selectedSkill.description}</div>
|
||||
{isSelectedSkillFocused && (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
marginTop: '10px',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '999px',
|
||||
background: 'var(--rah-accent-green-soft)',
|
||||
border: '1px solid var(--rah-accent-green-soft-strong)',
|
||||
color: 'var(--rah-accent-green)',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<Brain size={12} />
|
||||
Active chat context
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ borderTop: '1px solid var(--rah-border)', margin: '12px 0' }} />
|
||||
<SkillMarkdown content={selectedSkill.content} />
|
||||
@@ -167,6 +206,7 @@ export default function SkillsPane({
|
||||
onSelect={handleSelectSkill}
|
||||
onDelete={handleDeleteSkill}
|
||||
deleting={deleting}
|
||||
isActive={focusedSkill?.name === skill.name}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Pencil, Trash2, Save, X, FileText } from 'lucide-react';
|
||||
|
||||
interface SkillMeta {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Skill extends SkillMeta {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export default function GuidesViewer() {
|
||||
const [guides, setGuides] = useState<SkillMeta[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState<Skill | null>(null);
|
||||
const [isNew, setIsNew] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGuides();
|
||||
}, []);
|
||||
|
||||
const fetchGuides = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/skills');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setGuides(data.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch skills:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async (name: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/skills/${encodeURIComponent(name)}`);
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setEditing(data.data);
|
||||
setIsNew(false);
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch skill:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNew = () => {
|
||||
setEditing({
|
||||
name: '',
|
||||
description: '',
|
||||
content: '# New Skill\n\nWrite your skill content here...',
|
||||
});
|
||||
setIsNew(true);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editing) return;
|
||||
if (!editing.name.trim()) {
|
||||
setError('Name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/skills/${encodeURIComponent(editing.name)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: editing.content,
|
||||
description: editing.description,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setEditing(null);
|
||||
fetchGuides();
|
||||
window.dispatchEvent(new Event('skills:updated'));
|
||||
window.dispatchEvent(new Event('guides:updated'));
|
||||
} else {
|
||||
setError(data.error || 'Failed to save');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to save skill');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (name: string) => {
|
||||
if (!confirm(`Delete skill "${name}"?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/skills/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
fetchGuides();
|
||||
window.dispatchEvent(new Event('guides:updated'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete skill:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '24px', color: 'var(--rah-text-muted)' }}>Loading skills...</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', padding: '24px' }}>
|
||||
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.name}
|
||||
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
|
||||
placeholder="Skill name"
|
||||
disabled={!isNew}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 12px',
|
||||
background: 'var(--rah-bg-active)',
|
||||
border: '1px solid var(--rah-border-stronger)',
|
||||
borderRadius: '6px',
|
||||
color: 'var(--rah-text-active)',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#22c55e',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
color: '#000',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<Save size={14} /> Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditing(null)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--rah-border-stronger)',
|
||||
borderRadius: '6px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
<X size={14} /> Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ color: '#ef4444', fontSize: '13px', marginBottom: '12px' }}>{error}</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={editing.description}
|
||||
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
|
||||
placeholder="Brief description"
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: 'var(--rah-bg-active)',
|
||||
border: '1px solid var(--rah-border-stronger)',
|
||||
borderRadius: '6px',
|
||||
color: 'var(--rah-text-active)',
|
||||
fontSize: '13px',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
value={editing.content}
|
||||
onChange={(e) => setEditing({ ...editing, content: e.target.value })}
|
||||
placeholder="Skill content (markdown)"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: 'var(--rah-bg-active)',
|
||||
border: '1px solid var(--rah-border-stronger)',
|
||||
borderRadius: '6px',
|
||||
color: 'var(--rah-text-secondary)',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'monospace',
|
||||
resize: 'none',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
/>
|
||||
|
||||
<p style={{ color: 'var(--rah-text-muted)', fontSize: '12px', marginTop: '12px' }}>
|
||||
Skills are markdown files that agents can read for reusable procedural instructions.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', padding: '24px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<p style={{ color: 'var(--rah-text-muted)', fontSize: '13px', margin: 0 }}>
|
||||
Skills provide reusable context and procedural instructions for agents.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleNew}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#22c55e',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
color: '#000',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<Plus size={14} /> New Skill
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{guides.length === 0 ? (
|
||||
<div style={{ color: 'var(--rah-text-muted)', textAlign: 'center', paddingTop: '48px' }}>
|
||||
<FileText size={48} style={{ marginBottom: '12px', opacity: 0.5 }} />
|
||||
<p style={{ fontSize: '14px' }}>No skills yet</p>
|
||||
<p style={{ fontSize: '12px', color: 'var(--rah-text-muted)' }}>Create skills to guide how agents operate in your knowledge base</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{guides.map((guide) => (
|
||||
<div
|
||||
key={guide.name}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '12px 16px',
|
||||
background: 'var(--rah-bg-elevated)',
|
||||
border: '1px solid var(--rah-bg-active)',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
<FileText size={18} style={{ color: '#22c55e', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: 'var(--rah-text-base)', fontSize: '14px', fontWeight: 500 }}>{guide.name}</div>
|
||||
<div style={{ color: 'var(--rah-text-muted)', fontSize: '12px', marginTop: '2px' }}>{guide.description}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEdit(guide.name)}
|
||||
style={{
|
||||
padding: '6px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--rah-text-muted)',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(guide.name)}
|
||||
style={{
|
||||
padding: '6px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--rah-text-muted)',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2 } from 'lucide-react';
|
||||
|
||||
interface SkillMeta {
|
||||
name: string;
|
||||
description: string;
|
||||
immutable: boolean;
|
||||
}
|
||||
import type { SkillMeta } from '@/types/skills';
|
||||
|
||||
interface SkillCardProps {
|
||||
skill: SkillMeta;
|
||||
onSelect: (name: string) => void;
|
||||
onDelete?: (name: string, e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
deleting?: string | null;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export default function SkillCard({
|
||||
@@ -20,27 +16,33 @@ export default function SkillCard({
|
||||
onSelect,
|
||||
onDelete,
|
||||
deleting = null,
|
||||
isActive = false,
|
||||
}: SkillCardProps) {
|
||||
const isDeleting = deleting === skill.name;
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--rah-bg-active)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.background = 'var(--rah-bg-hover)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'var(--rah-bg-elevated)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.background = 'var(--rah-bg-panel)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border)';
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '10px',
|
||||
background: 'var(--rah-bg-elevated)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
background: isActive ? 'var(--rah-bg-active)' : 'var(--rah-bg-panel)',
|
||||
border: `1px solid ${isActive ? 'var(--rah-accent-green)' : 'var(--rah-border)'}`,
|
||||
borderRadius: '8px',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
@@ -56,7 +58,9 @@ export default function SkillCard({
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--rah-text-base)', fontSize: '13px', fontWeight: 500, display: 'block' }}>{skill.name}</span>
|
||||
<span style={{ color: 'var(--rah-text-base)', fontSize: '13px', fontWeight: 500, display: 'block' }}>
|
||||
{skill.name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--rah-text-muted)',
|
||||
@@ -81,7 +85,7 @@ export default function SkillCard({
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#555',
|
||||
color: 'var(--rah-text-muted)',
|
||||
cursor: isDeleting ? 'default' : 'pointer',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
@@ -91,6 +95,13 @@ export default function SkillCard({
|
||||
flexShrink: 0,
|
||||
opacity: isDeleting ? 0.3 : 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#ef4444';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--rah-text-muted)';
|
||||
}}
|
||||
aria-label={`Delete ${skill.name}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user