mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
feat(desktop): rework Skills page with semantic theming
This commit is contained in:
@@ -154,14 +154,17 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
models_search_bocha_key: '配置博查 API Key',
|
models_search_bocha_key: '配置博查 API Key',
|
||||||
models_search_bocha_hint: '前往博查开放平台创建 API Key',
|
models_search_bocha_hint: '前往博查开放平台创建 API Key',
|
||||||
skills_title: '技能管理',
|
skills_title: '技能管理',
|
||||||
skills_desc: '查看、启用或禁用 Agent 技能',
|
skills_desc: '查看、启用或禁用 Agent 工具和技能',
|
||||||
|
skills_hub_btn: '探索技能广场',
|
||||||
tools_section_title: '内置工具',
|
tools_section_title: '内置工具',
|
||||||
skills_section_title: '自定义技能',
|
skills_section_title: '技能',
|
||||||
tools_loading: '加载工具中...',
|
tools_loading: '加载工具中...',
|
||||||
skills_loading: '加载技能中...',
|
skills_loading: '加载技能中...',
|
||||||
skills_loading_desc: '技能将在加载后显示',
|
skills_loading_desc: '技能将在加载后显示',
|
||||||
skill_enabled: '已启用',
|
skill_enabled: '已启用',
|
||||||
skill_disabled: '已禁用',
|
skill_disabled: '已禁用',
|
||||||
|
tools_empty: '暂无内置工具',
|
||||||
|
skills_empty: '暂无技能',
|
||||||
memory_title: '记忆管理',
|
memory_title: '记忆管理',
|
||||||
memory_desc: '查看 Agent 记忆文件和内容',
|
memory_desc: '查看 Agent 记忆文件和内容',
|
||||||
memory_tab_files: '记忆文件',
|
memory_tab_files: '记忆文件',
|
||||||
@@ -418,7 +421,8 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
config_max_turns: 'Max Memory Turns',
|
config_max_turns: 'Max Memory Turns',
|
||||||
config_max_steps: 'Max Steps',
|
config_max_steps: 'Max Steps',
|
||||||
skills_title: 'Skills',
|
skills_title: 'Skills',
|
||||||
skills_desc: 'View, enable, or disable agent skills',
|
skills_desc: 'View, enable, or disable agent tools and skills',
|
||||||
|
skills_hub_btn: 'Skill Hub',
|
||||||
tools_section_title: 'Built-in Tools',
|
tools_section_title: 'Built-in Tools',
|
||||||
skills_section_title: 'Skills',
|
skills_section_title: 'Skills',
|
||||||
tools_loading: 'Loading tools...',
|
tools_loading: 'Loading tools...',
|
||||||
@@ -426,6 +430,8 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
skills_loading_desc: 'Skills will be displayed here after loading',
|
skills_loading_desc: 'Skills will be displayed here after loading',
|
||||||
skill_enabled: 'Enabled',
|
skill_enabled: 'Enabled',
|
||||||
skill_disabled: 'Disabled',
|
skill_disabled: 'Disabled',
|
||||||
|
tools_empty: 'No built-in tools',
|
||||||
|
skills_empty: 'No skills found',
|
||||||
memory_title: 'Memory',
|
memory_title: 'Memory',
|
||||||
memory_desc: 'View agent memory files and contents',
|
memory_desc: 'View agent memory files and contents',
|
||||||
memory_tab_files: 'Memory Files',
|
memory_tab_files: 'Memory Files',
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Loader2, Wrench, Zap, Puzzle } from 'lucide-react'
|
||||||
import { t } from '../i18n'
|
import { t } from '../i18n'
|
||||||
import apiClient from '../api/client'
|
import apiClient from '../api/client'
|
||||||
import type { ToolInfo, SkillInfo } from '../types'
|
import type { ToolInfo, SkillInfo } from '../types'
|
||||||
|
import { Toggle } from './settings/primitives'
|
||||||
|
|
||||||
interface SkillsPageProps {
|
interface SkillsPageProps {
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SKILL_HUB_URL = 'https://skills.cowagent.ai/'
|
||||||
|
|
||||||
const SkillsPage: React.FC<SkillsPageProps> = ({ baseUrl }) => {
|
const SkillsPage: React.FC<SkillsPageProps> = ({ baseUrl }) => {
|
||||||
const [tools, setTools] = useState<ToolInfo[]>([])
|
const [tools, setTools] = useState<ToolInfo[]>([])
|
||||||
const [skills, setSkills] = useState<SkillInfo[]>([])
|
const [skills, setSkills] = useState<SkillInfo[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [toggling, setToggling] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
apiClient.setBaseUrl(baseUrl)
|
|
||||||
loadData()
|
|
||||||
}, [baseUrl])
|
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -31,103 +29,92 @@ const SkillsPage: React.FC<SkillsPageProps> = ({ baseUrl }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggle = async (skill: SkillInfo) => {
|
useEffect(() => {
|
||||||
setToggling(skill.name)
|
apiClient.setBaseUrl(baseUrl)
|
||||||
|
void loadData()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [baseUrl])
|
||||||
|
|
||||||
|
const toggle = async (skill: SkillInfo, enabled: boolean) => {
|
||||||
|
// Optimistic flip; revert on failure.
|
||||||
|
setSkills((prev) => prev.map((s) => (s.name === skill.name ? { ...s, enabled } : s)))
|
||||||
try {
|
try {
|
||||||
await apiClient.toggleSkill(skill.name, skill.enabled ? 'close' : 'open')
|
const res = await apiClient.toggleSkill(skill.name, enabled ? 'open' : 'close')
|
||||||
setSkills((prev) => prev.map((s) => (s.name === skill.name ? { ...s, enabled: !s.enabled } : s)))
|
if (res.status !== 'success') throw new Error()
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error('Toggle failed:', err)
|
setSkills((prev) => prev.map((s) => (s.name === skill.name ? { ...s, enabled: !enabled } : s)))
|
||||||
} finally {
|
|
||||||
setToggling(null)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="flex items-center justify-between px-6 pt-5 pb-3 flex-shrink-0">
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-100">{t('skills_title')}</h2>
|
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">{t('skills_desc')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Built-in Tools */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">{t('tools_section_title')}</span>
|
|
||||||
{tools.length > 0 && (
|
|
||||||
<span className="px-2 py-0.5 rounded-full text-xs bg-slate-100 dark:bg-white/10 text-slate-500 dark:text-slate-400">{tools.length}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center gap-2 py-4 text-slate-400 dark:text-slate-500 text-sm">
|
|
||||||
<i className="fas fa-spinner fa-spin text-xs" />
|
|
||||||
<span>{t('tools_loading')}</span>
|
|
||||||
</div>
|
|
||||||
) : tools.length > 0 ? (
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
{tools.map((tool) => (
|
|
||||||
<div key={tool.name} className="bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
|
||||||
<i className="fas fa-cog text-xs text-primary-400" />
|
|
||||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">{tool.name}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400 leading-relaxed">{tool.description}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Skills */}
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<h2 className="text-xl font-bold text-content">{t('skills_title')}</h2>
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">{t('skills_section_title')}</span>
|
<p className="text-xs text-content-tertiary mt-1">{t('skills_desc')}</p>
|
||||||
{skills.length > 0 && (
|
</div>
|
||||||
<span className="px-2 py-0.5 rounded-full text-xs bg-slate-100 dark:bg-white/10 text-slate-500 dark:text-slate-400">{skills.length}</span>
|
<a
|
||||||
)}
|
href={SKILL_HUB_URL}
|
||||||
</div>
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn text-xs font-medium text-accent bg-accent-soft hover:bg-accent-soft transition-colors"
|
||||||
|
>
|
||||||
|
<Puzzle size={12} />
|
||||||
|
{t('skills_hub_btn')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto border-t border-default">
|
||||||
|
<div className="max-w-4xl mx-auto px-6 py-5">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
<div className="flex items-center justify-center py-20 text-content-tertiary">
|
||||||
<div className="w-14 h-14 rounded-2xl bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center mb-3">
|
<Loader2 size={18} className="animate-spin mr-2" />
|
||||||
<i className="fas fa-bolt text-amber-400 text-lg" />
|
{t('skills_loading')}
|
||||||
</div>
|
|
||||||
<p className="text-slate-500 dark:text-slate-400 font-medium">{t('skills_loading')}</p>
|
|
||||||
<p className="text-sm text-slate-400 dark:text-slate-500 mt-1">{t('skills_loading_desc')}</p>
|
|
||||||
</div>
|
|
||||||
) : skills.length > 0 ? (
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
{skills.map((skill) => (
|
|
||||||
<div key={skill.name} className="bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<i className="fas fa-bolt text-xs text-amber-400" />
|
|
||||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">{skill.name}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggle(skill)}
|
|
||||||
disabled={toggling === skill.name}
|
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors cursor-pointer ${
|
|
||||||
skill.enabled ? 'bg-primary-400' : 'bg-slate-300 dark:bg-slate-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
||||||
skill.enabled ? 'translate-x-6' : 'translate-x-1'
|
|
||||||
}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400 leading-relaxed">{skill.description}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
<div className="space-y-8">
|
||||||
<div className="w-14 h-14 rounded-2xl bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center mb-3">
|
<Section title={t('tools_section_title')} count={tools.length}>
|
||||||
<i className="fas fa-bolt text-amber-400 text-lg" />
|
{tools.length === 0 ? (
|
||||||
</div>
|
<Empty text={t('tools_empty')} />
|
||||||
<p className="text-slate-500 dark:text-slate-400 font-medium">{t('skills_loading')}</p>
|
) : (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<div key={tool.name} className="rounded-card border border-default bg-surface p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<Wrench size={13} className="text-content-tertiary flex-shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-content font-mono truncate">{tool.name}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-content-tertiary leading-relaxed line-clamp-2">{tool.description || '--'}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={t('skills_section_title')} count={skills.length}>
|
||||||
|
{skills.length === 0 ? (
|
||||||
|
<Empty text={t('skills_empty')} />
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{skills.map((skill) => (
|
||||||
|
<div key={skill.name} className="rounded-card border border-default bg-surface p-4 flex items-start gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-inset flex items-center justify-center flex-shrink-0">
|
||||||
|
<Zap size={15} className={skill.enabled ? 'text-accent' : 'text-content-tertiary'} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-sm font-medium text-content truncate flex-1">
|
||||||
|
{skill.display_name || skill.name}
|
||||||
|
</span>
|
||||||
|
<Toggle checked={skill.enabled} onChange={(v) => toggle(skill, v)} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-content-tertiary leading-relaxed line-clamp-2">{skill.description || '--'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -136,4 +123,20 @@ const SkillsPage: React.FC<SkillsPageProps> = ({ baseUrl }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Section: React.FC<{ title: string; count: number; children: React.ReactNode }> = ({ title, count, children }) => (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-content-tertiary">{title}</span>
|
||||||
|
{count > 0 && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded-full text-xs bg-inset text-content-tertiary min-w-[20px] text-center">{count}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const Empty: React.FC<{ text: string }> = ({ text }) => (
|
||||||
|
<p className="text-sm text-content-tertiary py-2">{text}</p>
|
||||||
|
)
|
||||||
|
|
||||||
export default SkillsPage
|
export default SkillsPage
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ export interface ToolInfo {
|
|||||||
|
|
||||||
export interface SkillInfo {
|
export interface SkillInfo {
|
||||||
name: string
|
name: string
|
||||||
|
display_name?: string
|
||||||
description: string
|
description: string
|
||||||
source?: string
|
source?: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user