feat(frontend): move capability management out of Settings (#5468)

* feat(frontend): move capability management out of settings

* fix(frontend): address capability center review feedback
This commit is contained in:
Wenchao An 2026-09-16 18:39:24 +08:00 committed by GitHub
parent 8ae8d2bfcf
commit a246c928e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1541 additions and 475 deletions

View File

@ -540,7 +540,7 @@ For stdio MCP servers, per-tool call timeouts can be configured with `tool_call_
MCP tool names are prefixed with `<server_name>_` 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. MCP tool names are prefixed with `<server_name>_` 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. 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. 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. 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. 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 ### 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*. 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. 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 Managed integrations install shared read-only skill packs without mixing them
into custom skills. The Lark/Feishu CLI integration is available under 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 upgrades the official `lark-*` pack once under
`{DEER_FLOW_HOME}/integrations/skills/lark-cli`, and every user discovers that `{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 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 verification. The action then remains **Reconnect Lark** so users can replace
or extend authorization. If an agent hits missing Lark authorization during a or extend authorization. If an agent hits missing Lark authorization during a
conversation, the managed `lark-shared` guidance points the user back to the 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 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 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 #### 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). 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).

View File

@ -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`) ### 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 App registration and direct app switching replace the per-user Lark credential
tree transactionally. Clear the old OAuth data before running `lark-cli config 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 init`: on Linux that command writes the new app secret into the file-backed

View File

@ -139,8 +139,8 @@ script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
exec "$script_dir/../linux-$arch/lark-cli" "$@" exec "$script_dir/../linux-$arch/lark-cli" "$@"
""" """
_VERSION_TAG_RE = re.compile(r"v?\d+\.\d+\.\d+") _VERSION_TAG_RE = re.compile(r"v?\d+\.\d+\.\d+")
_DEERFLOW_LARK_SHARED_GUIDANCE_MARKER = "<!-- deerflow-lark-cli-auth-guidance-v2 -->" _DEERFLOW_LARK_SHARED_GUIDANCE_MARKER = "<!-- deerflow-lark-cli-auth-guidance-v3 -->"
_DEERFLOW_LARK_SHARED_GUIDANCE_LEGACY_MARKERS = ("<!-- deerflow-lark-cli-auth-guidance-v1 -->",) _DEERFLOW_LARK_SHARED_GUIDANCE_LEGACY_MARKERS = ("<!-- deerflow-lark-cli-auth-guidance-v1 -->", "<!-- deerflow-lark-cli-auth-guidance-v2 -->")
_LARK_APP_REGISTRATION_PATH = "/oauth/v1/app/registration" _LARK_APP_REGISTRATION_PATH = "/oauth/v1/app/registration"
LARK_SKILL_NAMES: tuple[str, ...] = ( LARK_SKILL_NAMES: tuple[str, ...] = (
@ -2824,8 +2824,8 @@ def _append_deerflow_lark_shared_guidance(root: Path) -> None:
DeerFlow 如果 `lark-cli auth status` 或业务命令提示未配置未登录token 过期或缺少用户授权 DeerFlow 如果 `lark-cli auth status` 或业务命令提示未配置未登录token 过期或缺少用户授权
1. 不要要求用户在终端执行 `lark-cli config init``lark-cli auth login` `lark-cli auth login --device-code` 1. 不要要求用户在终端执行 `lark-cli config init``lark-cli auth login` `lark-cli auth login --device-code`
2. 回复用户这个可点击链接[打开飞书授权设置](?settings=integrations) 2. 回复用户这个可点击链接[打开飞书授权设置](/workspace/capabilities?tab=plugins&plugin=lark)
3. 告诉用户在 **Settings Integrations Lark / Feishu CLI** 点击连接飞书在浏览器里完成授权后再回来继续当前任务 3. 告诉用户在 **Capability Center Plugins Lark / Feishu** 点击连接飞书在浏览器里完成授权后再回来继续当前任务
4. 如果错误中包含缺失的 `scope``permission_violations` 或建议的 `--domain`告诉用户在该设置页选择对应权限域例如日历选择 Calendar或把具体 scope 填入Exact OAuth scope / 具体 OAuth scope后重新授权 4. 如果错误中包含缺失的 `scope``permission_violations` 或建议的 `--domain`告诉用户在该设置页选择对应权限域例如日历选择 Calendar或把具体 scope 填入Exact OAuth scope / 具体 OAuth scope后重新授权
只有在用户明确说明已经完成授权后才继续调用具体的 `lark-cli` 业务命令 只有在用户明确说明已经完成授权后才继续调用具体的 `lark-cli` 业务命令

View File

@ -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-doc" / "SKILL.md").is_file()
assert (root / lark_cli.LARK_CLI_MANIFEST_FILE).is_file() assert (root / lark_cli.LARK_CLI_MANIFEST_FILE).is_file()
shared_content = (root / "lark-shared" / "SKILL.md").read_text(encoding="utf-8") 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 "不要要求用户在终端执行" in shared_content
assert "Exact OAuth scope" in shared_content assert "Exact OAuth scope" in shared_content

View File

@ -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 with a localized fallback, and log the rejection for debugging. The shared
delete helper accepts remote 404 (not 403) before retrying local cleanup, and delete helper accepts remote 404 (not 403) before retrying local cleanup, and
`onDeleted` runs only after both deletion steps succeed. `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.

View File

@ -32,7 +32,7 @@
pages; `resolveThreadContext` must neither pages; `resolveThreadContext` must neither
enqueue account writes nor create a fallback thread override that masks a enqueue account writes nor create a fallback thread override that masks a
later server preference. The 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, mutation, disables switches until that mutation's success refetch completes,
displays the backend error `detail` through a toast, and invalidates displays the backend error `detail` through a toast, and invalidates
`["mcpConfig"]` only after success. `["mcpConfig"]` only after success.
@ -71,7 +71,7 @@
fixed page size and an explicit load-more control; full results remain available 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 only through JSONL export. The panel must not infer batch mode from prompt text
or inject the complete result set into chat state. 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 callbacks; server-issued Lark flow generations must be passed through every
config/auth completion and across switch-or-register to authorization chains config/auth completion and across switch-or-register to authorization chains
so backend cross-tab ordering remains authoritative. so backend cross-tab ordering remains authoritative.

View File

@ -0,0 +1,11 @@
import { Suspense } from "react";
import { CapabilityCenter } from "@/components/workspace/capabilities/capability-center";
export default function CapabilitiesPage() {
return (
<Suspense>
<CapabilityCenter />
</Suspense>
);
}

View File

@ -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 (
<div
className={cn(
"flex size-12 shrink-0 items-center justify-center rounded-2xl",
tones[tone],
)}
>
<Icon className="size-6" strokeWidth={1.6} />
</div>
);
}
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 (
<article className="bg-background group hover:border-foreground/20 flex min-w-0 flex-col rounded-2xl border p-5 transition-[border-color,box-shadow] hover:shadow-sm">
<div className="mb-5 flex items-start justify-between gap-3">
{icon}
<span className="text-muted-foreground bg-muted/60 rounded-md px-2 py-1 text-[11px] font-medium">
{label}
</span>
</div>
<h3 className="min-w-0 text-base font-semibold tracking-tight">
{onDetails ? (
<button
className="hover:text-muted-foreground flex max-w-full items-center gap-1.5 text-left"
onClick={onDetails}
aria-label={detailsLabel}
>
<span className="truncate">{name}</span>
<ArrowUpRightIcon className="size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-60" />
</button>
) : (
<span className="block truncate">{name}</span>
)}
</h3>
<p className="text-muted-foreground mt-2 line-clamp-2 min-h-10 text-[13px] leading-5">
{description}
</p>
<div className="mt-6 flex min-h-8 items-center justify-between gap-2 border-t pt-4">
<div className="text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs">
{status}
</div>
<div className="flex shrink-0 items-center gap-1">{children}</div>
</div>
</article>
);
}

