mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-10-26 01:50:40 +08:00
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:
1 parent
4e2bcec082
commit
345cdd59ac
26 files changed
+876
-28
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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');
|
||||
@@ -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>}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ダーク",
|
||||
|
||||
@@ -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": "深色",
|
||||
|
||||
@@ -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": "深色",
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in new issue
Block a user