feat: add skill management page with local/recommended tabs

- Add /skills route with local skills browser and recommended presets
- Backend: ListSkills/SkillDirs/AgentTypeName on Engine, skill presets API
- Provider preset cards: pin register/add buttons to card bottom
- Fix layout footer overlap by removing min-h-0 from content wrapper
- Replace logo with styled text brand, remove temporary local JSON override
- Add i18n translations for skill-related strings (en/zh/zh-TW/ja/es)

Made-with: Cursor
This commit is contained in:
Claude committed 2026-04-19 08:57:45 +08:00
1 parent 4e2bcec082
commit 345cdd59ac
26 files changed
+876 -28

No files matched your search

+1
View File
@@ -928,6 +928,7 @@ func main() {
return config.RemoveGlobalProvider(name)
})
mgmtSrv.SetFetchPresets(core.FetchProviderPresets)
mgmtSrv.SetFetchSkillPresets(core.FetchSkillPresets)
if cfg.ProviderPresetsURL != "" {
core.SetPresetsURL(cfg.ProviderPresetsURL)
}
+18
View File
@@ -884,6 +884,24 @@ func (e *Engine) ProjectName() string {
return e.name
}
// ListSkills returns all discovered skills for this engine's project.
func (e *Engine) ListSkills() []*Skill {
return e.skills.ListAll()
}
// SkillDirs returns the configured skill directories for this engine.
func (e *Engine) SkillDirs() []string {
return e.skills.Dirs()
}
// AgentTypeName returns the agent type name (e.g. "claudecode", "codex").
func (e *Engine) AgentTypeName() string {
if e.agent != nil {
return e.agent.Name()
}
return ""
}
// ActiveSessionKeys returns the session keys of all active interactive sessions.
func (e *Engine) ActiveSessionKeys() []string {
e.interactiveMu.Lock()
+73
View File
@@ -63,6 +63,7 @@ type ManagementServer struct {
updateGlobalProvider func(name string, info GlobalProviderInfo) error
removeGlobalProvider func(name string) error
fetchPresets func() (*ProviderPresetsResponse, error)
fetchSkillPresets func() (*SkillPresetsResponse, error)
// cc-switch migration callback
listCCSwitchProviders func() ([]CCSwitchProviderInfo, error)
@@ -173,6 +174,9 @@ func (m *ManagementServer) SetRemoveGlobalProvider(fn func(string) error) {
func (m *ManagementServer) SetFetchPresets(fn func() (*ProviderPresetsResponse, error)) {
m.fetchPresets = fn
}
func (m *ManagementServer) SetFetchSkillPresets(fn func() (*SkillPresetsResponse, error)) {
m.fetchSkillPresets = fn
}
func (m *ManagementServer) SetListCCSwitchProviders(fn func() ([]CCSwitchProviderInfo, error)) {
m.listCCSwitchProviders = fn
}
@@ -236,6 +240,10 @@ func (m *ManagementServer) buildHandler(mux *http.ServeMux) http.Handler {
mux.HandleFunc(prefix+"/providers", m.wrap(m.handleGlobalProviders))
mux.HandleFunc(prefix+"/providers/", m.wrap(m.handleGlobalProviderRoutes))
// Skills
mux.HandleFunc(prefix+"/skills", m.wrap(m.handleSkills))
mux.HandleFunc(prefix+"/skills/presets", m.wrap(m.handleSkillPresets))
// Bridge
mux.HandleFunc(prefix+"/bridge/adapters", m.wrap(m.handleBridgeAdapters))
@@ -1868,3 +1876,68 @@ func resolveGlobalProviderForAgent(g GlobalProviderInfo, agentType string) Provi
}
return pc
}
// ── Skills API ──
type skillInfo struct {
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
Description string `json:"description,omitempty"`
Source string `json:"source"`
}
type projectSkills struct {
Project string `json:"project"`
AgentType string `json:"agent_type"`
Dirs []string `json:"dirs"`
Skills []skillInfo `json:"skills"`
}
func (m *ManagementServer) handleSkills(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
mgmtError(w, http.StatusMethodNotAllowed, "GET only")
return
}
m.mu.RLock()
defer m.mu.RUnlock()
var result []projectSkills
for name, e := range m.engines {
skills := e.ListSkills()
items := make([]skillInfo, 0, len(skills))
for _, s := range skills {
items = append(items, skillInfo{
Name: s.Name,
DisplayName: s.DisplayName,
Description: s.Description,
Source: s.Source,
})
}
result = append(result, projectSkills{
Project: name,
AgentType: e.AgentTypeName(),
Dirs: e.SkillDirs(),
Skills: items,
})
}
mgmtJSON(w, http.StatusOK, map[string]any{"projects": result})
}
func (m *ManagementServer) handleSkillPresets(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
mgmtError(w, http.StatusMethodNotAllowed, "GET only")
return
}
if m.fetchSkillPresets == nil {
mgmtJSON(w, http.StatusOK, &SkillPresetsResponse{Version: 1})
return
}
data, err := m.fetchSkillPresets()
if err != nil {
mgmtError(w, http.StatusBadGateway, "fetch skill presets: "+err.Error())
return
}
mgmtJSON(w, http.StatusOK, data)
}
+7
View File
@@ -158,6 +158,13 @@ func realPath(path string) string {
return filepath.Clean(path)
}
// Dirs returns the configured skill directories.
func (r *SkillRegistry) Dirs() []string {
r.mu.RLock()
defer r.mu.RUnlock()
return append([]string(nil), r.dirs...)
}
// Invalidate clears the cache so skills are re-scanned on next access.
func (r *SkillRegistry) Invalidate() {
r.mu.Lock()
+141
View File
@@ -0,0 +1,141 @@
package core
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"sync"
"time"
)
const (
defaultSkillPresetsURL = "https://raw.githubusercontent.com/chenhg5/cc-connect/main/skill-presets.json"
fallbackSkillPresetsURL = "https://gitee.com/chenhg5/cc-connect/raw/main/skill-presets.json"
skillPresetsCacheTTL = 6 * time.Hour
skillPresetsHTTPTimeout = 15 * time.Second
skillPresetsFallbackHTTPTimeout = 10 * time.Second
)
// SkillPreset describes a recommended skill available from the remote presets list.
type SkillPreset struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description,omitempty"`
DescriptionZh string `json:"description_zh,omitempty"`
Version string `json:"version,omitempty"`
Author string `json:"author,omitempty"`
URL string `json:"url,omitempty"`
AgentTypes []string `json:"agent_types,omitempty"`
Tags []string `json:"tags,omitempty"`
Featured bool `json:"featured,omitempty"`
Source *SkillSource `json:"source,omitempty"`
Pricing *SkillPricing `json:"pricing,omitempty"`
}
// SkillSource describes where the skill is hosted / provided from.
type SkillSource struct {
Provider string `json:"provider"` // e.g. "github", "skills.sh", "npm"
Name string `json:"name,omitempty"` // display name, e.g. "GitHub", "Skills.sh"
URL string `json:"url,omitempty"` // provider home page
}
// SkillPricing describes the pricing model for a skill.
type SkillPricing struct {
Type string `json:"type"` // "free", "paid", "freemium"
Price float64 `json:"price,omitempty"` // 0 for free
Currency string `json:"currency,omitempty"` // "USD", "CNY", etc.
}
// SkillPresetsResponse is the top-level JSON schema for remote skill presets.
type SkillPresetsResponse struct {
Version int `json:"version"`
UpdatedAt string `json:"updated_at,omitempty"`
Skills []SkillPreset `json:"skills"`
}
type skillPresetsCache struct {
mu sync.RWMutex
data *SkillPresetsResponse
fetchedAt time.Time
url string
}
var globalSkillPresetsCache = &skillPresetsCache{}
// SetSkillPresetsURL overrides the default skill presets URL.
func SetSkillPresetsURL(url string) {
globalSkillPresetsCache.mu.Lock()
defer globalSkillPresetsCache.mu.Unlock()
globalSkillPresetsCache.url = url
globalSkillPresetsCache.data = nil
}
// FetchSkillPresets returns cached or freshly-fetched skill presets.
func FetchSkillPresets() (*SkillPresetsResponse, error) {
return globalSkillPresetsCache.fetch()
}
func (c *skillPresetsCache) fetch() (*SkillPresetsResponse, error) {
c.mu.RLock()
if c.data != nil && time.Since(c.fetchedAt) < skillPresetsCacheTTL {
defer c.mu.RUnlock()
return c.data, nil
}
c.mu.RUnlock()
c.mu.Lock()
defer c.mu.Unlock()
if c.data != nil && time.Since(c.fetchedAt) < skillPresetsCacheTTL {
return c.data, nil
}
primaryURL := c.url
if primaryURL == "" {
primaryURL = defaultSkillPresetsURL
}
result, err := fetchSkillPresetsFromURL(primaryURL, skillPresetsHTTPTimeout)
if err != nil {
slog.Warn("primary skill presets fetch failed, trying fallback", "url", primaryURL, "error", err)
result, err = fetchSkillPresetsFromURL(fallbackSkillPresetsURL, skillPresetsFallbackHTTPTimeout)
}
if err != nil {
if c.data != nil {
slog.Warn("all skill presets sources failed, using stale cache", "error", err)
return c.data, nil
}
return nil, fmt.Errorf("fetch skill presets: %w", err)
}
c.data = result
c.fetchedAt = time.Now()
return c.data, nil
}
func fetchSkillPresetsFromURL(url string, timeout time.Duration) (*SkillPresetsResponse, error) {
slog.Debug("fetching skill presets", "url", url)
client := &http.Client{Timeout: timeout}
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("HTTP GET %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP GET %s: status %d", url, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("read body from %s: %w", url, err)
}
var result SkillPresetsResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse JSON from %s: %w", url, err)
}
return &result, nil
}
+25
View File
@@ -0,0 +1,25 @@
{
"version": 1,
"updated_at": "2026-04-18",
"skills": [
{
"name": "find-skills",
"display_name": "Find Skills",
"description": "Discover and install specialized agent skills from the open ecosystem when users need extended capabilities",
"description_zh": "从开放技能生态中发现和安装专业 Agent 技能,帮助用户扩展 AI 代理能力",
"version": "1.0.0",
"author": "vercel-labs",
"url": "https://skills.sh/vercel-labs/skills/find-skills",
"tags": ["skills", "ecosystem", "discovery"],
"featured": true,
"source": {
"provider": "skills.sh",
"name": "Skills.sh",
"url": "https://skills.sh"
},
"pricing": {
"type": "free"
}
}
]
}
+2
View File
@@ -10,6 +10,7 @@ import ChatView from '@/pages/Chat/ChatView';
import CronList from '@/pages/Cron/CronList';
import SystemConfig from '@/pages/System/Config';
import ProviderList from '@/pages/Providers/ProviderList';
import SkillList from '@/pages/Skills/SkillList';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
@@ -28,6 +29,7 @@ export default function App() {
<Route path="projects" element={<ProjectList />} />
<Route path="projects/:name" element={<ProjectDetail />} />
<Route path="providers" element={<ProviderList />} />
<Route path="skills" element={<SkillList />} />
<Route path="chat" element={<ChatList />} />
<Route path="chat/:name" element={<ChatView />} />
<Route path="cron" element={<CronList />} />
+54
View File
@@ -0,0 +1,54 @@
import api from './client';
export interface SkillInfo {
name: string;
display_name?: string;
description?: string;
source: string;
}
export interface ProjectSkills {
project: string;
agent_type: string;
dirs: string[];
skills: SkillInfo[];
}
export interface SkillSource {
provider: string;
name?: string;
url?: string;
}
export interface SkillPricing {
type: 'free' | 'paid' | 'freemium';
price?: number;
currency?: string;
}
export interface SkillPreset {
name: string;
display_name: string;
description?: string;
description_zh?: string;
version?: string;
author?: string;
url?: string;
agent_types?: string[];
tags?: string[];
featured?: boolean;
source?: SkillSource;
pricing?: SkillPricing;
}
export interface SkillPresetsResponse {
version: number;
updated_at?: string;
skills: SkillPreset[];
}
export const listSkills = () =>
api.get<{ projects: ProjectSkills[] }>('/skills');
export const fetchSkillPresets = () =>
api.get<SkillPresetsResponse>('/skills/presets');
+1 -1
View File
@@ -11,7 +11,7 @@ export default function Footer() {
const year = new Date().getFullYear();
return (
<footer className="mt-4 pt-3 pb-1 text-center text-xs text-gray-400 dark:text-gray-500 select-none">
<footer className="shrink-0 mt-4 pt-3 pb-1 text-center text-xs text-gray-400 dark:text-gray-500 select-none">
<span>© {year} CC-Connect</span>
{version && <span className="mx-1.5">·</span>}
{version && <span>{version.startsWith('v') ? version : `v${version}`}</span>}
+1 -1
View File
@@ -17,7 +17,7 @@ export default function Layout() {
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
<Header />
<main className="flex-1 overflow-y-auto p-6 flex flex-col min-h-0">
<div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 flex flex-col">
<Outlet />
</div>
<Footer />
+12 -13
View File
@@ -9,6 +9,7 @@ import {
ChevronLeft,
ChevronRight,
Plug,
Puzzle,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useState } from 'react';
@@ -17,6 +18,7 @@ const navItems = [
{ key: 'dashboard', path: '/', icon: LayoutDashboard },
{ key: 'projects', path: '/projects', icon: FolderKanban },
{ key: 'providers', path: '/providers', icon: Plug },
{ key: 'skills', path: '/skills', icon: Puzzle },
{ key: 'chat', path: '/chat', icon: MessageSquare },
{ key: 'cron', path: '/cron', icon: Clock },
{ key: 'system', path: '/system', icon: Settings },
@@ -35,24 +37,21 @@ export default function Sidebar() {
collapsed ? 'w-16' : 'w-56',
)}
>
{/* Logo */}
{/* Brand */}
<div
className={cn(
'flex items-center gap-3 px-4 h-14 border-b transition-colors shrink-0',
'flex items-center px-4 h-14 border-b transition-colors shrink-0',
'border-gray-200/80 dark:border-white/[0.08]',
collapsed ? 'justify-center' : 'gap-0',
)}
>
<div
className={cn(
'w-8 h-8 rounded-xl flex items-center justify-center shrink-0',
'bg-gray-900/90 dark:bg-white/10 ring-1 ring-black/5 dark:ring-white/10',
)}
>
<div className="w-3 h-3 rounded-full bg-accent dark:shadow-[0_0_12px_rgb(var(--color-accent)/0.45)]" />
</div>
{!collapsed && (
<span className="font-semibold text-gray-900 dark:text-white text-sm tracking-tight">
CC-Connect
{collapsed ? (
<span className="text-base font-bold tracking-tighter text-gray-900 dark:text-white">
CC
</span>
) : (
<span className="text-base font-bold tracking-tight text-gray-900 dark:text-white">
CC<span className="text-accent">-</span>Connect
</span>
)}
</div>
+25
View File
@@ -7,6 +7,7 @@
"chat": "Chat",
"cron": "Cron",
"bridge": "Bridge",
"skills": "Skills",
"system": "System"
},
"dashboard": {
@@ -375,6 +376,30 @@
"result": "Import complete: {{imported}} imported, {{skipped}} skipped."
}
},
"skills": {
"title": "Skills",
"subtitle": "Manage agent skills and discover new ones",
"tab": {
"local": "Local Skills",
"recommended": "Recommended"
},
"projects": "Projects",
"skillCount": "{{count}} skills",
"scanDirs": "Scan directories",
"noSkills": "No skills found",
"noSkillsHint": "Skills are loaded from agent skill directories (e.g. ~/.claude/skills/)",
"emptyProject": "No skills found in this project's directories",
"noPresets": "No recommended skills available",
"noPresetsHint": "Skill recommendations could not be loaded. Check your network connection.",
"featured": "Featured",
"allSkills": "All Skills",
"author": "Author",
"source": "From",
"download": "Download",
"free": "Free",
"freemium": "Freemium",
"paid": "Paid"
},
"theme": {
"light": "Light",
"dark": "Dark",
+25
View File
@@ -7,6 +7,7 @@
"chat": "Chat",
"cron": "Cron",
"bridge": "Puente",
"skills": "Habilidades",
"system": "Sistema"
},
"dashboard": {
@@ -375,6 +376,30 @@
"result": "Importación completa: {{imported}} importados, {{skipped}} omitidos."
}
},
"skills": {
"title": "Habilidades",
"subtitle": "Gestiona las habilidades del agente y descubre nuevas",
"tab": {
"local": "Locales",
"recommended": "Recomendadas"
},
"projects": "Proyectos",
"skillCount": "{{count}} habilidades",
"scanDirs": "Directorios escaneados",
"noSkills": "No se encontraron habilidades",
"noSkillsHint": "Las habilidades se cargan desde los directorios de habilidades del agente (ej. ~/.claude/skills/)",
"emptyProject": "No se encontraron habilidades en los directorios de este proyecto",
"noPresets": "No hay habilidades recomendadas",
"noPresetsHint": "No se pudieron cargar las recomendaciones. Verifica tu conexión a internet.",
"featured": "Destacadas",
"allSkills": "Todas",
"author": "Autor",
"source": "Fuente",
"download": "Descargar",
"free": "Gratis",
"freemium": "Freemium",
"paid": "De pago"
},
"theme": {
"light": "Claro",
"dark": "Oscuro",
+25
View File
@@ -7,6 +7,7 @@
"chat": "チャット",
"cron": "Cron",
"bridge": "ブリッジ",
"skills": "スキル",
"system": "システム"
},
"dashboard": {
@@ -375,6 +376,30 @@
"result": "インポート完了:{{imported}} 件成功、{{skipped}} 件スキップ。"
}
},
"skills": {
"title": "スキル",
"subtitle": "エージェントスキルの管理とおすすめスキルの発見",
"tab": {
"local": "ローカルスキル",
"recommended": "おすすめ"
},
"projects": "プロジェクト",
"skillCount": "{{count}} 件のスキル",
"scanDirs": "スキャンディレクトリ",
"noSkills": "スキルが見つかりません",
"noSkillsHint": "スキルはエージェントのスキルディレクトリから読み込まれます(例: ~/.claude/skills/)",
"emptyProject": "このプロジェクトのディレクトリにスキルがありません",
"noPresets": "おすすめスキルがありません",
"noPresetsHint": "スキル一覧の読み込みに失敗しました。ネットワーク接続を確認してください。",
"featured": "注目",
"allSkills": "すべてのスキル",
"author": "作者",
"source": "提供元",
"download": "ダウンロード",
"free": "無料",
"freemium": "フリーミアム",
"paid": "有料"
},
"theme": {
"light": "ライト",
"dark": "ダーク",
+25
View File
@@ -7,6 +7,7 @@
"chat": "對話",
"cron": "排程工作",
"bridge": "橋接",
"skills": "技能",
"system": "系統"
},
"dashboard": {
@@ -375,6 +376,30 @@
"result": "匯入完成:成功 {{imported}} 個,跳過 {{skipped}} 個。"
}
},
"skills": {
"title": "技能",
"subtitle": "管理 Agent 技能,探索更多推薦技能",
"tab": {
"local": "本地技能",
"recommended": "推薦"
},
"projects": "專案",
"skillCount": "{{count}} 個技能",
"scanDirs": "掃描目錄",
"noSkills": "未發現技能",
"noSkillsHint": "技能從 Agent 技能目錄載入(如 ~/.claude/skills/)",
"emptyProject": "此專案的技能目錄中未發現技能",
"noPresets": "暫無推薦技能",
"noPresetsHint": "無法載入推薦技能列表,請檢查網路連線。",
"featured": "精選",
"allSkills": "全部技能",
"author": "作者",
"source": "來源",
"download": "下載",
"free": "免費",
"freemium": "免費增值",
"paid": "付費"
},
"theme": {
"light": "淺色",
"dark": "深色",
+25
View File
@@ -7,6 +7,7 @@
"chat": "对话",
"cron": "定时任务",
"bridge": "桥接",
"skills": "技能",
"system": "系统"
},
"dashboard": {
@@ -375,6 +376,30 @@
"result": "导入完成:成功 {{imported}} 个,跳过 {{skipped}} 个。"
}
},
"skills": {
"title": "技能",
"subtitle": "管理 Agent 技能,发现更多推荐技能",
"tab": {
"local": "本地技能",
"recommended": "推荐"
},
"projects": "项目",
"skillCount": "{{count}} 个技能",
"scanDirs": "扫描目录",
"noSkills": "未发现技能",
"noSkillsHint": "技能从 Agent 技能目录加载(如 ~/.claude/skills/)",
"emptyProject": "此项目的技能目录中未发现技能",
"noPresets": "暂无推荐技能",
"noPresetsHint": "无法加载推荐技能列表,请检查网络连接。",
"featured": "精选",
"allSkills": "全部技能",
"author": "作者",
"source": "来源",
"download": "下载",
"free": "免费",
"freemium": "免费增值",
"paid": "付费"
},
"theme": {
"light": "浅色",
"dark": "深色",
+1 -1
View File
@@ -71,7 +71,7 @@ export default function ChatList() {
}
return (
<div className="animate-fade-in space-y-4">
<div className="animate-fade-in space-y-4 ">
<h2 className="text-lg font-bold text-gray-900 dark:text-white">{t('nav.chat')}</h2>
{entries.length === 0 ? (
+1 -1
View File
@@ -305,7 +305,7 @@ export default function CronList() {
}
return (
<div className="space-y-4 animate-fade-in">
<div className="space-y-4 animate-fade-in ">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('cron.title')}</h2>
<Button onClick={openAdd}><Plus size={16} /> {t('cron.add')}</Button>
+1 -1
View File
@@ -65,7 +65,7 @@ export default function Dashboard() {
}
return (
<div className="space-y-8 animate-fade-in">
<div className="space-y-8 animate-fade-in ">
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard label={t('dashboard.version')} value={status?.version || '-'} accent />
+1 -1
View File
@@ -225,7 +225,7 @@ export default function ProjectDetail() {
}
return (
<div className="space-y-6 animate-fade-in">
<div className="space-y-6 animate-fade-in ">
{/* Back + title */}
<div className="flex items-center gap-3">
<Link to="/projects" className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
+1 -1
View File
@@ -106,7 +106,7 @@ export default function ProjectList() {
}
return (
<div className="animate-fade-in space-y-4">
<div className="animate-fade-in space-y-4 ">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-gray-900 dark:text-white">{t('projects.title')}</h2>
+5 -5
View File
@@ -96,7 +96,7 @@ export default function ProviderList() {
};
return (
<div className="space-y-6">
<div className="space-y-6 ">
{/* Header */}
<div className="flex items-center justify-between">
<div>
@@ -300,13 +300,13 @@ function PresetGrid({
{sorted.map(p => {
const added = existingNames.has(p.name);
return (
<Card key={p.name} className="relative overflow-hidden">
<Card key={p.name} className="relative overflow-hidden flex flex-col">
{p.featured && (
<div className="absolute top-0 right-0 bg-amber-400/90 text-white text-[10px] font-bold px-2 py-0.5 rounded-bl-lg">
<Star size={10} className="inline mr-0.5 -mt-0.5" />
</div>
)}
<div className="space-y-3">
<div className="space-y-3 flex-1">
<div>
<h3 className="font-medium text-gray-900 dark:text-white">{p.display_name || p.name}</h3>
{(p.description || p.description_zh) && (
@@ -335,7 +335,8 @@ function PresetGrid({
<ModelBadges models={firstAc.models} limit={5} />
) : null;
})()}
<div className="flex items-center justify-between pt-1">
</div>
<div className="flex items-center justify-between mt-4 pt-3 border-t border-gray-100 dark:border-white/[0.06]">
{p.invite_url ? (
<a
href={p.invite_url}
@@ -355,7 +356,6 @@ function PresetGrid({
{added ? t('globalProviders.added') : t('globalProviders.addPreset')}
</Button>
</div>
</div>
</Card>
);
})}
+1 -1
View File
@@ -72,7 +72,7 @@ export default function SessionList() {
}
return (
<div className="space-y-4 animate-fade-in">
<div className="space-y-4 animate-fade-in ">
{/* Filter bar */}
<div className="flex items-center gap-3">
<Filter size={16} className="text-gray-400" />
+403
View File
@@ -0,0 +1,403 @@
import { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
Sparkles, Star, ExternalLink, FolderOpen, Puzzle, RefreshCw,
} from 'lucide-react';
import { Card, Badge, Button } from '@/components/ui';
import {
listSkills, fetchSkillPresets,
type ProjectSkills, type SkillPreset,
} from '@/api/skills';
import { cn } from '@/lib/utils';
type Tab = 'local' | 'recommended';
export default function SkillList() {
const { t, i18n } = useTranslation();
const [tab, setTab] = useState<Tab>('local');
const [projects, setProjects] = useState<ProjectSkills[]>([]);
const [presets, setPresets] = useState<SkillPreset[]>([]);
const [loading, setLoading] = useState(true);
const [presetsLoading, setPresetsLoading] = useState(false);
const [activeProject, setActiveProject] = useState('');
const refresh = useCallback(async () => {
setLoading(true);
try {
const data = await listSkills();
const ps = data.projects || [];
setProjects(ps);
if (ps.length > 0 && !activeProject) setActiveProject(ps[0].project);
} catch { /* empty */ }
setLoading(false);
}, [activeProject]);
const loadPresets = useCallback(async () => {
setPresetsLoading(true);
try {
const data = await fetchSkillPresets();
setPresets(data.skills || []);
} catch { /* empty */ }
setPresetsLoading(false);
}, []);
useEffect(() => { refresh(); }, []);
useEffect(() => {
if (tab === 'recommended' && presets.length === 0) loadPresets();
}, [tab, presets.length, loadPresets]);
const current = projects.find(p => p.project === activeProject);
const lang = i18n.language || 'en';
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
{t('skills.title')}
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t('skills.subtitle')}
</p>
</div>
{/* Tabs + refresh on the same row */}
<div className="flex items-center justify-between">
<div className="flex gap-1 p-1 rounded-xl bg-gray-100 dark:bg-white/[0.06] w-fit">
{(['local', 'recommended'] as const).map(key => (
<button
key={key}
onClick={() => setTab(key)}
className={cn(
'px-4 py-1.5 rounded-lg text-sm font-medium transition-all',
tab === key
? 'bg-white dark:bg-white/10 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300',
)}
>
{t(`skills.tab.${key}`)}
</button>
))}
</div>
{tab === 'recommended' && (
<Button variant="ghost" size="sm" onClick={loadPresets}>
<RefreshCw size={14} className="mr-1.5" />
{t('common.refresh')}
</Button>
)}
</div>
{tab === 'local' && (
<LocalSkills
projects={projects}
current={current}
activeProject={activeProject}
onSelectProject={setActiveProject}
loading={loading}
t={t}
/>
)}
{tab === 'recommended' && (
<RecommendedSkills
presets={presets}
loading={presetsLoading}
onRefresh={loadPresets}
t={t}
lang={lang}
/>
)}
</div>
);
}
/* ── Local Skills ── */
function LocalSkills({
projects, current, activeProject, onSelectProject, loading, t,
}: {
projects: ProjectSkills[];
current?: ProjectSkills;
activeProject: string;
onSelectProject: (name: string) => void;
loading: boolean;
t: (k: string, opts?: any) => string;
}) {
if (loading) {
return (
<div className="flex justify-center py-16">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent border-t-transparent" />
</div>
);
}
if (projects.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Puzzle size={40} className="text-gray-300 dark:text-gray-600 mb-3" />
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{t('skills.noSkills')}</p>
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">{t('skills.noSkillsHint')}</p>
</div>
);
}
return (
<div className="flex gap-6 items-start">
<div className="w-48 shrink-0 sticky top-4 max-h-[calc(100vh-8rem)] overflow-y-auto">
<p className="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2 px-2">
{t('skills.projects')}
</p>
<div className="space-y-0.5">
{projects.map(p => (
<button
key={p.project}
onClick={() => onSelectProject(p.project)}
className={cn(
'w-full flex items-center gap-2.5 px-3 py-2 rounded-xl text-sm transition-all text-left',
activeProject === p.project
? 'bg-accent/10 text-accent font-medium'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/[0.06]',
)}
>
<FolderOpen size={15} className="shrink-0" />
<div className="min-w-0 flex-1">
<div className="truncate">{p.project}</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">{p.agent_type}</div>
</div>
<Badge variant="outline" className="text-[10px] shrink-0">{p.skills?.length || 0}</Badge>
</button>
))}
</div>
</div>
<div className="flex-1 min-w-0">
{current && (
<>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-sm font-medium text-gray-900 dark:text-white">
{current.project}
</h2>
<Badge variant="info">{current.agent_type}</Badge>
<span className="text-xs text-gray-400">
{t('skills.skillCount', { count: current.skills?.length || 0 })}
</span>
</div>
{current.dirs && current.dirs.length > 0 && (
<div className="mb-4 rounded-xl bg-gray-50 dark:bg-white/[0.03] border border-gray-100 dark:border-white/[0.06] px-3 py-2">
<p className="text-[11px] font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1">
{t('skills.scanDirs')}
</p>
{current.dirs.map(d => (
<p key={d} className="text-xs text-gray-500 dark:text-gray-400 font-mono truncate">{d}</p>
))}
</div>
)}
{(!current.skills || current.skills.length === 0) ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Puzzle size={36} className="text-gray-300 dark:text-gray-600 mb-3" />
<p className="text-sm text-gray-500 dark:text-gray-400">{t('skills.emptyProject')}</p>
</div>
) : (
<div className="space-y-2">
{current.skills.map(s => (
<Card key={s.name + s.source} className="!py-3">
<div className="flex items-start gap-3">
<div
className={cn(
'mt-0.5 w-8 h-8 rounded-lg flex items-center justify-center shrink-0',
'bg-purple-50 dark:bg-purple-500/10 text-purple-500 dark:text-purple-400',
)}
>
<Puzzle size={16} />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
{s.display_name || s.name}
</h3>
{s.description && (
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{s.description}
</p>
)}
<p className="mt-1 text-[11px] text-gray-400 dark:text-gray-500 font-mono truncate">
{s.source}
</p>
</div>
</div>
</Card>
))}
</div>
)}
</>
)}
</div>
</div>
);
}
/* ── Recommended Skills ── */
function RecommendedSkills({
presets, loading, onRefresh, t, lang,
}: {
presets: SkillPreset[];
loading: boolean;
onRefresh: () => void;
t: (k: string, opts?: any) => string;
lang: string;
}) {
if (loading) {
return (
<div className="flex justify-center py-16">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent border-t-transparent" />
</div>
);
}
if (presets.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Sparkles size={40} className="text-gray-300 dark:text-gray-600 mb-3" />
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{t('skills.noPresets')}</p>
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">{t('skills.noPresetsHint')}</p>
<Button variant="ghost" onClick={onRefresh} className="mt-3">
<RefreshCw size={14} className="mr-1.5" />
{t('common.refresh')}
</Button>
</div>
);
}
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{presets.map(s => (
<SkillPresetCard key={s.name} skill={s} t={t} lang={lang} />
))}
</div>
);
}
/* ── Pricing Badge ── */
function PricingBadge({ pricing, t }: { pricing: SkillPreset['pricing']; t: (k: string) => string }) {
if (!pricing) return null;
if (pricing.type === 'free') {
return (
<span className={cn(
'inline-flex items-center px-2 py-0.5 rounded-md text-xs font-semibold',
'bg-green-50 text-green-600 dark:bg-green-500/10 dark:text-green-400',
)}>
{t('skills.free')}
</span>
);
}
if (pricing.type === 'freemium') {
return (
<span className={cn(
'inline-flex items-center px-2 py-0.5 rounded-md text-xs font-semibold',
'bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400',
)}>
{t('skills.freemium')}
</span>
);
}
const price = pricing.price ?? 0;
const currency = pricing.currency || 'USD';
const symbol = currency === 'CNY' ? '¥' : currency === 'EUR' ? '€' : '$';
return (
<span className={cn(
'inline-flex items-center px-2 py-0.5 rounded-md text-xs font-semibold',
'bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-400',
)}>
{symbol}{price}
</span>
);
}
/* ── Skill Card ── */
function SkillPresetCard({
skill: s, t, lang,
}: {
skill: SkillPreset;
t: (k: string) => string;
lang: string;
}) {
return (
<Card className="relative overflow-hidden flex flex-col">
{s.featured && (
<div className="absolute top-0 right-0 bg-amber-400/90 text-white text-[10px] font-bold px-2 py-0.5 rounded-bl-lg">
<Star size={10} className="inline mr-0.5 -mt-0.5" />
</div>
)}
{/* Body */}
<div className="space-y-3 flex-1">
<div>
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-medium text-gray-900 dark:text-white">
{s.display_name || s.name}
</h3>
{s.version && (
<Badge variant="outline" className="text-[10px]">{s.version}</Badge>
)}
<PricingBadge pricing={s.pricing} t={t} />
</div>
{(s.description || s.description_zh) && (
<p className="mt-1.5 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{lang.startsWith('zh') && s.description_zh ? s.description_zh : s.description}
</p>
)}
</div>
{s.tags && s.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{s.tags.map(tag => (
<Badge key={tag} variant="success" className="text-xs">{tag}</Badge>
))}
</div>
)}
</div>
{/* Footer: source + author left, download right */}
<div className="flex items-center justify-between mt-4 pt-3 border-t border-gray-100 dark:border-white/[0.06]">
<div className="flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500 min-w-0">
{s.source && (
<span className="inline-flex items-center gap-1 shrink-0">
{t('skills.source')}:
{s.source.url ? (
<a
href={s.source.url}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
{s.source.name || s.source.provider}
</a>
) : (
<span>{s.source.name || s.source.provider}</span>
)}
</span>
)}
{s.author && (
<span className="truncate">{t('skills.author')}: {s.author}</span>
)}
</div>
{s.url && (
<a
href={s.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-accent hover:bg-accent/10 transition-colors shrink-0"
>
{t('skills.download')} <ExternalLink size={12} />
</a>
)}
</div>
</Card>
);
}
+1 -1
View File
@@ -68,7 +68,7 @@ export default function SystemConfig() {
};
return (
<div className="space-y-6 animate-fade-in">
<div className="space-y-6 animate-fade-in ">
{/* Actions */}
<div className="flex flex-wrap gap-3">
<Button variant="secondary" onClick={handleReload}><RefreshCw size={16} /> {t('system.reload')}</Button>
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/api/bridge.ts","./src/api/client.ts","./src/api/cron.ts","./src/api/heartbeat.ts","./src/api/index.ts","./src/api/projects.ts","./src/api/providers.ts","./src/api/sessions.ts","./src/api/settings.ts","./src/api/setup.ts","./src/api/status.ts","./src/components/Layout/Footer.tsx","./src/components/Layout/Header.tsx","./src/components/Layout/Layout.tsx","./src/components/Layout/Sidebar.tsx","./src/components/ui/Badge.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/EmptyState.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/index.ts","./src/hooks/useBridgeSocket.ts","./src/i18n/index.ts","./src/lib/platformMeta.ts","./src/lib/utils.ts","./src/pages/Dashboard.tsx","./src/pages/Login.tsx","./src/pages/Bridge/BridgeAdapters.tsx","./src/pages/Chat/ChatList.tsx","./src/pages/Chat/ChatView.tsx","./src/pages/Chat/CommandPalette.tsx","./src/pages/Chat/CommandResultPanel.tsx","./src/pages/Chat/SessionDrawer.tsx","./src/pages/Cron/CronList.tsx","./src/pages/Projects/PlatformManualForm.tsx","./src/pages/Projects/PlatformSetupQR.tsx","./src/pages/Projects/ProjectDetail.tsx","./src/pages/Projects/ProjectList.tsx","./src/pages/Providers/ProviderList.tsx","./src/pages/Sessions/SessionChat.tsx","./src/pages/Sessions/SessionList.tsx","./src/pages/System/Config.tsx","./src/pages/System/GlobalSettings.tsx","./src/store/auth.ts","./src/store/theme.ts","./vite-env.d.ts"],"version":"5.8.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/api/bridge.ts","./src/api/client.ts","./src/api/cron.ts","./src/api/heartbeat.ts","./src/api/index.ts","./src/api/projects.ts","./src/api/providers.ts","./src/api/sessions.ts","./src/api/settings.ts","./src/api/setup.ts","./src/api/skills.ts","./src/api/status.ts","./src/components/Layout/Footer.tsx","./src/components/Layout/Header.tsx","./src/components/Layout/Layout.tsx","./src/components/Layout/Sidebar.tsx","./src/components/ui/Badge.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/EmptyState.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/index.ts","./src/hooks/useBridgeSocket.ts","./src/i18n/index.ts","./src/lib/platformMeta.ts","./src/lib/utils.ts","./src/pages/Dashboard.tsx","./src/pages/Login.tsx","./src/pages/Bridge/BridgeAdapters.tsx","./src/pages/Chat/ChatList.tsx","./src/pages/Chat/ChatView.tsx","./src/pages/Chat/CommandPalette.tsx","./src/pages/Chat/CommandResultPanel.tsx","./src/pages/Chat/SessionDrawer.tsx","./src/pages/Cron/CronList.tsx","./src/pages/Projects/PlatformManualForm.tsx","./src/pages/Projects/PlatformSetupQR.tsx","./src/pages/Projects/ProjectDetail.tsx","./src/pages/Projects/ProjectList.tsx","./src/pages/Providers/ProviderList.tsx","./src/pages/Sessions/SessionChat.tsx","./src/pages/Sessions/SessionList.tsx","./src/pages/Skills/SkillList.tsx","./src/pages/System/Config.tsx","./src/pages/System/GlobalSettings.tsx","./src/store/auth.ts","./src/store/theme.ts","./vite-env.d.ts"],"version":"5.8.3"}