View File

@ -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 (
<div className="bg-background flex h-full min-h-0 flex-col">
<div className="text-muted-foreground flex h-14 shrink-0 items-center gap-3 border-b px-4 text-xs md:px-8">
<SidebarTrigger className="md:hidden" />
<span>{t.breadcrumb.workspace}</span>
<span className="opacity-40">/</span>
<span className="text-foreground">{t.capabilities.title}</span>
</div>
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-7xl px-5 py-8 md:px-10 md:py-10">
<header className="mb-8 flex flex-wrap items-end justify-between gap-5">
<div>
<h1 className="text-[28px] font-semibold tracking-tight">
{t.capabilities.title}
</h1>
<p className="text-muted-foreground mt-2 text-sm leading-6">
{t.capabilities.description}
</p>
</div>
<div className="relative w-full md:w-72">
<SearchIcon className="text-muted-foreground pointer-events-none absolute top-3 left-3 size-4" />
<Input
className="bg-muted/30 h-10 rounded-xl pl-9 shadow-none"
aria-label={
tab === "plugins"
? t.capabilities.searchPlugins
: t.capabilities.searchSkills
}
placeholder={
tab === "plugins"
? t.capabilities.searchPlugins
: t.capabilities.searchSkills
}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
</header>
<Tabs value={tab} onValueChange={changeTab} className="mb-7 border-b">
<TabsList variant="line" className="h-12 gap-7">
<TabsTrigger value="plugins" className="gap-2 px-1 pb-4 text-sm">
<BlocksIcon className="size-4" />
{t.capabilities.plugins}
</TabsTrigger>
<TabsTrigger value="skills" className="gap-2 px-1 pb-4 text-sm">
<SparklesIcon className="size-4" />
{t.capabilities.skills}
</TabsTrigger>
</TabsList>
</Tabs>
{tab === "plugins" ? (
<PluginGallery query={query} />
) : (
<SkillGallery query={query} />
)}
</div>
</div>
</div>
);
}

View File

