diff --git a/README.md b/README.md index 21b2833cd..bf6cfa201 100644 --- a/README.md +++ b/README.md @@ -540,7 +540,7 @@ For stdio MCP servers, per-tool call timeouts can be configured with `tool_call_ MCP tool names are prefixed with `_` by default to prevent collisions across servers. If a server already namespaces its own tools, set `tool_name_prefix: false` on that server in `extensions_config.json` to keep the original names. Disable the prefix only when the resulting names remain unique across all enabled servers. Signed-in users' notification toggle, default model, conversation mode, and reasoning effort are saved to their account and restored on other browsers or after clearing browser storage. Browser notification permission still needs to be granted on each device. Changes retry after network failures; unsent changes survive a reload in the same tab. Concurrent edits to different fields are preserved; for the same field, the last server write wins. Existing unscoped browser preferences are not uploaded automatically because they have no account owner; reselect those settings once after upgrading. Static demos and auth-disabled development keep browser-local settings. Thread-specific model overrides and other display preferences remain local. -Settings > Tools adds, replaces, and deletes one MCP server at a time through targeted mutations that preserve concurrent sibling changes; deletes use a bodyless URL-addressed request. An invalid stdio command on one server no longer blocks toggling another, while enabling that invalid server remains protected by the command allowlist and surfaces the backend validation message in the UI. +Capability Center > Plugins adds, replaces, and deletes one MCP server at a time through targeted mutations that preserve concurrent sibling changes; deletes use a bodyless URL-addressed request. An invalid stdio command on one server no longer blocks toggling another, while enabling that invalid server remains protected by the command allowlist and surfaces the backend validation message in the UI. Targeted updates accept both DeerFlow's `type` field and the MCP-spec `transport` field for SSE/HTTP servers. Runtime MCP and skill updates replace `extensions_config.json` atomically, so an interrupted write cannot leave the shared configuration truncated or partially written. MCP routing hints can also prefer a specific MCP tool for matching requests without forbidding other tools. When `tool_search` defers MCP schemas, matching routing metadata can auto-promote up to `tool_search.auto_promote_top_k` deferred schemas before the model call. @@ -942,6 +942,13 @@ Use it as-is. Or tear it apart and make it yours. ### Skills & Tools +Open **Capability Center** from the workspace sidebar to manage **Plugins** +(MCP servers and Lark/Feishu integration) and **Skills**. Both catalogs support +search; skill cards show concise descriptions with full descriptions in a detail +view. Built-in and user-created/imported skills are listed separately. The +Community tab supports importing `.skill` archives into My skills. +General preferences remain in Settings. + Skills are what make DeerFlow do *almost anything*. A standard Agent Skill is a structured capability module — a Markdown file that defines a workflow, best practices, and references to supporting resources. DeerFlow ships with built-in skills for research, report generation, slide creation, web pages, image and video generation, and more. But the real power is extensibility: add your own skills, replace the built-in ones, or combine them into compound workflows. @@ -964,7 +971,7 @@ For `LocalSandboxProvider`, this is a managed tool-path boundary rather than hos Managed integrations install shared read-only skill packs without mixing them into custom skills. The Lark/Feishu CLI integration is available under -`Settings → Integrations → Lark / Feishu CLI`; an administrator installs or +`Capability Center → Plugins → Lark / Feishu`; an administrator installs or upgrades the official `lark-*` pack once under `{DEER_FLOW_HOME}/integrations/skills/lark-cli`, and every user discovers that same pack with an independent enabled state. Each user's app configuration and @@ -982,7 +989,7 @@ inspects the local credential tree, so the UI reports **Credentials configured verification. The action then remains **Reconnect Lark** so users can replace or extend authorization. If an agent hits missing Lark authorization during a conversation, the managed `lark-shared` guidance points the user back to the -same settings entry with `?settings=integrations`. +same plugin configuration with `/workspace/capabilities?tab=plugins&plugin=lark`. Once configured, **Change Lark app** lets a user point their DeerFlow account at a different Lark/Feishu app without a reinstall — either by pasting an existing @@ -1230,7 +1237,7 @@ API keys from the Gateway process. #### Exporting Custom Skills -Administrators can export their own custom skills from **Settings → Skills → Custom → Export**. Review the file list and declared environment requirements, then choose **Download .skill**. The archive contains the currently saved skill, including supporting files and empty directories; disabled skills can also be exported. If the skill changes after preview, refresh the file list before downloading. Import the archive on another DeerFlow instance with **Install .skill**; existing-name conflicts and normal installation security checks still apply. +Administrators can export their own custom skills from **Capability Center → Skills → My skills → View details → Export**. Review the file list and declared environment requirements, then choose **Download .skill**. The archive contains the currently saved skill, including supporting files and empty directories; disabled skills can also be exported. If the skill changes after preview, refresh the file list before downloading. Import the archive on another DeerFlow instance with **Install .skill**; existing-name conflicts and normal installation security checks still apply. Account settings, conversations and history outside the skill folder are excluded. Files inside the folder are preserved unchanged, including any credentials an author placed there; filename notices are advisory. Configure dependencies and credentials on the destination. Linked folders/files, hard links, unsupported executable binaries, nested `SKILL.md` files and nonportable paths cannot be exported. Export supports hosts with descriptor-relative no-follow filesystem APIs (Linux/macOS); unsupported hosts fail explicitly. Limits: 4096 ZIP entries, 64 MiB per file, 100 MiB total content/archive and 1 MiB frontmatter. YAML aliases and excessively complex declarations are not supported. Ordinary script executable semantics are preserved on POSIX import, without restoring special permissions. See [the export API contract](backend/docs/API.md#export-a-custom-skill). diff --git a/backend/packages/harness/deerflow/AGENTS.md b/backend/packages/harness/deerflow/AGENTS.md index 99e47940e..c8daf35de 100644 --- a/backend/packages/harness/deerflow/AGENTS.md +++ b/backend/packages/harness/deerflow/AGENTS.md @@ -29,6 +29,10 @@ Tests: the `tests/test_trace_*` and `tests/test_worker_trace_binding.py` suites, ### Managed Lark CLI credentials (`integrations/lark_cli.py`) +Installed `lark-shared` guidance points to Capability Center > Plugins > Lark +(`/workspace/capabilities?tab=plugins&plugin=lark`). Guidance changes bump the +version marker; reinstalling the managed skill pack refreshes the stored text. + App registration and direct app switching replace the per-user Lark credential tree transactionally. Clear the old OAuth data before running `lark-cli config init`: on Linux that command writes the new app secret into the file-backed diff --git a/backend/packages/harness/deerflow/integrations/lark_cli.py b/backend/packages/harness/deerflow/integrations/lark_cli.py index 7ad6180aa..e09292233 100644 --- a/backend/packages/harness/deerflow/integrations/lark_cli.py +++ b/backend/packages/harness/deerflow/integrations/lark_cli.py @@ -139,8 +139,8 @@ script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) exec "$script_dir/../linux-$arch/lark-cli" "$@" """ _VERSION_TAG_RE = re.compile(r"v?\d+\.\d+\.\d+") -_DEERFLOW_LARK_SHARED_GUIDANCE_MARKER = "" -_DEERFLOW_LARK_SHARED_GUIDANCE_LEGACY_MARKERS = ("",) +_DEERFLOW_LARK_SHARED_GUIDANCE_MARKER = "" +_DEERFLOW_LARK_SHARED_GUIDANCE_LEGACY_MARKERS = ("", "") _LARK_APP_REGISTRATION_PATH = "/oauth/v1/app/registration" LARK_SKILL_NAMES: tuple[str, ...] = ( @@ -2824,8 +2824,8 @@ def _append_deerflow_lark_shared_guidance(root: Path) -> None: 在 DeerFlow 中,如果 `lark-cli auth status` 或业务命令提示未配置、未登录、token 过期或缺少用户授权: 1. 不要要求用户在终端执行 `lark-cli config init`、`lark-cli auth login` 或 `lark-cli auth login --device-code`。 -2. 回复用户这个可点击链接:[打开飞书授权设置](?settings=integrations)。 -3. 告诉用户在 **Settings → Integrations → Lark / Feishu CLI** 点击“连接飞书”,在浏览器里完成授权后再回来继续当前任务。 +2. 回复用户这个可点击链接:[打开飞书授权设置](/workspace/capabilities?tab=plugins&plugin=lark)。 +3. 告诉用户在 **Capability Center → Plugins → Lark / Feishu** 点击“连接飞书”,在浏览器里完成授权后再回来继续当前任务。 4. 如果错误中包含缺失的 `scope`、`permission_violations` 或建议的 `--domain`,告诉用户在该设置页选择对应权限域(例如日历选择 Calendar),或把具体 scope 填入“Exact OAuth scope / 具体 OAuth scope”后重新授权。 只有在用户明确说明已经完成授权后,才继续调用具体的 `lark-cli` 业务命令。 diff --git a/backend/tests/test_lark_cli_integration.py b/backend/tests/test_lark_cli_integration.py index abb5952d8..4cf1aa2ff 100644 --- a/backend/tests/test_lark_cli_integration.py +++ b/backend/tests/test_lark_cli_integration.py @@ -474,7 +474,8 @@ def test_install_lark_integration_installs_one_readonly_pack_for_all_users(monke assert (root / "lark-doc" / "SKILL.md").is_file() assert (root / lark_cli.LARK_CLI_MANIFEST_FILE).is_file() shared_content = (root / "lark-shared" / "SKILL.md").read_text(encoding="utf-8") - assert "?settings=integrations" in shared_content + assert "/workspace/capabilities?tab=plugins&plugin=lark" in shared_content + assert "?settings=integrations" not in shared_content assert "不要要求用户在终端执行" in shared_content assert "Exact OAuth scope" in shared_content diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index ae5909731..3850fd9fe 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -181,3 +181,12 @@ block dismissal while deletion is pending. Show the error message when available with a localized fallback, and log the rejection for debugging. The shared delete helper accepts remote 404 (not 403) before retrying local cleanup, and `onDeleted` runs only after both deletion steps succeed. + +## Capability Center + +`/workspace/capabilities` owns Plugins and Skills navigation. Plugins composes the +MCP manager and a lazily loaded Lark configuration dialog; installation, OAuth, +mutation permissions, and cache ownership remain in the existing hooks. Skill display names/summaries are presentation +metadata; runtime names and full descriptions remain unchanged. Public, custom, +integration, and legacy sources must stay distinct. Community currently offers +archive import, not a remote marketplace. Screenshot E2E fixtures are demo data. diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index c1b1a75bf..2fd0a5067 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -32,7 +32,7 @@ pages; `resolveThreadContext` must neither enqueue account writes nor create a fallback thread override that masks a later server preference. The - Settings > Tools MCP switch calls the targeted `PATCH /api/mcp/config` + Capability Center > Plugins MCP switch calls the targeted `PATCH /api/mcp/config` mutation, disables switches until that mutation's success refetch completes, displays the backend error `detail` through a toast, and invalidates `["mcpConfig"]` only after success. @@ -71,7 +71,7 @@ fixed page size and an explicit load-more control; full results remain available only through JSONL export. The panel must not infer batch mode from prompt text or inject the complete result set into chat state. - Settings > Integrations uses a local generation only to suppress stale React + Capability Center > Plugins > Lark uses a local generation only to suppress stale React callbacks; server-issued Lark flow generations must be passed through every config/auth completion and across switch-or-register to authorization chains so backend cross-tab ordering remains authoritative. diff --git a/frontend/src/app/workspace/capabilities/page.tsx b/frontend/src/app/workspace/capabilities/page.tsx new file mode 100644 index 000000000..b3a14f527 --- /dev/null +++ b/frontend/src/app/workspace/capabilities/page.tsx @@ -0,0 +1,11 @@ +import { Suspense } from "react"; + +import { CapabilityCenter } from "@/components/workspace/capabilities/capability-center"; + +export default function CapabilitiesPage() { + return ( + + + + ); +} diff --git a/frontend/src/components/workspace/capabilities/capability-card.tsx b/frontend/src/components/workspace/capabilities/capability-card.tsx new file mode 100644 index 000000000..7da0a4ec1 --- /dev/null +++ b/frontend/src/components/workspace/capabilities/capability-card.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { + ArrowUpRightIcon, + PuzzleIcon, + SparklesIcon, + type LucideIcon, +} from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +const tones = [ + "bg-sky-50 text-sky-600 dark:bg-sky-950 dark:text-sky-300", + "bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300", + "bg-violet-50 text-violet-600 dark:bg-violet-950 dark:text-violet-300", + "bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300", +]; + +export function CapabilityIcon({ + name, + skill = false, + icon: CustomIcon, +}: { + name: string; + skill?: boolean; + icon?: LucideIcon; +}) { + const tone = + [...name].reduce((value, char) => value + char.charCodeAt(0), 0) % + tones.length; + const Icon = CustomIcon ?? (skill ? SparklesIcon : PuzzleIcon); + return ( +
+ +
+ ); +} + +export function CapabilityCard({ + name, + description, + label, + icon, + status, + children, + onDetails, + detailsLabel, +}: { + name: string; + description: string; + label: string; + icon: ReactNode; + status?: ReactNode; + children: ReactNode; + onDetails?: () => void; + detailsLabel?: string; +}) { + return ( +
+
+ {icon} + + {label} + +
+

+ {onDetails ? ( + + ) : ( + {name} + )} +

+

+ {description} +

+
+
+ {status} +
+
{children}
+
+
+ ); +} diff --git a/frontend/src/components/workspace/capabilities/capability-center.tsx b/frontend/src/components/workspace/capabilities/capability-center.tsx new file mode 100644 index 000000000..62bb11d30 --- /dev/null +++ b/frontend/src/components/workspace/capabilities/capability-center.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { BlocksIcon, SearchIcon, SparklesIcon } from "lucide-react"; +import dynamic from "next/dynamic"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useState } from "react"; + +import { Input } from "@/components/ui/input"; +import { SidebarTrigger } from "@/components/ui/sidebar"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useI18n } from "@/core/i18n/hooks"; + +const PluginGallery = dynamic(() => + import("./plugin-gallery").then((module) => module.PluginGallery), +); +const SkillGallery = dynamic(() => + import("./skill-gallery").then((module) => module.SkillGallery), +); + +export function CapabilityCenter() { + const { t } = useI18n(); + const params = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + const tab = params.get("tab") === "skills" ? "skills" : "plugins"; + const [query, setQuery] = useState(""); + function changeTab(value: string) { + setQuery(""); + router.replace(`${pathname}?tab=${value}`, { scroll: false }); + } + return ( +
+
+ + {t.breadcrumb.workspace} + / + {t.capabilities.title} +
+
+
+
+
+

+ {t.capabilities.title} +

+

+ {t.capabilities.description} +

+
+
+ + setQuery(event.target.value)} + /> +
+
+ + + + + {t.capabilities.plugins} + + + + {t.capabilities.skills} + + + + {tab === "plugins" ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/workspace/settings/integrations-settings-page.tsx b/frontend/src/components/workspace/capabilities/lark-plugin-settings.tsx similarity index 99% rename from frontend/src/components/workspace/settings/integrations-settings-page.tsx rename to frontend/src/components/workspace/capabilities/lark-plugin-settings.tsx index c4e2e2383..2241d90c5 100644 --- a/frontend/src/components/workspace/settings/integrations-settings-page.tsx +++ b/frontend/src/components/workspace/capabilities/lark-plugin-settings.tsx @@ -45,7 +45,7 @@ import { import { env } from "@/env"; import { cn } from "@/lib/utils"; -import { SettingsSection } from "./settings-section"; +import { SettingsSection } from "../settings/settings-section"; type PendingLarkFlow = | ({ kind: "config" } & LarkConfigStartResponse) @@ -114,7 +114,7 @@ function uniqueScopes(scopes: string[]) { return Array.from(new Set(scopes)); } -export function IntegrationsSettingsPage() { +export function LarkPluginSettings() { const { t } = useI18n(); return ( - {isLoading ? ( -
{t.common.loading}
- ) : adminRequired ? ( -
- {t.settings.tools.adminRequired} + if (isLoading || error) { + return ( +
+ {props.toolbar} + {isLoading ? ( +

+ {t.common.loading} +

+ ) : ( +

+ {error instanceof MCPConfigRequestError && error.isAdminRequired + ? t.settings.tools.adminRequired + : `${t.common.error} ${error?.message}`} +

+ )} +
+ {props.children}
- ) : error ? ( -
- {t.common.error} {error.message} -
- ) : ( - config && - )} - - ); +
+ ); + } + return ; } function MCPServerList({ servers, -}: { + query = "", + children, + toolbar, +}: MCPPluginManagerProps & { servers?: Record; }) { const { t } = useI18n(); @@ -82,7 +84,11 @@ function MCPServerList({ const readOnly = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"; const current = servers ?? {}; - const entries = Object.entries(current); + const entries = Object.entries(current).filter(([name, config]) => + `${name} ${config.description ?? ""}` + .toLowerCase() + .includes(query.trim().toLowerCase()), + ); const isMutating = isPending || isWriting; function displayServerName(name: string | null) { @@ -189,39 +195,32 @@ function MCPServerList({ return (
-
+
+ {toolbar ?? }
- {entries.length === 0 ? ( + {entries.length === 0 && !children ? (
- {t.settings.tools.empty} + {query ? t.capabilities.noResults : t.settings.tools.empty}
) : ( - entries.map(([name, config]) => { - const displayName = displayServerName(name); - return ( - - - -
-
{displayName}
-
-
- - {config.description} - -
- +
+ {children} + {entries.map(([name, config]) => { + const displayName = displayServerName(name); + const actions = ( + <> enableMCPServer({ serverName: name, enabled: checked }) @@ -245,10 +244,43 @@ function MCPServerList({ > - - - ); - }) + + ); + return ( + } + status={ + <> + + {config.enabled + ? t.capabilities.enabled + : t.capabilities.disabled} + + } + onDetails={ + readOnly || isMutating + ? undefined + : () => openEditEditor(name, config) + } + detailsLabel={`${t.capabilities.details} ${displayName}`} + > + {actions} + + ); + })} +
)} + import("./lark-plugin-settings").then((module) => module.LarkPluginSettings), +); + +export function PluginGallery({ query }: { query: string }) { + const { t } = useI18n(); + const lark = useLarkIntegrationStatus(); + const [filter, setFilter] = useState("all"); + const [localOpen, setLocalOpen] = useState(false); + const params = useSearchParams(); + const pathname = usePathname(); + const router = useRouter(); + const open = localOpen || params.get("plugin") === "lark"; + function setOpen(value: boolean) { + setLocalOpen(value); + if (!value && params.has("plugin")) { + const next = new URLSearchParams(params); + next.delete("plugin"); + router.replace(`${pathname}?${next.toString()}`, { scroll: false }); + } + } + const showLark = + (filter === "all" || lark.data?.installed) && + `${t.capabilities.larkName} ${t.capabilities.larkDescription} feishu lark cli` + .toLowerCase() + .includes(query.trim().toLowerCase()); + const connected = + lark.data?.auth.status === "authenticated" && lark.data.auth.verified; + const larkCard = showLark ? ( + } + status={ + lark.isLoading ? ( + t.common.loading + ) : lark.error ? ( + t.common.error + ) : ( + <> + {lark.data?.installed && } + {lark.data?.installed + ? t.capabilities.installed + : t.capabilities.notInstalled} + + ) + } + onDetails={() => setOpen(true)} + detailsLabel={`${t.capabilities.configure} ${t.capabilities.larkName}`} + > + + + ) : null; + const toolbar = ( + + + + {t.capabilities.allPlugins} + + + {t.capabilities.installed} + + + + ); + return ( +
+
+

+ {t.capabilities.availablePlugins} +

+

+ {t.capabilities.pluginHint} +

+
+ + {larkCard} + + + + + {t.capabilities.pluginSettings} + + + + +
+ ); +} diff --git a/frontend/src/components/workspace/settings/skill-export-dialog.tsx b/frontend/src/components/workspace/capabilities/skill-export-dialog.tsx similarity index 100% rename from frontend/src/components/workspace/settings/skill-export-dialog.tsx rename to frontend/src/components/workspace/capabilities/skill-export-dialog.tsx diff --git a/frontend/src/components/workspace/capabilities/skill-gallery.tsx b/frontend/src/components/workspace/capabilities/skill-gallery.tsx new file mode 100644 index 000000000..ecbbc61ae --- /dev/null +++ b/frontend/src/components/workspace/capabilities/skill-gallery.tsx @@ -0,0 +1,397 @@ +"use client"; + +import { + DownloadIcon, + LoaderIcon, + SparklesIcon, + UploadIcon, +} from "lucide-react"; +import dynamic from "next/dynamic"; +import { useRouter } from "next/navigation"; +import { type ChangeEvent, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { Switch } from "@/components/ui/switch"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useAuth } from "@/core/auth/AuthProvider"; +import { useI18n } from "@/core/i18n/hooks"; +import { + formatSkillSecurityFindings, + MAX_SKILL_ARCHIVE_UPLOAD_BYTES, + SkillRequestError, +} from "@/core/skills/api"; +import { + useEnableSkill, + useSkills, + useUploadSkillArchive, +} from "@/core/skills/hooks"; +import type { Skill } from "@/core/skills/type"; +import { env } from "@/env"; + +import { CapabilityCard, CapabilityIcon } from "./capability-card"; +import { presentSkill } from "./skill-presentation"; + +const SkillExportDialog = dynamic(() => import("./skill-export-dialog"), { + ssr: false, +}); + +export function SkillGallery({ query = "" }: { query?: string } = {}) { + const { t } = useI18n(); + const { skills, isLoading, error } = useSkills(); + const adminRequired = + error instanceof SkillRequestError && error.isAdminRequired; + return ( +
+ {isLoading ? ( +
{t.common.loading}
+ ) : adminRequired ? ( +
+ {t.settings.skills.adminRequired} +
+ ) : error ? ( +
+ {t.common.error} {error.message} +
+ ) : ( + + )} +
+ ); +} + +function SkillList({ skills, query }: { skills: Skill[]; query: string }) { + const { t, locale } = useI18n(); + const router = useRouter(); + function sourceLabel(skill: Skill) { + if (skill.category === "public") return t.capabilities.builtin; + if (skill.category === "custom") return t.capabilities.custom; + if (skill.category === "integrations") + return t.capabilities.integrationSkills; + return t.capabilities.sharedSkills; + } + + const { user } = useAuth(); + const isAdmin = user?.system_role === "admin"; + const [exportName, setExportName] = useState(null); + const [filter, setFilter] = useState("public"); + const { mutate: enableSkill, isPending: isEnabling } = useEnableSkill(); + const [selectedSkill, setSelectedSkill] = useState(null); + const fileInputRef = useRef(null); + const { mutateAsync: uploadSkillArchive, isPending: isUploading } = + useUploadSkillArchive(); + const isArchiveUploadDisabled = + isUploading || !isAdmin || env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"; + const filteredSkills = useMemo( + () => + skills.filter((skill) => { + const presented = presentSkill(skill, locale); + return ( + (filter === "all" || skill.category === filter) && + `${skill.name} ${skill.description} ${presented.title} ${presented.description}` + .toLowerCase() + .includes(query.trim().toLowerCase()) + ); + }), + [skills, filter, query, locale], + ); + const handleCreateSkill = () => { + router.push("/workspace/chats/new?mode=skill"); + }; + const handleSkillArchive = async (event: ChangeEvent) => { + if (isUploading) { + event.target.value = ""; + return; + } + const archive = event.target.files?.[0]; + event.target.value = ""; + if (!archive) return; + if (!archive.name.toLowerCase().endsWith(".skill")) { + toast.error(t.settings.skills.invalidArchive); + return; + } + if (archive.size > MAX_SKILL_ARCHIVE_UPLOAD_BYTES) { + toast.error(t.settings.skills.archiveTooLarge); + return; + } + + try { + const result = await uploadSkillArchive(archive); + if (result.success) { + toast.success(result.message); + setFilter("custom"); + } else { + toast.error(result.message || t.settings.skills.installFailed); + } + } catch (error) { + if (error instanceof SkillRequestError && error.isAdminRequired) { + toast.error(t.settings.skills.installAdminRequired); + } else if (error instanceof SkillRequestError && error.status === 413) { + toast.error(t.settings.skills.archiveTooLarge); + } else if ( + error instanceof SkillRequestError && + error.findings.length > 0 + ) { + toast.error(error.message, { + description: ( + + {formatSkillSecurityFindings(error.findings)} + + ), + }); + } else { + toast.error( + error instanceof Error + ? error.message + : t.settings.skills.installFailed, + ); + } + } + }; + return ( +
+ {exportName && + isAdmin && + env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && ( + setExportName(null)} + /> + )} +
+

+ {t.capabilities.availableSkills} +

+

+ {t.capabilities.skillHint} +

+
+
+ + + + {t.capabilities.builtin} + + + {t.capabilities.community} + + + {t.capabilities.custom} + + + {t.capabilities.allSkills} + + + +
+ + {isAdmin && ( + + )} + +
+
+ {query.trim() && + (filter === "community" || filteredSkills.length === 0) ? ( +
+ {t.capabilities.noResults} +
+ ) : filter === "community" ? ( + + + + + + {t.capabilities.communityTitle} + + {t.capabilities.communityDescription} + + + {isAdmin && ( + + + + )} + + ) : filteredSkills.length === 0 ? ( + + ) : ( +
+ {filteredSkills.map((skill) => { + const presentation = presentSkill(skill, locale); + return ( + + } + status={ + <> + + {skill.enabled + ? t.capabilities.enabled + : t.capabilities.disabled} + + } + onDetails={() => setSelectedSkill(skill)} + detailsLabel={`${t.capabilities.details} ${presentation.title}`} + > + + + enableSkill( + { skillName: skill.name, enabled }, + { onError: (error) => toast.error(error.message) }, + ) + } + /> + + ); + })} +
+ )} + !open && setSelectedSkill(null)} + > + + {selectedSkill && ( + <> + + + + {presentSkill(selectedSkill, locale).title} + + + {selectedSkill.name} + + +
+ {selectedSkill.description} +
+
+ + {selectedSkill.license || sourceLabel(selectedSkill)} + +
+ {isAdmin && selectedSkill.category === "custom" && ( + + )} +
+
+ + )} +
+
+
+ ); +} + +function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) { + const { t } = useI18n(); + return ( + + + + + + {t.settings.skills.emptyTitle} + + {t.settings.skills.emptyDescription} + + + + + + + ); +} diff --git a/frontend/src/components/workspace/capabilities/skill-presentation.ts b/frontend/src/components/workspace/capabilities/skill-presentation.ts new file mode 100644 index 000000000..efc2f46a0 --- /dev/null +++ b/frontend/src/components/workspace/capabilities/skill-presentation.ts @@ -0,0 +1,88 @@ +import { + BookOpenIcon, + ChartNoAxesCombinedIcon, + CodeIcon, + FileSearchIcon, + ImageIcon, + MicIcon, + PresentationIcon, + SearchIcon, + ShieldCheckIcon, + SparklesIcon, + type LucideIcon, +} from "lucide-react"; + +import type { Skill } from "@/core/skills/type"; + +// Curated card summaries, not copies of the runtime skill descriptions. +// Keep keys aligned with skills/public; the catalog contract test checks this. +export const builtinSkillPresentation: Readonly< + Record +> = { + "deep-research": { + title: "深度研究", + description: "围绕复杂问题检索、交叉验证资料,整理成有据可查的研究报告。", + icon: SearchIcon, + }, + "data-analysis": { + title: "数据分析", + description: "分析表格与结构化数据,发现规律,并用图表清晰呈现结论。", + icon: ChartNoAxesCombinedIcon, + }, + "academic-paper-review": { + title: "学术论文审阅", + description: "梳理论文的方法、贡献与不足,生成结构化评审和改进建议。", + icon: BookOpenIcon, + }, + "ppt-generation": { + title: "演示文稿", + description: "将想法与资料组织成完整的演示文稿,让内容更清晰、更易表达。", + icon: PresentationIcon, + }, + "frontend-design": { + title: "前端设计", + description: "设计并实现网页与交互界面,兼顾视觉表达和实际使用体验。", + icon: CodeIcon, + }, + "image-generation": { + title: "图像创作", + description: "根据描述生成图片,把构思变成可直接查看和使用的视觉素材。", + icon: ImageIcon, + }, + "podcast-generation": { + title: "播客制作", + description: "将资料和主题整理成播客内容,完成从脚本到音频的创作。", + icon: MicIcon, + }, + "skill-creator": { + title: "技能创建", + description: "把你的工作方法整理成可复用技能,帮助 Agent 掌握新的任务。", + icon: SparklesIcon, + }, + "skill-reviewer": { + title: "技能审阅", + description: "检查技能包的质量与潜在问题,给出可执行的改进建议。", + icon: ShieldCheckIcon, + }, + "systematic-literature-review": { + title: "系统文献综述", + description: "系统检索和整理研究文献,梳理主题、证据与尚待解决的问题。", + icon: FileSearchIcon, + }, +}; + +export function presentSkill(skill: Skill, locale: string) { + const presentation = + skill.category === "public" && + Object.hasOwn(builtinSkillPresentation, skill.name) + ? builtinSkillPresentation[skill.name] + : undefined; + return { + title: locale === "zh-CN" && presentation ? presentation.title : skill.name, + description: + locale === "zh-CN" && presentation + ? presentation.description + : skill.description, + icon: presentation?.icon, + }; +} diff --git a/frontend/src/components/workspace/settings/settings-dialog.tsx b/frontend/src/components/workspace/settings/settings-dialog.tsx index f0512b1c4..2f454db96 100644 --- a/frontend/src/components/workspace/settings/settings-dialog.tsx +++ b/frontend/src/components/workspace/settings/settings-dialog.tsx @@ -6,11 +6,8 @@ import { InfoIcon, BrainIcon, PaletteIcon, - PlugZapIcon, - SparklesIcon, UsersRoundIcon, UserIcon, - WrenchIcon, } from "lucide-react"; import dynamic from "next/dynamic"; import { useEffect, useMemo, useState } from "react"; @@ -54,13 +51,6 @@ const ChannelsSettingsPage = dynamic( ), { loading: SettingsPageLoading }, ); -const IntegrationsSettingsPage = dynamic( - () => - import("./integrations-settings-page").then( - (module) => module.IntegrationsSettingsPage, - ), - { loading: SettingsPageLoading }, -); const MemorySettingsPage = dynamic( () => import("./memory-settings-page").then( @@ -75,16 +65,6 @@ const NotificationSettingsPage = dynamic( ), { loading: SettingsPageLoading }, ); -const SkillSettingsPage = dynamic( - () => - import("./skill-settings-page").then((module) => module.SkillSettingsPage), - { loading: SettingsPageLoading }, -); -const ToolSettingsPage = dynamic( - () => - import("./tool-settings-page").then((module) => module.ToolSettingsPage), - { loading: SettingsPageLoading }, -); const SubagentSettingsPage = dynamic( () => import("./subagent-settings-page").then( @@ -102,11 +82,8 @@ export type SettingsSection = | "account" | "appearance" | "channels" - | "integrations" | "memory" - | "tools" | "subagents" - | "skills" | "notification" | "about"; @@ -150,34 +127,24 @@ export function SettingsDialog(props: SettingsDialogProps) { label: t.settings.sections.channels, icon: CableIcon, }, - { - id: "integrations", - label: t.settings.sections.integrations, - icon: PlugZapIcon, - }, { id: "memory", label: t.settings.sections.memory, icon: BrainIcon, }, - { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon }, { id: "subagents", label: t.settings.sections.subagents, icon: UsersRoundIcon, }, - { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon }, { id: "about", label: t.settings.sections.about, icon: InfoIcon }, ], [ t.settings.sections.account, t.settings.sections.appearance, t.settings.sections.channels, - t.settings.sections.integrations, t.settings.sections.memory, - t.settings.sections.tools, t.settings.sections.subagents, - t.settings.sections.skills, t.settings.sections.notification, t.settings.sections.about, ], @@ -227,16 +194,9 @@ export function SettingsDialog(props: SettingsDialogProps) { {activeSection === "account" && } {activeSection === "appearance" && } {activeSection === "memory" && } - {activeSection === "tools" && } {activeSection === "subagents" && } - {activeSection === "skills" && ( - props.onOpenChange?.(false)} - /> - )} {activeSection === "notification" && } {activeSection === "channels" && } - {activeSection === "integrations" && } {activeSection === "about" && }
diff --git a/frontend/src/components/workspace/settings/skill-settings-page.tsx b/frontend/src/components/workspace/settings/skill-settings-page.tsx deleted file mode 100644 index 5a3a80c35..000000000 --- a/frontend/src/components/workspace/settings/skill-settings-page.tsx +++ /dev/null @@ -1,270 +0,0 @@ -"use client"; - -import { - DownloadIcon, - LoaderIcon, - SparklesIcon, - UploadIcon, -} from "lucide-react"; -import dynamic from "next/dynamic"; -import { useRouter } from "next/navigation"; -import { type ChangeEvent, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; - -import { Button } from "@/components/ui/button"; -import { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "@/components/ui/empty"; -import { - Item, - ItemActions, - ItemTitle, - ItemContent, - ItemDescription, -} from "@/components/ui/item"; -import { Switch } from "@/components/ui/switch"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useAuth } from "@/core/auth/AuthProvider"; -import { useI18n } from "@/core/i18n/hooks"; -import { - formatSkillSecurityFindings, - MAX_SKILL_ARCHIVE_UPLOAD_BYTES, - SkillRequestError, -} from "@/core/skills/api"; -import { - useEnableSkill, - useSkills, - useUploadSkillArchive, -} from "@/core/skills/hooks"; -import type { Skill } from "@/core/skills/type"; -import { env } from "@/env"; - -import { SettingsSection } from "./settings-section"; - -const SkillExportDialog = dynamic(() => import("./skill-export-dialog"), { - ssr: false, -}); - -export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) { - const { t } = useI18n(); - const { skills, isLoading, error } = useSkills(); - const adminRequired = - error instanceof SkillRequestError && error.isAdminRequired; - return ( - - {isLoading ? ( -
{t.common.loading}
- ) : adminRequired ? ( -
- {t.settings.skills.adminRequired} -
- ) : error ? ( -
- {t.common.error} {error.message} -
- ) : ( - - )} -
- ); -} - -function SkillSettingsList({ - skills, - onClose, -}: { - skills: Skill[]; - onClose?: () => void; -}) { - const { t } = useI18n(); - const router = useRouter(); - const { user } = useAuth(); - const isAdmin = user?.system_role === "admin"; - const [exportName, setExportName] = useState(null); - const [filter, setFilter] = useState("public"); - const { mutate: enableSkill } = useEnableSkill(); - const fileInputRef = useRef(null); - const { mutateAsync: uploadSkillArchive, isPending: isUploading } = - useUploadSkillArchive(); - const isArchiveUploadDisabled = - isUploading || !isAdmin || env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"; - const filteredSkills = useMemo( - () => skills.filter((skill) => skill.category === filter), - [skills, filter], - ); - const handleCreateSkill = () => { - onClose?.(); - router.push("/workspace/chats/new?mode=skill"); - }; - const handleSkillArchive = async (event: ChangeEvent) => { - if (isUploading) { - event.target.value = ""; - return; - } - const archive = event.target.files?.[0]; - event.target.value = ""; - if (!archive) return; - if (!archive.name.toLowerCase().endsWith(".skill")) { - toast.error(t.settings.skills.invalidArchive); - return; - } - if (archive.size > MAX_SKILL_ARCHIVE_UPLOAD_BYTES) { - toast.error(t.settings.skills.archiveTooLarge); - return; - } - - try { - const result = await uploadSkillArchive(archive); - if (result.success) { - toast.success(result.message); - setFilter("custom"); - } else { - toast.error(result.message || t.settings.skills.installFailed); - } - } catch (error) { - if (error instanceof SkillRequestError && error.isAdminRequired) { - toast.error(t.settings.skills.installAdminRequired); - } else if (error instanceof SkillRequestError && error.status === 413) { - toast.error(t.settings.skills.archiveTooLarge); - } else if ( - error instanceof SkillRequestError && - error.findings.length > 0 - ) { - toast.error(error.message, { - description: ( - - {formatSkillSecurityFindings(error.findings)} - - ), - }); - } else { - toast.error( - error instanceof Error - ? error.message - : t.settings.skills.installFailed, - ); - } - } - }; - return ( -
- {exportName && - isAdmin && - env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && ( - setExportName(null)} - /> - )} -
-
- - - {t.common.public} - {t.common.custom} - - -
-
- - {isAdmin && ( - - )} - -
-
- {filteredSkills.length === 0 && ( - - )} - {filteredSkills.length > 0 && - filteredSkills.map((skill) => ( - - - -
{skill.name}
-
- - {skill.description} - -
- - {isAdmin && skill.category === "custom" && ( - - )} - - enableSkill({ skillName: skill.name, enabled: checked }) - } - /> - -
- ))} -
- ); -} - -function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) { - const { t } = useI18n(); - return ( - - - - - - {t.settings.skills.emptyTitle} - - {t.settings.skills.emptyDescription} - - - - - - - ); -} diff --git a/frontend/src/components/workspace/workspace-nav-chat-list.tsx b/frontend/src/components/workspace/workspace-nav-chat-list.tsx index b7285d8d4..643e320f4 100644 --- a/frontend/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend/src/components/workspace/workspace-nav-chat-list.tsx @@ -1,6 +1,11 @@ "use client"; -import { BotIcon, CalendarClock, MessagesSquare } from "lucide-react"; +import { + BotIcon, + CalendarClock, + MessagesSquare, + BlocksIcon, +} from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; @@ -89,6 +94,20 @@ export function WorkspaceNavChatList() { + + + + + {t.capabilities.title} + + + ); diff --git a/frontend/src/components/workspace/workspace-settings-deep-link.tsx b/frontend/src/components/workspace/workspace-settings-deep-link.tsx index 3acfebb32..deda4936a 100644 --- a/frontend/src/components/workspace/workspace-settings-deep-link.tsx +++ b/frontend/src/components/workspace/workspace-settings-deep-link.tsx @@ -13,11 +13,8 @@ const SETTINGS_SECTIONS = new Set([ "account", "appearance", "channels", - "integrations", "memory", - "tools", "subagents", - "skills", "notification", "about", ]); diff --git a/frontend/src/content/en/tutorials/use-tools-and-skills.mdx b/frontend/src/content/en/tutorials/use-tools-and-skills.mdx index 2a12051f4..f9186f05e 100644 --- a/frontend/src/content/en/tutorials/use-tools-and-skills.mdx +++ b/frontend/src/content/en/tutorials/use-tools-and-skills.mdx @@ -30,12 +30,12 @@ tools: ## Enabling skills -Enable skills through the DeerFlow app's extensions panel, or edit `extensions_config.json` directly. +Enable skills through Capability Center → Skills, or edit `extensions_config.json` directly. **Via the app UI:** 1. Open the DeerFlow app -2. Click the Extensions/Skills icon in the sidebar +2. Open Capability Center in the sidebar, then select Skills 3. Find `deep-research` and toggle it on ## Using a skill for research diff --git a/frontend/src/content/zh/tutorials/use-tools-and-skills.mdx b/frontend/src/content/zh/tutorials/use-tools-and-skills.mdx index eedc95308..42670b9f8 100644 --- a/frontend/src/content/zh/tutorials/use-tools-and-skills.mdx +++ b/frontend/src/content/zh/tutorials/use-tools-and-skills.mdx @@ -28,12 +28,12 @@ tools: ## 启用技能 -通过 DeerFlow 应用界面的技能面板启用技能,或直接编辑 `extensions_config.json`。 +通过 DeerFlow 应用界面的「能力中心 → 技能」启用技能,或直接编辑 `extensions_config.json`。 示例:启用深度研究技能 1. 打开 DeerFlow 应用 -2. 点击侧边栏中的扩展/技能图标 +2. 打开侧边栏的「能力中心」,选择「技能」 3. 找到 `deep-research`,切换为启用 ## 使用技能进行研究 diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 28e25611b..56ce0338a 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -17,6 +17,49 @@ export const enUS: Translations = { localName: "English", }, + capabilities: { + integrationSkills: "From plugins", + sharedSkills: "Shared skills", + title: "Capability Center", + description: "Add tools and skills that help DeerFlow work your way.", + plugins: "Plugins", + skills: "Skills", + searchPlugins: "Search plugins by name or purpose", + searchSkills: "Search skills by name or purpose", + allPlugins: "All plugins", + installed: "Installed", + enabled: "Enabled", + disabled: "Disabled", + configure: "Configure", + details: "View details", + addPlugin: "Add MCP plugin", + builtin: "Built-in", + community: "Community", + custom: "My skills", + allSkills: "All skills", + availablePlugins: "Available plugins", + availableSkills: "Available skills", + pluginHint: + "Connect your everyday apps so your agent can access information and get work done.", + skillHint: + "Turn useful methods into skills, ready to use in any conversation.", + noResults: "No matches found", + larkName: "Lark / Feishu", + larkDescription: + "Work with documents, messages, calendars, and multidimensional tables in your conversations.", + larkTag: "Collaboration", + connect: "Connect", + notInstalled: "Not installed", + mcpDescription: "Let your agent use the tools provided by this plugin.", + mcpLabel: "MCP plugin", + pluginSettings: "Plugin settings", + communityTitle: "Bring a skill from the community", + communityDescription: + "Import a .skill file to manage and use it under My skills.", + skillEnabled: "Enable skill", + manage: "Manage", + }, + // Common common: { home: "Home", @@ -965,11 +1008,8 @@ export const enUS: Translations = { account: "Account", appearance: "Appearance", channels: "Channels", - integrations: "Integrations", memory: "Memory", - tools: "Tools", subagents: "Subagents", - skills: "Skills", notification: "Notification", about: "About", }, @@ -1066,8 +1106,6 @@ export const enUS: Translations = { languageDescription: "Switch between languages.", }, tools: { - title: "Tools", - description: "Manage the configuration and enabled status of MCP tools.", adminRequired: "Admin privileges are required to manage MCP tools.", empty: "No MCP tools configured.", addServer: "Add server", @@ -1425,9 +1463,6 @@ export const enUS: Translations = { exportLimit: "The package exceeds an export limit.", exportNotFound: "This custom skill no longer exists. Refresh the skill list.", - title: "Agent Skills", - description: - "Manage the configuration and enabled status of the agent skills.", createSkill: "Create skill", emptyTitle: "No agent skill yet", emptyDescription: diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index c4c3b57b7..00c1ae022 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -6,6 +6,45 @@ export interface Translations { localName: string; }; + capabilities: { + integrationSkills: string; + sharedSkills: string; + title: string; + description: string; + plugins: string; + skills: string; + searchPlugins: string; + searchSkills: string; + allPlugins: string; + installed: string; + enabled: string; + disabled: string; + configure: string; + details: string; + addPlugin: string; + builtin: string; + community: string; + custom: string; + allSkills: string; + availablePlugins: string; + availableSkills: string; + pluginHint: string; + skillHint: string; + noResults: string; + larkName: string; + larkDescription: string; + larkTag: string; + connect: string; + notInstalled: string; + mcpDescription: string; + mcpLabel: string; + pluginSettings: string; + communityTitle: string; + communityDescription: string; + skillEnabled: string; + manage: string; + }; + // Common common: { home: string; @@ -818,11 +857,8 @@ export interface Translations { account: string; appearance: string; channels: string; - integrations: string; memory: string; - tools: string; subagents: string; - skills: string; notification: string; about: string; }; @@ -912,8 +948,6 @@ export interface Translations { languageDescription: string; }; tools: { - title: string; - description: string; adminRequired: string; empty: string; addServer: string; @@ -1123,8 +1157,6 @@ export interface Translations { exportTimeout: string; exportLimit: string; exportNotFound: string; - title: string; - description: string; createSkill: string; emptyTitle: string; emptyDescription: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index efd891cf8..ffedbabce 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -17,6 +17,46 @@ export const zhCN: Translations = { localName: "中文", }, + capabilities: { + integrationSkills: "来自插件", + sharedSkills: "共享技能", + title: "能力中心", + description: "为你的工作添加工具与技能,让 DeerFlow 更懂你的工作方式。", + plugins: "插件", + skills: "技能", + searchPlugins: "搜索插件名称或用途", + searchSkills: "搜索技能名称或用途", + allPlugins: "全部插件", + installed: "已安装", + enabled: "已启用", + disabled: "未启用", + configure: "配置", + details: "查看详情", + addPlugin: "添加 MCP 插件", + builtin: "官方内置", + community: "社区", + custom: "我的技能", + allSkills: "全部技能", + availablePlugins: "可用插件", + availableSkills: "可用技能", + pluginHint: "连接常用应用,让 Agent 直接访问资料、处理工作。", + skillHint: "把常用方法变成技能,在对话中按需使用。", + noResults: "没有找到匹配的内容", + larkName: "飞书 / Lark", + larkDescription: "连接飞书文档、消息、日历与多维表格,在对话中协同办公。", + larkTag: "办公协作", + connect: "连接", + notInstalled: "未安装", + mcpDescription: "让 Agent 调用此插件提供的工具。", + mcpLabel: "MCP 插件", + pluginSettings: "插件配置", + communityTitle: "从社区带来新的技能", + communityDescription: + "导入你找到的 .skill 文件,即可在「我的技能」中管理和使用。", + skillEnabled: "启用技能", + manage: "管理", + }, + // Common common: { home: "首页", @@ -920,11 +960,8 @@ export const zhCN: Translations = { account: "账号", appearance: "外观", channels: "渠道", - integrations: "集成", memory: "记忆", - tools: "工具", subagents: "子智能体", - skills: "技能", notification: "通知", about: "关于", }, @@ -1018,8 +1055,6 @@ export const zhCN: Translations = { languageDescription: "在不同语言之间切换。", }, tools: { - title: "工具", - description: "管理 MCP 工具的配置和启用状态。", adminRequired: "需要管理员权限才能管理 MCP 工具。", empty: "暂无 MCP 工具。", addServer: "添加服务器", @@ -1351,8 +1386,6 @@ export const zhCN: Translations = { exportTimeout: "准备技能包超时,请稍后重试。", exportLimit: "技能包超出导出的大小、数量或路径限制。", exportNotFound: "此自定义技能已不存在,请刷新技能列表。", - title: "技能", - description: "管理 Agent Skill 配置和启用状态。", createSkill: "新建技能", emptyTitle: "还没有技能", emptyDescription: diff --git a/frontend/tests/e2e/capability-center.spec.ts b/frontend/tests/e2e/capability-center.spec.ts new file mode 100644 index 000000000..30e3db340 --- /dev/null +++ b/frontend/tests/e2e/capability-center.spec.ts @@ -0,0 +1,262 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; + +import { expect, test, type Page } from "@playwright/test"; + +import { mockLangGraphAPI } from "./utils/mock-api"; + +const skills = [ + { + name: "deep-research", + description: + "Search, cross-check sources, and write a detailed research report.", + }, + { + name: "data-analysis", + description: "Analyze structured data and create charts.", + }, + { + name: "academic-paper-review", + description: + "Review research papers, methods, contributions, and limitations.", + }, + { + name: "ppt-generation", + description: "Create presentations from ideas and reference materials.", + }, + { + name: "frontend-design", + description: "Build polished frontend interfaces.", + }, + { + name: "image-generation", + description: "Generate images from a written description.", + }, + { + name: "podcast-generation", + description: "Create podcast scripts and audio.", + }, + { + name: "skill-creator", + description: "Create reusable skills for new tasks.", + }, + { + name: "skill-reviewer", + description: "Review skill quality and report potential issues.", + }, +].map((skill) => ({ + ...skill, + category: "public", + enabled: true, + license: "MIT", +})); + +async function mockCatalog(page: Page) { + mockLangGraphAPI(page, { skills, threads: [] }); + await page.route("**/api/mcp/config", (route) => + route.fulfill({ + json: { + mcp_servers: { + GitHub: { + description: + "搜索代码与仓库,查看 Issue 和 Pull Request,协助推进开发工作。", + enabled: true, + type: "http", + url: "https://example.test/github", + }, + Notion: { + description: "搜索工作空间里的笔记和文档,整理资料,创建新的页面。", + enabled: true, + type: "http", + url: "https://example.test/notion", + }, + "Brave Search": { + description: "搜索互联网上的信息,为研究、写作和决策补充最新资料。", + enabled: true, + command: "example-search", + }, + Filesystem: { + description: "访问已授权的文件夹,读取文件内容并整理本地工作资料。", + enabled: true, + command: "example-files", + }, + PostgreSQL: { + description: "查询数据库中的业务数据,探索表结构,辅助数据分析。", + enabled: false, + command: "example-database", + }, + }, + }, + }), + ); +} + +async function screenshot(page: Page, name: string) { + const directory = process.env.CAPABILITY_SCREENSHOT_DIR; + if (!directory) return; + await mkdir(directory, { recursive: true }); + await page.screenshot({ path: path.join(directory, name), fullPage: true }); +} + +test("catalog navigation, search, details, and migrated settings", async ({ + page, + baseURL, +}) => { + test.setTimeout(90_000); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.setViewportSize({ width: 1512, height: 1060 }); + await page + .context() + .addCookies([{ name: "locale", value: "zh-CN", url: baseURL! }]); + await mockCatalog(page); + await page.goto("/workspace/capabilities"); + await expect( + page.getByRole("heading", { name: "能力中心", exact: true }), + ).toBeVisible(); + await expect(page.locator("article")).toHaveCount(6); + await expect(page.locator("a[href='/workspace/capabilities']")).toBeVisible(); + await screenshot(page, "capability-center-plugins.png"); + + await page + .getByRole("textbox", { name: "搜索插件名称或用途" }) + .fill("Notion"); + await expect(page.locator("article")).toHaveCount(1); + await page.getByRole("button", { name: "编辑 Notion", exact: true }).click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await expect( + page.getByRole("textbox", { name: "MCP 服务器 JSON 定义" }), + ).toHaveValue(/example.test\/notion/); + await page.keyboard.press("Escape"); + await page.getByRole("tab", { name: "技能", exact: true }).click(); + await expect(page.locator("article")).toHaveCount(9); + await expect( + page.getByRole("button", { name: "查看详情 深度研究", exact: true }), + ).toBeVisible(); + await page.setViewportSize({ width: 1512, height: 1270 }); + await screenshot(page, "capability-center-skills.png"); + await page + .getByRole("button", { name: "查看详情 深度研究", exact: true }) + .click(); + await expect( + page.getByRole("dialog").getByText(skills[0]!.description), + ).toBeVisible(); + await page.keyboard.press("Escape"); + await page.getByRole("tab", { name: "社区", exact: true }).click(); + await expect(page.getByText("从社区带来新的技能")).toBeVisible(); + + await page.goto("/workspace/capabilities?settings=appearance"); + const settings = page.getByRole("dialog", { name: "设置", exact: true }); + await expect(settings).toBeVisible(); + for (const name of ["工具", "集成", "技能"]) { + await expect( + settings.getByRole("button", { name, exact: true }), + ).toHaveCount(0); + } + expect(errors).toEqual([]); +}); + +test("the skills catalog fits a mobile viewport", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await mockCatalog(page); + await page.goto("/workspace/capabilities?tab=skills"); + await expect(page).toHaveURL(/workspace\/capabilities\?tab=skills/); + await expect(page.locator("article")).toHaveCount(9); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ).toBe(true); + await page + .getByRole("button", { name: "Toggle Sidebar", exact: true }) + .click(); + await expect( + page.getByRole("link", { name: "Capability Center" }), + ).toBeVisible(); +}); + +test("MCP access errors preserve the independently available Lark integration", async ({ + page, +}) => { + await mockCatalog(page); + await page.route("**/api/mcp/config", (route) => + route.fulfill({ status: 403, json: { detail: "Admin only" } }), + ); + await page.goto("/workspace/capabilities"); + await expect( + page.getByRole("alert").filter({ hasText: "Admin privileges" }), + ).toBeVisible(); + await expect( + page.locator("article").filter({ hasText: "Lark / Feishu" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Add MCP plugin" }), + ).toHaveCount(0); +}); + +test("Community search gives feedback and clearing it restores import guidance", async ({ + page, +}) => { + await mockCatalog(page); + await page.goto("/workspace/capabilities?tab=skills"); + await expect(page.locator("article")).toHaveCount(9); + const search = page.getByRole("textbox", { + name: "Search skills by name or purpose", + }); + await search.fill("nonexistent-query"); + await expect( + page.getByText("No matches found", { exact: true }), + ).toBeVisible(); + await page.getByRole("tab", { name: "Community", exact: true }).click(); + await expect(search).toHaveValue("nonexistent-query"); + await expect( + page.getByText("No matches found", { exact: true }), + ).toBeVisible(); + await expect( + page.getByText("Bring a skill from the community", { exact: true }), + ).toHaveCount(0); + await search.fill(" "); + await expect( + page.getByText("Bring a skill from the community", { exact: true }), + ).toBeVisible(); + await search.fill(""); + await page.getByRole("tab", { name: "Built-in", exact: true }).click(); + await expect(page.locator("article")).toHaveCount(9); +}); + +test("plugin filters remain usable after an MCP refetch fails", async ({ + page, +}) => { + await mockCatalog(page); + let failRead = false; + await page.route("**/api/mcp/config", async (route) => { + if (route.request().method() === "PATCH") { + failRead = true; + return route.fulfill({ json: { mcp_servers: {} } }); + } + if (failRead) + return route.fulfill({ status: 403, json: { detail: "Admin only" } }); + return route.fallback(); + }); + await page.goto("/workspace/capabilities"); + await expect(page.locator("article")).toHaveCount(6); + const installed = page.getByRole("tab", { name: "Installed", exact: true }); + await installed.click(); + await expect(page.locator("article")).toHaveCount(5); + await page + .getByRole("switch", { name: "Enabled GitHub", exact: true }) + .click(); + await expect(page.getByRole("alert")).toBeVisible(); + await expect(installed).toHaveAttribute("aria-selected", "true"); + await expect( + page.getByRole("button", { name: "Add MCP plugin" }), + ).toHaveCount(0); + await page.getByRole("tab", { name: "All plugins", exact: true }).click(); + await expect( + page.locator("article").filter({ hasText: "Lark / Feishu" }), + ).toBeVisible(); + await page + .getByRole("button", { name: "Configure Lark / Feishu", exact: true }) + .click(); + await expect(page.getByRole("dialog")).toBeVisible(); +}); diff --git a/frontend/tests/e2e/integrations.spec.ts b/frontend/tests/e2e/integrations.spec.ts index 53682811f..5a3c8a0e9 100644 --- a/frontend/tests/e2e/integrations.spec.ts +++ b/frontend/tests/e2e/integrations.spec.ts @@ -36,14 +36,14 @@ function configuredLarkStatus() { } test.describe("Integrations settings", () => { - test("opens integrations settings from a query-string deep link", async ({ + test("opens the Lark plugin from a capability deep link", async ({ page, }) => { mockLangGraphAPI(page); - await page.goto("/workspace/chats/new?settings=integrations"); + await page.goto("/workspace/capabilities?tab=plugins&plugin=lark"); - const dialog = page.getByRole("dialog", { name: "Settings" }); + const dialog = page.getByRole("dialog", { name: "Plugin settings" }); await expect(dialog).toBeVisible(); await expect(dialog.getByText("Lark / Feishu CLI")).toBeVisible(); }); @@ -109,8 +109,8 @@ test.describe("Integrations settings", () => { }, ); - await page.goto("/workspace/chats/new?settings=integrations"); - const dialog = page.getByRole("dialog", { name: "Settings" }); + await page.goto("/workspace/capabilities?tab=plugins&plugin=lark"); + const dialog = page.getByRole("dialog", { name: "Plugin settings" }); const popupPromise = page.waitForEvent("popup"); await dialog.getByRole("button", { name: "Connect Lark" }).click(); const popup = await popupPromise; @@ -141,23 +141,27 @@ test.describe("Integrations settings", () => { await expect(page.getByText("Copied to clipboard")).toBeVisible(); }); - test("keeps a single settings dialog across deep link and nav menu openings", async ({ + test("closes the plugin dialog before opening general settings", async ({ page, }) => { mockLangGraphAPI(page); // Deep link opens the shared dialog on Integrations. - await page.goto("/workspace/chats/new?settings=integrations"); - const dialog = page.getByRole("dialog", { name: "Settings" }); + await page.goto("/workspace/capabilities?tab=plugins&plugin=lark"); + const dialog = page.getByRole("dialog", { name: "Plugin settings" }); await expect(dialog).toBeVisible(); await expect(dialog.getByText("Lark / Feishu CLI")).toBeVisible(); - await expect(page.getByRole("dialog", { name: "Settings" })).toHaveCount(1); + await expect( + page.getByRole("dialog", { name: "Plugin settings" }), + ).toHaveCount(1); // Close the modal before using the sidebar. While the modal is open, the // background is intentionally inert and Playwright should not be able to // click sidebar controls there. await page.keyboard.press("Escape"); - await expect(page.getByRole("dialog", { name: "Settings" })).toHaveCount(0); + await expect( + page.getByRole("dialog", { name: "Plugin settings" }), + ).toHaveCount(0); // Opening again from the nav menu must still use the same shared host, not // mount a second SettingsDialog instance. @@ -165,11 +169,15 @@ test.describe("Integrations settings", () => { await sidebar.getByRole("button", { name: /Settings and more/ }).click(); await page.getByRole("menuitem", { name: "Settings" }).click(); - // Exactly one Settings dialog is mounted/visible at any time. - await expect(page.getByRole("dialog", { name: "Settings" })).toHaveCount(1); + await expect( + page.getByRole("dialog", { name: "Settings", exact: true }), + ).toHaveCount(1); + await expect( + page.getByRole("dialog", { name: "Plugin settings", exact: true }), + ).toHaveCount(0); }); - test("can install the Lark integration skill pack from settings", async ({ + test("can install the Lark integration skill pack from the capability center", async ({ page, }) => { mockLangGraphAPI(page); @@ -224,15 +232,9 @@ test.describe("Integrations settings", () => { }); }); - await page.goto("/workspace/chats/new"); - - const sidebar = page.locator("[data-sidebar='sidebar']"); - await sidebar.getByRole("button", { name: /Settings and more/ }).click(); - await page.getByRole("menuitem", { name: "Settings" }).click(); - - const dialog = page.getByRole("dialog", { name: "Settings" }); + await page.goto("/workspace/capabilities?tab=plugins&plugin=lark"); + const dialog = page.getByRole("dialog", { name: "Plugin settings" }); await expect(dialog).toBeVisible(); - await dialog.getByRole("button", { name: "Integrations" }).click(); await expect(dialog.getByText("Lark / Feishu CLI")).toBeVisible(); await expect( @@ -361,9 +363,9 @@ test.describe("Integrations settings", () => { }); }); - await page.goto("/workspace/chats/new?settings=integrations"); + await page.goto("/workspace/capabilities?tab=plugins&plugin=lark"); - const dialog = page.getByRole("dialog", { name: "Settings" }); + const dialog = page.getByRole("dialog", { name: "Plugin settings" }); await expect(dialog).toBeVisible(); await expect(dialog.getByText("Lark / Feishu CLI")).toBeVisible(); @@ -426,8 +428,8 @@ test.describe("Integrations settings", () => { await route.fallback(); }); - await page.goto("/workspace/chats/new?settings=integrations"); - const dialog = page.getByRole("dialog", { name: "Settings" }); + await page.goto("/workspace/capabilities?tab=plugins&plugin=lark"); + const dialog = page.getByRole("dialog", { name: "Plugin settings" }); await dialog.getByRole("button", { name: "Calendar" }).click(); await dialog .getByLabel("Exact OAuth scope") diff --git a/frontend/tests/e2e/mcp-settings.spec.ts b/frontend/tests/e2e/mcp-settings.spec.ts index 5e3b124b9..7c4f266cd 100644 --- a/frontend/tests/e2e/mcp-settings.spec.ts +++ b/frontend/tests/e2e/mcp-settings.spec.ts @@ -54,10 +54,10 @@ test.describe("MCP server settings", () => { }); }); - await page.goto("/workspace/chats/new?settings=tools"); + await page.goto("/workspace/capabilities"); - const settingsDialog = page.getByRole("dialog", { name: "Settings" }); - await expect(settingsDialog).toBeVisible(); + const settingsDialog = page; + await expect(page).toHaveURL(/workspace\/capabilities$/); await settingsDialog.getByRole("button", { name: "Edit remote" }).click(); const editor = page.getByRole("dialog", { name: "Edit MCP server" }); diff --git a/frontend/tests/e2e/skill-export.spec.ts b/frontend/tests/e2e/skill-export.spec.ts index 7a8196c4b..04498b55a 100644 --- a/frontend/tests/e2e/skill-export.spec.ts +++ b/frontend/tests/e2e/skill-export.spec.ts @@ -58,12 +58,21 @@ test("custom export previews, handles stale content and downloads only after ref body: Buffer.from("synthetic transport fixture"), }); }); - await page.goto("/workspace/chats/new?settings=skills"); + await page.goto("/workspace/capabilities?tab=skills"); + await page + .getByRole("button", { name: "View details public-demo", exact: true }) + .click(); await expect( - page.getByRole("button", { name: "Export public-demo" }), + page + .getByRole("dialog") + .getByRole("button", { name: "Export", exact: true }), ).toHaveCount(0); - await page.getByRole("tab", { name: "Custom", exact: true }).click(); - await page.getByRole("button", { name: "Export demo", exact: true }).click(); + await page.keyboard.press("Escape"); + await page.getByRole("tab", { name: "My skills", exact: true }).click(); + await page + .getByRole("button", { name: "View details demo", exact: true }) + .click(); + await page.getByRole("button", { name: "Export", exact: true }).click(); const dialog = page.getByRole("dialog", { name: "Export skill", exact: true, @@ -94,7 +103,7 @@ test("custom export previews, handles stale content and downloads only after ref await page.keyboard.press("Escape"); await expect(dialog).toBeHidden(); await expect( - page.getByRole("dialog", { name: "Settings", exact: true }), + page.getByRole("heading", { name: "Capability Center", exact: true }), ).toBeVisible(); }); @@ -123,9 +132,12 @@ test("mobile manifest blockers are readable and cannot download", async ({ }, }), ); - await page.goto("/workspace/chats/new?settings=skills"); - await page.getByRole("tab", { name: "Custom", exact: true }).click(); - await page.getByRole("button", { name: "Export demo", exact: true }).click(); + await page.goto("/workspace/capabilities?tab=skills"); + await page.getByRole("tab", { name: "My skills", exact: true }).click(); + await page + .getByRole("button", { name: "View details demo", exact: true }) + .click(); + await page.getByRole("button", { name: "Export", exact: true }).click(); const dialog = page.getByRole("dialog", { name: "Export skill", exact: true, diff --git a/frontend/tests/e2e/utils/mock-api.ts b/frontend/tests/e2e/utils/mock-api.ts index 1c338f99e..9c0ab1fe7 100644 --- a/frontend/tests/e2e/utils/mock-api.ts +++ b/frontend/tests/e2e/utils/mock-api.ts @@ -1407,7 +1407,11 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { return route.fallback(); }); - // Skills list — settings page and slash autocomplete + void page.route("**/api/mcp/config", (route) => + route.fulfill({ json: { mcp_servers: {} } }), + ); + + // Skills list — capability center and slash autocomplete void page.route("**/api/skills", (route) => { if (route.request().method() === "GET") { return route.fulfill({ diff --git a/frontend/tests/unit/components/workspace/settings/tool-settings-page.dom.test.tsx b/frontend/tests/unit/components/workspace/capabilities/mcp-plugin-manager.dom.test.tsx similarity index 84% rename from frontend/tests/unit/components/workspace/settings/tool-settings-page.dom.test.tsx rename to frontend/tests/unit/components/workspace/capabilities/mcp-plugin-manager.dom.test.tsx index 4a0ecd4ff..b165131e7 100644 --- a/frontend/tests/unit/components/workspace/settings/tool-settings-page.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/capabilities/mcp-plugin-manager.dom.test.tsx @@ -1,10 +1,11 @@ import { afterEach, describe, expect, it, rs } from "@rstest/core"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page"; +import { MCPPluginManager } from "@/components/workspace/capabilities/mcp-plugin-manager"; const mcpMockState = rs.hoisted(() => ({ isPending: false, + isLoading: false, error: null as Error | null, mutate: rs.fn(), updateIsPending: false, @@ -27,6 +28,14 @@ const DURABLE_TASK_SERVER = { rs.mock("@/core/i18n/hooks", () => ({ useI18n: () => ({ t: { + capabilities: { + enabled: "Enabled", + disabled: "Disabled", + details: "View details", + addPlugin: "Add server", + mcpLabel: "MCP", + mcpDescription: "MCP tools", + }, common: { error: "Error:", loading: "Loading", @@ -37,8 +46,6 @@ rs.mock("@/core/i18n/hooks", () => ({ }, settings: { tools: { - title: "Tools", - description: "Manage MCP tools", adminRequired: "Admin required", empty: "No tools", addServer: "Add server", @@ -68,7 +75,7 @@ rs.mock("@/core/i18n/hooks", () => ({ rs.mock("@/core/mcp/hooks", () => ({ useMCPConfig: () => ({ config: { mcp_servers: mcpMockState.servers }, - isLoading: false, + isLoading: mcpMockState.isLoading, error: mcpMockState.error, }), useEnableMCPServer: () => ({ @@ -120,6 +127,7 @@ function definitionTextbox(): HTMLTextAreaElement { afterEach(() => { mcpMockState.isPending = false; + mcpMockState.isLoading = false; mcpMockState.error = null; mcpMockState.updateIsPending = false; mcpMockState.mutate.mockReset(); @@ -128,11 +136,30 @@ afterEach(() => { cleanup(); }); -describe("ToolSettingsPage MCP switches", () => { +describe("MCPPluginManager MCP switches", () => { + it.each(["loading", "error"])( + "preserves plugin filters and other plugins during an MCP %s", + (state) => { + mcpMockState.isLoading = state === "loading"; + mcpMockState.error = + state === "error" ? new Error("request failed") : null; + render( + All plugins}> + + , + ); + expect( + screen.getByRole("button", { name: "Configure Lark" }), + ).toBeDefined(); + expect(screen.getByRole("button", { name: "All plugins" })).toBeDefined(); + expect(screen.queryByRole("button", { name: "Add server" })).toBeNull(); + }, + ); + it("renders a localized load error", () => { mcpMockState.error = new Error("request failed"); - render(); + render(); expect(screen.getByText("Error: request failed")).toBeDefined(); }); @@ -141,7 +168,7 @@ describe("ToolSettingsPage MCP switches", () => { twoServers(); mcpMockState.isPending = true; - render(); + render(); const switches = screen.getAllByRole("switch"); expect(switches).toHaveLength(2); @@ -153,7 +180,7 @@ describe("ToolSettingsPage MCP switches", () => { it("submits only the selected server state when idle", () => { twoServers(); - render(); + render(); const switches = screen.getAllByRole("switch"); const githubSwitch = switches[0]; @@ -169,11 +196,11 @@ describe("ToolSettingsPage MCP switches", () => { }); }); -describe("ToolSettingsPage add server", () => { +describe("MCPPluginManager add server", () => { it("submits only the pasted servers to the atomic create endpoint", () => { twoServers(); - render(); + render(); openAddDialog(); fireEvent.change(screen.getByRole("textbox"), { target: { @@ -193,7 +220,7 @@ describe("ToolSettingsPage add server", () => { it("does not submit stale sibling configurations while adding", () => { twoServers(); - render(); + render(); openAddDialog(); fireEvent.change(screen.getByRole("textbox"), { target: { value: '{"added": {"command": "uvx"}}' }, @@ -211,7 +238,7 @@ describe("ToolSettingsPage add server", () => { it("reports a malformed definition without writing", () => { twoServers(); - render(); + render(); openAddDialog(); fireEvent.change(screen.getByRole("textbox"), { target: { value: "{not json" }, @@ -228,7 +255,7 @@ describe("ToolSettingsPage add server", () => { it("offers the add action when no server is configured yet", () => { setServers({}); - render(); + render(); expect(screen.getByText("No tools")).toBeDefined(); expect( @@ -241,7 +268,7 @@ describe("ToolSettingsPage add server", () => { it("rejects an existing name instead of silently replacing it", () => { twoServers(); - render(); + render(); openAddDialog(); fireEvent.change(screen.getByRole("textbox"), { target: { value: '{"github": {"command": "uvx"}}' }, @@ -253,11 +280,11 @@ describe("ToolSettingsPage add server", () => { }); }); -describe("ToolSettingsPage edit server", () => { +describe("MCPPluginManager edit server", () => { it("prefills the complete server definition", () => { twoServers(); - render(); + render(); openEditDialog("remote"); const definition = JSON.parse(definitionTextbox().value) as { @@ -273,7 +300,7 @@ describe("ToolSettingsPage edit server", () => { it("updates only one server while preserving all of its hidden fields", () => { twoServers(); - render(); + render(); openEditDialog("remote"); const textbox = definitionTextbox(); const definition = JSON.parse(textbox.value) as { @@ -299,7 +326,7 @@ describe("ToolSettingsPage edit server", () => { it("rejects renaming through the edit dialog", () => { twoServers(); - render(); + render(); openEditDialog("github"); fireEvent.change(screen.getByRole("textbox"), { target: { @@ -315,7 +342,7 @@ describe("ToolSettingsPage edit server", () => { it("rejects editing multiple servers at once", () => { twoServers(); - render(); + render(); openEditDialog("github"); fireEvent.change(screen.getByRole("textbox"), { target: { @@ -330,11 +357,11 @@ describe("ToolSettingsPage edit server", () => { }); }); -describe("ToolSettingsPage remove server", () => { +describe("MCPPluginManager remove server", () => { it("submits only the selected server name", () => { twoServers(); - render(); + render(); fireEvent.click(screen.getByRole("button", { name: "Delete github" })); fireEvent.click(screen.getByRole("button", { name: "Delete" })); @@ -347,7 +374,7 @@ describe("ToolSettingsPage remove server", () => { it("does not write when the confirmation is dismissed", () => { twoServers(); - render(); + render(); fireEvent.click(screen.getByRole("button", { name: "Delete github" })); fireEvent.click(screen.getByRole("button", { name: "Cancel" })); @@ -357,7 +384,7 @@ describe("ToolSettingsPage remove server", () => { it("deletes a configured server whose name is empty", () => { setServers({ "": { enabled: false, description: "Legacy server" } }); - render(); + render(); fireEvent.click( screen.getByRole("button", { name: "Delete (empty name)" }), ); diff --git a/frontend/tests/unit/components/workspace/settings/skill-export-dialog.dom.test.tsx b/frontend/tests/unit/components/workspace/capabilities/skill-export-dialog.dom.test.tsx similarity index 97% rename from frontend/tests/unit/components/workspace/settings/skill-export-dialog.dom.test.tsx rename to frontend/tests/unit/components/workspace/capabilities/skill-export-dialog.dom.test.tsx index e49e7f83c..6346f0340 100644 --- a/frontend/tests/unit/components/workspace/settings/skill-export-dialog.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/capabilities/skill-export-dialog.dom.test.tsx @@ -30,7 +30,7 @@ rs.mock("@/core/skills/export", () => ({ rs.mock("@/core/i18n/hooks", () => ({ useI18n: () => ({ locale: "en-US", t: enUS }), })); -import SkillExportDialog from "@/components/workspace/settings/skill-export-dialog"; +import SkillExportDialog from "@/components/workspace/capabilities/skill-export-dialog"; import { enUS } from "@/core/i18n/locales/en-US"; import { SkillExportRequestError, diff --git a/frontend/tests/unit/components/workspace/capabilities/skill-presentation.test.ts b/frontend/tests/unit/components/workspace/capabilities/skill-presentation.test.ts new file mode 100644 index 000000000..9748ccabd --- /dev/null +++ b/frontend/tests/unit/components/workspace/capabilities/skill-presentation.test.ts @@ -0,0 +1,92 @@ +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "@rstest/core"; + +import { + builtinSkillPresentation, + presentSkill, +} from "@/components/workspace/capabilities/skill-presentation"; +import type { Skill } from "@/core/skills/type"; + +const publicSkillsRoot = join(process.cwd(), "../skills/public"); +const publicSkillNames = readdirSync(publicSkillsRoot).filter((name) => + existsSync(join(publicSkillsRoot, name, "SKILL.md")), +); + +function skill(name: string, category = "public"): Skill { + return { + name, + category, + description: "Current description from the skill manifest.", + license: "MIT", + enabled: true, + editable: false, + }; +} + +describe("skill card presentation", () => { + it("only defines curated metadata for existing public skills", () => { + expect(publicSkillNames.length).toBeGreaterThan(0); + const staleNames = Object.keys(builtinSkillPresentation).filter( + (name) => !publicSkillNames.includes(name), + ); + expect(staleNames).toEqual([]); + }); + + it("keeps source names and descriptions for English cards", () => { + for (const name of publicSkillNames) { + const source = skill(name); + expect(presentSkill(source, "en-US")).toMatchObject({ + title: source.name, + description: source.description, + }); + } + }); + + it("falls back to source metadata for uncurated public skills", () => { + for (const name of [...publicSkillNames, "new-public-skill"]) { + if (Object.hasOwn(builtinSkillPresentation, name)) continue; + const source = skill(name); + expect(presentSkill(source, "zh-CN")).toEqual({ + title: source.name, + description: source.description, + icon: undefined, + }); + } + }); + + it.each(["custom", "integrations", "legacy"])( + "does not shadow a %s skill with the same name as a built-in", + (category) => { + const source = skill("deep-research", category); + expect(presentSkill(source, "zh-CN")).toEqual({ + title: source.name, + description: source.description, + icon: undefined, + }); + }, + ); + + it("keeps the full source description available alongside a curated summary", () => { + const source = Object.freeze(skill("deep-research")); + const presented = presentSkill(source, "zh-CN"); + expect(presented.title).toBe("深度研究"); + expect(presented.description).not.toBe(source.description); + expect(source.description).toBe( + "Current description from the skill manifest.", + ); + }); + + it.each(["constructor", "toString", "__proto__"])( + "uses source metadata for the prototype-like name %s", + (name) => { + const source = skill(name); + expect(presentSkill(source, "zh-CN")).toEqual({ + title: name, + description: source.description, + icon: undefined, + }); + }, + ); +}); diff --git a/frontend/tests/unit/components/workspace/lazy-panels.test.ts b/frontend/tests/unit/components/workspace/lazy-panels.test.ts index c61a4eca5..3c7097c9e 100644 --- a/frontend/tests/unit/components/workspace/lazy-panels.test.ts +++ b/frontend/tests/unit/components/workspace/lazy-panels.test.ts @@ -23,7 +23,7 @@ describe("interaction-only bundle boundaries", () => { const dialog = read( "src/components/workspace/settings/settings-dialog.tsx", ); - expect(dialog.match(/dynamic\(/g)).toHaveLength(10); + expect(dialog.match(/dynamic\(/g)).toHaveLength(7); expect(dialog).not.toMatch( /import \{ \w+SettingsPage \} from "@\/components\/workspace\/settings\//, ); diff --git a/frontend/tests/unit/components/workspace/settings/settings-dialog-store.test.ts b/frontend/tests/unit/components/workspace/settings/settings-dialog-store.test.ts index b76215b4a..f3bbcc9fd 100644 --- a/frontend/tests/unit/components/workspace/settings/settings-dialog-store.test.ts +++ b/frontend/tests/unit/components/workspace/settings/settings-dialog-store.test.ts @@ -20,10 +20,10 @@ test("starts closed on the default section", () => { }); test("openSettingsDialog opens on the requested section", () => { - openSettingsDialog("integrations"); + openSettingsDialog("notification"); expect(getSettingsDialogSnapshot()).toEqual({ open: true, - section: "integrations", + section: "notification", }); }); @@ -42,9 +42,9 @@ test("notifies subscribers only on real state changes", () => { notifications += 1; }); - openSettingsDialog("integrations"); + openSettingsDialog("notification"); // Opening again on the same section is a no-op and must not re-notify. - openSettingsDialog("integrations"); + openSettingsDialog("notification"); expect(notifications).toBe(1); openSettingsDialog("memory");