From 5b16a1b84200dab200ad19463cee3e8ccf5d6747 Mon Sep 17 00:00:00 2001 From: papysans <807399089@qq.com> Date: Mon, 29 Jun 2026 14:31:24 +0800 Subject: [PATCH] feat(store): subscribe with AI-auto-invoke vs manual mode + usage section For skill/command items the subscribe control becomes a split button: the main action subscribes with AI auto-invoke allowed, the dropdown offers "manual /name only" and lets a subscriber switch mode later (favorite upsert, never an accidental unsubscribe). Adds a "How to use" section (shown only after subscribing) that explains, per item type and current mode, how to use the resource in the Workspace / CSC / CS-IDE. The subscribe tooltip now describes the cross-terminal, install-free benefit instead of the distribute copy. --- packages/app-ai-native/src/i18n/en.ts | 21 ++++ packages/app-ai-native/src/i18n/zh.ts | 17 +++ .../store/components/item-detail-content.tsx | 52 +++++++- .../store/components/subscribe-button.tsx | 119 +++++++++++++++++- .../pages/store/components/usage-section.tsx | 64 ++++++++++ .../app-ai-native/src/pages/store/lib/api.ts | 20 ++- .../src/pages/store/mobile/detail.tsx | 72 ++++++++++- .../src/pages/store/pages/detail.tsx | 9 +- .../src/pages/store/pages/home.tsx | 12 +- .../src/pages/store/pages/manager.tsx | 11 +- 10 files changed, 371 insertions(+), 26 deletions(-) create mode 100644 packages/app-ai-native/src/pages/store/components/usage-section.tsx diff --git a/packages/app-ai-native/src/i18n/en.ts b/packages/app-ai-native/src/i18n/en.ts index 062dc634c..722038b11 100644 --- a/packages/app-ai-native/src/i18n/en.ts +++ b/packages/app-ai-native/src/i18n/en.ts @@ -738,6 +738,7 @@ export const dict = { "store.detail.favorite": "Subscribe", "store.detail.favorited": "Subscribed", "store.detail.unfavorite": "Unsubscribe", + "store.detail.subscribeTooltip": "Once subscribed, every terminal signed in to this account can use this capability — no install needed.", "store.detail.favoriteSignIn": "Sign in to subscribe", "store.detail.mcpConfig.title": "Parameters", "store.detail.mcpConfig.description": @@ -814,6 +815,26 @@ export const dict = { "store.detail.usage.title": "Install command", "store.detail.usage.copy": "Copy command", "store.detail.usage.copied": "Copied", + "store.detail.usage.sectionTitle": "How to use", + "store.detail.usage.skill.auto": + "The AI may invoke this automatically when relevant. You can also trigger it manually with the slash command below — in the Workspace, CSC, or any CS-IDE:", + "store.detail.usage.skill.manual": + "The AI will not invoke this automatically. Trigger it manually with the slash command below — in the Workspace, CSC, or any CS-IDE:", + "store.detail.usage.subagent": + "Once subscribed, the AI can delegate to this subagent via its Task tool when relevant.", + "store.detail.usage.mcp": + "Once subscribed and configured, the AI can call this MCP's tools directly — no slash command needed.", + "store.detail.usage.rule": "Once subscribed, this rule is injected into the AI conversation as context.", + "store.detail.usage.template": "Once subscribed, apply this template when creating the matching resource.", + "store.detail.invokeMode.menuTooltip": "Choose how it's invoked", + "store.detail.invokeMode.subscribeAuto": "Subscribe, allow AI auto-invoke", + "store.detail.invokeMode.subscribeAutoDesc": "AI can call it automatically; /name still works", + "store.detail.invokeMode.subscribeManual": "Subscribe, manual /name only", + "store.detail.invokeMode.subscribeManualDesc": "AI won't auto-invoke; trigger with /name", + "store.detail.invokeMode.currentAuto": "Current: AI auto-invoke allowed", + "store.detail.invokeMode.currentManual": "Current: manual /name only", + "store.detail.invokeMode.switchToAuto": "Switch to AI auto-invoke", + "store.detail.invokeMode.switchToManual": "Switch to manual /name only", "store.detail.enterprise.label": "Enterprise", "store.scanResults": "Scan Results", "store.scanResults.verdict": "Verdict", diff --git a/packages/app-ai-native/src/i18n/zh.ts b/packages/app-ai-native/src/i18n/zh.ts index 08789ebe2..cec5a93e5 100644 --- a/packages/app-ai-native/src/i18n/zh.ts +++ b/packages/app-ai-native/src/i18n/zh.ts @@ -1671,6 +1671,7 @@ export const dict = { "store.detail.favorite": "订阅", "store.detail.favorited": "已订阅", "store.detail.unfavorite": "取消订阅", + "store.detail.subscribeTooltip": "订阅后,登录了该用户账号的所有终端都可以免安装使用此能力", "store.detail.favoriteSignIn": "登录后可订阅", "store.detail.mcpConfig.title": "参数配置", "store.detail.mcpConfig.description": "该 MCP 服务需要你先填写一些值(如本地路径或 API token)才能使用。", @@ -1746,6 +1747,22 @@ export const dict = { "store.detail.usage.title": "安装命令", "store.detail.usage.copy": "复制命令", "store.detail.usage.copied": "已复制", + "store.detail.usage.sectionTitle": "使用方法", + "store.detail.usage.skill.auto": "AI 会在合适时机自动调用此能力;你也可以在工作空间、CSC 或 CS-IDE 中输入下面的斜杠命令手动触发:", + "store.detail.usage.skill.manual": "AI 不会自动调用此能力。请在工作空间、CSC 或 CS-IDE 中输入下面的斜杠命令手动触发:", + "store.detail.usage.subagent": "订阅后,AI 会在合适时机通过 Task 工具委派给该子代理。", + "store.detail.usage.mcp": "订阅并填写所需参数后,AI 即可直接调用该 MCP 提供的工具,无需斜杠命令。", + "store.detail.usage.rule": "订阅后,该规则会作为上下文自动注入到 AI 对话中。", + "store.detail.usage.template": "订阅后,可在创建相应资源时套用该模板。", + "store.detail.invokeMode.menuTooltip": "选择调用方式", + "store.detail.invokeMode.subscribeAuto": "订阅,允许 AI 自动调用", + "store.detail.invokeMode.subscribeAutoDesc": "AI 可在对话中自动调用,也可手动 /name 触发", + "store.detail.invokeMode.subscribeManual": "订阅,仅手动 /name 调用", + "store.detail.invokeMode.subscribeManualDesc": "AI 不会自动调用,只能通过 /name 手动触发", + "store.detail.invokeMode.currentAuto": "当前:允许 AI 自动调用", + "store.detail.invokeMode.currentManual": "当前:仅手动 /name 调用", + "store.detail.invokeMode.switchToAuto": "改为允许 AI 自动调用", + "store.detail.invokeMode.switchToManual": "改为仅手动 /name 调用", "store.detail.enterprise.label": "大客户", "store.scanResults": "扫描结果", "store.scanResults.verdict": "结论", diff --git a/packages/app-ai-native/src/pages/store/components/item-detail-content.tsx b/packages/app-ai-native/src/pages/store/components/item-detail-content.tsx index 242dc3d13..d34fc31fa 100644 --- a/packages/app-ai-native/src/pages/store/components/item-detail-content.tsx +++ b/packages/app-ai-native/src/pages/store/components/item-detail-content.tsx @@ -20,6 +20,7 @@ import { pickItemDescription } from "../lib/item-description" import SecurityTag, { VerdictTag, type Verdict } from "./security-tag" import HealthRadar from "./health-radar" import { SubscribeButton } from "./subscribe-button" +import { UsageSection } from "./usage-section" import { matchEnterprise, matchEnterpriseByName, type EnterpriseInfo } from "../lib/enterprise" import { useLogoColor } from "../lib/use-logo-color" import { StoreIcon } from "../lib/store-icons" @@ -461,7 +462,8 @@ interface ItemDetailContentProps { favoriteCount?: number previewCount?: number installCount?: number - onToggleFavorite?: () => Promise + /** invokeMode is forwarded to the favorite API for skill-family subscribe/switch. */ + onToggleFavorite?: (invokeMode?: "auto" | "manual") => Promise favoritePending?: boolean isAuthenticated?: boolean } @@ -486,6 +488,22 @@ export default function ItemDetailContent(props: ItemDetailContentProps) { const data = item() if (data) props.onItemLoaded?.(data) }) + + // ─── 订阅调用模式(allow AI auto-invoke vs manual /name only)────────────────────────── + // 仅 skill / command 走 skill discovery、disable-model-invocation 才真实生效。subagent 经 + // Task 工具调用、不受此键影响,故不提供模式开关(其用法在「使用方法」区块单独说明)。 + const INVOKE_MODE_TYPES = new Set(["skill", "command"]) + const invokeModeEnabled = () => INVOKE_MODE_TYPES.has(item()?.itemType ?? "") + // 本地维护当前模式(乐观更新):已订阅时显示其模式,未订阅为 null。 + const [invokeMode, setInvokeMode] = createSignal<"auto" | "manual" | null>(null) + // item 载入时按云端回显 seed;取消订阅(favorited 变 false)时清空。 + createEffect(() => { + const data = item() + if (data && props.favorited) setInvokeMode(data.invokeMode ?? "auto") + }) + createEffect(() => { + if (!props.favorited) setInvokeMode(null) + }) const [authorName] = createResource( () => item()?.createdBy, (createdBy) => userApi.getNames([createdBy]).then((names) => names[createdBy] ?? createdBy), @@ -844,11 +862,32 @@ export default function ItemDetailContent(props: ItemDetailContentProps) { pending={props.favoritePending} authenticated={!!props.isAuthenticated} disabled={mcpPluginRuntimeBlocks() || mcpGateBlocks()} - onToggle={() => void props.onToggleFavorite?.()} + invokeModeEnabled={invokeModeEnabled()} + currentMode={invokeMode()} + onToggle={(_item, mode) => { + if (mode) { + setInvokeMode(mode) + // Patch the resource so the cloud-echo re-seed (when favorited flips + // false→true) reads the chosen mode instead of the stale undefined. + mutateItem((p) => (p ? { ...p, invokeMode: mode, favorited: true } : p)) + } + void props.onToggleFavorite?.(mode) + }} labels={{ subscribe: language.t("store.detail.favorite"), subscribed: language.t("store.detail.unfavorite"), - tooltip: language.t("store.distribute.tooltip"), + tooltip: language.t("store.detail.subscribeTooltip"), + }} + modeLabels={{ + menuTooltip: language.t("store.detail.invokeMode.menuTooltip"), + subscribeAuto: language.t("store.detail.invokeMode.subscribeAuto"), + subscribeAutoDesc: language.t("store.detail.invokeMode.subscribeAutoDesc"), + subscribeManual: language.t("store.detail.invokeMode.subscribeManual"), + subscribeManualDesc: language.t("store.detail.invokeMode.subscribeManualDesc"), + currentAuto: language.t("store.detail.invokeMode.currentAuto"), + currentManual: language.t("store.detail.invokeMode.currentManual"), + switchToAuto: language.t("store.detail.invokeMode.switchToAuto"), + switchToManual: language.t("store.detail.invokeMode.switchToManual"), }} /> @@ -962,6 +1001,13 @@ export default function ItemDetailContent(props: ItemDetailContentProps) {

{pickItemDescription(data(), language.locale())}

+ +
diff --git a/packages/app-ai-native/src/pages/store/components/subscribe-button.tsx b/packages/app-ai-native/src/pages/store/components/subscribe-button.tsx index 9886f91c2..2f714e507 100644 --- a/packages/app-ai-native/src/pages/store/components/subscribe-button.tsx +++ b/packages/app-ai-native/src/pages/store/components/subscribe-button.tsx @@ -1,10 +1,30 @@ import { createEffect, createSignal, on, onCleanup, onMount, Show, type JSX } from "solid-js" import { Tooltip } from "@opencode-ai/ui/tooltip" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { cn } from "@/lib/utils" import { formatCompact } from "./store-capability-table" import { StoreIcon } from "../lib/store-icons" import type { CapabilityItem } from "../lib/api" +/** Per-user subscription invoke mode for skill-family items. */ +export type InvokeMode = "auto" | "manual" + +export interface SubscribeModeLabels { + /** Tooltip on the dropdown caret. */ + menuTooltip: string + /** Subscribe-time choices (shown when not yet subscribed). */ + subscribeAuto: string + subscribeAutoDesc: string + subscribeManual: string + subscribeManualDesc: string + /** Group label showing the active mode when subscribed, e.g. "当前:AI 自动调用". */ + currentAuto: string + currentManual: string + /** Switch actions (shown when already subscribed). */ + switchToAuto: string + switchToManual: string +} + // Does the user prefer reduced motion? Read once per render so the width FLIP and the label // cross-fade both honor it (CSS already strips the bell/ping/transition keyframes separately). function prefersReducedMotion() { @@ -20,8 +40,18 @@ export interface SubscribeButtonProps { authenticated: boolean /** True when subscribing is blocked (parent passes mcpListSubscribeBlocked(item)). */ disabled?: boolean - onToggle: (item: CapabilityItem) => void + /** + * onToggle is called with an optional invokeMode. When undefined (non-skill items, or + * unsubscribe), the backend keeps its default ("auto"). Skill-family subscribe actions + * pass "auto" / "manual" explicitly. + */ + onToggle: (item: CapabilityItem, invokeMode?: InvokeMode) => void labels: { subscribe: string; subscribed: string; tooltip: string } + /** Enables the "订阅即选" split dropdown (skill-family items only). */ + invokeModeEnabled?: boolean + /** The user's current subscription mode; null/undefined when not subscribed. */ + currentMode?: InvokeMode | null + modeLabels?: SubscribeModeLabels } /** @@ -111,14 +141,27 @@ export function SubscribeButton(props: SubscribeButtonProps): JSX.Element { }) } + // Main pill: subscribing defaults to "auto" for skill-family (the recommended mode); + // unsubscribing carries no mode. Non-skill items pass undefined (backend default). + const primaryMode = (): InvokeMode | undefined => + props.favorited ? undefined : props.invokeModeEnabled ? "auto" : undefined + const handleClick = (event: MouseEvent) => { event.stopPropagation() if (!interactive()) return triggerAnimation() - props.onToggle(props.item) + props.onToggle(props.item, primaryMode()) } - return ( + // Selecting a mode from the dropdown both subscribes (when not yet) and switches mode + // (when already subscribed) — the backend favorite endpoint upserts the mode either way. + const selectMode = (mode: InvokeMode) => { + if (!interactive()) return + if (!props.favorited) triggerAnimation() + props.onToggle(props.item, mode) + } + + const pill = ( + {/* skill 系:第二选项(订阅即选 / 已订阅可切换模式) */} + + void toggleFavorite("manual")} + disabled={auth.loading() || favoritePending()} + class="flex h-11 w-full items-center justify-center gap-2 rounded-xl border border-border-weak-base text-sm font-medium text-text-strong transition-colors hover:bg-bg-muted disabled:cursor-not-allowed disabled:opacity-60" + > + {language.t("store.detail.invokeMode.subscribeManual")} + + } + > + + +
) diff --git a/packages/app-ai-native/src/pages/store/pages/detail.tsx b/packages/app-ai-native/src/pages/store/pages/detail.tsx index 014797bec..27858ffa3 100644 --- a/packages/app-ai-native/src/pages/store/pages/detail.tsx +++ b/packages/app-ai-native/src/pages/store/pages/detail.tsx @@ -23,12 +23,17 @@ export default function StoreDetail() { setFavoriteCount(item.favoriteCount ?? 0) } - const toggleFavorite = async () => { + const toggleFavorite = async (invokeMode?: "auto" | "manual") => { const data = currentItem() if (!data || !auth.user() || auth.loading() || favoritePending()) return setFavoritePending(true) try { - const result = favorited() ? await behaviorApi.unfavorite(data.id) : await behaviorApi.favorite(data.id) + // invokeMode present = subscribe-or-switch (upsert mode); absent = plain toggle. + const result = invokeMode + ? await behaviorApi.favorite(data.id, invokeMode) + : favorited() + ? await behaviorApi.unfavorite(data.id) + : await behaviorApi.favorite(data.id) setFavorited(result.favorited) setFavoriteCount(result.favoriteCount) } finally { diff --git a/packages/app-ai-native/src/pages/store/pages/home.tsx b/packages/app-ai-native/src/pages/store/pages/home.tsx index 426746563..d2a6fdc04 100644 --- a/packages/app-ai-native/src/pages/store/pages/home.tsx +++ b/packages/app-ai-native/src/pages/store/pages/home.tsx @@ -363,7 +363,7 @@ export default function Home() { setListCache(data) }) - const toggleFavorite = async () => { + const toggleFavorite = async (invokeMode?: "auto" | "manual") => { const data = detailItem() if (!data || !auth.user() || auth.loading() || favoritePending()) return @@ -375,7 +375,13 @@ export default function Home() { setFavoritePending(true) try { - const result = favorited() ? await behaviorApi.unfavorite(data.id) : await behaviorApi.favorite(data.id) + // invokeMode present = subscribe-or-switch (upsert mode, never unfavorite); + // absent = plain toggle. + const result = invokeMode + ? await behaviorApi.favorite(data.id, invokeMode) + : favorited() + ? await behaviorApi.unfavorite(data.id) + : await behaviorApi.favorite(data.id) setFavorited(result.favorited) setFavoriteCount(result.favoriteCount) // Keep the list view's per-item store in sync with a detail-page toggle (no item-object @@ -740,7 +746,7 @@ export default function Home() { favoriteLabels: { subscribe: language.t("store.detail.favorite"), subscribed: language.t("store.detail.unfavorite"), - tooltip: language.t("store.distribute.tooltip"), + tooltip: language.t("store.detail.subscribeTooltip"), }, emptyMessage: language.t("store.noResults"), })) diff --git a/packages/app-ai-native/src/pages/store/pages/manager.tsx b/packages/app-ai-native/src/pages/store/pages/manager.tsx index 08ceb9238..c6782884c 100644 --- a/packages/app-ai-native/src/pages/store/pages/manager.tsx +++ b/packages/app-ai-native/src/pages/store/pages/manager.tsx @@ -511,15 +511,18 @@ export default function StoreManagerPage() { setDetailState("item", item) } - const toggleFavorite = async () => { + const toggleFavorite = async (invokeMode?: "auto" | "manual") => { const data = detailState.item if (!data || !auth.user() || auth.loading() || detailState.favoritePending) return setDetailState("favoritePending", true) try { - const result = detailState.favorited - ? await behaviorApi.unfavorite(data.id) - : await behaviorApi.favorite(data.id) + // invokeMode present = subscribe-or-switch (upsert mode); absent = plain toggle. + const result = invokeMode + ? await behaviorApi.favorite(data.id, invokeMode) + : detailState.favorited + ? await behaviorApi.unfavorite(data.id) + : await behaviorApi.favorite(data.id) setDetailState("favorited", result.favorited) setDetailState("favoriteCount", result.favoriteCount) patchItemEverywhere(data.id, (item) => ({ ...item, favorited: result.favorited, favoriteCount: result.favoriteCount }))