@ -45,7 +45,7 @@ import {
import { env } from "@/env"; import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section"; import { SettingsSection } from "../settings/settings-section";
type PendingLarkFlow = type PendingLarkFlow =
| ({ kind: "config" } & LarkConfigStartResponse) | ({ kind: "config" } & LarkConfigStartResponse)
@ -114,7 +114,7 @@ function uniqueScopes(scopes: string[]) {
return Array.from(new Set(scopes)); return Array.from(new Set(scopes));
} }
export function IntegrationsSettingsPage() { export function LarkPluginSettings() {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
<SettingsSection <SettingsSection

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { PencilIcon, Trash2 } from "lucide-react"; import { PencilIcon, Trash2 } from "lucide-react";
import { useState } from "react"; import { type ReactNode, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -12,13 +12,6 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemTitle,
} from "@/components/ui/item";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
@ -36,38 +29,47 @@ import {
import type { MCPServerConfig } from "@/core/mcp/types"; import type { MCPServerConfig } from "@/core/mcp/types";
import { env } from "@/env"; import { env } from "@/env";
import { SettingsSection } from "./settings-section"; import { CapabilityCard, CapabilityIcon } from "./capability-card";
export function ToolSettingsPage() { type MCPPluginManagerProps = {
query?: string;
children?: ReactNode;
toolbar?: ReactNode;
};
export function MCPPluginManager(props: MCPPluginManagerProps) {
const { t } = useI18n(); const { t } = useI18n();
const { config, isLoading, error } = useMCPConfig(); const { config, isLoading, error } = useMCPConfig();
const adminRequired = if (isLoading || error) {
error instanceof MCPConfigRequestError && error.isAdminRequired; return (
return ( <div className="space-y-4">
<SettingsSection {props.toolbar}
title={t.settings.tools.title} {isLoading ? (
description={t.settings.tools.description} <p role="status" className="text-muted-foreground text-sm">
> {t.common.loading}
{isLoading ? ( </p>
<div className="text-muted-foreground text-sm">{t.common.loading}</div> ) : (
) : adminRequired ? ( <p role="alert" className="text-muted-foreground text-sm">
<div className="text-muted-foreground text-sm"> {error instanceof MCPConfigRequestError && error.isAdminRequired
{t.settings.tools.adminRequired} ? t.settings.tools.adminRequired
: `${t.common.error} ${error?.message}`}
</p>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{props.children}
</div> </div>
) : error ? ( </div>
<div> );
{t.common.error} {error.message} }
</div> return <MCPServerList {...props} servers={config?.mcp_servers} />;
) : (
config && <MCPServerList servers={config.mcp_servers} />
)}
</SettingsSection>
);
} }
function MCPServerList({ function MCPServerList({
servers, servers,
}: { query = "",
children,
toolbar,
}: MCPPluginManagerProps & {
servers?: Record<string, MCPServerConfig>; servers?: Record<string, MCPServerConfig>;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
@ -82,7 +84,11 @@ function MCPServerList({
const readOnly = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"; const readOnly = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
const current = servers ?? {}; 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; const isMutating = isPending || isWriting;
function displayServerName(name: string | null) { function displayServerName(name: string | null) {
@ -189,39 +195,32 @@ function MCPServerList({
return ( return (
<div className="flex w-full flex-col gap-4"> <div className="flex w-full flex-col gap-4">
<div className="flex justify-end"> <div className="flex flex-wrap items-center justify-between gap-3">
{toolbar ?? <span />}
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
disabled={readOnly || isMutating} disabled={readOnly || isMutating}
onClick={openAddEditor} onClick={openAddEditor}
> >
{t.settings.tools.addServer} {t.capabilities.addPlugin}
</Button> </Button>
</div> </div>
{entries.length === 0 ? ( {entries.length === 0 && !children ? (
<div className="text-muted-foreground text-sm"> <div className="text-muted-foreground text-sm">
{t.settings.tools.empty} {query ? t.capabilities.noResults : t.settings.tools.empty}
</div> </div>
) : ( ) : (
entries.map(([name, config]) => { <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
const displayName = displayServerName(name); {children}
return ( {entries.map(([name, config]) => {
<Item className="w-full" variant="outline" key={name}> const displayName = displayServerName(name);
<ItemContent> const actions = (
<ItemTitle> <>
<div className="flex items-center gap-2">
<div>{displayName}</div>
</div>
</ItemTitle>
<ItemDescription className="line-clamp-4">
{config.description}
</ItemDescription>
</ItemContent>
<ItemActions className="gap-1">
<Switch <Switch
checked={config.enabled} checked={config.enabled}
aria-label={`${t.capabilities.enabled} ${displayName}`}
disabled={readOnly || isMutating} disabled={readOnly || isMutating}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
enableMCPServer({ serverName: name, enabled: checked }) enableMCPServer({ serverName: name, enabled: checked })
@ -245,10 +244,43 @@ function MCPServerList({
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />
</Button> </Button>
</ItemActions> </>
</Item> );
); return (
}) <CapabilityCard
key={name}
name={displayName}
description={
config.description || t.capabilities.mcpDescription
}
label={t.capabilities.mcpLabel}
icon={<CapabilityIcon name={name} />}
status={
<>
<span
className={
config.enabled
? "size-1.5 rounded-full bg-emerald-500"
: "bg-muted-foreground/40 size-1.5 rounded-full"
}
/>
{config.enabled
? t.capabilities.enabled
: t.capabilities.disabled}
</>
}
onDetails={
readOnly || isMutating
? undefined
: () => openEditEditor(name, config)
}
detailsLabel={`${t.capabilities.details} ${displayName}`}
>
{actions}
</CapabilityCard>
);
})}
</div>
)} )}
<Dialog <Dialog

View File

@ -0,0 +1,125 @@
"use client";
import { CheckIcon, SendIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useI18n } from "@/core/i18n/hooks";
import { useLarkIntegrationStatus } from "@/core/integrations/lark";
import { CapabilityCard, CapabilityIcon } from "./capability-card";
import { MCPPluginManager } from "./mcp-plugin-manager";
const LarkPluginSettings = dynamic(() =>
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 ? (
<CapabilityCard
name={t.capabilities.larkName}
description={t.capabilities.larkDescription}
label={t.capabilities.larkTag}
icon={<CapabilityIcon name="lark" icon={SendIcon} />}
status={
lark.isLoading ? (
t.common.loading
) : lark.error ? (
t.common.error
) : (
<>
{lark.data?.installed && <CheckIcon className="size-3.5" />}
{lark.data?.installed
? t.capabilities.installed
: t.capabilities.notInstalled}
</>
)
}
onDetails={() => setOpen(true)}
detailsLabel={`${t.capabilities.configure} ${t.capabilities.larkName}`}
>
<Button
size="sm"
variant="outline"
className="h-8 rounded-lg text-xs shadow-none"
onClick={() => setOpen(true)}
>
{connected
? t.capabilities.manage
: lark.data?.installed
? t.capabilities.connect
: t.common.install}
</Button>
</CapabilityCard>
) : null;
const toolbar = (
<Tabs value={filter} onValueChange={setFilter}>
<TabsList className="bg-muted/50 h-9 rounded-lg">
<TabsTrigger className="rounded-md px-4 text-xs" value="all">
{t.capabilities.allPlugins}
</TabsTrigger>
<TabsTrigger className="rounded-md px-4 text-xs" value="installed">
{t.capabilities.installed}
</TabsTrigger>
</TabsList>
</Tabs>
);
return (
<div className="space-y-6">
<div>
<h2 className="text-base font-semibold">
{t.capabilities.availablePlugins}
</h2>
<p className="text-muted-foreground mt-1.5 text-sm">
{t.capabilities.pluginHint}
</p>
</div>
<MCPPluginManager query={query} toolbar={toolbar}>
{larkCard}
</MCPPluginManager>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
className="max-h-[85vh] overflow-y-auto sm:max-w-3xl"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>{t.capabilities.pluginSettings}</DialogTitle>
</DialogHeader>
<LarkPluginSettings />
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -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 (
<div>
{isLoading ? (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
) : adminRequired ? (
<div className="text-muted-foreground text-sm">
{t.settings.skills.adminRequired}
</div>
) : error ? (
<div>
{t.common.error} {error.message}
</div>
) : (
<SkillList skills={skills} query={query} />
)}
</div>
);
}
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<string | null>(null);
const [filter, setFilter] = useState<string>("public");
const { mutate: enableSkill, isPending: isEnabling } = useEnableSkill();
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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: (
<span className="whitespace-pre-line">
{formatSkillSecurityFindings(error.findings)}
</span>
),
});
} else {
toast.error(
error instanceof Error
? error.message
: t.settings.skills.installFailed,
);
}
}
};
return (
<div className="flex w-full flex-col gap-4">
{exportName &&
isAdmin &&
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
<SkillExportDialog
key={`${user.id}:${exportName}`}
name={exportName}
onClose={() => setExportName(null)}
/>
)}
<div>
<h2 className="text-base font-semibold">
{t.capabilities.availableSkills}
</h2>
<p className="text-muted-foreground mt-1.5 text-sm">
{t.capabilities.skillHint}
</p>
</div>
<header className="mt-2 flex flex-wrap items-center justify-between gap-3">
<Tabs value={filter} onValueChange={setFilter}>
<TabsList className="bg-muted/50 h-9 rounded-lg">
<TabsTrigger value="public" className="rounded-md px-3 text-xs">
{t.capabilities.builtin}
</TabsTrigger>
<TabsTrigger value="community" className="rounded-md px-3 text-xs">
{t.capabilities.community}
</TabsTrigger>
<TabsTrigger value="custom" className="rounded-md px-3 text-xs">
{t.capabilities.custom}
</TabsTrigger>
<TabsTrigger value="all" className="rounded-md px-3 text-xs">
{t.capabilities.allSkills}
</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
accept=".skill"
disabled={isArchiveUploadDisabled}
className="sr-only"
onChange={handleSkillArchive}
/>
{isAdmin && (
<Button
size="sm"
variant="outline"
disabled={isArchiveUploadDisabled}
onClick={() => fileInputRef.current?.click()}
>
{isUploading ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<UploadIcon className="size-4" />
)}
{isUploading
? t.settings.skills.installingArchive
: t.settings.skills.installFromFile}
</Button>
)}
<Button size="sm" onClick={handleCreateSkill}>
<SparklesIcon className="size-4" />
{t.settings.skills.createSkill}
</Button>
</div>
</header>
{query.trim() &&
(filter === "community" || filteredSkills.length === 0) ? (
<div className="text-muted-foreground py-20 text-center text-sm">
{t.capabilities.noResults}
</div>
) : filter === "community" ? (
<Empty className="mt-5 rounded-2xl border border-dashed py-20">
<EmptyHeader>
<EmptyMedia variant="icon">
<SparklesIcon />
</EmptyMedia>
<EmptyTitle>{t.capabilities.communityTitle}</EmptyTitle>
<EmptyDescription>
{t.capabilities.communityDescription}
</EmptyDescription>
</EmptyHeader>
{isAdmin && (
<EmptyContent>
<Button
variant="outline"
disabled={isArchiveUploadDisabled}
onClick={() => fileInputRef.current?.click()}
>
<UploadIcon />
{t.settings.skills.installFromFile}
</Button>
</EmptyContent>
)}
</Empty>
) : filteredSkills.length === 0 ? (
<EmptySkill onCreateSkill={handleCreateSkill} />
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{filteredSkills.map((skill) => {
const presentation = presentSkill(skill, locale);
return (
<CapabilityCard
key={skill.name}
name={presentation.title}
description={presentation.description}
label={sourceLabel(skill)}
icon={
<CapabilityIcon
name={skill.name}
skill
icon={presentation.icon}
/>
}
status={
<>
<span
className={
skill.enabled
? "size-1.5 rounded-full bg-emerald-500"
: "bg-muted-foreground/40 size-1.5 rounded-full"
}
/>
{skill.enabled
? t.capabilities.enabled
: t.capabilities.disabled}
</>
}
onDetails={() => setSelectedSkill(skill)}
detailsLabel={`${t.capabilities.details} ${presentation.title}`}
>
<Button
variant="ghost"
size="sm"
className="h-8 text-xs"
onClick={() => setSelectedSkill(skill)}
>
{t.capabilities.details}
</Button>
<Switch
aria-label={`${t.capabilities.skillEnabled} ${skill.name}`}
checked={skill.enabled}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
!isAdmin ||
isEnabling
}
onCheckedChange={(enabled) =>
enableSkill(
{ skillName: skill.name, enabled },
{ onError: (error) => toast.error(error.message) },
)
}
/>
</CapabilityCard>
);
})}
</div>
)}
<Dialog
open={selectedSkill !== null}
onOpenChange={(open) => !open && setSelectedSkill(null)}
>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
{selectedSkill && (
<>
<DialogHeader>
<CapabilityIcon
name={selectedSkill.name}
skill
icon={presentSkill(selectedSkill, locale).icon}
/>
<DialogTitle className="mt-3">
{presentSkill(selectedSkill, locale).title}
</DialogTitle>
<DialogDescription className="font-mono text-xs">
{selectedSkill.name}
</DialogDescription>
</DialogHeader>
<div className="text-muted-foreground text-sm leading-7 whitespace-pre-wrap">
{selectedSkill.description}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-4">
<span className="text-muted-foreground text-xs">
{selectedSkill.license || sourceLabel(selectedSkill)}
</span>
<div className="flex gap-2">
{isAdmin && selectedSkill.category === "custom" && (
<Button
size="sm"
variant="outline"
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onClick={() => {
setExportName(selectedSkill.name);
setSelectedSkill(null);
}}
>
<DownloadIcon className="size-4" />
{t.settings.skills.exportSkill}
</Button>
)}
</div>
</div>
</>
)}
</DialogContent>
</Dialog>
</div>
);
}
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
const { t } = useI18n();
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<SparklesIcon />
</EmptyMedia>
<EmptyTitle>{t.settings.skills.emptyTitle}</EmptyTitle>
<EmptyDescription>
{t.settings.skills.emptyDescription}
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button onClick={onCreateSkill}>{t.settings.skills.emptyButton}</Button>
</EmptyContent>
</Empty>
);
}

View File

@ -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<string, { title: string; description: string; icon: LucideIcon }>
> = {
"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,
};
}

View File

@ -6,11 +6,8 @@ import {
InfoIcon, InfoIcon,
BrainIcon, BrainIcon,
PaletteIcon, PaletteIcon,
PlugZapIcon,
SparklesIcon,
UsersRoundIcon, UsersRoundIcon,
UserIcon, UserIcon,
WrenchIcon,
} from "lucide-react"; } from "lucide-react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
@ -54,13 +51,6 @@ const ChannelsSettingsPage = dynamic(
), ),
{ loading: SettingsPageLoading }, { loading: SettingsPageLoading },
); );
const IntegrationsSettingsPage = dynamic(
() =>
import("./integrations-settings-page").then(
(module) => module.IntegrationsSettingsPage,
),
{ loading: SettingsPageLoading },
);
const MemorySettingsPage = dynamic( const MemorySettingsPage = dynamic(
() => () =>
import("./memory-settings-page").then( import("./memory-settings-page").then(
@ -75,16 +65,6 @@ const NotificationSettingsPage = dynamic(
), ),
{ loading: SettingsPageLoading }, { 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( const SubagentSettingsPage = dynamic(
() => () =>
import("./subagent-settings-page").then( import("./subagent-settings-page").then(
@ -102,11 +82,8 @@ export type SettingsSection =
| "account" | "account"
| "appearance" | "appearance"
| "channels" | "channels"
| "integrations"
| "memory" | "memory"
| "tools"
| "subagents" | "subagents"
| "skills"
| "notification" | "notification"
| "about"; | "about";
@ -150,34 +127,24 @@ export function SettingsDialog(props: SettingsDialogProps) {
label: t.settings.sections.channels, label: t.settings.sections.channels,
icon: CableIcon, icon: CableIcon,
}, },
{
id: "integrations",
label: t.settings.sections.integrations,
icon: PlugZapIcon,
},
{ {
id: "memory", id: "memory",
label: t.settings.sections.memory, label: t.settings.sections.memory,
icon: BrainIcon, icon: BrainIcon,
}, },
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
{ {
id: "subagents", id: "subagents",
label: t.settings.sections.subagents, label: t.settings.sections.subagents,
icon: UsersRoundIcon, icon: UsersRoundIcon,
}, },
{ id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
{ id: "about", label: t.settings.sections.about, icon: InfoIcon }, { id: "about", label: t.settings.sections.about, icon: InfoIcon },
], ],
[ [
t.settings.sections.account, t.settings.sections.account,
t.settings.sections.appearance, t.settings.sections.appearance,
t.settings.sections.channels, t.settings.sections.channels,
t.settings.sections.integrations,
t.settings.sections.memory, t.settings.sections.memory,
t.settings.sections.tools,
t.settings.sections.subagents, t.settings.sections.subagents,
t.settings.sections.skills,
t.settings.sections.notification, t.settings.sections.notification,
t.settings.sections.about, t.settings.sections.about,
], ],
@ -227,16 +194,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
{activeSection === "account" && <AccountSettingsPage />} {activeSection === "account" && <AccountSettingsPage />}
{activeSection === "appearance" && <AppearanceSettingsPage />} {activeSection === "appearance" && <AppearanceSettingsPage />}
{activeSection === "memory" && <MemorySettingsPage />} {activeSection === "memory" && <MemorySettingsPage />}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "subagents" && <SubagentSettingsPage />} {activeSection === "subagents" && <SubagentSettingsPage />}
{activeSection === "skills" && (
<SkillSettingsPage
onClose={() => props.onOpenChange?.(false)}
/>
)}
{activeSection === "notification" && <NotificationSettingsPage />} {activeSection === "notification" && <NotificationSettingsPage />}
{activeSection === "channels" && <ChannelsSettingsPage />} {activeSection === "channels" && <ChannelsSettingsPage />}
{activeSection === "integrations" && <IntegrationsSettingsPage />}
{activeSection === "about" && <AboutSettingsPage />} {activeSection === "about" && <AboutSettingsPage />}
</div> </div>
</ScrollArea> </ScrollArea>

View File

@ -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 (
<SettingsSection
title={t.settings.skills.title}
description={t.settings.skills.description}
>
{isLoading ? (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
) : adminRequired ? (
<div className="text-muted-foreground text-sm">
{t.settings.skills.adminRequired}
</div>
) : error ? (
<div>
{t.common.error} {error.message}
</div>
) : (
<SkillSettingsList skills={skills} onClose={onClose} />
)}
</SettingsSection>
);
}
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<string | null>(null);
const [filter, setFilter] = useState<string>("public");
const { mutate: enableSkill } = useEnableSkill();
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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: (
<span className="whitespace-pre-line">
{formatSkillSecurityFindings(error.findings)}
</span>
),
});
} else {
toast.error(
error instanceof Error
? error.message
: t.settings.skills.installFailed,
);
}
}
};
return (
<div className="flex w-full flex-col gap-4">
{exportName &&
isAdmin &&
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
<SkillExportDialog
key={`${user.id}:${exportName}`}
name={exportName}
onClose={() => setExportName(null)}
/>
)}
<header className="flex justify-between">
<div className="flex gap-2">
<Tabs value={filter} onValueChange={setFilter}>
<TabsList variant="line">
<TabsTrigger value="public">{t.common.public}</TabsTrigger>
<TabsTrigger value="custom">{t.common.custom}</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
accept=".skill"
disabled={isArchiveUploadDisabled}
className="sr-only"
onChange={handleSkillArchive}
/>
{isAdmin && (
<Button
size="sm"
variant="outline"
disabled={isArchiveUploadDisabled}
onClick={() => fileInputRef.current?.click()}
>
{isUploading ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<UploadIcon className="size-4" />
)}
{isUploading
? t.settings.skills.installingArchive
: t.settings.skills.installFromFile}
</Button>
)}
<Button size="sm" onClick={handleCreateSkill}>
<SparklesIcon className="size-4" />
{t.settings.skills.createSkill}
</Button>
</div>
</header>
{filteredSkills.length === 0 && (
<EmptySkill onCreateSkill={handleCreateSkill} />
)}
{filteredSkills.length > 0 &&
filteredSkills.map((skill) => (
<Item className="w-full" variant="outline" key={skill.name}>
<ItemContent>
<ItemTitle>
<div className="flex items-center gap-2">{skill.name}</div>
</ItemTitle>
<ItemDescription className="line-clamp-4">
{skill.description}
</ItemDescription>
</ItemContent>
<ItemActions>
{isAdmin && skill.category === "custom" && (
<Button
size="sm"
variant="ghost"
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onClick={() => setExportName(skill.name)}
aria-label={`${t.settings.skills.exportSkill} ${skill.name}`}
>
<DownloadIcon className="size-4" />
{t.settings.skills.exportSkill}
</Button>
)}
<Switch
checked={skill.enabled}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || !isAdmin
}
onCheckedChange={(checked) =>
enableSkill({ skillName: skill.name, enabled: checked })
}
/>
</ItemActions>
</Item>
))}
</div>
);
}
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
const { t } = useI18n();
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<SparklesIcon />
</EmptyMedia>
<EmptyTitle>{t.settings.skills.emptyTitle}</EmptyTitle>
<EmptyDescription>
{t.settings.skills.emptyDescription}
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button onClick={onCreateSkill}>{t.settings.skills.emptyButton}</Button>
</EmptyContent>
</Empty>
);
}

