From 34d7c4446a5ae54c9806290c71e1959205d178c9 Mon Sep 17 00:00:00 2001 From: xd-bobo Date: Mon, 31 Aug 2026 18:04:30 +0800 Subject: [PATCH 01/15] feat(skillhub): connect clients to Cindy SkillHub Signed-off-by: xd-bobo --- README.md | 5 +++ .../__tests__/clientEndpointsService.test.ts | 1 + .../main/skillhub/__tests__/hubApi.test.ts | 13 ++++--- .../skillhub/__tests__/infoMapping.test.ts | 2 ++ apps/desktop/src/main/skillhub/hubApi.ts | 7 ++-- apps/desktop/src/main/skillhub/infoMapping.ts | 6 ++++ .../features/skillhub/SkillhubHomeView.tsx | 4 +-- .../skillhub/SkillhubMarketListView.tsx | 6 ++-- .../features/skillhub/hooks/useMarketList.ts | 6 +++- .../lib/__tests__/marketAccess.test.ts | 28 +++++---------- .../features/skillhub/lib/marketAccess.ts | 34 ++++--------------- .../src/test/vitest/clientEndpointsFixture.ts | 1 + .../__tests__/clientEndpointStartup.test.ts | 1 + config/endpoint.dev.json.example | 1 + config/endpoint.global.json | 1 + config/endpoint.json | 1 + .../src/__tests__/clientEndpoints.test.ts | 1 + .../clientEndpointsManifestFile.test.ts | 23 +++++++++++-- packages/maker-shared/src/clientEndpoints.ts | 7 +++- .../__tests__/endpoint-local-file.test.mjs | 3 ++ scripts/shared/endpoint-local-file.mjs | 2 ++ 21 files changed, 90 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 73961d49512..f7bf135bcf3 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,11 @@ developers don't need to self-host a server: sign in with your own Cindy account in a dev build and develop / test directly against the official servers. +Skill Hub migration uses two separate manifest fields: released clients keep +using `skillhubApiBaseUrl` (the deprecated XD proxy), while current clients use +`cindySkillHubApiBaseUrl`. The current client deliberately does not fall back to +the legacy field when the new endpoint is absent. + ## Architecture - [`DESIGN.md`](DESIGN.md) — visual design system, color tokens, and UI conventions diff --git a/apps/desktop/src/main/__tests__/clientEndpointsService.test.ts b/apps/desktop/src/main/__tests__/clientEndpointsService.test.ts index 8d4028d29d7..6fcb5866dc0 100644 --- a/apps/desktop/src/main/__tests__/clientEndpointsService.test.ts +++ b/apps/desktop/src/main/__tests__/clientEndpointsService.test.ts @@ -104,6 +104,7 @@ const FULL_MANIFEST = JSON.stringify({ voiceApiBaseUrl: 'https://voice.remote.example.com', githubApiBaseUrl: 'https://github-api.remote.example.com', skillhubApiBaseUrl: 'https://skillhub.remote.example.com', + cindySkillHubApiBaseUrl: 'https://cindy-skillhub.remote.example.com', pluginApiBaseUrl: 'https://plugin.remote.example.com', cdnBaseUrl: 'https://cdn.remote.example.com/app', mobileUpdateBaseUrl: 'https://mobile-update.remote.example.com', diff --git a/apps/desktop/src/main/skillhub/__tests__/hubApi.test.ts b/apps/desktop/src/main/skillhub/__tests__/hubApi.test.ts index d24ad3532c2..dabdf1a81fa 100644 --- a/apps/desktop/src/main/skillhub/__tests__/hubApi.test.ts +++ b/apps/desktop/src/main/skillhub/__tests__/hubApi.test.ts @@ -8,15 +8,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ serverApiFetch: vi.fn() })); vi.mock('../../serverApiClient', () => ({ serverApiFetch: mocks.serverApiFetch })); -vi.mock('../../clientEndpointsService', () => ({ - getClientEndpoint: () => 'https://skills.example.com', -})); +const endpoint = vi.hoisted(() => vi.fn(() => 'https://skills.example.com')); +vi.mock('../../clientEndpointsService', () => ({ getClientEndpoint: endpoint })); vi.mock('../../appCapabilities.js', () => ({ requireAppCapability: () => undefined })); import { skillhubApiFetch } from '../hubApi'; describe('skillhubApiFetch', () => { - beforeEach(() => mocks.serverApiFetch.mockReset()); + beforeEach(() => { + mocks.serverApiFetch.mockReset(); + endpoint.mockClear(); + }); it('给 serverApiFetch 传 logLabel=/api/skills-hub(不外泄 skill 身份)', async () => { mocks.serverApiFetch.mockResolvedValueOnce({ ok: true }); @@ -25,6 +27,9 @@ describe('skillhubApiFetch', () => { expect(opts.logLabel).toBe('/api/skills-hub'); // 不设 redactErrorDetails:SkillHub 依赖 ServerApiError.code 做业务分支,不能把 code 压成通用码。 expect(opts.redactErrorDetails).toBeUndefined(); + expect(endpoint).not.toHaveBeenCalled(); + expect(opts.baseUrl?.()).toBe('https://skills.example.com'); + expect(endpoint).toHaveBeenCalledWith('cindySkillHubApiBaseUrl'); }); it('调用方显式传的 logLabel 优先', async () => { diff --git a/apps/desktop/src/main/skillhub/__tests__/infoMapping.test.ts b/apps/desktop/src/main/skillhub/__tests__/infoMapping.test.ts index 62e7d2ba93d..6407b4cf622 100644 --- a/apps/desktop/src/main/skillhub/__tests__/infoMapping.test.ts +++ b/apps/desktop/src/main/skillhub/__tests__/infoMapping.test.ts @@ -6,6 +6,7 @@ describe('mapHubSkillInfoToDesktopInfo', () => { it('preserves category slugs from Hub detail responses', () => { const info = mapHubSkillInfoToDesktopInfo({ slug: 'lark-task', + icon: 'https://skillhub.example.test/assets/default-skill-icon.svg', displayName: 'Lark Task', summary: 'Market summary', description: 'Manage tasks', @@ -22,6 +23,7 @@ describe('mapHubSkillInfoToDesktopInfo', () => { }); expect(info.categories).toEqual(['engine', 'office']); + expect(info.icon).toBe('https://skillhub.example.test/assets/default-skill-icon.svg'); expect(info.description).toBe('Market summary'); expect(info.downloads).toBe(135); }); diff --git a/apps/desktop/src/main/skillhub/hubApi.ts b/apps/desktop/src/main/skillhub/hubApi.ts index 67b0902a1e4..fe954a2522a 100644 --- a/apps/desktop/src/main/skillhub/hubApi.ts +++ b/apps/desktop/src/main/skillhub/hubApi.ts @@ -1,6 +1,7 @@ /** * skillhub 业务的统一 server API 入口:所有 /api/skills-hub/* 调用固定打 - * 独立部署的 skillhub-server(clientEndpoints 'skillhubApiBaseUrl';老主 + * 独立部署的 cindy-skill-hub-server(clientEndpoints + * 'cindySkillHubApiBaseUrl';旧 'skillhubApiBaseUrl' 永久保留给已发布客户端;老主 * server 的 apiBaseUrl 已随 2026-07 收敛退役)。serverApiFetch 的 Bearer * 注入与 401 自动刷新链路不变。 * getClientEndpoint 每次调用时惰性求值——端点清单在 app.ready 内解析, @@ -17,7 +18,9 @@ export function skillhubApiFetch( requireAppCapability('canUseSkillHubCloud', 'SkillHub cloud requires a Cindy account.'); return serverApiFetch(apiPath, { ...opts, - baseUrl: () => getClientEndpoint('skillhubApiBaseUrl'), + // 新客户端绝不回退旧 skillhubApiBaseUrl:XD 身份的只读兼容由新服务自己 + // 路由,回退会让个人/其它组织误连只面向 XD 的旧服务。 + baseUrl: () => getClientEndpoint('cindySkillHubApiBaseUrl'), // skills-hub 的 path 都带用户/第三方 skill 身份(`/api/skills-hub/skills/[/download]`), // 4xx/5xx 落进 serverApiClient 的 not_ok 日志会外泄它。用不含身份的路由模板代替真实 path, // 并借此在日志里连 msg 一起省掉(2026-08-06 review)。这里**不**设 redactErrorDetails:SkillHub diff --git a/apps/desktop/src/main/skillhub/infoMapping.ts b/apps/desktop/src/main/skillhub/infoMapping.ts index 2ea399d1993..eb7889d020c 100644 --- a/apps/desktop/src/main/skillhub/infoMapping.ts +++ b/apps/desktop/src/main/skillhub/infoMapping.ts @@ -1,5 +1,6 @@ export interface HubSkillInfoForDesktop { slug: string; + icon?: string; displayName?: string; summary?: string | null; description?: string; @@ -17,6 +18,8 @@ export interface HubSkillInfoForDesktop { updatedAt: string; isMine?: boolean; categories?: Array<{ slug: string; name: string }>; + tags?: Array<{ slug: string; name: string }>; + githubUrl?: string; stats?: { downloads?: number; }; @@ -29,6 +32,7 @@ interface MapOptions { export function mapHubSkillInfoToDesktopInfo(hub: HubSkillInfoForDesktop, opts?: MapOptions) { return { name: hub.slug, + icon: hub.icon, displayName: hub.displayName ?? hub.slug, description: hub.summary ?? hub.description ?? '', authorId: hub.owner.slug, @@ -47,6 +51,8 @@ export function mapHubSkillInfoToDesktopInfo(hub: HubSkillInfoForDesktop, opts?: pendingVersion: hub.pendingVersion, visibleDeptIds: [] as string[], categories: (hub.categories ?? []).map((category) => category.slug), + tags: (hub.tags ?? hub.categories ?? []).map((tag) => ({ slug: tag.slug, name: tag.name })), + githubUrl: hub.githubUrl, publishedAt: hub.updatedAt, downloads: Number.isFinite(hub.stats?.downloads) ? hub.stats?.downloads ?? 0 : 0, latestPublishedFromDeviceId: null as string | null, diff --git a/apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx b/apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx index 9ff41df3bad..55aad964176 100644 --- a/apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx +++ b/apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx @@ -69,8 +69,8 @@ export function SkillhubHomeView({ const [query, setQuery] = useState(''); const normalizedQuery = query.trim().toLocaleLowerCase(); - // 市场可见性门禁:仅 xd 组织的企业账号可见 Skill Hub 入口与推荐安装; - // 其它账号(个人 / 非 xd 组织 / 未登录)只显示本地技能,且不发市场请求。 + // 登录后所有账号都请求 SkillHub;数据可见范围由服务端按已验证身份决定。 + // 未登录 / 本地模式没有云端凭证,只显示本地技能。 const { user } = useAuth(); const marketAllowed = canAccessSkillhubMarket(user); diff --git a/apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx b/apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx index 6eb4cb9057f..86d094c1cff 100644 --- a/apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx +++ b/apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx @@ -75,9 +75,9 @@ function FilterChip({ } /** - * 市场路由门禁包装:市场不可见的账号(个人 / 非 xd 组织,见 lib/marketAccess.ts) - * 通过深链 / 历史记录直达 /skillhub/market 时,重定向回本地技能首页。 - * 登录态初始化期间(user 尚未水合)不误判,先按原样渲染。 + * 未登录 / 本地模式没有 SkillHub 云端凭证,深链返回本地技能首页。 + * 登录后不再做组织白名单判断;Skill 数据的可见范围由服务端决定。 + * 登录态初始化期间(user 尚未水合)不误判,先按原样渲染。 */ export function SkillhubMarketListView() { const { user, isInitializing } = useAuth(); diff --git a/apps/desktop/src/renderer/features/skillhub/hooks/useMarketList.ts b/apps/desktop/src/renderer/features/skillhub/hooks/useMarketList.ts index 37f02627c1b..37be49d622d 100644 --- a/apps/desktop/src/renderer/features/skillhub/hooks/useMarketList.ts +++ b/apps/desktop/src/renderer/features/skillhub/hooks/useMarketList.ts @@ -53,6 +53,8 @@ export type MarketCardState = export interface MarketSkill { /** 服务端主键 = name,前端 list key 用它。 */ name: string; + /** Skill 图标 URL;旧服务响应缺失时保持 undefined。 */ + icon?: string; displayName: string; description: string; authorName: string; @@ -95,6 +97,7 @@ export interface MarketSkill { interface ServerListItem { name: string; + icon?: string; displayName: string; description: string; authorId: string; @@ -204,6 +207,7 @@ function mapServerToView( ); return { name: item.name, + icon: item.icon, displayName: item.displayName, description: item.description, authorName: item.authorName, @@ -264,7 +268,7 @@ export function useMarketList( options?: { /** * false 时完全不发市场请求(items 保持空、loading 保持 false)。 - * 供市场不可见的账号(见 lib/marketAccess.ts)跳过网络与骨架屏;翻回 true 后自动补拉。 + * 供未登录 / 本地模式跳过云端请求与骨架屏;登录后自动补拉。 */ enabled?: boolean; }, diff --git a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketAccess.test.ts b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketAccess.test.ts index 22f269c557d..9c4f4b8b4b7 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketAccess.test.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketAccess.test.ts @@ -10,6 +10,7 @@ function user( }> = {}, ) { return { + id: 'membership-1', membershipKind: 'org' as const, orgName: null, orgSlug: null, @@ -18,30 +19,17 @@ function user( } describe('canAccessSkillhubMarket', () => { - it('allows xd org members by orgSlug', () => { - expect(canAccessSkillhubMarket(user({ orgSlug: 'xd', orgName: '心动' }))).toBe(true); - }); - - it('denies non-xd org slugs regardless of display name', () => { - expect(canAccessSkillhubMarket(user({ orgSlug: 'disco-corp', orgName: 'xd' }))).toBe(false); - // slug 只做全等匹配,不做包含匹配 - expect(canAccessSkillhubMarket(user({ orgSlug: 'xd-partner' }))).toBe(false); + it('allows personal accounts', () => { + expect(canAccessSkillhubMarket(user({ membershipKind: 'personal' }))).toBe(true); }); - it('falls back to orgName equality only when orgSlug claim is missing', () => { - expect(canAccessSkillhubMarket(user({ orgSlug: null, orgName: 'xd' }))).toBe(true); - expect(canAccessSkillhubMarket(user({ orgSlug: null, orgName: ' XD ' }))).toBe(true); - expect(canAccessSkillhubMarket(user({ orgSlug: null, orgName: 'Disco Corp' }))).toBe(false); - expect(canAccessSkillhubMarket(user({ orgSlug: null, orgName: null }))).toBe(false); - }); - - it('denies personal accounts even with stale org fields (fail-closed)', () => { - expect( - canAccessSkillhubMarket(user({ membershipKind: 'personal', orgSlug: 'xd', orgName: 'xd' })), - ).toBe(false); + it('allows every organization without inspecting its slug or display name', () => { + expect(canAccessSkillhubMarket(user({ orgSlug: 'xd', orgName: '心动' }))).toBe(true); + expect(canAccessSkillhubMarket(user({ orgSlug: 'disco-corp', orgName: 'Disco Corp' }))).toBe(true); + expect(canAccessSkillhubMarket(user({ orgSlug: null, orgName: null }))).toBe(true); }); - it('denies missing login state (fail-closed)', () => { + it('does not request cloud data without a logged-in Cindy account', () => { expect(canAccessSkillhubMarket(null)).toBe(false); }); }); diff --git a/apps/desktop/src/renderer/features/skillhub/lib/marketAccess.ts b/apps/desktop/src/renderer/features/skillhub/lib/marketAccess.ts index b0eb2e29935..750dbb00168 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/marketAccess.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/marketAccess.ts @@ -1,32 +1,12 @@ -/** - * marketAccess — Skill Hub 市场(浏览入口 / 推荐安装 / market 路由)的可见性门禁。 - * - * 市场当前是 xd 组织的内部市场:仅 xd 组织的企业(org)成员可见。 - * 个人账号、非 xd 组织的企业账号、未登录 / 登录态未就绪一律不可见(fail-closed), - * 技能页只保留本地技能管理。 - * - * 判据:主判据是 `orgSlug`(access token 的 orgSlug claim,auth-server 由已验证 - * 域名派生、全局唯一,xd.com → 'xd');orgId 是 cuid、orgName 是可重名的显示名, - * 都不适合当配置键。仅当 orgSlug 缺失(旧版 auth-server token 无此 claim)时, - * 才回退用 orgName 全等匹配兜底。 - */ - -/** 允许访问市场的组织 slug。 */ -const SKILLHUB_MARKET_ORG_SLUG = 'xd'; - interface MarketAccessUser { - membershipKind: 'personal' | 'org'; - orgName: string | null; - orgSlug: string | null; + id: string; } -/** 当前登录用户是否可见 Skill Hub 市场内容(null = 未登录,按不可见处理)。 */ +/** + * Skill Hub 只在 Cindy 云账号登录后发起请求。 + * 账号类型、组织和 Skill 可见范围均由 SkillHub 服务端根据已验证身份裁决, + * 客户端不再维护组织白名单。 + */ export function canAccessSkillhubMarket(user: MarketAccessUser | null): boolean { - if (!user || user.membershipKind !== 'org') return false; - if (user.orgSlug !== null) return user.orgSlug === SKILLHUB_MARKET_ORG_SLUG; - // 旧 token 无 orgSlug claim 的兜底:显示名全等匹配(大小写不敏感)。 - return ( - user.orgName !== null && - user.orgName.trim().toLocaleLowerCase() === SKILLHUB_MARKET_ORG_SLUG - ); + return user !== null; } diff --git a/apps/desktop/src/test/vitest/clientEndpointsFixture.ts b/apps/desktop/src/test/vitest/clientEndpointsFixture.ts index 2a857fe7686..50f0098c0c0 100644 --- a/apps/desktop/src/test/vitest/clientEndpointsFixture.ts +++ b/apps/desktop/src/test/vitest/clientEndpointsFixture.ts @@ -38,6 +38,7 @@ export const TEST_CLIENT_ENDPOINTS: ClientEndpointMap = { voiceApiBaseUrl: 'https://voice.test.invalid', githubApiBaseUrl: 'https://github-api.test.invalid', skillhubApiBaseUrl: 'https://skillhub.test.invalid', + cindySkillHubApiBaseUrl: 'https://cindy-skillhub.test.invalid', pluginApiBaseUrl: 'https://plugin.test.invalid', cdnBaseUrl: TEST_CDN_BASE_URL, mobileUpdateBaseUrl: 'https://mobile-update.test.invalid', diff --git a/apps/mobile/src/__tests__/clientEndpointStartup.test.ts b/apps/mobile/src/__tests__/clientEndpointStartup.test.ts index 53b593d2f54..dac7c81c42a 100644 --- a/apps/mobile/src/__tests__/clientEndpointStartup.test.ts +++ b/apps/mobile/src/__tests__/clientEndpointStartup.test.ts @@ -35,6 +35,7 @@ const FULL_MANIFEST_OBJECT = { voiceApiBaseUrl: 'https://voice-next.example.com', githubApiBaseUrl: 'https://github-api-next.example.com', skillhubApiBaseUrl: 'https://skillhub-next.example.com', + cindySkillHubApiBaseUrl: 'https://cindy-skillhub-next.example.com', cdnBaseUrl: 'https://cdn-next.example.com/app', mobileUpdateBaseUrl: 'https://mobile-update-next.example.com', }; diff --git a/config/endpoint.dev.json.example b/config/endpoint.dev.json.example index 826452f47ee..4f8b901b1d0 100644 --- a/config/endpoint.dev.json.example +++ b/config/endpoint.dev.json.example @@ -13,6 +13,7 @@ "voiceApiBaseUrl": "http://localhost:3342", "githubApiBaseUrl": "http://localhost:3336", "skillhubApiBaseUrl": "http://localhost:3341", + "cindySkillHubApiBaseUrl": "http://localhost:3345", "pluginApiBaseUrl": "http://localhost:3343", "cdnBaseUrl": "http://localhost:3345/cindy", "mobileUpdateBaseUrl": "http://localhost:3346", diff --git a/config/endpoint.global.json b/config/endpoint.global.json index 441e1f99ab9..9f5d4afcb03 100644 --- a/config/endpoint.global.json +++ b/config/endpoint.global.json @@ -13,6 +13,7 @@ "voiceApiBaseUrl": "https://voice.cindy.app", "githubApiBaseUrl": "https://github.cindy.app", "skillhubApiBaseUrl": "https://xd-skillhub.cindy.app", + "cindySkillHubApiBaseUrl": "https://skill-hub.cindy.app", "pluginApiBaseUrl": "https://plugin.cindy.app", "cdnBaseUrl": "https://hotfix.cindy.app/cindy", "mobileUpdateBaseUrl": "https://mobile-update.cindy.app", diff --git a/config/endpoint.json b/config/endpoint.json index f7ee5664cd7..15fa99b7fcb 100644 --- a/config/endpoint.json +++ b/config/endpoint.json @@ -13,6 +13,7 @@ "voiceApiBaseUrl": "https://voice.cindy.com.cn", "githubApiBaseUrl": "https://github.cindy.com.cn", "skillhubApiBaseUrl": "https://xd-skillhub.cindy.com.cn", + "cindySkillHubApiBaseUrl": "https://skill-hub.cindy.com.cn", "pluginApiBaseUrl": "https://plugin.cindy.com.cn", "cdnBaseUrl": "https://hotfix.cindy.com.cn/cindy", "mobileUpdateBaseUrl": "https://mobile-update.cindy.com.cn", diff --git a/packages/maker-shared/src/__tests__/clientEndpoints.test.ts b/packages/maker-shared/src/__tests__/clientEndpoints.test.ts index 60dce8d4ff5..6403cf1f55b 100644 --- a/packages/maker-shared/src/__tests__/clientEndpoints.test.ts +++ b/packages/maker-shared/src/__tests__/clientEndpoints.test.ts @@ -24,6 +24,7 @@ const VALID_MANIFEST = { voiceApiBaseUrl: 'https://voice.example.com', githubApiBaseUrl: 'https://github-api.example.com', skillhubApiBaseUrl: 'https://skillhub.example.com', + cindySkillHubApiBaseUrl: 'https://cindy-skillhub.example.com', pluginApiBaseUrl: 'https://plugin.example.com', cdnBaseUrl: 'https://cdn.example.com/app', mobileUpdateBaseUrl: 'https://mobile-update.example.com', diff --git a/packages/maker-shared/src/__tests__/clientEndpointsManifestFile.test.ts b/packages/maker-shared/src/__tests__/clientEndpointsManifestFile.test.ts index baa275d32e2..aafada1b2f0 100644 --- a/packages/maker-shared/src/__tests__/clientEndpointsManifestFile.test.ts +++ b/packages/maker-shared/src/__tests__/clientEndpointsManifestFile.test.ts @@ -30,11 +30,21 @@ import { const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); const MANIFESTS = [ - { label: 'cn', filePath: path.join(REPO_ROOT, 'config', 'endpoint.json') }, - { label: 'global', filePath: path.join(REPO_ROOT, 'config', 'endpoint.global.json') }, + { + label: 'cn', + filePath: path.join(REPO_ROOT, 'config', 'endpoint.json'), + legacySkillHub: 'https://xd-skillhub.cindy.com.cn', + cindySkillHub: 'https://skill-hub.cindy.com.cn', + }, + { + label: 'global', + filePath: path.join(REPO_ROOT, 'config', 'endpoint.global.json'), + legacySkillHub: 'https://xd-skillhub.cindy.app', + cindySkillHub: 'https://skill-hub.cindy.app', + }, ] as const; -describe.each(MANIFESTS)('config/endpoint*.json 守门($label)', ({ filePath }) => { +describe.each(MANIFESTS)('config/endpoint*.json 守门($label)', ({ filePath, legacySkillHub, cindySkillHub }) => { const rawText = fs.readFileSync(filePath, 'utf8'); it('必须能被客户端共享 parser 接受(JSON/schema/非空 URL 仍需合法)', () => { @@ -47,6 +57,13 @@ describe.each(MANIFESTS)('config/endpoint*.json 守门($label)', ({ filePath }) expect(parsed.schemaVersion).toBe(CLIENT_ENDPOINTS_SCHEMA_VERSION); }); + it('旧客户端地址保持不变,新客户端使用独立字段', () => { + const parsed = JSON.parse(rawText) as Record; + expect(parsed.skillhubApiBaseUrl).toBe(legacySkillHub); + expect(parsed.cindySkillHubApiBaseUrl).toBe(cindySkillHub); + expect(parsed.cindySkillHubApiBaseUrl).not.toBe(parsed.skillhubApiBaseUrl); + }); + it('无未知字段(字段名拼错会被客户端当未知字段忽略,静默不生效)', () => { const parsed = JSON.parse(rawText) as Record; const keys = Object.keys(parsed).filter( diff --git a/packages/maker-shared/src/clientEndpoints.ts b/packages/maker-shared/src/clientEndpoints.ts index bd657400444..533e207eebe 100644 --- a/packages/maker-shared/src/clientEndpoints.ts +++ b/packages/maker-shared/src/clientEndpoints.ts @@ -111,8 +111,12 @@ export const CLIENT_ENDPOINT_KEYS = [ 'voiceApiBaseUrl', // github-server(用户反馈 → 官方仓 GitHub issue 的薄代理)的 API 基址。 'githubApiBaseUrl', - // skillhub-server(SkillHub 技能市场/发布 → XD hub 的 S2S 代理)的 API 基址。 + // 旧 xd-skillhub-server 的 API 基址。已发布客户端继续消费这个字段,值不得 + // 改指新服务;新客户端只消费下方 cindySkillHubApiBaseUrl。 'skillhubApiBaseUrl', + // cindy-skill-hub-server 的 API 基址。纯增可选字段,不 bump schemaVersion: + // 老客户端按未知字段忽略,新客户端缺失时明确关闭云端 Skill Hub,不回退旧地址。 + 'cindySkillHubApiBaseUrl', // plugin-server(Plugin/Skill 市场、组织管理与发布)的 API 基址。 'pluginApiBaseUrl', // 更新/hotfix 链的 CDN base(manifest-*.json / hotfix 包 / agent 二进制)。 @@ -176,6 +180,7 @@ const FIELD_PROTOCOLS: Record = { voiceApiBaseUrl: ['https:'], githubApiBaseUrl: ['https:'], skillhubApiBaseUrl: ['https:'], + cindySkillHubApiBaseUrl: ['https:'], pluginApiBaseUrl: ['https:'], cdnBaseUrl: ['https:'], mobileUpdateBaseUrl: ['https:'], diff --git a/scripts/__tests__/endpoint-local-file.test.mjs b/scripts/__tests__/endpoint-local-file.test.mjs index ce23843fbd8..fbae6c753b1 100644 --- a/scripts/__tests__/endpoint-local-file.test.mjs +++ b/scripts/__tests__/endpoint-local-file.test.mjs @@ -39,6 +39,7 @@ const CN_MANIFEST = JSON.stringify({ voiceApiBaseUrl: 'https://voice.example.invalid', githubApiBaseUrl: 'https://github-api.example.invalid', skillhubApiBaseUrl: 'https://skillhub.example.invalid', + cindySkillHubApiBaseUrl: 'https://cindy-skillhub.example.invalid', pluginApiBaseUrl: 'https://plugin.example.invalid', cdnBaseUrl: 'https://cdn.example.invalid/app', mobileUpdateBaseUrl: 'https://mobile-update.example.invalid', @@ -61,6 +62,7 @@ test('localhost 八件套覆写,默认其余字段照抄 Global 正本,返回绝 assert.equal(local.voiceApiBaseUrl, 'http://localhost:3342'); assert.equal(local.githubApiBaseUrl, 'http://localhost:3336'); assert.equal(local.skillhubApiBaseUrl, 'http://localhost:3341'); + assert.equal(local.cindySkillHubApiBaseUrl, 'http://localhost:3345'); assert.equal(local.pluginApiBaseUrl, 'http://localhost:3343'); // 其余字段与正本一致(oauth broker 等本地不起的服务沿用远程值) assert.equal(local.oauthBrokerApiBaseUrl, 'https://oauth.global.example.invalid'); @@ -117,6 +119,7 @@ test('生成物能过客户端 parser 的 allowHttp 校验(与仓内正本同一 'voiceApiBaseUrl', 'githubApiBaseUrl', 'skillhubApiBaseUrl', + 'cindySkillHubApiBaseUrl', 'pluginApiBaseUrl', 'cdnBaseUrl', 'mobileUpdateBaseUrl', diff --git a/scripts/shared/endpoint-local-file.mjs b/scripts/shared/endpoint-local-file.mjs index 0daf5db0b7d..39db1c9f272 100644 --- a/scripts/shared/endpoint-local-file.mjs +++ b/scripts/shared/endpoint-local-file.mjs @@ -25,6 +25,7 @@ const LOCAL_MODEL_ACCESS_BASE_URL = 'http://localhost:3339'; const LOCAL_VOICE_BASE_URL = 'http://localhost:3342'; const LOCAL_GITHUB_BASE_URL = 'http://localhost:3336'; const LOCAL_SKILLHUB_BASE_URL = 'http://localhost:3341'; +const LOCAL_CINDY_SKILLHUB_BASE_URL = 'http://localhost:3345'; const LOCAL_PLUGIN_BASE_URL = 'http://localhost:3343'; /** @@ -76,6 +77,7 @@ export function generateEndpointLocalFile({ repoRoot, region = 'global' }) { voiceApiBaseUrl: LOCAL_VOICE_BASE_URL, githubApiBaseUrl: LOCAL_GITHUB_BASE_URL, skillhubApiBaseUrl: LOCAL_SKILLHUB_BASE_URL, + cindySkillHubApiBaseUrl: LOCAL_CINDY_SKILLHUB_BASE_URL, pluginApiBaseUrl: LOCAL_PLUGIN_BASE_URL, }; fs.writeFileSync(targetPath, `${JSON.stringify(local, null, 2)}\n`); From 01a21b105462cb086915558d7bbf5ea7e40083ed Mon Sep 17 00:00:00 2001 From: xd-bobo Date: Mon, 31 Aug 2026 18:37:11 +0800 Subject: [PATCH 02/15] fix(skillhub): remove legacy XD visibility copy Signed-off-by: xd-bobo --- .../skillhub/lib/__tests__/marketVisibility.test.ts | 13 +++++++++++++ .../src/renderer/i18n/locales/en/common.json | 8 ++++---- .../src/renderer/i18n/locales/ja/common.json | 8 ++++---- .../src/renderer/i18n/locales/ko/common.json | 8 ++++---- .../src/renderer/i18n/locales/zh-CN/common.json | 8 ++++---- .../src/renderer/i18n/locales/zh-TW/common.json | 8 ++++---- 6 files changed, 33 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketVisibility.test.ts b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketVisibility.test.ts index bfdeb1ab306..bdfc5032d50 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketVisibility.test.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketVisibility.test.ts @@ -1,8 +1,21 @@ import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; import { marketVisibilityLabelKey, marketVisibilitySubtitleKey } from '../marketVisibility'; +const SKILLHUB_LOCALES = ['zh-CN', 'zh-TW', 'en', 'ja', 'ko'] as const; + describe('marketVisibilityLabelKey', () => { + it('does not expose legacy XD branding in SkillHub copy', () => { + for (const locale of SKILLHUB_LOCALES) { + const common = JSON.parse(readFileSync( + new URL(`../../../../i18n/locales/${locale}/common.json`, import.meta.url), + 'utf8', + )) as { skillhub: unknown }; + expect(JSON.stringify(common.skillhub)).not.toMatch(/XD\.Inc/i); + } + }); + it('keeps existing public and department labels', () => { expect(marketVisibilityLabelKey({ visibility: 'PUBLIC', diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 77cf33e2591..8df89d9121e 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -10169,7 +10169,7 @@ "installError": "Unknown error" }, "marketCard": { - "visibilityPublic": "XD.Inc", + "visibilityPublic": "Public", "visibilityDept": "Team", "visibilityPrivate": "Personal", "timeLabel": "Published time", @@ -10205,7 +10205,7 @@ "emptyHint": "This cloud Skill did not return previewable files" }, "marketDetail": { - "visibilityPublic": "Visible to XD.Inc", + "visibilityPublic": "Visible to all users", "visibilityPrivate": "Personal", "visibilityDept": "Team visible", "files": "Files", @@ -10260,7 +10260,7 @@ "descriptionPlaceholder": "Briefly describe this skill", "visibilityLabel": "Visibility", "visibilityPublicTitle": "Public", - "visibilityPublicDesc": "Visible to the whole company · Default", + "visibilityPublicDesc": "Visible to all users · Default", "versionLabel": "Version", "versionFormatHint": "Format: x.y.z (e.g. 1.0.1)", "categoryLabel": "Category", @@ -10472,7 +10472,7 @@ "title": "Manage visibility", "tierLabel": "Visibility", "tierPublic": "Public", - "tierPublicDesc": "Visible to the whole company", + "tierPublicDesc": "Visible to all users", "tierTeam": "Team", "tierTeamDesc": "Visible to selected teams", "tierPrivate": "Only me", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index ce74e82390b..5ac7b3e5f7c 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -10147,7 +10147,7 @@ "installError": "不明なエラー" }, "marketCard": { - "visibilityPublic": "XD.Inc", + "visibilityPublic": "公開", "visibilityDept": "チーム", "visibilityPrivate": "個人", "timeLabel": "公開日時", @@ -10183,7 +10183,7 @@ "emptyHint": "このクラウド Skill からプレビュー可能なファイルが返されませんでした" }, "marketDetail": { - "visibilityPublic": "XD.Inc に公開", + "visibilityPublic": "すべてのユーザーに公開", "visibilityPrivate": "個人のみ", "visibilityDept": "チームに公開", "files": "ファイル", @@ -10238,7 +10238,7 @@ "descriptionPlaceholder": "この skill の用途を簡潔に", "visibilityLabel": "可視性", "visibilityPublicTitle": "公開", - "visibilityPublicDesc": "全社に公開 · デフォルト", + "visibilityPublicDesc": "すべてのユーザーに公開 · デフォルト", "versionLabel": "バージョン", "versionFormatHint": "形式:x.y.z(例:1.0.1)", "categoryLabel": "カテゴリ", @@ -10450,7 +10450,7 @@ "title": "公開設定を管理", "tierLabel": "可視性", "tierPublic": "公開", - "tierPublicDesc": "全社に公開", + "tierPublicDesc": "すべてのユーザーに公開", "tierTeam": "チーム", "tierTeamDesc": "指定したチームに公開", "tierPrivate": "自分のみ", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 84e5ecccf1d..0e1267d7d70 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -10147,7 +10147,7 @@ "installError": "알 수 없는 오류" }, "marketCard": { - "visibilityPublic": "XD.Inc", + "visibilityPublic": "공개", "visibilityDept": "팀", "visibilityPrivate": "개인", "timeLabel": "게시 시간", @@ -10183,7 +10183,7 @@ "emptyHint": "이 클라우드 Skill 에서 미리 볼 수 있는 파일을 반환하지 않았습니다" }, "marketDetail": { - "visibilityPublic": "XD.Inc 에 공개", + "visibilityPublic": "모든 사용자에게 공개", "visibilityPrivate": "개인 공개", "visibilityDept": "팀 공개", "files": "파일", @@ -10238,7 +10238,7 @@ "descriptionPlaceholder": "이 skill 의 용도를 간단히 설명", "visibilityLabel": "가시성", "visibilityPublicTitle": "공개", - "visibilityPublicDesc": "회사 전체 공개 · 기본값", + "visibilityPublicDesc": "모든 사용자에게 공개 · 기본값", "versionLabel": "버전", "versionFormatHint": "형식: x.y.z (예: 1.0.1)", "categoryLabel": "분류", @@ -10450,7 +10450,7 @@ "title": "공개 설정 관리", "tierLabel": "가시성", "tierPublic": "공개", - "tierPublicDesc": "회사 전체 공개", + "tierPublicDesc": "모든 사용자에게 공개", "tierTeam": "팀", "tierTeamDesc": "지정한 팀에 공개", "tierPrivate": "나만 사용", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index cdfe392d58c..5f90324a284 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -10139,7 +10139,7 @@ "installError": "未知错误" }, "marketCard": { - "visibilityPublic": "XD.Inc", + "visibilityPublic": "公开", "visibilityDept": "团队", "visibilityPrivate": "个人", "timeLabel": "发布时间", @@ -10175,7 +10175,7 @@ "emptyHint": "这个云端 Skill 没有返回可预览文件" }, "marketDetail": { - "visibilityPublic": "XD.Inc 可见", + "visibilityPublic": "所有用户可见", "visibilityPrivate": "个人可见", "visibilityDept": "团队可见", "files": "文件", @@ -10230,7 +10230,7 @@ "descriptionPlaceholder": "简要描述该 Skill 的用途", "visibilityLabel": "可见性", "visibilityPublicTitle": "公开", - "visibilityPublicDesc": "全公司可见 · 默认", + "visibilityPublicDesc": "所有用户可见 · 默认", "versionLabel": "版本号", "versionFormatHint": "格式:x.y.z (例如 1.0.1)", "categoryLabel": "分类", @@ -10442,7 +10442,7 @@ "title": "管理可见性", "tierLabel": "可见性", "tierPublic": "公开", - "tierPublicDesc": "全公司可见", + "tierPublicDesc": "所有用户可见", "tierTeam": "给团队使用", "tierTeamDesc": "指定团队可见", "tierPrivate": "仅自己使用", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json index 40e2658bf50..d91592adf4e 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -10139,7 +10139,7 @@ "installError": "未知錯誤" }, "marketCard": { - "visibilityPublic": "XD.Inc", + "visibilityPublic": "公開", "visibilityDept": "團隊", "visibilityPrivate": "個人", "timeLabel": "釋出時間", @@ -10175,7 +10175,7 @@ "emptyHint": "這個雲端 Skill 沒有返回可預覽檔案" }, "marketDetail": { - "visibilityPublic": "XD.Inc 可見", + "visibilityPublic": "所有使用者可見", "visibilityPrivate": "個人可見", "visibilityDept": "團隊可見", "files": "檔案", @@ -10230,7 +10230,7 @@ "descriptionPlaceholder": "簡要描述該 Skill 的用途", "visibilityLabel": "可見性", "visibilityPublicTitle": "公開", - "visibilityPublicDesc": "全公司可見 · 預設", + "visibilityPublicDesc": "所有使用者可見 · 預設", "versionLabel": "版本號", "versionFormatHint": "格式:x.y.z (例如 1.0.1)", "categoryLabel": "分類", @@ -10442,7 +10442,7 @@ "title": "管理可見性", "tierLabel": "可見性", "tierPublic": "公開", - "tierPublicDesc": "全公司可見", + "tierPublicDesc": "所有使用者可見", "tierTeam": "給團隊使用", "tierTeamDesc": "指定團隊可見", "tierPrivate": "僅自己使用", From c16e9f379d7ccaa16fea14c3963ff6515b4eab2f Mon Sep 17 00:00:00 2001 From: xd-bobo Date: Tue, 1 Sep 2026 10:54:58 +0800 Subject: [PATCH 03/15] feat(skillhub): add scoped catalog tabs Signed-off-by: xd-bobo --- .../skillhub/__tests__/marketService.test.ts | 16 ++ .../src/main/skillhub/marketService.ts | 3 +- apps/desktop/src/preload/preload.ts | 1 + .../features/skillhub/SkillhubHomeView.tsx | 133 ++++++++++++--- .../skillhub/SkillhubMarketListView.tsx | 120 ++----------- .../features/skillhub/hooks/useMarketList.ts | 44 ++++- .../skillhub/hooks/useMarketManagement.tsx | 157 ++++++++++++++++++ .../lib/__tests__/homeMarketFilter.test.ts | 66 ++++++++ .../lib/__tests__/manageGuard.test.ts | 1 + .../lib/__tests__/marketRoutes.test.ts | 2 +- .../lib/__tests__/mineGrouping.test.ts | 8 +- .../features/skillhub/lib/homeMarketFilter.ts | 49 ++++++ .../features/skillhub/lib/manageGuard.ts | 4 +- .../features/skillhub/lib/mineGrouping.ts | 4 +- .../src/renderer/i18n/locales/en/common.json | 10 +- .../src/renderer/i18n/locales/ja/common.json | 10 +- .../src/renderer/i18n/locales/ko/common.json | 10 +- .../renderer/i18n/locales/zh-CN/common.json | 10 +- .../renderer/i18n/locales/zh-TW/common.json | 10 +- apps/desktop/src/renderer/vite-env.d.ts | 1 + 20 files changed, 504 insertions(+), 155 deletions(-) create mode 100644 apps/desktop/src/renderer/features/skillhub/hooks/useMarketManagement.tsx create mode 100644 apps/desktop/src/renderer/features/skillhub/lib/__tests__/homeMarketFilter.test.ts create mode 100644 apps/desktop/src/renderer/features/skillhub/lib/homeMarketFilter.ts diff --git a/apps/desktop/src/main/skillhub/__tests__/marketService.test.ts b/apps/desktop/src/main/skillhub/__tests__/marketService.test.ts index 3859d5f7ba1..0b0a6c58c00 100644 --- a/apps/desktop/src/main/skillhub/__tests__/marketService.test.ts +++ b/apps/desktop/src/main/skillhub/__tests__/marketService.test.ts @@ -143,6 +143,22 @@ describe('SkillhubMarketService', () => { }); }); + it('passes public and organization catalog scopes to the Hub', async () => { + const { fetch, calls } = makeFetch([ + { items: [makeHubSkill('public-skill')], total: 1 }, + { items: [makeHubSkill('organization-skill', { visibility: 'shared' })], total: 1 }, + ]); + const service = new SkillhubMarketService({ fetch }); + + await service.listMarket({ scope: 'market', sort: 'trending' }); + await service.listMarket({ scope: 'team', sort: 'trending' }); + + expect(calls.map((call) => call.path)).toEqual([ + '/api/skills-hub/skills?page=1&pageSize=24&sort=trending&order=desc&scope=market', + '/api/skills-hub/skills?page=1&pageSize=24&sort=trending&order=desc&scope=team', + ]); + }); + it('builds detail, file preview, visibility, and scan routes', async () => { const { fetch, calls } = makeFetch([ makeHubSkill('demo/skill'), diff --git a/apps/desktop/src/main/skillhub/marketService.ts b/apps/desktop/src/main/skillhub/marketService.ts index bc724f7eb16..28dd98c39b4 100644 --- a/apps/desktop/src/main/skillhub/marketService.ts +++ b/apps/desktop/src/main/skillhub/marketService.ts @@ -45,6 +45,7 @@ export interface ListMarketParams { limit?: number; sort?: 'trending' | 'downloads' | 'updated_at' | 'created_at'; q?: string; + scope?: 'all' | 'market' | 'team'; mine?: boolean; available?: boolean; category?: string; @@ -114,7 +115,7 @@ export class SkillhubMarketService { }; } - search.set('scope', 'all'); + search.set('scope', params?.scope ?? 'all'); const qs = search.toString(); const hubResult = await this.fetch<{ items: HubSkillInfoForDesktop[]; total: number }>( params?.available diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index ffcf4d3e980..5c23c77bd70 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2933,6 +2933,7 @@ contextBridge.exposeInMainWorld('electronAPI', { limit?: number; sort?: 'trending' | 'downloads' | 'updated_at' | 'created_at'; q?: string; + scope?: 'all' | 'market' | 'team'; mine?: boolean; available?: boolean; category?: string; diff --git a/apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx b/apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx index 55aad964176..11e06777bd3 100644 --- a/apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx +++ b/apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx @@ -9,7 +9,7 @@ * 三块都是整页内容卡片/列表;首页是栈底,自身无返回。 */ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { @@ -35,9 +35,17 @@ import { canAccessSkillhubMarket } from './lib/marketAccess'; import { buildLocalSkillRoute, findLocalSkillByPath } from './lib/localRoutes'; import { refresh as refreshSkillhub, useSkillhub } from './hooks/useSkillhub'; import { useMarketList, type MarketSkill } from './hooks/useMarketList'; +import { MarketManagementDialogs, useMarketManagement } from './hooks/useMarketManagement'; import { basename, deriveProjectWorkingDir } from './lib/pathDerivations'; import { projectHash } from './lib/projectHash'; import { marketCardPrimaryAction } from './lib/marketDetailViewModel'; +import { + homeMarketQuery, + isHomeMarketResponseCurrent, + matchesHomeMarketFilter, + visibleHomeMarketFilters, + type HomeMarketFilter, +} from './lib/homeMarketFilter'; import { deriveSkillSource } from './lib/skillSource'; import { InstallTargetPicker, type InstallTargetSkill } from './components/InstallTargetPicker'; import { SkillhubMarketPreviewPanel } from './SkillhubMarketPreviewPanel'; @@ -48,8 +56,8 @@ const KIND_ICON: Record = { agent: Bot, }; -/** 推荐区展示条数(market trending 前 N)。 */ -const RECOMMENDED_LIMIT = 8; +/** 主 Skill Tab 每个云端目录最多展示的条数。 */ +const HOME_CATALOG_LIMIT = 8; function includesSkillQuery(values: ReadonlyArray, query: string): boolean { if (!query) return true; @@ -73,27 +81,53 @@ export function SkillhubHomeView({ // 未登录 / 本地模式没有云端凭证,只显示本地技能。 const { user } = useAuth(); const marketAllowed = canAccessSkillhubMarket(user); + const showOrganization = user?.membershipKind === 'org'; + const [marketFilter, setMarketFilter] = useState('public'); + const marketRequest = useMemo(() => homeMarketQuery(marketFilter), [marketFilter]); - // 推荐 = market trending 前 N(默认排序是 updated_at,挂载时切到 trending)。 + // 主 Skill Tab 只展示各云端目录的首批摘要,完整分页仍由 SkillHub 市场页承担。 const { items: marketItems, loading: marketLoading, + resolvedScope, + resolvedMine, + setSearchQuery, setSortBy, - } = useMarketList('available', { enabled: marketAllowed }); + setCatalogScope, + setVisibility, + reload: reloadMarket, + } = useMarketList('all', { + enabled: marketAllowed, + initialScope: 'market', + initialSort: 'trending', + }); + useEffect(() => { + setCatalogScope(marketRequest.scope); + setVisibility(marketRequest.visibility); + setSortBy(marketRequest.sort); + }, [marketRequest, setCatalogScope, setSortBy, setVisibility]); useEffect(() => { - setSortBy('trending'); - }, [setSortBy]); - const recommended = useMemo( + setSearchQuery(query); + }, [query, setSearchQuery]); + useEffect(() => { + if (!showOrganization && marketFilter === 'organization') setMarketFilter('public'); + }, [marketFilter, showOrganization]); + const marketResponseCurrent = isHomeMarketResponseCurrent(marketRequest, { + scope: resolvedScope, + mine: resolvedMine, + }); + const catalogItems = useMemo( () => - marketItems + (marketResponseCurrent ? marketItems : []) + .filter((skill) => matchesHomeMarketFilter(skill, marketFilter)) .filter((skill) => includesSkillQuery( [skill.displayName, skill.name, skill.description, skill.authorName], normalizedQuery, ), ) - .slice(0, RECOMMENDED_LIMIT), - [marketItems, normalizedQuery], + .slice(0, HOME_CATALOG_LIMIT), + [marketFilter, marketItems, marketResponseCurrent, normalizedQuery], ); // 本地技能:global 一组 + 每个 project 一组(displayName 取自 store.projects,兜底 basename)。 @@ -139,7 +173,7 @@ export function SkillhubHomeView({ globalSkills.length + projectGroups.reduce((count, group) => count + group.skills.length, 0), [globalSkills.length, projectGroups], ); - const hasSearchResults = (marketAllowed && recommended.length > 0) || visibleLocalCount > 0; + const hasSearchResults = (marketAllowed && catalogItems.length > 0) || visibleLocalCount > 0; // 推荐技能的预览浮层 + 安装选择器(复用 Market 那套):点推荐卡 = 下一步直接 // 进入该技能的预览;关闭 = 回退到首页。 @@ -162,11 +196,20 @@ export function SkillhubHomeView({ }); }; const openMarket = () => navigate('/skillhub/market'); - const openRecommended = (skill: MarketSkill) => setPreviewSkill(skill); + const openCatalogSkill = (skill: MarketSkill) => setPreviewSkill(skill); const handleClone = (skill: MarketSkill) => { setPickerSkill(skill); setPickerOpen(true); }; + const management = useMarketManagement({ + active: marketFilter === 'mine', + reload: reloadMarket, + onClone: handleClone, + onDeleted: (skill) => { + if (previewSkill?.name === skill.name) setPreviewSkill(null); + }, + }); + const homeMarketFilters = visibleHomeMarketFilters(showOrganization); const handleImportSkill = useCallback(async () => { if (importBusy) return; @@ -280,17 +323,44 @@ export function SkillhubHomeView({ ) : null} - {/* ② 推荐安装(仅市场可见账号) */} - {marketAllowed && (!normalizedQuery || recommended.length > 0 || marketLoading) ? ( + {/* ② 云端目录摘要(仅市场可见账号) */} + {marketAllowed && (!normalizedQuery || catalogItems.length > 0 || marketLoading) ? (
- {marketLoading && recommended.length === 0 ? ( + title={t('skillhub.home.catalog')} + count={catalogItems.length} + > +
+ {homeMarketFilters.map((filter) => ( + + ))} +
+
+ {(marketLoading || !marketResponseCurrent) && catalogItems.length === 0 ? ( // 占位骨架:与真实卡片同栅格、同行数、同高度,内容到位后原地替换不跳动。
- {Array.from({ length: RECOMMENDED_LIMIT }).map((_, i) => ( + {Array.from({ length: HOME_CATALOG_LIMIT }).map((_, i) => (
))}
- ) : recommended.length === 0 ? ( + ) : catalogItems.length === 0 ? (
- {t('skillhub.home.recommendedEmpty')} + {t('skillhub.home.catalogEmpty')}
) : (
- {recommended.map((s) => ( + {catalogItems.map((s) => (
); } diff --git a/apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx b/apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx index 86d094c1cff..3b8f91bc8cc 100644 --- a/apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx +++ b/apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx @@ -9,7 +9,6 @@ import { DropdownMenuItem, } from '@/components/ui/dropdown-menu'; import { toast } from '@/lib/toast'; -import { useConfirmDialog } from '@/components/ui/confirm-dialog-provider'; import { WINDOW_DRAG_STYLE, WINDOW_NO_DRAG_STYLE } from '@/components/layout/windowDrag'; import { useCategoryList, @@ -20,16 +19,13 @@ import { type Visibility, } from './hooks/useMarketList'; import { refresh as refreshSkillhub } from './hooks/useSkillhub'; +import { MarketManagementDialogs, useMarketManagement } from './hooks/useMarketManagement'; import { getMarketSelected, setMarketSelected } from './hooks/useMarketSelection'; -import { MarketCard, type MarketCardManageAction } from './components/MarketCard'; +import { MarketCard } from './components/MarketCard'; import { InstallTargetPicker } from './components/InstallTargetPicker'; -import { MarketInfoEditDialog } from './components/MarketInfoEditDialog'; -import { VisibilityEditorDialog, type VisibilityTier } from './components/VisibilityEditorDialog'; import { SkillhubMarketPreviewPanel } from './SkillhubMarketPreviewPanel'; import { marketCardPrimaryAction } from './lib/marketDetailViewModel'; import { groupMineByOwner } from './lib/mineGrouping'; -import { lacksTeamManagePermission } from './lib/manageGuard'; -import { marketActionErrorMessage } from './lib/marketErrors'; import { nextMarketPreviewName } from './lib/marketPreviewSelection'; import { syncMarketPreviewSelection } from './lib/marketPreviewSync'; import { canAccessSkillhubMarket } from './lib/marketAccess'; @@ -200,25 +196,6 @@ function SkillhubMarketListViewInner() { // Picker 状态 — Clone 按钮触发 const [pickerOpen, setPickerOpen] = useState(false); const [pickerSkill, setPickerSkill] = useState(null); - const [visibilityTarget, setVisibilityTarget] = useState(null); - const [editTarget, setEditTarget] = useState(null); - const { confirm } = useConfirmDialog(); - - // 「我的管理」按团队角色拦截写操作:viewer 团队的 skill 照常显示,但点 - // 编辑/可见性/删除时提示「权限不足」。角色取自 Hub /users/teams 的 myRole - // (一次性拉取);拿不到时不主动拦,留给保存时 Hub 的 403 兜底。 - const [myRoleByTeamSlug, setMyRoleByTeamSlug] = - useState>(() => new Map()); - useEffect(() => { - // myRoleByTeamSlug 只在「我的管理」tab 用,非该 tab 不发请求,省一次无意义的网络往返 - if (!isMineView) return; - let cancelled = false; - void window.electronAPI.skillhub.listUserTeams().then((res) => { - if (cancelled || !res.success) return; - setMyRoleByTeamSlug(new Map(res.teams.map((team) => [team.slug, team.myRole]))); - }); - return () => { cancelled = true; }; - }, [isMineView]); // 订阅 install progress 事件:done 时 refresh 本地 scan + toast useEffect(() => { @@ -258,60 +235,17 @@ function SkillhubMarketListViewInner() { setPickerSkill(skill); setPickerOpen(true); }; - - const handleDelete = async (skill: MarketSkill) => { - const skillName = skill.displayName || skill.name; - const ok = await confirm({ - title: t('skillhub.marketConfirm.deleteTitle', { name: skillName }), - description: t('skillhub.marketConfirm.deleteDesc', { name: skillName }), - confirmText: t('skillhub.marketConfirm.deleteConfirm'), - cancelText: t('skillhub.publishDialog.cancel'), - }); - if (!ok) return; - const res = await window.electronAPI.skillhub.deletePublished(skill.name); - if (!res.success) { - toast.error(marketActionErrorMessage(res.error, res.errorCode, t)); - return; - } - toast.success(t('skillhub.marketActions.deleteSuccess')); - if (previewSkill?.name === skill.name || selectedName === skill.name) { + const management = useMarketManagement({ + active: isMineView, + reload, + onClone: handleClone, + onDeleted: (skill) => { + if (previewSkill?.name !== skill.name && selectedName !== skill.name) return; setPreviewSkill(null); setSelectedName(null); setMarketSelected(null); - } - reload(); - void refreshSkillhub(); - }; - - const handleManageAction = (skill: MarketSkill, action: MarketCardManageAction) => { - // viewer 对团队 skill 没有写权限: - // - 编辑信息 / 改可见性:放行打开弹窗,但弹窗内只读 + 顶部提示(各弹窗 readOnly prop)。 - // - 删除:确认框无表单可禁用,直接 toast 拦下。 - // 克隆/安装是只读操作,照常放行。 - if (action === 'delete' && lacksTeamManagePermission(skill, myRoleByTeamSlug)) { - toast.error(t('skillhub.market.noManagePermission')); - return; - } - switch (action) { - case 'edit': - setEditTarget(skill); - break; - case 'manageVisibility': - setVisibilityTarget(skill); - break; - case 'clone': - handleClone(skill); - break; - case 'delete': - void handleDelete(skill); - break; - } - }; - - const tierForSkill = (skill: MarketSkill): VisibilityTier => { - const pv = skill.publishedVisibility ?? (skill.visibility === 'PUBLIC' ? 'public' : 'shared'); - return pv === 'shared' ? 'team' : pv; - }; + }, + }); const renderCard = (skill: MarketSkill) => ( @@ -570,37 +504,9 @@ function SkillhubMarketListViewInner() { }) : 'none'} onClone={handleClone} - onManageAction={handleManageAction} + onManageAction={management.handleManageAction} /> - {editTarget ? ( - { if (!v) setEditTarget(null); }} - skillName={editTarget.name} - currentCategories={editTarget.categories} - readOnly={lacksTeamManagePermission(editTarget, myRoleByTeamSlug)} - onSaved={() => { - setEditTarget(null); - reload(); - }} - /> - ) : null} - {visibilityTarget ? ( - { if (!v) setVisibilityTarget(null); }} - skillName={visibilityTarget.name} - currentTier={tierForSkill(visibilityTarget)} - currentOwnerType={visibilityTarget.ownerType} - currentOwnerSlug={visibilityTarget.authorId} - readOnly={lacksTeamManagePermission(visibilityTarget, myRoleByTeamSlug)} - onSaved={() => { - setVisibilityTarget(null); - reload(); - void refreshSkillhub(); - }} - /> - ) : null} +
); } diff --git a/apps/desktop/src/renderer/features/skillhub/hooks/useMarketList.ts b/apps/desktop/src/renderer/features/skillhub/hooks/useMarketList.ts index 37be49d622d..3c9745b37aa 100644 --- a/apps/desktop/src/renderer/features/skillhub/hooks/useMarketList.ts +++ b/apps/desktop/src/renderer/features/skillhub/hooks/useMarketList.ts @@ -32,6 +32,7 @@ import { useSkillhub } from './useSkillhub'; import { semverCompare } from '../versionUtils'; export type SortBy = 'trending' | 'downloads' | 'updated_at' | 'created_at'; +export type CatalogScope = 'all' | 'market' | 'team'; /** 'all' 表示不筛分类(显示全部);其他值是 MarketCategory.slug。 */ export type CategoryFilter = typeof CATEGORY_ALL | string; /** @@ -241,6 +242,8 @@ interface MarketListState { loadingMore: boolean; error: string | null; nextCursor: string | null; + resolvedScope: CatalogScope | null; + resolvedMine: boolean | null; } const INITIAL: MarketListState = { @@ -249,12 +252,15 @@ const INITIAL: MarketListState = { loadingMore: false, error: null, nextCursor: null, + resolvedScope: null, + resolvedMine: null, }; interface FetchMarketPageInput { cursor?: string; sort: SortBy; q: string; + scope: CatalogScope; mine: boolean; category?: string; } @@ -271,12 +277,19 @@ export function useMarketList( * 供未登录 / 本地模式跳过云端请求与骨架屏;登录后自动补拉。 */ enabled?: boolean; + /** Initial server-side catalog partition; `all` preserves historical behavior. */ + initialScope?: CatalogScope; + /** Fixed catalog surfaces can avoid an extra request by declaring their initial sort. */ + initialSort?: SortBy; }, ) { const enabled = options?.enabled ?? true; const { t, i18n: i18next } = useTranslation(); const [searchQuery, setSearchQueryState] = useState(''); - const [sortBy, setSortByState] = useState('updated_at'); + const [sortBy, setSortByState] = useState(() => options?.initialSort ?? 'updated_at'); + const [catalogScope, setCatalogScopeState] = useState( + () => options?.initialScope ?? 'all', + ); const [categoryFilter, setCategoryFilterState] = useState(CATEGORY_ALL); // 默认 'available':进入 Market 直接看"对自己有用"的内容。 const [visibility, setVisibilityState] = useState(() => initialVisibility); @@ -332,6 +345,7 @@ export function useMarketList( limit: PAGE_SIZE, sort: params.sort, q: params.q || undefined, + scope: params.scope, mine: params.mine, available: false, category: params.category, @@ -358,6 +372,7 @@ export function useMarketList( cursor, sort: params.sort, q: params.q, + scope: params.scope, mine: params.mine, category: params.category, }); @@ -379,13 +394,21 @@ export function useMarketList( }, [requestMarketPage]); const fetchPage = useCallback( - async (params: { sort: SortBy; q: string; mine: boolean; available: boolean; category?: string }) => { + async (params: { + sort: SortBy; + q: string; + scope: CatalogScope; + mine: boolean; + available: boolean; + category?: string; + }) => { const myId = ++requestIdRef.current; setState((prev) => ({ ...prev, loading: true, error: null })); try { const res = await collectVisiblePage({ sort: params.sort, q: params.q, + scope: params.scope, mine: params.mine, available: params.available, category: params.category, @@ -398,6 +421,8 @@ export function useMarketList( loadingMore: false, error: res.error ?? i18n.t('skillhub.market.installError'), nextCursor: null, + resolvedScope: params.scope, + resolvedMine: params.mine, }); return; } @@ -407,6 +432,8 @@ export function useMarketList( loadingMore: false, error: null, nextCursor: res.nextCursor ?? null, + resolvedScope: params.scope, + resolvedMine: params.mine, }); } catch (err) { if (myId !== requestIdRef.current) return; @@ -416,6 +443,8 @@ export function useMarketList( loadingMore: false, error: err instanceof Error ? err.message : String(err), nextCursor: null, + resolvedScope: params.scope, + resolvedMine: params.mine, }); } }, @@ -434,6 +463,7 @@ export function useMarketList( cursor, sort: sortBy, q: searchQuery, + scope: catalogScope, mine: visibility === 'mine', available: visibility === 'available', category: categoryFilter !== CATEGORY_ALL ? categoryFilter : undefined, @@ -453,7 +483,7 @@ export function useMarketList( if (myId !== requestIdRef.current) return; setState((prev) => ({ ...prev, loadingMore: false })); } - }, [state.nextCursor, state.loadingMore, state.loading, sortBy, searchQuery, visibility, categoryFilter, collectVisiblePage]); + }, [state.nextCursor, state.loadingMore, state.loading, sortBy, searchQuery, catalogScope, visibility, categoryFilter, collectVisiblePage]); // 外部主动刷新(删除/改可见性后)→ bump tick 触发重拉 const [reloadTick, setReloadTick] = useState(0); @@ -466,11 +496,12 @@ export function useMarketList( void fetchPage({ sort: sortBy, q: searchQuery, + scope: catalogScope, mine: visibility === 'mine', available: visibility === 'available', category: categoryFilter !== CATEGORY_ALL ? categoryFilter : undefined, }); - }, [enabled, sortBy, searchQuery, visibility, categoryFilter, fetchPage, reloadTick]); + }, [enabled, sortBy, searchQuery, catalogScope, visibility, categoryFilter, fetchPage, reloadTick]); // 当本地扫描结果或 installing 集合变化时,只重新派生 cardState/installedVersion,不重发请求。 // 依赖键用 (name, version, installing) 序列化字符串,避免对象引用变化导致每次都跑。 @@ -517,6 +548,7 @@ export function useMarketList( const setSearchQuery = useCallback((q: string) => setSearchQueryState(q), []); const setSortBy = useCallback((s: SortBy) => setSortByState(s), []); + const setCatalogScope = useCallback((scope: CatalogScope) => setCatalogScopeState(scope), []); const setCategoryFilter = useCallback((slug: CategoryFilter) => setCategoryFilterState(slug), []); const setVisibility = useCallback((v: Visibility) => setVisibilityState(v), []); @@ -545,12 +577,16 @@ export function useMarketList( loadingMore: state.loadingMore, error: state.error, hasMore: state.nextCursor !== null, + resolvedScope: state.resolvedScope, + resolvedMine: state.resolvedMine, searchQuery, sortBy, + catalogScope, categoryFilter, visibility, setSearchQuery, setSortBy, + setCatalogScope, setCategoryFilter, setVisibility, loadMore, diff --git a/apps/desktop/src/renderer/features/skillhub/hooks/useMarketManagement.tsx b/apps/desktop/src/renderer/features/skillhub/hooks/useMarketManagement.tsx new file mode 100644 index 00000000000..2c508599ced --- /dev/null +++ b/apps/desktop/src/renderer/features/skillhub/hooks/useMarketManagement.tsx @@ -0,0 +1,157 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useConfirmDialog } from '@/components/ui/confirm-dialog-provider'; +import { toast } from '@/lib/toast'; +import { MarketInfoEditDialog } from '../components/MarketInfoEditDialog'; +import { type MarketCardManageAction } from '../components/MarketCard'; +import { VisibilityEditorDialog, type VisibilityTier } from '../components/VisibilityEditorDialog'; +import { lacksTeamManagePermission } from '../lib/manageGuard'; +import { marketActionErrorMessage } from '../lib/marketErrors'; +import { refresh as refreshSkillhub } from './useSkillhub'; +import type { MarketSkill } from './useMarketList'; + +type TeamRole = 'admin' | 'publisher' | 'viewer' | undefined; + +export interface MarketManagementController { + editTarget: MarketSkill | null; + visibilityTarget: MarketSkill | null; + handleManageAction: (skill: MarketSkill, action: MarketCardManageAction) => void; + closeEdit: () => void; + closeVisibility: () => void; + editSaved: () => void; + visibilitySaved: () => void; + isReadOnly: (skill: MarketSkill) => boolean; +} + +/** Shares the SkillHub ownership and mutation behavior between both market surfaces. */ +export function useMarketManagement(options: { + active: boolean; + reload: () => void; + onClone: (skill: MarketSkill) => void; + onDeleted?: (skill: MarketSkill) => void; +}): MarketManagementController { + const { active, reload, onClone, onDeleted } = options; + const { t } = useTranslation(); + const { confirm } = useConfirmDialog(); + const [editTarget, setEditTarget] = useState(null); + const [visibilityTarget, setVisibilityTarget] = useState(null); + const [myRoleByTeamSlug, setMyRoleByTeamSlug] = + useState>(() => new Map()); + + useEffect(() => { + if (!active) return undefined; + let cancelled = false; + void window.electronAPI.skillhub.listUserTeams().then((res) => { + if (cancelled || !res.success) return; + setMyRoleByTeamSlug(new Map(res.teams.map((team) => [team.slug, team.myRole]))); + }); + return () => { + cancelled = true; + }; + }, [active]); + + const isReadOnly = useCallback( + (skill: MarketSkill) => lacksTeamManagePermission(skill, myRoleByTeamSlug), + [myRoleByTeamSlug], + ); + + const handleDelete = useCallback(async (skill: MarketSkill) => { + const skillName = skill.displayName || skill.name; + const ok = await confirm({ + title: t('skillhub.marketConfirm.deleteTitle', { name: skillName }), + description: t('skillhub.marketConfirm.deleteDesc', { name: skillName }), + confirmText: t('skillhub.marketConfirm.deleteConfirm'), + cancelText: t('skillhub.publishDialog.cancel'), + }); + if (!ok) return; + + const res = await window.electronAPI.skillhub.deletePublished(skill.name); + if (!res.success) { + toast.error(marketActionErrorMessage(res.error, res.errorCode, t)); + return; + } + toast.success(t('skillhub.marketActions.deleteSuccess')); + onDeleted?.(skill); + reload(); + void refreshSkillhub(); + }, [confirm, onDeleted, reload, t]); + + const handleManageAction = useCallback((skill: MarketSkill, action: MarketCardManageAction) => { + if (action === 'delete' && isReadOnly(skill)) { + toast.error(t('skillhub.market.noManagePermission')); + return; + } + switch (action) { + case 'edit': + setEditTarget(skill); + break; + case 'manageVisibility': + setVisibilityTarget(skill); + break; + case 'clone': + onClone(skill); + break; + case 'delete': + void handleDelete(skill); + break; + } + }, [handleDelete, isReadOnly, onClone, t]); + + return { + editTarget, + visibilityTarget, + handleManageAction, + closeEdit: () => setEditTarget(null), + closeVisibility: () => setVisibilityTarget(null), + editSaved: () => { + setEditTarget(null); + reload(); + }, + visibilitySaved: () => { + setVisibilityTarget(null); + reload(); + void refreshSkillhub(); + }, + isReadOnly, + }; +} + +function visibilityTier(skill: MarketSkill): VisibilityTier { + const visibility = skill.publishedVisibility + ?? (skill.visibility === 'PUBLIC' ? 'public' : 'shared'); + return visibility === 'shared' ? 'team' : visibility; +} + +export function MarketManagementDialogs({ + controller, +}: { + controller: MarketManagementController; +}) { + return ( + <> + {controller.editTarget ? ( + { if (!open) controller.closeEdit(); }} + skillName={controller.editTarget.name} + currentCategories={controller.editTarget.categories} + readOnly={controller.isReadOnly(controller.editTarget)} + onSaved={controller.editSaved} + /> + ) : null} + {controller.visibilityTarget ? ( + { if (!open) controller.closeVisibility(); }} + skillName={controller.visibilityTarget.name} + currentTier={visibilityTier(controller.visibilityTarget)} + currentOwnerType={controller.visibilityTarget.ownerType} + currentOwnerSlug={controller.visibilityTarget.authorId} + readOnly={controller.isReadOnly(controller.visibilityTarget)} + onSaved={controller.visibilitySaved} + /> + ) : null} + + ); +} diff --git a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/homeMarketFilter.test.ts b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/homeMarketFilter.test.ts new file mode 100644 index 00000000000..536f6eb60fd --- /dev/null +++ b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/homeMarketFilter.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { + homeMarketQuery, + isHomeMarketResponseCurrent, + matchesHomeMarketFilter, + visibleHomeMarketFilters, +} from '../homeMarketFilter'; + +describe('Skill home market filters', () => { + it('maps public and organization to the legacy-compatible catalog scopes', () => { + expect(homeMarketQuery('public')).toEqual({ + scope: 'market', + visibility: 'all', + sort: 'trending', + }); + expect(homeMarketQuery('organization')).toEqual({ + scope: 'team', + visibility: 'all', + sort: 'trending', + }); + }); + + it('uses the published-management mode for mine', () => { + expect(homeMarketQuery('mine')).toEqual({ + scope: 'all', + visibility: 'mine', + sort: 'updated_at', + }); + }); + + it('hides organization for personal memberships', () => { + expect(visibleHomeMarketFilters(false)).toEqual(['public', 'mine']); + expect(visibleHomeMarketFilters(true)).toEqual(['public', 'organization', 'mine']); + }); + + it('does not present a response from the previous tab as current', () => { + expect(isHomeMarketResponseCurrent(homeMarketQuery('mine'), { + scope: 'market', + mine: false, + })).toBe(false); + expect(isHomeMarketResponseCurrent(homeMarketQuery('mine'), { + scope: 'all', + mine: true, + })).toBe(true); + }); + + it('keeps public and shared organization items mutually exclusive', () => { + const publicOrganizationSkill = { + isMine: false, + ownerType: 'organization', + publishedVisibility: 'public' as const, + visibility: 'PUBLIC' as const, + }; + const sharedOrganizationSkill = { + isMine: false, + ownerType: 'organization', + publishedVisibility: 'shared' as const, + visibility: 'DEPARTMENT_SCOPED' as const, + }; + + expect(matchesHomeMarketFilter(publicOrganizationSkill, 'public')).toBe(true); + expect(matchesHomeMarketFilter(publicOrganizationSkill, 'organization')).toBe(false); + expect(matchesHomeMarketFilter(sharedOrganizationSkill, 'public')).toBe(false); + expect(matchesHomeMarketFilter(sharedOrganizationSkill, 'organization')).toBe(true); + }); +}); diff --git a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/manageGuard.test.ts b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/manageGuard.test.ts index fd403f4eddc..b10aed8478e 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/manageGuard.test.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/manageGuard.test.ts @@ -24,6 +24,7 @@ describe('lacksTeamManagePermission', () => { it('团队归属 + 我是 viewer → 无权,拦截', () => { const map = roleMap({ 'team-c': 'viewer' }); expect(lacksTeamManagePermission({ ownerType: 'org', authorId: 'team-c' }, map)).toBe(true); + expect(lacksTeamManagePermission({ ownerType: 'organization', authorId: 'team-c' }, map)).toBe(true); }); it('角色未知(团队不在列表 / Hub 未返回 myRole)→ 不主动拦截,留给 403 兜底', () => { diff --git a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketRoutes.test.ts b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketRoutes.test.ts index 3e026ed2a9e..2a18bae8f85 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketRoutes.test.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketRoutes.test.ts @@ -49,7 +49,7 @@ describe('market route scope', () => { expect(previewSource).toContain('readPublishedFile'); expect(previewSource).toContain('allowPrivilegedLinks={false}'); expect(previewSource).toContain('ManageMenu'); - expect(listSource).toContain('onManageAction={handleManageAction}'); + expect(listSource).toContain('onManageAction={management.handleManageAction}'); expect(previewSource).not.toContain('previewMarket'); }); diff --git a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/mineGrouping.test.ts b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/mineGrouping.test.ts index 1f180f4e34e..c7bd4335cf5 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/mineGrouping.test.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/mineGrouping.test.ts @@ -20,11 +20,15 @@ describe('groupMineByOwner', () => { }); it('没有个人 item 时不出现个人组(空组不显示)', () => { - const groups = groupMineByOwner([s('a', 'org', 't1', 'T1')]); + const groups = groupMineByOwner([ + s('a', 'org', 't1', 'T1'), + s('b', 'organization', 't1', 'T1'), + ]); expect(groups.map((g) => g.key)).toEqual(['t1']); + expect(groups[0].skills.map((x: { name: string }) => x.name)).toEqual(['a', 'b']); }); - it("ownerType 非 'org'(user / personal / 缺省)都归个人组", () => { + it("ownerType 非 'org'/'organization'(user / personal / 缺省)都归个人组", () => { const groups = groupMineByOwner([s('a', 'user', 'u', 'U'), s('b', '', 'u2', 'U2')]); expect(groups).toHaveLength(1); expect(groups[0].isPersonal).toBe(true); diff --git a/apps/desktop/src/renderer/features/skillhub/lib/homeMarketFilter.ts b/apps/desktop/src/renderer/features/skillhub/lib/homeMarketFilter.ts new file mode 100644 index 00000000000..786d5ba1ffd --- /dev/null +++ b/apps/desktop/src/renderer/features/skillhub/lib/homeMarketFilter.ts @@ -0,0 +1,49 @@ +import type { CatalogScope, MarketSkill, SortBy, Visibility } from '../hooks/useMarketList'; + +export type HomeMarketFilter = 'public' | 'organization' | 'mine'; + +export interface HomeMarketQuery { + scope: CatalogScope; + visibility: Visibility; + sort: SortBy; +} + +export function visibleHomeMarketFilters(showOrganization: boolean): HomeMarketFilter[] { + return showOrganization ? ['public', 'organization', 'mine'] : ['public', 'mine']; +} + +export function isHomeMarketResponseCurrent( + query: HomeMarketQuery, + response: { scope: CatalogScope | null; mine: boolean | null }, +): boolean { + return response.scope === query.scope && response.mine === (query.visibility === 'mine'); +} + +/** Maps home tabs onto the legacy-compatible SkillHub query contract. */ +export function homeMarketQuery(filter: HomeMarketFilter): HomeMarketQuery { + if (filter === 'organization') { + return { scope: 'team', visibility: 'all', sort: 'trending' }; + } + if (filter === 'mine') { + return { scope: 'all', visibility: 'mine', sort: 'updated_at' }; + } + return { scope: 'market', visibility: 'all', sort: 'trending' }; +} + +/** Prevents a completed request for the previous tab from flashing under a new selection. */ +export function matchesHomeMarketFilter( + skill: Pick, + filter: HomeMarketFilter, +): boolean { + if (filter === 'mine') return skill.isMine; + if (filter === 'public') { + return skill.publishedVisibility === 'public' + || (skill.publishedVisibility === undefined && skill.visibility === 'PUBLIC'); + } + const organizationOwned = skill.ownerType === 'org' + || skill.ownerType === 'organization' + || skill.ownerType === 'team'; + const shared = skill.publishedVisibility === 'shared' + || (skill.publishedVisibility === undefined && skill.visibility === 'DEPARTMENT_SCOPED'); + return organizationOwned && shared; +} diff --git a/apps/desktop/src/renderer/features/skillhub/lib/manageGuard.ts b/apps/desktop/src/renderer/features/skillhub/lib/manageGuard.ts index a791ccb740c..dfffbb25afd 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/manageGuard.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/manageGuard.ts @@ -15,7 +15,7 @@ type ManageRole = 'admin' | 'publisher' | 'viewer'; /** * 判断某个「我的管理」里的 skill 是否因「我在其所属团队只是 viewer」而无写权限。 * - * - 个人归属(ownerType !== 'org')→ 永远有权,返回 false。 + * - 个人归属(ownerType !== 'org' / 'organization')→ 永远有权,返回 false。 * - 团队归属 → 查我在该团队(owner slug)的角色: * - admin / publisher → 有权,返回 false。 * - viewer(含部门派生的只读身份)→ 无权,返回 true。 @@ -26,7 +26,7 @@ export function lacksTeamManagePermission( skill: { ownerType?: string; authorId: string }, myRoleByTeamSlug: Map, ): boolean { - if (skill.ownerType !== 'org') return false; + if (skill.ownerType !== 'org' && skill.ownerType !== 'organization') return false; const role = myRoleByTeamSlug.get(skill.authorId); if (role === undefined) return false; return role !== 'admin' && role !== 'publisher'; diff --git a/apps/desktop/src/renderer/features/skillhub/lib/mineGrouping.ts b/apps/desktop/src/renderer/features/skillhub/lib/mineGrouping.ts index 00d04089b5a..77d21e85309 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/mineGrouping.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/mineGrouping.ts @@ -1,7 +1,7 @@ /** * 把「我的发布」列表按归属(ownership)分组,供 SkillHub Market mine tab 的 * 分组视图(方案 C)使用。 - * - ownerType === 'org' → 团队组,key = owner.slug(authorId),label = 团队名(authorName) + * - ownerType === 'org' / 'organization' → 团队组,key = owner.slug(authorId),label = 团队名(authorName) * - 其它(personal / user / 缺省)→ 个人组 * 只对实际有 item 的 owner 建组(空组天然不出现);个人组排在最前。 */ @@ -22,7 +22,7 @@ export function groupMineByOwner(); for (const it of items) { - if (it.ownerType === 'org') { + if (it.ownerType === 'org' || it.ownerType === 'organization') { const g = teams.get(it.authorId) ?? { name: it.authorName, skills: [] }; g.skills.push(it); teams.set(it.authorId, g); diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 8df89d9121e..3777da4e094 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -9972,8 +9972,14 @@ "noSearchResults": "No matching skills found", "browseTitle": "Browse Skill Hub", "browseDesc": "Discover and install more skills", - "recommended": "Recommended", - "recommendedEmpty": "No recommendations right now", + "catalog": "SkillHub", + "catalogFiltersAria": "SkillHub categories", + "catalogFilter": { + "public": "Public", + "organization": "Organization", + "mine": "Managed" + }, + "catalogEmpty": "No skills in this category", "local": "Local Skills", "localEmpty": "No local skills yet", "installed": "Installed", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index 5ac7b3e5f7c..acd8f1a8707 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -9950,8 +9950,14 @@ "noSearchResults": "一致するスキルが見つかりません", "browseTitle": "Skill Hub を見る", "browseDesc": "もっと多くのスキルを見つけてインストール", - "recommended": "おすすめ", - "recommendedEmpty": "現在おすすめはありません", + "catalog": "SkillHub", + "catalogFiltersAria": "SkillHub カテゴリ", + "catalogFilter": { + "public": "公開", + "organization": "組織", + "mine": "管理対象" + }, + "catalogEmpty": "このカテゴリにはスキルがありません", "local": "ローカルスキル", "localEmpty": "ローカルスキルはまだありません", "installed": "インストール済み", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 0e1267d7d70..395f1c2e49a 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -9950,8 +9950,14 @@ "noSearchResults": "일치하는 스킬을 찾을 수 없습니다", "browseTitle": "Skill Hub 둘러보기", "browseDesc": "더 많은 스킬을 찾아 설치하세요", - "recommended": "추천", - "recommendedEmpty": "현재 추천 항목이 없습니다", + "catalog": "SkillHub", + "catalogFiltersAria": "SkillHub 카테고리", + "catalogFilter": { + "public": "공개", + "organization": "조직", + "mine": "관리 대상" + }, + "catalogEmpty": "이 카테고리에 스킬이 없습니다", "local": "로컬 스킬", "localEmpty": "아직 로컬 스킬이 없습니다", "installed": "설치됨", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index 5f90324a284..994d3273b3f 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -9942,8 +9942,14 @@ "noSearchResults": "没有找到匹配的技能", "browseTitle": "浏览 Skill Hub", "browseDesc": "发现并安装更多技能", - "recommended": "推荐安装", - "recommendedEmpty": "暂无推荐", + "catalog": "SkillHub", + "catalogFiltersAria": "SkillHub 分类", + "catalogFilter": { + "public": "公开", + "organization": "组织", + "mine": "我的管理" + }, + "catalogEmpty": "当前分类暂无技能", "local": "本地技能", "localEmpty": "还没有本地技能", "installed": "已安装", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json index d91592adf4e..d955e0ae63a 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -9942,8 +9942,14 @@ "noSearchResults": "沒有找到匹配的技能", "browseTitle": "瀏覽 Skill Hub", "browseDesc": "發現並安裝更多技能", - "recommended": "推薦安裝", - "recommendedEmpty": "暫無推薦", + "catalog": "SkillHub", + "catalogFiltersAria": "SkillHub 分類", + "catalogFilter": { + "public": "公開", + "organization": "組織", + "mine": "我的管理" + }, + "catalogEmpty": "目前分類暫無技能", "local": "本地技能", "localEmpty": "還沒有本地技能", "installed": "已安裝", diff --git a/apps/desktop/src/renderer/vite-env.d.ts b/apps/desktop/src/renderer/vite-env.d.ts index 50cb4692bc5..c6f1f208072 100644 --- a/apps/desktop/src/renderer/vite-env.d.ts +++ b/apps/desktop/src/renderer/vite-env.d.ts @@ -3149,6 +3149,7 @@ interface ElectronAPI { limit?: number; sort?: 'trending' | 'downloads' | 'updated_at' | 'created_at'; q?: string; + scope?: 'all' | 'market' | 'team'; mine?: boolean; /** Legacy: Hub-side available filtering switch. Current renderer keeps this false and filters locally. */ available?: boolean; From a5644ed35a3f1517708676125d9de430d4dd57f1 Mon Sep 17 00:00:00 2001 From: xd-bobo Date: Tue, 1 Sep 2026 11:20:08 +0800 Subject: [PATCH 04/15] fix(skillhub): remove available market filter Signed-off-by: xd-bobo --- .../features/skillhub/SkillhubMarketListView.tsx | 9 +-------- .../renderer/features/skillhub/hooks/useMarketList.ts | 4 ++-- .../features/skillhub/lib/__tests__/marketRoutes.test.ts | 9 +++++++++ apps/desktop/src/renderer/i18n/locales/en/common.json | 1 - apps/desktop/src/renderer/i18n/locales/ja/common.json | 1 - apps/desktop/src/renderer/i18n/locales/ko/common.json | 1 - apps/desktop/src/renderer/i18n/locales/zh-CN/common.json | 1 - apps/desktop/src/renderer/i18n/locales/zh-TW/common.json | 1 - 8 files changed, 12 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx b/apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx index 3b8f91bc8cc..381fa465742 100644 --- a/apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx +++ b/apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx @@ -89,8 +89,7 @@ function SkillhubMarketListViewInner() { const navigate = useNavigate(); const marketState = location.state as { freshEntry?: boolean; initialVisibility?: Visibility } | null; const initialVisibility = marketState?.initialVisibility === 'all' || - marketState?.initialVisibility === 'mine' || - marketState?.initialVisibility === 'available' + marketState?.initialVisibility === 'mine' ? marketState.initialVisibility : undefined; const { @@ -379,12 +378,6 @@ function SkillhubMarketListViewInner() { ) : null} - {/* 可获取默认选中,语义对齐 SkillHub 徽标 */} - setVisibility('available')} - /> options?.initialScope ?? 'all', ); const [categoryFilter, setCategoryFilterState] = useState(CATEGORY_ALL); - // 默认 'available':进入 Market 直接看"对自己有用"的内容。 + // 默认展示当前身份可见的完整目录;“我的管理”由列表页显式切换。 const [visibility, setVisibilityState] = useState(() => initialVisibility); const [state, setState] = useState(INITIAL); // 当前正在跑 install 的 name 集合(按 name 串行;同 name 不能重复触发) diff --git a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketRoutes.test.ts b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketRoutes.test.ts index 2a18bae8f85..a865402d557 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketRoutes.test.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketRoutes.test.ts @@ -60,6 +60,15 @@ describe('market route scope', () => { expect(listSource).toContain('marketCardPrimaryAction'); expect(viewModelSource).toContain("input.listVisibility === 'mine'"); }); + + it('shows the full visible catalog by default without an Available filter', () => { + const listSource = readFileSync(resolve(skillhubDir, 'SkillhubMarketListView.tsx'), 'utf8'); + const hookSource = readFileSync(resolve(skillhubDir, 'hooks/useMarketList.ts'), 'utf8'); + + expect(listSource).not.toContain('skillhub.market.chipAvailable'); + expect(listSource).not.toContain("setVisibility('available')"); + expect(hookSource).toContain("initialVisibility: Visibility = 'all'"); + }); }); describe('market management copy and errors', () => { diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 3777da4e094..18a5b157dfd 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -10161,7 +10161,6 @@ "sortDownloads": "Downloads", "sortLatest": "Latest update", "sortCreated": "Latest release", - "chipAvailable": "Available", "chipAll": "All", "chipMine": "Managed", "ownerGroupPersonal": "Personal", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index acd8f1a8707..72a6ff928f7 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -10139,7 +10139,6 @@ "sortDownloads": "ダウンロード数", "sortLatest": "最近の更新", "sortCreated": "最新公開", - "chipAvailable": "取得可能", "chipAll": "すべて", "chipMine": "管理対象", "ownerGroupPersonal": "個人", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 395f1c2e49a..7535302aff2 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -10139,7 +10139,6 @@ "sortDownloads": "다운로드 수", "sortLatest": "최근 업데이트", "sortCreated": "최신 게시", - "chipAvailable": "이용 가능", "chipAll": "전체", "chipMine": "관리 대상", "ownerGroupPersonal": "개인", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index 994d3273b3f..e0014307215 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -10131,7 +10131,6 @@ "sortDownloads": "下载量", "sortLatest": "最近更新", "sortCreated": "最新发布", - "chipAvailable": "可获取", "chipAll": "全部", "chipMine": "我的管理", "ownerGroupPersonal": "个人", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json index d955e0ae63a..99e2cf004f0 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -10131,7 +10131,6 @@ "sortDownloads": "下載量", "sortLatest": "最近更新", "sortCreated": "最新發布", - "chipAvailable": "可獲取", "chipAll": "全部", "chipMine": "我的管理", "ownerGroupPersonal": "個人", From bdfc94ab2a91dbbea804c943cc1e33261e2b8fc6 Mon Sep 17 00:00:00 2001 From: xd-bobo Date: Tue, 1 Sep 2026 11:30:41 +0800 Subject: [PATCH 05/15] refactor(skillhub): simplify skill catalog header Signed-off-by: xd-bobo --- .../features/skillhub/SkillhubHomeView.tsx | 153 ++++++++---------- .../lib/__tests__/marketRoutes.test.ts | 11 ++ .../src/renderer/i18n/locales/en/common.json | 4 +- .../src/renderer/i18n/locales/ja/common.json | 4 +- .../src/renderer/i18n/locales/ko/common.json | 4 +- .../renderer/i18n/locales/zh-CN/common.json | 4 +- .../renderer/i18n/locales/zh-TW/common.json | 4 +- 7 files changed, 80 insertions(+), 104 deletions(-) diff --git a/apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx b/apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx index 11e06777bd3..1693ee35f27 100644 --- a/apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx +++ b/apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx @@ -9,7 +9,7 @@ * 三块都是整页内容卡片/列表;首页是栈底,自身无返回。 */ -import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { @@ -18,7 +18,6 @@ import { Download, Package, SquareTerminal, - Store, Upload, type LucideIcon, } from 'lucide-react'; @@ -251,6 +250,25 @@ export function SkillhubHomeView({ clearSearchLabel={t('skillhub.home.clearSearch')} embedded={embedded} onSelectTab={onSelectCatalogTab} + headerActions={( + + )} >
- -
+ +

{t('skillhub.home.title')} @@ -272,91 +290,51 @@ export function SkillhubHomeView({ )}

- + {marketAllowed ? ( +
+ {homeMarketFilters.map((filter) => ( + + ))} + +
+ ) : null}
- {/* ① Skill Hub 入口 → 完整 Market 浏览页(仅市场可见账号) */} - {marketAllowed && !normalizedQuery ? ( - - ) : null} - - {/* ② 云端目录摘要(仅市场可见账号) */} + {/* ① 云端目录摘要(仅市场可见账号) */} {marketAllowed && (!normalizedQuery || catalogItems.length > 0 || marketLoading) ? (
- -
- {homeMarketFilters.map((filter) => ( - - ))} -
-
{(marketLoading || !marketResponseCurrent) && catalogItems.length === 0 ? ( // 占位骨架:与真实卡片同栅格、同行数、同高度,内容到位后原地替换不跳动。
@@ -421,7 +399,7 @@ export function SkillhubHomeView({
) : null} - {/* ③ 本地技能 */} + {/* ② 本地技能 */} {!normalizedQuery || visibleLocalCount > 0 ? (
@@ -552,11 +530,9 @@ export function SkillhubHomeView({ function SkillSectionHeading({ title, count, - children, }: { title: string; count: number; - children?: ReactNode; }) { return (
@@ -564,7 +540,6 @@ function SkillSectionHeading({

{title}

{count}
- {children} ); } diff --git a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketRoutes.test.ts b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketRoutes.test.ts index a865402d557..19ce7f2c432 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketRoutes.test.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketRoutes.test.ts @@ -69,6 +69,17 @@ describe('market route scope', () => { expect(listSource).not.toContain("setVisibility('available')"); expect(hookSource).toContain("initialVisibility: Visibility = 'all'"); }); + + it('uses a compact More entry for the full market and aligns import with plugin actions', () => { + const homeSource = readFileSync(resolve(skillhubDir, 'SkillhubHomeView.tsx'), 'utf8'); + + expect(homeSource).not.toContain('skillhub.home.browseTitle'); + expect(homeSource).not.toContain('skillhub.home.browseDesc'); + expect(homeSource).not.toContain("title={t('skillhub.home.catalog')}"); + expect(homeSource).toContain("t('skillhub.home.catalogMore')"); + expect(homeSource).toContain('headerActions={('); + expect(homeSource).toContain('plugin-management-action-trigger'); + }); }); describe('market management copy and errors', () => { diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 18a5b157dfd..cb80358c12e 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -9970,15 +9970,13 @@ "search": "Search skills", "clearSearch": "Clear skill search", "noSearchResults": "No matching skills found", - "browseTitle": "Browse Skill Hub", - "browseDesc": "Discover and install more skills", - "catalog": "SkillHub", "catalogFiltersAria": "SkillHub categories", "catalogFilter": { "public": "Public", "organization": "Organization", "mine": "Managed" }, + "catalogMore": "More", "catalogEmpty": "No skills in this category", "local": "Local Skills", "localEmpty": "No local skills yet", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index 72a6ff928f7..f205663b8aa 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -9948,15 +9948,13 @@ "search": "スキルを検索", "clearSearch": "スキル検索をクリア", "noSearchResults": "一致するスキルが見つかりません", - "browseTitle": "Skill Hub を見る", - "browseDesc": "もっと多くのスキルを見つけてインストール", - "catalog": "SkillHub", "catalogFiltersAria": "SkillHub カテゴリ", "catalogFilter": { "public": "公開", "organization": "組織", "mine": "管理対象" }, + "catalogMore": "もっと見る", "catalogEmpty": "このカテゴリにはスキルがありません", "local": "ローカルスキル", "localEmpty": "ローカルスキルはまだありません", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 7535302aff2..d8b4b429422 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -9948,15 +9948,13 @@ "search": "스킬 검색", "clearSearch": "스킬 검색 지우기", "noSearchResults": "일치하는 스킬을 찾을 수 없습니다", - "browseTitle": "Skill Hub 둘러보기", - "browseDesc": "더 많은 스킬을 찾아 설치하세요", - "catalog": "SkillHub", "catalogFiltersAria": "SkillHub 카테고리", "catalogFilter": { "public": "공개", "organization": "조직", "mine": "관리 대상" }, + "catalogMore": "더 보기", "catalogEmpty": "이 카테고리에 스킬이 없습니다", "local": "로컬 스킬", "localEmpty": "아직 로컬 스킬이 없습니다", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index e0014307215..ed58839fe82 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -9940,15 +9940,13 @@ "search": "搜索技能", "clearSearch": "清除技能搜索", "noSearchResults": "没有找到匹配的技能", - "browseTitle": "浏览 Skill Hub", - "browseDesc": "发现并安装更多技能", - "catalog": "SkillHub", "catalogFiltersAria": "SkillHub 分类", "catalogFilter": { "public": "公开", "organization": "组织", "mine": "我的管理" }, + "catalogMore": "更多", "catalogEmpty": "当前分类暂无技能", "local": "本地技能", "localEmpty": "还没有本地技能", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json index 99e2cf004f0..0fada1f5fa3 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -9940,15 +9940,13 @@ "search": "搜尋技能", "clearSearch": "清除技能搜尋", "noSearchResults": "沒有找到匹配的技能", - "browseTitle": "瀏覽 Skill Hub", - "browseDesc": "發現並安裝更多技能", - "catalog": "SkillHub", "catalogFiltersAria": "SkillHub 分類", "catalogFilter": { "public": "公開", "organization": "組織", "mine": "我的管理" }, + "catalogMore": "更多", "catalogEmpty": "目前分類暫無技能", "local": "本地技能", "localEmpty": "還沒有本地技能", From 0ff1fc1036199e731814d1006f94fad6c0d8e6a3 Mon Sep 17 00:00:00 2001 From: xd-bobo Date: Tue, 1 Sep 2026 14:38:37 +0800 Subject: [PATCH 06/15] =?UTF-8?q?feat(skillhub):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E5=AF=BC=E8=88=AA=E3=80=81=E5=8C=BF=E5=90=8D?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=92=8C=E6=89=AB=E6=8F=8F=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: xd-bobo --- apps/desktop/src/main/skillhub/hubApi.ts | 2 - .../features/skillhub/ScanResultDialog.tsx | 17 ++++- .../features/skillhub/SkillhubHomeView.tsx | 76 +++++++++---------- .../skillhub/SkillhubMarketListView.tsx | 43 +++++------ .../features/skillhub/hooks/useMarketList.ts | 2 +- .../lib/__tests__/homeMarketFilter.test.ts | 26 +++---- .../lib/__tests__/marketAccess.test.ts | 35 --------- .../__tests__/scanResultPresentation.test.ts | 10 +++ .../features/skillhub/lib/homeMarketFilter.ts | 13 ++-- .../features/skillhub/lib/marketAccess.ts | 12 --- .../skillhub/lib/scanResultPresentation.ts | 11 +++ .../src/renderer/i18n/locales/en/common.json | 5 +- .../src/renderer/i18n/locales/ja/common.json | 5 +- .../src/renderer/i18n/locales/ko/common.json | 5 +- .../renderer/i18n/locales/zh-CN/common.json | 5 +- .../renderer/i18n/locales/zh-TW/common.json | 5 +- 16 files changed, 128 insertions(+), 144 deletions(-) delete mode 100644 apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketAccess.test.ts create mode 100644 apps/desktop/src/renderer/features/skillhub/lib/__tests__/scanResultPresentation.test.ts delete mode 100644 apps/desktop/src/renderer/features/skillhub/lib/marketAccess.ts create mode 100644 apps/desktop/src/renderer/features/skillhub/lib/scanResultPresentation.ts diff --git a/apps/desktop/src/main/skillhub/hubApi.ts b/apps/desktop/src/main/skillhub/hubApi.ts index fe954a2522a..4cd51979791 100644 --- a/apps/desktop/src/main/skillhub/hubApi.ts +++ b/apps/desktop/src/main/skillhub/hubApi.ts @@ -9,13 +9,11 @@ */ import { serverApiFetch, type ApiFetchOptions } from '../serverApiClient'; import { getClientEndpoint } from '../clientEndpointsService'; -import { requireAppCapability } from '../appCapabilities.js'; export function skillhubApiFetch( apiPath: string, opts: Omit = {}, ): Promise { - requireAppCapability('canUseSkillHubCloud', 'SkillHub cloud requires a Cindy account.'); return serverApiFetch(apiPath, { ...opts, // 新客户端绝不回退旧 skillhubApiBaseUrl:XD 身份的只读兼容由新服务自己 diff --git a/apps/desktop/src/renderer/features/skillhub/ScanResultDialog.tsx b/apps/desktop/src/renderer/features/skillhub/ScanResultDialog.tsx index 17ca21d5f8d..75597cd46a5 100644 --- a/apps/desktop/src/renderer/features/skillhub/ScanResultDialog.tsx +++ b/apps/desktop/src/renderer/features/skillhub/ScanResultDialog.tsx @@ -13,6 +13,7 @@ import { cn } from '@/lib/utils'; import { toast } from '@/lib/toast'; import type { ScanResultPayload } from './PublishDialog'; import { isPassingScanStatus } from './lib/scanStatus'; +import { isPublicationProcessingFailure } from './lib/scanResultPresentation'; interface ScanIssue { severity?: string; @@ -83,6 +84,9 @@ function scanGateLabel(gate: ScanGate, t: TFunction): string { if (code === 'security-scan' || code === 'scan-status') { return t('skillhub.scanResult.gateLabel.securityScan'); } + if (code === 'internal-error') { + return t('skillhub.scanResult.gateLabel.publicationProcessing'); + } return gate.name; } @@ -124,13 +128,18 @@ export function ScanResultDialog({ open, onClose, result }: ScanResultDialogProp const passed = isPassingScanStatus(result.status); const failedGates = (result.gates ?? []).filter((g) => g.status !== 'pass'); + const processingFailure = !passed && isPublicationProcessingFailure(result.gates); const title = passed ? t('skillhub.scanResult.passedTitle') - : t('skillhub.scanResult.failedTitle', { status: result.status }); + : processingFailure + ? t('skillhub.scanResult.processingFailedTitle') + : t('skillhub.scanResult.failedTitle', { status: result.status }); const statusLabel = scanStatusLabel(result.status, t); const description = passed ? t('skillhub.scanResult.passedDesc') - : t('skillhub.scanResult.failedDesc', { status: statusLabel }); + : processingFailure + ? t('skillhub.scanResult.processingFailedDesc') + : t('skillhub.scanResult.failedDesc', { status: statusLabel }); const footerButtonBaseClass = cn( 'inline-flex h-9 min-w-[104px] items-center justify-center gap-1.5 rounded-full px-5', 'text-sm font-medium leading-none', @@ -194,6 +203,10 @@ export function ScanResultDialog({ open, onClose, result }: ScanResultDialogProp
+ ) : processingFailure ? ( +
+ +
) : (
diff --git a/apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx b/apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx index 1693ee35f27..9be28c54ac2 100644 --- a/apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx +++ b/apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx @@ -3,10 +3,7 @@ * * 重构(2026-06):SkillHub 不再用"左侧树导航 + 右侧内容"。左侧 app 侧栏还给 * 项目/对话列表;技能整页在右侧主区,无常驻导航树,改为下钻(下一步)+ 回退: - * - Skill Hub 入口 → 完整 Market 浏览页(/skillhub/market) - * - 推荐安装的技能 → market trending 前 N,点选 → market 页(预览/安装) - * - 本地技能 → 已安装/本地的 skill/command/agent,点 → 详情页 - * 三块都是整页内容卡片/列表;首页是栈底,自身无返回。 + * 公开、可选的组织目录和本地技能在同一行切换;“更多”进入完整 Market。 */ import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -30,7 +27,6 @@ import { PluginManagementLayout, PluginManagementPage, } from '@/features/plugin/PluginManagementLayout'; -import { canAccessSkillhubMarket } from './lib/marketAccess'; import { buildLocalSkillRoute, findLocalSkillByPath } from './lib/localRoutes'; import { refresh as refreshSkillhub, useSkillhub } from './hooks/useSkillhub'; import { useMarketList, type MarketSkill } from './hooks/useMarketList'; @@ -42,7 +38,8 @@ import { homeMarketQuery, isHomeMarketResponseCurrent, matchesHomeMarketFilter, - visibleHomeMarketFilters, + visibleHomeCatalogTabs, + type HomeCatalogTab, type HomeMarketFilter, } from './lib/homeMarketFilter'; import { deriveSkillSource } from './lib/skillSource'; @@ -76,12 +73,11 @@ export function SkillhubHomeView({ const [query, setQuery] = useState(''); const normalizedQuery = query.trim().toLocaleLowerCase(); - // 登录后所有账号都请求 SkillHub;数据可见范围由服务端按已验证身份决定。 - // 未登录 / 本地模式没有云端凭证,只显示本地技能。 + // 未登录也请求公开 Skill 目录;登录身份只扩大服务端可见范围。 const { user } = useAuth(); - const marketAllowed = canAccessSkillhubMarket(user); const showOrganization = user?.membershipKind === 'org'; - const [marketFilter, setMarketFilter] = useState('public'); + const [catalogTab, setCatalogTab] = useState('public'); + const marketFilter: HomeMarketFilter = catalogTab === 'organization' ? 'organization' : 'public'; const marketRequest = useMemo(() => homeMarketQuery(marketFilter), [marketFilter]); // 主 Skill Tab 只展示各云端目录的首批摘要,完整分页仍由 SkillHub 市场页承担。 @@ -96,7 +92,7 @@ export function SkillhubHomeView({ setVisibility, reload: reloadMarket, } = useMarketList('all', { - enabled: marketAllowed, + enabled: catalogTab !== 'local', initialScope: 'market', initialSort: 'trending', }); @@ -109,8 +105,8 @@ export function SkillhubHomeView({ setSearchQuery(query); }, [query, setSearchQuery]); useEffect(() => { - if (!showOrganization && marketFilter === 'organization') setMarketFilter('public'); - }, [marketFilter, showOrganization]); + if (!showOrganization && catalogTab === 'organization') setCatalogTab('public'); + }, [catalogTab, showOrganization]); const marketResponseCurrent = isHomeMarketResponseCurrent(marketRequest, { scope: resolvedScope, mine: resolvedMine, @@ -172,7 +168,9 @@ export function SkillhubHomeView({ globalSkills.length + projectGroups.reduce((count, group) => count + group.skills.length, 0), [globalSkills.length, projectGroups], ); - const hasSearchResults = (marketAllowed && catalogItems.length > 0) || visibleLocalCount > 0; + const hasSearchResults = catalogTab === 'local' + ? visibleLocalCount > 0 + : catalogItems.length > 0; // 推荐技能的预览浮层 + 安装选择器(复用 Market 那套):点推荐卡 = 下一步直接 // 进入该技能的预览;关闭 = 回退到首页。 @@ -201,14 +199,14 @@ export function SkillhubHomeView({ setPickerOpen(true); }; const management = useMarketManagement({ - active: marketFilter === 'mine', + active: false, reload: reloadMarket, onClone: handleClone, onDeleted: (skill) => { if (previewSkill?.name === skill.name) setPreviewSkill(null); }, }); - const homeMarketFilters = visibleHomeMarketFilters(showOrganization); + const homeCatalogTabs = visibleHomeCatalogTabs(showOrganization); const handleImportSkill = useCallback(async () => { if (importBusy) return; @@ -283,37 +281,34 @@ export function SkillhubHomeView({ {t('skillhub.home.title')}

- {t( - marketAllowed - ? 'skillhub.home.description' - : 'skillhub.home.descriptionLocalOnly', - )} + {t('skillhub.home.description')}

- {marketAllowed ? ( -
- {homeMarketFilters.map((filter) => ( +
+ {homeCatalogTabs.map((tab) => ( ))} -
- ) : null} +
- {/* ① 云端目录摘要(仅市场可见账号) */} - {marketAllowed && (!normalizedQuery || catalogItems.length > 0 || marketLoading) ? ( + {/* ① 当前云端目录摘要 */} + {catalogTab !== 'local' && (!normalizedQuery || catalogItems.length > 0 || marketLoading) ? (
{(marketLoading || !marketResponseCurrent) && catalogItems.length === 0 ? ( // 占位骨架:与真实卡片同栅格、同行数、同高度,内容到位后原地替换不跳动。 @@ -400,7 +394,7 @@ export function SkillhubHomeView({ ) : null} {/* ② 本地技能 */} - {!normalizedQuery || visibleLocalCount > 0 ? ( + {catalogTab === 'local' && (!normalizedQuery || visibleLocalCount > 0) ? (
{visibleLocalCount === 0 ? ( @@ -431,7 +425,7 @@ export function SkillhubHomeView({
) : null} - {normalizedQuery && !marketLoading && !hasSearchResults ? ( + {normalizedQuery && (catalogTab === 'local' || !marketLoading) && !hasSearchResults ? (
{t('skillhub.home.noSearchResults')}
@@ -445,10 +439,10 @@ export function SkillhubHomeView({ skill={previewSkill} onClose={() => setPreviewSkill(null)} primaryAction={ - previewSkill + previewSkill && user ? marketCardPrimaryAction({ isMine: previewSkill.isMine, - listVisibility: marketFilter === 'mine' ? 'mine' : 'all', + listVisibility: 'all', cardState: previewSkill.cardState, }) : 'none' diff --git a/apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx b/apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx index 381fa465742..1f1c12a10d9 100644 --- a/apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx +++ b/apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx @@ -1,5 +1,5 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { Navigate, useLocation, useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Search, ChevronDown, ArrowLeft } from 'lucide-react'; import { @@ -28,7 +28,6 @@ import { marketCardPrimaryAction } from './lib/marketDetailViewModel'; import { groupMineByOwner } from './lib/mineGrouping'; import { nextMarketPreviewName } from './lib/marketPreviewSelection'; import { syncMarketPreviewSelection } from './lib/marketPreviewSync'; -import { canAccessSkillhubMarket } from './lib/marketAccess'; import { useAuth } from '@/contexts/AuthContext'; import { CATEGORY_ALL } from '../../../shared/skillhubCategory'; @@ -70,21 +69,13 @@ function FilterChip({ ); } -/** - * 未登录 / 本地模式没有 SkillHub 云端凭证,深链返回本地技能首页。 - * 登录后不再做组织白名单判断;Skill 数据的可见范围由服务端决定。 - * 登录态初始化期间(user 尚未水合)不误判,先按原样渲染。 - */ export function SkillhubMarketListView() { - const { user, isInitializing } = useAuth(); - if (!isInitializing && !canAccessSkillhubMarket(user)) { - return ; - } return ; } function SkillhubMarketListViewInner() { const { t } = useTranslation(); + const { user, isInitializing } = useAuth(); const location = useLocation(); const navigate = useNavigate(); const marketState = location.state as { freshEntry?: boolean; initialVisibility?: Visibility } | null; @@ -133,6 +124,10 @@ function SkillhubMarketListViewInner() { ); const [previewSkill, setPreviewSkill] = useState(null); + useEffect(() => { + if (!isInitializing && !user && visibility === 'mine') setVisibility('all'); + }, [isInitializing, setVisibility, user, visibility]); + useEffect(() => { if (isFreshEntry) { setPreviewSkill(null); @@ -250,11 +245,13 @@ function SkillhubMarketListViewInner() { setVisibility('all')} /> - setVisibility('mine')} - /> + {user ? ( + setVisibility('mine')} + /> + ) : null} @@ -489,7 +488,7 @@ function SkillhubMarketListViewInner() { open={previewSkill !== null} skill={previewSkill} onClose={handlePreviewClose} - primaryAction={previewSkill + primaryAction={previewSkill && user ? marketCardPrimaryAction({ isMine: previewSkill.isMine, listVisibility: visibility, diff --git a/apps/desktop/src/renderer/features/skillhub/hooks/useMarketList.ts b/apps/desktop/src/renderer/features/skillhub/hooks/useMarketList.ts index 6499f3f0332..3648afef218 100644 --- a/apps/desktop/src/renderer/features/skillhub/hooks/useMarketList.ts +++ b/apps/desktop/src/renderer/features/skillhub/hooks/useMarketList.ts @@ -274,7 +274,7 @@ export function useMarketList( options?: { /** * false 时完全不发市场请求(items 保持空、loading 保持 false)。 - * 供未登录 / 本地模式跳过云端请求与骨架屏;登录后自动补拉。 + * 供本地技能 Tab 跳过云端请求与骨架屏;切回云端目录后自动补拉。 */ enabled?: boolean; /** Initial server-side catalog partition; `all` preserves historical behavior. */ diff --git a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/homeMarketFilter.test.ts b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/homeMarketFilter.test.ts index 536f6eb60fd..59669f7d31c 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/homeMarketFilter.test.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/homeMarketFilter.test.ts @@ -3,7 +3,7 @@ import { homeMarketQuery, isHomeMarketResponseCurrent, matchesHomeMarketFilter, - visibleHomeMarketFilters, + visibleHomeCatalogTabs, } from '../homeMarketFilter'; describe('Skill home market filters', () => { @@ -20,27 +20,19 @@ describe('Skill home market filters', () => { }); }); - it('uses the published-management mode for mine', () => { - expect(homeMarketQuery('mine')).toEqual({ - scope: 'all', - visibility: 'mine', - sort: 'updated_at', - }); - }); - - it('hides organization for personal memberships', () => { - expect(visibleHomeMarketFilters(false)).toEqual(['public', 'mine']); - expect(visibleHomeMarketFilters(true)).toEqual(['public', 'organization', 'mine']); + it('places local skills after public and the optional organization tab', () => { + expect(visibleHomeCatalogTabs(false)).toEqual(['public', 'local']); + expect(visibleHomeCatalogTabs(true)).toEqual(['public', 'organization', 'local']); }); it('does not present a response from the previous tab as current', () => { - expect(isHomeMarketResponseCurrent(homeMarketQuery('mine'), { + expect(isHomeMarketResponseCurrent(homeMarketQuery('public'), { scope: 'market', - mine: false, - })).toBe(false); - expect(isHomeMarketResponseCurrent(homeMarketQuery('mine'), { - scope: 'all', mine: true, + })).toBe(false); + expect(isHomeMarketResponseCurrent(homeMarketQuery('public'), { + scope: 'market', + mine: false, })).toBe(true); }); diff --git a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketAccess.test.ts b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketAccess.test.ts deleted file mode 100644 index 9c4f4b8b4b7..00000000000 --- a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketAccess.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { canAccessSkillhubMarket } from '../marketAccess'; - -function user( - overrides: Partial<{ - membershipKind: 'personal' | 'org'; - orgName: string | null; - orgSlug: string | null; - }> = {}, -) { - return { - id: 'membership-1', - membershipKind: 'org' as const, - orgName: null, - orgSlug: null, - ...overrides, - }; -} - -describe('canAccessSkillhubMarket', () => { - it('allows personal accounts', () => { - expect(canAccessSkillhubMarket(user({ membershipKind: 'personal' }))).toBe(true); - }); - - it('allows every organization without inspecting its slug or display name', () => { - expect(canAccessSkillhubMarket(user({ orgSlug: 'xd', orgName: '心动' }))).toBe(true); - expect(canAccessSkillhubMarket(user({ orgSlug: 'disco-corp', orgName: 'Disco Corp' }))).toBe(true); - expect(canAccessSkillhubMarket(user({ orgSlug: null, orgName: null }))).toBe(true); - }); - - it('does not request cloud data without a logged-in Cindy account', () => { - expect(canAccessSkillhubMarket(null)).toBe(false); - }); -}); diff --git a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/scanResultPresentation.test.ts b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/scanResultPresentation.test.ts new file mode 100644 index 00000000000..3e9e5d12bef --- /dev/null +++ b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/scanResultPresentation.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { isPublicationProcessingFailure } from '../scanResultPresentation'; + +describe('scan result presentation', () => { + it('distinguishes a server processing error from a security rejection', () => { + expect(isPublicationProcessingFailure([{ name: 'INTERNAL_ERROR' }])).toBe(true); + expect(isPublicationProcessingFailure([{ name: 'security-scan' }])).toBe(false); + expect(isPublicationProcessingFailure(undefined)).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/features/skillhub/lib/homeMarketFilter.ts b/apps/desktop/src/renderer/features/skillhub/lib/homeMarketFilter.ts index 786d5ba1ffd..82d1d39e6a0 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/homeMarketFilter.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/homeMarketFilter.ts @@ -1,6 +1,7 @@ import type { CatalogScope, MarketSkill, SortBy, Visibility } from '../hooks/useMarketList'; -export type HomeMarketFilter = 'public' | 'organization' | 'mine'; +export type HomeMarketFilter = 'public' | 'organization'; +export type HomeCatalogTab = HomeMarketFilter | 'local'; export interface HomeMarketQuery { scope: CatalogScope; @@ -8,8 +9,10 @@ export interface HomeMarketQuery { sort: SortBy; } -export function visibleHomeMarketFilters(showOrganization: boolean): HomeMarketFilter[] { - return showOrganization ? ['public', 'organization', 'mine'] : ['public', 'mine']; +export function visibleHomeCatalogTabs( + showOrganization: boolean, +): HomeCatalogTab[] { + return showOrganization ? ['public', 'organization', 'local'] : ['public', 'local']; } export function isHomeMarketResponseCurrent( @@ -24,9 +27,6 @@ export function homeMarketQuery(filter: HomeMarketFilter): HomeMarketQuery { if (filter === 'organization') { return { scope: 'team', visibility: 'all', sort: 'trending' }; } - if (filter === 'mine') { - return { scope: 'all', visibility: 'mine', sort: 'updated_at' }; - } return { scope: 'market', visibility: 'all', sort: 'trending' }; } @@ -35,7 +35,6 @@ export function matchesHomeMarketFilter( skill: Pick, filter: HomeMarketFilter, ): boolean { - if (filter === 'mine') return skill.isMine; if (filter === 'public') { return skill.publishedVisibility === 'public' || (skill.publishedVisibility === undefined && skill.visibility === 'PUBLIC'); diff --git a/apps/desktop/src/renderer/features/skillhub/lib/marketAccess.ts b/apps/desktop/src/renderer/features/skillhub/lib/marketAccess.ts deleted file mode 100644 index 750dbb00168..00000000000 --- a/apps/desktop/src/renderer/features/skillhub/lib/marketAccess.ts +++ /dev/null @@ -1,12 +0,0 @@ -interface MarketAccessUser { - id: string; -} - -/** - * Skill Hub 只在 Cindy 云账号登录后发起请求。 - * 账号类型、组织和 Skill 可见范围均由 SkillHub 服务端根据已验证身份裁决, - * 客户端不再维护组织白名单。 - */ -export function canAccessSkillhubMarket(user: MarketAccessUser | null): boolean { - return user !== null; -} diff --git a/apps/desktop/src/renderer/features/skillhub/lib/scanResultPresentation.ts b/apps/desktop/src/renderer/features/skillhub/lib/scanResultPresentation.ts new file mode 100644 index 00000000000..40358ea5536 --- /dev/null +++ b/apps/desktop/src/renderer/features/skillhub/lib/scanResultPresentation.ts @@ -0,0 +1,11 @@ +interface ScanGateLike { + name: string; +} + +function normalizeCode(value: unknown): string { + return String(value ?? '').trim().toLowerCase().replace(/[\s_]+/g, '-'); +} + +export function isPublicationProcessingFailure(gates: ScanGateLike[] | undefined): boolean { + return gates?.some((gate) => normalizeCode(gate.name) === 'internal-error') ?? false; +} diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index cb80358c12e..6dcc5df48e2 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -10406,6 +10406,8 @@ "passedDesc": "This version has passed all security checks and is now live on the market.", "failedTitle": "Security Scan Failed", "failedDesc": "This version was marked as {{status}} due to security scan issues and cannot be installed by other users. Please fix the issues below and republish.", + "processingFailedTitle": "Publication Processing Failed", + "processingFailedDesc": "This version was uploaded, but the server encountered an internal error while processing it. It was not rejected by a security check; please republish later.", "copyReviewResult": "Copy review result", "copiedReviewResult": "Copied", "copyReviewResultFailed": "Copy failed", @@ -10423,7 +10425,8 @@ }, "gateLabel": { "llmReview": "LLM review", - "securityScan": "Security scan" + "securityScan": "Security scan", + "publicationProcessing": "Publication processing" } }, "diffPanel": { diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index f205663b8aa..194defb9e64 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -10384,6 +10384,8 @@ "passedDesc": "このバージョンはすべてのセキュリティチェックに合格し、マーケットに公開されました。", "failedTitle": "セキュリティスキャン不合格", "failedDesc": "このバージョンはセキュリティスキャンの問題により {{status}} としてマークされ、他のユーザーはインストールできません。以下の問題を修正して再公開してください。", + "processingFailedTitle": "公開処理に失敗しました", + "processingFailedDesc": "このバージョンはアップロードされましたが、サーバーの公開処理中に内部エラーが発生しました。セキュリティチェックによる拒否ではありません。時間をおいて再公開してください。", "copyReviewResult": "審査結果をコピー", "copiedReviewResult": "コピー済み", "copyReviewResultFailed": "コピーに失敗しました", @@ -10401,7 +10403,8 @@ }, "gateLabel": { "llmReview": "LLM 審査", - "securityScan": "セキュリティスキャン" + "securityScan": "セキュリティスキャン", + "publicationProcessing": "公開処理" } }, "diffPanel": { diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index d8b4b429422..a43a8caa8ac 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -10384,6 +10384,8 @@ "passedDesc": "이 버전은 모든 보안 검사를 통과하여 마켓에 게시되었습니다.", "failedTitle": "보안 스캔 실패", "failedDesc": "이 버전은 보안 스캔 문제로 인해 {{status}}으로 표시되어 다른 사용자가 설치할 수 없습니다. 아래 문제를 수정한 후 다시 게시하세요.", + "processingFailedTitle": "게시 처리 실패", + "processingFailedDesc": "이 버전은 업로드되었지만 서버가 게시 처리 중 내부 오류를 만났습니다. 보안 검사에서 거부된 것이 아니므로 잠시 후 다시 게시하세요.", "copyReviewResult": "심사 결과 복사", "copiedReviewResult": "복사됨", "copyReviewResultFailed": "복사 실패", @@ -10401,7 +10403,8 @@ }, "gateLabel": { "llmReview": "LLM 심사", - "securityScan": "보안 스캔" + "securityScan": "보안 스캔", + "publicationProcessing": "게시 처리" } }, "diffPanel": { diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index ed58839fe82..48b2de7c095 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -10376,6 +10376,8 @@ "passedDesc": "该版本已通过所有安全检查,现已发布到市场。", "failedTitle": "安全扫描未通过", "failedDesc": "该版本因安全扫描不达标被标记为 {{status}},暂时无法在市场中被其他用户安装。请修复以下问题后重新发布。", + "processingFailedTitle": "发布处理失败", + "processingFailedDesc": "该版本已上传,但服务器在处理发布时发生内部错误。这不是安全检查拒绝,请稍后重新发布。", "copyReviewResult": "复制审核结果", "copiedReviewResult": "已复制", "copyReviewResultFailed": "复制失败", @@ -10393,7 +10395,8 @@ }, "gateLabel": { "llmReview": "LLM 审核", - "securityScan": "安全扫描" + "securityScan": "安全扫描", + "publicationProcessing": "发布处理" } }, "diffPanel": { diff --git a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json index 0fada1f5fa3..896b0ffbde4 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -10376,6 +10376,8 @@ "passedDesc": "該版本已通過所有安全檢查,現已釋出到市場。", "failedTitle": "安全掃描未通過", "failedDesc": "該版本因安全掃描不達標被標記為 {{status}},暫時無法在市場中被其他使用者安裝。請修復以下問題後重新發布。", + "processingFailedTitle": "發布處理失敗", + "processingFailedDesc": "該版本已上傳,但伺服器處理發布時發生內部錯誤。這不是安全檢查拒絕,請稍後重新發布。", "copyReviewResult": "複製稽核結果", "copiedReviewResult": "已複製", "copyReviewResultFailed": "複製失敗", @@ -10393,7 +10395,8 @@ }, "gateLabel": { "llmReview": "LLM 稽核", - "securityScan": "安全掃描" + "securityScan": "安全掃描", + "publicationProcessing": "發布處理" } }, "diffPanel": { From 4f81da1c54ea1648d9b98d76a0c1d361ade6cff4 Mon Sep 17 00:00:00 2001 From: xd-bobo Date: Tue, 1 Sep 2026 17:16:45 +0800 Subject: [PATCH 07/15] fix(desktop): keep local endpoints across realm switches Signed-off-by: xd-bobo --- .../__tests__/clientEndpointsService.test.ts | 48 +++++++++++++++++-- .../src/main/clientEndpointsService.ts | 13 ++++- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/__tests__/clientEndpointsService.test.ts b/apps/desktop/src/main/__tests__/clientEndpointsService.test.ts index 6fcb5866dc0..5578c6d31f8 100644 --- a/apps/desktop/src/main/__tests__/clientEndpointsService.test.ts +++ b/apps/desktop/src/main/__tests__/clientEndpointsService.test.ts @@ -41,6 +41,7 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(), getAppPath: vi.fn(() => '/repo/apps/desktop'), + getPreferredSystemLanguages: vi.fn(() => ['en-US']), isPackaged: false, exit: vi.fn(), }, @@ -69,6 +70,7 @@ import { getClientEndpoint, getClientEndpointForRealm, getResolvedClientEndpoints, + initClientEndpoints, loadClientEndpointsForRealm, isUsingCachedClientEndpoints, registerClientEndpointsIpc, @@ -258,6 +260,46 @@ describe('resolveEndpointSource(清单来源三选一)', () => { }); }); +describe('localhost 开发端点的 realm 固定', () => { + it('登录恢复切换 realm 时仍复用本地文件清单,不加载线上清单', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'client-endpoints-local-realm-')); + const manifestPath = path.join(root, 'endpoint.local.json'); + const previousMode = process.env.XDT_DESKTOP_DEV_MODE; + const previousManifestFile = process.env.XDT_ENDPOINT_MANIFEST_FILE; + fs.writeFileSync( + manifestPath, + JSON.stringify({ + ...(JSON.parse(LOCAL_MANIFEST) as Record), + cindySkillHubApiBaseUrl: 'http://localhost:3345', + }), + ); + process.env.XDT_DESKTOP_DEV_MODE = 'local'; + process.env.XDT_ENDPOINT_MANIFEST_FILE = manifestPath; + + try { + await expect(initClientEndpoints()).resolves.toBe(true); + + activateClientEndpointRealm('cn'); + expect(getClientEndpoint('cindySkillHubApiBaseUrl')).toBe('http://localhost:3345'); + activateClientEndpointRealm('global'); + expect(getClientEndpoint('cindySkillHubApiBaseUrl')).toBe('http://localhost:3345'); + await expect(loadClientEndpointsForRealm('cn')).resolves.toMatchObject({ + cindySkillHubApiBaseUrl: 'http://localhost:3345', + }); + await expect(loadClientEndpointsForRealm('global')).resolves.toMatchObject({ + cindySkillHubApiBaseUrl: 'http://localhost:3345', + }); + expect(netRequest).not.toHaveBeenCalled(); + } finally { + if (previousMode === undefined) delete process.env.XDT_DESKTOP_DEV_MODE; + else process.env.XDT_DESKTOP_DEV_MODE = previousMode; + if (previousManifestFile === undefined) delete process.env.XDT_ENDPOINT_MANIFEST_FILE; + else process.env.XDT_ENDPOINT_MANIFEST_FILE = previousManifestFile; + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + /** 自动重试预算关掉的公共 deps 片段(测"一轮一次尝试"的原语义)。 */ const NO_AUTO_RETRY = { autoRetryDelaysMs: [] as readonly number[] }; @@ -1406,11 +1448,7 @@ describe('netlog 产物事后核对(verifyEndpointNetLogCapture)', () => { const captureDir = path.dirname(capture.file); const elsewhere = fs.mkdtempSync(path.join(logDir, 'elsewhere-')); fs.rmSync(captureDir, { recursive: true, force: true }); - fs.symlinkSync( - elsewhere, - captureDir, - process.platform === 'win32' ? 'junction' : 'dir', - ); + fs.symlinkSync(elsewhere, captureDir, process.platform === 'win32' ? 'junction' : 'dir'); fs.writeFileSync(capture.file, '{}', 'utf8'); expect(verifyEndpointNetLogCapture(capture)).toBe(false); }); diff --git a/apps/desktop/src/main/clientEndpointsService.ts b/apps/desktop/src/main/clientEndpointsService.ts index aa6781578b7..e2478538605 100644 --- a/apps/desktop/src/main/clientEndpointsService.ts +++ b/apps/desktop/src/main/clientEndpointsService.ts @@ -1153,7 +1153,18 @@ export async function initClientEndpoints(): Promise { // 缓存在构建区域,不能同时塞进两区,否则升级后留下的跨区 token 会被误发。 activeSessionRealm = resolvedRegion ?? BUILD_AUTH_REGION; realmEndpointCache.clear(); - realmEndpointCache.set(activeSessionRealm, endpoints); + // localhost 开发模式是一套本机服务,不存在跨区端点。登录恢复仍会按凭证 realm + // 调用 loadClientEndpointsForRealm/activateClientEndpointRealm;若只缓存启动区域, + // 另一 realm 会重新拉线上清单,把本地服务悄悄替换掉。仅对明确的 local + file + // 启动把同一份清单固定到两个 realm;remote/CDN 与普通文件覆写仍保持区域隔离。 + const pinLocalEndpointsToAllRealms = + !app.isPackaged && process.env.XDT_DESKTOP_DEV_MODE === 'local' && source.kind === 'file'; + if (pinLocalEndpointsToAllRealms) { + realmEndpointCache.set('cn', endpoints); + realmEndpointCache.set('global', endpoints); + } else { + realmEndpointCache.set(activeSessionRealm, endpoints); + } log.info( 'resolved from %s (%s): auth=%s cdn=%s', startedFromCachedManifest From ae70a4435fd911666532ad375331f0bf4cffb55c Mon Sep 17 00:00:00 2001 From: xd-bobo Date: Tue, 1 Sep 2026 17:17:08 +0800 Subject: [PATCH 08/15] fix(skillhub): align catalog presentation and review feedback Signed-off-by: xd-bobo --- .../skillhub/__tests__/infoMapping.test.ts | 4 +- .../skillhub/__tests__/publishService.test.ts | 507 ++++++++++++------ .../src/main/skillhub/publishService.ts | 265 ++++++--- apps/desktop/src/preload/preload.ts | 2 + .../features/skillhub/ScanResultDialog.tsx | 97 +++- .../features/skillhub/SkillhubDetailView.tsx | 36 +- .../features/skillhub/SkillhubHomeView.tsx | 42 +- .../__tests__/ScanResultDialog.test.tsx | 40 ++ .../skillhub/components/MarketCard.tsx | 38 +- .../skillhub/components/SkillIcon.tsx | 31 ++ .../components/__tests__/MarketCard.test.tsx | 74 +++ .../lib/__tests__/marketRoutes.test.ts | 3 + .../lib/__tests__/publishedStatus.test.ts | 2 +- .../skillhub/lib/__tests__/scanStatus.test.ts | 12 + .../features/skillhub/lib/publishedStatus.ts | 2 + .../features/skillhub/lib/scanStatus.ts | 6 +- .../src/renderer/i18n/locales/en/common.json | 4 + .../src/renderer/i18n/locales/ja/common.json | 4 + .../src/renderer/i18n/locales/ko/common.json | 4 + .../renderer/i18n/locales/zh-CN/common.json | 4 + .../renderer/i18n/locales/zh-TW/common.json | 4 + apps/desktop/src/renderer/vite-env.d.ts | 2 + 22 files changed, 829 insertions(+), 354 deletions(-) create mode 100644 apps/desktop/src/renderer/features/skillhub/__tests__/ScanResultDialog.test.tsx create mode 100644 apps/desktop/src/renderer/features/skillhub/components/SkillIcon.tsx create mode 100644 apps/desktop/src/renderer/features/skillhub/components/__tests__/MarketCard.test.tsx diff --git a/apps/desktop/src/main/skillhub/__tests__/infoMapping.test.ts b/apps/desktop/src/main/skillhub/__tests__/infoMapping.test.ts index 6407b4cf622..343410fae91 100644 --- a/apps/desktop/src/main/skillhub/__tests__/infoMapping.test.ts +++ b/apps/desktop/src/main/skillhub/__tests__/infoMapping.test.ts @@ -6,7 +6,7 @@ describe('mapHubSkillInfoToDesktopInfo', () => { it('preserves category slugs from Hub detail responses', () => { const info = mapHubSkillInfoToDesktopInfo({ slug: 'lark-task', - icon: 'https://skillhub.example.test/assets/default-skill-icon.svg', + icon: 'https://skillhub.example.test/assets/default-skill-icon-v4.svg', displayName: 'Lark Task', summary: 'Market summary', description: 'Manage tasks', @@ -23,7 +23,7 @@ describe('mapHubSkillInfoToDesktopInfo', () => { }); expect(info.categories).toEqual(['engine', 'office']); - expect(info.icon).toBe('https://skillhub.example.test/assets/default-skill-icon.svg'); + expect(info.icon).toBe('https://skillhub.example.test/assets/default-skill-icon-v4.svg'); expect(info.description).toBe('Market summary'); expect(info.downloads).toBe(135); }); diff --git a/apps/desktop/src/main/skillhub/__tests__/publishService.test.ts b/apps/desktop/src/main/skillhub/__tests__/publishService.test.ts index d728d32e18a..84e4f4271c7 100644 --- a/apps/desktop/src/main/skillhub/__tests__/publishService.test.ts +++ b/apps/desktop/src/main/skillhub/__tests__/publishService.test.ts @@ -78,11 +78,13 @@ vi.mock('../../appCapabilities.js', () => ({ requireAppCapability: vi.fn(), })); - function writeApiKeyFile() { const safeStorageDir = `${TEST_ROOT}/safe-storage`; fs.mkdirSync(safeStorageDir, { recursive: true }); - fs.writeFileSync(`${safeStorageDir}/api_key.enc`, Buffer.from('encrypted-api-key').toString('base64')); + fs.writeFileSync( + `${safeStorageDir}/api_key.enc`, + Buffer.from('encrypted-api-key').toString('base64'), + ); } describe('SkillPublishService', () => { @@ -126,16 +128,19 @@ describe('SkillPublishService', () => { writeApiKeyFile(); const skillPath = '/tmp/xdt-publish-service-test/skill'; fs.mkdirSync(skillPath, { recursive: true }); - fs.writeFileSync(`${skillPath}/SKILL.md`, [ - '---', - 'name: lark-task', - 'version: 1.0.0', - 'description: Frontmatter description', - '---', - '', - '# Lark task', - '', - ].join('\n')); + fs.writeFileSync( + `${skillPath}/SKILL.md`, + [ + '---', + 'name: lark-task', + 'version: 1.0.0', + 'description: Frontmatter description', + '---', + '', + '# Lark task', + '', + ].join('\n'), + ); const { serverApiFetch } = await import('../../serverApiClient'); const { computeFolderHash } = await import('../folderHash'); @@ -148,25 +153,37 @@ describe('SkillPublishService', () => { vi.mocked(computeFolderHash).mockResolvedValue('folder-hash'); vi.mocked(writeSnapshot).mockResolvedValue(undefined); - vi.mocked(pack).mockResolvedValue({ buffer: Buffer.from('zip'), size: 3, sha256: 'zip-sha', manifest: { files: [] } }); + vi.mocked(pack).mockResolvedValue({ + buffer: Buffer.from('zip'), + size: 3, + sha256: 'zip-sha', + manifest: { files: [] }, + }); vi.mocked(getCurrentUserId).mockReturnValue('user-1'); vi.mocked(registryService.getInstall).mockResolvedValue(null); vi.mocked(net.fetch).mockResolvedValue({ ok: true, status: 200 } as Response); - vi.mocked(serverApiFetch).mockImplementation(async (apiPath: string, opts?: { body?: unknown }) => { - if (apiPath === '/api/skills-hub/skills/publish/init') { - return { - nextVersion: '1.1.0', - ossKey: 'skills/lark-task/v1.1.0.zip', - uploadUrl: 'https://oss.example.com/skills/lark-task.zip', - }; - } - if (apiPath === '/api/skills-hub/skills/publish/commit') { - return { slug: 'lark-task', version: (opts?.body as { version: string }).version, status: 'scanning' }; - } - throw new Error(`unexpected api path ${apiPath}`); - }); + vi.mocked(serverApiFetch).mockImplementation( + async (apiPath: string, opts?: { body?: unknown }) => { + if (apiPath === '/api/skills-hub/skills/publish/init') { + return { + nextVersion: '1.1.0', + ossKey: 'skills/lark-task/v1.1.0.zip', + uploadUrl: 'https://oss.example.com/skills/lark-task.zip', + }; + } + if (apiPath === '/api/skills-hub/skills/publish/commit') { + return { + slug: 'lark-task', + version: (opts?.body as { version: string }).version, + status: 'scanning', + }; + } + throw new Error(`unexpected api path ${apiPath}`); + }, + ); const service = new SkillPublishService(); + const scanPollSpy = vi.spyOn(service, 'startScanPoll').mockImplementation(() => {}); const result = await service.publish( { absolutePath: skillPath, @@ -179,7 +196,9 @@ describe('SkillPublishService', () => { ); expect(result.success).toBe(true); - const commitCall = vi.mocked(serverApiFetch).mock.calls.find(([path]) => path === '/api/skills-hub/skills/publish/commit'); + const commitCall = vi + .mocked(serverApiFetch) + .mock.calls.find(([path]) => path === '/api/skills-hub/skills/publish/commit'); expect(commitCall?.[1]?.body).toMatchObject({ ossKey: 'skills/lark-task/v1.1.0.zip', slug: 'lark-task', @@ -189,21 +208,25 @@ describe('SkillPublishService', () => { expect(commitCall?.[1]?.body).not.toHaveProperty('categoryMode'); expect(commitCall?.[1]?.body).not.toHaveProperty('categories'); expect(commitCall?.[1]?.body).not.toHaveProperty('visibility'); + expect(scanPollSpy).toHaveBeenCalledWith('lark-task', '1.1.0'); }); it('publishes through Hub without requiring a local LLM API key file', async () => { const skillPath = '/tmp/xdt-publish-service-test/skill'; fs.mkdirSync(skillPath, { recursive: true }); - fs.writeFileSync(`${skillPath}/SKILL.md`, [ - '---', - 'name: lark-task', - 'version: 1.0.0', - 'description: Frontmatter description', - '---', - '', - '# Lark task', - '', - ].join('\n')); + fs.writeFileSync( + `${skillPath}/SKILL.md`, + [ + '---', + 'name: lark-task', + 'version: 1.0.0', + 'description: Frontmatter description', + '---', + '', + '# Lark task', + '', + ].join('\n'), + ); const { serverApiFetch } = await import('../../serverApiClient'); const { computeFolderHash } = await import('../folderHash'); @@ -216,23 +239,34 @@ describe('SkillPublishService', () => { vi.mocked(computeFolderHash).mockResolvedValue('folder-hash'); vi.mocked(writeSnapshot).mockResolvedValue(undefined); - vi.mocked(pack).mockResolvedValue({ buffer: Buffer.from('zip'), size: 3, sha256: 'zip-sha', manifest: { files: [] } }); + vi.mocked(pack).mockResolvedValue({ + buffer: Buffer.from('zip'), + size: 3, + sha256: 'zip-sha', + manifest: { files: [] }, + }); vi.mocked(getCurrentUserId).mockReturnValue('user-1'); vi.mocked(registryService.getInstall).mockResolvedValue(null); vi.mocked(net.fetch).mockResolvedValue({ ok: true, status: 200 } as Response); - vi.mocked(serverApiFetch).mockImplementation(async (apiPath: string, opts?: { body?: unknown }) => { - if (apiPath === '/api/skills-hub/skills/publish/init') { - return { - nextVersion: '1.1.0', - ossKey: 'skills/lark-task/v1.1.0.zip', - uploadUrl: 'https://oss.example.com/skills/lark-task.zip', - }; - } - if (apiPath === '/api/skills-hub/skills/publish/commit') { - return { slug: 'lark-task', version: (opts?.body as { version: string }).version, status: 'scanning' }; - } - throw new Error(`unexpected api path ${apiPath}`); - }); + vi.mocked(serverApiFetch).mockImplementation( + async (apiPath: string, opts?: { body?: unknown }) => { + if (apiPath === '/api/skills-hub/skills/publish/init') { + return { + nextVersion: '1.1.0', + ossKey: 'skills/lark-task/v1.1.0.zip', + uploadUrl: 'https://oss.example.com/skills/lark-task.zip', + }; + } + if (apiPath === '/api/skills-hub/skills/publish/commit') { + return { + slug: 'lark-task', + version: (opts?.body as { version: string }).version, + status: 'scanning', + }; + } + throw new Error(`unexpected api path ${apiPath}`); + }, + ); const service = new SkillPublishService(); const result = await service.publish( @@ -252,27 +286,30 @@ describe('SkillPublishService', () => { baseUrl: expect.any(Function), logLabel: '/api/skills-hub', // 不外泄 skill 身份进 serverApiClient 日志(2026-08-06 review) }); - const initCall = vi.mocked(serverApiFetch).mock.calls.find( - ([path]) => path === '/api/skills-hub/skills/publish/init', - ); + const initCall = vi + .mocked(serverApiFetch) + .mock.calls.find(([path]) => path === '/api/skills-hub/skills/publish/init'); const initBaseUrl = initCall?.[1]?.baseUrl; - expect( - typeof initBaseUrl === 'function' ? initBaseUrl() : initBaseUrl, - ).toBe('https://skillhub.test.invalid'); + expect(typeof initBaseUrl === 'function' ? initBaseUrl() : initBaseUrl).toBe( + 'https://skillhub.test.invalid', + ); }); it('sends the hand-filled 280-char text to Hub commit as summary', async () => { writeApiKeyFile(); fs.mkdirSync('/tmp/xdt-publish-service-test/skill', { recursive: true }); - fs.writeFileSync('/tmp/xdt-publish-service-test/skill/SKILL.md', [ - '---', - 'name: lark-task', - 'description: Frontmatter description', - '---', - '', - '# Lark task', - '', - ].join('\n')); + fs.writeFileSync( + '/tmp/xdt-publish-service-test/skill/SKILL.md', + [ + '---', + 'name: lark-task', + 'description: Frontmatter description', + '---', + '', + '# Lark task', + '', + ].join('\n'), + ); const { serverApiFetch } = await import('../../serverApiClient'); const { computeFolderHash } = await import('../folderHash'); @@ -285,23 +322,34 @@ describe('SkillPublishService', () => { vi.mocked(computeFolderHash).mockResolvedValue('folder-hash'); vi.mocked(writeSnapshot).mockResolvedValue(undefined); - vi.mocked(pack).mockResolvedValue({ buffer: Buffer.from('zip'), size: 3, sha256: 'zip-sha', manifest: { files: [] } }); + vi.mocked(pack).mockResolvedValue({ + buffer: Buffer.from('zip'), + size: 3, + sha256: 'zip-sha', + manifest: { files: [] }, + }); vi.mocked(getCurrentUserId).mockReturnValue('user-1'); vi.mocked(registryService.getInstall).mockResolvedValue(null); vi.mocked(net.fetch).mockResolvedValue({ ok: true, status: 200 } as Response); - vi.mocked(serverApiFetch).mockImplementation(async (apiPath: string, opts?: { body?: unknown }) => { - if (apiPath === '/api/skills-hub/skills/publish/init') { - return { - nextVersion: '1.0.0', - ossKey: 'skills/lark-task/v1.0.0.zip', - uploadUrl: 'https://oss.example.com/skills/lark-task.zip', - }; - } - if (apiPath === '/api/skills-hub/skills/publish/commit') { - return { slug: 'lark-task', version: (opts?.body as { version: string }).version, status: 'scanning' }; - } - throw new Error(`unexpected api path ${apiPath}`); - }); + vi.mocked(serverApiFetch).mockImplementation( + async (apiPath: string, opts?: { body?: unknown }) => { + if (apiPath === '/api/skills-hub/skills/publish/init') { + return { + nextVersion: '1.0.0', + ossKey: 'skills/lark-task/v1.0.0.zip', + uploadUrl: 'https://oss.example.com/skills/lark-task.zip', + }; + } + if (apiPath === '/api/skills-hub/skills/publish/commit') { + return { + slug: 'lark-task', + version: (opts?.body as { version: string }).version, + status: 'scanning', + }; + } + throw new Error(`unexpected api path ${apiPath}`); + }, + ); const service = new SkillPublishService(); const result = await service.publish( @@ -331,22 +379,27 @@ describe('SkillPublishService', () => { categoryMode: 'manual', }), }); - const commitCall = vi.mocked(serverApiFetch).mock.calls.find(([path]) => path === '/api/skills-hub/skills/publish/commit'); + const commitCall = vi + .mocked(serverApiFetch) + .mock.calls.find(([path]) => path === '/api/skills-hub/skills/publish/commit'); expect(commitCall?.[1]?.body).not.toHaveProperty('description'); }); it('allows auto category mode and asks Hub to classify the skill', async () => { writeApiKeyFile(); fs.mkdirSync('/tmp/xdt-publish-service-test/skill', { recursive: true }); - fs.writeFileSync('/tmp/xdt-publish-service-test/skill/SKILL.md', [ - '---', - 'name: lark-task', - 'description: Frontmatter description', - '---', - '', - '# Lark task', - '', - ].join('\n')); + fs.writeFileSync( + '/tmp/xdt-publish-service-test/skill/SKILL.md', + [ + '---', + 'name: lark-task', + 'description: Frontmatter description', + '---', + '', + '# Lark task', + '', + ].join('\n'), + ); const { serverApiFetch } = await import('../../serverApiClient'); const { computeFolderHash } = await import('../folderHash'); @@ -359,23 +412,34 @@ describe('SkillPublishService', () => { vi.mocked(computeFolderHash).mockResolvedValue('folder-hash'); vi.mocked(writeSnapshot).mockResolvedValue(undefined); - vi.mocked(pack).mockResolvedValue({ buffer: Buffer.from('zip'), size: 3, sha256: 'zip-sha', manifest: { files: [] } }); + vi.mocked(pack).mockResolvedValue({ + buffer: Buffer.from('zip'), + size: 3, + sha256: 'zip-sha', + manifest: { files: [] }, + }); vi.mocked(getCurrentUserId).mockReturnValue('user-1'); vi.mocked(registryService.getInstall).mockResolvedValue(null); vi.mocked(net.fetch).mockResolvedValue({ ok: true, status: 200 } as Response); - vi.mocked(serverApiFetch).mockImplementation(async (apiPath: string, opts?: { body?: unknown }) => { - if (apiPath === '/api/skills-hub/skills/publish/init') { - return { - nextVersion: '1.0.0', - ossKey: 'skills/lark-task/v1.0.0.zip', - uploadUrl: 'https://oss.example.com/skills/lark-task.zip', - }; - } - if (apiPath === '/api/skills-hub/skills/publish/commit') { - return { slug: 'lark-task', version: (opts?.body as { version: string }).version, status: 'scanning' }; - } - throw new Error(`unexpected api path ${apiPath}`); - }); + vi.mocked(serverApiFetch).mockImplementation( + async (apiPath: string, opts?: { body?: unknown }) => { + if (apiPath === '/api/skills-hub/skills/publish/init') { + return { + nextVersion: '1.0.0', + ossKey: 'skills/lark-task/v1.0.0.zip', + uploadUrl: 'https://oss.example.com/skills/lark-task.zip', + }; + } + if (apiPath === '/api/skills-hub/skills/publish/commit') { + return { + slug: 'lark-task', + version: (opts?.body as { version: string }).version, + status: 'scanning', + }; + } + throw new Error(`unexpected api path ${apiPath}`); + }, + ); const service = new SkillPublishService(); const result = await service.publish( @@ -408,15 +472,18 @@ describe('SkillPublishService', () => { it('keeps an explicit empty visibleSlugs list in first-publish commit', async () => { writeApiKeyFile(); fs.mkdirSync('/tmp/xdt-publish-service-test/skill', { recursive: true }); - fs.writeFileSync('/tmp/xdt-publish-service-test/skill/SKILL.md', [ - '---', - 'name: lark-task', - 'description: Frontmatter description', - '---', - '', - '# Lark task', - '', - ].join('\n')); + fs.writeFileSync( + '/tmp/xdt-publish-service-test/skill/SKILL.md', + [ + '---', + 'name: lark-task', + 'description: Frontmatter description', + '---', + '', + '# Lark task', + '', + ].join('\n'), + ); const { serverApiFetch } = await import('../../serverApiClient'); const { computeFolderHash } = await import('../folderHash'); @@ -429,23 +496,34 @@ describe('SkillPublishService', () => { vi.mocked(computeFolderHash).mockResolvedValue('folder-hash'); vi.mocked(writeSnapshot).mockResolvedValue(undefined); - vi.mocked(pack).mockResolvedValue({ buffer: Buffer.from('zip'), size: 3, sha256: 'zip-sha', manifest: { files: [] } }); + vi.mocked(pack).mockResolvedValue({ + buffer: Buffer.from('zip'), + size: 3, + sha256: 'zip-sha', + manifest: { files: [] }, + }); vi.mocked(getCurrentUserId).mockReturnValue('user-1'); vi.mocked(registryService.getInstall).mockResolvedValue(null); vi.mocked(net.fetch).mockResolvedValue({ ok: true, status: 200 } as Response); - vi.mocked(serverApiFetch).mockImplementation(async (apiPath: string, opts?: { body?: unknown }) => { - if (apiPath === '/api/skills-hub/skills/publish/init') { - return { - nextVersion: '1.0.0', - ossKey: 'skills/lark-task/v1.0.0.zip', - uploadUrl: 'https://oss.example.com/skills/lark-task.zip', - }; - } - if (apiPath === '/api/skills-hub/skills/publish/commit') { - return { slug: 'lark-task', version: (opts?.body as { version: string }).version, status: 'scanning' }; - } - throw new Error(`unexpected api path ${apiPath}`); - }); + vi.mocked(serverApiFetch).mockImplementation( + async (apiPath: string, opts?: { body?: unknown }) => { + if (apiPath === '/api/skills-hub/skills/publish/init') { + return { + nextVersion: '1.0.0', + ossKey: 'skills/lark-task/v1.0.0.zip', + uploadUrl: 'https://oss.example.com/skills/lark-task.zip', + }; + } + if (apiPath === '/api/skills-hub/skills/publish/commit') { + return { + slug: 'lark-task', + version: (opts?.body as { version: string }).version, + status: 'scanning', + }; + } + throw new Error(`unexpected api path ${apiPath}`); + }, + ); const service = new SkillPublishService(); const result = await service.publish( @@ -661,15 +739,18 @@ describe('SkillPublishService', () => { it('maps preserved Hub business error codes to actionable publish errors', async () => { writeApiKeyFile(); fs.mkdirSync('/tmp/xdt-publish-service-test/skill', { recursive: true }); - fs.writeFileSync('/tmp/xdt-publish-service-test/skill/SKILL.md', [ - '---', - 'name: lark-task', - 'description: Frontmatter description', - '---', - '', - '# Lark task', - '', - ].join('\n')); + fs.writeFileSync( + '/tmp/xdt-publish-service-test/skill/SKILL.md', + [ + '---', + 'name: lark-task', + 'description: Frontmatter description', + '---', + '', + '# Lark task', + '', + ].join('\n'), + ); const { ServerApiError, serverApiFetch } = await import('../../serverApiClient'); const { computeFolderHash } = await import('../folderHash'); @@ -678,7 +759,12 @@ describe('SkillPublishService', () => { const { SkillPublishService } = await import('../publishService'); vi.mocked(computeFolderHash).mockResolvedValue('folder-hash-2'); - vi.mocked(pack).mockResolvedValue({ buffer: Buffer.from('zip'), size: 3, sha256: 'zip-sha', manifest: { files: [] } }); + vi.mocked(pack).mockResolvedValue({ + buffer: Buffer.from('zip'), + size: 3, + sha256: 'zip-sha', + manifest: { files: [] }, + }); vi.mocked(net.fetch).mockResolvedValue({ ok: true, status: 200 } as Response); vi.mocked(serverApiFetch).mockImplementation(async (apiPath: string) => { if (apiPath === '/api/skills-hub/skills/publish/init') { @@ -754,21 +840,26 @@ describe('SkillPublishService', () => { { phase: 'packing' }, { phase: 'failed', name: 'lark-task', errorCode: 'PACK_FAILED', message: 'zip failed' }, ]); - expect(fs.readFileSync('/tmp/xdt-publish-service-test/skill/SKILL.md', 'utf8')).toBe(originalSkillMd); + expect(fs.readFileSync('/tmp/xdt-publish-service-test/skill/SKILL.md', 'utf8')).toBe( + originalSkillMd, + ); }); it('maps pack timeout failures to PACK_FAILED without relying on IPC rejection', async () => { fs.mkdirSync('/tmp/xdt-publish-service-test/skill', { recursive: true }); - fs.writeFileSync('/tmp/xdt-publish-service-test/skill/SKILL.md', [ - '---', - 'name: lark-task', - 'version: 0.9.0', - 'description: Frontmatter description', - '---', - '', - '# Lark task', - '', - ].join('\n')); + fs.writeFileSync( + '/tmp/xdt-publish-service-test/skill/SKILL.md', + [ + '---', + 'name: lark-task', + 'version: 0.9.0', + 'description: Frontmatter description', + '---', + '', + '# Lark task', + '', + ].join('\n'), + ); const { computeFolderHash } = await import('../folderHash'); const { pack } = await import('../zipPacker'); @@ -804,25 +895,33 @@ describe('SkillPublishService', () => { it('treats cancellation during packing as CANCELLED', async () => { fs.mkdirSync('/tmp/xdt-publish-service-test/skill', { recursive: true }); - fs.writeFileSync('/tmp/xdt-publish-service-test/skill/SKILL.md', [ - '---', - 'name: lark-task', - 'version: 0.9.0', - 'description: Frontmatter description', - '---', - '', - '# Lark task', - '', - ].join('\n')); + fs.writeFileSync( + '/tmp/xdt-publish-service-test/skill/SKILL.md', + [ + '---', + 'name: lark-task', + 'version: 0.9.0', + 'description: Frontmatter description', + '---', + '', + '# Lark task', + '', + ].join('\n'), + ); const { computeFolderHash } = await import('../folderHash'); const { pack } = await import('../zipPacker'); const { SkillPublishService } = await import('../publishService'); vi.mocked(computeFolderHash).mockResolvedValue('folder-hash'); - vi.mocked(pack).mockImplementation((_absolutePath, options) => new Promise((_, reject) => { - options?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true }); - })); + vi.mocked(pack).mockImplementation( + (_absolutePath, options) => + new Promise((_, reject) => { + options?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { + once: true, + }); + }), + ); const events: Array<{ phase: string; errorCode?: string; message?: string }> = []; const service = new SkillPublishService(); @@ -915,14 +1014,88 @@ describe('SkillPublishService', () => { } }); + it('finishes the publish-scoped poll when machine review hands off to manual review', async () => { + vi.useFakeTimers(); + try { + const { serverApiFetch } = await import('../../serverApiClient'); + const { SkillPublishService } = await import('../publishService'); + + vi.mocked(serverApiFetch).mockResolvedValue({ + status: 'pending', + gates: [{ name: 'security-scan', status: 'pass' }], + }); + + const events: Array<{ phase: string; status?: string; gates?: unknown[] }> = []; + const service = new SkillPublishService({ + scanPollIntervalMs: 10, + onProgress: (event) => events.push(event), + }); + + service.startScanPoll('lark-task', '1.0.0'); + await vi.advanceTimersByTimeAsync(10); + + expect(events).toEqual([ + { + phase: 'scan-status', + name: 'lark-task', + version: '1.0.0', + status: 'pending', + gates: [{ name: 'security-scan', status: 'pass' }], + }, + { + phase: 'scan-result', + name: 'lark-task', + version: '1.0.0', + status: 'pending', + gates: [{ name: 'security-scan', status: 'pass' }], + }, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it('stops polling when the release is waiting for Platform review', async () => { + vi.useFakeTimers(); + try { + const { serverApiFetch } = await import('../../serverApiClient'); + const { SkillPublishService } = await import('../publishService'); + + vi.mocked(serverApiFetch).mockResolvedValue({ status: 'pending', gates: [] }); + + const events: Array<{ phase: string; status?: string }> = []; + const service = new SkillPublishService({ + scanPollIntervalMs: 10, + onProgress: (event) => events.push(event), + }); + + service.startScanPoll('lark-task', '1.0.0'); + await vi.advanceTimersByTimeAsync(20); + + expect(events.map((event) => [event.phase, event.status])).toEqual([ + ['scan-status', 'pending'], + ['scan-result', 'pending'], + ]); + expect(serverApiFetch).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + it('ignores stale scan poll responses after a newer poll starts', async () => { vi.useFakeTimers(); try { const { serverApiFetch } = await import('../../serverApiClient'); const { SkillPublishService } = await import('../publishService'); - let resolveOldPoll!: (value: { status: string; gates: Array<{ name: string; status: string }> }) => void; - const oldPollResult = new Promise<{ status: string; gates: Array<{ name: string; status: string }> }>((resolve) => { + let resolveOldPoll!: (value: { + status: string; + gates: Array<{ name: string; status: string }>; + }) => void; + const oldPollResult = new Promise<{ + status: string; + gates: Array<{ name: string; status: string }>; + }>((resolve) => { resolveOldPoll = resolve; }); vi.mocked(serverApiFetch) @@ -932,7 +1105,13 @@ describe('SkillPublishService', () => { gates: [{ name: 'policy', status: 'pass' }], }); - const events: Array<{ phase: string; name?: string; version?: string; status?: string; gates?: unknown[] }> = []; + const events: Array<{ + phase: string; + name?: string; + version?: string; + status?: string; + gates?: unknown[]; + }> = []; const service = new SkillPublishService({ scanPollIntervalMs: 10, onProgress: (event) => events.push(event), diff --git a/apps/desktop/src/main/skillhub/publishService.ts b/apps/desktop/src/main/skillhub/publishService.ts index 1c27115b4ca..2da8ab74ce9 100644 --- a/apps/desktop/src/main/skillhub/publishService.ts +++ b/apps/desktop/src/main/skillhub/publishService.ts @@ -137,7 +137,8 @@ function serverErrorToCode(err: unknown): PublishErrorCode { if (code === 'CHECKSUM_MISMATCH') return 'CHECKSUM_MISMATCH'; if (code === 'NOT_AUTHOR') return 'NOT_AUTHOR'; if (code === 'OSS_OBJECT_NOT_FOUND') return 'OSS_OBJECT_NOT_FOUND'; - if (err.message.includes('manifest') || err.message.includes('frontmatter')) return 'MANIFEST_INVALID'; + if (err.message.includes('manifest') || err.message.includes('frontmatter')) + return 'MANIFEST_INVALID'; return 'INTERNAL'; } return 'INTERNAL'; @@ -152,7 +153,7 @@ function normalizePublishCategories(categories?: string[]): string[] { return [...new Set((categories ?? []).map((category) => category.trim()).filter(Boolean))]; } -const PASSING_SCAN_STATUSES = new Set(['pass', 'passed', 'published']); +const PASSING_SCAN_STATUSES = new Set(['pass', 'passed', 'approved', 'published']); const FAILING_SCAN_STATUSES = new Set(['fail', 'failed', 'quarantine', 'rejected', 'blocked']); function normalizeScanStatus(status: string): string { @@ -164,7 +165,16 @@ function isTerminalScanStatus(status: string): boolean { return PASSING_SCAN_STATUSES.has(normalized) || FAILING_SCAN_STATUSES.has(normalized); } -async function syncPublishedRegistry(slug: string, absolutePath: string, version: string, folderHash: string): Promise { +function isPendingManualReviewStatus(status: string): boolean { + return normalizeScanStatus(status) === 'pending'; +} + +async function syncPublishedRegistry( + slug: string, + absolutePath: string, + version: string, + folderHash: string, +): Promise { const nowSec = Math.floor(Date.now() / 1000); const myUserId = getCurrentUserId() ?? ''; const existing = await registryService.getInstall(slug, absolutePath); @@ -195,7 +205,11 @@ export class SkillPublishService { private readonly onProgress?: ProgressCb; private readonly scanPollIntervalMs: number; private readonly packTimeoutMs: number; - private activeScanPoll: { slug: string; version: string; timer: ReturnType } | null = null; + private activeScanPoll: { + slug: string; + version: string; + timer: ReturnType; + } | null = null; private scanPollGeneration = 0; constructor(options: SkillPublishServiceOptions = {}) { @@ -212,36 +226,62 @@ export class SkillPublishService { async publish( params: PublishParams, onProgress: ProgressCb = () => {}, - ): Promise<{ success: boolean; result?: { name: string; version: string }; errorCode?: string; error?: string }> { + ): Promise<{ + success: boolean; + result?: { name: string; version: string }; + errorCode?: string; + error?: string; + }> { if (!getAppCapabilities().canUseSkillHubCloud) { - this.emitProgress({ - phase: 'failed', - name: params.name, - errorCode: 'CANCELLED', - message: 'SkillHub publish is unavailable in local mode', - }, onProgress); + this.emitProgress( + { + phase: 'failed', + name: params.name, + errorCode: 'CANCELLED', + message: 'SkillHub publish is unavailable in local mode', + }, + onProgress, + ); return { success: false, errorCode: 'CANCELLED' }; } const publishOwnerId = getCurrentDataOwnerId(); if (!publishOwnerId) { - this.emitProgress({ - phase: 'failed', - name: params.name, - errorCode: 'CANCELLED', - message: 'SkillHub publish requires an active data owner', - }, onProgress); + this.emitProgress( + { + phase: 'failed', + name: params.name, + errorCode: 'CANCELLED', + message: 'SkillHub publish requires an active data owner', + }, + onProgress, + ); return { success: false, errorCode: 'CANCELLED' }; } if (this.current) { - this.emitProgress({ phase: 'failed', name: params.name, errorCode: 'INTERNAL', message: '已有发布任务进行中' }, onProgress); + this.emitProgress( + { + phase: 'failed', + name: params.name, + errorCode: 'INTERNAL', + message: '已有发布任务进行中', + }, + onProgress, + ); return { success: false, errorCode: 'INTERNAL' }; } - const categoryMode = params.isFirstPublish ? (params.categoryMode ?? 'manual') : undefined; const categories = categoryMode === 'auto' ? [] : normalizePublishCategories(params.categories); if (params.isFirstPublish && categoryMode === 'manual' && categories.length === 0) { - this.emitProgress({ phase: 'failed', name: params.name, errorCode: 'CATEGORY_REQUIRED', message: '请选择分类后再发布' }, onProgress); + this.emitProgress( + { + phase: 'failed', + name: params.name, + errorCode: 'CATEGORY_REQUIRED', + message: '请选择分类后再发布', + }, + onProgress, + ); return { success: false, errorCode: 'CATEGORY_REQUIRED' }; } @@ -263,7 +303,9 @@ export class SkillPublishService { const skillMdPath = path.join(params.absolutePath, 'SKILL.md'); try { originalSkillMd = await fs.promises.readFile(skillMdPath, 'utf-8'); - } catch { /* 文件不存在则无需回滚 */ } + } catch { + /* 文件不存在则无需回滚 */ + } await updateSkillMdVersion(params.absolutePath, params.version); } @@ -274,27 +316,39 @@ export class SkillPublishService { if (!state.packCache) { this.emitProgress({ phase: 'packing' }, onProgress); if (isCancelled()) { - this.emitProgress({ phase: 'failed', name: params.name, errorCode: 'CANCELLED', message: '已取消' }, onProgress); + this.emitProgress( + { phase: 'failed', name: params.name, errorCode: 'CANCELLED', message: '已取消' }, + onProgress, + ); return { success: false, errorCode: 'CANCELLED' }; } try { - state.packCache = await pack(params.absolutePath, { timeoutMs: this.packTimeoutMs, signal }); + state.packCache = await pack(params.absolutePath, { + timeoutMs: this.packTimeoutMs, + signal, + }); } catch (err) { if (isCancelled()) throw err; const message = err instanceof Error ? err.message : String(err); log.error(`[publish:pack] failed | name=${params.name}:`, err); - this.emitProgress({ - phase: 'failed', - name: params.name, - errorCode: 'PACK_FAILED', - message, - }, onProgress); + this.emitProgress( + { + phase: 'failed', + name: params.name, + errorCode: 'PACK_FAILED', + message, + }, + onProgress, + ); return { success: false, errorCode: 'PACK_FAILED', error: message }; } } if (isCancelled()) { - this.emitProgress({ phase: 'failed', name: params.name, errorCode: 'CANCELLED', message: '已取消' }, onProgress); + this.emitProgress( + { phase: 'failed', name: params.name, errorCode: 'CANCELLED', message: '已取消' }, + onProgress, + ); return { success: false, errorCode: 'CANCELLED' }; } @@ -305,15 +359,21 @@ export class SkillPublishService { if (!state.initCache) { this.emitProgress({ phase: 'init' }, onProgress); if (isCancelled()) { - this.emitProgress({ phase: 'failed', name: params.name, errorCode: 'CANCELLED', message: '已取消' }, onProgress); + this.emitProgress( + { phase: 'failed', name: params.name, errorCode: 'CANCELLED', message: '已取消' }, + onProgress, + ); return { success: false, errorCode: 'CANCELLED' }; } try { - const initResp = await skillhubApiFetch('/api/skills-hub/skills/publish/init', { - method: 'POST', - body: { slug: params.name, ...(params.version && { version: params.version }) }, - }); + const initResp = await skillhubApiFetch( + '/api/skills-hub/skills/publish/init', + { + method: 'POST', + body: { slug: params.name, ...(params.version && { version: params.version }) }, + }, + ); state.initCache = initResp; const urlPreview = (() => { try { @@ -335,16 +395,22 @@ export class SkillPublishService { } catch (err) { log.error(`[publish:init] failed | name=${params.name} err=`, err); if (isCancelled()) { - this.emitProgress({ phase: 'failed', name: params.name, errorCode: 'CANCELLED', message: '已取消' }, onProgress); + this.emitProgress( + { phase: 'failed', name: params.name, errorCode: 'CANCELLED', message: '已取消' }, + onProgress, + ); return { success: false, errorCode: 'CANCELLED' }; } const code = serverErrorToCode(err); - this.emitProgress({ - phase: 'failed', - name: params.name, - errorCode: code, - message: err instanceof Error ? err.message : String(err), - }, onProgress); + this.emitProgress( + { + phase: 'failed', + name: params.name, + errorCode: code, + message: err instanceof Error ? err.message : String(err), + }, + onProgress, + ); return { success: false, errorCode: code }; } } @@ -352,7 +418,10 @@ export class SkillPublishService { // ── 步骤 5: OSS PUT ────────────────────────────────────────────── this.emitProgress({ phase: 'uploading' }, onProgress); if (isCancelled()) { - this.emitProgress({ phase: 'failed', name: params.name, errorCode: 'CANCELLED', message: '已取消' }, onProgress); + this.emitProgress( + { phase: 'failed', name: params.name, errorCode: 'CANCELLED', message: '已取消' }, + onProgress, + ); return { success: false, errorCode: 'CANCELLED' }; } @@ -410,41 +479,55 @@ export class SkillPublishService { const elapsedMs = Date.now() - ossStartedAt; const errMsg = err instanceof Error ? err.message : String(err); const errStack = err instanceof Error ? err.stack : undefined; - ossFailDetail = - `OSS PUT network error\n` + - `url: ${urlForLog}\n` + - `error: ${errMsg}`; + ossFailDetail = `OSS PUT network error\n` + `url: ${urlForLog}\n` + `error: ${errMsg}`; log.error( `[publish:oss] PUT exception | elapsedMs=${elapsedMs} err=${errMsg}\nstack=${errStack ?? '(no stack)'}`, ); } if (isCancelled()) { - this.emitProgress({ phase: 'failed', name: params.name, errorCode: 'CANCELLED', message: '已取消' }, onProgress); + this.emitProgress( + { phase: 'failed', name: params.name, errorCode: 'CANCELLED', message: '已取消' }, + onProgress, + ); return { success: false, errorCode: 'CANCELLED' }; } if (ossExpired) { state.initCache = undefined; state.packCache = undefined; - this.emitProgress({ phase: 'failed', name: params.name, errorCode: 'OSS_PUT_EXPIRED', message: '上传链接已过期,请重新发布' }, onProgress); + this.emitProgress( + { + phase: 'failed', + name: params.name, + errorCode: 'OSS_PUT_EXPIRED', + message: '上传链接已过期,请重新发布', + }, + onProgress, + ); return { success: false, errorCode: 'OSS_PUT_EXPIRED' }; } if (!ossOk) { - this.emitProgress({ - phase: 'failed', - name: params.name, - errorCode: 'OSS_PUT_FAILED', - message: ossFailDetail || '上传失败,请重试', - }, onProgress); + this.emitProgress( + { + phase: 'failed', + name: params.name, + errorCode: 'OSS_PUT_FAILED', + message: ossFailDetail || '上传失败,请重试', + }, + onProgress, + ); return { success: false, errorCode: 'OSS_PUT_FAILED' }; } // ── 步骤 6: publish/commit ─────────────────────────────────────── this.emitProgress({ phase: 'commit' }, onProgress); if (isCancelled()) { - this.emitProgress({ phase: 'failed', name: params.name, errorCode: 'CANCELLED', message: '已取消' }, onProgress); + this.emitProgress( + { phase: 'failed', name: params.name, errorCode: 'CANCELLED', message: '已取消' }, + onProgress, + ); return { success: false, errorCode: 'CANCELLED' }; } @@ -461,9 +544,12 @@ export class SkillPublishService { if (params.isFirstPublish) { commitBody.categories = categories; commitBody.categoryMode = categoryMode; - commitBody.visibility = params.visibility === 'PUBLIC' - ? 'public' - : params.visibility === 'PRIVATE' ? 'private' : 'shared'; + commitBody.visibility = + params.visibility === 'PUBLIC' + ? 'public' + : params.visibility === 'PRIVATE' + ? 'private' + : 'shared'; if (params.deptTeamSlug) commitBody.deptTeamSlug = params.deptTeamSlug; if (params.teamSlug) commitBody.teamSlug = params.teamSlug; if (params.visibleSlugs !== undefined) commitBody.visibleSlugs = params.visibleSlugs; @@ -486,16 +572,29 @@ export class SkillPublishService { publishSucceeded = true; await writeSnapshot(params.absolutePath, params.name).catch((err) => - log.warn('[publish] writeSnapshot failed (non-fatal):', err)); - await syncPublishedRegistry(params.name, params.absolutePath, publishedVersion, folderHash) - .catch((err) => log.warn('[publish] registry sync failed (non-fatal):', err)); - - this.emitProgress({ phase: 'done', name: params.name, version: publishedVersion }, onProgress); + log.warn('[publish] writeSnapshot failed (non-fatal):', err), + ); + await syncPublishedRegistry( + params.name, + params.absolutePath, + publishedVersion, + folderHash, + ).catch((err) => log.warn('[publish] registry sync failed (non-fatal):', err)); + + this.emitProgress( + { phase: 'done', name: params.name, version: publishedVersion }, + onProgress, + ); + // 公开发布完成机审后会进入 pending,等待 Platform 人工审核。收到 pending 后 + // 结束本轮轮询;用户主动刷新列表或详情时再读取最新审核状态。 this.startScanPoll(params.name, publishedVersion); return { success: true, result: { name: params.name, version: publishedVersion } }; } catch (err) { if (isCancelled()) { - this.emitProgress({ phase: 'failed', name: params.name, errorCode: 'CANCELLED', message: '已取消' }, onProgress); + this.emitProgress( + { phase: 'failed', name: params.name, errorCode: 'CANCELLED', message: '已取消' }, + onProgress, + ); return { success: false, errorCode: 'CANCELLED' }; } if (err instanceof ServerApiError && err.code === 'VERSION_RACE') { @@ -506,31 +605,35 @@ export class SkillPublishService { } } const code = serverErrorToCode(err); - this.emitProgress({ - phase: 'failed', - name: params.name, - errorCode: code, - message: err instanceof Error ? err.message : String(err), - }, onProgress); + this.emitProgress( + { + phase: 'failed', + name: params.name, + errorCode: code, + message: err instanceof Error ? err.message : String(err), + }, + onProgress, + ); return { success: false, errorCode: code }; } } } catch (err) { const code = isCancelled() ? 'CANCELLED' : unhandledPublishErrorToCode(err); - const message = isCancelled() - ? '已取消' - : err instanceof Error ? err.message : String(err); + const message = isCancelled() ? '已取消' : err instanceof Error ? err.message : String(err); if (isCancelled()) { log.debug(`[publish] cancelled | name=${params.name}`); } else { log.error(`[publish] unexpected failure | name=${params.name} code=${code}:`, err); } - this.emitProgress({ - phase: 'failed', - name: params.name, - errorCode: code, - message, - }, onProgress); + this.emitProgress( + { + phase: 'failed', + name: params.name, + errorCode: code, + message, + }, + onProgress, + ); return { success: false, errorCode: code, error: message }; } finally { if (this.current === state) { @@ -573,7 +676,7 @@ export class SkillPublishService { gates: result.gates, }); - if (isTerminalScanStatus(result.status)) { + if (isTerminalScanStatus(result.status) || isPendingManualReviewStatus(result.status)) { if (!isCurrentPoll()) return; this.activeScanPoll = null; this.emitProgress({ diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 5c23c77bd70..430c8dba43a 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2943,6 +2943,8 @@ contextBridge.exposeInMainWorld('electronAPI', { success: boolean; items?: Array<{ name: string; + /** Skill 图标 URL;旧服务响应可能缺失。 */ + icon?: string; displayName: string; description: string; authorId: string; diff --git a/apps/desktop/src/renderer/features/skillhub/ScanResultDialog.tsx b/apps/desktop/src/renderer/features/skillhub/ScanResultDialog.tsx index 75597cd46a5..32ecc9410b3 100644 --- a/apps/desktop/src/renderer/features/skillhub/ScanResultDialog.tsx +++ b/apps/desktop/src/renderer/features/skillhub/ScanResultDialog.tsx @@ -12,7 +12,7 @@ import { ShieldAlert, ShieldCheck, AlertTriangle, Check, Copy } from 'lucide-rea import { cn } from '@/lib/utils'; import { toast } from '@/lib/toast'; import type { ScanResultPayload } from './PublishDialog'; -import { isPassingScanStatus } from './lib/scanStatus'; +import { isPassingScanStatus, isPendingManualReviewStatus } from './lib/scanStatus'; import { isPublicationProcessingFailure } from './lib/scanResultPresentation'; interface ScanIssue { @@ -46,13 +46,16 @@ function resolveI18nField(value: unknown): string { function visibleScanIssues(gate: ScanGate): ScanIssue[] { const issues = Array.isArray(gate.issues) ? gate.issues : []; - return (issues as ScanIssue[]).filter((issue) => ( - issue.severity === 'warning' || issue.severity === 'error' - )); + return (issues as ScanIssue[]).filter( + (issue) => issue.severity === 'warning' || issue.severity === 'error', + ); } function normalizeScanCode(value: unknown): string { - return String(value ?? '').trim().toLowerCase().replace(/[\s_]+/g, '-'); + return String(value ?? '') + .trim() + .toLowerCase() + .replace(/[\s_]+/g, '-'); } function scanStatusLabel(value: unknown, t: TFunction): string { @@ -60,13 +63,28 @@ function scanStatusLabel(value: unknown, t: TFunction): string { if (code === 'pass' || code === 'passed' || code === 'success' || code === 'ok') { return t('skillhub.scanResult.statusLabel.passed'); } - if (code === 'fail' || code === 'failed' || code === 'rejected' || code === 'blocked' || code === 'quarantine' || code === 'error') { + if ( + code === 'fail' || + code === 'failed' || + code === 'rejected' || + code === 'blocked' || + code === 'quarantine' || + code === 'error' + ) { return t('skillhub.scanResult.statusLabel.failed'); } if (code === 'warn' || code === 'warning') { return t('skillhub.scanResult.statusLabel.warning'); } - if (code === 'pending' || code === 'scanning' || code === 'reviewing' || code === 'running' || code === 'in-progress') { + if (code === 'pending') { + return t('skillhub.scanResult.statusLabel.waitingReview'); + } + if ( + code === 'scanning' || + code === 'reviewing' || + code === 'running' || + code === 'in-progress' + ) { return t('skillhub.scanResult.statusLabel.reviewing'); } if (code === 'unavailable' || code === 'scan-status-unavailable') { @@ -127,19 +145,25 @@ export function ScanResultDialog({ open, onClose, result }: ScanResultDialogProp if (!result) return null; const passed = isPassingScanStatus(result.status); - const failedGates = (result.gates ?? []).filter((g) => g.status !== 'pass'); - const processingFailure = !passed && isPublicationProcessingFailure(result.gates); + const pendingManualReview = isPendingManualReviewStatus(result.status); + const failedGates = (result.gates ?? []).filter((g) => !isPassingScanStatus(g.status)); + const processingFailure = + !passed && !pendingManualReview && isPublicationProcessingFailure(result.gates); const title = passed ? t('skillhub.scanResult.passedTitle') - : processingFailure - ? t('skillhub.scanResult.processingFailedTitle') - : t('skillhub.scanResult.failedTitle', { status: result.status }); + : pendingManualReview + ? t('skillhub.scanResult.pendingTitle') + : processingFailure + ? t('skillhub.scanResult.processingFailedTitle') + : t('skillhub.scanResult.failedTitle', { status: result.status }); const statusLabel = scanStatusLabel(result.status, t); const description = passed ? t('skillhub.scanResult.passedDesc') - : processingFailure - ? t('skillhub.scanResult.processingFailedDesc') - : t('skillhub.scanResult.failedDesc', { status: statusLabel }); + : pendingManualReview + ? t('skillhub.scanResult.pendingDesc') + : processingFailure + ? t('skillhub.scanResult.processingFailedDesc') + : t('skillhub.scanResult.failedDesc', { status: statusLabel }); const footerButtonBaseClass = cn( 'inline-flex h-9 min-w-[104px] items-center justify-center gap-1.5 rounded-full px-5', 'text-sm font-medium leading-none', @@ -147,7 +171,7 @@ export function ScanResultDialog({ open, onClose, result }: ScanResultDialogProp ); async function handleCopyReviewResult(): Promise { - const gatesToCopy = passed ? (result?.gates ?? []) : failedGates; + const gatesToCopy = passed || pendingManualReview ? (result?.gates ?? []) : failedGates; const lines = [ title, `${t('skillhub.scanResult.copyText.status')}: ${withRawCode(statusLabel, result?.status)}`, @@ -158,7 +182,9 @@ export function ScanResultDialog({ open, onClose, result }: ScanResultDialogProp lines.push('', `${t('skillhub.scanResult.copyText.gates')}:`); for (const gate of gatesToCopy) { const label = scanGateLabel(gate, t); - lines.push(`- ${withRawCode(label, gate.name)}: ${withRawCode(scanStatusLabel(gate.status, t), gate.status)}`); + lines.push( + `- ${withRawCode(label, gate.name)}: ${withRawCode(scanStatusLabel(gate.status, t), gate.status)}`, + ); for (const issue of visibleScanIssues(gate)) { const line = scanIssueCopyLine(issue); if (line) lines.push(` - ${line}`); @@ -180,7 +206,12 @@ export function ScanResultDialog({ open, onClose, result }: ScanResultDialogProp } return ( - { if (!v) onClose(); }}> + { + if (!v) onClose(); + }} + > + ) : pendingManualReview ? ( +
+ +
) : processingFailure ? (
@@ -232,7 +267,10 @@ export function ScanResultDialog({ open, onClose, result }: ScanResultDialogProp className="rounded-lg border border-[var(--error-border)] bg-[var(--error-bg)] p-3" >
- + {scanGateLabel(gate, t)} @@ -248,10 +286,14 @@ export function ScanResultDialog({ open, onClose, result }: ScanResultDialogProp {visibleScanIssues(gate).length > 0 && (
{visibleScanIssues(gate).map((issue, i) => ( -
+
{issue.path && ( - {issue.path}{issue.line != null ? `:${issue.line}` : ''} + {issue.path} + {issue.line != null ? `:${issue.line}` : ''} )} {issue.path && issue.message && } @@ -267,13 +309,12 @@ export function ScanResultDialog({ open, onClose, result }: ScanResultDialogProp ))}
)} -
)} {/* Footer */}
- {!passed && ( + {!passed && !pendingManualReview && ( )} ) : ( - - {t('skillhub.detail.reviewing')} + {publishedStatus === 'pending' ? : } + + {publishedStatus + ? t(publishedStatusLabelKey(publishedStatus)) + : t('skillhub.detail.reviewing')} + ) : (
) : null} + {publicReview ? ( +
+ {publicReview.status === 'pending' + ? t('skillhub.visibilityEditor.publicReviewPending') + : t('skillhub.visibilityEditor.publicReviewRejected', { reason: publicReview.reason || '—' })} +
+ ) : null} {/* 可见范围三卡(与发布弹窗共用 VisibilityCard) */}
@@ -238,7 +249,8 @@ export function VisibilityEditorDialog({
diff --git a/apps/desktop/src/renderer/features/skillhub/hooks/__tests__/useSkillhubIdentityPolicy.test.ts b/apps/desktop/src/renderer/features/skillhub/hooks/__tests__/useSkillhubIdentityPolicy.test.ts index 6736a0ea6ce..9fb17972937 100644 --- a/apps/desktop/src/renderer/features/skillhub/hooks/__tests__/useSkillhubIdentityPolicy.test.ts +++ b/apps/desktop/src/renderer/features/skillhub/hooks/__tests__/useSkillhubIdentityPolicy.test.ts @@ -1,64 +1,25 @@ // @vitest-environment jsdom -import { renderHook, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; import { useSkillhubIdentityPolicy } from '../useSkillhubIdentityPolicy'; describe('useSkillhubIdentityPolicy', () => { - const capabilities = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - (window as unknown as { electronAPI: unknown }).electronAPI = { - skillhub: { capabilities }, - }; - }); - - it('keeps organization writes disabled until the server capability arrives', async () => { - let resolveCapabilities!: (value: unknown) => void; - capabilities.mockReturnValue(new Promise((resolve) => { - resolveCapabilities = resolve; - })); - + it('keeps the organization publish entry available without probing server capabilities', () => { const { result } = renderHook(() => useSkillhubIdentityPolicy({ membershipKind: 'org', orgSlug: 'example-org', })); - expect(result.current.canWrite).toBe(false); - resolveCapabilities({ - success: true, - capabilities: { - canWrite: true, - ownerType: 'organization', - allowedVisibilities: ['shared', 'public'], - readOnlyReason: null, - }, - }); - await waitFor(() => expect(result.current.canWrite).toBe(true)); - expect(result.current.allowedVisibilities).toEqual(['DEPARTMENT_SCOPED', 'PUBLIC']); + expect(result.current.canWrite).toBe(true); + expect(result.current.allowedVisibilities).toEqual(['PUBLIC', 'DEPARTMENT_SCOPED']); }); - it('uses the generic read-only capability without inspecting organization names', async () => { - capabilities.mockResolvedValue({ - success: true, - capabilities: { - canWrite: false, - ownerType: 'organization', - allowedVisibilities: [], - readOnlyReason: 'organization-catalog-read-only', - }, - }); - - const { result } = renderHook(() => useSkillhubIdentityPolicy({ - membershipKind: 'org', - orgSlug: 'any-organization', - })); + it('does not special-case organization slugs in the renderer', () => { + const first = renderHook(() => useSkillhubIdentityPolicy({ membershipKind: 'org', orgSlug: 'xd' })); + const second = renderHook(() => useSkillhubIdentityPolicy({ membershipKind: 'org', orgSlug: 'other' })); - await waitFor(() => { - expect(result.current.readOnlyReason).toBe('organization-catalog-read-only'); - }); - expect(result.current.canWrite).toBe(false); + expect(first.result.current).toEqual(second.result.current); }); }); diff --git a/apps/desktop/src/renderer/features/skillhub/hooks/useMarketList.ts b/apps/desktop/src/renderer/features/skillhub/hooks/useMarketList.ts index 273a58f067b..7bdc3b8aafb 100644 --- a/apps/desktop/src/renderer/features/skillhub/hooks/useMarketList.ts +++ b/apps/desktop/src/renderer/features/skillhub/hooks/useMarketList.ts @@ -75,6 +75,11 @@ export interface MarketSkill { version: string; status?: string; }; + visibilityReview?: { + requestedVisibility: 'public'; + status: 'pending' | 'rejected'; + reason?: string; + }; visibleDeptIds: string[]; /** 分类 slug 列表。服务端目前还未返回时给空数组兜底。 */ categories: string[]; @@ -121,6 +126,11 @@ interface ServerListItem { version: string; status?: string; }; + visibilityReview?: { + requestedVisibility: 'public'; + status: 'pending' | 'rejected'; + reason?: string; + }; visibleDeptIds: string[]; categories?: string[]; tags?: Array<{ slug: string; name: string }>; @@ -232,6 +242,7 @@ function mapServerToView( ownerType: item.ownerType, moderationStatus: item.moderationStatus, pendingVersion: item.pendingVersion, + visibilityReview: item.visibilityReview, visibleDeptIds: item.visibleDeptIds, categories: item.categories ?? [], tags: item.tags ?? [], diff --git a/apps/desktop/src/renderer/features/skillhub/hooks/useMarketManagement.tsx b/apps/desktop/src/renderer/features/skillhub/hooks/useMarketManagement.tsx index 533bda7bd39..f4d19c8e0f4 100644 --- a/apps/desktop/src/renderer/features/skillhub/hooks/useMarketManagement.tsx +++ b/apps/desktop/src/renderer/features/skillhub/hooks/useMarketManagement.tsx @@ -151,6 +151,7 @@ export function MarketManagementDialogs({ skillName={controller.visibilityTarget.name} currentTier={visibilityTier(controller.visibilityTarget)} currentOwnerType={controller.visibilityTarget.ownerType} + publicReview={controller.visibilityTarget.visibilityReview} readOnly={controller.isReadOnly(controller.visibilityTarget)} onSaved={controller.visibilitySaved} /> diff --git a/apps/desktop/src/renderer/features/skillhub/hooks/useSkillhubIdentityPolicy.ts b/apps/desktop/src/renderer/features/skillhub/hooks/useSkillhubIdentityPolicy.ts index ce4b30bd134..dbeedc88010 100644 --- a/apps/desktop/src/renderer/features/skillhub/hooks/useSkillhubIdentityPolicy.ts +++ b/apps/desktop/src/renderer/features/skillhub/hooks/useSkillhubIdentityPolicy.ts @@ -1,55 +1,16 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; -import { - deriveSkillhubIdentityPolicy, - skillhubIdentityPolicyFromServer, - type SkillhubIdentity, - type SkillhubIdentityPolicy, -} from '../../../../shared/skillhubIdentityPolicy'; +import { deriveSkillhubIdentityPolicy, type SkillhubIdentity, type SkillhubIdentityPolicy } from '../../../../shared/skillhubIdentityPolicy'; /** - * The server owns catalog routing and write policy. The renderer only consumes - * the generic capability result and never infers a backing Skill Hub source. + * This only projects signed-in identity into generic UI choices. The server + * remains authoritative for ownership, routing, and organization write policy. */ export function useSkillhubIdentityPolicy( identity: SkillhubIdentity | null | undefined, ): SkillhubIdentityPolicy { - const localPolicy = useMemo( + return useMemo( () => deriveSkillhubIdentityPolicy(identity), [identity?.membershipKind, identity?.orgSlug], ); - const pendingPolicy = useMemo( - () => identity?.membershipKind === 'org' - ? { ...localPolicy, canWrite: false, allowedVisibilities: [] } - : localPolicy, - [identity?.membershipKind, localPolicy], - ); - const [policy, setPolicy] = useState(pendingPolicy); - - useEffect(() => { - let cancelled = false; - setPolicy(pendingPolicy); - - const loadCapabilities = window.electronAPI?.skillhub?.capabilities; - if (typeof loadCapabilities !== 'function') return undefined; - - void loadCapabilities().then((result) => { - if (cancelled) return; - if (result.success && result.capabilities) { - setPolicy(skillhubIdentityPolicyFromServer(result.capabilities)); - } else { - setPolicy(pendingPolicy); - } - }).catch(() => { - if (!cancelled) { - setPolicy(pendingPolicy); - } - }); - - return () => { - cancelled = true; - }; - }, [pendingPolicy]); - - return policy; } diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 24309a21991..8f18e615992 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -10501,13 +10501,18 @@ "save": "Save", "saving": "Saving…", "saved": "Visibility settings saved", + "submitReview": "Submit for review", + "waitingReview": "Waiting for review", + "publicReviewSubmitted": "The current version was submitted for public review", + "publicReviewPending": "The current version is awaiting public review and will enter the public market after approval.", + "publicReviewRejected": "Public review was rejected: {{reason}}", "impactPrivate": "After saving, only you can see this Skill.", "impactPrivateLeavingMarket": "Saving will delist it from the public market and make it visible only to you; versions that passed review keep their approved status.", "impactPrivateTeam": "After saving, only you can see it; the team will no longer see or manage this Skill.", "impactPrivateTeamLeavingMarket": "Saving will delist it from the public market and make it visible only to you; the team will no longer see or manage this Skill. Versions that passed review keep their approved status.", "impactTeam": "After saving, the selected teams and departments can see and download it.", "impactTeamLeavingMarket": "Saving will delist it from the public market; only the selected teams and departments can see and download it. Versions that passed review keep their approved status.", - "impactPublic": "After saving it enters the public market; if already approved, it becomes publicly visible immediately and keeps its review status." + "impactPublic": "Submit the current version for public review. It enters the public market after approval, with no new version required." }, "marketEdit": { "saved": "Market info saved", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index a320ded0fd7..5c14e3d9929 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -10479,13 +10479,18 @@ "save": "保存", "saving": "保存中…", "saved": "公開設定を保存しました", + "submitReview": "審査に提出", + "waitingReview": "審査待ち", + "publicReviewSubmitted": "現在のバージョンを公開審査に提出しました", + "publicReviewPending": "現在のバージョンは公開審査待ちです。承認後に公開マーケットへ掲載されます。", + "publicReviewRejected": "公開審査で却下されました:{{reason}}", "impactPrivate": "保存後はこの Skill を閲覧できるのは自分のみになります。", "impactPrivateLeavingMarket": "保存すると公開マーケットから取り下げられ、自分のみ閲覧可になります。審査済みバージョンの承認状態は保持されます。", "impactPrivateTeam": "保存後は自分のみ閲覧可になり、チームはこの Skill を閲覧・管理できなくなります。", "impactPrivateTeamLeavingMarket": "保存すると公開マーケットから取り下げられ、自分のみ閲覧可になります。チームはこの Skill を閲覧・管理できなくなります。審査済みバージョンの承認状態は保持されます。", "impactTeam": "保存後は選択したチーム・部署が閲覧・ダウンロードできます。", "impactTeamLeavingMarket": "保存すると公開マーケットから取り下げられ、選択したチーム・部署のみが閲覧・ダウンロードできます。審査済みバージョンの承認状態は保持されます。", - "impactPublic": "保存すると公開マーケットに公開されます。すでに審査済みの場合は即座に公開され、審査状態は保持されます。" + "impactPublic": "現在のバージョンを公開審査に提出します。承認後に公開マーケットへ掲載され、新しいバージョンの公開は不要です。" }, "marketEdit": { "saved": "マーケット表示情報を保存しました", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 71184434ef5..33dcb3f6337 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -10479,13 +10479,18 @@ "save": "저장", "saving": "저장 중…", "saved": "공개 설정이 저장되었습니다", + "submitReview": "심사 제출", + "waitingReview": "심사 대기 중", + "publicReviewSubmitted": "현재 버전을 공개 심사에 제출했습니다", + "publicReviewPending": "현재 버전이 공개 심사를 기다리고 있으며 승인 후 공개 마켓에 표시됩니다.", + "publicReviewRejected": "공개 심사가 거부되었습니다: {{reason}}", "impactPrivate": "저장 후에는 본인만 이 Skill을 볼 수 있습니다.", "impactPrivateLeavingMarket": "저장하면 공개 마켓에서 내려가고 본인만 볼 수 있게 됩니다. 심사를 통과한 버전의 승인 상태는 유지됩니다.", "impactPrivateTeam": "저장 후 본인만 볼 수 있게 되며, 팀은 이 Skill을 보거나 관리할 수 없습니다.", "impactPrivateTeamLeavingMarket": "저장하면 공개 마켓에서 내려가고 본인만 볼 수 있게 됩니다. 팀은 이 Skill을 보거나 관리할 수 없습니다. 심사를 통과한 버전의 승인 상태는 유지됩니다.", "impactTeam": "저장 후 선택한 팀과 부서가 보고 다운로드할 수 있습니다.", "impactTeamLeavingMarket": "저장하면 공개 마켓에서 내려가고 선택한 팀과 부서만 보고 다운로드할 수 있습니다. 심사를 통과한 버전의 승인 상태는 유지됩니다.", - "impactPublic": "저장하면 공개 마켓에 공개됩니다. 이미 심사를 통과한 경우 즉시 공개되며 심사 상태는 유지됩니다." + "impactPublic": "현재 버전을 공개 심사에 제출합니다. 승인 후 공개 마켓에 표시되며 새 버전을 게시할 필요가 없습니다." }, "marketEdit": { "saved": "마켓 표시 정보가 저장되었습니다", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index e28607276db..74cf75fb123 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -10471,13 +10471,18 @@ "save": "保存", "saving": "保存中…", "saved": "管理可见性已保存", + "submitReview": "提交审核", + "waitingReview": "等待审核", + "publicReviewSubmitted": "已提交当前版本的公开审核", + "publicReviewPending": "当前版本正在等待公开审核,审核通过后会进入公开市场。", + "publicReviewRejected": "公开审核未通过:{{reason}}", "impactPrivate": "保存后,仅你可见。", "impactPrivateLeavingMarket": "保存后会从公开市场下架,并转为自己可见;已通过审核的版本会保留审核通过状态。", "impactPrivateTeam": "保存后转为自己可见;团队将看不到这个 Skill,也无法管理。", "impactPrivateTeamLeavingMarket": "保存后会从公开市场下架,并转为自己可见;团队将看不到这个 Skill,也无法管理。已通过审核的版本会保留审核通过状态。", "impactTeam": "保存后,选中的团队或部门可以看到并下载。", "impactTeamLeavingMarket": "保存后会从公开市场下架,只有选中的团队或部门可以看到并下载;已通过审核的版本会保留审核通过状态。", - "impactPublic": "保存后进入公开市场;若当前已审核通过,会立即对外可见,审核状态会保留。" + "impactPublic": "提交当前版本的公开审核;审核通过后进入公开市场,无需发布新版本。" }, "marketEdit": { "saved": "市场展示信息已保存", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json index 8ca792b28e5..055931db7f8 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -10471,13 +10471,18 @@ "save": "儲存", "saving": "儲存中…", "saved": "管理可見性已儲存", + "submitReview": "提交稽核", + "waitingReview": "等待稽核", + "publicReviewSubmitted": "已提交目前版本的公開稽核", + "publicReviewPending": "目前版本正在等待公開稽核,通過後會進入公開市場。", + "publicReviewRejected": "公開稽核未通過:{{reason}}", "impactPrivate": "儲存後,僅你可見。", "impactPrivateLeavingMarket": "儲存後會從公開市場下架,並轉為自己可見;已通過稽核的版本會保留稽核通過狀態。", "impactPrivateTeam": "儲存後轉為自己可見;團隊將看不到這個 Skill,也無法管理。", "impactPrivateTeamLeavingMarket": "儲存後會從公開市場下架,並轉為自己可見;團隊將看不到這個 Skill,也無法管理。已通過稽核的版本會保留稽核通過狀態。", "impactTeam": "儲存後,選中的團隊或部門可以看到並下載。", "impactTeamLeavingMarket": "儲存後會從公開市場下架,只有選中的團隊或部門可以看到並下載;已通過稽核的版本會保留稽核通過狀態。", - "impactPublic": "儲存後進入公開市場;若當前已稽核通過,會立即對外可見,稽核狀態會保留。" + "impactPublic": "提交目前版本的公開稽核;稽核通過後進入公開市場,無需發布新版本。" }, "marketEdit": { "saved": "市場展示資訊已儲存", diff --git a/apps/desktop/src/renderer/vite-env.d.ts b/apps/desktop/src/renderer/vite-env.d.ts index 8b981b26574..8fdf9a508ba 100644 --- a/apps/desktop/src/renderer/vite-env.d.ts +++ b/apps/desktop/src/renderer/vite-env.d.ts @@ -3180,6 +3180,11 @@ interface ElectronAPI { version: string; status?: string; }; + visibilityReview?: { + requestedVisibility: 'public'; + status: 'pending' | 'rejected'; + reason?: string; + }; visibleDeptIds: string[]; categories?: string[]; tags?: Array<{ slug: string; name: string }>; @@ -3192,12 +3197,6 @@ interface ElectronAPI { }>; nextCursor?: string | null; }>; - capabilities: () => Promise<{ - success: boolean; - capabilities?: import('../shared/skillhubIdentityPolicy').SkillhubServerCapabilities; - error?: string; - errorCode?: string; - }>; info: (name: string, catalogScope?: import('../shared/skillhubCatalog').SkillhubCatalogScope) => Promise<{ success: boolean; error?: string; @@ -3248,7 +3247,12 @@ interface ElectronAPI { visibility: 'private' | 'shared' | 'public'; teamSlug?: string; visibleSlugs?: string[]; - }) => Promise<{ success: boolean; result?: unknown; error?: string; errorCode?: string }>; + }) => Promise<{ + success: boolean; + result?: { slug: string; visibility: 'private' | 'shared' | 'public'; requestedVisibility?: 'public'; reviewStatus?: 'pending' }; + error?: string; + errorCode?: string; + }>; getPublishedVisibility: (name: string) => Promise<{ success: boolean; sharedTeams?: Array<{ id: number; slug: string; name: string }>; @@ -6594,6 +6598,11 @@ interface SkillhubInfoResult { version: string; status?: string; }; + visibilityReview?: { + requestedVisibility: 'public'; + status: 'pending' | 'rejected'; + reason?: string; + }; visibleDeptIds: string[]; visibleDeptNames?: string[]; categories?: string[]; diff --git a/apps/desktop/src/shared/__tests__/skillhubIdentityPolicy.test.ts b/apps/desktop/src/shared/__tests__/skillhubIdentityPolicy.test.ts index 5863a120044..9bdc52751ab 100644 --- a/apps/desktop/src/shared/__tests__/skillhubIdentityPolicy.test.ts +++ b/apps/desktop/src/shared/__tests__/skillhubIdentityPolicy.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - deriveSkillhubIdentityPolicy, - skillhubIdentityPolicyFromServer, -} from '../skillhubIdentityPolicy'; +import { deriveSkillhubIdentityPolicy } from '../skillhubIdentityPolicy'; describe('skillhub identity policy', () => { it('keeps personal publishing personal and excludes shared visibility', () => { @@ -28,18 +25,4 @@ describe('skillhub identity policy', () => { expect(deriveSkillhubIdentityPolicy({ membershipKind: 'org', orgSlug: 'example-org' })) .toEqual(deriveSkillhubIdentityPolicy({ membershipKind: 'org', orgSlug: 'another-org' })); }); - - it('uses the generic server capability response as the write-policy authority', () => { - expect(skillhubIdentityPolicyFromServer({ - canWrite: false, - ownerType: 'organization', - allowedVisibilities: [], - readOnlyReason: 'organization-catalog-read-only', - })).toEqual({ - canWrite: false, - ownerType: 'organization', - allowedVisibilities: [], - readOnlyReason: 'organization-catalog-read-only', - }); - }); }); diff --git a/apps/desktop/src/shared/skillhubIdentityPolicy.ts b/apps/desktop/src/shared/skillhubIdentityPolicy.ts index d99c56364f9..1fe7ab2e4a9 100644 --- a/apps/desktop/src/shared/skillhubIdentityPolicy.ts +++ b/apps/desktop/src/shared/skillhubIdentityPolicy.ts @@ -9,17 +9,10 @@ export interface SkillhubIdentityPolicy { canWrite: boolean; ownerType: 'personal' | 'organization' | null; allowedVisibilities: readonly SkillhubPublishVisibility[]; - readOnlyReason: 'organization-catalog-read-only' | 'organization-routing-unavailable' | 'signed-out' | null; + readOnlyReason: 'signed-out' | null; } -export interface SkillhubServerCapabilities { - canWrite: boolean; - ownerType: 'personal' | 'organization' | null; - allowedVisibilities: Array<'private' | 'shared' | 'public'>; - readOnlyReason: SkillhubIdentityPolicy['readOnlyReason']; -} - -/** Local projection used until the server capability response arrives. */ +/** UI projection only; authorization and organization-specific policy remain server-owned. */ export function deriveSkillhubIdentityPolicy( identity: SkillhubIdentity | null | undefined, ): SkillhubIdentityPolicy { @@ -46,17 +39,3 @@ export function deriveSkillhubIdentityPolicy( readOnlyReason: null, }; } - -/** Converts the server-owned policy into the existing UI visibility vocabulary. */ -export function skillhubIdentityPolicyFromServer( - capabilities: SkillhubServerCapabilities, -): SkillhubIdentityPolicy { - return { - canWrite: capabilities.canWrite, - ownerType: capabilities.ownerType, - allowedVisibilities: capabilities.allowedVisibilities.map((visibility) => ( - visibility === 'shared' ? 'DEPARTMENT_SCOPED' : visibility.toUpperCase() - )) as SkillhubPublishVisibility[], - readOnlyReason: capabilities.readOnlyReason, - }; -} diff --git a/docs/design-rules/design-inventory.md b/docs/design-rules/design-inventory.md index eb2f1cd9b50..959f5494916 100644 --- a/docs/design-rules/design-inventory.md +++ b/docs/design-rules/design-inventory.md @@ -9,7 +9,7 @@ 本区块由 `scripts/design-inventory.mjs` 生成,请勿手改。 重新生成:`pnpm design:inventory`;校验:`pnpm check:design-inventory`。 -计数快照日期:2026-09-01。生成命令:`pnpm design:inventory`。裸颜色匹配与 `scripts/hardcoded-color-audit.mjs` 共用 `scripts/shared/hardcoded-color-match.mjs`(HEX / rgb() / rgba() / hsl() / hsla()),台账统计层额外剔除 `var()` 包装与注释(TS/TSX 剥块注释与整行注释)——语义 token 消费与注释引用不是迁移债务;裸圆角为粗粒度(`rounded*` class、`border-radius:` 与 React style 对象的 `borderRadius:`)。Token 计数为样式源里 `var(--token)` / `hsl(var(--token)` 的去重 ID 数。 +计数快照日期:2026-09-02。生成命令:`pnpm design:inventory`。裸颜色匹配与 `scripts/hardcoded-color-audit.mjs` 共用 `scripts/shared/hardcoded-color-match.mjs`(HEX / rgb() / rgba() / hsl() / hsla()),台账统计层额外剔除 `var()` 包装与注释(TS/TSX 剥块注释与整行注释)——语义 token 消费与注释引用不是迁移债务;裸圆角为粗粒度(`rounded*` class、`border-radius:` 与 React style 对象的 `borderRadius:`)。Token 计数为样式源里 `var(--token)` / `hsl(var(--token)` 的去重 ID 数。 登记 surface 数:34。平台本轮仅 Desktop。 @@ -40,7 +40,7 @@ | `desktop.settings` | desktop | 设置 | hash `/settings`(SettingsView;tab 含 general / personalization / providers / billing / usage / voice-input / im-bot / shortcuts / agent-island / import / remote-control / ghosts / builtin-tools / computer-use / help / about) | SettingsView | apps/desktop/src/renderer/components/settings/AboutSection.tsx, apps/desktop/src/renderer/components/settings/AccountDeletionSection.tsx, apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx, apps/desktop/src/renderer/components/settings/AgentIslandSection.tsx, apps/desktop/src/renderer/components/settings/AgentResourceSection.tsx, apps/desktop/src/renderer/components/settings/AppearanceSection.tsx, apps/desktop/src/renderer/components/settings/AuxiliaryModelSection.tsx, apps/desktop/src/renderer/components/settings/BetaChannelCell.tsx, apps/desktop/src/renderer/components/settings/BrowserBackendSubsection.tsx, apps/desktop/src/renderer/components/settings/BrowserRealProfileSubsection.tsx, apps/desktop/src/renderer/components/settings/BuiltinToolsSection.tsx, apps/desktop/src/renderer/components/settings/ChatEmbeddingCell.tsx, apps/desktop/src/renderer/components/settings/ChipMetricsSection.tsx, apps/desktop/src/renderer/components/settings/CollaborationSection.tsx, apps/desktop/src/renderer/components/settings/CompactionSection.tsx, apps/desktop/src/renderer/components/settings/ComposerSendShortcutSection.tsx, apps/desktop/src/renderer/components/settings/ComputerPermissionGuideWindow.tsx, apps/desktop/src/renderer/components/settings/ComputerUseSection.tsx, apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx, apps/desktop/src/renderer/components/settings/CustomProviderRuntimeFillOverlay.tsx, apps/desktop/src/renderer/components/settings/DefaultOverrideControls.tsx, apps/desktop/src/renderer/components/settings/DingTalkBotSection.tsx, apps/desktop/src/renderer/components/settings/DiscordBotSection.tsx, apps/desktop/src/renderer/components/settings/DownloadMeter.tsx, apps/desktop/src/renderer/components/settings/ExperimentalSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotNotificationSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotSection.tsx, apps/desktop/src/renderer/components/settings/FontFamilyPicker.tsx, apps/desktop/src/renderer/components/settings/GitSafetySection.tsx, apps/desktop/src/renderer/components/settings/HelpAssistantPanel.tsx, apps/desktop/src/renderer/components/settings/HelpSection.tsx, apps/desktop/src/renderer/components/settings/HelpThreadView.tsx, apps/desktop/src/renderer/components/settings/HookConnectionsSection.tsx, apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx, apps/desktop/src/renderer/components/settings/ImBotSection.tsx, apps/desktop/src/renderer/components/settings/ImChannelSettingsCard.tsx, apps/desktop/src/renderer/components/settings/ImDefaultSettingsSection.tsx, apps/desktop/src/renderer/components/settings/ImLifecycleAnnouncementSection.tsx, apps/desktop/src/renderer/components/settings/InputDeviceConnectionStatus.tsx, apps/desktop/src/renderer/components/settings/KeyboardShortcutsSection.tsx, apps/desktop/src/renderer/components/settings/LanguageSection.tsx, apps/desktop/src/renderer/components/settings/LayoutResetControl.tsx, apps/desktop/src/renderer/components/settings/LinkOpenSection.tsx, apps/desktop/src/renderer/components/settings/LocalOllamaInstall.tsx, apps/desktop/src/renderer/components/settings/LocalPackagingTag.tsx, apps/desktop/src/renderer/components/settings/LogoutSection.tsx, apps/desktop/src/renderer/components/settings/LspBetaCell.tsx, apps/desktop/src/renderer/components/settings/McpServerDialog.tsx, apps/desktop/src/renderer/components/settings/McpServersSection.tsx, apps/desktop/src/renderer/components/settings/MemorySection.tsx, apps/desktop/src/renderer/components/settings/MessageNavRailCell.tsx, apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx, apps/desktop/src/renderer/components/settings/MyDevicesPanel.tsx, apps/desktop/src/renderer/components/settings/NotificationSection.tsx, apps/desktop/src/renderer/components/settings/OAuthDeviceCodeCard.tsx, apps/desktop/src/renderer/components/settings/OllamaProviderDetail.tsx, apps/desktop/src/renderer/components/settings/PiPackagesSection.tsx, apps/desktop/src/renderer/components/settings/PlayStationGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/ProfileEditDialog.tsx, apps/desktop/src/renderer/components/settings/ProvidersSection.tsx, apps/desktop/src/renderer/components/settings/RemoteControlSection.tsx, apps/desktop/src/renderer/components/settings/RemoteHostDetail.tsx, apps/desktop/src/renderer/components/settings/RemoteSection.tsx, apps/desktop/src/renderer/components/settings/SessionImportSection.tsx, apps/desktop/src/renderer/components/settings/SessionRuntimeFallbackCell.tsx, apps/desktop/src/renderer/components/settings/SessionShareImportWizard.tsx, apps/desktop/src/renderer/components/settings/SettingsCatalogPanel.tsx, apps/desktop/src/renderer/components/settings/SettingsSidebarNav.tsx, apps/desktop/src/renderer/components/settings/SettingsTextInput.tsx, apps/desktop/src/renderer/components/settings/SettingsView.tsx, apps/desktop/src/renderer/components/settings/SilentEncryptedRetryCell.tsx, apps/desktop/src/renderer/components/settings/SshKeySetupDialog.tsx, apps/desktop/src/renderer/components/settings/StorageManagementCard.tsx, apps/desktop/src/renderer/components/settings/StreamFadeSection.tsx, apps/desktop/src/renderer/components/settings/SubagentModelSection.tsx, apps/desktop/src/renderer/components/settings/TelegramBehaviorSettings.tsx, apps/desktop/src/renderer/components/settings/TelegramBotSection.tsx, apps/desktop/src/renderer/components/settings/TelegramRemoteDevices.tsx, apps/desktop/src/renderer/components/settings/TerminalShellSection.tsx, apps/desktop/src/renderer/components/settings/TipsSection.tsx, apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx, apps/desktop/src/renderer/components/settings/UserProfileCard.tsx, apps/desktop/src/renderer/components/settings/UserPromptSection.tsx, apps/desktop/src/renderer/components/settings/VisionBridgeSection.tsx, apps/desktop/src/renderer/components/settings/VoiceInputSection.tsx, apps/desktop/src/renderer/components/settings/WechatBotSection.tsx, apps/desktop/src/renderer/components/settings/WecomBotSection.tsx, apps/desktop/src/renderer/components/settings/WindowBehaviorSection.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeyboardLayout.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeycapGlyphs.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexSettings.tsx, apps/desktop/src/renderer/components/settings/XUsageGuide.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadSettings.tsx, apps/desktop/src/renderer/components/settings/androidStatusPresentation.ts, apps/desktop/src/renderer/components/settings/billingVisibility.ts, apps/desktop/src/renderer/components/settings/computerPermissionFlow.ts, apps/desktop/src/renderer/components/settings/contacts/ContactDetailPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsImportDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsListPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsManagerDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsSection.tsx, apps/desktop/src/renderer/components/settings/contacts/startContactsAiSession.ts, apps/desktop/src/renderer/components/settings/dualSenseSilhouette.ts, apps/desktop/src/renderer/components/settings/feishuBotPresentation.ts, apps/desktop/src/renderer/components/settings/fontFamilyValue.ts, apps/desktop/src/renderer/components/settings/hookWorkspacePrefsLogic.ts, apps/desktop/src/renderer/components/settings/imBotVisibility.ts, apps/desktop/src/renderer/components/settings/imDefaultSettingsLogic.ts, apps/desktop/src/renderer/components/settings/myDevicesModel.ts, apps/desktop/src/renderer/components/settings/providerAssetModule.ts, apps/desktop/src/renderer/components/settings/realProfilePermissionGuide.ts, apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx, apps/desktop/src/renderer/components/settings/usage/UsageHistorySection.tsx, apps/desktop/src/renderer/components/settings/usage/UsageStatRow.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTaskTable.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTokenBars.tsx, apps/desktop/src/renderer/components/settings/usage/formatUsagePercent.ts, apps/desktop/src/renderer/components/settings/usage/usageHistoryStats.ts, apps/desktop/src/renderer/components/settings/usageVisibility.ts, apps/desktop/src/renderer/components/settings/wizardRecommend.ts, apps/desktop/src/renderer/components/settings/workLouderCodexCommandCopy.ts, apps/desktop/src/renderer/components/settings/xboxSeriesSilhouette.ts, apps/desktop/src/renderer/styles/sortable.css | 142 | 102 | 701 | | `desktop.shell.main-layout` | desktop | 主窗口壳(标题栏 / 左右栏 / 内容区) | main BrowserWindow → renderer/index.tsx → main-entry.tsx → App → MainLayout (`#/` 受保护壳) | ChromeActions, FeishuConflictDialogHost, GhostMediaLightboxHost, MainLayout, RightSidebar, RightSidebarShell, SessionShareImportWizard, Sidebar, UpdateNoticeDialog, WindowControls | apps/desktop/src/renderer/cindy-brain/GhostMediaLightboxHost.tsx, apps/desktop/src/renderer/components/UpdateNoticeDialog.tsx, apps/desktop/src/renderer/components/feishuBot/FeishuConflictDialog.tsx, apps/desktop/src/renderer/components/feishuBot/FeishuConflictDialogHost.tsx, apps/desktop/src/renderer/components/layout/BrowserWebviewPool.tsx, apps/desktop/src/renderer/components/layout/ChromeActions.tsx, apps/desktop/src/renderer/components/layout/ContentHeader.tsx, apps/desktop/src/renderer/components/layout/CredentialStoreBanner.tsx, apps/desktop/src/renderer/components/layout/FadeSwitcher.tsx, apps/desktop/src/renderer/components/layout/GhostPanelWindowLayout.tsx, apps/desktop/src/renderer/components/layout/GlobalDropImportListener.tsx, apps/desktop/src/renderer/components/layout/MainLayout.tsx, apps/desktop/src/renderer/components/layout/RightSidebar.tsx, apps/desktop/src/renderer/components/layout/RightSidebarDetach.tsx, apps/desktop/src/renderer/components/layout/RightSidebarMaximize.tsx, apps/desktop/src/renderer/components/layout/RightSidebarToggle.tsx, apps/desktop/src/renderer/components/layout/SidebarWindowLayout.tsx, apps/desktop/src/renderer/components/layout/chromeActionsGeometry.ts, apps/desktop/src/renderer/components/layout/chromeActionsLayout.ts, apps/desktop/src/renderer/components/layout/railChromeActions.ts, apps/desktop/src/renderer/components/layout/windowDrag.tsx, apps/desktop/src/renderer/components/settings/SessionShareImportWizard.tsx, apps/desktop/src/renderer/components/sidebar/AccountSwitcherDialog.tsx, apps/desktop/src/renderer/components/sidebar/AttentionDot.tsx, apps/desktop/src/renderer/components/sidebar/GhostMainViewNavEntries.tsx, apps/desktop/src/renderer/components/sidebar/MobileDownloadDialog.tsx, apps/desktop/src/renderer/components/sidebar/Sidebar.tsx, apps/desktop/src/renderer/components/sidebar/SidebarIconButton.tsx, apps/desktop/src/renderer/components/sidebar/SidebarTopNav.tsx, apps/desktop/src/renderer/components/sidebar/SortableList.tsx, apps/desktop/src/renderer/components/sidebar/UpdateBanner.tsx, apps/desktop/src/renderer/components/sidebar/UserInfoSection.tsx, apps/desktop/src/renderer/components/sidebar/VendorIcon.tsx, apps/desktop/src/renderer/components/sidebar/VendorReadinessBadge.tsx, apps/desktop/src/renderer/components/sidebar/WorktreeBadge.tsx, apps/desktop/src/renderer/components/title-bar/ChromeIconButton.tsx, apps/desktop/src/renderer/components/title-bar/MenuButton.tsx, apps/desktop/src/renderer/components/title-bar/WindowControls.tsx, apps/desktop/src/renderer/features/right-sidebar/AddTabDropdown.tsx, apps/desktop/src/renderer/features/right-sidebar/EmptyState.tsx, apps/desktop/src/renderer/features/right-sidebar/RightSidebarShell.tsx, apps/desktop/src/renderer/features/right-sidebar/TabBar.tsx, apps/desktop/src/renderer/features/right-sidebar/TabBodyErrorBoundary.tsx, apps/desktop/src/renderer/features/right-sidebar/hooks/useBrowserWebview.ts, apps/desktop/src/renderer/features/right-sidebar/hooks/useNativePopupSurface.ts, apps/desktop/src/renderer/features/right-sidebar/iosSimulatorPluginAvailability.ts, apps/desktop/src/renderer/features/right-sidebar/lib/browserPartition.ts, apps/desktop/src/renderer/features/right-sidebar/lib/browserWebviewPool.ts, apps/desktop/src/renderer/features/right-sidebar/lib/detachedSidebarRouting.ts, apps/desktop/src/renderer/features/right-sidebar/lib/executeSidebarCommand.ts, apps/desktop/src/renderer/features/right-sidebar/lib/iosSimulatorFocusBridge.ts, apps/desktop/src/renderer/features/right-sidebar/lib/lastTurnChangedFiles.ts, apps/desktop/src/renderer/features/right-sidebar/lib/nativePopupTabs.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openBackgroundTasksTab.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openInSidebarBrowser.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openInSidebarFileBrowser.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openSubagentsTab.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openTerminalShortcut.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openTurnReview.ts, apps/desktop/src/renderer/features/right-sidebar/lib/popupRouter.ts, apps/desktop/src/renderer/features/right-sidebar/lib/popupTabs.ts, apps/desktop/src/renderer/features/right-sidebar/lib/rsbBrowserBridge.ts, apps/desktop/src/renderer/features/right-sidebar/lib/sidebarCommands.ts, apps/desktop/src/renderer/features/right-sidebar/lib/sidebarHostSession.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/BackgroundTasksBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/WorkflowAgentStrip.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/WorkflowProgressTree.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/chatTaskFocusIntent.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/listSessionTasks.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/workflowProgressModel.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/FileBrowserBody.css, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/FileBrowserBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/dropExternalFile.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/fileTreeImagePreview.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/useSessionScopedTreeWidth.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/index.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/IOSSimulatorInstanceGrid.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/IOSSimulatorTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/iosSimulatorH264Decoder.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/OrcaWorkersAttentionIcon.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/OrcaWorkersTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/actions.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/closeDecision.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/ResourceUsageBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/resource-usage.css, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/subscription.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/ImageDiffPreview.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/MarkdownDiffPreview.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/PlainUnifiedDiff.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/diffRows.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/highlight.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/inlineDiff.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/useDiffHighlights.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/ReviewTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/diffExpansionPreference.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/fileTree.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/gitApplyCommand.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/markdownPreview.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/useLastTurnFilter.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/useReviewGitState.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/useReviewSource.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/ConversationStream.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/DetailView.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/RunList.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentChrome.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentToolCard.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentsBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/subagentChangeFence.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/subagentConversation.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/subagentFormat.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/terminal/TerminalTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/terminal/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/terminal/lib/xtermPool.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/BrowserChrome.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/BrowserCommentPopover.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/BrowserTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/browserCommentEditorDraft.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/lib/parseOmnibox.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/useBrowserComment.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/useLocalHtmlAutoReload.ts, apps/desktop/src/renderer/features/right-sidebar/registry.ts, apps/desktop/src/renderer/features/right-sidebar/store.ts, apps/desktop/src/renderer/features/right-sidebar/types.ts, apps/desktop/src/renderer/layout/LayoutRoot.tsx, apps/desktop/src/renderer/layout/PanelDragController.tsx, apps/desktop/src/renderer/layout/PanelDragPrototype.tsx, apps/desktop/src/renderer/layout/collapsePrefs.ts, apps/desktop/src/renderer/layout/layoutDevTools.ts, apps/desktop/src/renderer/layout/panePlacement.tsx, apps/desktop/src/renderer/layout/paneWidths.tsx, apps/desktop/src/renderer/layout/panelMaximize.tsx, apps/desktop/src/renderer/styles/globals.css | 144 | 72 | 282 | | `desktop.skillhub.local` | desktop | SkillHub 本地技能 | hash `/skillhub/local` 及详情 `/skillhub/local/:kind/global/:name`、`/skillhub/local/:kind/project/:projectHash/:name` | InstallTargetPicker, PluginManagementLayout, SkillhubDetailView, SkillhubFeatureLayout, SkillhubHomeView, SkillhubMarketPreviewPanel | apps/desktop/src/renderer/features/plugin/PluginManagementLayout.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubDetailView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubFeatureLayout.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubMarketPreviewPanel.tsx, apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx | 41 | 0 | 70 | -| `desktop.skillhub.market` | desktop | SkillHub 市场 | hash `/skillhub/market`(SkillhubMarketListView) | InstallTargetPicker, MarketCard, MarketInfoEditDialog, SkillhubMarketListView, SkillhubMarketPreviewPanel, VisibilityEditorDialog | apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubMarketPreviewPanel.tsx, apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketCard.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketInfoEditDialog.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketPreviewTree.tsx, apps/desktop/src/renderer/features/skillhub/components/SkillIcon.tsx, apps/desktop/src/renderer/features/skillhub/components/TeamScopePicker.tsx, apps/desktop/src/renderer/features/skillhub/components/VisibilityEditorDialog.tsx | 43 | 0 | 50 | +| `desktop.skillhub.market` | desktop | SkillHub 市场 | hash `/skillhub/market`(SkillhubMarketListView) | InstallTargetPicker, MarketCard, MarketInfoEditDialog, SkillhubMarketListView, SkillhubMarketPreviewPanel, VisibilityEditorDialog | apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubMarketPreviewPanel.tsx, apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketCard.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketInfoEditDialog.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketPreviewTree.tsx, apps/desktop/src/renderer/features/skillhub/components/SkillIcon.tsx, apps/desktop/src/renderer/features/skillhub/components/TeamScopePicker.tsx, apps/desktop/src/renderer/features/skillhub/components/VisibilityEditorDialog.tsx | 43 | 0 | 51 | | `desktop.window.computer-permission-guide` | desktop | 电脑使用权限引导 | `?view=computer-permission-guide` → ComputerPermissionGuideWindow;backdrop 同文件 | ComputerPermissionBackdrop, ComputerPermissionGuideWindow | apps/desktop/src/main/computer-permission-guide/MacComputerPermissionGuideNativeHost.ts, apps/desktop/src/main/computer-permission-guide/placement.ts, apps/desktop/src/main/computer-permission-guide/request.ts, apps/desktop/src/main/computer-permission-guide/switch-target.ts, apps/desktop/src/main/computer-permission-guide/window.ts, apps/desktop/src/renderer/components/settings/ComputerPermissionGuideWindow.tsx | 7 | 2 | 9 | | `desktop.window.ghost-panel` | desktop | 插件面板独立窗口 | `?ghostPanelWindow=` → renderer/ghost-panel-window-entry.tsx;hash `/ghost-panel-window` | GhostPanelWindowLayout | apps/desktop/src/main/ghost-panel-window/controller.ts, apps/desktop/src/main/ghost-panel-window/ipc.ts, apps/desktop/src/main/ghost-panel-window/registry.ts, apps/desktop/src/main/ghost-panel-window/settings-store.ts, apps/desktop/src/main/ghost-panel-window/window.ts, apps/desktop/src/renderer/components/layout/GhostPanelWindowLayout.tsx, apps/desktop/src/renderer/ghost-panel-window-entry.tsx, apps/desktop/src/renderer/styles/globals.css | 59 | 64 | 14 | | `desktop.window.resource-usage` | desktop | 资源用量窗口 | `?resourceUsageWindow=1` → renderer/resource-usage-entry.tsx(不走 router.tsx) | ResourceUsageWindowLayout, ResourceUsageWindowRoot | apps/desktop/src/main/resource-usage-window/controller.ts, apps/desktop/src/main/resource-usage-window/ipc.ts, apps/desktop/src/main/resource-usage-window/open-sender.ts, apps/desktop/src/main/resource-usage-window/registry.ts, apps/desktop/src/main/resource-usage-window/window.ts, apps/desktop/src/renderer/components/resource-usage/ResourceUsageWindowLayout.tsx, apps/desktop/src/renderer/resource-usage-entry.tsx, apps/desktop/src/renderer/styles/globals.css | 59 | 64 | 14 | From 745b37c4c18a61c2182e290e9a55171cdc0fbe13 Mon Sep 17 00:00:00 2001 From: xd-bobo Date: Wed, 2 Sep 2026 21:08:10 +0800 Subject: [PATCH 14/15] =?UTF-8?q?feat(skillhub):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E5=88=86=E9=A1=B5=E5=8A=A0=E8=BD=BD=E6=9B=B4?= =?UTF-8?q?=E5=A4=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: xd-bobo --- .../features/skillhub/SkillhubHomeView.tsx | 35 ++++++++++++++----- .../features/skillhub/hooks/useMarketList.ts | 6 ++-- .../lib/__tests__/marketRoutes.test.ts | 12 +++++++ .../src/renderer/i18n/locales/en/common.json | 2 ++ .../src/renderer/i18n/locales/ja/common.json | 2 ++ .../src/renderer/i18n/locales/ko/common.json | 2 ++ .../renderer/i18n/locales/zh-CN/common.json | 2 ++ .../renderer/i18n/locales/zh-TW/common.json | 2 ++ docs/design-rules/design-inventory.md | 2 +- 9 files changed, 53 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx b/apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx index 066c41dee45..c88122f5a80 100644 --- a/apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx +++ b/apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx @@ -29,7 +29,7 @@ import { } from '@/features/plugin/PluginManagementLayout'; import { buildLocalSkillRoute, findLocalSkillByPath } from './lib/localRoutes'; import { refresh as refreshSkillhub, useSkillhub } from './hooks/useSkillhub'; -import { useMarketList, type MarketSkill } from './hooks/useMarketList'; +import { MARKET_PAGE_SIZE, useMarketList, type MarketSkill } from './hooks/useMarketList'; import { MarketManagementDialogs, useMarketManagement } from './hooks/useMarketManagement'; import { basename, deriveProjectWorkingDir } from './lib/pathDerivations'; import { projectHash } from './lib/projectHash'; @@ -53,9 +53,6 @@ const KIND_ICON: Record = { agent: Bot, }; -/** 主 Skill Tab 每个云端目录最多展示的条数。 */ -const HOME_CATALOG_LIMIT = 8; - function includesSkillQuery(values: ReadonlyArray, query: string): boolean { if (!query) return true; return values.some((value) => value?.toLocaleLowerCase().includes(query)); @@ -82,16 +79,19 @@ export function SkillhubHomeView({ const marketFilter: HomeMarketFilter = catalogTab === 'organization' ? 'organization' : 'public'; const marketRequest = useMemo(() => homeMarketQuery(marketFilter), [marketFilter]); - // 主 Skill Tab 只展示各云端目录的首批摘要,完整分页仍由 SkillHub 市场页承担。 + // 主 Skill Tab 直接分页展示当前云端目录;“更多”仍进入带完整筛选能力的 Market。 const { items: marketItems, loading: marketLoading, + loadingMore: marketLoadingMore, + hasMore: marketHasMore, resolvedScope, resolvedMine, setSearchQuery, setSortBy, setCatalogScope, setVisibility, + loadMore: loadMoreMarket, reload: reloadMarket, } = useMarketList('all', { enabled: catalogTab !== 'local', @@ -121,8 +121,7 @@ export function SkillhubHomeView({ [skill.displayName, skill.name, skill.description, skill.authorName], normalizedQuery, ), - ) - .slice(0, HOME_CATALOG_LIMIT), + ), [marketFilter, marketItems, marketResponseCurrent, normalizedQuery], ); @@ -333,7 +332,7 @@ export function SkillhubHomeView({ {(marketLoading || !marketResponseCurrent) && catalogItems.length === 0 ? ( // 占位骨架:与真实卡片同栅格、同行数、同高度,内容到位后原地替换不跳动。
- {Array.from({ length: HOME_CATALOG_LIMIT }).map((_, i) => ( + {Array.from({ length: MARKET_PAGE_SIZE }).map((_, i) => (
))} + {marketResponseCurrent && marketHasMore ? ( +
+ +
+ ) : null}
)}
diff --git a/apps/desktop/src/renderer/features/skillhub/hooks/useMarketList.ts b/apps/desktop/src/renderer/features/skillhub/hooks/useMarketList.ts index 7bdc3b8aafb..74ed4b6dece 100644 --- a/apps/desktop/src/renderer/features/skillhub/hooks/useMarketList.ts +++ b/apps/desktop/src/renderer/features/skillhub/hooks/useMarketList.ts @@ -142,7 +142,7 @@ interface ServerListItem { catalogScope?: SkillhubCatalogScope; } -const PAGE_SIZE = 24; +export const MARKET_PAGE_SIZE = 24; function deriveAvatarInitial(authorName: string): string { const trimmed = authorName.trim(); @@ -366,7 +366,7 @@ export function useMarketList( const requestMarketPage = useCallback(async (params: FetchMarketPageInput): Promise => { const res = await window.electronAPI.skillhub.listMarket({ cursor: params.cursor, - limit: PAGE_SIZE, + limit: MARKET_PAGE_SIZE, sort: params.sort, q: params.q || undefined, scope: params.scope, @@ -408,7 +408,7 @@ export function useMarketList( collected.push(...pageItems); nextCursor = res.nextCursor ?? null; cursor = nextCursor ?? undefined; - } while (params.available && collected.length < PAGE_SIZE && nextCursor); + } while (params.available && collected.length < MARKET_PAGE_SIZE && nextCursor); return { success: true as const, diff --git a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketRoutes.test.ts b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketRoutes.test.ts index 498e582d2da..cefb93d94e9 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketRoutes.test.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketRoutes.test.ts @@ -83,6 +83,18 @@ describe('market route scope', () => { expect(homeSource).not.toContain(' { + const homeSource = readFileSync(resolve(skillhubDir, 'SkillhubHomeView.tsx'), 'utf8'); + const hookSource = readFileSync(resolve(skillhubDir, 'hooks/useMarketList.ts'), 'utf8'); + + expect(hookSource).toContain('export const MARKET_PAGE_SIZE = 24'); + expect(homeSource).toContain('length: MARKET_PAGE_SIZE'); + expect(homeSource).not.toContain('.slice(0, HOME_CATALOG'); + expect(homeSource).toContain('marketHasMore'); + expect(homeSource).toContain('loadMoreMarket()'); + expect(homeSource).toContain("t('skillhub.home.loadMore')"); + }); }); describe('market management copy and errors', () => { diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 21a0725252b..5612d013fc1 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -10020,6 +10020,8 @@ "mine": "Managed" }, "catalogMore": "More", + "loadMore": "Load more", + "loadingMore": "Loading…", "catalogEmpty": "No skills in this category", "local": "Local Skills", "localEmpty": "No local skills yet", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index 81dfd21f16f..2ab443c4449 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -9998,6 +9998,8 @@ "mine": "管理対象" }, "catalogMore": "もっと見る", + "loadMore": "さらに読み込む", + "loadingMore": "読み込み中…", "catalogEmpty": "このカテゴリにはスキルがありません", "local": "ローカルスキル", "localEmpty": "ローカルスキルはまだありません", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 59d4a58838f..7a909c569f4 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -9998,6 +9998,8 @@ "mine": "관리 대상" }, "catalogMore": "더 보기", + "loadMore": "더 불러오기", + "loadingMore": "불러오는 중…", "catalogEmpty": "이 카테고리에 스킬이 없습니다", "local": "로컬 스킬", "localEmpty": "아직 로컬 스킬이 없습니다", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index bd5b26a2723..18f4ae7cfe6 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -9990,6 +9990,8 @@ "mine": "我的管理" }, "catalogMore": "更多", + "loadMore": "加载更多", + "loadingMore": "加载中…", "catalogEmpty": "当前分类暂无技能", "local": "本地技能", "localEmpty": "还没有本地技能", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json index 683793ceeea..d45a72cfed1 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -9990,6 +9990,8 @@ "mine": "我的管理" }, "catalogMore": "更多", + "loadMore": "載入更多", + "loadingMore": "載入中…", "catalogEmpty": "目前分類暫無技能", "local": "本地技能", "localEmpty": "還沒有本地技能", diff --git a/docs/design-rules/design-inventory.md b/docs/design-rules/design-inventory.md index 970ede6555f..dd4e5428fe4 100644 --- a/docs/design-rules/design-inventory.md +++ b/docs/design-rules/design-inventory.md @@ -39,7 +39,7 @@ | `desktop.plugins.installed` | desktop | 已装插件 | hash `/plugins`(GhostPluginPage) | AddMarketplaceDialog, GhostPluginDetailView, GhostPluginPage, MarketPluginDetailView, MyPublishesSection, PluginManagementLayout, PluginScopePicker, UpdateAllDialog | apps/desktop/src/renderer/features/plugin/AddMarketplaceDialog.tsx, apps/desktop/src/renderer/features/plugin/GhostPluginDetailView.tsx, apps/desktop/src/renderer/features/plugin/GhostPluginPage.tsx, apps/desktop/src/renderer/features/plugin/MarketPluginDetailView.tsx, apps/desktop/src/renderer/features/plugin/MyPublishesSection.tsx, apps/desktop/src/renderer/features/plugin/PluginManagementLayout.tsx, apps/desktop/src/renderer/features/plugin/PluginScopePicker.tsx, apps/desktop/src/renderer/features/plugin/UpdateAllDialog.tsx, apps/desktop/src/renderer/features/plugin/plugin-motion.css | 49 | 0 | 81 | | `desktop.settings` | desktop | 设置 | hash `/settings`(SettingsView;tab 含 general / personalization / providers / billing / usage / voice-input / im-bot / shortcuts / agent-island / import / remote-control / ghosts / builtin-tools / computer-use / help / about) | SettingsView | apps/desktop/src/renderer/components/settings/AboutSection.tsx, apps/desktop/src/renderer/components/settings/AccountDeletionSection.tsx, apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx, apps/desktop/src/renderer/components/settings/AgentIslandSection.tsx, apps/desktop/src/renderer/components/settings/AgentResourceSection.tsx, apps/desktop/src/renderer/components/settings/AppearanceSection.tsx, apps/desktop/src/renderer/components/settings/AuxiliaryModelSection.tsx, apps/desktop/src/renderer/components/settings/BetaChannelCell.tsx, apps/desktop/src/renderer/components/settings/BrowserBackendSubsection.tsx, apps/desktop/src/renderer/components/settings/BrowserRealProfileSubsection.tsx, apps/desktop/src/renderer/components/settings/BuiltinToolsSection.tsx, apps/desktop/src/renderer/components/settings/ChatEmbeddingCell.tsx, apps/desktop/src/renderer/components/settings/ChipMetricsSection.tsx, apps/desktop/src/renderer/components/settings/CollaborationSection.tsx, apps/desktop/src/renderer/components/settings/CompactionSection.tsx, apps/desktop/src/renderer/components/settings/ComposerSendShortcutSection.tsx, apps/desktop/src/renderer/components/settings/ComputerPermissionGuideWindow.tsx, apps/desktop/src/renderer/components/settings/ComputerUseSection.tsx, apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx, apps/desktop/src/renderer/components/settings/CustomProviderRuntimeFillOverlay.tsx, apps/desktop/src/renderer/components/settings/DefaultOverrideControls.tsx, apps/desktop/src/renderer/components/settings/DingTalkBotSection.tsx, apps/desktop/src/renderer/components/settings/DiscordBotSection.tsx, apps/desktop/src/renderer/components/settings/DownloadMeter.tsx, apps/desktop/src/renderer/components/settings/ExperimentalSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotNotificationSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotSection.tsx, apps/desktop/src/renderer/components/settings/FontFamilyPicker.tsx, apps/desktop/src/renderer/components/settings/GenericGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/GitSafetySection.tsx, apps/desktop/src/renderer/components/settings/HelpAssistantPanel.tsx, apps/desktop/src/renderer/components/settings/HelpSection.tsx, apps/desktop/src/renderer/components/settings/HelpThreadView.tsx, apps/desktop/src/renderer/components/settings/HookConnectionsSection.tsx, apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx, apps/desktop/src/renderer/components/settings/ImBotSection.tsx, apps/desktop/src/renderer/components/settings/ImChannelSettingsCard.tsx, apps/desktop/src/renderer/components/settings/ImDefaultSettingsSection.tsx, apps/desktop/src/renderer/components/settings/ImLifecycleAnnouncementSection.tsx, apps/desktop/src/renderer/components/settings/InputDeviceConnectionStatus.tsx, apps/desktop/src/renderer/components/settings/JoyConGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/KeyboardShortcutsSection.tsx, apps/desktop/src/renderer/components/settings/LanguageSection.tsx, apps/desktop/src/renderer/components/settings/LayoutResetControl.tsx, apps/desktop/src/renderer/components/settings/LinkOpenSection.tsx, apps/desktop/src/renderer/components/settings/LocalOllamaInstall.tsx, apps/desktop/src/renderer/components/settings/LocalPackagingTag.tsx, apps/desktop/src/renderer/components/settings/LogoutSection.tsx, apps/desktop/src/renderer/components/settings/LspBetaCell.tsx, apps/desktop/src/renderer/components/settings/McpServerDialog.tsx, apps/desktop/src/renderer/components/settings/McpServersSection.tsx, apps/desktop/src/renderer/components/settings/MemorySection.tsx, apps/desktop/src/renderer/components/settings/MessageNavRailCell.tsx, apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx, apps/desktop/src/renderer/components/settings/MyDevicesPanel.tsx, apps/desktop/src/renderer/components/settings/NotificationSection.tsx, apps/desktop/src/renderer/components/settings/OAuthDeviceCodeCard.tsx, apps/desktop/src/renderer/components/settings/OllamaProviderDetail.tsx, apps/desktop/src/renderer/components/settings/PiPackagesSection.tsx, apps/desktop/src/renderer/components/settings/PlayStationGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/ProfileEditDialog.tsx, apps/desktop/src/renderer/components/settings/ProvidersSection.tsx, apps/desktop/src/renderer/components/settings/RemoteControlSection.tsx, apps/desktop/src/renderer/components/settings/RemoteHostDetail.tsx, apps/desktop/src/renderer/components/settings/RemoteSection.tsx, apps/desktop/src/renderer/components/settings/SessionImportSection.tsx, apps/desktop/src/renderer/components/settings/SessionRuntimeFallbackCell.tsx, apps/desktop/src/renderer/components/settings/SessionShareImportWizard.tsx, apps/desktop/src/renderer/components/settings/SettingsCatalogPanel.tsx, apps/desktop/src/renderer/components/settings/SettingsSidebarNav.tsx, apps/desktop/src/renderer/components/settings/SettingsTextInput.tsx, apps/desktop/src/renderer/components/settings/SettingsView.tsx, apps/desktop/src/renderer/components/settings/SilentEncryptedRetryCell.tsx, apps/desktop/src/renderer/components/settings/SshKeySetupDialog.tsx, apps/desktop/src/renderer/components/settings/StorageManagementCard.tsx, apps/desktop/src/renderer/components/settings/StreamFadeSection.tsx, apps/desktop/src/renderer/components/settings/SubagentModelSection.tsx, apps/desktop/src/renderer/components/settings/SwitchProGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/TelegramBehaviorSettings.tsx, apps/desktop/src/renderer/components/settings/TelegramBotSection.tsx, apps/desktop/src/renderer/components/settings/TelegramRemoteDevices.tsx, apps/desktop/src/renderer/components/settings/TerminalShellSection.tsx, apps/desktop/src/renderer/components/settings/TipsSection.tsx, apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx, apps/desktop/src/renderer/components/settings/UserProfileCard.tsx, apps/desktop/src/renderer/components/settings/UserPromptSection.tsx, apps/desktop/src/renderer/components/settings/VisionBridgeSection.tsx, apps/desktop/src/renderer/components/settings/VoiceInputSection.tsx, apps/desktop/src/renderer/components/settings/WechatBotSection.tsx, apps/desktop/src/renderer/components/settings/WecomBotSection.tsx, apps/desktop/src/renderer/components/settings/WindowBehaviorSection.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeyboardLayout.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeycapGlyphs.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexSettings.tsx, apps/desktop/src/renderer/components/settings/XUsageGuide.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadSettings.tsx, apps/desktop/src/renderer/components/settings/androidStatusPresentation.ts, apps/desktop/src/renderer/components/settings/billingVisibility.ts, apps/desktop/src/renderer/components/settings/browserOpenForLoginError.ts, apps/desktop/src/renderer/components/settings/computerPermissionFlow.ts, apps/desktop/src/renderer/components/settings/contacts/ContactDetailPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsImportDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsListPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsManagerDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsSection.tsx, apps/desktop/src/renderer/components/settings/contacts/startContactsAiSession.ts, apps/desktop/src/renderer/components/settings/dualSenseSilhouette.ts, apps/desktop/src/renderer/components/settings/feishuBotPresentation.ts, apps/desktop/src/renderer/components/settings/fontFamilyValue.ts, apps/desktop/src/renderer/components/settings/gamepadLayoutPrimitives.tsx, apps/desktop/src/renderer/components/settings/gamepadSilhouetteGeom.ts, apps/desktop/src/renderer/components/settings/hookWorkspacePrefsLogic.ts, apps/desktop/src/renderer/components/settings/imBotVisibility.ts, apps/desktop/src/renderer/components/settings/imDefaultSettingsLogic.ts, apps/desktop/src/renderer/components/settings/joyConSilhouette.ts, apps/desktop/src/renderer/components/settings/myDevicesModel.ts, apps/desktop/src/renderer/components/settings/providerAssetModule.ts, apps/desktop/src/renderer/components/settings/realProfilePermissionGuide.ts, apps/desktop/src/renderer/components/settings/switchProSilhouette.ts, apps/desktop/src/renderer/components/settings/ultimateC1Silhouette.ts, apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx, apps/desktop/src/renderer/components/settings/usage/UsageHistorySection.tsx, apps/desktop/src/renderer/components/settings/usage/UsageStatRow.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTaskTable.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTokenBars.tsx, apps/desktop/src/renderer/components/settings/usage/formatUsagePercent.ts, apps/desktop/src/renderer/components/settings/usage/usageHistoryStats.ts, apps/desktop/src/renderer/components/settings/usageVisibility.ts, apps/desktop/src/renderer/components/settings/wizardRecommend.ts, apps/desktop/src/renderer/components/settings/workLouderCodexCommandCopy.ts, apps/desktop/src/renderer/components/settings/xboxSeriesSilhouette.ts, apps/desktop/src/renderer/styles/sortable.css | 142 | 102 | 703 | | `desktop.shell.main-layout` | desktop | 主窗口壳(标题栏 / 左右栏 / 内容区) | main BrowserWindow → renderer/index.tsx → main-entry.tsx → App → MainLayout (`#/` 受保护壳) | ChromeActions, FeishuConflictDialogHost, GhostMediaLightboxHost, MainLayout, RightSidebar, RightSidebarShell, SessionShareImportWizard, Sidebar, UpdateNoticeDialog, WindowControls | apps/desktop/src/renderer/cindy-brain/GhostMediaLightboxHost.tsx, apps/desktop/src/renderer/components/UpdateNoticeDialog.tsx, apps/desktop/src/renderer/components/feishuBot/FeishuConflictDialog.tsx, apps/desktop/src/renderer/components/feishuBot/FeishuConflictDialogHost.tsx, apps/desktop/src/renderer/components/layout/BrowserWebviewPool.tsx, apps/desktop/src/renderer/components/layout/ChromeActions.tsx, apps/desktop/src/renderer/components/layout/ContentHeader.tsx, apps/desktop/src/renderer/components/layout/CredentialStoreBanner.tsx, apps/desktop/src/renderer/components/layout/FadeSwitcher.tsx, apps/desktop/src/renderer/components/layout/GhostPanelWindowLayout.tsx, apps/desktop/src/renderer/components/layout/GlobalDropImportListener.tsx, apps/desktop/src/renderer/components/layout/MainLayout.tsx, apps/desktop/src/renderer/components/layout/RightSidebar.tsx, apps/desktop/src/renderer/components/layout/RightSidebarDetach.tsx, apps/desktop/src/renderer/components/layout/RightSidebarMaximize.tsx, apps/desktop/src/renderer/components/layout/RightSidebarToggle.tsx, apps/desktop/src/renderer/components/layout/SidebarWindowLayout.tsx, apps/desktop/src/renderer/components/layout/chromeActionsGeometry.ts, apps/desktop/src/renderer/components/layout/chromeActionsLayout.ts, apps/desktop/src/renderer/components/layout/railChromeActions.ts, apps/desktop/src/renderer/components/layout/windowDrag.tsx, apps/desktop/src/renderer/components/settings/SessionShareImportWizard.tsx, apps/desktop/src/renderer/components/sidebar/AccountSwitcherDialog.tsx, apps/desktop/src/renderer/components/sidebar/AttentionDot.tsx, apps/desktop/src/renderer/components/sidebar/GhostMainViewNavEntries.tsx, apps/desktop/src/renderer/components/sidebar/MobileDownloadDialog.tsx, apps/desktop/src/renderer/components/sidebar/Sidebar.tsx, apps/desktop/src/renderer/components/sidebar/SidebarIconButton.tsx, apps/desktop/src/renderer/components/sidebar/SidebarTopNav.tsx, apps/desktop/src/renderer/components/sidebar/SortableList.tsx, apps/desktop/src/renderer/components/sidebar/UpdateBanner.tsx, apps/desktop/src/renderer/components/sidebar/UserInfoSection.tsx, apps/desktop/src/renderer/components/sidebar/VendorIcon.tsx, apps/desktop/src/renderer/components/sidebar/VendorReadinessBadge.tsx, apps/desktop/src/renderer/components/sidebar/WorktreeBadge.tsx, apps/desktop/src/renderer/components/title-bar/ChromeIconButton.tsx, apps/desktop/src/renderer/components/title-bar/MenuButton.tsx, apps/desktop/src/renderer/components/title-bar/WindowControls.tsx, apps/desktop/src/renderer/features/right-sidebar/AddTabDropdown.tsx, apps/desktop/src/renderer/features/right-sidebar/EmptyState.tsx, apps/desktop/src/renderer/features/right-sidebar/RightSidebarShell.tsx, apps/desktop/src/renderer/features/right-sidebar/TabBar.tsx, apps/desktop/src/renderer/features/right-sidebar/TabBodyErrorBoundary.tsx, apps/desktop/src/renderer/features/right-sidebar/hooks/useBrowserWebview.ts, apps/desktop/src/renderer/features/right-sidebar/hooks/useNativePopupSurface.ts, apps/desktop/src/renderer/features/right-sidebar/iosSimulatorPluginAvailability.ts, apps/desktop/src/renderer/features/right-sidebar/lib/browserPartition.ts, apps/desktop/src/renderer/features/right-sidebar/lib/browserWebviewPool.ts, apps/desktop/src/renderer/features/right-sidebar/lib/detachedSidebarRouting.ts, apps/desktop/src/renderer/features/right-sidebar/lib/executeSidebarCommand.ts, apps/desktop/src/renderer/features/right-sidebar/lib/iosSimulatorFocusBridge.ts, apps/desktop/src/renderer/features/right-sidebar/lib/lastTurnChangedFiles.ts, apps/desktop/src/renderer/features/right-sidebar/lib/nativePopupTabs.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openBackgroundTasksTab.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openInSidebarBrowser.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openInSidebarFileBrowser.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openSubagentsTab.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openTerminalShortcut.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openTurnReview.ts, apps/desktop/src/renderer/features/right-sidebar/lib/popupRouter.ts, apps/desktop/src/renderer/features/right-sidebar/lib/popupTabs.ts, apps/desktop/src/renderer/features/right-sidebar/lib/rsbBrowserBridge.ts, apps/desktop/src/renderer/features/right-sidebar/lib/sidebarCommands.ts, apps/desktop/src/renderer/features/right-sidebar/lib/sidebarHostSession.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/BackgroundTasksBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/WorkflowAgentStrip.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/WorkflowProgressTree.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/chatTaskFocusIntent.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/listSessionTasks.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/workflowProgressModel.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/FileBrowserBody.css, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/FileBrowserBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/dropExternalFile.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/fileTreeImagePreview.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/useSessionScopedTreeWidth.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/index.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/IOSSimulatorInstanceGrid.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/IOSSimulatorTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/iosSimulatorH264Decoder.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/OrcaWorkersAttentionIcon.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/OrcaWorkersTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/actions.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/closeDecision.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/ResourceUsageBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/resource-usage.css, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/subscription.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/ImageDiffPreview.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/MarkdownDiffPreview.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/PlainUnifiedDiff.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/diffRows.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/highlight.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/inlineDiff.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/useDiffHighlights.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/ReviewTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/diffExpansionPreference.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/fileTree.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/gitApplyCommand.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/markdownPreview.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/useLastTurnFilter.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/useReviewGitState.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/useReviewSource.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/ConversationStream.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/DetailView.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/RunList.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentChrome.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentToolCard.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentsBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/subagentChangeFence.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/subagentConversation.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/subagentFormat.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/terminal/TerminalTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/terminal/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/terminal/lib/xtermPool.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/BrowserChrome.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/BrowserCommentPopover.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/BrowserTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/browserCommentEditorDraft.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/lib/parseOmnibox.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/useBrowserComment.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/useLocalHtmlAutoReload.ts, apps/desktop/src/renderer/features/right-sidebar/registry.ts, apps/desktop/src/renderer/features/right-sidebar/store.ts, apps/desktop/src/renderer/features/right-sidebar/types.ts, apps/desktop/src/renderer/layout/LayoutRoot.tsx, apps/desktop/src/renderer/layout/PanelDragController.tsx, apps/desktop/src/renderer/layout/PanelDragPrototype.tsx, apps/desktop/src/renderer/layout/collapsePrefs.ts, apps/desktop/src/renderer/layout/layoutDevTools.ts, apps/desktop/src/renderer/layout/panePlacement.tsx, apps/desktop/src/renderer/layout/paneWidths.tsx, apps/desktop/src/renderer/layout/panelMaximize.tsx, apps/desktop/src/renderer/styles/globals.css | 144 | 72 | 282 | -| `desktop.skillhub.local` | desktop | SkillHub 本地技能 | hash `/skillhub/local` 及详情 `/skillhub/local/:kind/global/:name`、`/skillhub/local/:kind/project/:projectHash/:name` | InstallTargetPicker, PluginManagementLayout, SkillhubDetailView, SkillhubFeatureLayout, SkillhubHomeView, SkillhubMarketPreviewPanel | apps/desktop/src/renderer/features/plugin/PluginManagementLayout.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubDetailView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubFeatureLayout.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubMarketPreviewPanel.tsx, apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx | 41 | 0 | 70 | +| `desktop.skillhub.local` | desktop | SkillHub 本地技能 | hash `/skillhub/local` 及详情 `/skillhub/local/:kind/global/:name`、`/skillhub/local/:kind/project/:projectHash/:name` | InstallTargetPicker, PluginManagementLayout, SkillhubDetailView, SkillhubFeatureLayout, SkillhubHomeView, SkillhubMarketPreviewPanel | apps/desktop/src/renderer/features/plugin/PluginManagementLayout.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubDetailView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubFeatureLayout.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubMarketPreviewPanel.tsx, apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx | 41 | 0 | 71 | | `desktop.skillhub.market` | desktop | SkillHub 市场 | hash `/skillhub/market`(SkillhubMarketListView) | InstallTargetPicker, MarketCard, MarketInfoEditDialog, SkillhubMarketListView, SkillhubMarketPreviewPanel, VisibilityEditorDialog | apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubMarketPreviewPanel.tsx, apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketCard.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketInfoEditDialog.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketPreviewTree.tsx, apps/desktop/src/renderer/features/skillhub/components/SkillIcon.tsx, apps/desktop/src/renderer/features/skillhub/components/TeamScopePicker.tsx, apps/desktop/src/renderer/features/skillhub/components/VisibilityEditorDialog.tsx | 43 | 0 | 51 | | `desktop.window.computer-permission-guide` | desktop | 电脑使用权限引导 | `?view=computer-permission-guide` → ComputerPermissionGuideWindow;backdrop 同文件 | ComputerPermissionBackdrop, ComputerPermissionGuideWindow | apps/desktop/src/main/computer-permission-guide/MacComputerPermissionGuideNativeHost.ts, apps/desktop/src/main/computer-permission-guide/placement.ts, apps/desktop/src/main/computer-permission-guide/request.ts, apps/desktop/src/main/computer-permission-guide/switch-target.ts, apps/desktop/src/main/computer-permission-guide/window.ts, apps/desktop/src/renderer/components/settings/ComputerPermissionGuideWindow.tsx | 7 | 2 | 9 | | `desktop.window.ghost-panel` | desktop | 插件面板独立窗口 | `?ghostPanelWindow=` → renderer/ghost-panel-window-entry.tsx;hash `/ghost-panel-window` | GhostPanelWindowLayout | apps/desktop/src/main/ghost-panel-window/controller.ts, apps/desktop/src/main/ghost-panel-window/ipc.ts, apps/desktop/src/main/ghost-panel-window/registry.ts, apps/desktop/src/main/ghost-panel-window/settings-store.ts, apps/desktop/src/main/ghost-panel-window/window.ts, apps/desktop/src/renderer/components/layout/GhostPanelWindowLayout.tsx, apps/desktop/src/renderer/ghost-panel-window-entry.tsx, apps/desktop/src/renderer/styles/globals.css | 59 | 64 | 14 | From 5e42be2d09219bdbd594dc1e9de80163cf04f9a0 Mon Sep 17 00:00:00 2001 From: xd-bobo Date: Wed, 2 Sep 2026 22:55:14 +0800 Subject: [PATCH 15/15] fix(skillhub): align catalog scope and publishing contracts Signed-off-by: xd-bobo --- apps/desktop/src/main/bootstrap-electron.ts | 2 +- .../__tests__/builtinsRemoteRouting.test.ts | 16 ++++ apps/desktop/src/main/commands/builtins.ts | 5 +- .../learn-host/__tests__/controller.test.ts | 20 +++-- .../learn-host/__tests__/hubReference.test.ts | 19 +++++ .../desktop/src/main/learn-host/controller.ts | 12 ++- .../src/main/learn-host/hubReference.ts | 16 ++-- apps/desktop/src/main/learn-host/index.ts | 2 +- .../skillhub/__tests__/infoMapping.test.ts | 20 ++++- .../skillhub/__tests__/marketService.test.ts | 48 +++++++++++- .../skillhub/__tests__/publishService.test.ts | 68 +++++------------ .../skillhub/__tests__/syncMapping.test.ts | 19 +++-- apps/desktop/src/main/skillhub/infoMapping.ts | 14 +++- .../src/main/skillhub/marketService.ts | 74 ++++++++++++++++--- .../src/main/skillhub/publishService.ts | 35 ++++----- .../registry/__tests__/manifestIO.test.ts | 12 ++- .../__tests__/registryService.test.ts | 13 ++++ .../src/main/skillhub/registry/manifestIO.ts | 16 ++-- .../src/main/skillhub/registry/migrations.ts | 38 ++++++++++ .../main/skillhub/registry/registryService.ts | 27 ++++--- .../src/main/skillhub/registry/types.ts | 2 + apps/desktop/src/main/skillhub/syncMapping.ts | 29 ++++++-- apps/desktop/src/preload/preload.ts | 10 ++- .../features/learn/LearnStatusCard.tsx | 2 +- .../features/skillhub/PublishDialog.tsx | 11 ++- .../features/skillhub/SkillhubDetailView.tsx | 71 +++++++++++++----- .../skillhub/SkillhubFeatureLayout.tsx | 3 +- .../features/skillhub/SkillhubHomeView.tsx | 8 +- .../skillhub/SkillhubMarketPreviewPanel.tsx | 5 +- .../skillhub/components/MarketCard.tsx | 3 +- .../components/MarketInfoEditDialog.tsx | 40 +++++++--- .../components/__tests__/MarketCard.test.tsx | 3 +- .../features/skillhub/hooks/useMarketList.ts | 11 ++- .../skillhub/hooks/useMarketManagement.tsx | 26 +------ .../features/skillhub/hooks/useSkillSync.ts | 20 ++++- .../features/skillhub/hooks/useSkillhub.ts | 5 +- .../lib/__tests__/detailButtons.test.ts | 70 +++++++++++++++++- .../lib/__tests__/marketPreviewSync.test.ts | 3 +- .../lib/__tests__/publishForm.test.ts | 8 +- .../lib/__tests__/publisherLabel.test.ts | 19 +++++ .../features/skillhub/lib/detailButtons.ts | 57 +++++++------- .../features/skillhub/lib/marketErrors.ts | 2 + .../features/skillhub/lib/publishForm.ts | 10 ++- .../features/skillhub/lib/publisherLabel.ts | 10 +++ .../src/renderer/i18n/locales/en/common.json | 3 +- .../src/renderer/i18n/locales/ja/common.json | 3 +- .../src/renderer/i18n/locales/ko/common.json | 3 +- .../renderer/i18n/locales/zh-CN/common.json | 3 +- .../renderer/i18n/locales/zh-TW/common.json | 3 +- apps/desktop/src/renderer/vite-env.d.ts | 19 +++-- apps/desktop/src/shared/learnTypes.ts | 3 + apps/desktop/src/shared/skillhubCatalog.ts | 6 ++ apps/desktop/src/shared/skillhubCategory.ts | 1 + docs/dev-rules/protocol-compatibility.md | 15 ++++ 54 files changed, 692 insertions(+), 271 deletions(-) create mode 100644 apps/desktop/src/main/skillhub/registry/migrations.ts create mode 100644 apps/desktop/src/renderer/features/skillhub/lib/__tests__/publisherLabel.test.ts create mode 100644 apps/desktop/src/renderer/features/skillhub/lib/publisherLabel.ts diff --git a/apps/desktop/src/main/bootstrap-electron.ts b/apps/desktop/src/main/bootstrap-electron.ts index 2b26b88568f..6ae8b1571c0 100644 --- a/apps/desktop/src/main/bootstrap-electron.ts +++ b/apps/desktop/src/main/bootstrap-electron.ts @@ -1122,7 +1122,7 @@ async function attemptStartSchedulerOnce(): Promise { startLearnHost({ maker, broadcast: broadcastLearnEvent, - fetchHubSkill: (slug) => fetchHubSkillReference(learnMarketService, slug), + fetchHubSkill: (slug, catalogScope) => fetchHubSkillReference(learnMarketService, slug, catalogScope), ...automationGitBaselineHooks, }); } catch (err) { diff --git a/apps/desktop/src/main/commands/__tests__/builtinsRemoteRouting.test.ts b/apps/desktop/src/main/commands/__tests__/builtinsRemoteRouting.test.ts index 3d928baf6f1..b0596d43e6d 100644 --- a/apps/desktop/src/main/commands/__tests__/builtinsRemoteRouting.test.ts +++ b/apps/desktop/src/main/commands/__tests__/builtinsRemoteRouting.test.ts @@ -115,6 +115,22 @@ describe('/learn 远程路由', () => { ]); }); + it('deviceId + hub:: → 保留目录作用域', async () => { + const { remoteInvoke, registry } = makeHarness({ + remoteInvoke: async () => ({ runId: 'r-scope' }), + }); + await registry.execute('learn', { sessionId: 'rs', deviceId: 'dev-1', args: 'hub:team:my-skill 精简点' }); + expect(remoteInvoke).toHaveBeenCalledWith('dev-1', 'learn:start', [ + { + input: '精简点', + sourceKind: 'hub', + hubSlug: 'my-skill', + hubCatalogScope: 'team', + originSessionId: 'rs', + }, + ]); + }); + it('隧道 [LEARN_BUSY] 编码 → learn-busy(与本机 err.code 同分类)', async () => { const { registry } = makeHarness({ remoteInvoke: async () => { diff --git a/apps/desktop/src/main/commands/builtins.ts b/apps/desktop/src/main/commands/builtins.ts index f883e9cba54..3038b549d9a 100644 --- a/apps/desktop/src/main/commands/builtins.ts +++ b/apps/desktop/src/main/commands/builtins.ts @@ -541,12 +541,13 @@ export function registerBuiltinDesktopCommands( } // `/learn hub: [补充要求]` —— skill hub「学习此技能」预填的形态, // 用户可在输入框改要求、换模型后再发。slug 规则与市场一致([a-z0-9-])。 - const hubMatch = /^hub:([a-z0-9][a-z0-9-]*)\s*/.exec(arg); + const hubMatch = /^hub:(?:(market|team):)?([a-z0-9][a-z0-9-]*)\s*/.exec(arg); const req = hubMatch ? { input: arg.slice(hubMatch[0].length).trim(), sourceKind: 'hub' as const, - hubSlug: hubMatch[1], + hubSlug: hubMatch[2], + ...(hubMatch[1] ? { hubCatalogScope: hubMatch[1] as 'market' | 'team' } : {}), ...(ctx.sessionId ? { originSessionId: ctx.sessionId } : {}), } : { diff --git a/apps/desktop/src/main/learn-host/__tests__/controller.test.ts b/apps/desktop/src/main/learn-host/__tests__/controller.test.ts index 557f8aad477..a847861975b 100644 --- a/apps/desktop/src/main/learn-host/__tests__/controller.test.ts +++ b/apps/desktop/src/main/learn-host/__tests__/controller.test.ts @@ -1322,19 +1322,27 @@ describe('LearnController 状态机', () => { it('hub 源命中同名本地 skill:本地 SKILL.md 注入 prompt 前过 redaction', async () => { const secret = 'sk-abcdef1234567890abcdef1234567890'; + const fetchHubSkill = vi.fn(async () => ({ + name: 'my-skill', + description: 'upstream', + content: '# upstream skill', + })); const h = makeHarness({ - fetchHubSkill: async () => ({ - name: 'my-skill', - description: 'upstream', - content: '# upstream skill', - }), + fetchHubSkill, dirExists: async (dir) => dir.startsWith(path.join('/', 'fake-staging')) || dir === path.join('/', 'installed', 'my-skill'), readFileText: async () => `# local skill\napi key: ${secret}\n`, }); h.setScan(goodScan()); - const { runId } = await h.controller.startLearn({ input: '', sourceKind: 'hub', hubSlug: 'my-skill' }); + const { runId } = await h.controller.startLearn({ + input: '', + sourceKind: 'hub', + hubSlug: 'my-skill', + hubCatalogScope: 'team', + }); await h.waitForStatus(runId, 'distilling'); + expect(fetchHubSkill).toHaveBeenCalledWith('my-skill', 'team'); + expect(h.store.get(runId)?.hubCatalogScope).toBe('team'); expect(h.session.sent[0]).toContain('# local skill'); expect(h.session.sent[0]).not.toContain(secret); }); diff --git a/apps/desktop/src/main/learn-host/__tests__/hubReference.test.ts b/apps/desktop/src/main/learn-host/__tests__/hubReference.test.ts index 09fff58bf71..a835104e0fb 100644 --- a/apps/desktop/src/main/learn-host/__tests__/hubReference.test.ts +++ b/apps/desktop/src/main/learn-host/__tests__/hubReference.test.ts @@ -77,6 +77,25 @@ describe('fetchHubSkillReference', () => { expect(reader.readPublishedFile).not.toHaveBeenCalledWith({ name: 'demo-skill', path: 'scripts/large.py' }); }); + it('keeps the originating catalog scope on every Hub read', async () => { + const reader = makeReader(); + + await fetchHubSkillReference(reader, 'demo-skill', 'team'); + + expect(reader.info).toHaveBeenCalledWith('demo-skill', 'team'); + expect(reader.getPublishedFiles).toHaveBeenCalledWith({ name: 'demo-skill', catalogScope: 'team' }); + expect(reader.readPublishedFile).toHaveBeenCalledWith({ + name: 'demo-skill', + path: 'SKILL.md', + catalogScope: 'team', + }); + expect(reader.readPublishedFile).toHaveBeenCalledWith({ + name: 'demo-skill', + path: 'scripts/run.py', + catalogScope: 'team', + }); + }); + it('surfaces files omitted by the reference file cap', async () => { const auxFiles = Array.from({ length: 42 }, (_, i) => ({ path: `scripts/${i}.py`, diff --git a/apps/desktop/src/main/learn-host/controller.ts b/apps/desktop/src/main/learn-host/controller.ts index 6670d4bab1a..95d54784c8e 100644 --- a/apps/desktop/src/main/learn-host/controller.ts +++ b/apps/desktop/src/main/learn-host/controller.ts @@ -163,7 +163,7 @@ export interface LearnControllerDeps { /** 已装 skill 清单块("改 vs 加"决策依据;无 skill 返空串)。 */ getInstalledSkillsIndex(): Promise; /** hub 源:拉市场 skill 详情 + 全部已发布文件(PR3 注入;未注入时 hub 源报 INVALID_PARAMS)。 */ - fetchHubSkill?: (slug: string) => Promise<{ + fetchHubSkill?: (slug: string, catalogScope?: 'market' | 'team') => Promise<{ name: string; description: string; content: string; @@ -337,6 +337,9 @@ export class LearnController { if (req.sourceKind === 'hub' && req.hubSlug && !/^[a-z0-9][a-z0-9-]*$/.test(req.hubSlug)) { throw new LearnError('INVALID_PARAMS', `invalid hubSlug: ${req.hubSlug}`); } + if (req.sourceKind === 'hub' && req.hubCatalogScope && req.hubCatalogScope !== 'market' && req.hubCatalogScope !== 'team') { + throw new LearnError('INVALID_PARAMS', `invalid hubCatalogScope: ${String(req.hubCatalogScope)}`); + } if (req.sourceKind === 'hub' && !this.deps.fetchHubSkill) { throw new LearnError('INVALID_PARAMS', 'hub source is not available'); } @@ -361,6 +364,7 @@ export class LearnController { ...(dataOwnerId ? { dataOwnerId } : {}), input, ...(req.hubSlug ? { hubSlug: req.hubSlug } : {}), + ...(req.hubCatalogScope ? { hubCatalogScope: req.hubCatalogScope } : {}), ...(req.originSessionId ? { originSessionId: req.originSessionId } : {}), usedSessionEvidence: false, createdAt: this.now(), @@ -393,7 +397,7 @@ export class LearnController { let referenceFilesOmissions: Array<{ path: string; reason: string }> | undefined; let evidenceQuery = run.input; if (run.sourceKind === 'hub' && run.hubSlug && this.deps.fetchHubSkill) { - const hub = await this.deps.fetchHubSkill(run.hubSlug); + const hub = await this.deps.fetchHubSkill(run.hubSlug, run.hubCatalogScope); if (!hub) throw new LearnError('NOT_FOUND', `hub skill ${run.hubSlug} not found`); // fetch 的网络 await 期间可能被 cancel(cleanup 已删 staging):此处不设门 // 的话 writeReferenceFiles 会把 _reference/ 整个重建成孤儿目录(自查)。 @@ -516,7 +520,7 @@ export class LearnController { const cleanMessage = run.sourceKind === 'hub' - ? `/learn hub:${run.hubSlug}` + ? `/learn hub:${run.hubCatalogScope ? `${run.hubCatalogScope}:` : ''}${run.hubSlug}` : run.sourceKind === 'session' ? '/learn (distill current conversation)' : `/learn ${run.input}`; @@ -960,7 +964,7 @@ export class LearnController { const provenance: LearnProvenance = { method: 'learn', sourceKind: run.sourceKind, - ...(run.hubSlug ? { sourceRef: run.hubSlug } : {}), + ...(run.hubSlug ? { sourceRef: `${run.hubCatalogScope ? `${run.hubCatalogScope}:` : ''}${run.hubSlug}` } : {}), usedSessionEvidence: run.usedSessionEvidence, personal: run.usedSessionEvidence, // 硬规则:含 session 证据 ⇒ personal,不可配置 learnedAt: Math.floor(this.now() / 1000), diff --git a/apps/desktop/src/main/learn-host/hubReference.ts b/apps/desktop/src/main/learn-host/hubReference.ts index 8510e3f577d..8891fbe952a 100644 --- a/apps/desktop/src/main/learn-host/hubReference.ts +++ b/apps/desktop/src/main/learn-host/hubReference.ts @@ -5,6 +5,7 @@ */ import { createLogger } from '../logger'; +import type { SkillhubCatalogScope } from '../../shared/skillhubCatalog'; const log = createLogger('learn-host:hub-reference'); const MAX_REFERENCE_FILES = 40; @@ -33,9 +34,9 @@ interface HubFilesResult { } export interface HubSkillReferenceReader { - info(slug: string): Promise; - readPublishedFile(params: { name: string; path: string }): Promise; - getPublishedFiles(params: { name: string }): Promise; + info(slug: string, catalogScope?: SkillhubCatalogScope): Promise; + readPublishedFile(params: { name: string; path: string; catalogScope?: SkillhubCatalogScope }): Promise; + getPublishedFiles(params: { name: string; catalogScope?: SkillhubCatalogScope }): Promise; } export interface HubSkillReferenceOmission { @@ -54,12 +55,13 @@ export interface HubSkillReference { export async function fetchHubSkillReference( marketService: HubSkillReferenceReader, slug: string, + catalogScope?: SkillhubCatalogScope, ): Promise { - const infoRes = await marketService.info(slug); + const infoRes = await marketService.info(slug, catalogScope); if (!('info' in infoRes) || !infoRes.info) return null; const fileRes = await marketService - .readPublishedFile({ name: slug, path: 'SKILL.md' }) + .readPublishedFile({ name: slug, path: 'SKILL.md', catalogScope }) .catch(() => null); if (!fileRes || fileRes.file.truncated) return null; // 主 SKILL.md 同样吃单文件上限(Greptile review):server 未标 truncated 但超 @@ -83,7 +85,7 @@ export async function fetchHubSkillReference( } }; try { - const listing = await marketService.getPublishedFiles({ name: slug }); + const listing = await marketService.getPublishedFiles({ name: slug, catalogScope }); let consideredReferenceFiles = 0; for (const meta of listing.files) { if (meta.path === 'SKILL.md') continue; @@ -101,7 +103,7 @@ export async function fetchHubSkillReference( continue; } const one = await marketService - .readPublishedFile({ name: slug, path: meta.path }) + .readPublishedFile({ name: slug, path: meta.path, catalogScope }) .catch(() => null); if (!one) { pushOmission(meta.path, 'file read failed'); diff --git a/apps/desktop/src/main/learn-host/index.ts b/apps/desktop/src/main/learn-host/index.ts index 31bb6a163f7..fc2e3046058 100644 --- a/apps/desktop/src/main/learn-host/index.ts +++ b/apps/desktop/src/main/learn-host/index.ts @@ -64,7 +64,7 @@ export interface StartLearnHostDeps { onUndispatchedUserTurn?: (sessionId: string) => void; /** hub 源:拉市场 skill 详情 + 可用已发布文件(bootstrap 注入,/learn hub: * 与 skill hub「学习此技能」共用)。未注入时 hub 源请求报 INVALID_PARAMS(兜底)。 */ - fetchHubSkill?: (slug: string) => Promise<{ + fetchHubSkill?: (slug: string, catalogScope?: 'market' | 'team') => Promise<{ name: string; description: string; content: string; diff --git a/apps/desktop/src/main/skillhub/__tests__/infoMapping.test.ts b/apps/desktop/src/main/skillhub/__tests__/infoMapping.test.ts index 521b43dec88..e38ffc22041 100644 --- a/apps/desktop/src/main/skillhub/__tests__/infoMapping.test.ts +++ b/apps/desktop/src/main/skillhub/__tests__/infoMapping.test.ts @@ -19,13 +19,13 @@ describe('mapHubSkillInfoToDesktopInfo', () => { { slug: 'engine', name: 'Engine' }, { slug: 'office', name: 'Office' }, ], - tags: [{ slug: 'automation', name: 'Automation' }], + tags: [{ slug: 'automation', name: 'Automation', source: 'author' }], githubUrl: 'https://github.com/example/lark-task', stats: { downloads: 135 }, }, { catalogScope: 'market' }); expect(info.categories).toEqual(['engine', 'office']); - expect(info.tags).toEqual([{ slug: 'automation', name: 'Automation' }]); + expect(info.tags).toEqual([{ slug: 'automation', name: 'Automation', source: 'author' }]); expect(info.githubUrl).toBe('https://github.com/example/lark-task'); expect(info.icon).toBe('https://skillhub.example.test/assets/default-skill-icon-v4.svg'); expect(info.description).toBe('Market summary'); @@ -50,6 +50,22 @@ describe('mapHubSkillInfoToDesktopInfo', () => { expect(info.downloads).toBe(0); }); + it('keeps organization ownership and the member publisher as separate fields', () => { + const info = mapHubSkillInfoToDesktopInfo({ + slug: 'org-skill', + displayName: 'Org Skill', + description: 'Organization skill', + version: '1.0.0', + owner: { type: 'org', slug: 'acme', name: 'Acme' }, + publisher: { name: 'Cindy Publisher' }, + visibility: 'public', + updatedAt: '2026-09-02T00:00:00.000Z', + }); + + expect(info.authorName).toBe('Acme'); + expect(info.publisherName).toBe('Cindy Publisher'); + }); + it('preserves Hub ownership, visibility, and review status needed by My Published management', () => { const info = mapHubSkillInfoToDesktopInfo({ slug: 'review-helper', diff --git a/apps/desktop/src/main/skillhub/__tests__/marketService.test.ts b/apps/desktop/src/main/skillhub/__tests__/marketService.test.ts index 3f6f1abc752..0c95073c549 100644 --- a/apps/desktop/src/main/skillhub/__tests__/marketService.test.ts +++ b/apps/desktop/src/main/skillhub/__tests__/marketService.test.ts @@ -105,6 +105,28 @@ describe('SkillhubMarketService', () => { expect((fetchCalls[1]?.opts?.body as { slugs: string[] }).slugs).toEqual(['skill-100']); }); + it('groups batch sync by catalog scope and keeps same-slug results distinct', async () => { + const { fetch, calls } = makeFetch([ + { items: [makeHubSkill('same', { displayName: 'Market Same' })] }, + { items: [makeHubSkill('same', { displayName: 'Team Same', visibility: 'shared' })] }, + ]); + const service = new SkillhubMarketService({ fetch }); + + const result = await service.sync({ skills: [ + { slug: 'same', catalogScope: 'market' }, + { slug: 'same', catalogScope: 'team' }, + ] }); + + expect(calls.map(({ path }) => path)).toEqual([ + '/api/skills-hub/skills/batch-detail?scope=market', + '/api/skills-hub/skills/batch-detail?scope=team', + ]); + expect(result.results).toMatchObject([ + { name: 'same', displayName: 'Market Same', catalogScope: 'market' }, + { name: 'same', displayName: 'Team Same', catalogScope: 'team' }, + ]); + }); + it('lists my published skills through the user-published broker route', async () => { const { fetch, calls } = makeFetch([ { items: [makeHubSkill('mine', { isMine: false })], total: 25 }, @@ -269,12 +291,36 @@ describe('SkillhubMarketService', () => { ]); }); + it('updates installed scope for immediate moves and public-review transitions', async () => { + const updateRegistryCatalogScope = vi.fn(async () => undefined); + const { fetch } = makeFetch([ + { slug: 'demo', visibility: 'shared' }, + { slug: 'demo', visibility: 'private' }, + { slug: 'demo', visibility: 'shared', requestedVisibility: 'public', reviewStatus: 'pending' }, + ]); + const service = new SkillhubMarketService({ + fetch, + assertWriteAllowed: vi.fn(), + assertVisibilityAllowed: vi.fn(), + updateRegistryCatalogScope, + }); + + await service.setPublishedVisibility({ name: 'demo', visibility: 'shared' }); + await service.setPublishedVisibility({ name: 'demo', visibility: 'private' }); + await service.setPublishedVisibility({ name: 'demo', visibility: 'public' }); + + expect(updateRegistryCatalogScope).toHaveBeenNthCalledWith(1, 'demo', 'team'); + expect(updateRegistryCatalogScope).toHaveBeenNthCalledWith(2, 'demo', undefined); + expect(updateRegistryCatalogScope).toHaveBeenNthCalledWith(3, 'demo', undefined); + }); + it('maps categories and user departments into renderer result shapes', async () => { const { fetch, calls } = makeFetch([ [ { slug: 'devtools', name: 'DevTools', + source: 'platform', skillCount: 3, mySkillCount: 1, children: [ @@ -298,7 +344,7 @@ describe('SkillhubMarketService', () => { await expect(service.listCategories()).resolves.toEqual({ success: true, categories: [ - { slug: 'devtools', name: 'DevTools', count: 3, myCount: 1 }, + { slug: 'devtools', name: 'DevTools', count: 3, myCount: 1, source: 'platform' }, { slug: 'devtools/review', name: 'Review', count: 2, myCount: 1 }, { slug: 'writing', name: 'Writing', count: 0, myCount: 0 }, ], diff --git a/apps/desktop/src/main/skillhub/__tests__/publishService.test.ts b/apps/desktop/src/main/skillhub/__tests__/publishService.test.ts index 96792b6ba36..d6807fc034e 100644 --- a/apps/desktop/src/main/skillhub/__tests__/publishService.test.ts +++ b/apps/desktop/src/main/skillhub/__tests__/publishService.test.ts @@ -158,34 +158,6 @@ describe('SkillPublishService', () => { })).resolves.toEqual({ success: false, errorCode: 'INVALID_VISIBILITY' }); }); - it('rejects manual-category publish requests without a category before review or upload starts', async () => { - const { SkillPublishService } = await import('../publishService'); - const service = new SkillPublishService(); - const events: Array<{ phase: string; errorCode?: string; message?: string }> = []; - - const result = await service.publish( - { - absolutePath: '/tmp/skill', - name: 'lark-task', - isFirstPublish: true, - version: '1.0.0', - categoryMode: 'manual', - categories: [], - }, - (event) => events.push(event), - ); - - expect(result).toEqual({ success: false, errorCode: 'CATEGORY_REQUIRED' }); - expect(events).toEqual([ - { - phase: 'failed', - name: 'lark-task', - errorCode: 'CATEGORY_REQUIRED', - message: '请选择分类后再发布', - }, - ]); - }); - it('allows version publishes without category metadata and omits category fields from commit', async () => { writeApiKeyFile(); const skillPath = '/tmp/xdt-publish-service-test/skill'; @@ -267,8 +239,7 @@ describe('SkillPublishService', () => { version: '1.1.0', changelog: 'Update flow.', }); - expect(commitCall?.[1]?.body).not.toHaveProperty('categoryMode'); - expect(commitCall?.[1]?.body).not.toHaveProperty('categories'); + expect(commitCall?.[1]?.body).not.toHaveProperty('tags'); expect(commitCall?.[1]?.body).not.toHaveProperty('visibility'); expect(scanPollSpy).toHaveBeenCalledWith('lark-task', '1.1.0'); }); @@ -423,8 +394,7 @@ describe('SkillPublishService', () => { displayName: 'Lark Task', summary: 'Publish summary', visibility: 'PUBLIC', - categoryMode: 'manual', - categories: ['productivity'], + tags: ['Productivity'], }, () => {}, ); @@ -437,8 +407,7 @@ describe('SkillPublishService', () => { body: expect.objectContaining({ displayName: 'Lark Task', summary: 'Publish summary', - categories: ['productivity'], - categoryMode: 'manual', + tags: ['Productivity'], }), }); const commitCall = vi @@ -447,7 +416,7 @@ describe('SkillPublishService', () => { expect(commitCall?.[1]?.body).not.toHaveProperty('description'); }); - it('allows auto category mode and asks Hub to classify the skill', async () => { + it('allows a first publish without author tags', async () => { writeApiKeyFile(); fs.mkdirSync('/tmp/xdt-publish-service-test/skill', { recursive: true }); fs.writeFileSync( @@ -513,8 +482,7 @@ describe('SkillPublishService', () => { displayName: 'Lark Task', summary: 'Publish summary', visibility: 'PUBLIC', - categoryMode: 'auto', - categories: [], + tags: [], }, () => {}, ); @@ -525,8 +493,7 @@ describe('SkillPublishService', () => { baseUrl: expect.any(Function), logLabel: '/api/skills-hub', body: expect.objectContaining({ - categoryMode: 'auto', - categories: [], + tags: [], }), }); }); @@ -598,8 +565,7 @@ describe('SkillPublishService', () => { summary: 'Publish summary', visibility: 'PUBLIC', visibleSlugs: [], - categoryMode: 'manual', - categories: ['productivity'], + tags: ['Productivity'], }, () => {}, ); @@ -684,8 +650,7 @@ describe('SkillPublishService', () => { displayName: 'Lark Task', summary: 'Publish summary', visibility: 'PUBLIC', - categoryMode: 'manual', - categories: ['productivity'], + tags: ['Productivity'], }, () => {}, ); @@ -779,8 +744,7 @@ describe('SkillPublishService', () => { displayName: 'Lark Task', summary: 'Publish summary', visibility: 'PUBLIC', - categoryMode: 'manual', - categories: ['productivity'], + tags: ['Productivity'], }, (event) => events.push(event), ); @@ -798,7 +762,10 @@ describe('SkillPublishService', () => { expect(events).not.toContainEqual(expect.objectContaining({ phase: 'failed' })); }); - it('maps preserved Hub business error codes to actionable publish errors', async () => { + it.each([ + ['NAME_TAKEN', 409, '名字已被占用'], + ['INVALID_VISIBILITY', 400, '当前组织暂不支持组织或私有可见性,请选择公开发布'], + ])('maps preserved Hub business error %s to an actionable publish error', async (errorCode, statusCode, message) => { writeApiKeyFile(); fs.mkdirSync('/tmp/xdt-publish-service-test/skill', { recursive: true }); fs.writeFileSync( @@ -837,7 +804,7 @@ describe('SkillPublishService', () => { }; } if (apiPath === '/api/skills-hub/skills/publish/commit') { - throw new ServerApiError('NAME_TAKEN', 409, '名字已被占用'); + throw new ServerApiError(errorCode, statusCode, message); } throw new Error(`unexpected api path ${apiPath}`); }); @@ -853,14 +820,13 @@ describe('SkillPublishService', () => { displayName: 'Lark Task', summary: 'Publish summary', visibility: 'PUBLIC', - categoryMode: 'manual', - categories: ['productivity'], + tags: ['Productivity'], }, (event) => events.push(event), ); - expect(result).toEqual({ success: false, errorCode: 'NAME_TAKEN' }); - expect(events.at(-1)).toMatchObject({ phase: 'failed', errorCode: 'NAME_TAKEN' }); + expect(result).toEqual({ success: false, errorCode }); + expect(events.at(-1)).toMatchObject({ phase: 'failed', errorCode }); }); it('emits a failed progress event when packing throws unexpectedly', async () => { diff --git a/apps/desktop/src/main/skillhub/__tests__/syncMapping.test.ts b/apps/desktop/src/main/skillhub/__tests__/syncMapping.test.ts index 85f7b301d14..f321f5865a1 100644 --- a/apps/desktop/src/main/skillhub/__tests__/syncMapping.test.ts +++ b/apps/desktop/src/main/skillhub/__tests__/syncMapping.test.ts @@ -19,7 +19,7 @@ function makeHubSkill(slug: string, overrides: Partial = describe('buildSkillhubSyncResponse', () => { it('preserves availableUninstalledCount for empty local syncs', () => { expect(buildSkillhubSyncResponse([], [ - { items: [], availableCount: 7 }, + { catalogScope: 'market', response: { items: [], availableCount: 7 } }, ])).toEqual({ success: true, results: [], @@ -28,9 +28,12 @@ describe('buildSkillhubSyncResponse', () => { }); it('keeps the first available count from chunked batch-detail responses', () => { - const response = buildSkillhubSyncResponse(['skill-a', 'skill-b'], [ - { items: [makeHubSkill('skill-a')], availableCount: 2 }, - { items: [makeHubSkill('skill-b')], availableCount: 3 }, + const response = buildSkillhubSyncResponse([ + { slug: 'skill-a', catalogScope: 'market' }, + { slug: 'skill-b', catalogScope: 'team' }, + ], [ + { catalogScope: 'market', response: { items: [makeHubSkill('skill-a')], availableCount: 2 } }, + { catalogScope: 'team', response: { items: [makeHubSkill('skill-b')], availableCount: 3 } }, ]); expect(response.availableUninstalledCount).toBe(2); @@ -38,6 +41,7 @@ describe('buildSkillhubSyncResponse', () => { { exists: true, name: 'skill-a', + catalogScope: 'market', displayName: 'skill-a display', authorId: 'owner-skill-a', latestVersion: '1.0.0', @@ -45,6 +49,7 @@ describe('buildSkillhubSyncResponse', () => { { exists: true, name: 'skill-b', + catalogScope: 'team', displayName: 'skill-b display', authorId: 'owner-skill-b', latestVersion: '1.0.0', @@ -53,11 +58,11 @@ describe('buildSkillhubSyncResponse', () => { }); it('returns exists:false for local skills missing from Hub without inventing count metadata', () => { - expect(buildSkillhubSyncResponse(['missing-skill'], [ - { items: [] }, + expect(buildSkillhubSyncResponse([{ slug: 'missing-skill', catalogScope: 'team' }], [ + { catalogScope: 'team', response: { items: [] } }, ])).toEqual({ success: true, - results: [{ name: 'missing-skill', exists: false }], + results: [{ name: 'missing-skill', catalogScope: 'team', exists: false }], }); }); }); diff --git a/apps/desktop/src/main/skillhub/infoMapping.ts b/apps/desktop/src/main/skillhub/infoMapping.ts index 9b2a73f9e7d..af6dfc196ed 100644 --- a/apps/desktop/src/main/skillhub/infoMapping.ts +++ b/apps/desktop/src/main/skillhub/infoMapping.ts @@ -20,12 +20,14 @@ export interface HubSkillInfoForDesktop { folderHash?: string; fileHash?: string; owner: { type?: string; slug: string; name: string }; + publisher?: { name?: string }; visibility: string; moderationStatus?: string; updatedAt: string; isMine?: boolean; - categories?: Array<{ slug: string; name: string }>; - tags?: Array<{ slug: string; name: string }>; + canManage?: boolean; + categories?: Array<{ slug: string; name: string; source?: 'author' | 'platform' }>; + tags?: Array<{ slug: string; name: string; source?: 'author' | 'platform' }>; githubUrl?: string | null; stats?: { downloads?: number; @@ -45,8 +47,10 @@ export function mapHubSkillInfoToDesktopInfo(hub: HubSkillInfoForDesktop, opts?: description: hub.summary ?? hub.description ?? '', authorId: hub.owner.slug, authorName: hub.owner.name, + publisherName: hub.publisher?.name?.trim() || hub.owner.name, authorAvatarUrl: null as string | null, isMine: opts?.forceMine === true || hub.isMine === true, + canManage: hub.canManage === true, latestVersion: hub.version, folderHash: hub.folderHash ?? hub.fileHash, visibility: (hub.visibility === 'public' ? 'PUBLIC' : 'DEPARTMENT_SCOPED') as 'PUBLIC' | 'DEPARTMENT_SCOPED', @@ -60,7 +64,11 @@ export function mapHubSkillInfoToDesktopInfo(hub: HubSkillInfoForDesktop, opts?: visibilityReview: hub.visibilityReview, visibleDeptIds: [] as string[], categories: (hub.categories ?? []).map((category) => category.slug), - tags: (hub.tags ?? hub.categories ?? []).map((tag) => ({ slug: tag.slug, name: tag.name })), + tags: (hub.tags ?? hub.categories ?? []).map((tag) => ({ + slug: tag.slug, + name: tag.name, + ...(tag.source ? { source: tag.source } : {}), + })), githubUrl: hub.githubUrl, publishedAt: hub.updatedAt, downloads: Number.isFinite(hub.stats?.downloads) ? hub.stats?.downloads ?? 0 : 0, diff --git a/apps/desktop/src/main/skillhub/marketService.ts b/apps/desktop/src/main/skillhub/marketService.ts index ccf344b6699..c86c37ad2fe 100644 --- a/apps/desktop/src/main/skillhub/marketService.ts +++ b/apps/desktop/src/main/skillhub/marketService.ts @@ -1,12 +1,15 @@ import { ServerApiError, type ApiFetchOptions } from '../serverApiClient'; import { skillhubApiFetch } from './hubApi'; import { mapHubSkillInfoToDesktopInfo, type HubSkillInfoForDesktop } from './infoMapping'; -import { buildSkillhubSyncResponse, type SkillhubBatchDetailResponse } from './syncMapping'; +import { buildSkillhubSyncResponse, type SkillhubBatchDetailResponse, type SkillhubSyncRef } from './syncMapping'; import { assertSkillhubVisibilityAllowed, assertSkillhubWriteAllowed } from './identityPolicy'; -import { withSkillhubCatalogScope, type SkillhubCatalogScope } from '../../shared/skillhubCatalog'; +import { registryService } from './registry'; +import { createLogger } from '../logger'; +import { skillhubCatalogKey, withSkillhubCatalogScope, type SkillhubCatalogScope } from '../../shared/skillhubCatalog'; const SKILLHUB_SYNC_BATCH_SIZE = 100; const HUB_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,127}$/; +const log = createLogger('skillhub:marketService'); export type SkillhubMarketFetcher = (apiPath: string, opts?: Omit) => Promise; @@ -42,6 +45,7 @@ export interface SkillhubMarketServiceOptions { fetch?: SkillhubMarketFetcher; assertWriteAllowed?: () => void | Promise; assertVisibilityAllowed?: (visibility: 'private' | 'shared' | 'public') => void | Promise; + updateRegistryCatalogScope?: (name: string, scope: SkillhubCatalogScope | undefined) => Promise; } export interface ListMarketParams { @@ -60,7 +64,7 @@ export interface UpdatePublishedFields { displayName?: string; summary?: string; description?: string; - categories?: string[]; + tags?: string[]; visibility?: 'private' | 'shared' | 'public'; /** 归属统一参数:团队 slug / od- 部门 id;null = 收回到个人 */ teamSlug?: string | null; @@ -90,19 +94,32 @@ export class SkillhubMarketService { private readonly fetch: SkillhubMarketFetcher; private readonly assertWriteAllowed: () => void | Promise; private readonly assertVisibilityAllowed: (visibility: 'private' | 'shared' | 'public') => void | Promise; + private readonly updateRegistryCatalogScope: (name: string, scope: SkillhubCatalogScope | undefined) => Promise; constructor(options: SkillhubMarketServiceOptions = {}) { this.fetch = options.fetch ?? skillhubApiFetch; this.assertWriteAllowed = options.assertWriteAllowed ?? assertSkillhubWriteAllowed; this.assertVisibilityAllowed = options.assertVisibilityAllowed ?? assertSkillhubVisibilityAllowed; + this.updateRegistryCatalogScope = options.updateRegistryCatalogScope + ?? registryService.updateCatalogScopeForSkill; } - async sync(params: { slugs?: string[] } | undefined) { - const names = normalizeSkillhubSlugs(params?.slugs); - const hubSlugs = names.filter(isValidHubSlug); - const detailBatches = hubSlugs.length === 0 ? [[]] : chunkSkillhubSlugs(hubSlugs); - const detailResponses = await Promise.all(detailBatches.map((batch) => this.fetchSkillhubBatchDetail(batch))); - return buildSkillhubSyncResponse(names, detailResponses); + async sync(params: { skills?: unknown; slugs?: string[] } | undefined) { + const refs = normalizeSkillhubSyncRefs(params?.skills ?? params?.slugs); + const grouped = new Map(); + for (const ref of refs.filter(({ slug }) => isValidHubSlug(slug))) { + const scope = ref.catalogScope; + grouped.set(scope, [...(grouped.get(scope) ?? []), ref.slug]); + } + const batches = [...grouped.entries()].flatMap(([catalogScope, slugs]) => + chunkSkillhubSlugs(slugs).map((batch) => ({ catalogScope, slugs: batch })) + ); + const requests = batches.length > 0 ? batches : [{ catalogScope: 'market' as const, slugs: [] }]; + const detailResponses = await Promise.all(requests.map(async ({ catalogScope, slugs }) => ({ + catalogScope, + response: await this.fetchSkillhubBatchDetail(slugs, catalogScope), + }))); + return buildSkillhubSyncResponse(refs, detailResponses); } async listMarket(params: ListMarketParams | undefined) { @@ -234,6 +251,13 @@ export class SkillhubMarketService { }, }, ); + // A public-review request has already moved the user's management view to + // the native record even while the old catalog visibility remains active. + const targetVisibility = result.requestedVisibility ?? result.visibility; + const catalogScope = targetVisibility === 'shared' ? 'team' as const : undefined; + await this.updateRegistryCatalogScope(name, catalogScope).catch((err) => { + log.warn(`[visibility] registry catalog scope update failed name=${name}:`, err); + }); return { success: true as const, result }; } @@ -267,6 +291,7 @@ export class SkillhubMarketService { name: string; skillCount?: number; mySkillCount?: number; + source?: 'author' | 'platform'; children?: Array<{ slug: string; name: string; @@ -303,8 +328,8 @@ export class SkillhubMarketService { return { success: true as const, ...result }; } - private fetchSkillhubBatchDetail(slugs: string[]): Promise { - return this.fetch('/api/skills-hub/skills/batch-detail', { + private fetchSkillhubBatchDetail(slugs: string[], catalogScope?: SkillhubCatalogScope): Promise { + return this.fetch(withSkillhubCatalogScope('/api/skills-hub/skills/batch-detail', catalogScope), { method: 'POST', body: { slugs }, }); @@ -323,6 +348,23 @@ export function normalizeSkillhubSlugs(slugs: unknown): string[] { ))]; } +export function normalizeSkillhubSyncRefs(items: unknown): SkillhubSyncRef[] { + const refs = Array.isArray(items) ? items : []; + const byKey = new Map(); + for (const item of refs) { + const raw = typeof item === 'string' ? { slug: item } : item; + if (!raw || typeof raw !== 'object') continue; + const slug = (raw as { slug?: unknown }).slug; + if (typeof slug !== 'string' || slug.length === 0 || slug.length > 128) continue; + const candidateScope = (raw as { catalogScope?: unknown }).catalogScope; + const catalogScope = candidateScope === 'team' || candidateScope === 'market' + ? candidateScope + : undefined; + byKey.set(skillhubCatalogKey(slug, catalogScope), { slug, catalogScope }); + } + return [...byKey.values()]; +} + function isValidHubSlug(slug: string): boolean { return HUB_SLUG_RE.test(slug); } @@ -354,17 +396,25 @@ type HubCategoryNode = { name: string; skillCount?: number; mySkillCount?: number; + source?: 'author' | 'platform'; children?: HubCategoryNode[]; }; function flattenHubCategories(nodes: HubCategoryNode[]) { - const out: Array<{ slug: string; name: string; count: number; myCount: number }> = []; + const out: Array<{ + slug: string; + name: string; + count: number; + myCount: number; + source?: 'author' | 'platform'; + }> = []; const visit = (node: HubCategoryNode) => { out.push({ slug: node.slug, name: node.name, count: node.skillCount ?? 0, myCount: node.mySkillCount ?? 0, + source: node.source, }); for (const child of node.children ?? []) visit(child); }; diff --git a/apps/desktop/src/main/skillhub/publishService.ts b/apps/desktop/src/main/skillhub/publishService.ts index 6e8d605e1dd..4da96826c94 100644 --- a/apps/desktop/src/main/skillhub/publishService.ts +++ b/apps/desktop/src/main/skillhub/publishService.ts @@ -20,6 +20,7 @@ import { writeSnapshot } from './snapshot'; import { pack } from './zipPacker'; import type { PackResult } from './zipPacker'; import { registryService } from './registry'; +import type { SkillhubCatalogScope } from '../../shared/skillhubCatalog'; import { getCurrentDataOwnerId, getCurrentUserId } from '../authManager'; import { getAppCapabilities } from '../appCapabilities.js'; import { currentSkillhubIdentityPolicy } from './identityPolicy'; @@ -45,8 +46,7 @@ export interface PublishParams { deptTeamSlug?: string; /** 发布者为普通团队时的团队归属 slug */ teamSlug?: string; - categoryMode?: 'auto' | 'manual'; - categories?: string[]; + tags?: string[]; changelog?: string; } @@ -139,6 +139,7 @@ function serverErrorToCode(err: unknown): PublishErrorCode { if (code === 'VERSION_RACE') return 'VERSION_RACE'; if (code === 'CHECKSUM_MISMATCH') return 'CHECKSUM_MISMATCH'; if (code === 'NOT_AUTHOR') return 'NOT_AUTHOR'; + if (code === 'INVALID_VISIBILITY') return 'INVALID_VISIBILITY'; if (code === 'OSS_OBJECT_NOT_FOUND') return 'OSS_OBJECT_NOT_FOUND'; if (err.message.includes('manifest') || err.message.includes('frontmatter')) return 'MANIFEST_INVALID'; @@ -152,8 +153,8 @@ function unhandledPublishErrorToCode(err: unknown): PublishErrorCode { return 'INTERNAL'; } -function normalizePublishCategories(categories?: string[]): string[] { - return [...new Set((categories ?? []).map((category) => category.trim()).filter(Boolean))]; +function normalizePublishTags(tags?: string[]): string[] { + return [...new Set((tags ?? []).map((tag) => tag.trim()).filter(Boolean))]; } const PASSING_SCAN_STATUSES = new Set(['pass', 'passed', 'approved', 'published']); @@ -177,6 +178,7 @@ async function syncPublishedRegistry( absolutePath: string, version: string, folderHash: string, + catalogScope?: SkillhubCatalogScope | null, ): Promise { const nowSec = Math.floor(Date.now() / 1000); const myUserId = getCurrentUserId() ?? ''; @@ -188,6 +190,7 @@ async function syncPublishedRegistry( updatedAt: nowSec, authorId: myUserId, origin: 'published', + ...(catalogScope !== undefined ? { catalogScope: catalogScope ?? undefined } : {}), }); } else { await registryService.addInstall(slug, absolutePath, { @@ -197,6 +200,7 @@ async function syncPublishedRegistry( installedAt: nowSec, updatedAt: nowSec, origin: 'published', + ...(catalogScope ? { catalogScope } : {}), }); } } @@ -304,20 +308,7 @@ export class SkillPublishService { return { success: false, errorCode: 'INTERNAL' }; } - const categoryMode = params.isFirstPublish ? (params.categoryMode ?? 'manual') : undefined; - const categories = categoryMode === 'auto' ? [] : normalizePublishCategories(params.categories); - if (params.isFirstPublish && categoryMode === 'manual' && categories.length === 0) { - this.emitProgress( - { - phase: 'failed', - name: params.name, - errorCode: 'CATEGORY_REQUIRED', - message: '请选择分类后再发布', - }, - onProgress, - ); - return { success: false, errorCode: 'CATEGORY_REQUIRED' }; - } + const tags = params.isFirstPublish ? normalizePublishTags(params.tags) : undefined; const abortController = new AbortController(); const state: InternalState = { abortController }; @@ -576,8 +567,7 @@ export class SkillPublishService { if (params.summary) commitBody.summary = params.summary; if (params.description) commitBody.description = params.description; if (params.isFirstPublish) { - commitBody.categories = categories; - commitBody.categoryMode = categoryMode; + commitBody.tags = tags; commitBody.visibility = params.visibility === 'PUBLIC' ? 'public' @@ -613,6 +603,11 @@ export class SkillPublishService { params.absolutePath, publishedVersion, folderHash, + params.isFirstPublish + ? params.visibility === 'DEPARTMENT_SCOPED' + ? 'team' + : null + : undefined, ).catch((err) => log.warn('[publish] registry sync failed (non-fatal):', err)); this.emitProgress( diff --git a/apps/desktop/src/main/skillhub/registry/__tests__/manifestIO.test.ts b/apps/desktop/src/main/skillhub/registry/__tests__/manifestIO.test.ts index 29b425a4eee..62da9edfb39 100644 --- a/apps/desktop/src/main/skillhub/registry/__tests__/manifestIO.test.ts +++ b/apps/desktop/src/main/skillhub/registry/__tests__/manifestIO.test.ts @@ -54,6 +54,7 @@ import type { StoredManifest } from '../types.js'; function makeManifest(skillName: string, overrides?: Partial): StoredManifest { return { schemaVersion: 1, + catalogScopeMigrated: true, skillName, installs: { [path.normalize(`/home/sam/.claude/skills/${skillName}`)]: { @@ -62,6 +63,7 @@ function makeManifest(skillName: string, overrides?: Partial): S folderHash: 'abc123', installedAt: 1714000000, updatedAt: 1714000000, + catalogScope: 'team', }, }, ...overrides, @@ -90,7 +92,7 @@ describe('manifestIO', () => { expect(result).toBeNull(); }); - it('老 manifest 缺 authorId 字段 → 读取后兜底为空串,旧 isMine 字段被剥离', async () => { + it('旧 registry 原子回填 authorId 与历史 XD 目录作用域并剥离 isMine', async () => { const root = manifestsRoot(); fs.mkdirSync(root, { recursive: true }); // 模拟一份只含 isMine 没 authorId 的老数据 @@ -114,9 +116,17 @@ describe('manifestIO', () => { ); const result = await readFile('legacy-skill'); expect(result).not.toBeNull(); + expect(result!.catalogScopeMigrated).toBe(true); const installEntry = result!.installs[path.normalize('/home/sam/.claude/skills/legacy-skill')]; expect(installEntry.authorId).toBe(''); + expect(installEntry.catalogScope).toBe('team'); expect((installEntry as unknown as Record).isMine).toBeUndefined(); + const persisted = JSON.parse(fs.readFileSync(path.join(root, 'legacy-skill.json'), 'utf-8')); + expect(persisted.installs[path.normalize('/home/sam/.claude/skills/legacy-skill')]).toMatchObject({ + authorId: '', + catalogScope: 'team', + }); + expect(persisted.catalogScopeMigrated).toBe(true); }); it('skillName 字段不符 → 抛 RegistryError CORRUPTED', async () => { diff --git a/apps/desktop/src/main/skillhub/registry/__tests__/registryService.test.ts b/apps/desktop/src/main/skillhub/registry/__tests__/registryService.test.ts index 3a9d1a974c3..0894ae129c7 100644 --- a/apps/desktop/src/main/skillhub/registry/__tests__/registryService.test.ts +++ b/apps/desktop/src/main/skillhub/registry/__tests__/registryService.test.ts @@ -38,6 +38,7 @@ let removeInstall: typeof import('../registryService.js').removeInstall; let readManifest: typeof import('../registryService.js').readManifest; let getInstall: typeof import('../registryService.js').getInstall; let listAllInstalls: typeof import('../registryService.js').listAllInstalls; +let updateCatalogScopeForSkill: typeof import('../registryService.js').updateCatalogScopeForSkill; beforeEach(async () => { vi.resetModules(); @@ -48,6 +49,7 @@ beforeEach(async () => { readManifest = svc.readManifest; getInstall = svc.getInstall; listAllInstalls = svc.listAllInstalls; + updateCatalogScopeForSkill = svc.updateCatalogScopeForSkill; }); import type { StoredInstall, StoredManifest } from '../types.js'; @@ -101,6 +103,7 @@ describe('addInstall', () => { const manifest = expectManifest(await readManifest('my-skill')); expect(manifest.skillName).toBe('my-skill'); expect(manifest.schemaVersion).toBe(1); + expect(manifest.catalogScopeMigrated).toBe(true); const normalizedPath = path.normalize(globalPath); expect(manifest.installs[normalizedPath]).toEqual(entry); }); @@ -188,6 +191,16 @@ describe('updateInstall', () => { expect(readJson(backupManifestPath('my-skill'))).toEqual(readJson(manifestPath('my-skill'))); }); + it('可见性迁移可清除目录作用域且不会被旧数据迁移再次回填', async () => { + await addInstall('my-skill', globalPath, makeEntry({ catalogScope: 'market' })); + + await updateCatalogScopeForSkill('my-skill', undefined); + + expect((await getInstall('my-skill', globalPath))?.catalogScope).toBeUndefined(); + expect((await readManifest('my-skill'))?.catalogScopeMigrated).toBe(true); + expect((await getInstall('my-skill', globalPath))?.catalogScope).toBeUndefined(); + }); + it('不存在的 installPath → 抛 REGISTRY_IO_FAILED', async () => { await expect( updateInstall('my-skill', '/nonexistent/path', { version: '2' }), diff --git a/apps/desktop/src/main/skillhub/registry/manifestIO.ts b/apps/desktop/src/main/skillhub/registry/manifestIO.ts index 554beba1e5b..1e0a508a0d6 100644 --- a/apps/desktop/src/main/skillhub/registry/manifestIO.ts +++ b/apps/desktop/src/main/skillhub/registry/manifestIO.ts @@ -6,8 +6,9 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { app } from 'electron'; -import { RegistryError, type StoredInstall, type StoredManifest } from './types.js'; +import { RegistryError, type StoredManifest } from './types.js'; import { sanitizeSkillName } from './derivations.js'; +import { migrateStoredManifest } from './migrations.js'; import { createLogger, maskPath } from '../../logger'; @@ -43,16 +44,9 @@ export async function readFile(skillName: string): Promise = {}; + const shouldBackfillCatalogScope = manifest.catalogScopeMigrated !== true; + + for (const [installPath, rawEntry] of Object.entries(manifest.installs ?? {})) { + const entry = { ...rawEntry } as StoredInstall & { isMine?: unknown }; + if (typeof entry.authorId !== 'string') { + entry.authorId = ''; + changed = true; + } + if ('isMine' in entry) { + delete entry.isMine; + changed = true; + } + if (shouldBackfillCatalogScope && !entry.catalogScope) { + entry.catalogScope = 'team'; + changed = true; + } + installs[installPath] = entry; + } + + if (shouldBackfillCatalogScope) changed = true; + + return changed + ? { manifest: { ...manifest, catalogScopeMigrated: true, installs }, changed: true } + : { manifest, changed: false }; +} diff --git a/apps/desktop/src/main/skillhub/registry/registryService.ts b/apps/desktop/src/main/skillhub/registry/registryService.ts index 7085a3b06c7..11a3ddea682 100644 --- a/apps/desktop/src/main/skillhub/registry/registryService.ts +++ b/apps/desktop/src/main/skillhub/registry/registryService.ts @@ -9,6 +9,7 @@ import { RegistryError, type StoredInstall, type StoredManifest } from './types. import * as manifestIO from './manifestIO.js'; import { sanitizeSkillName } from './derivations.js'; import { withLock } from './lock.js'; +import { migrateStoredManifest } from './migrations.js'; import { createLogger } from '../../logger'; @@ -51,14 +52,7 @@ async function readBackupManifest(skillName: string): Promise>, + partial: Partial>, ): Promise { const normalizedPath = path.normalize(installPath); @@ -261,3 +256,15 @@ export async function listAllInstalls(): Promise< } return result; } + +/** Re-point every local install of a slug after the server moves it between catalogs. */ +export async function updateCatalogScopeForSkill( + skillName: string, + catalogScope: StoredInstall['catalogScope'], +): Promise { + const installs = (await listAllInstalls()).filter((item) => item.skillName === skillName); + await Promise.all(installs.map(({ installPath }) => updateInstall(skillName, installPath, { + catalogScope, + updatedAt: Math.floor(Date.now() / 1000), + }))); +} diff --git a/apps/desktop/src/main/skillhub/registry/types.ts b/apps/desktop/src/main/skillhub/registry/types.ts index a123b819319..9c2b4bc5584 100644 --- a/apps/desktop/src/main/skillhub/registry/types.ts +++ b/apps/desktop/src/main/skillhub/registry/types.ts @@ -38,6 +38,8 @@ export interface StoredInstall { export interface StoredManifest { schemaVersion: 1; + /** catalogScope 缺省值迁移标记。旧客户端忽略并在 spread 写回时保留。 */ + catalogScopeMigrated?: true; /** 自校验:必须等于文件名(去 .json)。不一致即抛 RegistryCorruptedError。 */ skillName: string; /** key = path.normalize 后的绝对 installPath。 */ diff --git a/apps/desktop/src/main/skillhub/syncMapping.ts b/apps/desktop/src/main/skillhub/syncMapping.ts index 17ed7c41eaa..ffaa13129eb 100644 --- a/apps/desktop/src/main/skillhub/syncMapping.ts +++ b/apps/desktop/src/main/skillhub/syncMapping.ts @@ -1,4 +1,10 @@ import { mapHubSkillInfoToDesktopInfo, type HubSkillInfoForDesktop } from './infoMapping'; +import { skillhubCatalogKey, type SkillhubCatalogScope } from '../../shared/skillhubCatalog'; + +export interface SkillhubSyncRef { + slug: string; + catalogScope?: SkillhubCatalogScope; +} export interface SkillhubBatchDetailResponse { items?: HubSkillInfoForDesktop[]; @@ -6,24 +12,33 @@ export interface SkillhubBatchDetailResponse { } export function buildSkillhubSyncResponse( - names: string[], - detailResponses: SkillhubBatchDetailResponse[], + refs: SkillhubSyncRef[], + detailResponses: Array<{ catalogScope?: SkillhubCatalogScope; response: SkillhubBatchDetailResponse }>, ) { const mappedBySlug = new Map>(); let availableUninstalledCount: number | undefined; - for (const resp of detailResponses) { + for (const { catalogScope, response: resp } of detailResponses) { if (availableUninstalledCount === undefined && typeof resp.availableCount === 'number') { availableUninstalledCount = resp.availableCount; } for (const hub of resp.items ?? []) { - mappedBySlug.set(hub.slug, mapHubSkillInfoToDesktopInfo(hub)); + mappedBySlug.set( + skillhubCatalogKey(hub.slug, catalogScope), + mapHubSkillInfoToDesktopInfo(hub, { catalogScope }), + ); } } - const results = names.map((name) => { - const mapped = mappedBySlug.get(name); - return mapped ? { exists: true as const, ...mapped } : { name, exists: false as const }; + const results = refs.map(({ slug, catalogScope }) => { + const mapped = mappedBySlug.get(skillhubCatalogKey(slug, catalogScope)); + return mapped + ? { exists: true as const, ...mapped } + : { + name: slug, + ...(catalogScope ? { catalogScope } : {}), + exists: false as const, + }; }); return { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 0d8d051264f..e5d5d5aaa0f 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2938,6 +2938,7 @@ contextBridge.exposeInMainWorld('electronAPI', { | string[] | { slugs?: string[]; + skills?: Array<{ slug: string; catalogScope?: 'market' | 'team' }>; }, ): Promise<{ success: boolean; @@ -2969,8 +2970,10 @@ contextBridge.exposeInMainWorld('electronAPI', { description: string; authorId: string; authorName: string; + publisherName?: string; authorAvatarUrl: string | null; isMine: boolean; + canManage: boolean; latestVersion: string; visibility: 'PUBLIC' | 'DEPARTMENT_SCOPED'; publishedVisibility?: 'private' | 'shared' | 'public'; @@ -2988,7 +2991,7 @@ contextBridge.exposeInMainWorld('electronAPI', { }; visibleDeptIds: string[]; categories?: string[]; - tags?: Array<{ slug: string; name: string }>; + tags?: Array<{ slug: string; name: string; source?: 'author' | 'platform' }>; githubUrl?: string | null; publishedAt: string; downloads: number; @@ -3053,7 +3056,7 @@ contextBridge.exposeInMainWorld('electronAPI', { displayName?: string; summary?: string; description?: string; - categories?: string[]; + tags?: string[]; visibility?: 'private' | 'shared' | 'public'; /** 归属统一参数:团队 slug / od- 部门 id;null = 收回到个人 */ teamSlug?: string | null; @@ -3214,8 +3217,7 @@ contextBridge.exposeInMainWorld('electronAPI', { displayName?: string; summary?: string; description?: string; - categoryMode?: 'auto' | 'manual'; - categories?: string[]; + tags?: string[]; visibility?: 'PUBLIC' | 'DEPARTMENT_SCOPED' | 'PRIVATE'; visibleSlugs?: string[]; deptTeamSlug?: string; diff --git a/apps/desktop/src/renderer/features/learn/LearnStatusCard.tsx b/apps/desktop/src/renderer/features/learn/LearnStatusCard.tsx index 036422fb273..517808a2a7c 100644 --- a/apps/desktop/src/renderer/features/learn/LearnStatusCard.tsx +++ b/apps/desktop/src/renderer/features/learn/LearnStatusCard.tsx @@ -151,7 +151,7 @@ export function LearnStatusCard({ data, contextSessionId }: LearnStatusCardProps {run.status === 'awaiting-review' && !isOriginCard ? t('learn.card.continueHint') : run.sourceKind === 'hub' && run.hubSlug - ? `hub:${run.hubSlug}` + ? `hub:${run.hubCatalogScope ? `${run.hubCatalogScope}:` : ''}${run.hubSlug}` : run.sourceKind === 'session' ? t('learn.card.fromConversation') : run.input} diff --git a/apps/desktop/src/renderer/features/skillhub/PublishDialog.tsx b/apps/desktop/src/renderer/features/skillhub/PublishDialog.tsx index 0a2ffd9f73c..545e438499f 100644 --- a/apps/desktop/src/renderer/features/skillhub/PublishDialog.tsx +++ b/apps/desktop/src/renderer/features/skillhub/PublishDialog.tsx @@ -556,6 +556,10 @@ export function PublishDialog({ categories: [], error: null, }); + const editableCategories = useMemo( + () => categoryState.categories.filter((category) => category.source === 'author'), + [categoryState.categories], + ); const loadCategories = useCallback(async () => { setCategoryState({ loading: true, categories: [], error: null }); @@ -695,7 +699,7 @@ export function PublishDialog({ ? validateRequiredCategory({ loading: categoryState.loading, error: categoryState.error, - categories: categoryState.categories, + categories: editableCategories, categoryMode: form.categoryMode, selectedSlug: form.categorySlug, }) @@ -719,8 +723,9 @@ export function PublishDialog({ submitName, isFirstPublish: effectiveFirstPublish, ownerType: identityPolicy.ownerType, + categories: editableCategories, }), - [form, effectiveFirstPublish, identityPolicy.ownerType], + [form, effectiveFirstPublish, identityPolicy.ownerType, editableCategories], ); const runPublish = useCallback((params: SkillhubPublishParams) => { @@ -1090,7 +1095,7 @@ export function PublishDialog({ value: AUTO_CATEGORY_VALUE, label: t('skillhub.publishDialog.categoryAuto'), }, - ...categoryState.categories.map((category) => ({ + ...editableCategories.map((category) => ({ value: category.slug, label: category.name, })), diff --git a/apps/desktop/src/renderer/features/skillhub/SkillhubDetailView.tsx b/apps/desktop/src/renderer/features/skillhub/SkillhubDetailView.tsx index e2c32814e3a..1f77db6c276 100644 --- a/apps/desktop/src/renderer/features/skillhub/SkillhubDetailView.tsx +++ b/apps/desktop/src/renderer/features/skillhub/SkillhubDetailView.tsx @@ -1049,6 +1049,10 @@ export function SkillhubDetailView() { () => (entry?.name ? getCachedInfo(entry.name, entryCatalogScope) : null), ); const [infoLoading, setInfoLoading] = useState(false); + const [publishTargetInfo, setPublishTargetInfo] = useState( + () => (entry?.name && entryCatalogScope === 'team' ? getCachedInfo(entry.name) : null), + ); + const [publishTargetLoading, setPublishTargetLoading] = useState(false); const [infoFetchTrigger, setInfoFetchTrigger] = useState(0); // 同步重置:entry.name 变化时立刻把 infoResult 切到新 name 的缓存值。 @@ -1064,6 +1068,9 @@ export function SkillhubDetailView() { setTrackedEntryInfoKey(entryInfoKey); const cached = entry?.name ? getCachedInfo(entry.name, entryCatalogScope) : null; setInfoResult(cached); + setPublishTargetInfo( + entry?.name && entryCatalogScope === 'team' ? getCachedInfo(entry.name) : null, + ); setLiveScanStatus(null); // 有缓存就不显示 loading(SWR 后台静默刷),没缓存才进 loading 态 setInfoLoading(isSkill && entry != null && cached === null); @@ -1141,6 +1148,28 @@ export function SkillhubDetailView() { return () => { cancelled = true; }; }, [remoteInfoRequest, refreshRemoteInfo]); + // 组织目录条目可能来自只读代理,而发布始终写入原生 Hub。用无 scope + // 详情独立读取写入目标,避免拿组织目录的同名 Skill 判断首发/升级。 + useEffect(() => { + if (!remoteInfoRequest || remoteInfoRequest.catalogScope !== 'team') { + setPublishTargetLoading(false); + return; + } + let cancelled = false; + setPublishTargetLoading(getCachedInfo(remoteInfoRequest.name) === null); + refreshInfo(remoteInfoRequest.name) + .then((info) => { + if (!cancelled) setPublishTargetInfo(info); + }) + .catch((err) => { + log.warn(`[DetailView/publish-target] getInfo failed name=${remoteInfoRequest.name}`, err); + }) + .finally(() => { + if (!cancelled) setPublishTargetLoading(false); + }); + return () => { cancelled = true; }; + }, [remoteInfoRequest]); + // Local folder hash — only meaningful for skill kind. // force=true:每次切换 entry 都强制重算,bypass 30s cache, // 与下方 getInfo 一起组成"切换 entry 时的原子刷新"。 @@ -1228,11 +1257,13 @@ export function SkillhubDetailView() { latestVersion: liveScanStatus.version, } : null; - const reviewVersion = activePublishedReviewVersion(infoResult) ?? activePublishedReviewVersion(liveScanSource); - const isPublishedReviewing = isEffectiveActivePublishedReview(infoResult) || isEffectiveActivePublishedReview(liveScanSource); - const publishedStatus = effectivePublishedStatus(infoResult) ?? effectivePublishedStatus(liveScanSource); + const effectivePublishInfo = entryCatalogScope === 'team' ? publishTargetInfo : infoResult; + const effectivePublishLoading = entryCatalogScope === 'team' ? publishTargetLoading : infoLoading; + const reviewVersion = activePublishedReviewVersion(effectivePublishInfo) ?? activePublishedReviewVersion(liveScanSource); + const isPublishedReviewing = isEffectiveActivePublishedReview(effectivePublishInfo) || isEffectiveActivePublishedReview(liveScanSource); + const publishedStatus = effectivePublishedStatus(effectivePublishInfo) ?? effectivePublishedStatus(liveScanSource); const publishDialogPendingVersion = - infoResult?.pendingVersion ?? + effectivePublishInfo?.pendingVersion ?? (reviewVersion && publishedStatus ? { version: reviewVersion, status: publishedStatus } : null); @@ -1240,7 +1271,7 @@ export function SkillhubDetailView() { // 按钮区/banner 数据就绪:info 落定 + hash 算完即可,不再依赖批量 sync。 // 仅 isSkill 场景需要 hash;command/agent 直接视为 ready。 const hashReady = !isSkill || (!hashLoading && localFolderHash !== null); - const detailReady = !isSkill || (!infoLoading && hashReady); + const detailReady = !isSkill || (!infoLoading && !effectivePublishLoading && hashReady); // 三维度 detail state const marketDeleted = !infoLoading && checkMarketDeleted(entry?.name ?? '', entryCatalogScope); @@ -1248,6 +1279,11 @@ export function SkillhubDetailView() { const state = deriveDetailState(isSkill ? entry : null, infoResult, marketDeleted); return state; }, [isSkill, entry, infoResult, marketDeleted]); + const publishTargetDeleted = !effectivePublishLoading + && checkMarketDeleted(entry?.name ?? '', entryCatalogScope === 'team' ? undefined : entryCatalogScope); + const publishDetailState = useMemo(() => ( + deriveDetailState(isSkill ? entry : null, effectivePublishInfo, publishTargetDeleted) + ), [isSkill, entry, effectivePublishInfo, publishTargetDeleted]); // ── 从 detailState 派生互斥的 UI action state ── const registryEntry = entry?.registryEntry ?? null; @@ -1258,8 +1294,9 @@ export function SkillhubDetailView() { localFolderHash, publishedStatus, identityPolicy.canWrite, + publishDetailState, ), - [detailState, registryEntry, localFolderHash, publishedStatus, identityPolicy.canWrite], + [detailState, registryEntry, localFolderHash, publishedStatus, identityPolicy.canWrite, publishDetailState], ); const detailAction = detailActionState?.status ?? null; const isOutdated = detailActionState?.isOutdated ?? false; @@ -1875,7 +1912,7 @@ export function SkillhubDetailView() { ); })} - {detailState?.isMine && publishedStatus === 'rejected' && ( + {publishDetailState?.canManage && publishedStatus === 'rejected' && (