Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

## [Unreleased]

### 新增
- 专家:发布专家模板时新增「允许查看技能详情」开关;关闭后,安装者只能使用技能,无法查看或修改技能内容(发布者与管理员除外)

## [0.9.24] - 2026-08-15

### 新增
Expand Down
5 changes: 5 additions & 0 deletions dashboard/src/api/modules/publishedExperts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ describe("publishedExpertsApi", () => {
"/experts/published/expert%2F1/refresh",
{
method: "POST",
body: JSON.stringify({
name: "Updated",
description: "New description",
welcome_message: { zh: "欢迎", en: "Welcome" },
}),
},
);
expect(request).toHaveBeenNthCalledWith(
Expand Down
4 changes: 4 additions & 0 deletions dashboard/src/api/modules/publishedExperts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@ export interface PublishedExpert {
color: string | null;
created_at: string;
updated_at: string;
/** When false, installers can use the expert's skills but not view or
* edit their contents. */
allow_skill_details?: boolean;
}

export interface PublishExpertBody {
name: string;
description?: string;
slug?: string;
welcome_message?: { zh?: string; en?: string };
allow_skill_details?: boolean;
}

export type RefreshPublishedExpertBody = PublishExpertBody;
Expand Down
14 changes: 11 additions & 3 deletions dashboard/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
"TLS_DOMAIN_MISMATCH": "The domain does not match the existing certificate.",
"WORKSPACE_OP_UNSUPPORTED": "This workspace backend does not support that file operation.",
"SKILL_IMPORT_UNSUPPORTED_URL": "This skill URL source is not supported.",
"SKILL_DETAILS_PROTECTED": "The publisher of this expert has disabled viewing or editing its skill contents.",
"SKILL_IMPORT_FAILED": "Skill import failed: {{reason}}",
"SKILL_ALREADY_EXISTS": "Skill {{name}} already exists. Enable overwrite to replace it.",
"SKILL_PACKAGE_NOT_FOUND": "Skill package not found.",
Expand Down Expand Up @@ -594,7 +595,10 @@
"fieldDescription": "Description",
"fieldWelcomeZh": "Welcome message (Chinese)",
"fieldWelcomeEn": "Welcome message (English)",
"fieldWelcomePlaceholder": "One-line greeting shown on the chat welcome screen"
"fieldWelcomePlaceholder": "One-line greeting shown on the chat welcome screen",
"fieldAllowSkillDetails": "Allow installers to view skill details",
"allowSkillDetailsHint": "When off, users who install this template can use its skills but cannot view or edit skill contents.",
"skillsProtected": "Skill details locked after install"
},
"noExperts": "No experts available",
"selectExpertHint": "Pick an expert on the left to view details",
Expand Down Expand Up @@ -1056,6 +1060,8 @@
"dockFileListCount": "{{count}} files",
"dockFileListHint": "This list only shows files produced during execution. They are not guaranteed to be kept, and the model may delete them after processing finishes.",
"dockFileMaybeDeleted": "This file may have been a temporary artifact during processing and has already been deleted.",
"dockFileDelete": "Delete file",
"dockFileDeleteConfirm": "Delete this file? This cannot be undone.",
"remoteBrowserTitle": "Remote Browser",
"dockTerminalTitle": "Terminal",
"loadEarlierMessages": "Load earlier messages"
Expand Down Expand Up @@ -1298,13 +1304,15 @@
"skillContent": "Skill Content",
"pleaseInputName": "Please input skill name",
"pleaseInputContent": "Please input skill content",
"skillNamePlaceholder": "e.g., weather_query",
"skillNamePlaceholder": "e.g., weather-analysis / 天气查询",
"contentPlaceholder": "---\nname: <skill_name> (required)\ndescription: <skill description> (required)\nmetadata: { \"octop\": { \"emoji\": \"🔧\" } }\n---\n\nSkill implementation content...\n\n# Example:\n# ---\n# name: cron\n# description: Manage cron jobs via octop commands - create, query, pause, resume, delete tasks\n# metadata: { \"octop\": { \"emoji\": \"⏰\" } }\n# ---",
"createSuccess": "Skill created successfully",
"importSuccess": "Skill imported successfully",
"importFailed": "Failed to import skill",
"loadFailed": "Failed to load skills",
"loadDetailFailed": "Failed to load skill details",
"protectedTag": "Protected",
"protectedHint": "This skill is protected by the expert publisher. It can be used but its contents cannot be viewed or edited.",
"enabledSuccess": "Skill enabled",
"disabledSuccess": "Skill disabled",
"operationFailed": "Operation failed",
Expand Down Expand Up @@ -1365,7 +1373,7 @@
"disableBeforeDelete": "Disable the skill before deleting it",
"applyNow": "Apply now",
"nameLabel": "Name",
"namePattern": "Only letters / digits / . _ - are allowed",
"namePattern": "Unicode names (e.g. Chinese) are allowed, but not / \\ : * ? \" < > |, a leading dot, or over 64 characters",
"sourceLabel": "Source",
"pathLabel": "Path",
"metadataLabel": "Metadata",
Expand Down
14 changes: 11 additions & 3 deletions dashboard/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
"TLS_DOMAIN_MISMATCH": "域名与现有证书不一致。",
"WORKSPACE_OP_UNSUPPORTED": "当前工作区后端不支持该文件操作。",
"SKILL_IMPORT_UNSUPPORTED_URL": "不支持该技能 URL 来源。",
"SKILL_DETAILS_PROTECTED": "该专家的发布者未开放技能内容的查看与修改权限。",
"SKILL_IMPORT_FAILED": "技能导入失败:{{reason}}",
"SKILL_ALREADY_EXISTS": "技能 {{name}} 已存在,如需覆盖请启用 overwrite。",
"SKILL_PACKAGE_NOT_FOUND": "未找到技能包。",
Expand Down Expand Up @@ -594,7 +595,10 @@
"fieldDescription": "模板描述",
"fieldWelcomeZh": "中文引导语",
"fieldWelcomeEn": "英文引导语",
"fieldWelcomePlaceholder": "聊天欢迎页展示的一行引导语"
"fieldWelcomePlaceholder": "聊天欢迎页展示的一行引导语",
"fieldAllowSkillDetails": "允许安装者查看技能详情",
"allowSkillDetailsHint": "关闭后,安装该模板的用户只能使用其中的技能,无法查看或修改技能内容。",
"skillsProtected": "安装后技能详情不可查看"
},
"noExperts": "暂无可用专家",
"selectExpertHint": "请从左侧选择一个专家以查看详情",
Expand Down Expand Up @@ -1055,6 +1059,8 @@
"dockFileListCount": "{{count}} 个文件",
"dockFileListHint": "当前仅列出执行过程中生成的文件,不代表最终一定存储,可能在处理结束后被大模型删除。",
"dockFileMaybeDeleted": "该文件可能为处理过程中的临时文件,当前已经被删除。",
"dockFileDelete": "删除文件",
"dockFileDeleteConfirm": "确定删除该文件吗?删除后不可恢复。",
"remoteBrowserTitle": "远程浏览器",
"dockTerminalTitle": "终端",
"loadEarlierMessages": "加载更早的消息"
Expand Down Expand Up @@ -1296,13 +1302,15 @@
"skillContent": "技能内容",
"pleaseInputName": "请输入技能名称",
"pleaseInputContent": "请输入技能内容",
"skillNamePlaceholder": "例如:weather_query",
"skillNamePlaceholder": "例如:天气查询 / weather-query",
"contentPlaceholder": "---\nname: <技能名称>(必填)\ndescription: <技能功能描述>(必填)\nmetadata: { \"octop\": { \"emoji\": \"🔧\" } }\n---\n\n技能实现内容...\n\n# 示例:\n# ---\n# name: cron\n# description: 通过 octop 命令管理定时任务 - 创建、查询、暂停、恢复、删除任务\n# metadata: { \"octop\": { \"emoji\": \"⏰\" } }\n# ---",
"createSuccess": "技能创建成功",
"importSuccess": "技能导入成功",
"importFailed": "技能导入失败",
"loadFailed": "加载技能失败",
"loadDetailFailed": "加载技能详情失败",
"protectedTag": "已保护",
"protectedHint": "该技能由专家模板发布者保护,仅可使用,无法查看或修改内容。",
"enabledSuccess": "技能已启用",
"disabledSuccess": "技能已禁用",
"operationFailed": "操作失败",
Expand Down Expand Up @@ -1363,7 +1371,7 @@
"disableBeforeDelete": "请先禁用该技能后再删除",
"applyNow": "立即应用",
"nameLabel": "名称",
"namePattern": "仅支持字母 / 数字 / . _ -",
"namePattern": "名称支持中文等 Unicode 字符,但不能包含 / \\ : * ? \" < > | 等特殊字符,且不能以 . 开头(最长 64 字符)",
"sourceLabel": "来源",
"pathLabel": "路径",
"metadataLabel": "Metadata",
Expand Down
16 changes: 14 additions & 2 deletions dashboard/src/pages/Agent/Skills/components/InstalledSkillsTab.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useMemo, useState, useCallback } from "react";
import { Form, Segmented, Tooltip } from "antd";
import { message } from "@/utils/antdMessage";
import { Download, LayoutGrid, List, Plus, RefreshCw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useAgent } from "../../../../context/AgentContext";
Expand Down Expand Up @@ -117,6 +118,10 @@ export default function InstalledSkillsTab({
};

const handleEdit = async (skill: SkillSpec) => {
if (skill.protected) {
message.warning(t("skills.protectedHint"));
return;
}
const detail = await getDetail(skill.slug);
if (detail) {
setEditingSkill(detail);
Expand Down Expand Up @@ -180,7 +185,9 @@ export default function InstalledSkillsTab({
onMouseLeave={() => setHoverKey(null)}
onToggleEnabled={(e) => void handleToggleEnabled(skill, e)}
onDelete={
kind === "custom" ? (e) => void handleDelete(skill, e) : undefined
kind === "custom" && !skill.protected
? (e) => void handleDelete(skill, e)
: undefined
}
/>
))}
Expand All @@ -192,7 +199,12 @@ export default function InstalledSkillsTab({
onView={(skill) => void handleEdit(skill)}
onToggleEnabled={(skill) => void handleToggleEnabled(skill)}
onDelete={
kind === "custom" ? (skill) => void handleDelete(skill) : undefined
kind === "custom"
? (skill) => {
if (skill.protected) return;
void handleDelete(skill);
}
: undefined
}
/>
);
Expand Down
7 changes: 7 additions & 0 deletions dashboard/src/pages/Agent/Skills/components/SkillCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
FileText,
Image,
Info,
Lock,
Presentation,
Sparkles,
Trash2,
Expand Down Expand Up @@ -251,6 +252,12 @@ export function SkillCard({
<div className={styles.cardMeta}>
<div className={styles.cardTitle}>{displayName}</div>
<div className={styles.cardBadges}>
{skill.protected && (
<span className={styles.builtinBadge}>
<Lock size={11} style={{ verticalAlign: "-1px" }} />{" "}
{t("skills.protectedTag")}
</span>
)}
{isBuiltin && (
<span className={styles.builtinBadge}>
{t("skills.kindBuiltin")}
Expand Down
18 changes: 18 additions & 0 deletions dashboard/src/pages/Agent/Skills/components/SkillDrawer.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
import { describe, expect, it } from "vitest";
import {
buildSkillMarkdown,
isValidSkillName,
OCTOP_EMOJI_META_KEY,
parseSkillEmojiAndMetadata,
} from "./SkillDrawer";

describe("isValidSkillName", () => {
it("accepts CJK, letters, digits and . _ -", () => {
expect(isValidSkillName("天气查询")).toBe(true);
expect(isValidSkillName("weather-analysis")).toBe(true);
expect(isValidSkillName("weather_query.v2")).toBe(true);
});

it("rejects filesystem-hostile characters and leading dot", () => {
expect(isValidSkillName(".hidden")).toBe(false);
expect(isValidSkillName("a/b")).toBe(false);
expect(isValidSkillName("a\\b")).toBe(false);
expect(isValidSkillName('a:b*c?d"e<f>g|h')).toBe(false);
expect(isValidSkillName("")).toBe(false);
expect(isValidSkillName("x".repeat(65))).toBe(false);
});
});

describe("SkillDrawer emoji metadata", () => {
it("writes octop.emoji into frontmatter from the emoji field", () => {
const md = buildSkillMarkdown({
Expand Down
33 changes: 29 additions & 4 deletions dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ export interface SkillFormValues {

export const OCTOP_EMOJI_META_KEY = "octop.emoji";

/**
* The skill name doubles as the workspace directory slug, so only
* filesystem-hostile characters are rejected - CJK and other Unicode
* letters are allowed.
*/
const SKILL_NAME_PATTERN = /^(?!\.)[^\\/:*?"<>|\x00-\x1f]{1,64}$/;

export function isValidSkillName(name: string): boolean {
return SKILL_NAME_PATTERN.test(name.trim());
}

function yamlQuote(value: string): string {
if (!value) return '""';
if (/[:#\n"'{}[\],&*?|>!%@`]/.test(value) || value.trim() !== value) {
Expand Down Expand Up @@ -401,9 +412,16 @@ export function SkillDrawer({
}
const { body } = splitMarkdownFrontmatter(content);
const fm = splitMarkdownFrontmatter(content).raw ?? "";
const name = yamlTopLevel(fm, "name") || values.name;
// Edits keep the existing slug (updateSkill ignores the name), so only
// creation needs the slug check - legacy skills with odd names stay editable.
if (isCreate && !isValidSkillName(name)) {
message.warning(t("skills.namePattern"));
return;
}
onSubmit({
...values,
name: yamlTopLevel(fm, "name") || values.name,
name,
description: yamlTopLevel(fm, "description") || values.description,
body,
content,
Expand Down Expand Up @@ -431,8 +449,13 @@ export function SkillDrawer({
? [
{ required: true, message: t("skills.pleaseInputName") },
{
pattern: /^[a-zA-Z0-9._-]+$/,
message: t("skills.namePattern"),
validator: (_: unknown, value: string) => {
// Empty is reported by the required rule above.
if (!String(value ?? "").trim()) return Promise.resolve();
return isValidSkillName(String(value))
? Promise.resolve()
: Promise.reject(new Error(t("skills.namePattern")));
},
},
]
: undefined
Expand Down Expand Up @@ -791,7 +814,9 @@ export function SkillDrawer({
) : (
<>
<Button onClick={onClose}>{t("common.close")}</Button>
{editingSkill?.kind === "workspace" && viewingSkillMd ? (
{editingSkill?.kind === "workspace" &&
viewingSkillMd &&
!editingSkill?.protected ? (
<Button
type="primary"
onClick={() => {
Expand Down
20 changes: 16 additions & 4 deletions dashboard/src/pages/Agent/Skills/components/SkillsTable.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Popconfirm, Switch, Table, Tag } from "antd";
import { Popconfirm, Switch, Table, Tag, Tooltip } from "antd";
import type { ColumnsType } from "antd/es/table";
import { Trash2 } from "lucide-react";
import { Lock, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SkillSpec } from "../useSkills";
import { useSkillDisplayName } from "../skillDisplayNames";
Expand Down Expand Up @@ -30,7 +30,19 @@ export default function SkillsTable({
dataIndex: "name",
width: "18%",
ellipsis: true,
render: (_name: string, row) => skillDisplayName(row),
render: (_name: string, row) => (
<span>
{skillDisplayName(row)}
{row.protected ? (
<Tooltip title={t("skills.protectedHint")}>
<Lock
size={12}
style={{ marginLeft: 6, verticalAlign: "-2px", opacity: 0.6 }}
/>
</Tooltip>
) : null}
</span>
),
},
{
title: t("skills.skillDescription"),
Expand Down Expand Up @@ -85,7 +97,7 @@ export default function SkillsTable({
>
{t("common.view")}
</button>
{kind === "custom" && onDelete ? (
{kind === "custom" && onDelete && !row.protected ? (
<Popconfirm
title={t("skills.deleteConfirmContent", { slug: row.slug })}
okText={t("common.delete")}
Expand Down
4 changes: 4 additions & 0 deletions dashboard/src/pages/Agent/Skills/useSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export interface SkillSpec {
emoji?: string;
/** Persisted SkillHub marketplace icon. */
iconUrl?: string;
/** True when the expert publisher disabled viewing this skill's details. */
protected?: boolean;
}

export interface SkillDetail extends SkillSpec {
Expand All @@ -54,6 +56,7 @@ interface ServerSummary {
kind?: "workspace" | "builtin" | "package";
emoji?: string;
icon_url?: string;
protected?: boolean;
}

interface ServerDetail extends ServerSummary {
Expand All @@ -77,6 +80,7 @@ const toSpec = (row: ServerSummary): SkillSpec => ({
: "workspace",
emoji: row.emoji,
iconUrl: row.icon_url,
protected: row.protected,
});

const toDetail = (row: ServerDetail): SkillDetail => ({
Expand Down
Loading
Loading