View File

@ -1,6 +1,11 @@
"use client"; "use client";
import { BotIcon, CalendarClock, MessagesSquare } from "lucide-react"; import {
BotIcon,
CalendarClock,
MessagesSquare,
BlocksIcon,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
@ -89,6 +94,20 @@ export function WorkspaceNavChatList() {
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/workspace/capabilities")}
asChild
>
<Link
className="text-muted-foreground"
href="/workspace/capabilities"
>
<BlocksIcon />
<span>{t.capabilities.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
); );

View File

@ -13,11 +13,8 @@ const SETTINGS_SECTIONS = new Set<SettingsSection>([
"account", "account",
"appearance", "appearance",
"channels", "channels",
"integrations",
"memory", "memory",
"tools",
"subagents", "subagents",
"skills",
"notification", "notification",
"about", "about",
]); ]);

View File

@ -30,12 +30,12 @@ tools:
## Enabling skills ## 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:** **Via the app UI:**
1. Open the DeerFlow app 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 3. Find `deep-research` and toggle it on
## Using a skill for research ## Using a skill for research

View File

@ -28,12 +28,12 @@ tools:
## 启用技能 ## 启用技能
通过 DeerFlow 应用界面的技能面板启用技能,或直接编辑 `extensions_config.json`。 通过 DeerFlow 应用界面的「能力中心 → 技能」启用技能,或直接编辑 `extensions_config.json`。
示例:启用深度研究技能 示例:启用深度研究技能
1. 打开 DeerFlow 应用 1. 打开 DeerFlow 应用
2. 点击侧边栏中的扩展/技能图标 2. 打开侧边栏的「能力中心」,选择「技能」
3. 找到 `deep-research`,切换为启用 3. 找到 `deep-research`,切换为启用
## 使用技能进行研究 ## 使用技能进行研究

