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
21 changes: 21 additions & 0 deletions packages/app-ai-native/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,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":
Expand Down Expand Up @@ -827,6 +828,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",
Expand Down
17 changes: 17 additions & 0 deletions packages/app-ai-native/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1684,6 +1684,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)才能使用。",
Expand Down Expand Up @@ -1759,6 +1760,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": "结论",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -461,7 +462,8 @@ interface ItemDetailContentProps {
favoriteCount?: number
previewCount?: number
installCount?: number
onToggleFavorite?: () => Promise<void>
/** invokeMode is forwarded to the favorite API for skill-family subscribe/switch. */
onToggleFavorite?: (invokeMode?: "auto" | "manual") => Promise<void>
favoritePending?: boolean
isAuthenticated?: boolean
}
Expand All @@ -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),
Expand Down Expand Up @@ -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"),
}}
/>
</Show>
Expand Down Expand Up @@ -962,6 +1001,13 @@ export default function ItemDetailContent(props: ItemDetailContentProps) {
<p class="text-[13px] leading-6 text-text-weak">{pickItemDescription(data(), language.locale())}</p>
</Show>

<UsageSection
itemType={data().itemType}
name={data().slug ?? data().name}
invokeMode={invokeMode()}
favorited={!!props.favorited}
/>

<Show when={data().parentPluginName && data().parentPluginId}>
<div class="flex flex-wrap items-center gap-1.5 rounded-[var(--native-radius-md)] border border-border-weak-base bg-bg-muted/40 px-3 py-2 text-[13px] leading-5 text-text-weak">
<Icon name="configuration" size="small" />
Expand Down
119 changes: 116 additions & 3 deletions packages/app-ai-native/src/pages/store/components/subscribe-button.tsx
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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
}

/**
Expand Down Expand Up @@ -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 = (
<Tooltip value={props.labels.tooltip} placement="top">
<button
ref={buttonRef}
Expand Down Expand Up @@ -214,6 +257,76 @@ export function SubscribeButton(props: SubscribeButtonProps): JSX.Element {
</button>
</Tooltip>
)

// Non-skill items keep the original single-button control.
if (!props.invokeModeEnabled || !props.modeLabels) return pill

const ml = () => props.modeLabels as SubscribeModeLabels

return (
<span class="inline-flex items-center gap-1">
{pill}
<DropdownMenu placement="bottom-end">
<Tooltip value={ml().menuTooltip} placement="top">
<DropdownMenu.Trigger
type="button"
disabled={!interactive()}
onClick={(e: MouseEvent) => e.stopPropagation()}
aria-label={ml().menuTooltip}
class={cn(
"store-subscribe-btn inline-flex h-8 w-7 cursor-pointer items-center justify-center rounded-[var(--native-radius-full)] border text-[var(--native-foreground)]",
"[transition:background-color_0.25s_cubic-bezier(0.22,1,0.36,1),border-color_0.25s_cubic-bezier(0.22,1,0.36,1),transform_0.12s_cubic-bezier(0.34,1.56,0.64,1)]",
"active:scale-[0.94] disabled:cursor-not-allowed disabled:opacity-[var(--native-disabled-opacity)]",
props.favorited
? "border-[color:color-mix(in_oklab,var(--native-primary)_45%,transparent)] bg-[color:color-mix(in_oklab,var(--native-primary)_15%,transparent)] text-[var(--native-primary)]"
: "border-[color:color-mix(in_oklab,var(--native-foreground)_11%,transparent)] bg-[color:color-mix(in_oklab,var(--native-foreground)_4%,transparent)] hover:border-[var(--native-dim)]",
)}
>
<StoreIcon name="caret" size={13} />
</DropdownMenu.Trigger>
</Tooltip>
<DropdownMenu.Portal>
<DropdownMenu.Content class="min-w-[260px]">
<Show
when={props.favorited}
fallback={
<>
<DropdownMenu.Item onSelect={() => selectMode("auto")}>
<DropdownMenu.ItemLabel>{ml().subscribeAuto}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemDescription>{ml().subscribeAutoDesc}</DropdownMenu.ItemDescription>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => selectMode("manual")}>
<DropdownMenu.ItemLabel>{ml().subscribeManual}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemDescription>{ml().subscribeManualDesc}</DropdownMenu.ItemDescription>
</DropdownMenu.Item>
</>
}
>
{/* GroupLabel requires an enclosing Group (Kobalte throws otherwise). */}
<DropdownMenu.Group>
<DropdownMenu.GroupLabel>
{props.currentMode === "manual" ? ml().currentManual : ml().currentAuto}
</DropdownMenu.GroupLabel>
<DropdownMenu.Separator />
<Show
when={props.currentMode === "manual"}
fallback={
<DropdownMenu.Item onSelect={() => selectMode("manual")}>
<DropdownMenu.ItemLabel>{ml().switchToManual}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
}
>
<DropdownMenu.Item onSelect={() => selectMode("auto")}>
<DropdownMenu.ItemLabel>{ml().switchToAuto}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
</DropdownMenu.Group>
</Show>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</span>
)
}

export default SubscribeButton
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Show, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"

/**
* 「使用方法」区块:订阅后如何在 CoStrict (csc) / 其它 CS-IDE 中使用该资源。
*
* - skill 系(skill/command/subagent):文案随订阅模式联动(auto = AI 自动调用;
* manual = 仅手动 /name;未订阅 = 中性说明),并展示可手动触发的斜杠命令 `/<slug>`。
* - mcp / rule / template:各自的应用方式。
* - plugin:沿用详情页既有的「安装命令」区块,这里不重复渲染。
*/
export function UsageSection(props: {
itemType: string
/** slug used to form the `/name` slash command. */
name: string
invokeMode: "auto" | "manual" | null
favorited: boolean
}): JSX.Element {
const language = useLanguage()
// Mode-aware slash-command guidance only applies to skill / command (they honor
// disable-model-invocation). subagent gets its own static note.
const hasInvokeMode = () => ["skill", "command"].includes(props.itemType)
const slashCmd = () => `/${props.name}`

const lead = () => {
if (hasInvokeMode()) {
return props.invokeMode === "manual"
? language.t("store.detail.usage.skill.manual")
: language.t("store.detail.usage.skill.auto")
}
if (props.itemType === "subagent") return language.t("store.detail.usage.subagent")
if (props.itemType === "mcp") return language.t("store.detail.usage.mcp")
if (props.itemType === "rule") return language.t("store.detail.usage.rule")
if (props.itemType === "template") return language.t("store.detail.usage.template")
return ""
}

// 「使用方法」只在订阅后展示(订阅前不可见)。
return (
<Show when={props.favorited && props.itemType !== "plugin" && lead()}>
<div>
<div
class="mb-2 text-xs"
style={{
color: "color-mix(in srgb, var(--native-muted) 70%, var(--native-panel))",
"font-weight": 700,
}}
>
{language.t("store.detail.usage.sectionTitle")}
</div>
<div class="space-y-2 rounded-[var(--native-radius-md)] border border-border-weak-base bg-bg-muted/40 px-3 py-2.5 text-[13px] leading-6 text-text-weak">
<p>{lead()}</p>
<Show when={hasInvokeMode()}>
<code class="inline-block rounded-[var(--native-radius-sm)] border border-border-weak-base bg-bg-muted/60 px-2 py-1 text-12-mono text-text-strong">
{slashCmd()}
</code>
</Show>
</div>
</div>
</Show>
)
}

export default UsageSection
Loading
Loading