+
+ setEnterpriseRefreshRevision((revision) => revision + 1)} />
{resourceScope !== "personal" ? (
{t("enterprise_connection.enterprise_extensions_empty")}
)
- ) : props.activeTab === "skills" ? props.skillsView : props.pluginPackagesView}
+ ) : props.pluginPackagesView}
);
}
diff --git a/apps/app/src/react-app/domains/settings/pages/skills-view.tsx b/apps/app/src/react-app/domains/settings/pages/skills-view.tsx
index 386e3c28e..0f509cca1 100644
--- a/apps/app/src/react-app/domains/settings/pages/skills-view.tsx
+++ b/apps/app/src/react-app/domains/settings/pages/skills-view.tsx
@@ -1,91 +1,25 @@
/** @jsxImportSource react */
-import {
- forwardRef,
- useCallback,
- useEffect,
- useImperativeHandle,
- useMemo,
- useReducer,
- type SetStateAction,
-} from "react";
-import {
- Cloud,
- Download,
- Edit2,
- Loader2,
- Package,
- Plus,
- RefreshCw,
- Search,
- Sparkles,
- Trash2,
- Users,
-} from "lucide-react";
-import { toast } from "@/components/ui/sonner";
-
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { t } from "@/i18n";
-import { saveInstalledSkillToiPolloWorkOrg } from "@/app/lib/den-skills";
-import {
- buildDenAuthUrl,
- DEFAULT_DEN_BASE_URL,
- readDenSettings,
-} from "@/app/lib/den";
-import type {
- DenOrgSkillCard,
- HubSkillCard,
- HubSkillRepo,
- SkillCard,
-} from "@/app/types";
-import {
- modalNoticeErrorClass,
- modalNoticeSuccessClass,
- pillGhostClass,
- pillPrimaryClass,
- pillSecondaryClass,
- surfaceCardClass,
- tagClass,
-} from "@/react-app/domains/workspace/modal-styles";
-import { Button } from "@/components/ui/button";
+import { useLocation } from "react-router-dom";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { settingsPageTitleClass, settingsPageDescriptionClass } from "@/react-app/domains/settings/shell/panel";
+import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
+import { Building2, Cloud, Download, Loader2, Plus, RefreshCw, Search, Sparkles, User } from "lucide-react";
+import type { DenOrgSkillCard, HubSkillCard, HubSkillRepo, SkillCard } from "@/app/types";
+import type { InstalledEnterpriseSkill } from "@ipollowork/types/skills";
+import type { iPolloWorkServerClient, StandaloneSkillPackage } from "@/app/lib/ipollowork-server";
+import { downloadBlobAsFile } from "@/app/lib/download";
+import { downloadEnterpriseResource, listEnterpriseResources, type EnterpriseResource } from "@/app/lib/enterprise-connections";
+import { enterpriseApi, enterpriseErrorMessage, enterpriseSkillContext } from "@/app/lib/enterprise-client";
+import { downloadPublicSkill, listPublicSkills, skillFileDigest, type PublicSkill } from "@/app/lib/skill-catalog";
+import { EnterpriseResourceControls, useActiveEnterpriseConnection } from "@/react-app/domains/enterprise";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { toast } from "@/components/ui/sonner";
import { ConfirmModal } from "@/react-app/design-system/modals/confirm-modal";
-import {
- settingsPageDescriptionClass,
- settingsPageTitleClass,
- settingsSectionTitleClass,
- settingsStandardContentClass,
-} from "@/react-app/domains/settings/shell/panel";
-import { SettingsSegmentedTabs } from "@/react-app/domains/settings/settings-segmented-tabs";
-
type InstallResult = { ok: boolean; message: string };
-type SkillsFilter = "all" | "cloud" | "hub";
-type SkillsStatusFilter = "all" | "installed" | "available" | "update";
-type CloudSkillInstallState = "available" | "installed" | "update" | "missing_local";
-
-function isSkillsStatusFilter(value: string): value is SkillsStatusFilter {
- return value === "all" || value === "installed" || value === "available" || value === "update";
-}
-
-const panelCardClass =
- "group rounded-[8px] bg-transparent p-4 transition-colors hover:bg-[#f6f7fb] focus-within:bg-[#f6f7fb] dark:hover:bg-dls-hover dark:focus-within:bg-dls-hover";
-
-const IPOLLOWORK_DEFAULT_SKILL_NAMES = new Set([
- "workspace-guide",
- "get-started",
- "skill-creator",
- "command-creator",
- "agent-creator",
- "plugin-creator",
-]);
-
+type Source = "personal" | "public" | "enterprise";
export type ImportedCloudSkillRecord = {
installedName: string;
updatedAt?: string | null;
@@ -124,6 +58,8 @@ export type SkillsExtensionsStore = {
};
export type SkillsViewProps = {
+ client?: iPolloWorkServerClient | null;
+ workspaceId?: string | null;
workspaceName: string;
busy: boolean;
showHeader?: boolean;
@@ -142,1215 +78,132 @@ export type SkillsViewHandle = {
createInChat: () => void;
};
-type SkillsViewLocalState = {
- uninstallTarget: SkillCard | null;
- searchQuery: string;
- activeFilter: SkillsFilter;
- statusFilter: SkillsStatusFilter;
- customRepoOpen: boolean;
- customRepoOwner: string;
- customRepoName: string;
- customRepoRef: string;
- customRepoError: string | null;
- shareTarget: SkillCard | null;
- cloudSessionNonce: number;
- shareTeamBusy: boolean;
- shareTeamError: string | null;
- shareTeamSuccess: string | null;
- sharePermissionChoice: string;
- selectedSkill: SkillCard | null;
- selectedContent: string;
- selectedLoading: boolean;
- selectedDirty: boolean;
- selectedError: string | null;
- installingSkillCreator: boolean;
- installingHubSkill: string | null;
- installingCloudSkillId: string | null;
- denUiTick: number;
-};
-
-type SkillsViewLocalAction
=
- | { type: "set"; key: K; value: SetStateAction }
- | { type: "denSessionUpdated" }
- | { type: "closeShare" }
- | { type: "openShare"; skill: SkillCard };
-const initialSkillsViewLocalState: SkillsViewLocalState = {
- uninstallTarget: null,
- searchQuery: "",
- activeFilter: "all",
- statusFilter: "all",
- customRepoOpen: false,
- customRepoOwner: "",
- customRepoName: "",
- customRepoRef: "main",
- customRepoError: null,
- shareTarget: null,
- cloudSessionNonce: 0,
- shareTeamBusy: false,
- shareTeamError: null,
- shareTeamSuccess: null,
- sharePermissionChoice: "org",
- selectedSkill: null,
- selectedContent: "",
- selectedLoading: false,
- selectedDirty: false,
- selectedError: null,
- installingSkillCreator: false,
- installingHubSkill: null,
- installingCloudSkillId: null,
- denUiTick: 0,
-};
-
-function skillsViewLocalReducer(
- state: SkillsViewLocalState,
- action: SkillsViewLocalAction,
-): SkillsViewLocalState {
- switch (action.type) {
- case "set": {
- const current = state[action.key];
- const next =
- typeof action.value === "function"
- ? (action.value as (value: typeof current) => typeof current)(current)
- : action.value;
- if (Object.is(current, next)) return state;
- return { ...state, [action.key]: next };
- }
- case "denSessionUpdated":
- return {
- ...state,
- denUiTick: state.denUiTick + 1,
- cloudSessionNonce: state.cloudSessionNonce + 1,
- };
- case "closeShare":
- return {
- ...state,
- shareTarget: null,
- shareTeamBusy: false,
- shareTeamError: null,
- shareTeamSuccess: null,
- sharePermissionChoice: "org",
- };
- case "openShare":
- return {
- ...state,
- shareTarget: action.skill,
- shareTeamBusy: false,
- shareTeamError: null,
- shareTeamSuccess: null,
- sharePermissionChoice: "org",
- cloudSessionNonce: state.cloudSessionNonce + 1,
- };
- }
-}
export const SkillsView = forwardRef(function SkillsView(props, ref) {
- const { extensions } = props;
- const [localState, dispatchLocal] = useReducer(
- skillsViewLocalReducer,
- initialSkillsViewLocalState,
- );
- const {
- uninstallTarget,
- searchQuery,
- activeFilter,
- statusFilter,
- customRepoOpen,
- customRepoOwner,
- customRepoName,
- customRepoRef,
- customRepoError,
- shareTarget,
- cloudSessionNonce,
- shareTeamBusy,
- shareTeamError,
- shareTeamSuccess,
- sharePermissionChoice,
- selectedSkill,
- selectedContent,
- selectedLoading,
- selectedDirty,
- selectedError,
- installingSkillCreator,
- installingHubSkill,
- installingCloudSkillId,
- denUiTick,
- } = localState;
- const setLocal = (
- key: K,
- value: SetStateAction,
- ) => dispatchLocal({ type: "set", key, value });
- const setUninstallTarget = (value: SetStateAction) => setLocal("uninstallTarget", value);
- const setSearchQuery = (value: SetStateAction) => setLocal("searchQuery", value);
- const setActiveFilter = (value: SetStateAction) => setLocal("activeFilter", value);
- const setStatusFilter = (value: SetStateAction) => setLocal("statusFilter", value);
- const setCustomRepoOpen = (value: SetStateAction) => setLocal("customRepoOpen", value);
- const setCustomRepoOwner = (value: SetStateAction) => setLocal("customRepoOwner", value);
- const setCustomRepoName = (value: SetStateAction) => setLocal("customRepoName", value);
- const setCustomRepoRef = (value: SetStateAction) => setLocal("customRepoRef", value);
- const setCustomRepoError = (value: SetStateAction) => setLocal("customRepoError", value);
- const setShareTeamBusy = (value: SetStateAction) => setLocal("shareTeamBusy", value);
- const setShareTeamError = (value: SetStateAction) => setLocal("shareTeamError", value);
- const setShareTeamSuccess = (value: SetStateAction) => setLocal("shareTeamSuccess", value);
- const setSharePermissionChoice = (value: SetStateAction) => setLocal("sharePermissionChoice", value);
- const setSelectedSkill = (value: SetStateAction) => setLocal("selectedSkill", value);
- const setSelectedContent = (value: SetStateAction) => setLocal("selectedContent", value);
- const setSelectedLoading = (value: SetStateAction) => setLocal("selectedLoading", value);
- const setSelectedDirty = (value: SetStateAction) => setLocal("selectedDirty", value);
- const setSelectedError = (value: SetStateAction) => setLocal("selectedError", value);
- const setInstallingSkillCreator = (value: SetStateAction) => setLocal("installingSkillCreator", value);
- const setInstallingHubSkill = (value: SetStateAction) => setLocal("installingHubSkill", value);
- const setInstallingCloudSkillId = (value: SetStateAction) => setLocal("installingCloudSkillId", value);
-
- const maskError = useCallback(
- (value: unknown) =>
- value instanceof Error ? value.message : t("common.something_went_wrong"),
- [],
- );
-
- useEffect(() => {
- void extensions.ensureHubSkillsFresh();
- void extensions.ensureCloudOrgSkillsFresh();
- const onDenSession = () => {
- dispatchLocal({ type: "denSessionUpdated" });
- void extensions.refreshCloudOrgSkills({ force: true });
- };
- window.addEventListener("ipollowork-den-session-updated", onDenSession);
- return () => window.removeEventListener("ipollowork-den-session-updated", onDenSession);
- }, [extensions]);
-
- useEffect(() => {
- if (!shareTarget) return;
- const onKeyDown = (event: KeyboardEvent) => {
- if (event.key !== "Escape") return;
- event.preventDefault();
- dispatchLocal({ type: "closeShare" });
- };
- window.addEventListener("keydown", onKeyDown);
- return () => window.removeEventListener("keydown", onKeyDown);
- }, [shareTarget]);
-
- const shareCloudSignedIn = useMemo(() => {
- cloudSessionNonce;
- return Boolean(readDenSettings().authToken?.trim());
- }, [cloudSessionNonce]);
-
- const shareTeamOrgLabel = useMemo(() => {
- cloudSessionNonce;
- const name = readDenSettings().activeOrgName?.trim();
- return name || t("skills.share_team_org_fallback");
- }, [cloudSessionNonce]);
-
- const shareTeamDisabledReason = useMemo(() => {
- if (!shareCloudSignedIn) return null;
- const settings = readDenSettings();
- if (!settings.activeOrgId?.trim() && !settings.activeOrgSlug?.trim()) {
- return t("skills.share_team_choose_org");
- }
- return null;
- }, [shareCloudSignedIn]);
-
- const skills = extensions.skills();
- const hubSkills = extensions.hubSkills();
- const cloudOrgSkills = extensions.cloudOrgSkills();
- const importedCloudSkills = extensions.importedCloudSkills();
- const hubRepo = extensions.hubRepo();
- const hubRepos = extensions.hubRepos();
- const skillsStatus = extensions.skillsStatus();
- const hubSkillsStatus = extensions.hubSkillsStatus();
- const cloudOrgSkillsStatus = extensions.cloudOrgSkillsStatus();
-
- const skillCreatorInstalled = useMemo(
- () => skills.some((skill) => skill.name === "skill-creator"),
- [skills],
- );
-
- const filteredSkills = useMemo(() => {
- const query = searchQuery.trim().toLowerCase();
- if (!query) return skills;
- return skills.filter((skill) => {
- const description = skill.description ?? "";
- return skill.name.toLowerCase().includes(query) || description.toLowerCase().includes(query);
- });
- }, [searchQuery, skills]);
-
- const installedNames = useMemo(() => new Set(skills.map((skill) => skill.name)), [skills]);
-
- const filteredHubSkills = useMemo(() => {
- const query = searchQuery.trim().toLowerCase();
- const items = hubSkills.filter((skill) => !installedNames.has(skill.name));
- if (!query) return items;
- return items.filter((skill) => {
- const description = skill.description ?? "";
- const trigger = skill.trigger ?? "";
- return (
- skill.name.toLowerCase().includes(query) ||
- description.toLowerCase().includes(query) ||
- trigger.toLowerCase().includes(query)
- );
- });
- }, [hubSkills, installedNames, searchQuery]);
-
- const filteredCloudOrgSkills = useMemo(() => {
- const query = searchQuery.trim().toLowerCase();
- if (!query) return cloudOrgSkills;
- return cloudOrgSkills.filter((skill) => {
- const description = skill.description ?? "";
- return (
- skill.title.toLowerCase().includes(query) ||
- description.toLowerCase().includes(query)
- );
- });
- }, [cloudOrgSkills, searchQuery]);
-
- const cloudSkillInstallState = useCallback(
- (skill: DenOrgSkillCard): CloudSkillInstallState => {
- const imported = importedCloudSkills[skill.id];
- if (!imported) return "available";
- if (!installedNames.has(imported.installedName)) return "missing_local";
-
- const remoteUpdatedAt = skill.updatedAt ? Date.parse(skill.updatedAt) : Number.NaN;
- const importedUpdatedAt = imported.updatedAt ? Date.parse(imported.updatedAt) : Number.NaN;
- if (
- Number.isFinite(remoteUpdatedAt) &&
- (!Number.isFinite(importedUpdatedAt) || remoteUpdatedAt > importedUpdatedAt)
- ) {
- return "update";
- }
- return "installed";
- },
- [importedCloudSkills, installedNames],
- );
-
- const cloudOrgLabel = useMemo(() => {
- denUiTick;
- const name = readDenSettings().activeOrgName?.trim();
- return name || t("skills.cloud_org_fallback");
- }, [denUiTick]);
-
- const cloudSessionReady = useMemo(() => {
- denUiTick;
- const settings = readDenSettings();
- return Boolean(settings.authToken?.trim() && settings.activeOrgId?.trim());
- }, [denUiTick]);
-
- const cloudNeedsSignIn = useMemo(() => {
- denUiTick;
- return !readDenSettings().authToken?.trim();
- }, [denUiTick]);
-
- const sharePermissionOptions = useMemo(
- () => [
- { value: "private", label: t("skills.share_team_permission_private") },
- { value: "org", label: t("skills.share_team_permission_org") },
- ],
- [],
- );
-
- const shareModalSubtitle = t("skills.share_subtitle_team");
-
- const activeHubRepoLabel = useMemo(
- () => (hubRepo ? `${hubRepo.owner}/${hubRepo.repo}@${hubRepo.ref}` : t("skills.no_hub_repo_label")),
- [hubRepo],
- );
-
- const hasDefaultHubRepo = useMemo(
- () => hubRepos.some((repo) => `${repo.owner}/${repo.repo}@${repo.ref}` === "Devin-AXIS/iPolloWork-hub@main"),
- [hubRepos],
- );
-
- const statusFilterOptions = useMemo(
- () => [
- { value: "all", label: t("skills.filter_status_all") },
- { value: "installed", label: t("skills.filter_installed") },
- { value: "available", label: t("skills.filter_status_available") },
- { value: "update", label: t("skills.filter_status_update") },
- ],
- [],
- );
-
- const visibleCloudOrgSkills = useMemo(() => {
- if (statusFilter === "all") return filteredCloudOrgSkills;
- return filteredCloudOrgSkills.filter((skill) => {
- const state = cloudSkillInstallState(skill);
- if (statusFilter === "available") return state === "available" || state === "missing_local";
- return state === statusFilter;
- });
- }, [cloudSkillInstallState, filteredCloudOrgSkills, statusFilter]);
-
- const visibleHubSkills = statusFilter === "all" || statusFilter === "available" ? filteredHubSkills : [];
- const showInstalledSection = activeFilter === "all" && (statusFilter === "all" || statusFilter === "installed");
- const showCloudSection = activeFilter === "all" || activeFilter === "cloud";
- const showHubSection = activeFilter === "all" || activeFilter === "hub";
- const canCreateInChat = !props.busy && (props.canInstallSkillCreator || props.canUseDesktopTools);
-
- const resolveSharePermission = () => {
- const choice = sharePermissionChoice.trim();
- if (choice === "private") return { shared: null };
- return { shared: "org" as const };
- };
-
- const closeShareLink = useCallback(() => {
- dispatchLocal({ type: "closeShare" });
- }, []);
-
- const runDesktopAction = useCallback(
- (action: () => void | Promise) => {
- if (props.busy) return;
- if (!props.canUseDesktopTools) {
- toast.warning(t("skills.desktop_required"));
- return;
- }
- void Promise.resolve(action());
- },
- [props.busy, props.canUseDesktopTools],
- );
-
- const refreshCatalogs = useCallback(() => {
- if (props.busy) return;
- void extensions.refreshSkills({ force: true });
- void extensions.refreshHubSkills({ force: true });
- void extensions.refreshCloudOrgSkills({ force: true });
- }, [extensions, props.busy]);
-
- const installSkillCreator = useCallback(async () => {
- if (props.busy || installingSkillCreator) return;
- if (!props.canInstallSkillCreator) {
- toast.warning(props.accessHint ?? t("skills.host_only_error"));
- return;
- }
- setInstallingSkillCreator(true);
- toast.info(t("skills.installing_skill_creator"));
+ const connection = useActiveEnterpriseConnection();
+ const location = useLocation();
+ const [source, setSource] = useState(() => new URLSearchParams(location.search).get("source") === "enterprise" ? "enterprise" : "personal");
+ const [search, setSearch] = useState("");
+ const [status, setStatus] = useState("all");
+ const [personal, setPersonal] = useState([]);
+ const [installed, setInstalled] = useState([]);
+ const [enterprise, setEnterprise] = useState([]);
+ const [publicItems, setPublicItems] = useState([]);
+ const [cursor, setCursor] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [pending, setPending] = useState(false);
+ const [error, setError] = useState("");
+ const [exporting, setExporting] = useState(null);
+ const [title, setTitle] = useState("");
+ const [version, setVersion] = useState("1.0.0");
+ const [details, setDetails] = useState<{ title: string; content: string; files?: string[]; editable?: StandaloneSkillPackage } | null>(null);
+ const [content, setContent] = useState("");
+ const [uninstalling, setUninstalling] = useState(null);
+ const fileInput = useRef(null);
+ const epoch = useRef(0);
+ const client = props.client;
+ const workspaceId = props.workspaceId;
+ const identity = `${workspaceId ?? ""}:${source}:${connection?.origin ?? ""}:${connection?.id ?? ""}:${connection?.membership.id ?? ""}`;
+ const actor = useRef({ identity, client });
+ if (actor.current.identity !== identity || actor.current.client !== client) actor.current = { identity, client };
+ const ready = Boolean(client && workspaceId && !props.busy && !pending);
+ const reload = useCallback(async (more = false) => {
+ const request = ++epoch.current; setLoading(true); setError("");
try {
- const result = await extensions.installSkillCreator();
- toast.success(result.message);
- } catch (error) {
- toast.error(maskError(error));
- } finally {
- setInstallingSkillCreator(false);
- }
- }, [extensions, installingSkillCreator, maskError, props.accessHint, props.busy, props.canInstallSkillCreator]);
-
- const installFromCloud = useCallback(
- async (skill: DenOrgSkillCard) => {
- if (props.busy || installingCloudSkillId) return;
- const state = cloudSkillInstallState(skill);
- if (state === "installed") return;
- setInstallingCloudSkillId(skill.id);
- toast.info(
- t(state === "update" ? "skills.cloud_updating" : "skills.cloud_installing", undefined, { title: skill.title }),
- );
- try {
- const result = await extensions.installCloudOrgSkill(skill);
- if (result.ok) {
- toast.success(result.message);
- } else {
- toast.error(result.message);
- }
- } catch (error) {
- toast.error(maskError(error));
- } finally {
- setInstallingCloudSkillId(null);
+ if (source === "public") {
+ const result = await listPublicSkills({ search, cursor: more ? cursor : null });
+ if (request !== epoch.current) return;
+ if (more && result.nextCursor === cursor && cursor) throw new Error("公开技能分页无效,请联系云端管理员。");
+ setPublicItems((items) => more ? [...new Map([...items, ...result.items].map((item) => [item.id, item])).values()].slice(0, 500) : result.items); setCursor(result.nextCursor);
}
- },
- [cloudSkillInstallState, extensions, installingCloudSkillId, maskError, props.busy],
- );
-
- const installFromHub = useCallback(
- async (skill: HubSkillCard) => {
- if (props.busy || installingHubSkill) return;
- setInstallingHubSkill(skill.name);
- toast.info(`${t("skills.installing_prefix")} ${skill.name}...`);
- try {
- const result = await extensions.installHubSkill(skill.name);
- toast.success(result.message);
- } catch (error) {
- toast.error(maskError(error));
- } finally {
- setInstallingHubSkill(null);
+ if (client && workspaceId) {
+ await client.setEnterpriseSkillContext(workspaceId, connection?.features?.skills ? enterpriseSkillContext(connection) : null);
+ const result = await client.listSkillPackages(workspaceId);
+ if (request !== epoch.current) return;
+ setPersonal(result.personal); setInstalled(result.enterprise);
+ if (source === "enterprise" && connection) { const items = await listEnterpriseResources("skill", { connection }); if (request === epoch.current) setEnterprise(items); }
+ } else if (source === "personal") {
+ setPersonal(props.extensions.skills().map((item) => ({ ...item, scope: "project", description: item.description ?? "", pluginName: null, canExport: false, source: null })));
}
- },
- [extensions, installingHubSkill, maskError, props.busy],
- );
-
- const handleNewSkill = useCallback(async () => {
- if (props.busy) return;
- if (props.canInstallSkillCreator && !skillCreatorInstalled) {
- await installSkillCreator();
- }
- await Promise.resolve(props.createSessionAndOpen("/skill-creator"));
- }, [installSkillCreator, props, skillCreatorInstalled]);
-
- useImperativeHandle(
- ref,
- () => ({
- refresh: refreshCatalogs,
- importLocal: () => runDesktopAction(extensions.importLocalSkill),
- createInChat: () => void handleNewSkill(),
- }),
- [extensions.importLocalSkill, handleNewSkill, refreshCatalogs, runDesktopAction],
- );
-
- const openCloudSignIn = useCallback(() => {
- const base = readDenSettings().baseUrl?.trim() || DEFAULT_DEN_BASE_URL;
- // Label stays "Sign in"; opens the sign-up tab (returning users can toggle).
- props.onOpenLink(buildDenAuthUrl(base, "sign-up"));
- }, [props]);
-
- const openShareLink = useCallback(
- (skill: SkillCard) => {
- if (props.busy) return;
- dispatchLocal({ type: "openShare", skill });
- },
- [props.busy],
- );
-
- const startShareSkillSignIn = useCallback(() => {
- const settings = readDenSettings();
- // Label stays "Sign in"; opens the sign-up tab (returning users can toggle).
- props.onOpenLink(buildDenAuthUrl(settings.baseUrl, "sign-up"));
- }, [props]);
-
- const publishSkillToTeam = useCallback(async () => {
- if (!shareTarget || props.busy || shareTeamBusy || shareTeamDisabledReason) return;
- setShareTeamBusy(true);
- setShareTeamError(null);
- setShareTeamSuccess(null);
- try {
- const skill = await extensions.readSkill(shareTarget.name);
- if (!skill) throw new Error("Failed to load skill");
- const sharing = resolveSharePermission();
- const { orgName, orgId } = await saveInstalledSkillToiPolloWorkOrg({
- skillText: skill.content,
- shared: sharing.shared,
- });
- setShareTeamSuccess(t("skills.share_team_uploaded_success", undefined, { org: orgName }));
- window.dispatchEvent(
- new CustomEvent<{ orgId: string }>("ipollowork-den-org-skills-changed", {
- detail: { orgId },
- }),
- );
- void extensions.refreshCloudOrgSkills({ force: true });
- } catch (error) {
- setShareTeamError(maskError(error));
- } finally {
- setShareTeamBusy(false);
- }
- }, [extensions, maskError, props.busy, shareTarget, shareTeamBusy, shareTeamDisabledReason]);
-
- const openSkill = useCallback(
- async (skill: SkillCard) => {
- if (props.busy) return;
- setSelectedSkill(skill);
- setSelectedContent("");
- setSelectedDirty(false);
- setSelectedError(null);
- setSelectedLoading(true);
- try {
- const result = await extensions.readSkill(skill.name);
- if (!result) {
- setSelectedError(t("skills.skill_load_failed"));
- return;
- }
- setSelectedContent(result.content);
- } catch (error) {
- setSelectedError(maskError(error));
- } finally {
- setSelectedLoading(false);
- }
- },
- [extensions, maskError, props.busy],
- );
-
- const saveSelectedSkill = useCallback(async () => {
- if (!selectedSkill || !selectedDirty) return;
- setSelectedError(null);
- try {
- await Promise.resolve(
- extensions.saveSkill({
- name: selectedSkill.name,
- content: selectedContent,
- description: selectedSkill.description,
- }),
- );
- setSelectedDirty(false);
- } catch (error) {
- setSelectedError(maskError(error));
- }
- }, [extensions, maskError, selectedContent, selectedDirty, selectedSkill]);
-
- const selectHubRepo = useCallback(
- (repo: HubSkillRepo) => {
- void Promise.resolve(extensions.setHubRepo(repo)).then(() => {
- void extensions.refreshHubSkills({ force: true });
- });
- },
- [extensions],
- );
-
- const openCustomRepoModal = useCallback(() => {
- if (props.busy) return;
- setCustomRepoOpen(true);
- setCustomRepoOwner(hubRepo?.owner ?? "");
- setCustomRepoName(hubRepo?.repo ?? "");
- setCustomRepoRef(hubRepo?.ref || "main");
- setCustomRepoError(null);
- }, [hubRepo, props.busy]);
-
- const closeCustomRepoModal = useCallback(() => {
- setCustomRepoOpen(false);
- setCustomRepoError(null);
- }, []);
-
- const saveCustomRepo = useCallback(() => {
- const owner = customRepoOwner.trim();
- const repo = customRepoName.trim();
- const ref = customRepoRef.trim() || "main";
- if (!owner || !repo) {
- setCustomRepoError(t("skills.owner_repo_required"));
- return;
- }
- void Promise.resolve(extensions.addHubRepo({ owner, repo, ref })).then(() => {
- void extensions.refreshHubSkills({ force: true });
- });
- closeCustomRepoModal();
- }, [closeCustomRepoModal, customRepoName, customRepoOwner, customRepoRef, extensions]);
-
- const isiPolloWorkInjectedSkill = (skill: SkillCard) => {
- const normalizedName = skill.name.trim().toLowerCase();
- const normalizedPath = skill.path.replace(/\\/g, "/").toLowerCase();
- return normalizedPath.includes("/.opencode/skills/") &&
- (IPOLLOWORK_DEFAULT_SKILL_NAMES.has(normalizedName) || normalizedName.endsWith("-creator"));
+ } catch (cause) { if (request === epoch.current) setError(cause instanceof Error ? cause.message : "技能暂时无法加载。"); }
+ finally { if (request === epoch.current) setLoading(false); }
+ }, [client, workspaceId, source, search, cursor, connection, props.extensions]);
+ const reloadRef = useRef(reload); reloadRef.current = reload;
+ useEffect(() => {
+ setDetails(null); setExporting(null); setUninstalling(null); setPending(false); setError(""); setPersonal([]); setInstalled([]); setEnterprise([]); setCursor(null); setPublicItems([]);
+ const timer = window.setTimeout(() => void reloadRef.current(), source === "public" ? 250 : 0);
+ return () => { window.clearTimeout(timer); epoch.current += 1; };
+ }, [client, workspaceId, source, source === "public" ? search : "", connection?.id, connection?.membership.id]);
+ useEffect(() => { if (source === "enterprise" && !connection) setSource("personal"); }, [source, connection]);
+ const perform = async (action: (isCurrent: () => boolean) => Promise, message: string) => {
+ const startedBy = actor.current;
+ const isCurrent = () => actor.current === startedBy;
+ setPending(true); setError("");
+ try { await action(isCurrent); if (isCurrent()) { toast.success(message); await reloadRef.current(); } }
+ catch (cause) { if (isCurrent()) setError(cause instanceof Error ? cause.message : "操作未完成,请重试。"); }
+ finally { if (isCurrent()) setPending(false); }
};
-
- return (
-
-
-
-
- {props.showHeader !== false ?
{t("skills.title")}
: null}
-
- {t("skills.worker_profile_desc")}
-
-
-
- {props.showActions === false ? null : (
-
-
-
-
-
- )}
-
-
-
-
- setSearchQuery(event.currentTarget.value)}
- placeholder={t("skills.catalog_search_placeholder")}
- className="h-[34px] w-full rounded-[8px] border border-transparent bg-[#f5f6f9] pl-[42px] pr-4 text-[13px] text-dls-text shadow-none outline-none transition-colors placeholder:text-[#a2a6af] focus:border-ring focus:ring-3 focus:ring-ring/30 dark:bg-dls-hover dark:placeholder:text-dls-secondary"
- />
-
-
-
-
-
-
-
-
-
+ useImperativeHandle(ref, () => ({ refresh: () => void reloadRef.current(), importLocal: () => fileInput.current?.click(), createInChat: () => void props.createSessionAndOpen("帮我创建一个独立技能,包含标准 SKILL.md 和必要的配套文件。") }), [props.createSessionAndOpen]);
+ const filtered =
(items: T[]) => items.filter((item) => `${item.name} ${item.description ?? ""}`.toLowerCase().includes(search.trim().toLowerCase()));
+ const savePackage = async (file: File) => downloadBlobAsFile(file.name, file);
+ const exportPackage = async (submit: boolean) => {
+ if (!exporting || !client || !workspaceId) return;
+ await perform(async () => {
+ const result = await client.exportSkillPackage(workspaceId, { name: exporting.name, title: title.trim(), version: version.trim() });
+ const file = new File([result.data], result.filename ?? `${exporting.name}-${version}.ipollowork-skill`, { type: "application/vnd.ipollowork.skill+zip" });
+ if (submit && connection) await enterpriseApi.submit(connection, "skill", file); else await savePackage(file);
+ setExporting(null);
+ }, submit ? "技能投稿已提交,等待审核。" : "技能包已导出。");
+ };
+ const openLocal = (item: StandaloneSkillPackage) => void perform(async (isCurrent) => {
+ if (!client || !workspaceId) return;
+ const result = await client.getSkill(workspaceId, item.name, { includeGlobal: true });
+ if (!isCurrent()) return;
+ setDetails({ title: item.name, content: result.content, ...(!item.pluginName && item.scope === "project" ? { editable: item } : {}) }); setContent(result.content);
+ }, "技能内容已加载。");
+ const installRemote = (item: PublicSkill) => void perform(async () => {
+ if (!client || !workspaceId) return;
+ const result = await downloadPublicSkill(item); await client.importSkillPackage(workspaceId, result.file, result.source); await props.extensions.refreshSkills({ force: true });
+ }, "公开技能已安装。");
+ const mutateEnterprise = (item: InstalledEnterpriseSkill, enabled: boolean | null) => void perform(async () => {
+ if (!client || !workspaceId || !connection) return;
+ await client.setEnterpriseSkillContext(workspaceId, enterpriseSkillContext(connection)); await client.updateEnterpriseSkill(workspaceId, item.resourceId, enabled);
+ }, enabled === null ? "企业技能已卸载。" : enabled ? "企业技能已启用。" : "企业技能已停用。");
+ return
+ {props.showHeader !== false ? : null}
+
+
+
+
+ {connection ? : null}
-
- {props.accessHint ? (
-
- {props.accessHint}
-
- ) : null}
- {!props.accessHint && !props.canInstallSkillCreator && !props.canUseDesktopTools ? (
-
- {t("skills.host_mode_only")}
-
- ) : null}
-
- {skillsStatus ? (
-
- {skillsStatus}
-
- ) : null}
-
- {showInstalledSection ? (
-
-
-
-
{t("skills.installed")}
-
{t("skills.installed_desc")}
-
-
{t("skills.shown_count", undefined, { count: filteredSkills.length })}
-
-
- {filteredSkills.length === 0 ? (
-
- {t("skills.no_skills")}
-
- ) : (
-
- {filteredSkills.map((skill) => (
-
-
-
-
-
{t("skills.installed_status")}
-
-
-
-
-
-
- ))}
-
- )}
-
- ) : null}
-
- {showCloudSection ? (
-
-
-
-
{t("skills.cloud_section_title")}
-
{t("skills.cloud_section_subtitle")}
-
-
{cloudOrgLabel}
-
-
- {!cloudSessionReady ? (
-
- {cloudNeedsSignIn ? (
-
-
{t("skills.cloud_sign_in_hint")}
-
-
- ) : (
-
-
{t("skills.cloud_choose_org_hint")}
-
{t("skills.cloud_choose_org_detail")}
-
- )}
-
- ) : (
- <>
- {cloudOrgSkillsStatus ? (
-
- {cloudOrgSkillsStatus}
-
- ) : null}
-
- {visibleCloudOrgSkills.length === 0 ? (
-
- {cloudOrgSkills.length === 0 ? t("skills.cloud_org_empty") : t("skills.cloud_no_search_matches")}
-
- ) : (
-
-
- {visibleCloudOrgSkills.map((skill) => {
- const state = cloudSkillInstallState(skill);
- const installedName = importedCloudSkills[skill.id]?.installedName ?? null;
- return (
-
-
-
-
-
-
-
{skill.title}
- {skill.description ? (
-
{skill.description}
- ) : null}
-
- {skill.shared === "org" ? {t("skills.cloud_shared_org")} : null}
- {skill.shared === "public" ? {t("skills.cloud_shared_public")} : null}
- {skill.shared === null ? {t("skills.cloud_shared_private")} : null}
- {installedName ? {t("skills.cloud_installed_as", undefined, { name: installedName })} : null}
- {state === "installed" ? {t("skills.cloud_status_installed")} : null}
- {state === "update" ? {t("skills.cloud_status_update")} : null}
-
-
-
-
-
-
{t("skills.cloud_footer_label")}
-
-
-
- );
- })}
-
-
- )}
- >
- )}
-
- ) : null}
-
- {showHubSection ? (
-
-
-
-
{t("skills.available_from_hub")}
-
{t("skills.hub_desc")}
-
-
-
-
-
-
-
-
-
- {t("skills.source_label")}: {activeHubRepoLabel}
-
-
- {hubRepos.map((repo) => {
- const key = `${repo.owner}/${repo.repo}@${repo.ref}`;
- const active = hubRepo ? key === `${hubRepo.owner}/${hubRepo.repo}@${hubRepo.ref}` : false;
- return (
-
-
-
-
- );
- })}
-
-
-
- {hubSkillsStatus ? (
-
- {hubSkillsStatus}
-
- ) : null}
-
- {visibleHubSkills.length === 0 ? (
-
- {hubRepo ? t("skills.no_hub_skills") : t("skills.no_hub_repo_selected")}
-
- ) : (
-
-
- {visibleHubSkills.map((skill) => (
-
-
-
-
-
{skill.name}
-
- {skill.description || t("skills.from_repo", undefined, { owner: skill.source.owner, repo: skill.source.repo })}
-
-
- {skill.source.owner}/{skill.source.repo}
- {skill.trigger ? (
-
- {t("skills.trigger_label", undefined, { trigger: skill.trigger })}
-
- ) : null}
-
-
-
-
-
-
{t("skills.hub_label")}
-
-
-
- ))}
-
-
- )}
-
- ) : null}
-
-
-
-
setUninstallTarget(null)}
- onConfirm={() => {
- const target = uninstallTarget;
- setUninstallTarget(null);
- if (!target) return;
- void extensions.uninstallSkill(target.name);
- }}
- />
-
-
-
-
-
- );
+
+ {source === "enterprise" && connection ?
void reloadRef.current()} /> : null}
+ {props.showActions !== false ? <>
+ {source === "personal" ? <>> : null}
+
+ > : null}
+
+
+ {
+ const file = event.target.files?.[0]; event.target.value = ""; if (!file || !client || !workspaceId) return;
+ void perform(async () => { const digest = await skillFileDigest(await file.arrayBuffer()); await client.importSkillPackage(workspaceId, file, { origin: "local", resourceId: "local", digest }); await props.extensions.refreshSkills({ force: true }); }, "技能包已导入。");
+ }} />
+
+ {error ? {error}
: null}
+ {source === "enterprise" ? 企业技能仅在当前企业账号获授权时加载。撤权或离线后,后续任务停止加载,已下载文件另行管理。
: null}
+ {loading ? 正在加载技能
:
+ {source === "personal" && status !== "available" && status !== "update" ? filtered(personal).map((item) =>
{item.name}
{item.pluginName ? `来自插件 ${item.pluginName}` : item.source?.origin === "local" ? "本地导入" : item.source ? "来自公开云端" : item.scope === "global" ? "设备全局技能" : "个人技能"}{item.source ? ` · v${item.source.version}` : ""}
{item.description || "暂无说明"}
{item.canExport ? : null}{!item.pluginName && item.scope === "project" ? : null}
) : null}
+ {source === "public" ? publicItems.filter((item) => { const existing = personal.find((skill) => skill.source?.resourceId === item.id); return status === "all" || (status === "installed" ? Boolean(existing) : status === "available" ? !existing : existing && existing.source?.version !== item.latestVersion.version); }).map((item) => { const existing = personal.find((skill) => skill.source?.resourceId === item.id); const current = existing?.source?.version === item.latestVersion.version; return
{item.title}
{item.description}
{item.authorName} · {item.category} · v{item.latestVersion.version}
; }) : null}
+ {source === "enterprise" ? enterprise.filter((item) => `${item.name} ${item.description}`.toLowerCase().includes(search.toLowerCase())).filter((item) => { const existing = installed.find((skill) => skill.resourceId === item.id); return status === "all" || (status === "installed" ? Boolean(existing) : status === "available" ? !existing : existing && existing.manifest.version !== item.latestVersion?.version); }).map((item) => { const existing = installed.find((skill) => skill.resourceId === item.id); const current = existing?.manifest.version === item.latestVersion?.version; return
{item.name}
{item.description}
{item.enterpriseCategory} · v{item.latestVersion?.version}{existing ? existing.enabled ? " · 已启用" : " · 已停用" : ""}
{existing ? <>> : null}{!current && item.latestVersion ? : null}
; }) : null}
+ {source === "enterprise" ? installed.filter((item) => !enterprise.some((resource) => resource.id === item.resourceId)).map((item) =>
{item.manifest.title}
权限已撤销或资源已下架,后续任务不再加载。
) : null}
+
}
+ {!loading && !error && (source === "personal" ? personal.length === 0 : source === "public" ? publicItems.length === 0 : enterprise.length === 0 && installed.length === 0) ? {source === "personal" ? "暂无独立技能,可以创建或导入技能包。" : source === "public" ? "暂无公开技能。" : "暂无获授权的企业技能。"}
: null}
+ {source === "personal" ? 支持导入标准技能 .md(最大 1 MB);含脚本、参考资料的技能请使用 .ipollowork-skill 包。
: null}
+ {source === "public" && cursor && publicItems.length < 500 ? : null}
+
+
+ setUninstalling(null)} onConfirm={() => void perform(async () => { if (uninstalling) await props.extensions.uninstallSkill(uninstalling.name); setUninstalling(null); }, "技能已卸载。") } />
+ ;
});
diff --git a/apps/app/src/react-app/domains/settings/plugin-package-list-item.tsx b/apps/app/src/react-app/domains/settings/plugin-package-list-item.tsx
index 44682e55a..68e0315f9 100644
--- a/apps/app/src/react-app/domains/settings/plugin-package-list-item.tsx
+++ b/apps/app/src/react-app/domains/settings/plugin-package-list-item.tsx
@@ -15,6 +15,7 @@ type PluginPackageListItemProps = {
actionLabel: ReactNode;
actionBusy?: boolean;
actionDisabled?: boolean;
+ secondaryAction?: ReactNode;
featured?: boolean;
compact?: boolean;
onOpen?: () => void;
@@ -29,6 +30,7 @@ export function PluginPackageListItem({
actionLabel,
actionBusy = false,
actionDisabled = false,
+ secondaryAction,
featured = false,
compact = false,
onOpen,
@@ -44,7 +46,7 @@ export function PluginPackageListItem({
if (compact) {
return (
-
+
:
}
-
- {manifest.name}
+
+ {manifest.name}
{badge}
{manifest.description}
- v{version}{status}
+
+ v{version}
+ {status ? {status} : null}
+
+ {secondaryAction}
);
}
@@ -94,6 +100,7 @@ export function PluginPackageListItem({
{actionBusy ?
: null}
{actionLabel}
+ {secondaryAction}
);
}
diff --git a/apps/app/src/react-app/domains/settings/plugin-packages-panel.tsx b/apps/app/src/react-app/domains/settings/plugin-packages-panel.tsx
index a3ec2c1f5..9018935e6 100644
--- a/apps/app/src/react-app/domains/settings/plugin-packages-panel.tsx
+++ b/apps/app/src/react-app/domains/settings/plugin-packages-panel.tsx
@@ -1,5 +1,5 @@
/** @jsxImportSource react */
-import { cloudPluginUpdate, installCloudPluginResource, listEnterpriseResources, type EnterpriseResource } from "@/app/lib/enterprise-connections";
+import { listEnterpriseResources, type EnterpriseResource } from "@/app/lib/enterprise-connections";
import { useCloudSession } from "./cloud/cloud-session-provider";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type ReactNode } from "react";
import {
@@ -16,10 +16,12 @@ import {
Plug,
ShieldCheck,
Sparkles,
+ Trash2,
WandSparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
+import { toast } from "@/components/ui/sonner";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { currentLocale, t, translationKey } from "@/i18n";
@@ -180,6 +182,8 @@ function pluginPackageBadges(
export const PluginPackagesPanel = forwardRef(function PluginPackagesPanel(props, ref) {
const locale = currentLocale();
const [items, setItems] = useState([]);
+ const [catalogItems, setCatalogItems] = useState([]);
+ const [catalogError, setCatalogError] = useState(null);
const cloud = useCloudSession();
const [cloudResources, setCloudResources] = useState([]);
const [cloudError, setCloudError] = useState(null);
@@ -207,6 +211,8 @@ export const PluginPackagesPanel = forwardRef client.listPluginPackages(workspaceId)),
- cloud.isSignedIn ? listEnterpriseResources("extension") : Promise.resolve([]),
+ loadPluginPackageData(() => client.listBundledPluginPackages(workspaceId)),
+ source === "marketplace" && cloud.isSignedIn ? listEnterpriseResources("extension") : Promise.resolve([]),
]);
if (generation !== refreshGeneration.current) return;
@@ -241,6 +248,12 @@ export const PluginPackagesPanel = forwardRef ({
refresh: () => void refresh(),
@@ -271,7 +284,7 @@ export const PluginPackagesPanel = forwardRef { refreshGeneration.current++; };
}, [refresh]);
useEffect(() => {
- if (!cloud.isSignedIn || source !== "personal") return;
+ if (!cloud.isSignedIn || source !== "marketplace") return;
const check = () => { if (Date.now() - lastCloudCheck.current >= 60_000) void refresh(); };
window.addEventListener("focus", check);
const timer = window.setInterval(check, 300_000);
@@ -279,8 +292,8 @@ export const PluginPackagesPanel = forwardRef collectPluginPackageRelationships(items, []),
- [items],
+ () => collectPluginPackageRelationships(items, catalogItems),
+ [items, catalogItems],
);
const localizedItems = useMemo(() => items.map((sourceItem) => {
const manifest = localizePluginPackageManifest(
@@ -302,6 +315,15 @@ export const PluginPackagesPanel = forwardRef [item.name, item.manifest.description, item.manifest.category ?? ""]
.some((value) => value.toLocaleLowerCase().includes(query)));
}, [localizedItems, search]);
+ const filteredCatalogItems = useMemo(() => {
+ const query = search.trim().toLocaleLowerCase();
+ return catalogItems.filter(entry => !items.some(item => item.pluginId === entry.pluginId)).flatMap(item => {
+ const manifest = localizePluginPackageManifest(item.manifest, locale);
+ return !query || [manifest.name, manifest.description, manifest.category ?? ""].some(value => value.toLocaleLowerCase().includes(query))
+ ? [{ ...item, manifest }] : [];
+ });
+ }, [catalogItems, items, locale, search]);
+
useEffect(() => {
props.onRelationshipsChange(relationships);
}, [props.onRelationshipsChange, relationships]);
@@ -332,11 +354,13 @@ export const PluginPackagesPanel = forwardRef run(`cloud:${resource.id}`, async () => {
+ const installBundledPackage = (resource: iPolloWorkBundledPluginPackageItem) => run(`catalog:${resource.pluginId}`, async () => {
if (!props.client || !props.workspaceId) return;
- await installCloudPluginResource(resource, props.client, props.workspaceId);
+ await props.client.installBundledPluginPackage(props.workspaceId, resource.pluginId);
+ await refresh();
+ notifyPluginUiContributionsChanged();
+ });
+
+ const uninstallPackage = (pluginId: string) => run(`${pluginId}:remove`, async () => {
+ if (!props.client || !props.workspaceId) return;
+ await props.client.uninstallPluginPackage(props.workspaceId, pluginId);
+ props.onSelectPlugin(null);
await refresh();
notifyPluginUiContributionsChanged();
});
@@ -397,7 +429,7 @@ export const PluginPackagesPanel = forwardRef entry.pluginId === selectedSourceItem.pluginId && entry.updateAvailable);
const localizedManifest = localizePluginPackageManifest(
selectedSourceItem.manifest,
locale,
@@ -470,15 +502,15 @@ export const PluginPackagesPanel = forwardRefv{item.version}
- {cloudUpdate ? (
+ {catalogUpdate ? (
) : null}