View File

@ -17,6 +17,49 @@ export const enUS: Translations = {
localName: "English", 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
common: { common: {
home: "Home", home: "Home",
@ -965,11 +1008,8 @@ export const enUS: Translations = {
account: "Account", account: "Account",
appearance: "Appearance", appearance: "Appearance",
channels: "Channels", channels: "Channels",
integrations: "Integrations",
memory: "Memory", memory: "Memory",
tools: "Tools",
subagents: "Subagents", subagents: "Subagents",
skills: "Skills",
notification: "Notification", notification: "Notification",
about: "About", about: "About",
}, },
@ -1066,8 +1106,6 @@ export const enUS: Translations = {
languageDescription: "Switch between languages.", languageDescription: "Switch between languages.",
}, },
tools: { tools: {
title: "Tools",
description: "Manage the configuration and enabled status of MCP tools.",
adminRequired: "Admin privileges are required to manage MCP tools.", adminRequired: "Admin privileges are required to manage MCP tools.",
empty: "No MCP tools configured.", empty: "No MCP tools configured.",
addServer: "Add server", addServer: "Add server",
@ -1425,9 +1463,6 @@ export const enUS: Translations = {
exportLimit: "The package exceeds an export limit.", exportLimit: "The package exceeds an export limit.",
exportNotFound: exportNotFound:
"This custom skill no longer exists. Refresh the skill list.", "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", createSkill: "Create skill",
emptyTitle: "No agent skill yet", emptyTitle: "No agent skill yet",
emptyDescription: emptyDescription:

View File

@ -6,6 +6,45 @@ export interface Translations {
localName: string; 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
common: { common: {
home: string; home: string;
@ -818,11 +857,8 @@ export interface Translations {
account: string; account: string;
appearance: string; appearance: string;
channels: string; channels: string;
integrations: string;
memory: string; memory: string;
tools: string;
subagents: string; subagents: string;
skills: string;
notification: string; notification: string;
about: string; about: string;
}; };
@ -912,8 +948,6 @@ export interface Translations {
languageDescription: string; languageDescription: string;
}; };
tools: { tools: {
title: string;
description: string;
adminRequired: string; adminRequired: string;
empty: string; empty: string;
addServer: string; addServer: string;
@ -1123,8 +1157,6 @@ export interface Translations {
exportTimeout: string; exportTimeout: string;
exportLimit: string; exportLimit: string;
exportNotFound: string; exportNotFound: string;
title: string;
description: string;
createSkill: string; createSkill: string;
emptyTitle: string; emptyTitle: string;
emptyDescription: string; emptyDescription: string;

View File

@ -17,6 +17,46 @@ export const zhCN: Translations = {
localName: "中文", 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
common: { common: {
home: "首页", home: "首页",
@ -920,11 +960,8 @@ export const zhCN: Translations = {
account: "账号", account: "账号",
appearance: "外观", appearance: "外观",
channels: "渠道", channels: "渠道",
integrations: "集成",
memory: "记忆", memory: "记忆",
tools: "工具",
subagents: "子智能体", subagents: "子智能体",
skills: "技能",
notification: "通知", notification: "通知",
about: "关于", about: "关于",
}, },
@ -1018,8 +1055,6 @@ export const zhCN: Translations = {
languageDescription: "在不同语言之间切换。", languageDescription: "在不同语言之间切换。",
}, },
tools: { tools: {
title: "工具",
description: "管理 MCP 工具的配置和启用状态。",
adminRequired: "需要管理员权限才能管理 MCP 工具。", adminRequired: "需要管理员权限才能管理 MCP 工具。",
empty: "暂无 MCP 工具。", empty: "暂无 MCP 工具。",
addServer: "添加服务器", addServer: "添加服务器",
@ -1351,8 +1386,6 @@ export const zhCN: Translations = {
exportTimeout: "准备技能包超时,请稍后重试。", exportTimeout: "准备技能包超时,请稍后重试。",
exportLimit: "技能包超出导出的大小、数量或路径限制。", exportLimit: "技能包超出导出的大小、数量或路径限制。",
exportNotFound: "此自定义技能已不存在,请刷新技能列表。", exportNotFound: "此自定义技能已不存在,请刷新技能列表。",
title: "技能",
description: "管理 Agent Skill 配置和启用状态。",
createSkill: "新建技能", createSkill: "新建技能",
emptyTitle: "还没有技能", emptyTitle: "还没有技能",
emptyDescription: emptyDescription:

View File

@ -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();
});

View File

@ -36,14 +36,14 @@ function configuredLarkStatus() {
} }
test.describe("Integrations settings", () => { 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, page,
}) => { }) => {
mockLangGraphAPI(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).toBeVisible();
await expect(dialog.getByText("Lark / Feishu CLI")).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"); await page.goto("/workspace/capabilities?tab=plugins&plugin=lark");
const dialog = page.getByRole("dialog", { name: "Settings" }); const dialog = page.getByRole("dialog", { name: "Plugin settings" });
const popupPromise = page.waitForEvent("popup"); const popupPromise = page.waitForEvent("popup");
await dialog.getByRole("button", { name: "Connect Lark" }).click(); await dialog.getByRole("button", { name: "Connect Lark" }).click();
const popup = await popupPromise; const popup = await popupPromise;
@ -141,23 +141,27 @@ test.describe("Integrations settings", () => {
await expect(page.getByText("Copied to clipboard")).toBeVisible(); 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, page,
}) => { }) => {
mockLangGraphAPI(page); mockLangGraphAPI(page);
// Deep link opens the shared dialog on Integrations. // Deep link opens the shared dialog on Integrations.
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).toBeVisible();
await expect(dialog.getByText("Lark / Feishu CLI")).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 // Close the modal before using the sidebar. While the modal is open, the
// background is intentionally inert and Playwright should not be able to // background is intentionally inert and Playwright should not be able to
// click sidebar controls there. // click sidebar controls there.
await page.keyboard.press("Escape"); 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 // Opening again from the nav menu must still use the same shared host, not
// mount a second SettingsDialog instance. // mount a second SettingsDialog instance.
@ -165,11 +169,15 @@ test.describe("Integrations settings", () => {
await sidebar.getByRole("button", { name: /Settings and more/ }).click(); await sidebar.getByRole("button", { name: /Settings and more/ }).click();
await page.getByRole("menuitem", { name: "Settings" }).click(); await page.getByRole("menuitem", { name: "Settings" }).click();
// Exactly one Settings dialog is mounted/visible at any time. await expect(
await expect(page.getByRole("dialog", { name: "Settings" })).toHaveCount(1); 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, page,
}) => { }) => {
mockLangGraphAPI(page); mockLangGraphAPI(page);
@ -224,15 +232,9 @@ test.describe("Integrations settings", () => {
}); });
}); });
await page.goto("/workspace/chats/new"); await page.goto("/workspace/capabilities?tab=plugins&plugin=lark");
const dialog = page.getByRole("dialog", { name: "Plugin settings" });
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 expect(dialog).toBeVisible(); await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "Integrations" }).click();
await expect(dialog.getByText("Lark / Feishu CLI")).toBeVisible(); await expect(dialog.getByText("Lark / Feishu CLI")).toBeVisible();
await expect( 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).toBeVisible();
await expect(dialog.getByText("Lark / Feishu CLI")).toBeVisible(); await expect(dialog.getByText("Lark / Feishu CLI")).toBeVisible();
@ -426,8 +428,8 @@ test.describe("Integrations settings", () => {
await route.fallback(); await route.fallback();
}); });
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 dialog.getByRole("button", { name: "Calendar" }).click(); await dialog.getByRole("button", { name: "Calendar" }).click();
await dialog await dialog
.getByLabel("Exact OAuth scope") .getByLabel("Exact OAuth scope")

