feat(experts): add built-in expert skills with per-agent activation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
xintaofei
2026-04-10 15:17:51 +08:00
parent 64d4e9c903
commit 5b613daded
73 changed files with 11199 additions and 30 deletions

View File

@@ -0,0 +1,682 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
Bot,
Bug,
CheckCheck,
FileCode2,
FlaskConical,
FolderOpen,
GitBranch,
GitFork,
GitMerge,
Lightbulb,
ListTodo,
Loader2,
MessageSquareQuote,
MessageSquareReply,
PlayCircle,
RefreshCw,
Sparkles,
type LucideIcon,
} from "lucide-react"
import { useLocale, useTranslations } from "next-intl"
import ReactMarkdown from "react-markdown"
import remarkGfm from "remark-gfm"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import {
acpListAgents,
expertsGetInstallStatus,
expertsLinkToAgent,
expertsList,
expertsOpenCentralDir,
expertsReadContent,
expertsUnlinkFromAgent,
openFolderWindow,
} from "@/lib/api"
import type {
AcpAgentInfo,
AgentType,
ExpertInstallStatus,
ExpertLinkState,
ExpertListItem,
} from "@/lib/types"
const ICON_MAP: Record<string, LucideIcon> = {
Lightbulb,
ListTodo,
PlayCircle,
Bot,
GitFork,
GitBranch,
FlaskConical,
CheckCheck,
Bug,
MessageSquareQuote,
MessageSquareReply,
GitMerge,
Sparkles,
FileCode2,
}
const CATEGORY_SORT: Record<string, number> = {
discovery: 1,
planning: 2,
execution: 3,
quality: 4,
debugging: 5,
review: 6,
meta: 7,
}
const LEFT_MIN_WIDTH = 320
const RIGHT_MIN_WIDTH = 440
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value))
}
function toPercent(pixels: number, totalPixels: number): number {
if (totalPixels <= 0) return 0
return (pixels / totalPixels) * 100
}
/**
* next-intl locales are lower-case underscored like `zh_cn`. Our expert
* metadata dictionary uses BCP47-ish keys like `zh-CN`. Normalize both
* sides and fall back to `en`.
*/
function pickLocalized(
dict: Record<string, string> | undefined,
locale: string
): string {
if (!dict) return ""
if (dict[locale]) return dict[locale]
const normalized = locale.replace("_", "-")
if (dict[normalized]) return dict[normalized]
const [lang] = normalized.split("-")
const match = Object.keys(dict).find(
(key) => key.toLowerCase().split("-")[0] === lang.toLowerCase()
)
if (match) return dict[match]
return dict.en ?? Object.values(dict)[0] ?? ""
}
function stripFrontmatter(content: string): string {
const match = content.match(/^---\s*\r?\n[\s\S]*?\r?\n---\s*(?:\r?\n)?/)
if (!match) return content
return content.slice(match[0].length)
}
function getIcon(name: string | null | undefined): LucideIcon {
if (name && ICON_MAP[name]) return ICON_MAP[name]
return Sparkles
}
export function ExpertsSettings() {
const t = useTranslations("ExpertsSettings")
const locale = useLocale()
const panelContainerRef = useRef<HTMLDivElement | null>(null)
const [panelContainerWidth, setPanelContainerWidth] = useState(0)
const [experts, setExperts] = useState<ExpertListItem[]>([])
const [agents, setAgents] = useState<AcpAgentInfo[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [selectedExpertId, setSelectedExpertId] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState("")
const [content, setContent] = useState<string>("")
const [contentLoading, setContentLoading] = useState(false)
const [statuses, setStatuses] = useState<Record<string, ExpertInstallStatus>>(
{}
)
const [statusLoading, setStatusLoading] = useState(false)
const [pendingMutation, setPendingMutation] = useState<string | null>(null)
const translatedCategory = useCallback(
(category: string): string => {
switch (category) {
case "discovery":
return t("categories.discovery")
case "planning":
return t("categories.planning")
case "execution":
return t("categories.execution")
case "quality":
return t("categories.quality")
case "debugging":
return t("categories.debugging")
case "review":
return t("categories.review")
case "meta":
return t("categories.meta")
default:
return category
}
},
[t]
)
const translatedState = useCallback(
(state: ExpertLinkState): string => {
switch (state) {
case "not_linked":
return t("states.not_linked")
case "linked_to_codeg":
return t("states.linked_to_codeg")
case "linked_elsewhere":
return t("states.linked_elsewhere")
case "blocked_by_real_directory":
return t("states.blocked_by_real_directory")
case "broken":
return t("states.broken")
default:
return state
}
},
[t]
)
const refresh = useCallback(async () => {
setLoading(true)
setLoadError(null)
try {
const [expertList, agentList] = await Promise.all([
expertsList(),
acpListAgents(),
])
setExperts(expertList)
setAgents(agentList)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setLoadError(message)
setExperts([])
setAgents([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
refresh().catch((err) => {
console.error("[ExpertsSettings] initial refresh failed:", err)
})
}, [refresh])
useEffect(() => {
const container = panelContainerRef.current
if (!container) return
const updateWidth = (next: number) => {
setPanelContainerWidth((prev) =>
Math.abs(prev - next) < 1 ? prev : next
)
}
updateWidth(container.getBoundingClientRect().width)
const observer = new ResizeObserver((entries) => {
updateWidth(
entries[0]?.contentRect.width ?? container.getBoundingClientRect().width
)
})
observer.observe(container)
return () => {
observer.disconnect()
}
}, [])
const sortedExperts = useMemo(() => {
return [...experts].sort((a, b) => {
const ca = CATEGORY_SORT[a.metadata.category] ?? 99
const cb = CATEGORY_SORT[b.metadata.category] ?? 99
if (ca !== cb) return ca - cb
const sa = a.metadata.sort_order ?? 0
const sb = b.metadata.sort_order ?? 0
if (sa !== sb) return sa - sb
return a.metadata.id.localeCompare(b.metadata.id)
})
}, [experts])
const filteredExperts = useMemo(() => {
const q = searchQuery.trim().toLowerCase()
if (!q) return sortedExperts
return sortedExperts.filter((item) => {
const name = pickLocalized(item.metadata.display_name, locale)
const desc = pickLocalized(item.metadata.description, locale)
return (
item.metadata.id.toLowerCase().includes(q) ||
name.toLowerCase().includes(q) ||
desc.toLowerCase().includes(q)
)
})
}, [sortedExperts, searchQuery, locale])
const groupedExperts = useMemo(() => {
const groups = new Map<string, ExpertListItem[]>()
for (const item of filteredExperts) {
const key = item.metadata.category
const list = groups.get(key) ?? []
list.push(item)
groups.set(key, list)
}
return Array.from(groups.entries()).sort(
(a, b) => (CATEGORY_SORT[a[0]] ?? 99) - (CATEGORY_SORT[b[0]] ?? 99)
)
}, [filteredExperts])
const selectedExpert = useMemo(
() => experts.find((e) => e.metadata.id === selectedExpertId) ?? null,
[experts, selectedExpertId]
)
// Auto-select first expert once loaded.
useEffect(() => {
if (!selectedExpertId && sortedExperts.length > 0) {
setSelectedExpertId(sortedExperts[0].metadata.id)
}
}, [selectedExpertId, sortedExperts])
// Load content + status for the currently selected expert.
useEffect(() => {
if (!selectedExpert) {
setContent("")
setStatuses({})
return
}
const expertId = selectedExpert.metadata.id
let cancelled = false
setContentLoading(true)
setStatusLoading(true)
Promise.all([
expertsReadContent(expertId),
expertsGetInstallStatus(expertId),
])
.then(([body, statusList]) => {
if (cancelled) return
setContent(body)
const map: Record<string, ExpertInstallStatus> = {}
for (const entry of statusList) {
map[entry.agentType] = entry
}
setStatuses(map)
})
.catch((err) => {
if (cancelled) return
const message = err instanceof Error ? err.message : String(err)
toast.error(t("toasts.loadFailed"), { description: message })
})
.finally(() => {
if (!cancelled) {
setContentLoading(false)
setStatusLoading(false)
}
})
return () => {
cancelled = true
}
}, [selectedExpert, t])
const handleToggle = useCallback(
async (expertId: string, agentType: AgentType, enable: boolean) => {
const key = `${expertId}:${agentType}`
setPendingMutation(key)
try {
if (enable) {
const next = await expertsLinkToAgent({ expertId, agentType })
setStatuses((prev) => ({ ...prev, [agentType]: next }))
toast.success(t("toasts.enabled"))
} else {
await expertsUnlinkFromAgent({ expertId, agentType })
// Re-fetch status to get the accurate post-unlink state.
const latest = await expertsGetInstallStatus(expertId)
const map: Record<string, ExpertInstallStatus> = {}
for (const entry of latest) {
map[entry.agentType] = entry
}
setStatuses(map)
toast.success(t("toasts.disabled"))
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast.error(
enable ? t("toasts.enableFailed") : t("toasts.disableFailed"),
{
description: message,
}
)
} finally {
setPendingMutation(null)
}
},
[t]
)
const handleOpenCentralDir = useCallback(async () => {
try {
const path = await expertsOpenCentralDir()
await openFolderWindow(path)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast.error(t("toasts.openFolderFailed"), { description: message })
}
}, [t])
const safeContainerWidth =
panelContainerWidth > 0 ? panelContainerWidth : 1200
const leftMinSize = clamp(
toPercent(LEFT_MIN_WIDTH, safeContainerWidth),
5,
95
)
const rightMinSize = clamp(
toPercent(RIGHT_MIN_WIDTH, safeContainerWidth),
5,
95
)
const leftMaxSize = Math.max(leftMinSize, 100 - rightMinSize)
if (loading) {
return (
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t("loading")}
</div>
)
}
const selectedName = selectedExpert
? pickLocalized(selectedExpert.metadata.display_name, locale) ||
selectedExpert.metadata.id
: ""
const selectedDescription = selectedExpert
? pickLocalized(selectedExpert.metadata.description, locale)
: ""
const selectedIcon = getIcon(selectedExpert?.metadata.icon ?? null)
const SelectedIcon = selectedIcon
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between gap-3 pb-4">
<div>
<h2 className="text-base font-semibold">{t("title")}</h2>
<p className="text-xs text-muted-foreground mt-1">
{t("description")}
</p>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
handleOpenCentralDir().catch((err) => {
console.error("[ExpertsSettings] open central dir failed:", err)
})
}}
>
<FolderOpen className="h-3.5 w-3.5" />
{t("actions.openCentralDir")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
refresh().catch((err) => {
console.error("[ExpertsSettings] refresh failed:", err)
})
}}
>
<RefreshCw className="h-3.5 w-3.5" />
{t("actions.refresh")}
</Button>
</div>
</div>
{loadError && (
<div className="mb-3 rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
{loadError}
</div>
)}
{experts.length === 0 ? (
<div className="h-full rounded-lg border bg-card flex items-center justify-center text-sm text-muted-foreground">
{t("emptyExperts")}
</div>
) : (
<div ref={panelContainerRef} className="flex-1 min-h-0 min-w-0">
<ResizablePanelGroup
direction="horizontal"
className="h-full min-h-0 min-w-0"
>
<ResizablePanel
defaultSize={38}
minSize={leftMinSize}
maxSize={leftMaxSize}
>
<div className="min-h-0 h-full min-w-0 rounded-lg border bg-card flex flex-col overflow-hidden lg:rounded-r-none">
<div className="border-b p-3 space-y-2.5">
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={t("searchPlaceholder")}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-3">
{groupedExperts.map(([category, items]) => (
<div key={category} className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-wide font-semibold text-muted-foreground">
{translatedCategory(category)}
</div>
{items.map((item) => {
const Icon = getIcon(item.metadata.icon)
const name =
pickLocalized(item.metadata.display_name, locale) ||
item.metadata.id
const desc = pickLocalized(
item.metadata.description,
locale
)
const isActive = selectedExpertId === item.metadata.id
return (
<button
key={item.metadata.id}
type="button"
onClick={() =>
setSelectedExpertId(item.metadata.id)
}
className={cn(
"w-full rounded-md border px-2.5 py-2 text-left transition-colors",
isActive
? "border-primary/60 bg-primary/5"
: "hover:bg-muted/30"
)}
>
<div className="flex items-start gap-2 min-w-0">
<Icon className="h-4 w-4 mt-0.5 shrink-0 text-primary/80" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{name}
</div>
<div className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
{desc}
</div>
</div>
{item.user_modified && (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] shrink-0 border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400"
>
{t("badges.userModified")}
</Badge>
)}
</div>
</button>
)
})}
</div>
))}
{groupedExperts.length === 0 && (
<div className="text-xs text-muted-foreground px-2 py-3">
{t("emptySearch")}
</div>
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={62} minSize={rightMinSize}>
<div className="h-full flex-1 min-h-0 min-w-0 rounded-lg border bg-card overflow-hidden lg:rounded-l-none lg:border-l-0">
{selectedExpert ? (
<div className="h-full flex flex-col">
<div className="border-b px-4 py-3 flex items-start gap-3">
<SelectedIcon className="h-5 w-5 mt-0.5 shrink-0 text-primary/80" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold truncate">
{selectedName}
</h3>
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px]"
>
{translatedCategory(
selectedExpert.metadata.category
)}
</Badge>
<code className="text-[11px] text-muted-foreground font-mono truncate">
{selectedExpert.metadata.id}
</code>
</div>
<p className="text-xs text-muted-foreground mt-1">
{selectedDescription}
</p>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
<div className="rounded-md border p-3">
<div className="text-[11px] text-muted-foreground mb-2 flex items-center justify-between">
<span>{t("enableForAgents")}</span>
{statusLoading && (
<Loader2 className="h-3 w-3 animate-spin" />
)}
</div>
<div className="space-y-1.5">
{agents.length === 0 ? (
<div className="text-xs text-muted-foreground py-2">
{t("noAgents")}
</div>
) : (
agents.map((agent) => {
const status = statuses[agent.agent_type] ?? null
const enabled =
status?.state === "linked_to_codeg"
const blocked =
status?.state === "blocked_by_real_directory" ||
status?.state === "linked_elsewhere"
const key = `${selectedExpert.metadata.id}:${agent.agent_type}`
const pending = pendingMutation === key
return (
<div
key={agent.agent_type}
className={cn(
"flex items-center gap-3 rounded-md border px-3 py-2",
enabled
? "border-primary/40 bg-primary/5"
: "border-border"
)}
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{agent.name}
</div>
<div className="text-[11px] text-muted-foreground truncate">
{status
? translatedState(status.state)
: "—"}
</div>
{status?.copyMode && (
<div className="text-[11px] text-amber-500 mt-0.5">
{t("copyModeWarning")}
</div>
)}
</div>
<Switch
checked={enabled}
disabled={pending || (blocked && !enabled)}
onCheckedChange={(checked: boolean) => {
handleToggle(
selectedExpert.metadata.id,
agent.agent_type,
checked
).catch((err) => {
console.error(
"[ExpertsSettings] toggle failed:",
err
)
})
}}
/>
</div>
)
})
)}
</div>
</div>
<div className="rounded-md border p-3">
<div className="text-[11px] text-muted-foreground mb-2">
{t("previewTitle")}
</div>
{contentLoading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground py-3">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("loadingContent")}
</div>
) : (
<div
className={cn(
"text-sm leading-6 rounded-md bg-muted/10 p-3 overflow-auto max-h-[480px]",
"[&_h1]:text-xl [&_h1]:font-semibold [&_h1]:mb-3",
"[&_h2]:text-lg [&_h2]:font-semibold [&_h2]:mt-5 [&_h2]:mb-2",
"[&_h3]:text-base [&_h3]:font-semibold [&_h3]:mt-4 [&_h3]:mb-2",
"[&_p]:mb-3 [&_li]:mb-1",
"[&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5",
"[&_code]:font-mono [&_code]:text-xs [&_code]:bg-muted [&_code]:rounded [&_code]:px-1",
"[&_pre]:bg-muted [&_pre]:rounded-md [&_pre]:p-3 [&_pre]:overflow-x-auto"
)}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{stripFrontmatter(content)}
</ReactMarkdown>
</div>
)}
</div>
</div>
</div>
) : (
<div className="h-full flex items-center justify-center text-xs text-muted-foreground">
{t("emptySelection")}
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
)}
</div>
)
}

View File

@@ -19,6 +19,7 @@ import {
PlugZap,
Server,
Settings,
Sparkles,
} from "lucide-react"
import { useTranslations } from "next-intl"
import { usePathname } from "next/navigation"
@@ -39,6 +40,7 @@ interface SettingsNavItem {
| "model_providers"
| "mcp"
| "skills"
| "experts"
| "shortcuts"
| "version_control"
| "chat_channels"
@@ -63,6 +65,11 @@ const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
labelKey: "skills",
icon: BookOpenText,
},
{
href: "/settings/experts",
labelKey: "experts",
icon: Sparkles,
},
{
href: "/settings/agents",
labelKey: "agents",