mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
feat(skills): add Skills tab UI with list, file tree, and content viewer
- SkillList: grouped display by source with icons and token estimates - SkillDetail: two-panel layout with file tree navigation + Markdown/code preview - Settings: integrated Skills tab with auto_awesome icon Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
039ad8afd7
commit
a06f6bf78f
221
desktop/src/components/skills/SkillDetail.tsx
Normal file
221
desktop/src/components/skills/SkillDetail.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
import { useState } from 'react'
|
||||
import { useSkillStore } from '../../stores/skillStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||
import { CodeViewer } from '../chat/CodeViewer'
|
||||
import type { FileTreeNode } from '../../types/skill'
|
||||
|
||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
export function SkillDetail() {
|
||||
const { selectedSkill, isDetailLoading, clearSelection } = useSkillStore()
|
||||
const t = useTranslation()
|
||||
const [selectedFile, setSelectedFile] = useState<string>('SKILL.md')
|
||||
|
||||
if (isDetailLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!selectedSkill) return null
|
||||
|
||||
const { meta, tree, files } = selectedSkill
|
||||
const currentFile = files.find((f) => f.path === selectedFile) || files[0]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Back button */}
|
||||
<div className="mb-3">
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
className="flex items-center gap-1 text-sm text-[var(--color-text-accent)] hover:underline"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">
|
||||
arrow_back
|
||||
</span>
|
||||
{t('settings.skills.back')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Skill header */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-base font-semibold text-[var(--color-text-primary)]">
|
||||
{meta.displayName || meta.name}
|
||||
</h3>
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)] leading-none uppercase">
|
||||
{meta.source}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)]">
|
||||
{meta.description}
|
||||
</p>
|
||||
<div className="flex gap-3 mt-1 text-xs text-[var(--color-text-tertiary)]">
|
||||
<span>~{Math.ceil(meta.contentLength / 4)} tokens</span>
|
||||
<span>
|
||||
{files.length} {t('settings.skills.files')}
|
||||
</span>
|
||||
{meta.version && <span>v{meta.version}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-panel: file tree + content viewer */}
|
||||
<div className="flex flex-1 min-h-0 border border-[var(--color-border)] rounded-xl overflow-hidden">
|
||||
{/* Left: File tree */}
|
||||
<div className="w-[200px] border-r border-[var(--color-border)] overflow-y-auto bg-[var(--color-surface-container-low)] py-1">
|
||||
<TreeView
|
||||
nodes={tree}
|
||||
selectedPath={selectedFile}
|
||||
onSelect={setSelectedFile}
|
||||
depth={0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: File content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{currentFile && (
|
||||
<div className="flex flex-col">
|
||||
{/* File path header */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--color-border)] bg-[var(--color-surface-container)]">
|
||||
<span className="text-xs text-[var(--color-text-tertiary)] font-mono">
|
||||
{currentFile.path}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)] uppercase tracking-wider">
|
||||
{currentFile.language}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
{currentFile.language === 'markdown' ? (
|
||||
<MarkdownRenderer content={currentFile.content} />
|
||||
) : (
|
||||
<CodeViewer
|
||||
code={currentFile.content}
|
||||
language={currentFile.language}
|
||||
maxLines={9999}
|
||||
showLineNumbers
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── File Tree Components ────────────────────────────────────────────────────
|
||||
|
||||
function TreeView({
|
||||
nodes,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
depth,
|
||||
}: {
|
||||
nodes: FileTreeNode[]
|
||||
selectedPath: string
|
||||
onSelect: (path: string) => void
|
||||
depth: number
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{nodes.map((node) => (
|
||||
<TreeItem
|
||||
key={node.path}
|
||||
node={node}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
depth={depth}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeItem({
|
||||
node,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
depth,
|
||||
}: {
|
||||
node: FileTreeNode
|
||||
selectedPath: string
|
||||
onSelect: (path: string) => void
|
||||
depth: number
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const isSelected = node.path === selectedPath
|
||||
const isDir = node.type === 'directory'
|
||||
|
||||
const icon = isDir
|
||||
? expanded
|
||||
? 'folder_open'
|
||||
: 'folder'
|
||||
: fileIcon(node.name)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => (isDir ? setExpanded(!expanded) : onSelect(node.path))}
|
||||
className={`w-full flex items-center gap-1.5 px-2 py-1 text-left text-xs transition-colors ${
|
||||
isSelected
|
||||
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] font-medium'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
style={{ paddingLeft: `${8 + depth * 16}px` }}
|
||||
>
|
||||
{isDir ? (
|
||||
<span className="material-symbols-outlined text-[12px] text-[var(--color-text-tertiary)]">
|
||||
{expanded ? 'expand_more' : 'chevron_right'}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ width: 12 }} />
|
||||
)}
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="truncate">{node.name}</span>
|
||||
</button>
|
||||
|
||||
{isDir && expanded && node.children && (
|
||||
<TreeView
|
||||
nodes={node.children}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function fileIcon(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
switch (ext) {
|
||||
case 'md':
|
||||
return 'description'
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
case 'py':
|
||||
case 'rs':
|
||||
case 'go':
|
||||
return 'code'
|
||||
case 'json':
|
||||
case 'yaml':
|
||||
case 'yml':
|
||||
case 'toml':
|
||||
return 'data_object'
|
||||
case 'sh':
|
||||
case 'bash':
|
||||
return 'terminal'
|
||||
default:
|
||||
return 'draft'
|
||||
}
|
||||
}
|
||||
122
desktop/src/components/skills/SkillList.tsx
Normal file
122
desktop/src/components/skills/SkillList.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useSkillStore } from '../../stores/skillStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { SkillMeta, SkillSource } from '../../types/skill'
|
||||
|
||||
const SOURCE_ORDER: SkillSource[] = ['user', 'project', 'plugin', 'mcp', 'bundled']
|
||||
|
||||
const SOURCE_ICONS: Record<SkillSource, string> = {
|
||||
user: 'person',
|
||||
project: 'folder',
|
||||
plugin: 'extension',
|
||||
mcp: 'hub',
|
||||
bundled: 'inventory_2',
|
||||
}
|
||||
|
||||
export function SkillList() {
|
||||
const { skills, isLoading, error, fetchSkills, fetchSkillDetail } =
|
||||
useSkillStore()
|
||||
const t = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
fetchSkills()
|
||||
}, [fetchSkills])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-sm text-[var(--color-error)] py-4">{error}</div>
|
||||
}
|
||||
|
||||
if (skills.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<span className="material-symbols-outlined text-[40px] text-[var(--color-text-tertiary)] mb-2 block">
|
||||
auto_awesome
|
||||
</span>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)]">
|
||||
{t('settings.skills.empty')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
|
||||
{t('settings.skills.emptyHint')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Group by source
|
||||
const grouped: Partial<Record<SkillSource, SkillMeta[]>> = {}
|
||||
for (const skill of skills) {
|
||||
const src = skill.source as SkillSource
|
||||
;(grouped[src] ??= []).push(skill)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{SOURCE_ORDER.map((source) => {
|
||||
const group = grouped[source]
|
||||
if (!group?.length) return null
|
||||
|
||||
return (
|
||||
<div key={source}>
|
||||
{/* Group header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)]">
|
||||
{SOURCE_ICONS[source]}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-[var(--color-text-tertiary)] uppercase tracking-wider">
|
||||
{t(`settings.skills.source.${source}`)}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]">
|
||||
({group.length})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Skill items */}
|
||||
<div className="flex flex-col gap-1">
|
||||
{group.map((skill) => (
|
||||
<button
|
||||
key={`${skill.source}-${skill.name}`}
|
||||
onClick={() =>
|
||||
skill.hasDirectory &&
|
||||
fetchSkillDetail(skill.source, skill.name)
|
||||
}
|
||||
disabled={!skill.hasDirectory}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-[var(--color-border)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] transition-all text-left group disabled:opacity-60 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:border-[var(--color-border)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">
|
||||
auto_awesome
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-[var(--color-text-primary)] truncate">
|
||||
{skill.displayName || skill.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">
|
||||
{skill.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)]">
|
||||
~{Math.ceil(skill.contentLength / 4)} tokens
|
||||
</span>
|
||||
{skill.hasDirectory && (
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">
|
||||
chevron_right
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -11,8 +11,11 @@ import { PROVIDER_PRESETS } from '../config/providerPresets'
|
||||
import type { ProviderPreset } from '../config/providerPresets'
|
||||
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping } from '../types/provider'
|
||||
import { AdapterSettings } from './AdapterSettings'
|
||||
import { useSkillStore } from '../stores/skillStore'
|
||||
import { SkillList } from '../components/skills/SkillList'
|
||||
import { SkillDetail } from '../components/skills/SkillDetail'
|
||||
|
||||
type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters'
|
||||
type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters' | 'skills'
|
||||
|
||||
export function Settings() {
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
|
||||
@ -27,6 +30,7 @@ export function Settings() {
|
||||
<TabButton icon="shield" label={t('settings.tab.permissions')} active={activeTab === 'permissions'} onClick={() => setActiveTab('permissions')} />
|
||||
<TabButton icon="tune" label={t('settings.tab.general')} active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
|
||||
<TabButton icon="chat" label={t('settings.tab.adapters')} active={activeTab === 'adapters'} onClick={() => setActiveTab('adapters')} />
|
||||
<TabButton icon="auto_awesome" label={t('settings.tab.skills')} active={activeTab === 'skills'} onClick={() => setActiveTab('skills')} />
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
@ -35,6 +39,7 @@ export function Settings() {
|
||||
{activeTab === 'permissions' && <PermissionSettings />}
|
||||
{activeTab === 'general' && <GeneralSettings />}
|
||||
{activeTab === 'adapters' && <AdapterSettings />}
|
||||
{activeTab === 'skills' && <SkillSettings />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -603,3 +608,30 @@ function GeneralSettings() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Skill Settings ──────────────────────────────────────
|
||||
|
||||
function SkillSettings() {
|
||||
const selectedSkill = useSkillStore((s) => s.selectedSkill)
|
||||
const t = useTranslation()
|
||||
|
||||
if (selectedSkill) {
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<SkillDetail />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">
|
||||
{t('settings.skills.title')}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">
|
||||
{t('settings.skills.description')}
|
||||
</p>
|
||||
<SkillList />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user