View File

@ -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" }); const settingsDialog = page;
await expect(settingsDialog).toBeVisible(); await expect(page).toHaveURL(/workspace\/capabilities$/);
await settingsDialog.getByRole("button", { name: "Edit remote" }).click(); await settingsDialog.getByRole("button", { name: "Edit remote" }).click();
const editor = page.getByRole("dialog", { name: "Edit MCP server" }); const editor = page.getByRole("dialog", { name: "Edit MCP server" });

View File

@ -58,12 +58,21 @@ test("custom export previews, handles stale content and downloads only after ref
body: Buffer.from("synthetic transport fixture"), 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( await expect(
page.getByRole("button", { name: "Export public-demo" }), page
.getByRole("dialog")
.getByRole("button", { name: "Export", exact: true }),
).toHaveCount(0); ).toHaveCount(0);
await page.getByRole("tab", { name: "Custom", exact: true }).click(); await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Export demo", exact: true }).click(); 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", { const dialog = page.getByRole("dialog", {
name: "Export skill", name: "Export skill",
exact: true, exact: true,
@ -94,7 +103,7 @@ test("custom export previews, handles stale content and downloads only after ref
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await expect(dialog).toBeHidden(); await expect(dialog).toBeHidden();
await expect( await expect(
page.getByRole("dialog", { name: "Settings", exact: true }), page.getByRole("heading", { name: "Capability Center", exact: true }),
).toBeVisible(); ).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.goto("/workspace/capabilities?tab=skills");
await page.getByRole("tab", { name: "Custom", exact: true }).click(); await page.getByRole("tab", { name: "My skills", exact: true }).click();
await page.getByRole("button", { name: "Export demo", 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", { const dialog = page.getByRole("dialog", {
name: "Export skill", name: "Export skill",
exact: true, exact: true,

View File

@ -1407,7 +1407,11 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
return route.fallback(); 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) => { void page.route("**/api/skills", (route) => {
if (route.request().method() === "GET") { if (route.request().method() === "GET") {
return route.fulfill({ return route.fulfill({

View File

@ -1,10 +1,11 @@
import { afterEach, describe, expect, it, rs } from "@rstest/core"; import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { cleanup, fireEvent, render, screen } from "@testing-library/react"; 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(() => ({ const mcpMockState = rs.hoisted(() => ({
isPending: false, isPending: false,
isLoading: false,
error: null as Error | null, error: null as Error | null,
mutate: rs.fn(), mutate: rs.fn(),
updateIsPending: false, updateIsPending: false,
@ -27,6 +28,14 @@ const DURABLE_TASK_SERVER = {
rs.mock("@/core/i18n/hooks", () => ({ rs.mock("@/core/i18n/hooks", () => ({
useI18n: () => ({ useI18n: () => ({
t: { t: {
capabilities: {
enabled: "Enabled",
disabled: "Disabled",
details: "View details",
addPlugin: "Add server",
mcpLabel: "MCP",
mcpDescription: "MCP tools",
},
common: { common: {
error: "Error:", error: "Error:",
loading: "Loading", loading: "Loading",
@ -37,8 +46,6 @@ rs.mock("@/core/i18n/hooks", () => ({
}, },
settings: { settings: {
tools: { tools: {
title: "Tools",
description: "Manage MCP tools",
adminRequired: "Admin required", adminRequired: "Admin required",
empty: "No tools", empty: "No tools",
addServer: "Add server", addServer: "Add server",
@ -68,7 +75,7 @@ rs.mock("@/core/i18n/hooks", () => ({
rs.mock("@/core/mcp/hooks", () => ({ rs.mock("@/core/mcp/hooks", () => ({
useMCPConfig: () => ({ useMCPConfig: () => ({
config: { mcp_servers: mcpMockState.servers }, config: { mcp_servers: mcpMockState.servers },
isLoading: false, isLoading: mcpMockState.isLoading,
error: mcpMockState.error, error: mcpMockState.error,
}), }),
useEnableMCPServer: () => ({ useEnableMCPServer: () => ({
@ -120,6 +127,7 @@ function definitionTextbox(): HTMLTextAreaElement {
afterEach(() => { afterEach(() => {
mcpMockState.isPending = false; mcpMockState.isPending = false;
mcpMockState.isLoading = false;
mcpMockState.error = null; mcpMockState.error = null;
mcpMockState.updateIsPending = false; mcpMockState.updateIsPending = false;
mcpMockState.mutate.mockReset(); mcpMockState.mutate.mockReset();
@ -128,11 +136,30 @@ afterEach(() => {
cleanup(); 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(
<MCPPluginManager toolbar={<button>All plugins</button>}>
<button>Configure Lark</button>
</MCPPluginManager>,
);
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", () => { it("renders a localized load error", () => {
mcpMockState.error = new Error("request failed"); mcpMockState.error = new Error("request failed");
render(<ToolSettingsPage />); render(<MCPPluginManager />);
expect(screen.getByText("Error: request failed")).toBeDefined(); expect(screen.getByText("Error: request failed")).toBeDefined();
}); });
@ -141,7 +168,7 @@ describe("ToolSettingsPage MCP switches", () => {
twoServers(); twoServers();
mcpMockState.isPending = true; mcpMockState.isPending = true;
render(<ToolSettingsPage />); render(<MCPPluginManager />);
const switches = screen.getAllByRole("switch"); const switches = screen.getAllByRole("switch");
expect(switches).toHaveLength(2); expect(switches).toHaveLength(2);
@ -153,7 +180,7 @@ describe("ToolSettingsPage MCP switches", () => {
it("submits only the selected server state when idle", () => { it("submits only the selected server state when idle", () => {
twoServers(); twoServers();
render(<ToolSettingsPage />); render(<MCPPluginManager />);
const switches = screen.getAllByRole("switch"); const switches = screen.getAllByRole("switch");
const githubSwitch = switches[0]; 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", () => { it("submits only the pasted servers to the atomic create endpoint", () => {
twoServers(); twoServers();
render(<ToolSettingsPage />); render(<MCPPluginManager />);
openAddDialog(); openAddDialog();
fireEvent.change(screen.getByRole("textbox"), { fireEvent.change(screen.getByRole("textbox"), {
target: { target: {
@ -193,7 +220,7 @@ describe("ToolSettingsPage add server", () => {
it("does not submit stale sibling configurations while adding", () => { it("does not submit stale sibling configurations while adding", () => {
twoServers(); twoServers();
render(<ToolSettingsPage />); render(<MCPPluginManager />);
openAddDialog(); openAddDialog();
fireEvent.change(screen.getByRole("textbox"), { fireEvent.change(screen.getByRole("textbox"), {
target: { value: '{"added": {"command": "uvx"}}' }, target: { value: '{"added": {"command": "uvx"}}' },
@ -211,7 +238,7 @@ describe("ToolSettingsPage add server", () => {
it("reports a malformed definition without writing", () => { it("reports a malformed definition without writing", () => {
twoServers(); twoServers();
render(<ToolSettingsPage />); render(<MCPPluginManager />);
openAddDialog(); openAddDialog();
fireEvent.change(screen.getByRole("textbox"), { fireEvent.change(screen.getByRole("textbox"), {
target: { value: "{not json" }, target: { value: "{not json" },
@ -228,7 +255,7 @@ describe("ToolSettingsPage add server", () => {
it("offers the add action when no server is configured yet", () => { it("offers the add action when no server is configured yet", () => {
setServers({}); setServers({});
render(<ToolSettingsPage />); render(<MCPPluginManager />);
expect(screen.getByText("No tools")).toBeDefined(); expect(screen.getByText("No tools")).toBeDefined();
expect( expect(
@ -241,7 +268,7 @@ describe("ToolSettingsPage add server", () => {
it("rejects an existing name instead of silently replacing it", () => { it("rejects an existing name instead of silently replacing it", () => {
twoServers(); twoServers();
render(<ToolSettingsPage />); render(<MCPPluginManager />);
openAddDialog(); openAddDialog();
fireEvent.change(screen.getByRole("textbox"), { fireEvent.change(screen.getByRole("textbox"), {
target: { value: '{"github": {"command": "uvx"}}' }, 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", () => { it("prefills the complete server definition", () => {
twoServers(); twoServers();
render(<ToolSettingsPage />); render(<MCPPluginManager />);
openEditDialog("remote"); openEditDialog("remote");
const definition = JSON.parse(definitionTextbox().value) as { 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", () => { it("updates only one server while preserving all of its hidden fields", () => {
twoServers(); twoServers();
render(<ToolSettingsPage />); render(<MCPPluginManager />);
openEditDialog("remote"); openEditDialog("remote");
const textbox = definitionTextbox(); const textbox = definitionTextbox();
const definition = JSON.parse(textbox.value) as { const definition = JSON.parse(textbox.value) as {
@ -299,7 +326,7 @@ describe("ToolSettingsPage edit server", () => {
it("rejects renaming through the edit dialog", () => { it("rejects renaming through the edit dialog", () => {
twoServers(); twoServers();
render(<ToolSettingsPage />); render(<MCPPluginManager />);
openEditDialog("github"); openEditDialog("github");
fireEvent.change(screen.getByRole("textbox"), { fireEvent.change(screen.getByRole("textbox"), {
target: { target: {
@ -315,7 +342,7 @@ describe("ToolSettingsPage edit server", () => {
it("rejects editing multiple servers at once", () => { it("rejects editing multiple servers at once", () => {
twoServers(); twoServers();
render(<ToolSettingsPage />); render(<MCPPluginManager />);
openEditDialog("github"); openEditDialog("github");
fireEvent.change(screen.getByRole("textbox"), { fireEvent.change(screen.getByRole("textbox"), {
target: { target: {
@ -330,11 +357,11 @@ describe("ToolSettingsPage edit server", () => {
}); });
}); });
describe("ToolSettingsPage remove server", () => { describe("MCPPluginManager remove server", () => {
it("submits only the selected server name", () => { it("submits only the selected server name", () => {
twoServers(); twoServers();
render(<ToolSettingsPage />); render(<MCPPluginManager />);
fireEvent.click(screen.getByRole("button", { name: "Delete github" })); fireEvent.click(screen.getByRole("button", { name: "Delete github" }));
fireEvent.click(screen.getByRole("button", { name: "Delete" })); fireEvent.click(screen.getByRole("button", { name: "Delete" }));
@ -347,7 +374,7 @@ describe("ToolSettingsPage remove server", () => {
it("does not write when the confirmation is dismissed", () => { it("does not write when the confirmation is dismissed", () => {
twoServers(); twoServers();
render(<ToolSettingsPage />); render(<MCPPluginManager />);
fireEvent.click(screen.getByRole("button", { name: "Delete github" })); fireEvent.click(screen.getByRole("button", { name: "Delete github" }));
fireEvent.click(screen.getByRole("button", { name: "Cancel" })); fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
@ -357,7 +384,7 @@ describe("ToolSettingsPage remove server", () => {
it("deletes a configured server whose name is empty", () => { it("deletes a configured server whose name is empty", () => {
setServers({ "": { enabled: false, description: "Legacy server" } }); setServers({ "": { enabled: false, description: "Legacy server" } });
render(<ToolSettingsPage />); render(<MCPPluginManager />);
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "Delete (empty name)" }), screen.getByRole("button", { name: "Delete (empty name)" }),
); );

View File

@ -30,7 +30,7 @@ rs.mock("@/core/skills/export", () => ({
rs.mock("@/core/i18n/hooks", () => ({ rs.mock("@/core/i18n/hooks", () => ({
useI18n: () => ({ locale: "en-US", t: enUS }), 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 { enUS } from "@/core/i18n/locales/en-US";
import { import {
SkillExportRequestError, SkillExportRequestError,

View File

@ -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,
});
},
);
});

View File

@ -23,7 +23,7 @@ describe("interaction-only bundle boundaries", () => {
const dialog = read( const dialog = read(
"src/components/workspace/settings/settings-dialog.tsx", "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( expect(dialog).not.toMatch(
/import \{ \w+SettingsPage \} from "@\/components\/workspace\/settings\//, /import \{ \w+SettingsPage \} from "@\/components\/workspace\/settings\//,
); );

View File

@ -20,10 +20,10 @@ test("starts closed on the default section", () => {
}); });
test("openSettingsDialog opens on the requested section", () => { test("openSettingsDialog opens on the requested section", () => {
openSettingsDialog("integrations"); openSettingsDialog("notification");
expect(getSettingsDialogSnapshot()).toEqual({ expect(getSettingsDialogSnapshot()).toEqual({
open: true, open: true,
section: "integrations", section: "notification",
}); });
}); });
@ -42,9 +42,9 @@ test("notifies subscribers only on real state changes", () => {
notifications += 1; notifications += 1;
}); });
openSettingsDialog("integrations"); openSettingsDialog("notification");
// Opening again on the same section is a no-op and must not re-notify. // Opening again on the same section is a no-op and must not re-notify.
openSettingsDialog("integrations"); openSettingsDialog("notification");
expect(notifications).toBe(1); expect(notifications).toBe(1);
openSettingsDialog("memory"); openSettingsDialog("memory");