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..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, @@ -104,6 +106,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', @@ -257,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[] }; @@ -1405,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/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/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 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__/hubApi.test.ts b/apps/desktop/src/main/skillhub/__tests__/hubApi.test.ts index d24ad3532c2..c0068225025 100644 --- a/apps/desktop/src/main/skillhub/__tests__/hubApi.test.ts +++ b/apps/desktop/src/main/skillhub/__tests__/hubApi.test.ts @@ -7,16 +7,30 @@ 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', +vi.mock('../../serverApiClient', () => ({ + ServerApiError: class ServerApiError extends Error { + constructor( + public readonly code: string, + public readonly statusCode: number, + message: string, + ) { + super(message); + this.name = 'ServerApiError'; + } + }, + serverApiFetch: mocks.serverApiFetch, })); +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 +39,21 @@ describe('skillhubApiFetch', () => { expect(opts.logLabel).toBe('/api/skills-hub'); // 不设 redactErrorDetails:SkillHub 依赖 ServerApiError.code 做业务分支,不能把 code 压成通用码。 expect(opts.redactErrorDetails).toBeUndefined(); + expect(endpoint).toHaveBeenCalledTimes(1); + expect(opts.baseUrl?.()).toBe('https://skills.example.com'); + expect(endpoint).toHaveBeenCalledTimes(2); + expect(endpoint).toHaveBeenCalledWith('cindySkillHubApiBaseUrl'); + }); + + it('缺失 Cindy Skill Hub 端点时关闭云端能力且不发起相对请求', async () => { + endpoint.mockReturnValueOnce(''); + + await expect(skillhubApiFetch('/api/skills-hub/skills')).rejects.toMatchObject({ + name: 'ServerApiError', + code: 'UNSUPPORTED_CAPABILITY', + statusCode: 0, + }); + expect(mocks.serverApiFetch).not.toHaveBeenCalled(); }); 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..e38ffc22041 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-v4.svg', displayName: 'Lark Task', summary: 'Market summary', description: 'Manage tasks', @@ -18,12 +19,18 @@ describe('mapHubSkillInfoToDesktopInfo', () => { { slug: 'engine', name: 'Engine' }, { slug: 'office', name: 'Office' }, ], + 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', 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'); expect(info.downloads).toBe(135); + expect(info.catalogScope).toBe('market'); }); it('falls back to Hub description when summary is absent', () => { @@ -43,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', @@ -85,6 +108,31 @@ describe('mapHubSkillInfoToDesktopInfo', () => { expect(info.moderationStatus).toBe('published'); }); + it('preserves an independent public visibility review on the current version', () => { + const info = mapHubSkillInfoToDesktopInfo({ + slug: 'review-helper', + displayName: 'Review Helper', + description: 'Review flow', + version: '1.0.0', + owner: { type: 'personal', slug: 'u_1', name: 'User One' }, + visibility: 'private', + visibilityReview: { + requestedVisibility: 'public', + status: 'rejected', + reason: 'More details required', + }, + updatedAt: '2026-06-03T01:00:00.000Z', + isMine: true, + categories: [], + }); + + expect(info.visibilityReview).toEqual({ + requestedVisibility: 'public', + status: 'rejected', + reason: 'More details required', + }); + }); + it('maps Hub fileHash to folderHash when provided', () => { 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 3859d5f7ba1..0c95073c549 100644 --- a/apps/desktop/src/main/skillhub/__tests__/marketService.test.ts +++ b/apps/desktop/src/main/skillhub/__tests__/marketService.test.ts @@ -78,6 +78,21 @@ describe('SkillhubMarketService', () => { }); }); + it('blocks management mutations before issuing a request for read-only identities', async () => { + const { fetch, calls } = makeFetch([]); + const service = new SkillhubMarketService({ + fetch, + assertWriteAllowed: () => { + throw new ServerApiError('SKILL_HUB_READ_ONLY', 403, 'read-only'); + }, + }); + + await expect(service.deletePublished('demo')).rejects.toMatchObject({ + code: 'SKILL_HUB_READ_ONLY', + }); + expect(calls).toEqual([]); + }); + it('chunks sync requests at the broker batch limit', async () => { const slugs = Array.from({ length: 101 }, (_, i) => `skill-${i}`); const { fetch, calls: fetchCalls } = makeFetch([{ items: [] }, { items: [] }]); @@ -90,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 }, @@ -143,6 +180,24 @@ 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 }); + + const publicResult = await service.listMarket({ scope: 'market', sort: 'trending' }); + const organizationResult = 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', + ]); + expect(publicResult.items[0]?.catalogScope).toBe('market'); + expect(organizationResult.items[0]?.catalogScope).toBe('team'); + }); + it('builds detail, file preview, visibility, and scan routes', async () => { const { fetch, calls } = makeFetch([ makeHubSkill('demo/skill'), @@ -154,20 +209,20 @@ describe('SkillhubMarketService', () => { ]); const service = new SkillhubMarketService({ fetch }); - await service.info('demo/skill'); - await service.getPublishedFiles({ name: 'demo/skill', version: '1.0.0' }); - await service.readPublishedFile({ name: 'demo/skill', path: 'docs/README.md', version: '1.0.0' }); - await service.listPublishedVersions('demo/skill'); + await service.info('demo/skill', 'market'); + await service.getPublishedFiles({ name: 'demo/skill', version: '1.0.0', catalogScope: 'market' }); + await service.readPublishedFile({ name: 'demo/skill', path: 'docs/README.md', version: '1.0.0', catalogScope: 'market' }); + await service.listPublishedVersions('demo/skill', 'market'); await service.getPublishedVisibility('demo/skill'); - await service.getScanStatus({ slug: 'demo/skill', version: '1.0.0' }); + await service.getScanStatus({ slug: 'demo/skill', version: '1.0.0', catalogScope: 'market' }); expect(calls.map((call) => call.path)).toEqual([ - '/api/skills-hub/skills/demo%2Fskill', - '/api/skills-hub/skills/demo%2Fskill/files?version=1.0.0', - '/api/skills-hub/skills/demo%2Fskill/file?path=docs%2FREADME.md&version=1.0.0', - '/api/skills-hub/skills/demo%2Fskill/versions', + '/api/skills-hub/skills/demo%2Fskill?scope=market', + '/api/skills-hub/skills/demo%2Fskill/files?version=1.0.0&scope=market', + '/api/skills-hub/skills/demo%2Fskill/file?path=docs%2FREADME.md&version=1.0.0&scope=market', + '/api/skills-hub/skills/demo%2Fskill/versions?scope=market', '/api/skills-hub/skills/demo%2Fskill/visibility', - '/api/skills-hub/skills/demo%2Fskill/scan?version=1.0.0', + '/api/skills-hub/skills/demo%2Fskill/scan?version=1.0.0&scope=market', ]); expect(calls[5]?.opts).toEqual({ cache: 'no-store', @@ -190,20 +245,29 @@ describe('SkillhubMarketService', () => { { updated: true }, { deleted: true }, { unpublished: true }, - { visibility: 'shared' }, + { slug: 'demo', visibility: 'private', requestedVisibility: 'public', reviewStatus: 'pending' }, ]); - const service = new SkillhubMarketService({ fetch }); + const service = new SkillhubMarketService({ + fetch, + assertWriteAllowed: vi.fn(), + assertVisibilityAllowed: vi.fn(), + }); await service.updatePublished('demo', { summary: 'new', teamSlug: null }); await service.deletePublished('demo'); await service.unpublishPublished('demo'); - await service.setPublishedVisibility({ + const visibilityResult = await service.setPublishedVisibility({ name: 'demo', - visibility: 'shared', + visibility: 'public', teamSlug: 'team-a', visibleSlugs: ['team-a', 'od-1'], }); + expect(visibilityResult).toEqual({ + success: true, + result: { slug: 'demo', visibility: 'private', requestedVisibility: 'public', reviewStatus: 'pending' }, + }); + expect(calls).toEqual([ { path: '/api/skills-hub/skills/demo', @@ -221,18 +285,42 @@ describe('SkillhubMarketService', () => { path: '/api/skills-hub/skills/demo/set-visibility', opts: { method: 'POST', - body: { visibility: 'shared', teamSlug: 'team-a', visibleSlugs: ['team-a', 'od-1'] }, + body: { visibility: 'public', teamSlug: 'team-a', visibleSlugs: ['team-a', 'od-1'] }, }, }, ]); }); + 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: [ @@ -256,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 d728d32e18a..d6807fc034e 100644 --- a/apps/desktop/src/main/skillhub/__tests__/publishService.test.ts +++ b/apps/desktop/src/main/skillhub/__tests__/publishService.test.ts @@ -2,7 +2,17 @@ import fs from 'node:fs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const TEST_ROOT = '/tmp/xdt-publish-service-test'; -const authState = vi.hoisted(() => ({ ownerId: 'user-1' as string | null })); +const authState = vi.hoisted(() => ({ + ownerId: 'user-1' as string | null, + membershipKind: 'personal' as 'personal' | 'org', + orgSlug: null as string | null, +})); +const serverPolicy = vi.hoisted(() => ({ + canWrite: true, + ownerType: 'personal' as 'personal' | 'organization' | null, + allowedVisibilities: ['PUBLIC', 'PRIVATE'] as Array<'PUBLIC' | 'DEPARTMENT_SCOPED' | 'PRIVATE'>, + readOnlyReason: null as 'signed-out' | null, +})); vi.mock('electron', () => ({ app: { @@ -71,6 +81,15 @@ vi.mock('../registry', () => ({ vi.mock('../../authManager', () => ({ getCurrentUserId: vi.fn(), getCurrentDataOwnerId: vi.fn(() => authState.ownerId), + getAuthState: vi.fn(() => ({ + user: authState.ownerId + ? { + membershipKind: authState.membershipKind, + orgSlug: authState.orgSlug, + orgName: null, + } + : null, + })), })); vi.mock('../../appCapabilities.js', () => ({ @@ -78,64 +97,84 @@ vi.mock('../../appCapabilities.js', () => ({ requireAppCapability: vi.fn(), })); +vi.mock('../identityPolicy', () => ({ + currentSkillhubIdentityPolicy: vi.fn(async () => ({ ...serverPolicy })), +})); 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', () => { beforeEach(() => { authState.ownerId = 'user-1'; + authState.membershipKind = 'personal'; + authState.orgSlug = null; + serverPolicy.canWrite = true; + serverPolicy.ownerType = 'personal'; + serverPolicy.allowedVisibilities = ['PUBLIC', 'PRIVATE']; + serverPolicy.readOnlyReason = null; vi.resetModules(); vi.clearAllMocks(); fs.rmSync(TEST_ROOT, { recursive: true, force: true }); fs.mkdirSync(TEST_ROOT, { recursive: true }); }); - it('rejects manual-category publish requests without a category before review or upload starts', async () => { + it('rejects signed-out publishing before packing', async () => { + authState.ownerId = null; + serverPolicy.canWrite = false; + serverPolicy.ownerType = null; + serverPolicy.allowedVisibilities = []; + serverPolicy.readOnlyReason = 'signed-out'; 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), - ); + await expect(service.publish({ + absolutePath: '/tmp/skill', + name: 'read-only', + isFirstPublish: true, + visibility: 'PUBLIC', + })).resolves.toEqual({ success: false, errorCode: 'CANCELLED' }); + }); - expect(result).toEqual({ success: false, errorCode: 'CATEGORY_REQUIRED' }); - expect(events).toEqual([ - { - phase: 'failed', - name: 'lark-task', - errorCode: 'CATEGORY_REQUIRED', - message: '请选择分类后再发布', - }, - ]); + it('rejects private organization publishing before packing or network access', async () => { + authState.membershipKind = 'org'; + authState.orgSlug = 'acme'; + serverPolicy.ownerType = 'organization'; + serverPolicy.allowedVisibilities = ['PUBLIC', 'DEPARTMENT_SCOPED']; + const { SkillPublishService } = await import('../publishService'); + const service = new SkillPublishService(); + + await expect(service.publish({ + absolutePath: '/tmp/skill', + name: 'org-private', + isFirstPublish: true, + visibility: 'PRIVATE', + })).resolves.toEqual({ success: false, errorCode: 'INVALID_VISIBILITY' }); }); it('allows version publishes without category metadata and omits category fields from commit', async () => { 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 +187,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,31 +230,36 @@ 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', 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'); }); 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 +272,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 +319,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 +355,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( @@ -313,8 +394,7 @@ describe('SkillPublishService', () => { displayName: 'Lark Task', summary: 'Publish summary', visibility: 'PUBLIC', - categoryMode: 'manual', - categories: ['productivity'], + tags: ['Productivity'], }, () => {}, ); @@ -327,26 +407,30 @@ describe('SkillPublishService', () => { body: expect.objectContaining({ displayName: 'Lark Task', summary: 'Publish summary', - categories: ['productivity'], - categoryMode: 'manual', + tags: ['Productivity'], }), }); - 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 () => { + it('allows a first publish without author tags', 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 +443,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( @@ -387,8 +482,7 @@ describe('SkillPublishService', () => { displayName: 'Lark Task', summary: 'Publish summary', visibility: 'PUBLIC', - categoryMode: 'auto', - categories: [], + tags: [], }, () => {}, ); @@ -399,8 +493,7 @@ describe('SkillPublishService', () => { baseUrl: expect.any(Function), logLabel: '/api/skills-hub', body: expect.objectContaining({ - categoryMode: 'auto', - categories: [], + tags: [], }), }); }); @@ -408,15 +501,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 +525,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( @@ -458,8 +565,7 @@ describe('SkillPublishService', () => { summary: 'Publish summary', visibility: 'PUBLIC', visibleSlugs: [], - categoryMode: 'manual', - categories: ['productivity'], + tags: ['Productivity'], }, () => {}, ); @@ -544,8 +650,7 @@ describe('SkillPublishService', () => { displayName: 'Lark Task', summary: 'Publish summary', visibility: 'PUBLIC', - categoryMode: 'manual', - categories: ['productivity'], + tags: ['Productivity'], }, () => {}, ); @@ -639,8 +744,7 @@ describe('SkillPublishService', () => { displayName: 'Lark Task', summary: 'Publish summary', visibility: 'PUBLIC', - categoryMode: 'manual', - categories: ['productivity'], + tags: ['Productivity'], }, (event) => events.push(event), ); @@ -658,18 +762,24 @@ 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('/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 +788,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') { @@ -689,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}`); }); @@ -705,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 () => { @@ -754,21 +868,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 +923,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(); @@ -836,7 +963,9 @@ describe('SkillPublishService', () => { (event) => events.push(event), ); - await Promise.resolve(); + await vi.waitFor(() => { + expect(events).toEqual([{ phase: 'packing' }]); + }); service.cancel(); const result = await publishPromise; @@ -915,14 +1044,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 +1135,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/__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/hubApi.ts b/apps/desktop/src/main/skillhub/hubApi.ts index 67b0902a1e4..401713fcd27 100644 --- a/apps/desktop/src/main/skillhub/hubApi.ts +++ b/apps/desktop/src/main/skillhub/hubApi.ts @@ -1,23 +1,40 @@ /** * 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 内解析, * 模块加载期不可读。 */ -import { serverApiFetch, type ApiFetchOptions } from '../serverApiClient'; +import { ServerApiError, serverApiFetch, type ApiFetchOptions } from '../serverApiClient'; import { getClientEndpoint } from '../clientEndpointsService'; -import { requireAppCapability } from '../appCapabilities.js'; -export function skillhubApiFetch( +function requireSkillhubApiBaseUrl(): string { + const baseUrl = getClientEndpoint('cindySkillHubApiBaseUrl'); + if (!baseUrl) { + throw new ServerApiError( + 'UNSUPPORTED_CAPABILITY', + 0, + 'Cindy Skill Hub is not configured for this environment', + ); + } + return baseUrl; +} + +export async function skillhubApiFetch( apiPath: string, opts: Omit = {}, ): Promise { - requireAppCapability('canUseSkillHubCloud', 'SkillHub cloud requires a Cindy account.'); + // 空端点是清单级关闭开关。先于 serverApiFetch 拒绝,避免 Electron net.fetch + // 把 `/api/skills-hub/*` 当作相对地址;resolver 内再次校验,覆盖 401 刷新后 + // 登录区域切换导致目标区域未部署 Skill Hub 的情况。 + requireSkillhubApiBaseUrl(); return serverApiFetch(apiPath, { ...opts, - baseUrl: () => getClientEndpoint('skillhubApiBaseUrl'), + // 新客户端绝不回退旧 skillhubApiBaseUrl:XD 身份的只读兼容由新服务自己 + // 路由,回退会让个人/其它组织误连只面向 XD 的旧服务。 + baseUrl: requireSkillhubApiBaseUrl, // 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/identityPolicy.ts b/apps/desktop/src/main/skillhub/identityPolicy.ts new file mode 100644 index 00000000000..8a48986ebee --- /dev/null +++ b/apps/desktop/src/main/skillhub/identityPolicy.ts @@ -0,0 +1,36 @@ +import { deriveSkillhubIdentityPolicy } from '../../shared/skillhubIdentityPolicy'; +import { getAuthState } from '../authManager'; +import { ServerApiError } from '../serverApiClient'; +import type { SkillhubPublishVisibility } from '../../shared/skillhubIdentityPolicy'; + +export function currentSkillhubIdentityPolicy() { + return deriveSkillhubIdentityPolicy(getAuthState().user); +} + +function skillhubWritePolicyError(): ServerApiError { + return new ServerApiError('UNAUTHORIZED', 401, 'Skill Hub write access requires sign-in'); +} + +export function assertSkillhubWriteAllowed(): void { + const policy = currentSkillhubIdentityPolicy(); + if (policy.canWrite) return; + throw skillhubWritePolicyError(); +} + +export function assertSkillhubVisibilityAllowed( + visibility: 'private' | 'shared' | 'public', +): void { + const policy = currentSkillhubIdentityPolicy(); + if (!policy.canWrite) throw skillhubWritePolicyError(); + const clientVisibility: SkillhubPublishVisibility = visibility === 'shared' + ? 'DEPARTMENT_SCOPED' + : visibility.toUpperCase() as SkillhubPublishVisibility; + if (policy.allowedVisibilities.includes(clientVisibility)) return; + throw new ServerApiError( + 'INVALID_VISIBILITY', + 400, + policy.ownerType === 'organization' + ? 'Organization skills only support public or organization visibility' + : 'Personal skills only support public or private visibility', + ); +} diff --git a/apps/desktop/src/main/skillhub/infoMapping.ts b/apps/desktop/src/main/skillhub/infoMapping.ts index 2ea399d1993..af6dfc196ed 100644 --- a/apps/desktop/src/main/skillhub/infoMapping.ts +++ b/apps/desktop/src/main/skillhub/infoMapping.ts @@ -1,5 +1,8 @@ +import type { SkillhubCatalogScope } from '../../shared/skillhubCatalog'; + export interface HubSkillInfoForDesktop { slug: string; + icon?: string | null; displayName?: string; summary?: string | null; description?: string; @@ -9,14 +12,23 @@ export interface HubSkillInfoForDesktop { version: string; status?: string; }; + visibilityReview?: { + requestedVisibility: 'public'; + status: 'pending' | 'rejected'; + reason?: string; + }; 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 }>; + 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; }; @@ -24,17 +36,21 @@ export interface HubSkillInfoForDesktop { interface MapOptions { forceMine?: boolean; + catalogScope?: SkillhubCatalogScope; } 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, 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', @@ -45,10 +61,18 @@ export function mapHubSkillInfoToDesktopInfo(hub: HubSkillInfoForDesktop, opts?: moderationStatus: hub.moderationStatus, marketVersion: hub.marketVersion, pendingVersion: hub.pendingVersion, + 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, + ...(tag.source ? { source: tag.source } : {}), + })), + githubUrl: hub.githubUrl, publishedAt: hub.updatedAt, downloads: Number.isFinite(hub.stats?.downloads) ? hub.stats?.downloads ?? 0 : 0, latestPublishedFromDeviceId: null as string | null, + catalogScope: opts?.catalogScope, }; } diff --git a/apps/desktop/src/main/skillhub/installService.ts b/apps/desktop/src/main/skillhub/installService.ts index 2044585508d..1080dfab614 100644 --- a/apps/desktop/src/main/skillhub/installService.ts +++ b/apps/desktop/src/main/skillhub/installService.ts @@ -45,6 +45,7 @@ import { projectWorkingDirFromSkillPath, } from '../maker-host/shared-global-skills.js'; import { clearIgnoredAutoSyncSkill, ignoreAutoSyncSkill, isKnownAutoSyncCandidateSkill } from './autoSyncPreferences'; +import { withSkillhubCatalogScope, type SkillhubCatalogScope } from '../../shared/skillhubCatalog'; import { createLogger } from '../logger'; @@ -58,6 +59,8 @@ const MAX_SKILL_ZIP_ENTRIES = 10_000; export interface InstallParams { name: string; version?: string; + /** Generic catalog context returned by the list flow; absent on older installs. */ + catalogScope?: SkillhubCatalogScope; /** 由产品自动同步服务发起的安装 / 更新。 */ autoSync?: boolean; /** @@ -527,13 +530,13 @@ export async function install( // 如果没传版本号,先查 hub 拿最新版本 if (!downloadVersion) { const detail = await skillhubApiFetch<{ version: string }>( - `/api/skills-hub/skills/${encodeURIComponent(p.name)}`, + withSkillhubCatalogScope(`/api/skills-hub/skills/${encodeURIComponent(p.name)}`, p.catalogScope), ); downloadVersion = detail.version; } const versionQs = downloadVersion ? `?version=${encodeURIComponent(downloadVersion)}` : ''; info = await skillhubApiFetch( - `/api/skills-hub/skills/${encodeURIComponent(p.name)}/download${versionQs}`, + withSkillhubCatalogScope(`/api/skills-hub/skills/${encodeURIComponent(p.name)}/download${versionQs}`, p.catalogScope), ); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -731,7 +734,7 @@ export async function install( const resp = await skillhubApiFetch<{ items: Array<{ slug: string; owner: { slug: string }; isMine: boolean }>; availableCount?: number; - }>('/api/skills-hub/skills/batch-detail', { + }>(withSkillhubCatalogScope('/api/skills-hub/skills/batch-detail', p.catalogScope), { method: 'POST', body: { slugs: [p.name] }, }); @@ -785,6 +788,7 @@ export async function install( updatedAt: nowSec, origin: 'installed', autoSynced: nextAutoSynced, + ...(p.catalogScope ? { catalogScope: p.catalogScope } : {}), }); logicalRegistryWritten = true; for (const { installPath } of physicalRegistrySnapshots) { diff --git a/apps/desktop/src/main/skillhub/marketService.ts b/apps/desktop/src/main/skillhub/marketService.ts index bc724f7eb16..c86c37ad2fe 100644 --- a/apps/desktop/src/main/skillhub/marketService.ts +++ b/apps/desktop/src/main/skillhub/marketService.ts @@ -1,10 +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 { 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; @@ -38,6 +43,9 @@ function mapFirstLevelDepartments( 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 { @@ -45,6 +53,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; @@ -55,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; @@ -68,6 +77,13 @@ export interface SetPublishedVisibilityParams { visibleSlugs?: string[]; } +export interface SkillVisibilityUpdateResult { + slug: string; + visibility: 'private' | 'shared' | 'public'; + requestedVisibility?: 'public'; + reviewStatus?: 'pending'; +} + /** * Main-process SkillHub market API adapter. * @@ -76,17 +92,34 @@ export interface SetPublishedVisibilityParams { */ 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) { @@ -114,7 +147,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 @@ -125,7 +158,8 @@ export class SkillhubMarketService { : undefined, ); - const items = (hubResult.items ?? []).map((item) => mapHubSkillInfoToDesktopInfo(item)); + const catalogScope = params?.scope === 'team' ? 'team' : 'market'; + const items = (hubResult.items ?? []).map((item) => mapHubSkillInfoToDesktopInfo(item, { catalogScope })); const hasMore = page * pageSize < hubResult.total; return { success: true as const, @@ -134,28 +168,28 @@ export class SkillhubMarketService { }; } - async info(name: string) { + async info(name: string, catalogScope?: SkillhubCatalogScope) { const hub = await this.fetch( - `/api/skills-hub/skills/${encodeURIComponent(name)}`, + withSkillhubCatalogScope(`/api/skills-hub/skills/${encodeURIComponent(name)}`, catalogScope), ); if ('deleted' in hub) { return { success: true as const, deleted: true as const }; } - const info = mapHubSkillInfoToDesktopInfo(hub); + const info = mapHubSkillInfoToDesktopInfo(hub, { catalogScope }); return { success: true as const, info }; } - async getPublishedFiles({ name, version }: { name: string; version?: string }) { + async getPublishedFiles({ name, version, catalogScope }: { name: string; version?: string; catalogScope?: SkillhubCatalogScope }) { const qs = version ? `?version=${encodeURIComponent(version)}` : ''; const result = await this.fetch<{ slug: string; version: string; files: Array<{ path: string; size: number; language: string; truncated: boolean }>; - }>(`/api/skills-hub/skills/${encodeURIComponent(name)}/files${qs}`); + }>(withSkillhubCatalogScope(`/api/skills-hub/skills/${encodeURIComponent(name)}/files${qs}`, catalogScope)); return { success: true as const, ...result }; } - async readPublishedFile({ name, path: filePath, version }: { name: string; path: string; version?: string }) { + async readPublishedFile({ name, path: filePath, version, catalogScope }: { name: string; path: string; version?: string; catalogScope?: SkillhubCatalogScope }) { const search = new URLSearchParams({ path: filePath }); if (version) search.set('version', version); const result = await this.fetch<{ @@ -164,18 +198,20 @@ export class SkillhubMarketService { language: string; truncated: boolean; content: string; - }>(`/api/skills-hub/skills/${encodeURIComponent(name)}/file?${search.toString()}`); + }>(withSkillhubCatalogScope(`/api/skills-hub/skills/${encodeURIComponent(name)}/file?${search.toString()}`, catalogScope)); return { success: true as const, file: result }; } - async listPublishedVersions(name: string) { + async listPublishedVersions(name: string, catalogScope?: SkillhubCatalogScope) { const versions = await this.fetch( - `/api/skills-hub/skills/${encodeURIComponent(name)}/versions`, + withSkillhubCatalogScope(`/api/skills-hub/skills/${encodeURIComponent(name)}/versions`, catalogScope), ); return { success: true as const, versions }; } async updatePublished(name: string, fields: UpdatePublishedFields) { + await this.assertWriteAllowed(); + if (fields.visibility) await this.assertVisibilityAllowed(fields.visibility); const result = await this.fetch( `/api/skills-hub/skills/${encodeURIComponent(name)}`, { method: 'PATCH', body: fields }, @@ -184,6 +220,7 @@ export class SkillhubMarketService { } async deletePublished(name: string) { + await this.assertWriteAllowed(); const result = await this.fetch( `/api/skills-hub/skills/${encodeURIComponent(name)}`, { method: 'DELETE' }, @@ -192,6 +229,7 @@ export class SkillhubMarketService { } async unpublishPublished(name: string) { + await this.assertWriteAllowed(); const result = await this.fetch( `/api/skills-hub/skills/${encodeURIComponent(name)}/unpublish`, { method: 'POST' }, @@ -200,7 +238,9 @@ export class SkillhubMarketService { } async setPublishedVisibility({ name, visibility, teamSlug, visibleSlugs }: SetPublishedVisibilityParams) { - const result = await this.fetch( + await this.assertWriteAllowed(); + await this.assertVisibilityAllowed(visibility); + const result = await this.fetch( `/api/skills-hub/skills/${encodeURIComponent(name)}/set-visibility`, { method: 'POST', @@ -211,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 }; } @@ -244,13 +291,14 @@ export class SkillhubMarketService { name: string; skillCount?: number; mySkillCount?: number; + source?: 'author' | 'platform'; children?: Array<{ slug: string; name: string; skillCount?: number; mySkillCount?: number; }>; - }>>('/api/skills-hub/categories'); + }>>('/api/skills-hub/categories?scope=market'); const categories = flattenHubCategories(items ?? []); const totalCount = categories.reduce((s, c) => s + c.count, 0); const myTotalCount = categories.reduce((s, c) => s + c.myCount, 0); @@ -271,16 +319,17 @@ export class SkillhubMarketService { return { success: true as const, teams }; } - async getScanStatus({ slug, version }: { slug: string; version?: string }) { + async getScanStatus({ slug, version, catalogScope }: { slug: string; version?: string; catalogScope?: SkillhubCatalogScope }) { + const path = `/api/skills-hub/skills/${encodeURIComponent(slug)}/scan${version ? `?version=${encodeURIComponent(version)}` : ''}`; const result = await this.fetch<{ status: string; gates?: unknown[]; scorecard?: unknown }>( - `/api/skills-hub/skills/${encodeURIComponent(slug)}/scan${version ? `?version=${encodeURIComponent(version)}` : ''}`, + withSkillhubCatalogScope(path, catalogScope), { cache: 'no-store', headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } }, ); 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 }, }); @@ -299,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); } @@ -330,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 1c27115b4ca..4da96826c94 100644 --- a/apps/desktop/src/main/skillhub/publishService.ts +++ b/apps/desktop/src/main/skillhub/publishService.ts @@ -20,8 +20,10 @@ 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'; import { createLogger } from '../logger'; @@ -44,8 +46,7 @@ export interface PublishParams { deptTeamSlug?: string; /** 发布者为普通团队时的团队归属 slug */ teamSlug?: string; - categoryMode?: 'auto' | 'manual'; - categories?: string[]; + tags?: string[]; changelog?: string; } @@ -64,6 +65,8 @@ export type PublishErrorCode = | 'CATEGORY_REQUIRED' | 'MANIFEST_INVALID' | 'CANCELLED' + | 'SKILL_HUB_READ_ONLY' + | 'INVALID_VISIBILITY' | 'INTERNAL'; export type PublishProgressEvent = @@ -136,8 +139,10 @@ 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'; + if (err.message.includes('manifest') || err.message.includes('frontmatter')) + return 'MANIFEST_INVALID'; return 'INTERNAL'; } return 'INTERNAL'; @@ -148,11 +153,11 @@ 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', '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 +169,17 @@ 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, + catalogScope?: SkillhubCatalogScope | null, +): Promise { const nowSec = Math.floor(Date.now() / 1000); const myUserId = getCurrentUserId() ?? ''; const existing = await registryService.getInstall(slug, absolutePath); @@ -175,6 +190,7 @@ async function syncPublishedRegistry(slug: string, absolutePath: string, version updatedAt: nowSec, authorId: myUserId, origin: 'published', + ...(catalogScope !== undefined ? { catalogScope: catalogScope ?? undefined } : {}), }); } else { await registryService.addInstall(slug, absolutePath, { @@ -184,6 +200,7 @@ async function syncPublishedRegistry(slug: string, absolutePath: string, version installedAt: nowSec, updatedAt: nowSec, origin: 'published', + ...(catalogScope ? { catalogScope } : {}), }); } } @@ -195,7 +212,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,38 +233,82 @@ 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 identityPolicy = await currentSkillhubIdentityPolicy(); + if (!identityPolicy.canWrite) { + this.emitProgress( + { + phase: 'failed', + name: params.name, + errorCode: 'CANCELLED', + message: 'SkillHub publish requires sign-in', + }, + onProgress, + ); return { success: false, errorCode: 'CANCELLED' }; } + if ( + params.isFirstPublish + && params.visibility + && !identityPolicy.allowedVisibilities.includes(params.visibility) + ) { + this.emitProgress( + { + phase: 'failed', + name: params.name, + errorCode: 'INVALID_VISIBILITY', + message: identityPolicy.ownerType === 'organization' + ? 'Organization skills only support public or organization visibility' + : 'Personal skills only support public or private visibility', + }, + onProgress, + ); + return { success: false, errorCode: 'INVALID_VISIBILITY' }; + } 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); - return { success: false, errorCode: 'CATEGORY_REQUIRED' }; - } + const tags = params.isFirstPublish ? normalizePublishTags(params.tags) : undefined; const abortController = new AbortController(); const state: InternalState = { abortController }; @@ -263,7 +328,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 +341,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 +384,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 +420,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 +443,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 +504,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' }; } @@ -459,11 +567,13 @@ 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.visibility = params.visibility === 'PUBLIC' - ? 'public' - : params.visibility === 'PRIVATE' ? 'private' : 'shared'; + commitBody.tags = tags; + 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 +596,34 @@ 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, + params.isFirstPublish + ? params.visibility === 'DEPARTMENT_SCOPED' + ? 'team' + : null + : undefined, + ).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 +634,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 +705,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/main/skillhub/registerIpc.ts b/apps/desktop/src/main/skillhub/registerIpc.ts index 15e79d87c1e..1be72a5a9b8 100644 --- a/apps/desktop/src/main/skillhub/registerIpc.ts +++ b/apps/desktop/src/main/skillhub/registerIpc.ts @@ -8,6 +8,7 @@ import { ensureReady as ensureLocalDbReady, getRawDb } from '../localDb'; import { createLogger } from '../logger'; import { assertTrustedAppRendererEvent } from '../security/trustedAppRenderer.js'; import { normalizeWorkingDirForStorage } from '../../shared/workingDir.js'; +import { isSkillhubCatalogScope } from '../../shared/skillhubCatalog.js'; import { computeFolderHashDetailed } from './folderHash'; import { type MdKind, parseAndValidateFrontmatter } from './frontmatterValidation'; import * as importLocalSkill from './importLocalSkill'; @@ -402,9 +403,9 @@ export function registerSkillhubIpc(options: RegisterSkillhubIpcOptions): void { ipcMain.handle( 'skillhub:info', - async (_event, { name }: { name: string }) => { + async (_event, { name, catalogScope }: { name: string; catalogScope?: unknown }) => { try { - return await marketService.info(name); + return await marketService.info(name, isSkillhubCatalogScope(catalogScope) ? catalogScope : undefined); } catch (err) { const message = err instanceof Error ? err.message : String(err); const code = (err as { code?: string }).code; @@ -418,9 +419,13 @@ export function registerSkillhubIpc(options: RegisterSkillhubIpcOptions): void { ipcMain.handle( 'skillhub:get-published-files', - async (_event, params: { name: string; version?: string }) => { + async (_event, params: { name: string; version?: string; catalogScope?: unknown }) => { try { - return await marketService.getPublishedFiles(params); + return await marketService.getPublishedFiles({ + name: params.name, + ...(params.version !== undefined ? { version: params.version } : {}), + ...(isSkillhubCatalogScope(params.catalogScope) ? { catalogScope: params.catalogScope } : {}), + }); } catch (err) { return skillhubIpcError(err); } @@ -429,9 +434,14 @@ export function registerSkillhubIpc(options: RegisterSkillhubIpcOptions): void { ipcMain.handle( 'skillhub:read-published-file', - async (_event, params: { name: string; path: string; version?: string }) => { + async (_event, params: { name: string; path: string; version?: string; catalogScope?: unknown }) => { try { - return await marketService.readPublishedFile(params); + return await marketService.readPublishedFile({ + name: params.name, + path: params.path, + ...(params.version !== undefined ? { version: params.version } : {}), + ...(isSkillhubCatalogScope(params.catalogScope) ? { catalogScope: params.catalogScope } : {}), + }); } catch (err) { return skillhubIpcError(err); } @@ -440,9 +450,9 @@ export function registerSkillhubIpc(options: RegisterSkillhubIpcOptions): void { ipcMain.handle( 'skillhub:list-published-versions', - async (_event, { name }: { name: string }) => { + async (_event, { name, catalogScope }: { name: string; catalogScope?: unknown }) => { try { - return await marketService.listPublishedVersions(name); + return await marketService.listPublishedVersions(name, isSkillhubCatalogScope(catalogScope) ? catalogScope : undefined); } catch (err) { return skillhubIpcError(err); } @@ -559,9 +569,13 @@ export function registerSkillhubIpc(options: RegisterSkillhubIpcOptions): void { // 查询发布后的安全扫描状态(renderer 轮询用) ipcMain.handle( 'skillhub:get-scan-status', - async (_event, params: { slug: string; version?: string }) => { + async (_event, params: { slug: string; version?: string; catalogScope?: unknown }) => { try { - return await marketService.getScanStatus(params); + return await marketService.getScanStatus({ + slug: params.slug, + ...(params.version !== undefined ? { version: params.version } : {}), + ...(isSkillhubCatalogScope(params.catalogScope) ? { catalogScope: params.catalogScope } : {}), + }); } catch (err) { const message = err instanceof Error ? err.message : String(err); return { success: false, error: message, status: 'unknown' }; @@ -800,6 +814,7 @@ export function registerSkillhubIpc(options: RegisterSkillhubIpcOptions): void { const publicParams: import('./installService').InstallParams = { name: params.name, ...(params.version !== undefined ? { version: params.version } : {}), + ...(isSkillhubCatalogScope(params.catalogScope) ? { catalogScope: params.catalogScope } : {}), ...(params.force !== undefined ? { force: params.force } : {}), ...(params.installPath !== undefined ? { installPath: params.installPath } : {}), ...(params.skipBackup !== undefined ? { skipBackup: params.skipBackup } : {}), 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 ccbe8f2314d..9c2b4bc5584 100644 --- a/apps/desktop/src/main/skillhub/registry/types.ts +++ b/apps/desktop/src/main/skillhub/registry/types.ts @@ -1,6 +1,7 @@ // ── 核心存储类型 ───────────────────────────────────────────────────────────── import type { LearnProvenance } from '../../../shared/learnTypes'; +import type { SkillhubCatalogScope } from '../../../shared/skillhubCatalog'; export interface StoredInstall { /** 市场版本号字符串。注意:与 server latestVersion 类型对齐(string,非 number)。 @@ -27,6 +28,8 @@ export interface StoredInstall { origin?: 'installed' | 'published' | 'learned' | 'imported'; /** 是否由产品自动同步流程安装。用于区分普通市场安装与用户可 opt-out 的自动同步安装。 */ autoSynced?: boolean; + /** Catalog used for detail/download; absent on older registry entries. */ + catalogScope?: SkillhubCatalogScope; /** /learn 蒸馏产物的溯源(仅 origin='learned' 时存在)。 * provenance.personal=true ⇒ 含本地会话衍生内容。当前不拦截发布 —— * 作为将来「发布前泛化」流程(另行独立 PR)的判定依据保留。 */ @@ -35,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 360ae873ebb..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; @@ -2953,6 +2954,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; @@ -2962,12 +2964,16 @@ contextBridge.exposeInMainWorld('electronAPI', { success: boolean; items?: Array<{ name: string; + /** Skill 图标 URL;旧服务响应可能缺失。 */ + icon?: string; displayName: string; 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'; @@ -2978,12 +2984,20 @@ contextBridge.exposeInMainWorld('electronAPI', { version: string; status?: string; }; + visibilityReview?: { + requestedVisibility: 'public'; + status: 'pending' | 'rejected'; + reason?: string; + }; visibleDeptIds: string[]; categories?: string[]; + tags?: Array<{ slug: string; name: string; source?: 'author' | 'platform' }>; + githubUrl?: string | null; publishedAt: string; downloads: number; /** 跨设备识别:null = pre-feature 历史版本 */ latestPublishedFromDeviceId: string | null; + catalogScope?: import('../shared/skillhubCatalog').SkillhubCatalogScope; }>; nextCursor?: string | null; error?: string; @@ -2992,17 +3006,19 @@ contextBridge.exposeInMainWorld('electronAPI', { // 查询单个 skill 市场详情(有 in-flight dedupe 在 renderer 侧) info: ( name: string, + catalogScope?: import('../shared/skillhubCatalog').SkillhubCatalogScope, ): Promise<{ success: boolean; info?: unknown; deleted?: boolean; error?: string; errorCode?: string; - }> => ipcRenderer.invoke('skillhub:info', { name }), + }> => ipcRenderer.invoke('skillhub:info', { name, catalogScope }), getPublishedFiles: (params: { name: string; version?: string; + catalogScope?: import('../shared/skillhubCatalog').SkillhubCatalogScope; }): Promise<{ success: boolean; slug?: string; @@ -3016,6 +3032,7 @@ contextBridge.exposeInMainWorld('electronAPI', { name: string; path: string; version?: string; + catalogScope?: import('../shared/skillhubCatalog').SkillhubCatalogScope; }): Promise<{ success: boolean; file?: { path: string; size: number; language: string; truncated: boolean; content: string }; @@ -3025,12 +3042,13 @@ contextBridge.exposeInMainWorld('electronAPI', { listPublishedVersions: ( name: string, + catalogScope?: import('../shared/skillhubCatalog').SkillhubCatalogScope, ): Promise<{ success: boolean; versions?: unknown[]; error?: string; errorCode?: string; - }> => ipcRenderer.invoke('skillhub:list-published-versions', { name }), + }> => ipcRenderer.invoke('skillhub:list-published-versions', { name, catalogScope }), updatePublished: (params: { name: string; @@ -3038,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; @@ -3061,7 +3079,12 @@ contextBridge.exposeInMainWorld('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; + }> => ipcRenderer.invoke('skillhub:set-published-visibility', params), // 读取已发布 skill 的可见对象(共享团队 + 可见部门),编辑可见范围弹窗回显用 @@ -3088,6 +3111,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getScanStatus: (params: { slug: string; version?: string; + catalogScope?: import('../shared/skillhubCatalog').SkillhubCatalogScope; }): Promise<{ success: boolean; status: string; @@ -3193,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; @@ -3232,6 +3255,7 @@ contextBridge.exposeInMainWorld('electronAPI', { install: (params: { name: string; version?: string; + catalogScope?: import('../shared/skillhubCatalog').SkillhubCatalogScope; force?: boolean; /** 完整安装目标路径。不传 → global scope 默认路径。 */ installPath?: 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 d3fc2505741..545e438499f 100644 --- a/apps/desktop/src/renderer/features/skillhub/PublishDialog.tsx +++ b/apps/desktop/src/renderer/features/skillhub/PublishDialog.tsx @@ -14,7 +14,7 @@ * 统一 spinner + "正在发布中"文案,不分步展示。 */ -import { useCallback, useEffect, useReducer, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import * as Dialog from '@radix-ui/react-dialog'; @@ -24,8 +24,8 @@ import { X, CloudUpload, Globe, Users, Lock, RefreshCw, CircleAlert, Check, Chev import { cn } from '@/lib/utils'; import { Spinner } from '@/components/ui/spinner'; import { toast } from '@/lib/toast'; +import { useAuth } from '@/contexts/AuthContext'; import { useConfirmDialog } from '@/components/ui/confirm-dialog-provider'; -import { AudiencePicker, PublisherPicker } from './components/TeamScopePicker'; import { pickDefaultVersion } from './versionUtils'; import { triggerIncrementalSync } from './hooks/useSkillSync'; import { invalidateHash } from './hooks/useSkillFolderHash'; @@ -33,7 +33,7 @@ import { refresh as refreshSkillhub } from './hooks/useSkillhub'; import { getPublishErrorCopy, type PublishActionType } from './lib/publishErrorMap'; import { shouldHandlePublishProgressEvent } from './lib/publishProgressFilter'; import { buildPublishFailureEvent, shouldDispatchPublishResultFallback } from './lib/publishFailureFallback'; -import { selectableUserTeams } from './lib/userTeams'; +import { useSkillhubIdentityPolicy } from './hooks/useSkillhubIdentityPolicy'; import { buildSkillhubPublishParams, validateRequiredCategory, @@ -487,10 +487,6 @@ export interface PublishDialogProps { latestVersionStatus?: string | null; /** Latest version submitted to Hub and its review status. Rejected versions can be reused. */ pendingVersion?: { version?: string | null; status?: string | null } | null; - /** Dept ids the current user can see. */ - currentUserDeptIds: string[]; - /** Dept display names parallel to currentUserDeptIds. */ - currentUserDeptNames: string[]; /** * 仅 autoCleanName 改名流程触发:本地 skill 已被改名(目录 + frontmatter)。 * DetailView 拿到新 absolutePath/name 后,刷新 scanner 并导航到新 URL, @@ -511,8 +507,6 @@ export function PublishDialog({ latestVersion, latestVersionStatus, pendingVersion, - currentUserDeptIds, - currentUserDeptNames, onLocalRenamed, onScanResult, }: PublishDialogProps) { @@ -520,6 +514,8 @@ export function PublishDialog({ const [pubState, dispatch] = useReducer(publishReducer, INITIAL_STATE); const { confirm } = useConfirmDialog(); const navigate = useNavigate(); + const { user } = useAuth(); + const identityPolicy = useSkillhubIdentityPolicy(user); // refresh/sync 延迟到 dialog 关闭后才触发,isFirstPublish 在 dialog 生命周期内不会翻转 const effectiveFirstPublish = isFirstPublish; @@ -554,25 +550,16 @@ export function PublishDialog({ }, [onLocalRenamed]); - // ── User teams (for multi-team visibility) ────────────────────────────── - const [userTeams, setUserTeams] = useState>([]); - useEffect(() => { - if (!open || !effectiveFirstPublish) return; - void window.electronAPI.skillhub.listUserTeams().then((res) => { - if (res.success) { - const teams = selectableUserTeams(res.teams) - .map((t) => ({ slug: t.slug, name: t.name, type: t.type ?? 'team' })); - setUserTeams(teams); - } - }); - }, [open, effectiveFirstPublish, currentUserDeptIds]); - // ── Hub categories (required for first publish only) ───────────────────── const [categoryState, setCategoryState] = useState({ loading: false, 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 }); @@ -614,7 +601,7 @@ export function PublishDialog({ const frontmatterVersion = (skill.frontmatter?.['version'] as string | undefined) ?? ''; const defaultVersion = pickDefaultVersion(frontmatterVersion || undefined, latestVersion, pendingVersion, latestVersionStatus); - // 默认对齐 SkillHub:公开 · 个人发布者 + // 新服务以当前 membership 固定归属,客户端不再允许跨归属发布。 const [form, setForm] = useState(() => ({ name: autoCleanName ? '' : skill.name, version: defaultVersion, @@ -622,7 +609,7 @@ export function PublishDialog({ summary: frontmatterSummary, description: frontmatterSummary, visibility: 'PUBLIC', - publisherMode: 'personal', + publisherMode: identityPolicy.ownerType === 'organization' ? 'team' : 'personal', ownerTeamSlug: '', visibleDeptIds: [], sharedTeamSlugs: [], @@ -641,7 +628,7 @@ export function PublishDialog({ summary: frontmatterSummary, description: frontmatterSummary, visibility: 'PUBLIC', - publisherMode: 'personal', + publisherMode: identityPolicy.ownerType === 'organization' ? 'team' : 'personal', ownerTeamSlug: '', visibleDeptIds: [], sharedTeamSlugs: [], @@ -650,7 +637,7 @@ export function PublishDialog({ categorySlug: '', }); } - }, [open, latestVersion, latestVersionStatus, pendingVersion]); + }, [open, latestVersion, latestVersionStatus, pendingVersion, identityPolicy.ownerType]); // ── Progress event subscription ─────────────────────────────────────────── useEffect(() => { @@ -704,12 +691,15 @@ export function PublishDialog({ const changelogError = !effectiveFirstPublish && form.changelog.length > PUBLISH_TEXT_LIMIT; const versionError = form.version.length > 0 && !isValidVersion(form.version); - const visibilityScopeValidation = validateVisibilityScope(form); + const visibilityScopeValidation = identityPolicy.ownerType + ? { ok: true as const } + : validateVisibilityScope(form); + const visibilityAllowed = identityPolicy.allowedVisibilities.includes(form.visibility); const categoryValidation = effectiveFirstPublish ? validateRequiredCategory({ loading: categoryState.loading, error: categoryState.error, - categories: categoryState.categories, + categories: editableCategories, categoryMode: form.categoryMode, selectedSlug: form.categorySlug, }) @@ -723,6 +713,7 @@ export function PublishDialog({ (effectiveFirstPublish ? !summaryError : true) && !changelogRequired && !changelogError && + visibilityAllowed && (effectiveFirstPublish ? visibilityScopeValidation.ok : true); const buildCurrentPublishParams = useCallback( @@ -731,8 +722,10 @@ export function PublishDialog({ publishAbsolutePath, submitName, isFirstPublish: effectiveFirstPublish, + ownerType: identityPolicy.ownerType, + categories: editableCategories, }), - [form, effectiveFirstPublish], + [form, effectiveFirstPublish, identityPolicy.ownerType, editableCategories], ); const runPublish = useCallback((params: SkillhubPublishParams) => { @@ -941,9 +934,6 @@ export function PublishDialog({ ? getPublishErrorCopy(pubState.failurePayload.errorCode) : null; - // ── Dept section available? ─────────────────────────────────────────────── - const hasDepts = currentUserDeptIds.length > 0; - // dlg-head subtitle — 优先 frontmatter displayName,fallback 到目录名 const skillDisplayTitle = frontmatterDisplayName !== skill.name ? frontmatterDisplayName : skill.name; const baseSubtitle = effectiveFirstPublish @@ -1105,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, })), @@ -1148,8 +1138,8 @@ export function PublishDialog({ {effectiveFirstPublish && (
{t('skillhub.publishDialog.visibilityLabel')} - {/* 三档横排,对齐 SkillHub:公开 / 团队 / 仅自己使用 */} -
+ {/* 归属由当前 membership 固定:个人=公开/私有,组织=公开/组织。 */} +
-
- } - selected={form.visibility === 'DEPARTMENT_SCOPED'} - disabled={!hasDepts && userTeams.length === 0} - onSelect={(v) => setForm((f) => ({ ...f, visibility: v }))} - /> -
-
- } - selected={form.visibility === 'PRIVATE'} - onSelect={(v) => setForm((f) => ({ - ...f, - visibility: v, - // 私有强制个人归属(对齐 SkillHub) - publisherMode: 'personal', - ownerTeamSlug: '', - visibleDeptIds: [], - sharedTeamSlugs: [], - }))} - /> -
-
- {!hasDepts && userTeams.length === 0 && ( -

{t('skillhub.publishDialog.noTeamsHint')}

- )} - - {/* 发布者 — SkillHub 同款常驻区块(个人/团队两卡 + 发布团队下拉);私有档锁定个人 */} -
- { - const nextOwnerSlug = mode === 'team' && !ownerTeamSlug - ? (currentUserDeptIds[0] ?? userTeams[0]?.slug ?? '') - : ownerTeamSlug; - setForm((f) => ({ - ...f, - publisherMode: mode, - ownerTeamSlug: nextOwnerSlug, - visibleDeptIds: mode === 'team' && nextOwnerSlug - ? f.visibleDeptIds.filter((id) => id !== nextOwnerSlug) - : f.visibleDeptIds, - sharedTeamSlugs: mode === 'team' && nextOwnerSlug - ? f.sharedTeamSlugs.filter((slug) => slug !== nextOwnerSlug) - : f.sharedTeamSlugs, - })); - }} - /> - {visibilityScopeValidation.ok === false && visibilityScopeValidation.reason === 'publisher-team-required' && ( -

- {t('skillhub.publishDialog.publisherTeamRequired')} -

+ {identityPolicy.ownerType === 'organization' ? ( +
+ } + selected={form.visibility === 'DEPARTMENT_SCOPED'} + onSelect={(v) => setForm((f) => ({ ...f, visibility: v }))} + /> +
+ ) : ( +
+ } + selected={form.visibility === 'PRIVATE'} + onSelect={(v) => setForm((f) => ({ ...f, visibility: v }))} + /> +
)}
- - {/* 谁可以使用 — 仅团队可见档显示 */} - {form.visibility === 'DEPARTMENT_SCOPED' && (hasDepts || userTeams.length > 0) && ( -
- setForm((f) => ({ - ...f, - visibleDeptIds: value.visibleDeptIds, - sharedTeamSlugs: value.sharedTeamSlugs, - }))} - /> - {visibilityScopeValidation.ok === false && visibilityScopeValidation.reason === 'audience-required' && ( -

- {t('skillhub.publishDialog.audienceRequired')} -

- )} -
- )}
)} diff --git a/apps/desktop/src/renderer/features/skillhub/ScanResultDialog.tsx b/apps/desktop/src/renderer/features/skillhub/ScanResultDialog.tsx index 17ca21d5f8d..32ecc9410b3 100644 --- a/apps/desktop/src/renderer/features/skillhub/ScanResultDialog.tsx +++ b/apps/desktop/src/renderer/features/skillhub/ScanResultDialog.tsx @@ -12,7 +12,8 @@ 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 { severity?: string; @@ -45,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 { @@ -59,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') { @@ -83,6 +102,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; } @@ -123,14 +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 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') - : 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') - : 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', @@ -138,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)}`, @@ -149,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}`); @@ -171,7 +206,12 @@ export function ScanResultDialog({ open, onClose, result }: ScanResultDialogProp } return ( - { if (!v) onClose(); }}> + { + if (!v) onClose(); + }} + >
+ ) : pendingManualReview ? ( +
+ +
+ ) : processingFailure ? ( +
+ +
) : (
@@ -219,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)} @@ -235,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 && } @@ -254,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')} + ) : ( + )} >
- -
+ +

{t('skillhub.home.title')}

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

- + {homeCatalogTabs.map((tab) => ( + + ))} + +
- {/* ① Skill Hub 入口 → 完整 Market 浏览页(仅市场可见账号) */} - {marketAllowed && !normalizedQuery ? ( - - ) : null} - - {/* ② 推荐安装(仅市场可见账号) */} - {marketAllowed && (!normalizedQuery || recommended.length > 0 || marketLoading) ? ( + {/* ① 当前云端目录摘要 */} + {catalogTab !== 'local' && (!normalizedQuery || catalogItems.length > 0 || marketLoading) ? (
- - {marketLoading && recommended.length === 0 ? ( + {(marketLoading || !marketResponseCurrent) && catalogItems.length === 0 ? ( // 占位骨架:与真实卡片同栅格、同行数、同高度,内容到位后原地替换不跳动。
- {Array.from({ length: RECOMMENDED_LIMIT }).map((_, i) => ( + {Array.from({ length: MARKET_PAGE_SIZE }).map((_, i) => (
-
+
+
+
+
))}
- ) : recommended.length === 0 ? ( + ) : catalogItems.length === 0 ? (
- {t('skillhub.home.recommendedEmpty')} + {t('skillhub.home.catalogEmpty')}
) : (
- {recommended.map((s) => ( + {catalogItems.map((s) => ( ))} + {marketResponseCurrent && marketHasMore ? ( +
+ +
+ ) : null}
)}
) : null} - {/* ③ 本地技能 */} - {!normalizedQuery || visibleLocalCount > 0 ? ( + {/* ② 本地技能 */} + {catalogTab === 'local' && (!normalizedQuery || visibleLocalCount > 0) ? (
- {visibleLocalCount === 0 ? (
{bootstrapped ? t('skillhub.home.localEmpty') : t('skillhub.welcome.scanning')} @@ -363,7 +430,6 @@ export function SkillhubHomeView({
{globalSkills.length > 0 && ( ) : null} - {normalizedQuery && !marketLoading && !hasSearchResults ? ( + {normalizedQuery && (catalogTab === 'local' || !marketLoading) && !hasSearchResults ? (
{t('skillhub.home.noSearchResults')}
@@ -397,16 +463,21 @@ export function SkillhubHomeView({ skill={previewSkill} onClose={() => setPreviewSkill(null)} primaryAction={ - previewSkill - ? marketCardPrimaryAction({ - isMine: previewSkill.isMine, - listVisibility: 'available', - cardState: previewSkill.cardState, - }) + previewSkill && user + ? (() => { + const action = marketCardPrimaryAction({ + isMine: previewSkill.isMine, + listVisibility: 'all', + cardState: previewSkill.cardState, + }); + return action === 'manage' && !identityPolicy.canWrite ? 'clone' : action; + })() : 'none' } onClone={handleClone} + onManageAction={management.handleManageAction} /> + -
-

{title}

- {count} -
-
- ); -} - function LocalGroup({ label, skills, syncResults, onOpen, }: { - label: string; + label?: string; skills: SkillhubSkill[]; /** server 归属结果(含 isMine),用于历史遗留 registry(origin 缺失)的来源推断 */ syncResults: Map; @@ -503,14 +563,14 @@ function LocalGroup({ const { t } = useTranslation(); return (
- {label} + {label ? {label} : null}
{skills.map((s) => { const Icon = KIND_ICON[s.kind] ?? Package; // 来源:'skillhub' = 从市场安装的副本(填充徽标);'local' = 自己开发/发布、 // 没走 SkillHub 安装的本地副本(弱化文字,不与 SkillHub 抢视觉)。 // origin 缺失的历史 registry 靠 server isMine 兜底判定(见 deriveSkillSource)。 - const sync = syncResults.get(s.name); + const sync = syncResults.get(skillhubCatalogKey(s.name, s.registryEntry?.catalogScope)); const isMine = sync?.exists === true ? sync.isMine : null; const source = deriveSkillSource( s.registryEntry?.origin, @@ -531,9 +591,13 @@ function LocalGroup({ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus-ring)]', )} > - - - + {s.kind === 'skill' ? ( + + ) : ( + + + + )} diff --git a/apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx b/apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx index 6eb4cb9057f..0749914b750 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 { @@ -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,21 +19,18 @@ 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'; import { useAuth } from '@/contexts/AuthContext'; import { CATEGORY_ALL } from '../../../shared/skillhubCategory'; +import { useSkillhubIdentityPolicy } from './hooks/useSkillhubIdentityPolicy'; const FILTER_CHIP_STYLE = { height: '32px', padding: '0 12px', fontSize: '12px' }; // Must match the global native scrollbar width in styles/globals.css. @@ -74,27 +70,19 @@ function FilterChip({ ); } -/** - * 市场路由门禁包装:市场不可见的账号(个人 / 非 xd 组织,见 lib/marketAccess.ts) - * 通过深链 / 历史记录直达 /skillhub/market 时,重定向回本地技能首页。 - * 登录态初始化期间(user 尚未水合)不误判,先按原样渲染。 - */ export function SkillhubMarketListView() { - const { user, isInitializing } = useAuth(); - if (!isInitializing && !canAccessSkillhubMarket(user)) { - return ; - } return ; } function SkillhubMarketListViewInner() { const { t } = useTranslation(); + const { user, isInitializing } = useAuth(); + const identityPolicy = useSkillhubIdentityPolicy(user); const location = useLocation(); 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 { @@ -113,7 +101,7 @@ function SkillhubMarketListViewInner() { setVisibility, loadMore, reload, - } = useMarketList(initialVisibility); + } = useMarketList(initialVisibility, { initialScope: 'market' }); const { categories } = useCategoryList(); // 「我的发布」按归属(个人 / 各团队)分组渲染;空组不显示(groupMineByOwner 只对有 item 的 owner 建组)。 @@ -138,6 +126,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); @@ -200,25 +192,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,73 +231,35 @@ 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) => ( { + const action = marketCardPrimaryAction({ + isMine: skill.isMine, + listVisibility: visibility, + cardState: skill.cardState, + }); + return action === 'manage' && !identityPolicy.canWrite ? 'clone' : action; + })() + : 'none'} allowPrivateVisibilityLabel={visibility === 'mine'} onClone={handleClone} - onManageAction={handleManageAction} + onManageAction={management.handleManageAction} onClick={handleCardClick} selected={skill.name === selectedName} /> @@ -445,22 +380,18 @@ function SkillhubMarketListViewInner() { ) : null} - {/* 可获取默认选中,语义对齐 SkillHub 徽标 */} - setVisibility('available')} - /> setVisibility('all')} /> - setVisibility('mine')} - /> + {user ? ( + setVisibility('mine')} + /> + ) : null}
@@ -562,45 +493,20 @@ function SkillhubMarketListViewInner() { open={previewSkill !== null} skill={previewSkill} onClose={handlePreviewClose} - primaryAction={previewSkill - ? marketCardPrimaryAction({ - isMine: previewSkill.isMine, - listVisibility: visibility, - cardState: previewSkill.cardState, - }) + primaryAction={previewSkill && user + ? (() => { + const action = marketCardPrimaryAction({ + isMine: previewSkill.isMine, + listVisibility: visibility, + cardState: previewSkill.cardState, + }); + return action === 'manage' && !identityPolicy.canWrite ? 'clone' : action; + })() : '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/SkillhubMarketPreviewPanel.tsx b/apps/desktop/src/renderer/features/skillhub/SkillhubMarketPreviewPanel.tsx index e6f40ad63e4..c8ea193524e 100644 --- a/apps/desktop/src/renderer/features/skillhub/SkillhubMarketPreviewPanel.tsx +++ b/apps/desktop/src/renderer/features/skillhub/SkillhubMarketPreviewPanel.tsx @@ -21,6 +21,7 @@ import { } from './lib/marketDetailViewModel'; import { marketActionErrorMessage } from './lib/marketErrors'; import { marketVisibilityLabelKey } from './lib/marketVisibility'; +import { skillPublisherLabel } from './lib/publisherLabel'; import { effectivePublishedStatus, effectivePublishedStatusVersion, @@ -99,7 +100,7 @@ export function SkillhubMarketPreviewPanel({ setFilesError(null); setSelectedPath(null); void window.electronAPI.skillhub - .getPublishedFiles({ name: skillName, version: skillVersion }) + .getPublishedFiles({ name: skillName, version: skillVersion, catalogScope: skill?.catalogScope }) .then((res) => { if (cancelled) return; setFilesLoading(false); @@ -119,7 +120,7 @@ export function SkillhubMarketPreviewPanel({ return () => { cancelled = true; }; - }, [panelOpen, skillName, skillVersion, t]); + }, [panelOpen, skill?.catalogScope, skillName, skillVersion, t]); useEffect(() => { if (!panelOpen || !skillName || !selectedPath) { @@ -131,7 +132,7 @@ export function SkillhubMarketPreviewPanel({ // 不预清空 file:切换文件时保留旧内容直到新内容到达,避免空白帧 setFileLoading(true); void window.electronAPI.skillhub - .readPublishedFile({ name: skillName, path: selectedPath, version: skillVersion }) + .readPublishedFile({ name: skillName, path: selectedPath, version: skillVersion, catalogScope: skill?.catalogScope }) .then((res) => { if (cancelled) return; setFileLoading(false); @@ -150,7 +151,7 @@ export function SkillhubMarketPreviewPanel({ return () => { cancelled = true; }; - }, [panelOpen, selectedPath, skillName, skillVersion, t]); + }, [panelOpen, selectedPath, skill?.catalogScope, skillName, skillVersion, t]); const tree = useMemo(() => buildPreviewTree(files), [files]); @@ -207,6 +208,7 @@ export function SkillhubMarketPreviewPanel({ .getScanStatus({ slug: skill.name, version: effectivePublishedStatusVersion(skill) ?? skill.latestVersion, + catalogScope: skill.catalogScope, }) .then((res) => { setScanResult(res.success @@ -236,7 +238,7 @@ export function SkillhubMarketPreviewPanel({ // New Maker 草稿,用户在那里用原生入口选 agent/模型/项目, // 发送时走正常建会话路径(蒸馏会话继承该会话的模型)。 saveComposerDraft(NEW_MAKER_DRAFT_KEY, { - text: plainTextToTiptapDoc(`/learn hub:${skill.name} `), + text: plainTextToTiptapDoc(`/learn hub:${skill.catalogScope ?? 'market'}:${skill.name} `), attachments: [], }); // 草稿目标重置为本地对话:残留的 device-link 远程草稿 @@ -286,7 +288,7 @@ export function SkillhubMarketPreviewPanel({

- {skill.authorName} · {skill.name} · v{skill.latestVersion} + {skillPublisherLabel(skill)} · {skill.name} · v{skill.latestVersion}

{skill.description && (

diff --git a/apps/desktop/src/renderer/features/skillhub/__tests__/ScanResultDialog.test.tsx b/apps/desktop/src/renderer/features/skillhub/__tests__/ScanResultDialog.test.tsx new file mode 100644 index 00000000000..3031583045d --- /dev/null +++ b/apps/desktop/src/renderer/features/skillhub/__tests__/ScanResultDialog.test.tsx @@ -0,0 +1,40 @@ +// @vitest-environment jsdom + +import { cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('@/lib/toast', () => ({ + toast: { error: vi.fn() }, +})); + +import { ScanResultDialog } from '../ScanResultDialog'; + +afterEach(cleanup); + +describe('ScanResultDialog pending review presentation', () => { + it('presents passed machine checks as success instead of failure', () => { + render( + , + ); + + expect(document.querySelector('.lucide-shield-check')).not.toBeNull(); + expect(document.querySelector('.lucide-clock-3')).toBeNull(); + expect(document.querySelector('.lucide-triangle-alert')).toBeNull(); + expect(document.body.textContent).not.toContain('archive-safety'); + expect(document.body.textContent).not.toContain('manifest'); + }); +}); diff --git a/apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx b/apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx index 40e2273df06..8e11e7c5c34 100644 --- a/apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx +++ b/apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx @@ -27,6 +27,7 @@ import { joinSkillInstallPath, normalizeInstallPathKey, } from '../lib/installTargetPaths'; +import type { SkillhubCatalogScope } from '../../../../shared/skillhubCatalog'; /** Minimal skill identity for the picker (market Clone or local import). */ export interface InstallTargetSkill { @@ -36,6 +37,7 @@ export interface InstallTargetSkill { /** Market Clone passes latestVersion; used when versionLabel is absent. */ latestVersion?: string | number; description?: string; + catalogScope?: SkillhubCatalogScope; } export type InstallTargetActionResult = @@ -55,6 +57,7 @@ interface InstallTargetPickerProps { name: string; installPath?: string; force?: boolean; + catalogScope?: SkillhubCatalogScope; }) => Promise; /** i18n key override for dialog title (default installPicker.title). */ titleKey?: string; @@ -73,11 +76,13 @@ async function runMarketInstall(params: { name: string; installPath?: string; force?: boolean; + catalogScope?: SkillhubCatalogScope; }): Promise { return window.electronAPI.skillhub.install({ name: params.name, installPath: params.installPath, force: params.force, + catalogScope: params.catalogScope, }); } @@ -145,7 +150,7 @@ export function InstallTargetPicker({ setBannerError(null); setInstalling(true); try { - const res = await runAction({ name: skill.name, installPath }); + const res = await runAction({ name: skill.name, installPath, catalogScope: skill.catalogScope }); if (res.success) { toast.success( t(successToastKey, { @@ -168,7 +173,7 @@ export function InstallTargetPicker({ cancelText: t('skillhub.installPicker.conflictDialog.cancel'), }); if (!ok) return; - const forced = await runAction({ name: skill.name, installPath, force: true }); + const forced = await runAction({ name: skill.name, installPath, force: true, catalogScope: skill.catalogScope }); if (forced.success) { toast.success( t(successToastKey, { diff --git a/apps/desktop/src/renderer/features/skillhub/components/MarketCard.tsx b/apps/desktop/src/renderer/features/skillhub/components/MarketCard.tsx index 329e7e0609a..1a8a92568c3 100644 --- a/apps/desktop/src/renderer/features/skillhub/components/MarketCard.tsx +++ b/apps/desktop/src/renderer/features/skillhub/components/MarketCard.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ChevronDown, Clock3, Download, Eye, Pencil, Trash2, type LucideIcon } from 'lucide-react'; @@ -14,42 +13,14 @@ import { i18n } from '@/i18n'; import type { MarketSkill } from '../hooks/useMarketList'; import type { MarketCardPrimaryAction } from '../lib/marketDetailViewModel'; import { marketVisibilityLabelKey } from '../lib/marketVisibility'; +import { skillPublisherLabel } from '../lib/publisherLabel'; import { effectivePublishedStatus, isEffectiveActivePublishedReview, publishedStatusClass, publishedStatusLabelKey, } from '../lib/publishedStatus'; - -interface AuthorAvatarProps { - url: string | null; - initial: string; -} - -/** 18x18 圆形头像(F-UI-1 设计稿 av 尺寸)。url 存在时优先 ;失败回落字母。 */ -function AuthorAvatar({ url, initial }: AuthorAvatarProps) { - const [errored, setErrored] = useState(false); - const showImage = !!url && !errored; - - return ( -

- {showImage ? ( - {initial} setErrored(true)} - referrerPolicy="no-referrer" - /> - ) : ( - initial - )} -
- ); -} +import { SkillIcon } from './SkillIcon'; function visibilityLabel(skill: MarketSkill, allowPrivateLabel: boolean): string { return i18n.t(marketVisibilityLabelKey({ @@ -201,9 +172,10 @@ export function MarketCard({ style={{ gap: '10px', height: '220px', borderWidth: '1.5px' }} > {/* Title */} -
+
+

{skill.displayName || skill.name} @@ -212,9 +184,8 @@ export function MarketCard({ {/* Author · Version + Visibility tag */}
- - {skill.authorName} · {versionStr} + {skillPublisherLabel(skill)} · {versionStr} {status ? ( category.source === 'author') + .map((category) => [category.slug, category]), + ); + // Private/shared author tags may have no public-market count and thus be + // absent from /categories. Preserve them from the detail response so an + // unrelated metadata edit does not silently clear the current tag. + for (const tag of infoRes.info.tags ?? []) { + if (tag.source !== 'author' || editableBySlug.has(tag.slug)) continue; + editableBySlug.set(tag.slug, { + slug: tag.slug, + name: tag.name, + count: 0, + myCount: 1, + source: 'author', + }); + } + const editableCategories = [...editableBySlug.values()]; + const currentCategory = [ + ...(infoRes.info.tags ?? []).filter((tag) => tag.source === 'author').map((tag) => tag.slug), + ...(infoRes.info.categories ?? []), + ...currentCategories, + ].find((slug) => editableBySlug.has(slug)); + setCategorySlug(currentCategory ?? ''); + setCategories(editableCategories); }); return () => { cancelled = true; }; }, [open, skillName, currentCategories]); @@ -91,8 +115,7 @@ export function MarketInfoEditDialog({ const displayNameMissing = displayName.trim().length === 0; const displayNameOverLimit = displayName.length > DISPLAY_NAME_LIMIT; const descriptionOverLimit = description.length > DESCRIPTION_LIMIT; - const categoryMissing = categories.length > 0 && !categorySlug; - const invalid = displayNameMissing || displayNameOverLimit || descriptionOverLimit || categoryMissing; + const invalid = displayNameMissing || displayNameOverLimit || descriptionOverLimit; const handleSave = async () => { if (invalid || saving) return; @@ -103,7 +126,9 @@ export function MarketInfoEditDialog({ fields: { displayName: displayName.trim(), summary: description, - categories: categorySlug ? [categorySlug] : [], + tags: categorySlug + ? [categories.find((category) => category.slug === categorySlug)?.name ?? categorySlug] + : [], }, }); if (!res.success) { @@ -243,11 +268,6 @@ export function MarketInfoEditDialog({ })), ]} /> - {categoryMissing ? ( -

- {t('skillhub.publishDialog.categoryRequired')} -

- ) : null}
)} diff --git a/apps/desktop/src/renderer/features/skillhub/components/SkillIcon.tsx b/apps/desktop/src/renderer/features/skillhub/components/SkillIcon.tsx new file mode 100644 index 00000000000..3d9a6197516 --- /dev/null +++ b/apps/desktop/src/renderer/features/skillhub/components/SkillIcon.tsx @@ -0,0 +1,31 @@ +import { useState } from 'react'; +import { Package } from 'lucide-react'; + +const DEFAULT_SKILL_ICON_URL = /\/assets\/default-skill-icon(?:-v\d+)?\.svg(?:[?#]|$)/i; + +/** 默认占位与本地 Skill 共用 Package 图标;仅真正配置的市场图标使用图片。 */ +export function SkillIcon({ url }: { url?: string }) { + const normalizedUrl = url?.trim() || null; + const [failedUrl, setFailedUrl] = useState(null); + const useRemoteIcon = normalizedUrl !== null + && normalizedUrl !== failedUrl + && !DEFAULT_SKILL_ICON_URL.test(normalizedUrl); + + return ( + + {useRemoteIcon ? ( + setFailedUrl(normalizedUrl)} + referrerPolicy="no-referrer" + /> + ) : ( + + ); +} diff --git a/apps/desktop/src/renderer/features/skillhub/components/VisibilityEditorDialog.tsx b/apps/desktop/src/renderer/features/skillhub/components/VisibilityEditorDialog.tsx index cd3dcb2f261..d6459f5abf1 100644 --- a/apps/desktop/src/renderer/features/skillhub/components/VisibilityEditorDialog.tsx +++ b/apps/desktop/src/renderer/features/skillhub/components/VisibilityEditorDialog.tsx @@ -1,14 +1,8 @@ /** * VisibilityEditorDialog — 「管理可见性」弹窗,对齐 SkillHub 工作台同名能力。 * - * 一个入口管理三件事(SkillHub origin/main 语义): - * 1. 可见范围:公开 / 给团队使用 / 仅自己使用(三卡,带「当前」徽标) - * 2. 发布者:个人 / 团队(+ 发布团队选择)——「谁能管」 - * 3. 谁可以使用:团队可见时的额外团队/部门多选——「谁能用」 - * - * 保存走两步(同 SkillHub web): - * 1) PATCH metadata { visibility, teamSlug | null } —— 可见档位 + 归属 - * 2) set-visibility { visibility, visibleSlugs } —— 可见对象 + * 归属由新服务根据当前 membership 固定:个人 Skill 只允许 + * public/private,组织 Skill 只允许 public/shared。客户端不再提供归属转移。 */ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -18,13 +12,11 @@ import { Globe, Lock, Users, X } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Spinner } from '@/components/ui/spinner'; import { toast } from '@/lib/toast'; +import { useAuth } from '@/contexts/AuthContext'; +import { useSkillhubIdentityPolicy } from '../hooks/useSkillhubIdentityPolicy'; -import { AudiencePicker, PublisherPicker } from './TeamScopePicker'; import { VisibilityCard } from '../PublishDialog'; import { marketActionErrorMessage } from '../lib/marketErrors'; -import { matchesDeptMirrorTeamSlug } from '../lib/publishForm'; -import { selectableUserTeams } from '../lib/userTeams'; -import type { TeamOption } from '../lib/marketDetailViewModel'; export type VisibilityTier = 'public' | 'team' | 'private'; @@ -37,8 +29,7 @@ type VisibilityEditorDialogProps = { currentTier: VisibilityTier; /** 当前归属:org = 团队归属 */ currentOwnerType?: string; - /** 当前归属团队 slug(ownerType=org 时) */ - currentOwnerSlug?: string; + publicReview?: { status: 'pending' | 'rejected'; reason?: string }; /** 保存成功后回调(父组件刷新详情) */ onSaved: () => void; /** viewer 等无写权限时:弹窗只读打开(控件禁用 + 顶部提示),不能保存。 */ @@ -51,11 +42,13 @@ export function VisibilityEditorDialog({ skillName, currentTier, currentOwnerType, - currentOwnerSlug, + publicReview, onSaved, readOnly = false, }: VisibilityEditorDialogProps) { const { t } = useTranslation(); + const { user } = useAuth(); + const identityPolicy = useSkillhubIdentityPolicy(user); const currentOwnerIsTeam = currentOwnerType === 'org'; // loading 初始为 true,且关闭时复位 —— Dialog 在数据就绪前不挂载, @@ -64,13 +57,6 @@ export function VisibilityEditorDialog({ const [saving, setSaving] = useState(false); const [loadError, setLoadError] = useState(null); const [tier, setTier] = useState(currentTier); - const [ownerMode, setOwnerMode] = useState<'personal' | 'team'>(currentOwnerIsTeam ? 'team' : 'personal'); - const [ownerTeamSlug, setOwnerTeamSlug] = useState(currentOwnerIsTeam ? (currentOwnerSlug ?? '') : ''); - const [deptIds, setDeptIds] = useState([]); - const [deptNames, setDeptNames] = useState([]); - const [teams, setTeams] = useState([]); - const [visibleDeptIds, setVisibleDeptIds] = useState([]); - const [sharedTeamSlugs, setSharedTeamSlugs] = useState([]); useEffect(() => { if (!open) { @@ -79,60 +65,18 @@ export function VisibilityEditorDialog({ } // 每次打开都从当前状态重置(避免上次编辑残留) setTier(currentTier); - setOwnerMode(currentOwnerIsTeam ? 'team' : 'personal'); - setOwnerTeamSlug(currentOwnerIsTeam ? (currentOwnerSlug ?? '') : ''); - let cancelled = false; - setLoading(true); setLoadError(null); - void Promise.all([ - window.electronAPI.skillhub.getPublishedVisibility(skillName), - window.electronAPI.skillhub.getMyDepts(), - window.electronAPI.skillhub.listUserTeams(), - ]).then(([vis, depts, teamsRes]) => { - if (cancelled) return; - setLoading(false); - if (!vis.success) { - setLoadError(marketActionErrorMessage(vis.error, vis.errorCode)); - return; - } - const ids = depts.success ? depts.ids : []; - const names = depts.success ? depts.names : []; - setDeptIds(ids); - setDeptNames(names); - const regularTeams = teamsRes.success - ? selectableUserTeams(teamsRes.teams) - : []; - // 团队选项:普通团队;部门统一走 od- id(归属保存走 PATCH deptId) - setTeams(regularTeams - .map((team) => ({ slug: team.slug, name: team.name, source: team.source }))); - // 当前归属是部门镜像团队时,映射回 od- id,让「部门」组里正确高亮 - if (currentOwnerIsTeam && currentOwnerSlug) { - const ownerTeamSource = teamsRes.success - ? teamsRes.teams.find((team) => team.slug === currentOwnerSlug)?.source - : undefined; - const ownerOd = ids.find((id) => - matchesDeptMirrorTeamSlug(id, currentOwnerSlug, ownerTeamSource)); - if (ownerOd) setOwnerTeamSlug(ownerOd); - } - // 回显;不在我可选范围内的历史值原样保留,保存时不静默丢弃 - setVisibleDeptIds(vis.visibleDepts ?? []); - setSharedTeamSlugs((vis.sharedTeams ?? []).map((team) => team.slug)); - }); - return () => { cancelled = true; }; - }, [open, skillName, currentTier, currentOwnerIsTeam, currentOwnerSlug, t]); + setLoading(false); + }, [open, currentTier]); // ── 校验(对齐 SkillHub StepMeta/管理弹窗规则) ────────────────────────── - const needsManagementTeam = ownerMode === 'team' && !ownerTeamSlug; - const needsAudience = - tier === 'team' && - !(ownerMode === 'team' && ownerTeamSlug) && - visibleDeptIds.length === 0 && - sharedTeamSlugs.length === 0; - // ── 影响提示(SkillHub origin/main 同款文案) ─────────────────────────── const tierChanged = tier !== currentTier; const leavingMarket = currentTier === 'public' && tier !== 'public'; - const teamOwnedToPrivate = currentOwnerIsTeam && ownerMode === 'personal' && tier === 'private'; + const teamOwnedToPrivate = currentOwnerIsTeam && tier === 'private'; + const tierAllowed = identityPolicy.allowedVisibilities.includes( + tier === 'team' ? 'DEPARTMENT_SCOPED' : tier.toUpperCase() as 'PUBLIC' | 'PRIVATE', + ); const impactText = useMemo(() => { if (tier === 'private') { if (teamOwnedToPrivate) { @@ -152,39 +96,26 @@ export function VisibilityEditorDialog({ const chooseTier = (next: VisibilityTier) => { setTier(next); - // 仅自己使用 → 归属强制个人(SkillHub 同款规则) - if (next === 'private') setOwnerMode('personal'); }; const handleSave = async () => { - if (needsManagementTeam || needsAudience) return; + const publishVisibility = tier === 'team' ? 'DEPARTMENT_SCOPED' : tier.toUpperCase(); + if (!identityPolicy.allowedVisibilities.includes(publishVisibility as 'PUBLIC' | 'DEPARTMENT_SCOPED' | 'PRIVATE')) return; setSaving(true); try { const visibility = tier === 'team' ? 'shared' as const : tier; - // 第一步:可见档位 + 归属。teamSlug 是统一参数:普通团队 slug 或 - // od- 部门 id(Hub 端识别前缀并懒创建镜像团队),消费方不感知差异 - const fields: { - visibility: 'private' | 'shared' | 'public'; - teamSlug?: string | null; - } = { visibility }; - if (ownerMode === 'team' && ownerTeamSlug) fields.teamSlug = ownerTeamSlug; - else if (currentOwnerIsTeam) fields.teamSlug = null; - const metaRes = await window.electronAPI.skillhub.updatePublished({ name: skillName, fields }); - if (!metaRes.success) { - toast.error(marketActionErrorMessage(metaRes.error, metaRes.errorCode)); - return; - } - // 第二步:可见对象(非团队档清空) + // 归属由服务端根据当前 membership 固定,客户端只修改可见性。 const visRes = await window.electronAPI.skillhub.setPublishedVisibility({ name: skillName, visibility, - visibleSlugs: tier === 'team' ? [...visibleDeptIds, ...sharedTeamSlugs] : [], }); if (!visRes.success) { toast.error(marketActionErrorMessage(visRes.error, visRes.errorCode)); return; } - toast.success(t('skillhub.visibilityEditor.saved')); + toast.success(visRes.result?.reviewStatus === 'pending' + ? t('skillhub.visibilityEditor.publicReviewSubmitted') + : t('skillhub.visibilityEditor.saved')); onOpenChange(false); onSaved(); } finally { @@ -240,12 +171,19 @@ export function VisibilityEditorDialog({ {t('skillhub.market.noManagePermission')}

) : null} + {publicReview ? ( +
+ {publicReview.status === 'pending' + ? t('skillhub.visibilityEditor.publicReviewPending') + : t('skillhub.visibilityEditor.publicReviewRejected', { reason: publicReview.reason || '—' })} +
+ ) : null} {/* 可见范围三卡(与发布弹窗共用 VisibilityCard) */}
{t('skillhub.visibilityEditor.tierLabel')} -
+
chooseTier('public')} /> - } - disabled={readOnly} - selected={tier === 'team'} - onSelect={() => chooseTier('team')} - /> - } - disabled={readOnly} - selected={tier === 'private'} - onSelect={() => chooseTier('private')} - /> + {identityPolicy.ownerType === 'organization' ? ( + } + disabled={readOnly} + selected={tier === 'team'} + onSelect={() => chooseTier('team')} + /> + ) : ( + } + disabled={readOnly} + selected={tier === 'private'} + onSelect={() => chooseTier('private')} + /> + )}
- {/* 发布者 — 与发布弹窗一致:私有档也显示,团队卡置灰 */} - { - // 切到团队且未选过时,默认所属部门(第一个),其次普通团队 - const next = mode === 'team' && !slug - ? (deptIds[0] ?? teams[0]?.slug ?? '') - : slug; - setOwnerMode(mode); - setOwnerTeamSlug(next); - if (mode === 'team' && next) { - // 发布团队天然可见,不重复出现在「谁可以使用」里 - setSharedTeamSlugs((prev) => prev.filter((s) => s !== next)); - setVisibleDeptIds((prev) => prev.filter((s) => s !== next)); - } - }} - /> - {needsManagementTeam ? ( -

- {t('skillhub.publishDialog.publisherTeamRequired')} -

- ) : null} - - {/* 谁可以使用(团队档) */} - {tier === 'team' ? ( - <> - { - setVisibleDeptIds(value.visibleDeptIds); - setSharedTeamSlugs(value.sharedTeamSlugs); - }} - /> - {needsAudience ? ( -

- {t('skillhub.publishDialog.audienceRequired')} -

- ) : null} - - ) : null} - {/* 影响提示 */} {impactText ? (
diff --git a/apps/desktop/src/renderer/features/skillhub/components/__tests__/MarketCard.test.tsx b/apps/desktop/src/renderer/features/skillhub/components/__tests__/MarketCard.test.tsx new file mode 100644 index 00000000000..dccfde916c8 --- /dev/null +++ b/apps/desktop/src/renderer/features/skillhub/components/__tests__/MarketCard.test.tsx @@ -0,0 +1,77 @@ +/** + * SkillHub 市场卡片图标回归:只展示 Skill 图标,并保证远程资源不可用时仍有本地兜底。 + * @vitest-environment jsdom + */ + +import { fireEvent, render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { MarketSkill } from '../../hooks/useMarketList'; +import { MarketCard } from '../MarketCard'; + +const MARKET_SKILL: MarketSkill = { + name: 'demo-skill', + icon: 'https://assets.example.test/demo-skill.png', + displayName: 'Demo Skill', + description: 'A demo skill', + authorName: 'Publisher', + authorId: 'publisher-1', + authorAvatarUrl: 'https://assets.example.test/publisher.png', + avatarInitial: 'P', + isMine: false, + canManage: false, + latestVersion: '1.0.0', + visibility: 'PUBLIC', + publishedVisibility: 'public', + visibleDeptIds: [], + categories: [], + tags: [], + githubUrl: null, + publishedAt: '2026-09-01T00:00:00.000Z', + relativeTime: 'today', + downloads: 0, + installedLocally: false, + installedVersion: null, + installedAbsolutePath: null, + hasAnyInstall: false, + latestPublishedFromDeviceId: null, + cardState: 'not-installed', +}; + +describe('MarketCard Skill icon', () => { + it('renders only a custom Skill icon and falls back to the local Package icon on load failure', () => { + const { container } = render( + , + ); + + const images = container.querySelectorAll('img'); + expect(images).toHaveLength(1); + expect(images[0]?.getAttribute('src')).toBe(MARKET_SKILL.icon); + + fireEvent.error(images[0]!); + + expect(container.querySelector('img')).toBeNull(); + expect(container.querySelector('.lucide-package')).not.toBeNull(); + expect(container.textContent).toContain('Publisher · v1.0.0'); + }); + + it.each([ + undefined, + 'http://localhost:3345/assets/default-skill-icon-v4.svg', + ])('uses the same Package glyph as local Skills for default URL %s', (icon) => { + const { container } = render( + , + ); + + expect(container.querySelector('img')).toBeNull(); + expect(container.querySelector('.lucide-package')).not.toBeNull(); + }); +}); 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 new file mode 100644 index 00000000000..9fb17972937 --- /dev/null +++ b/apps/desktop/src/renderer/features/skillhub/hooks/__tests__/useSkillhubIdentityPolicy.test.ts @@ -0,0 +1,25 @@ +// @vitest-environment jsdom + +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { useSkillhubIdentityPolicy } from '../useSkillhubIdentityPolicy'; + +describe('useSkillhubIdentityPolicy', () => { + 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(true); + expect(result.current.allowedVisibilities).toEqual(['PUBLIC', 'DEPARTMENT_SCOPED']); + }); + + 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' })); + + 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 37f02627c1b..8c9239c26c2 100644 --- a/apps/desktop/src/renderer/features/skillhub/hooks/useMarketList.ts +++ b/apps/desktop/src/renderer/features/skillhub/hooks/useMarketList.ts @@ -25,6 +25,7 @@ import { useTranslation } from 'react-i18next'; import { i18n } from '@/i18n'; import { CATEGORY_ALL, type MarketCategory } from '../../../../shared/skillhubCategory'; +import type { SkillhubCatalogScope } from '../../../../shared/skillhubCatalog'; import type { HubPublishedVisibility } from '../lib/marketVisibility'; import { filterAvailableMarketItems } from '../lib/marketDetailViewModel'; @@ -32,6 +33,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; /** @@ -53,15 +55,20 @@ export type MarketCardState = export interface MarketSkill { /** 服务端主键 = name,前端 list key 用它。 */ name: string; + /** Skill 图标 URL;旧服务响应缺失时保持 undefined。 */ + icon?: string; displayName: string; description: string; authorName: string; + /** 实际提交当前版本的成员;authorName 仍表示个人或组织归属。 */ + publisherName?: string; authorId: string; /** 飞书头像 URL;为 null/失败时回落到 avatarInitial 字母。 */ authorAvatarUrl: string | null; /** Latin/中文首字符,用于头像 fallback。 */ avatarInitial: string; isMine: boolean; + canManage: boolean; latestVersion: string; visibility: 'PUBLIC' | 'DEPARTMENT_SCOPED'; publishedVisibility?: HubPublishedVisibility; @@ -71,9 +78,18 @@ export interface MarketSkill { version: string; status?: string; }; + visibilityReview?: { + requestedVisibility: 'public'; + status: 'pending' | 'rejected'; + reason?: string; + }; visibleDeptIds: string[]; /** 分类 slug 列表。服务端目前还未返回时给空数组兜底。 */ categories: string[]; + /** 服务端可搜索标签,保留显示名供详情等消费方使用。 */ + tags: Array<{ slug: string; name: string; source?: 'author' | 'platform' }>; + /** Skill 对应的公开仓库地址;null 表示发布者未配置。 */ + githubUrl: string | null; publishedAt: string; // ISO /** 相对时间显示,如 "3 天前"、"昨天"、"刚刚"。 */ relativeTime: string; @@ -91,16 +107,21 @@ export interface MarketSkill { latestPublishedFromDeviceId: string | null; /** 派生的 card 状态,UI 直接 switch 这个字段决定按钮。 */ cardState: MarketCardState; + /** 列表所在的通用目录,后续详情和安装请求必须继续携带。 */ + catalogScope?: SkillhubCatalogScope; } interface ServerListItem { name: string; + icon?: string; displayName: string; description: string; authorId: string; authorName: string; + publisherName?: string; authorAvatarUrl: string | null; isMine: boolean; + canManage: boolean; latestVersion: string; visibility: 'PUBLIC' | 'DEPARTMENT_SCOPED'; publishedVisibility?: HubPublishedVisibility; @@ -110,15 +131,23 @@ interface ServerListItem { version: string; status?: string; }; + visibilityReview?: { + requestedVisibility: 'public'; + status: 'pending' | 'rejected'; + reason?: string; + }; visibleDeptIds: string[]; categories?: string[]; + tags?: Array<{ slug: string; name: string; source?: 'author' | 'platform' }>; + githubUrl?: string | null; publishedAt: string; downloads?: number; /** 跨设备识别:null = pre-feature 历史版本 */ latestPublishedFromDeviceId: string | null; + catalogScope?: SkillhubCatalogScope; } -const PAGE_SIZE = 24; +export const MARKET_PAGE_SIZE = 24; function deriveAvatarInitial(authorName: string): string { const trimmed = authorName.trim(); @@ -204,21 +233,27 @@ function mapServerToView( ); return { name: item.name, + icon: item.icon, displayName: item.displayName, description: item.description, authorName: item.authorName, + publisherName: item.publisherName || item.authorName, authorId: item.authorId, authorAvatarUrl: item.authorAvatarUrl ?? null, avatarInitial: deriveAvatarInitial(item.authorName), isMine: item.isMine, + canManage: item.canManage, latestVersion: item.latestVersion, visibility: item.visibility, publishedVisibility: item.publishedVisibility, ownerType: item.ownerType, moderationStatus: item.moderationStatus, pendingVersion: item.pendingVersion, + visibilityReview: item.visibilityReview, visibleDeptIds: item.visibleDeptIds, categories: item.categories ?? [], + tags: item.tags ?? [], + githubUrl: item.githubUrl ?? null, publishedAt: item.publishedAt, relativeTime: formatMarketRelativeTime(item.publishedAt, translate), downloads: Number.isFinite(item.downloads) ? item.downloads ?? 0 : 0, @@ -228,6 +263,7 @@ function mapServerToView( hasAnyInstall, latestPublishedFromDeviceId: item.latestPublishedFromDeviceId, cardState: deriveCardState(item, group, installingNames.has(item.name)), + catalogScope: item.catalogScope, }; } @@ -237,6 +273,8 @@ interface MarketListState { loadingMore: boolean; error: string | null; nextCursor: string | null; + resolvedScope: CatalogScope | null; + resolvedMine: boolean | null; } const INITIAL: MarketListState = { @@ -245,12 +283,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; } @@ -260,21 +301,28 @@ type MarketPageResult = | { success: false; error?: string }; export function useMarketList( - initialVisibility: Visibility = 'available', + initialVisibility: Visibility = 'all', options?: { /** * false 时完全不发市场请求(items 保持空、loading 保持 false)。 - * 供市场不可见的账号(见 lib/marketAccess.ts)跳过网络与骨架屏;翻回 true 后自动补拉。 + * 供本地技能 Tab 跳过云端请求与骨架屏;切回云端目录后自动补拉。 */ 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); const [state, setState] = useState(INITIAL); // 当前正在跑 install 的 name 集合(按 name 串行;同 name 不能重复触发) @@ -325,9 +373,10 @@ 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, mine: params.mine, available: false, category: params.category, @@ -354,6 +403,7 @@ export function useMarketList( cursor, sort: params.sort, q: params.q, + scope: params.scope, mine: params.mine, category: params.category, }); @@ -365,7 +415,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, @@ -375,13 +425,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, @@ -394,6 +452,8 @@ export function useMarketList( loadingMore: false, error: res.error ?? i18n.t('skillhub.market.installError'), nextCursor: null, + resolvedScope: params.scope, + resolvedMine: params.mine, }); return; } @@ -403,6 +463,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; @@ -412,6 +474,8 @@ export function useMarketList( loadingMore: false, error: err instanceof Error ? err.message : String(err), nextCursor: null, + resolvedScope: params.scope, + resolvedMine: params.mine, }); } }, @@ -430,6 +494,7 @@ export function useMarketList( cursor, sort: sortBy, q: searchQuery, + scope: catalogScope, mine: visibility === 'mine', available: visibility === 'available', category: categoryFilter !== CATEGORY_ALL ? categoryFilter : undefined, @@ -449,7 +514,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); @@ -462,11 +527,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) 序列化字符串,避免对象引用变化导致每次都跑。 @@ -513,6 +579,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), []); @@ -541,12 +608,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..b9d6cdb1288 --- /dev/null +++ b/apps/desktop/src/renderer/features/skillhub/hooks/useMarketManagement.tsx @@ -0,0 +1,143 @@ +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useConfirmDialog } from '@/components/ui/confirm-dialog-provider'; +import { useAuth } from '@/contexts/AuthContext'; +import { toast } from '@/lib/toast'; +import { MarketInfoEditDialog } from '../components/MarketInfoEditDialog'; +import { type MarketCardManageAction } from '../components/MarketCard'; +import { VisibilityEditorDialog, type VisibilityTier } from '../components/VisibilityEditorDialog'; +import { marketActionErrorMessage } from '../lib/marketErrors'; +import { refresh as refreshSkillhub } from './useSkillhub'; +import type { MarketSkill } from './useMarketList'; +import { useSkillhubIdentityPolicy } from './useSkillhubIdentityPolicy'; + +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 { reload, onClone, onDeleted } = options; + const { t } = useTranslation(); + const { user } = useAuth(); + const identityPolicy = useSkillhubIdentityPolicy(user); + const { confirm } = useConfirmDialog(); + const [editTarget, setEditTarget] = useState(null); + const [visibilityTarget, setVisibilityTarget] = useState(null); + const isReadOnly = useCallback( + (skill: MarketSkill) => !identityPolicy.canWrite || !skill.canManage, + [identityPolicy.canWrite], + ); + + 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 !== 'clone' && 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} + publicReview={controller.visibilityTarget.visibilityReview} + readOnly={controller.isReadOnly(controller.visibilityTarget)} + onSaved={controller.visibilitySaved} + /> + ) : null} + + ); +} diff --git a/apps/desktop/src/renderer/features/skillhub/hooks/useSkillSync.ts b/apps/desktop/src/renderer/features/skillhub/hooks/useSkillSync.ts index f731eb737c5..2935e19f930 100644 --- a/apps/desktop/src/renderer/features/skillhub/hooks/useSkillSync.ts +++ b/apps/desktop/src/renderer/features/skillhub/hooks/useSkillSync.ts @@ -6,6 +6,7 @@ */ import { useEffect, useRef } from 'react'; +import { skillhubCatalogKey } from '../../../../shared/skillhubCatalog'; /** * Store setter injected by useSkillhub at startup. @@ -32,8 +33,17 @@ export function registerSyncStoreSetters(setters: SyncSetters): void { storeSetters = setters; } -function uniqueSkillSlugs(skills: SkillhubSkill[]): string[] { - return [...new Set(skills.filter((s) => s.kind === 'skill').map((s) => s.name))]; +function uniqueSkillRefs(skills: SkillhubSkill[]): Array<{ slug: string; catalogScope?: 'market' | 'team' }> { + const byKey = new Map(); + for (const skill of skills) { + if (skill.kind !== 'skill') continue; + const catalogScope = skill.registryEntry?.catalogScope; + byKey.set(skillhubCatalogKey(skill.name, catalogScope), { + slug: skill.name, + ...(catalogScope ? { catalogScope } : {}), + }); + } + return [...byKey.values()]; } export function globalInstalledSkills(skills: SkillhubSkill[]): Array<{ slug: string; version: string }> { @@ -54,7 +64,7 @@ async function doSync(skills: SkillhubSkill[]): Promise { const requestId = ++fullSyncRequestId; try { const res = await window.electronAPI.skillhub.sync({ - slugs: uniqueSkillSlugs(skills), + skills: uniqueSkillRefs(skills), }); if (requestId !== fullSyncRequestId) return; if (res.success && res.results) { @@ -91,7 +101,9 @@ export async function triggerIncrementalSync( ): Promise { const requestId = ++incrementalSyncRequestId; try { - const res = await window.electronAPI.skillhub.sync({ slugs: [...new Set(slugs)] }); + const res = await window.electronAPI.skillhub.sync({ + skills: [...new Set(slugs)].map((slug) => ({ slug })), + }); if (requestId !== incrementalSyncRequestId) return; if (res.success && res.results) { storeSetters?.mergeSyncResults(res.results); diff --git a/apps/desktop/src/renderer/features/skillhub/hooks/useSkillhub.ts b/apps/desktop/src/renderer/features/skillhub/hooks/useSkillhub.ts index 44808780bbc..fd65bf54b5b 100644 --- a/apps/desktop/src/renderer/features/skillhub/hooks/useSkillhub.ts +++ b/apps/desktop/src/renderer/features/skillhub/hooks/useSkillhub.ts @@ -17,6 +17,7 @@ */ import { useEffect, useMemo, useState } from 'react'; +import { skillhubCatalogKey } from '../../../../shared/skillhubCatalog'; import { invalidateSkillSyncRequests, registerSyncStoreSetters } from './useSkillSync'; interface SkillhubProject { @@ -165,7 +166,7 @@ export function setSyncResults( availableUninstalledCount?: number, ): void { const map = new Map(); - for (const r of results) map.set(r.name, r); + for (const r of results) map.set(skillhubCatalogKey(r.name, r.catalogScope), r); setState({ syncResults: map, syncError: null, @@ -180,7 +181,7 @@ export function setSyncResults( */ export function mergeSyncResults(results: SkillhubSyncResult[]): void { const map = new Map(state.syncResults); - for (const r of results) map.set(r.name, r); + for (const r of results) map.set(skillhubCatalogKey(r.name, r.catalogScope), r); setState({ syncResults: map }); } diff --git a/apps/desktop/src/renderer/features/skillhub/hooks/useSkillhubIdentityPolicy.ts b/apps/desktop/src/renderer/features/skillhub/hooks/useSkillhubIdentityPolicy.ts new file mode 100644 index 00000000000..dbeedc88010 --- /dev/null +++ b/apps/desktop/src/renderer/features/skillhub/hooks/useSkillhubIdentityPolicy.ts @@ -0,0 +1,16 @@ +import { useMemo } from 'react'; + +import { deriveSkillhubIdentityPolicy, type SkillhubIdentity, type SkillhubIdentityPolicy } from '../../../../shared/skillhubIdentityPolicy'; + +/** + * 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 { + return useMemo( + () => deriveSkillhubIdentityPolicy(identity), + [identity?.membershipKind, identity?.orgSlug], + ); +} diff --git a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/detailButtons.test.ts b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/detailButtons.test.ts index 71e2b20ab45..f0f99dacec2 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/detailButtons.test.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/detailButtons.test.ts @@ -28,13 +28,15 @@ function makeRegistryEntry(overrides: Partial = {}): StoredInstal } function makeInfo(overrides: Partial = {}): SkillhubInfoResult { + const isMine = overrides.isMine ?? false; return { name: 'test-skill', displayName: 'Test Skill', description: '', authorId: 'user_other', authorName: 'Alice', - isMine: false, + isMine, + canManage: overrides.canManage ?? isMine, latestVersion: '5', folderHash: 'def456', visibility: 'PUBLIC', @@ -46,9 +48,11 @@ function makeInfo(overrides: Partial = {}): SkillhubInfoResu } function makeDetailState(overrides: Partial = {}): DetailState { + const isMine = overrides.isMine ?? null; return { origin: null, - isMine: null, + isMine, + canManage: overrides.canManage ?? isMine, localVersion: null, latestVersion: null, marketDeleted: false, @@ -76,6 +80,7 @@ describe('deriveDetailState — no registryEntry', () => { expect(deriveDetailState(makeSkill({ registryEntry: null }), null, false)).toEqual({ origin: null, isMine: null, + canManage: null, localVersion: null, latestVersion: null, marketDeleted: false, @@ -91,6 +96,7 @@ describe('deriveDetailState — no registryEntry', () => { )).toEqual({ origin: null, isMine: true, + canManage: true, localVersion: null, latestVersion: '2', marketDeleted: false, @@ -108,6 +114,7 @@ describe('deriveDetailState — registryEntry + server info', () => { )).toEqual({ origin: 'installed', isMine: true, + canManage: true, localVersion: '3', latestVersion: '5', marketDeleted: false, @@ -123,6 +130,7 @@ describe('deriveDetailState — registryEntry + server info', () => { )).toEqual({ origin: 'installed', isMine: false, + canManage: false, localVersion: '1', latestVersion: '1', marketDeleted: false, @@ -138,6 +146,7 @@ describe('deriveDetailState — registryEntry + server info', () => { )).toEqual({ origin: null, isMine: true, + canManage: true, localVersion: '2', latestVersion: '3', marketDeleted: false, @@ -155,6 +164,7 @@ describe('deriveDetailState — server unavailable and market deleted', () => { )).toEqual({ origin: 'installed', isMine: null, + canManage: null, localVersion: '3', latestVersion: null, marketDeleted: false, @@ -489,4 +499,80 @@ describe('deriveDetailActionState', () => { null, )?.status).toEqual({ kind: 'publish-to-market' }); }); + + it('hides first-publish actions for read-only Skill Hub identities', () => { + expect(deriveDetailActionState( + makeDetailState({ isMine: false, latestVersion: null, marketDeleted: true }), + null, + null, + null, + false, + )?.status).toEqual({ kind: 'none' }); + }); + + it('keeps the published version visible but hides republish for read-only identities', () => { + expect(deriveDetailActionState( + makeDetailState({ isMine: true, latestVersion: '1.2.3' }), + makeRegistryEntry({ origin: 'published', version: '1.2.3', folderHash: 'before' }), + 'after', + null, + false, + )?.status).toEqual({ kind: 'published-tag', version: '1.2.3' }); + }); + + it('does not offer management actions when organization ownership lacks per-skill permission', () => { + expect(deriveDetailActionState( + makeDetailState({ + origin: 'published', + isMine: true, + canManage: false, + localVersion: '1.2.3', + latestVersion: '1.2.3', + }), + makeRegistryEntry({ origin: 'published', version: '1.2.3', folderHash: 'before' }), + 'after', + )?.status).toEqual({ kind: 'none' }); + }); + + it('uses the native write target to classify a team catalog skill as a first publish', () => { + const teamState = makeDetailState({ + origin: 'installed', + isMine: true, + canManage: false, + localVersion: '1.0.0', + latestVersion: '1.0.0', + }); + const nativeState = makeDetailState({ + origin: 'installed', + isMine: false, + canManage: false, + localVersion: '1.0.0', + latestVersion: null, + marketDeleted: true, + }); + + expect(deriveDetailActionState( + teamState, + makeRegistryEntry({ origin: 'installed', version: '1.0.0' }), + 'abc123', + null, + true, + nativeState, + )?.status).toEqual({ kind: 'publish-to-market' }); + }); + + it('hides update actions when the user is signed out', () => { + expect(deriveDetailActionState( + makeDetailState({ + origin: 'installed', + isMine: false, + localVersion: '1.0.0', + latestVersion: '2.0.0', + }), + makeRegistryEntry({ origin: 'installed', version: '1.0.0' }), + 'abc123', + null, + false, + )?.status).toEqual({ kind: 'none' }); + }); }); 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..67c7201128e --- /dev/null +++ b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/homeMarketFilter.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { + homeMarketQuery, + isHomeMarketResponseCurrent, + visibleHomeCatalogTabs, +} from '../homeMarketFilter'; + +describe('Skill home market filters', () => { + it('maps public and organization to generic catalog scopes', () => { + expect(homeMarketQuery('public')).toEqual({ + scope: 'market', + visibility: 'all', + sort: 'trending', + }); + expect(homeMarketQuery('organization')).toEqual({ + scope: 'team', + visibility: 'all', + sort: 'trending', + }); + }); + + 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('public'), { + scope: 'market', + 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__/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__/marketAccess.test.ts b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketAccess.test.ts deleted file mode 100644 index 22f269c557d..00000000000 --- a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketAccess.test.ts +++ /dev/null @@ -1,47 +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 { - membershipKind: 'org' as const, - orgName: null, - orgSlug: null, - ...overrides, - }; -} - -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('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('denies missing login state (fail-closed)', () => { - expect(canAccessSkillhubMarket(null)).toBe(false); - }); -}); diff --git a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketPreviewSync.test.ts b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketPreviewSync.test.ts index 6b31c3732f6..f74388d4e1f 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketPreviewSync.test.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/marketPreviewSync.test.ts @@ -12,11 +12,14 @@ function skill(name: string, description: string): MarketSkill { authorId: 'user_lizi', authorAvatarUrl: null, avatarInitial: 'L', - isMine: true, + isMine: true, + canManage: true, latestVersion: '1.0.0', visibility: 'PUBLIC', visibleDeptIds: [], categories: [], + tags: [], + githubUrl: null, publishedAt: '2026-06-11T00:00:00.000Z', relativeTime: '刚刚', downloads: 0, 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..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 @@ -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'); }); @@ -60,6 +60,41 @@ 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'"); + }); + + 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'); + expect(homeSource).toContain(''); + 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', () => { @@ -74,12 +109,13 @@ describe('market management copy and errors', () => { expect(zhLocale).toContain('也不会删除你本机的 Skill 文件'); }); - it('consolidates ownership transfer into the manage-visibility dialog', () => { + it('keeps ownership fixed by membership in the manage-visibility dialog', () => { const editorSource = readFileSync(resolve(skillhubDir, 'components/VisibilityEditorDialog.tsx'), 'utf8'); expect(editorSource).toContain('skillhub.visibilityEditor.tierLabel'); - expect(editorSource).toContain('PublisherPicker'); - expect(editorSource).toContain("teamSlug = null"); + expect(editorSource).not.toContain('PublisherPicker'); + expect(editorSource).not.toContain('fields.teamSlug'); + expect(editorSource).toContain('identityPolicy.ownerType'); }); it('does not expose an extra published status pill in the market preview panel', () => { 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/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/__tests__/publishForm.test.ts b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/publishForm.test.ts index 338bad76b8c..a3b2bd94b9f 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/publishForm.test.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/publishForm.test.ts @@ -145,6 +145,7 @@ describe('buildSkillhubPublishParams', () => { publishAbsolutePath: '/tmp/sivi-boss-fighting', submitName: 'sivi-boss-fighting', isFirstPublish: true, + categories: [{ slug: 'engine', name: 'Game Engine' }], })).toMatchObject({ absolutePath: '/tmp/sivi-boss-fighting', name: 'sivi-boss-fighting', @@ -153,7 +154,7 @@ describe('buildSkillhubPublishParams', () => { displayName: 'Boss fighting', summary: 'Helps structure boss fight encounters.', description: 'Helps structure boss fight encounters.', - categories: ['engine'], + tags: ['Game Engine'], visibility: 'DEPARTMENT_SCOPED', deptTeamSlug: 'od-dept-owner', visibleSlugs: ['od-dept-1', 'combat-team'], @@ -174,15 +175,14 @@ describe('buildSkillhubPublishParams', () => { expect(params.deptTeamSlug).toBeUndefined(); }); - it('uses auto category mode without sending manual category slugs', () => { + it('uses the no-tag option without sending a misleading auto-classification mode', () => { expect(buildSkillhubPublishParams({ form: { ...baseForm, categoryMode: 'auto' }, publishAbsolutePath: '/tmp/sivi-boss-fighting', submitName: 'sivi-boss-fighting', isFirstPublish: true, })).toMatchObject({ - categoryMode: 'auto', - categories: [], + tags: [], deptTeamSlug: 'od-dept-owner', visibleSlugs: ['od-dept-1', 'combat-team'], }); @@ -200,6 +200,23 @@ describe('buildSkillhubPublishParams', () => { expect(params).not.toHaveProperty('deptTeamSlug'); }); + it('omits legacy owner selectors when ownership comes from the authenticated membership', () => { + const params = buildSkillhubPublishParams({ + form: baseForm, + publishAbsolutePath: '/tmp/sivi-boss-fighting', + submitName: 'sivi-boss-fighting', + isFirstPublish: true, + ownerType: 'organization', + }); + + expect(params).not.toHaveProperty('deptTeamSlug'); + expect(params).not.toHaveProperty('teamSlug'); + expect(params).toMatchObject({ + visibility: 'DEPARTMENT_SCOPED', + visibleSlugs: [], + }); + }); + it('sends team publisher for public visibility and omits visibleSlugs', () => { expect(buildSkillhubPublishParams({ form: { ...baseForm, visibility: 'PUBLIC' }, diff --git a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/publishedStatus.test.ts b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/publishedStatus.test.ts index db30e7090f3..be547e026ca 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/publishedStatus.test.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/publishedStatus.test.ts @@ -25,7 +25,7 @@ describe('published status badges', () => { }); it('maps special states to the user-facing review label keys', () => { - expect(publishedStatusLabelKey('pending')).toBe('skillhub.publishedStatus.machineReviewing'); + expect(publishedStatusLabelKey('pending')).toBe('skillhub.publishedStatus.waitingReview'); expect(publishedStatusLabelKey('scanning')).toBe('skillhub.publishedStatus.machineReviewing'); expect(publishedStatusLabelKey('quarantine')).toBe('skillhub.publishedStatus.manualReviewing'); expect(publishedStatusLabelKey('rejected')).toBe('skillhub.publishedStatus.rejected'); diff --git a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/publisherLabel.test.ts b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/publisherLabel.test.ts new file mode 100644 index 00000000000..bfa720d0a63 --- /dev/null +++ b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/publisherLabel.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { skillPublisherLabel } from '../publisherLabel'; + +describe('skillPublisherLabel', () => { + it('shows both the member publisher and organization owner', () => { + expect(skillPublisherLabel({ + publisherName: 'Cindy Publisher', + authorName: 'Acme', + })).toBe('Cindy Publisher · Acme'); + }); + + it('does not duplicate a personal owner fallback', () => { + expect(skillPublisherLabel({ + publisherName: 'Cindy Publisher', + authorName: 'Cindy Publisher', + })).toBe('Cindy Publisher'); + }); +}); 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/__tests__/scanStatus.test.ts b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/scanStatus.test.ts index d7e268fb6f2..d3e67c9837f 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/scanStatus.test.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/scanStatus.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { isPassingScanStatus, + isPendingManualReviewStatus, isScanStatusUnavailable, isTerminalScanStatus, normalizeScanStatus, @@ -27,6 +28,17 @@ describe('scan status helpers', () => { expect(isPassingScanStatus('blocked')).toBe(false); }); + it('treats the server approved release status as a passing terminal state', () => { + expect(isTerminalScanStatus('approved')).toBe(true); + expect(isPassingScanStatus('approved')).toBe(true); + }); + + it('distinguishes pending manual review from an active machine scan', () => { + expect(isPendingManualReviewStatus(' Pending ')).toBe(true); + expect(isPendingManualReviewStatus('scanning')).toBe(false); + expect(isTerminalScanStatus('pending')).toBe(false); + }); + it('normalizes whitespace and case from API responses', () => { expect(normalizeScanStatus(' Pass ')).toBe('pass'); expect(isTerminalScanStatus(' Pass ')).toBe(true); diff --git a/apps/desktop/src/renderer/features/skillhub/lib/detailButtons.ts b/apps/desktop/src/renderer/features/skillhub/lib/detailButtons.ts index 29509fb4e92..4fe302bfa53 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/detailButtons.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/detailButtons.ts @@ -17,6 +17,8 @@ export interface DetailState { origin: 'installed' | 'published' | 'learned' | 'imported' | null; /** server 确认的管理权。null = server 不可用(404/error/loading) */ isMine: boolean | null; + /** 当前成员对这一条 Skill 的逐项管理权限。 */ + canManage: boolean | null; /** registryEntry 里记录的本地版本。null = 无 registryEntry */ localVersion: string | null; /** server 上最新版本。null = server 不可用或市场上不存在 */ @@ -60,6 +62,7 @@ export function deriveDetailState( return { origin: null, isMine: infoResult?.isMine ?? null, + canManage: infoResult?.canManage ?? null, localVersion: null, latestVersion: infoResult?.latestVersion ?? null, marketDeleted, @@ -75,6 +78,7 @@ export function deriveDetailState( return { origin: explicitOrigin, isMine: null, + canManage: null, localVersion: reg.version, latestVersion: null, marketDeleted, @@ -87,6 +91,7 @@ export function deriveDetailState( return { origin, isMine: infoResult.isMine, + canManage: infoResult.canManage, localVersion: reg.version, latestVersion: infoResult.latestVersion ?? null, marketDeleted: false, @@ -119,6 +124,8 @@ export function deriveDetailActionState( registryEntry: StoredInstall | null | undefined, localFolderHash: string | null, publishedStatus?: string | null, + canPublish = true, + publishTargetState: DetailState | null = detailState, ): DetailActionState | null { if (!detailState) return null; @@ -127,54 +134,44 @@ export function deriveDetailActionState( detailState.latestVersion !== null && semverCompare(detailState.latestVersion, detailState.localVersion) > 0 ); - const isLocalAhead = !!( + const publishState = publishTargetState ?? detailState; + const isPublishLocalAhead = !!( detailState.localVersion !== null && - detailState.latestVersion !== null && - semverCompare(detailState.localVersion, detailState.latestVersion) > 0 + publishState.latestVersion !== null && + semverCompare(detailState.localVersion, publishState.latestVersion) > 0 ); const localChanged = hasLocalChanges(registryEntry, localFolderHash); - const isMineDirty = !!(detailState.isMine === true && localChanged); + const isMineDirty = !!(publishState.canManage === true && localChanged); const showForeignDirtyBanner = !!( detailState.origin === 'installed' && detailState.localVersion !== null && !detailState.marketDeleted && - detailState.isMine !== true && + publishState.canManage !== true && localChanged ); let status: DetailActionStatus = { kind: 'none' }; - if (detailState.isMine === true && publishedStatus === 'rejected') { + if (publishState.canManage === true && publishedStatus === 'rejected') { status = { kind: 'publish-new-version' }; - } else if (isLocalAhead && detailState.isMine === true) { + } else if (isPublishLocalAhead && publishState.canManage === true) { status = { kind: 'publish-new-version' }; } else if ( - isOutdated && - detailState.latestVersion !== null && - detailState.isMine === true && + publishState.canManage === true && localChanged ) { status = { kind: 'publish-new-version' }; + } else if (publishState.latestVersion !== null && publishState.canManage === true) { + status = (detailState.origin === 'learned' || detailState.origin === 'imported') + ? { kind: 'publish-new-version' } + : { kind: 'published-tag', version: publishState.latestVersion }; } else if (isOutdated && detailState.latestVersion !== null && detailState.origin !== 'learned' && detailState.origin !== 'imported') { // learned / imported 不进市场更新路径:用市场包覆盖会丢掉本地创作 / 导入内容。 status = { kind: 'update', latestVersion: detailState.latestVersion }; - } else if (detailState.latestVersion !== null) { - // Server confirms the skill exists; never offer first-publish in this branch. - if ( - (detailState.origin === 'learned' || detailState.origin === 'imported') && - detailState.isMine === true - ) { - // learned / imported 的 registry hash 对应本地内容,不是 server 已发布版本。 - // 即使 localChanged=false 也不能显示 published-tag;若用户确实拥有同名 - // 市场 skill,应走发布新版本路径。 - status = { kind: 'publish-new-version' }; - } else if (detailState.isMine === true) { - status = isMineDirty - ? { kind: 'publish-new-version' } - : { kind: 'published-tag', version: detailState.latestVersion }; - } else if (detailState.origin === 'installed' && detailState.localVersion !== null) { - status = { kind: 'installed-tag', version: detailState.localVersion }; - } - } else if (detailState.marketDeleted || detailState.isMine === false) { + } else if ( + publishState.latestVersion === null && + (publishState.marketDeleted || publishState.isMine === false) && + (detailState.origin !== 'installed' || localChanged || detailState.isMine === true) + ) { // Server explicitly says "not found", or returns no record for this user. status = { kind: 'publish-to-market' }; } else if (detailState.origin === 'installed' && detailState.localVersion !== null) { @@ -182,6 +179,16 @@ export function deriveDetailActionState( status = { kind: 'installed-tag', version: detailState.localVersion }; } + if (!canPublish) { + if (status.kind === 'publish-to-market') status = { kind: 'none' }; + if (status.kind === 'publish-new-version') { + status = publishState.latestVersion + ? { kind: 'published-tag', version: publishState.latestVersion } + : { kind: 'none' }; + } + if (status.kind === 'update') status = { kind: 'none' }; + } + return { showUninstall: detailState.origin === 'installed' || detailState.origin === 'imported', status, 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..ac154831b3c --- /dev/null +++ b/apps/desktop/src/renderer/features/skillhub/lib/homeMarketFilter.ts @@ -0,0 +1,31 @@ +import type { CatalogScope, SortBy, Visibility } from '../hooks/useMarketList'; + +export type HomeMarketFilter = 'public' | 'organization'; +export type HomeCatalogTab = HomeMarketFilter | 'local'; + +export interface HomeMarketQuery { + scope: CatalogScope; + visibility: Visibility; + sort: SortBy; +} + +export function visibleHomeCatalogTabs( + showOrganization: boolean, +): HomeCatalogTab[] { + return showOrganization ? ['public', 'organization', 'local'] : ['public', 'local']; +} + +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 server-owned generic catalog contract. */ +export function homeMarketQuery(filter: HomeMarketFilter): HomeMarketQuery { + if (filter === 'organization') { + return { scope: 'team', visibility: 'all', sort: 'trending' }; + } + return { scope: 'market', visibility: 'all', sort: 'trending' }; +} diff --git a/apps/desktop/src/renderer/features/skillhub/lib/infoDedupe.ts b/apps/desktop/src/renderer/features/skillhub/lib/infoDedupe.ts index c798fe9a952..52b1279287a 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/infoDedupe.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/infoDedupe.ts @@ -2,74 +2,82 @@ * infoDedupe.ts — in-flight dedupe + SWR 缓存 for skillhub info requests * * 三层数据结构: - * - inFlight: Map 并发去重,promise resolve 后立即删 - * - lastResults: Map SWR 缓存,用于切 skill 时立刻渲染 - * - lastDeleted: Map 标记 server 是否显式返回 404(已删除) + * - inFlight: Map 并发去重,promise resolve 后立即删 + * - lastResults: Map SWR 缓存,用于切 skill 时立刻渲染 + * - lastDeleted: Map 标记 server 是否显式返回 404(已删除) * * SWR 模式:render 阶段从 lastResults 拿到上次结果立刻渲染,后台异步 getInfo * 仍然照跑,新结果回来后再 setState 修正。 * 网络错误时不覆盖 lastResults/lastDeleted,保留 stale 数据(真正的 SWR 语义)。 */ +import type { SkillhubCatalogScope } from '../../../../shared/skillhubCatalog'; + const inFlight = new Map>(); const lastResults = new Map(); const lastDeleted = new Map(); -function fetchInfo(name: string): Promise { +function cacheKey(name: string, catalogScope?: SkillhubCatalogScope): string { + return `${catalogScope ?? 'default'}:${name}`; +} + +function fetchInfo(name: string, catalogScope?: SkillhubCatalogScope): Promise { + const key = cacheKey(name, catalogScope); const p = window.electronAPI.skillhub - .info(name) + .info(name, catalogScope) .then((res) => { if (res.success && res.info && 'isMine' in res.info) { const info = res.info as SkillhubInfoResult; - lastResults.set(name, info); - lastDeleted.set(name, false); + lastResults.set(key, info); + lastDeleted.set(key, false); return info; } if (res.success && res.deleted) { - lastResults.set(name, null); - lastDeleted.set(name, true); + lastResults.set(key, null); + lastDeleted.set(key, true); return null; } // error (!res.success): preserve stale cache (SWR) - return lastResults.get(name) ?? null; + return lastResults.get(key) ?? null; }) .catch(() => { // network error: preserve stale cache - return lastResults.get(name) ?? null; + return lastResults.get(key) ?? null; }) .finally(() => { - if (inFlight.get(name) === p) inFlight.delete(name); + if (inFlight.get(key) === p) inFlight.delete(key); }); - inFlight.set(name, p); + inFlight.set(key, p); return p; } -export function getInfo(name: string): Promise { - const existing = inFlight.get(name); +export function getInfo(name: string, catalogScope?: SkillhubCatalogScope): Promise { + const existing = inFlight.get(cacheKey(name, catalogScope)); if (existing) return existing; - return fetchInfo(name); + return fetchInfo(name, catalogScope); } /** Force one network refresh while keeping stale cache as the failure fallback. */ -export function refreshInfo(name: string): Promise { - return fetchInfo(name); +export function refreshInfo(name: string, catalogScope?: SkillhubCatalogScope): Promise { + return fetchInfo(name, catalogScope); } /** 同步读上次拿到的 info(用于 render 阶段 seed state,实现切 skill 不闪)。 */ -export function getCachedInfo(name: string): SkillhubInfoResult | null { - return lastResults.get(name) ?? null; +export function getCachedInfo(name: string, catalogScope?: SkillhubCatalogScope): SkillhubInfoResult | null { + return lastResults.get(cacheKey(name, catalogScope)) ?? null; } /** Server 是否显式返回 404(skill 已从市场删除)。 */ -export function isMarketDeleted(name: string): boolean { - return lastDeleted.get(name) ?? false; +export function isMarketDeleted(name: string, catalogScope?: SkillhubCatalogScope): boolean { + return lastDeleted.get(cacheKey(name, catalogScope)) ?? false; } /** Force re-fetch on next call (e.g. after publish success). */ -export function invalidate(name: string): void { - inFlight.delete(name); - lastResults.delete(name); - lastDeleted.delete(name); +export function invalidate(name: string, catalogScope?: SkillhubCatalogScope): void { + const key = cacheKey(name, catalogScope); + inFlight.delete(key); + lastResults.delete(key); + lastDeleted.delete(key); } 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/marketAccess.ts b/apps/desktop/src/renderer/features/skillhub/lib/marketAccess.ts deleted file mode 100644 index b0eb2e29935..00000000000 --- a/apps/desktop/src/renderer/features/skillhub/lib/marketAccess.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * 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; -} - -/** 当前登录用户是否可见 Skill Hub 市场内容(null = 未登录,按不可见处理)。 */ -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 - ); -} diff --git a/apps/desktop/src/renderer/features/skillhub/lib/marketErrors.ts b/apps/desktop/src/renderer/features/skillhub/lib/marketErrors.ts index 2f6046c9eba..b47a69fee79 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/marketErrors.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/marketErrors.ts @@ -5,10 +5,12 @@ export type MarketActionErrorKey = | 'skillhub.marketErrors.forbidden' | 'skillhub.marketErrors.notFound' | 'skillhub.marketErrors.managementApiUnavailable' + | 'skillhub.marketErrors.invalidVisibility' | 'skillhub.marketErrors.default'; export function marketActionErrorKey(error?: string, errorCode?: string): MarketActionErrorKey | null { const raw = `${errorCode ?? ''} ${error ?? ''}`.toLowerCase(); + if (errorCode === 'INVALID_VISIBILITY') return 'skillhub.marketErrors.invalidVisibility'; if (raw.includes('403') || raw.includes('forbidden')) return 'skillhub.marketErrors.forbidden'; if (!raw.includes('hub_404') && (raw.includes('404') || raw.includes('not found'))) return 'skillhub.marketErrors.notFound'; if ( 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/features/skillhub/lib/publishErrorMap.ts b/apps/desktop/src/renderer/features/skillhub/lib/publishErrorMap.ts index 932cb3e2792..38d8f0e863f 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/publishErrorMap.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/publishErrorMap.ts @@ -100,6 +100,12 @@ const errorMap: Record = { CANCELLED: { primaryAction: CLOSE_ACTION, }, + SKILL_HUB_READ_ONLY: { + primaryAction: CLOSE_ACTION, + }, + INVALID_VISIBILITY: { + primaryAction: CLOSE_ACTION, + }, INTERNAL: { primaryAction: RETRY_ACTION, secondaryAction: CLOSE_ACTION, diff --git a/apps/desktop/src/renderer/features/skillhub/lib/publishFailureFallback.ts b/apps/desktop/src/renderer/features/skillhub/lib/publishFailureFallback.ts index 8728d8bc287..88ae60ca29a 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/publishFailureFallback.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/publishFailureFallback.ts @@ -13,6 +13,8 @@ const PUBLISH_ERROR_CODES = new Set([ 'OSS_OBJECT_NOT_FOUND', 'API_KEY_MISSING', 'CANCELLED', + 'SKILL_HUB_READ_ONLY', + 'INVALID_VISIBILITY', 'INTERNAL', ]); diff --git a/apps/desktop/src/renderer/features/skillhub/lib/publishForm.ts b/apps/desktop/src/renderer/features/skillhub/lib/publishForm.ts index c1a83f75229..9a9d63f3d7b 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/publishForm.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/publishForm.ts @@ -32,13 +32,13 @@ export type RequiredCategoryValidation = export function validateRequiredCategory({ loading, error, - categories, + categories = [], categoryMode, selectedSlug, }: { loading: boolean; error: string | null; - categories: PublishCategoryOption[]; + categories?: PublishCategoryOption[]; categoryMode: 'auto' | 'manual'; selectedSlug: string; }): RequiredCategoryValidation { @@ -115,15 +115,22 @@ export function buildSkillhubPublishParams({ publishAbsolutePath, submitName, isFirstPublish, + ownerType, + categories = [], }: { form: PublishFormValues; publishAbsolutePath: string; submitName: string; isFirstPublish: boolean; + /** New SkillHub fixes ownership from the authenticated membership. */ + ownerType?: 'personal' | 'organization' | null; + categories?: PublishCategoryOption[]; }): SkillhubPublishParams { const categorySlug = form.categorySlug.trim(); + const selectedTagName = categories.find((category) => category.slug === categorySlug)?.name?.trim(); // 私有发布强制个人归属(Hub 约束:private + teamSlug 会 400) - const teamPublisher = form.visibility !== 'PRIVATE' && form.publisherMode === 'team' && form.ownerTeamSlug + const teamPublisher = ownerType === undefined + && form.visibility !== 'PRIVATE' && form.publisherMode === 'team' && form.ownerTeamSlug ? form.ownerTeamSlug : undefined; @@ -136,10 +143,9 @@ export function buildSkillhubPublishParams({ summary: form.summary, description: form.description, ...(isFirstPublish && { - categoryMode: form.categoryMode, - categories: form.categoryMode === 'manual' && categorySlug ? [categorySlug] : [], + tags: form.categoryMode === 'manual' && selectedTagName ? [selectedTagName] : [], visibility: form.visibility, - visibleSlugs: form.visibility === 'DEPARTMENT_SCOPED' + visibleSlugs: ownerType === undefined && form.visibility === 'DEPARTMENT_SCOPED' ? [...form.visibleDeptIds, ...form.sharedTeamSlugs] : [], ...(teamPublisher diff --git a/apps/desktop/src/renderer/features/skillhub/lib/publishedStatus.ts b/apps/desktop/src/renderer/features/skillhub/lib/publishedStatus.ts index 338d6385901..873a132b123 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/publishedStatus.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/publishedStatus.ts @@ -46,11 +46,13 @@ export function effectivePublishedStatus(source: PublishedStatusSource | null | } export type PublishedStatusLabelKey = + | 'skillhub.publishedStatus.waitingReview' | 'skillhub.publishedStatus.machineReviewing' | 'skillhub.publishedStatus.manualReviewing' | 'skillhub.publishedStatus.rejected'; export function publishedStatusLabelKey(status: SpecialPublishedStatus): PublishedStatusLabelKey { + if (status === 'pending') return 'skillhub.publishedStatus.waitingReview'; if (status === 'quarantine') return 'skillhub.publishedStatus.manualReviewing'; if (status === 'rejected') return 'skillhub.publishedStatus.rejected'; return 'skillhub.publishedStatus.machineReviewing'; diff --git a/apps/desktop/src/renderer/features/skillhub/lib/publisherLabel.ts b/apps/desktop/src/renderer/features/skillhub/lib/publisherLabel.ts new file mode 100644 index 00000000000..0cac6780e2f --- /dev/null +++ b/apps/desktop/src/renderer/features/skillhub/lib/publisherLabel.ts @@ -0,0 +1,10 @@ +export function skillPublisherLabel(skill: { + authorName: string; + publisherName?: string; +}): string { + const owner = skill.authorName.trim(); + const publisher = skill.publisherName?.trim() ?? ''; + if (!publisher || publisher === owner) return owner || publisher; + if (!owner) return publisher; + return `${publisher} · ${owner}`; +} 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/features/skillhub/lib/scanStatus.ts b/apps/desktop/src/renderer/features/skillhub/lib/scanStatus.ts index 0a36bc118f8..dcaf102f667 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/scanStatus.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/scanStatus.ts @@ -1,4 +1,4 @@ -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']); export const MAX_SCAN_STATUS_FAILURES = 3; @@ -10,6 +10,10 @@ export function isPassingScanStatus(status: string): boolean { return PASSING_SCAN_STATUSES.has(normalizeScanStatus(status)); } +export function isPendingManualReviewStatus(status: string): boolean { + return normalizeScanStatus(status) === 'pending'; +} + export function isTerminalScanStatus(status: string): boolean { const normalized = normalizeScanStatus(status); return PASSING_SCAN_STATUSES.has(normalized) || FAILING_SCAN_STATUSES.has(normalized); diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 2ef58d9bb37..55fd170d0e9 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -10013,10 +10013,16 @@ "search": "Search skills", "clearSearch": "Clear skill search", "noSearchResults": "No matching skills found", - "browseTitle": "Browse Skill Hub", - "browseDesc": "Discover and install more skills", - "recommended": "Recommended", - "recommendedEmpty": "No recommendations right now", + "catalogFiltersAria": "SkillHub categories", + "catalogFilter": { + "public": "Public", + "organization": "Organization", + "mine": "Managed" + }, + "catalogMore": "More", + "loadMore": "Load more", + "loadingMore": "Loading…", + "catalogEmpty": "No skills in this category", "local": "Local Skills", "localEmpty": "No local skills yet", "installed": "Installed", @@ -10198,7 +10204,6 @@ "sortDownloads": "Downloads", "sortLatest": "Latest update", "sortCreated": "Latest release", - "chipAvailable": "Available", "chipAll": "All", "chipMine": "Managed", "ownerGroupPersonal": "Personal", @@ -10212,7 +10217,7 @@ "installError": "Unknown error" }, "marketCard": { - "visibilityPublic": "XD.Inc", + "visibilityPublic": "Public", "visibilityDept": "Team", "visibilityPrivate": "Personal", "timeLabel": "Published time", @@ -10237,6 +10242,7 @@ } }, "publishedStatus": { + "waitingReview": "Awaiting review", "machineReviewing": "Machine review", "manualReviewing": "Manual review", "rejected": "Review rejected" @@ -10248,7 +10254,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", @@ -10259,6 +10265,7 @@ "forbidden": "No permission to operate on this Skill", "notFound": "This Skill no longer exists. Go back to the list and refresh", "managementApiUnavailable": "Hub management API is not ready. Please try again later", + "invalidVisibility": "This organization does not support that visibility. Choose Public", "default": "Operation failed. Please try again later" }, "installPicker": { @@ -10303,11 +10310,11 @@ "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", - "categoryAuto": "Auto", + "categoryAuto": "No Tag", "categoryPlaceholder": "Select a category", "categoryLoading": "Loading categories…", "categoryLoadFailed": "Failed to load categories. Please retry.", @@ -10431,6 +10438,14 @@ "title": "Cancelled", "message": "" }, + "SKILL_HUB_READ_ONLY": { + "title": "Organization catalog is read-only", + "message": "Publishing and management are not available for this organization catalog" + }, + "INVALID_VISIBILITY": { + "title": "Visibility unavailable", + "message": "Choose a visibility supported by the current publishing identity" + }, "INTERNAL": { "title": "Server error", "message": "Publish failed, please retry later" @@ -10444,8 +10459,12 @@ "dismiss": "Got it", "passedTitle": "Security Scan Passed", "passedDesc": "This version has passed all security checks and is now live on the market.", + "pendingTitle": "Submitted for Platform Review", + "pendingDesc": "The security scan is complete. This version will be published to the public market after Platform approval.", "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", @@ -10458,12 +10477,14 @@ "passed": "Passed", "failed": "Failed", "warning": "Needs attention", + "waitingReview": "Awaiting review", "reviewing": "In review", "unavailable": "Unavailable" }, "gateLabel": { "llmReview": "LLM review", - "securityScan": "Security scan" + "securityScan": "Security scan", + "publicationProcessing": "Publication processing" } }, "diffPanel": { @@ -10515,7 +10536,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", @@ -10523,13 +10544,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 59e3794d662..e7ec7daf0b2 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -9991,10 +9991,16 @@ "search": "スキルを検索", "clearSearch": "スキル検索をクリア", "noSearchResults": "一致するスキルが見つかりません", - "browseTitle": "Skill Hub を見る", - "browseDesc": "もっと多くのスキルを見つけてインストール", - "recommended": "おすすめ", - "recommendedEmpty": "現在おすすめはありません", + "catalogFiltersAria": "SkillHub カテゴリ", + "catalogFilter": { + "public": "公開", + "organization": "組織", + "mine": "管理対象" + }, + "catalogMore": "もっと見る", + "loadMore": "さらに読み込む", + "loadingMore": "読み込み中…", + "catalogEmpty": "このカテゴリにはスキルがありません", "local": "ローカルスキル", "localEmpty": "ローカルスキルはまだありません", "installed": "インストール済み", @@ -10176,7 +10182,6 @@ "sortDownloads": "ダウンロード数", "sortLatest": "最近の更新", "sortCreated": "最新公開", - "chipAvailable": "取得可能", "chipAll": "すべて", "chipMine": "管理対象", "ownerGroupPersonal": "個人", @@ -10190,7 +10195,7 @@ "installError": "不明なエラー" }, "marketCard": { - "visibilityPublic": "XD.Inc", + "visibilityPublic": "公開", "visibilityDept": "チーム", "visibilityPrivate": "個人", "timeLabel": "公開日時", @@ -10215,6 +10220,7 @@ } }, "publishedStatus": { + "waitingReview": "審査待ち", "machineReviewing": "機械審査中", "manualReviewing": "人手確認中", "rejected": "審査不通過" @@ -10226,7 +10232,7 @@ "emptyHint": "このクラウド Skill からプレビュー可能なファイルが返されませんでした" }, "marketDetail": { - "visibilityPublic": "XD.Inc に公開", + "visibilityPublic": "すべてのユーザーに公開", "visibilityPrivate": "個人のみ", "visibilityDept": "チームに公開", "files": "ファイル", @@ -10237,6 +10243,7 @@ "forbidden": "この Skill を操作する権限がありません", "notFound": "この Skill は存在しません。リストに戻って更新してください", "managementApiUnavailable": "Hub 管理 API はまだ利用できません。しばらくしてから再試行してください", + "invalidVisibility": "この組織ではその公開範囲を利用できません。公開を選択してください", "default": "操作に失敗しました。しばらくしてから再試行してください" }, "installPicker": { @@ -10281,11 +10288,11 @@ "descriptionPlaceholder": "この skill の用途を簡潔に", "visibilityLabel": "可視性", "visibilityPublicTitle": "公開", - "visibilityPublicDesc": "全社に公開 · デフォルト", + "visibilityPublicDesc": "すべてのユーザーに公開 · デフォルト", "versionLabel": "バージョン", "versionFormatHint": "形式:x.y.z(例:1.0.1)", "categoryLabel": "カテゴリ", - "categoryAuto": "自動", + "categoryAuto": "タグなし", "categoryPlaceholder": "カテゴリを選択", "categoryLoading": "カテゴリを読み込み中…", "categoryLoadFailed": "カテゴリの読み込みに失敗しました。再試行してください", @@ -10409,6 +10416,14 @@ "title": "キャンセル済み", "message": "" }, + "SKILL_HUB_READ_ONLY": { + "title": "組織カタログは読み取り専用です", + "message": "この組織カタログでは公開や管理を利用できません" + }, + "INVALID_VISIBILITY": { + "title": "公開範囲を利用できません", + "message": "現在の公開者が利用できる公開範囲を選択してください" + }, "INTERNAL": { "title": "サーバーエラー", "message": "公開失敗、後ほど再試行してください" @@ -10422,8 +10437,12 @@ "dismiss": "了解", "passedTitle": "セキュリティスキャン通過", "passedDesc": "このバージョンはすべてのセキュリティチェックに合格し、マーケットに公開されました。", + "pendingTitle": "送信済み、プラットフォーム審査待ち", + "pendingDesc": "セキュリティスキャンは完了しました。プラットフォームの承認後、公開マーケットに自動公開されます。", "failedTitle": "セキュリティスキャン不合格", "failedDesc": "このバージョンはセキュリティスキャンの問題により {{status}} としてマークされ、他のユーザーはインストールできません。以下の問題を修正して再公開してください。", + "processingFailedTitle": "公開処理に失敗しました", + "processingFailedDesc": "このバージョンはアップロードされましたが、サーバーの公開処理中に内部エラーが発生しました。セキュリティチェックによる拒否ではありません。時間をおいて再公開してください。", "copyReviewResult": "審査結果をコピー", "copiedReviewResult": "コピー済み", "copyReviewResultFailed": "コピーに失敗しました", @@ -10436,12 +10455,14 @@ "passed": "通過", "failed": "不合格", "warning": "要確認", + "waitingReview": "審査待ち", "reviewing": "審査中", "unavailable": "利用不可" }, "gateLabel": { "llmReview": "LLM 審査", - "securityScan": "セキュリティスキャン" + "securityScan": "セキュリティスキャン", + "publicationProcessing": "公開処理" } }, "diffPanel": { @@ -10493,7 +10514,7 @@ "title": "公開設定を管理", "tierLabel": "可視性", "tierPublic": "公開", - "tierPublicDesc": "全社に公開", + "tierPublicDesc": "すべてのユーザーに公開", "tierTeam": "チーム", "tierTeamDesc": "指定したチームに公開", "tierPrivate": "自分のみ", @@ -10501,13 +10522,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 20c083decce..b1598b7efbb 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -9991,10 +9991,16 @@ "search": "스킬 검색", "clearSearch": "스킬 검색 지우기", "noSearchResults": "일치하는 스킬을 찾을 수 없습니다", - "browseTitle": "Skill Hub 둘러보기", - "browseDesc": "더 많은 스킬을 찾아 설치하세요", - "recommended": "추천", - "recommendedEmpty": "현재 추천 항목이 없습니다", + "catalogFiltersAria": "SkillHub 카테고리", + "catalogFilter": { + "public": "공개", + "organization": "조직", + "mine": "관리 대상" + }, + "catalogMore": "더 보기", + "loadMore": "더 불러오기", + "loadingMore": "불러오는 중…", + "catalogEmpty": "이 카테고리에 스킬이 없습니다", "local": "로컬 스킬", "localEmpty": "아직 로컬 스킬이 없습니다", "installed": "설치됨", @@ -10176,7 +10182,6 @@ "sortDownloads": "다운로드 수", "sortLatest": "최근 업데이트", "sortCreated": "최신 게시", - "chipAvailable": "이용 가능", "chipAll": "전체", "chipMine": "관리 대상", "ownerGroupPersonal": "개인", @@ -10190,7 +10195,7 @@ "installError": "알 수 없는 오류" }, "marketCard": { - "visibilityPublic": "XD.Inc", + "visibilityPublic": "공개", "visibilityDept": "팀", "visibilityPrivate": "개인", "timeLabel": "게시 시간", @@ -10215,6 +10220,7 @@ } }, "publishedStatus": { + "waitingReview": "심사 대기", "machineReviewing": "기계 심사 중", "manualReviewing": "수동 검토 중", "rejected": "심사 미통과" @@ -10226,7 +10232,7 @@ "emptyHint": "이 클라우드 Skill 에서 미리 볼 수 있는 파일을 반환하지 않았습니다" }, "marketDetail": { - "visibilityPublic": "XD.Inc 에 공개", + "visibilityPublic": "모든 사용자에게 공개", "visibilityPrivate": "개인 공개", "visibilityDept": "팀 공개", "files": "파일", @@ -10237,6 +10243,7 @@ "forbidden": "이 Skill 을 조작할 권한이 없습니다", "notFound": "이 Skill 은 더 이상 존재하지 않습니다. 목록으로 돌아가 새로고침하세요", "managementApiUnavailable": "Hub 관리 API 가 아직 준비되지 않았습니다. 잠시 후 다시 시도하세요", + "invalidVisibility": "이 조직에서는 해당 공개 범위를 지원하지 않습니다. 공개를 선택하세요", "default": "작업에 실패했습니다. 잠시 후 다시 시도하세요" }, "installPicker": { @@ -10281,11 +10288,11 @@ "descriptionPlaceholder": "이 skill 의 용도를 간단히 설명", "visibilityLabel": "가시성", "visibilityPublicTitle": "공개", - "visibilityPublicDesc": "회사 전체 공개 · 기본값", + "visibilityPublicDesc": "모든 사용자에게 공개 · 기본값", "versionLabel": "버전", "versionFormatHint": "형식: x.y.z (예: 1.0.1)", "categoryLabel": "분류", - "categoryAuto": "자동", + "categoryAuto": "태그 없음", "categoryPlaceholder": "분류 선택", "categoryLoading": "분류 불러오는 중…", "categoryLoadFailed": "분류를 불러오지 못했습니다. 다시 시도하세요", @@ -10409,6 +10416,14 @@ "title": "취소됨", "message": "" }, + "SKILL_HUB_READ_ONLY": { + "title": "조직 카탈로그는 읽기 전용입니다", + "message": "이 조직 카탈로그에서는 게시 및 관리 기능을 사용할 수 없습니다" + }, + "INVALID_VISIBILITY": { + "title": "공개 범위를 사용할 수 없습니다", + "message": "현재 게시자에게 허용된 공개 범위를 선택하세요" + }, "INTERNAL": { "title": "서버 오류", "message": "게시 실패, 잠시 후 재시도하세요" @@ -10422,8 +10437,12 @@ "dismiss": "확인", "passedTitle": "보안 스캔 통과", "passedDesc": "이 버전은 모든 보안 검사를 통과하여 마켓에 게시되었습니다.", + "pendingTitle": "제출 완료, 플랫폼 심사 대기 중", + "pendingDesc": "보안 스캔이 완료되었습니다. 플랫폼 승인 후 공개 마켓에 자동으로 게시됩니다.", "failedTitle": "보안 스캔 실패", "failedDesc": "이 버전은 보안 스캔 문제로 인해 {{status}}으로 표시되어 다른 사용자가 설치할 수 없습니다. 아래 문제를 수정한 후 다시 게시하세요.", + "processingFailedTitle": "게시 처리 실패", + "processingFailedDesc": "이 버전은 업로드되었지만 서버가 게시 처리 중 내부 오류를 만났습니다. 보안 검사에서 거부된 것이 아니므로 잠시 후 다시 게시하세요.", "copyReviewResult": "심사 결과 복사", "copiedReviewResult": "복사됨", "copyReviewResultFailed": "복사 실패", @@ -10436,12 +10455,14 @@ "passed": "통과", "failed": "미통과", "warning": "확인 필요", + "waitingReview": "심사 대기", "reviewing": "심사 중", "unavailable": "사용 불가" }, "gateLabel": { "llmReview": "LLM 심사", - "securityScan": "보안 스캔" + "securityScan": "보안 스캔", + "publicationProcessing": "게시 처리" } }, "diffPanel": { @@ -10493,7 +10514,7 @@ "title": "공개 설정 관리", "tierLabel": "가시성", "tierPublic": "공개", - "tierPublicDesc": "회사 전체 공개", + "tierPublicDesc": "모든 사용자에게 공개", "tierTeam": "팀", "tierTeamDesc": "지정한 팀에 공개", "tierPrivate": "나만 사용", @@ -10501,13 +10522,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 b1921872f26..e0d0635642c 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -9983,10 +9983,16 @@ "search": "搜索技能", "clearSearch": "清除技能搜索", "noSearchResults": "没有找到匹配的技能", - "browseTitle": "浏览 Skill Hub", - "browseDesc": "发现并安装更多技能", - "recommended": "推荐安装", - "recommendedEmpty": "暂无推荐", + "catalogFiltersAria": "SkillHub 分类", + "catalogFilter": { + "public": "公开", + "organization": "组织", + "mine": "我的管理" + }, + "catalogMore": "更多", + "loadMore": "加载更多", + "loadingMore": "加载中…", + "catalogEmpty": "当前分类暂无技能", "local": "本地技能", "localEmpty": "还没有本地技能", "installed": "已安装", @@ -10168,7 +10174,6 @@ "sortDownloads": "下载量", "sortLatest": "最近更新", "sortCreated": "最新发布", - "chipAvailable": "可获取", "chipAll": "全部", "chipMine": "我的管理", "ownerGroupPersonal": "个人", @@ -10182,7 +10187,7 @@ "installError": "未知错误" }, "marketCard": { - "visibilityPublic": "XD.Inc", + "visibilityPublic": "公开", "visibilityDept": "团队", "visibilityPrivate": "个人", "timeLabel": "发布时间", @@ -10207,6 +10212,7 @@ } }, "publishedStatus": { + "waitingReview": "等待审核", "machineReviewing": "机审中", "manualReviewing": "人工复核中", "rejected": "审核未通过" @@ -10218,7 +10224,7 @@ "emptyHint": "这个云端 Skill 没有返回可预览文件" }, "marketDetail": { - "visibilityPublic": "XD.Inc 可见", + "visibilityPublic": "所有用户可见", "visibilityPrivate": "个人可见", "visibilityDept": "团队可见", "files": "文件", @@ -10229,6 +10235,7 @@ "forbidden": "没有权限操作这个 Skill", "notFound": "这个 Skill 已不存在,请返回列表刷新", "managementApiUnavailable": "Hub 管理接口暂未就绪,请稍后再试", + "invalidVisibility": "当前组织暂不支持该可见范围,请选择公开发布", "default": "操作失败,请稍后重试" }, "installPicker": { @@ -10273,11 +10280,11 @@ "descriptionPlaceholder": "简要描述该 Skill 的用途", "visibilityLabel": "可见性", "visibilityPublicTitle": "公开", - "visibilityPublicDesc": "全公司可见 · 默认", + "visibilityPublicDesc": "所有用户可见 · 默认", "versionLabel": "版本号", "versionFormatHint": "格式:x.y.z (例如 1.0.1)", "categoryLabel": "分类", - "categoryAuto": "自动", + "categoryAuto": "不添加标签", "categoryPlaceholder": "请选择分类", "categoryLoading": "正在加载分类…", "categoryLoadFailed": "分类加载失败,请重试", @@ -10401,6 +10408,14 @@ "title": "已取消", "message": "" }, + "SKILL_HUB_READ_ONLY": { + "title": "当前组织仅支持浏览", + "message": "当前组织的技能目录暂不支持发布或管理" + }, + "INVALID_VISIBILITY": { + "title": "可见范围不可用", + "message": "请选择当前发布身份支持的可见范围" + }, "INTERNAL": { "title": "服务器错误", "message": "发布失败,请稍后重试" @@ -10414,8 +10429,12 @@ "dismiss": "知道了", "passedTitle": "安全扫描通过", "passedDesc": "该版本已通过所有安全检查,现已发布到市场。", + "pendingTitle": "已提交,等待平台审核", + "pendingDesc": "安全扫描已经完成,平台审核通过后会自动发布到公开市场。", "failedTitle": "安全扫描未通过", "failedDesc": "该版本因安全扫描不达标被标记为 {{status}},暂时无法在市场中被其他用户安装。请修复以下问题后重新发布。", + "processingFailedTitle": "发布处理失败", + "processingFailedDesc": "该版本已上传,但服务器在处理发布时发生内部错误。这不是安全检查拒绝,请稍后重新发布。", "copyReviewResult": "复制审核结果", "copiedReviewResult": "已复制", "copyReviewResultFailed": "复制失败", @@ -10428,12 +10447,14 @@ "passed": "已通过", "failed": "未通过", "warning": "有风险", + "waitingReview": "等待审核", "reviewing": "审核中", "unavailable": "暂不可用" }, "gateLabel": { "llmReview": "LLM 审核", - "securityScan": "安全扫描" + "securityScan": "安全扫描", + "publicationProcessing": "发布处理" } }, "diffPanel": { @@ -10485,7 +10506,7 @@ "title": "管理可见性", "tierLabel": "可见性", "tierPublic": "公开", - "tierPublicDesc": "全公司可见", + "tierPublicDesc": "所有用户可见", "tierTeam": "给团队使用", "tierTeamDesc": "指定团队可见", "tierPrivate": "仅自己使用", @@ -10493,13 +10514,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 b1cfba190e6..3a4cbec77ff 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -9983,10 +9983,16 @@ "search": "搜尋技能", "clearSearch": "清除技能搜尋", "noSearchResults": "沒有找到匹配的技能", - "browseTitle": "瀏覽 Skill Hub", - "browseDesc": "發現並安裝更多技能", - "recommended": "推薦安裝", - "recommendedEmpty": "暫無推薦", + "catalogFiltersAria": "SkillHub 分類", + "catalogFilter": { + "public": "公開", + "organization": "組織", + "mine": "我的管理" + }, + "catalogMore": "更多", + "loadMore": "載入更多", + "loadingMore": "載入中…", + "catalogEmpty": "目前分類暫無技能", "local": "本地技能", "localEmpty": "還沒有本地技能", "installed": "已安裝", @@ -10168,7 +10174,6 @@ "sortDownloads": "下載量", "sortLatest": "最近更新", "sortCreated": "最新發布", - "chipAvailable": "可獲取", "chipAll": "全部", "chipMine": "我的管理", "ownerGroupPersonal": "個人", @@ -10182,7 +10187,7 @@ "installError": "未知錯誤" }, "marketCard": { - "visibilityPublic": "XD.Inc", + "visibilityPublic": "公開", "visibilityDept": "團隊", "visibilityPrivate": "個人", "timeLabel": "釋出時間", @@ -10207,6 +10212,7 @@ } }, "publishedStatus": { + "waitingReview": "等待審核", "machineReviewing": "機審中", "manualReviewing": "人工複核中", "rejected": "稽核未通過" @@ -10218,7 +10224,7 @@ "emptyHint": "這個雲端 Skill 沒有返回可預覽檔案" }, "marketDetail": { - "visibilityPublic": "XD.Inc 可見", + "visibilityPublic": "所有使用者可見", "visibilityPrivate": "個人可見", "visibilityDept": "團隊可見", "files": "檔案", @@ -10229,6 +10235,7 @@ "forbidden": "沒有權限操作這個 Skill", "notFound": "這個 Skill 已不存在,請返回列表重新整理", "managementApiUnavailable": "Hub 管理介面暫未就緒,請稍後再試", + "invalidVisibility": "目前組織暫不支援此可見範圍,請選擇公開發佈", "default": "操作失敗,請稍後重試" }, "installPicker": { @@ -10273,11 +10280,11 @@ "descriptionPlaceholder": "簡要描述該 Skill 的用途", "visibilityLabel": "可見性", "visibilityPublicTitle": "公開", - "visibilityPublicDesc": "全公司可見 · 預設", + "visibilityPublicDesc": "所有使用者可見 · 預設", "versionLabel": "版本號", "versionFormatHint": "格式:x.y.z (例如 1.0.1)", "categoryLabel": "分類", - "categoryAuto": "自動", + "categoryAuto": "不新增標籤", "categoryPlaceholder": "請選擇分類", "categoryLoading": "正在載入分類…", "categoryLoadFailed": "分類載入失敗,請重試", @@ -10401,6 +10408,14 @@ "title": "已取消", "message": "" }, + "SKILL_HUB_READ_ONLY": { + "title": "目前組織僅支援瀏覽", + "message": "目前組織的技能目錄暫不支援發佈或管理" + }, + "INVALID_VISIBILITY": { + "title": "可見範圍不可用", + "message": "請選擇目前發佈身分支援的可見範圍" + }, "INTERNAL": { "title": "伺服器錯誤", "message": "釋出失敗,請稍後重試" @@ -10414,8 +10429,12 @@ "dismiss": "知道了", "passedTitle": "安全掃描通過", "passedDesc": "該版本已通過所有安全檢查,現已釋出到市場。", + "pendingTitle": "已提交,等待平台審核", + "pendingDesc": "安全掃描已完成,平台審核通過後會自動釋出到公開市場。", "failedTitle": "安全掃描未通過", "failedDesc": "該版本因安全掃描不達標被標記為 {{status}},暫時無法在市場中被其他使用者安裝。請修復以下問題後重新發布。", + "processingFailedTitle": "發布處理失敗", + "processingFailedDesc": "該版本已上傳,但伺服器處理發布時發生內部錯誤。這不是安全檢查拒絕,請稍後重新發布。", "copyReviewResult": "複製稽核結果", "copiedReviewResult": "已複製", "copyReviewResultFailed": "複製失敗", @@ -10428,12 +10447,14 @@ "passed": "已通過", "failed": "未通過", "warning": "有風險", + "waitingReview": "等待審核", "reviewing": "稽核中", "unavailable": "暫不可用" }, "gateLabel": { "llmReview": "LLM 稽核", - "securityScan": "安全掃描" + "securityScan": "安全掃描", + "publicationProcessing": "發布處理" } }, "diffPanel": { @@ -10485,7 +10506,7 @@ "title": "管理可見性", "tierLabel": "可見性", "tierPublic": "公開", - "tierPublicDesc": "全公司可見", + "tierPublicDesc": "所有使用者可見", "tierTeam": "給團隊使用", "tierTeamDesc": "指定團隊可見", "tierPrivate": "僅自己使用", @@ -10493,13 +10514,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 2590a6f8664..2e05d6abc0c 100644 --- a/apps/desktop/src/renderer/vite-env.d.ts +++ b/apps/desktop/src/renderer/vite-env.d.ts @@ -3141,6 +3141,7 @@ interface ElectronAPI { | string[] | { slugs?: string[]; + skills?: Array<{ slug: string; catalogScope?: 'market' | 'team' }>; }, ) => Promise<{ success: boolean; @@ -3157,6 +3158,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; @@ -3168,13 +3170,17 @@ interface ElectronAPI { error?: string; items?: Array<{ name: string; + /** Skill 图标 URL;旧服务响应可能缺失。 */ + icon?: string; displayName: string; description: string; authorId: string; authorName: string; + publisherName?: string; /** 飞书登录时拉到的头像 URL,可能为 null。 */ authorAvatarUrl: string | null; isMine: boolean; + canManage: boolean; latestVersion: string; visibility: 'PUBLIC' | 'DEPARTMENT_SCOPED'; publishedVisibility?: 'private' | 'shared' | 'public'; @@ -3185,23 +3191,31 @@ interface ElectronAPI { version: string; status?: string; }; + visibilityReview?: { + requestedVisibility: 'public'; + status: 'pending' | 'rejected'; + reason?: string; + }; visibleDeptIds: string[]; categories?: string[]; + tags?: Array<{ slug: string; name: string; source?: 'author' | 'platform' }>; + githubUrl?: string | null; publishedAt: string; downloads: number; /** 跨设备识别:null = pre-feature 历史版本 */ latestPublishedFromDeviceId: string | null; + catalogScope?: import('../shared/skillhubCatalog').SkillhubCatalogScope; }>; nextCursor?: string | null; }>; - info: (name: string) => Promise<{ + info: (name: string, catalogScope?: import('../shared/skillhubCatalog').SkillhubCatalogScope) => Promise<{ success: boolean; error?: string; info?: SkillhubInfoResult; deleted?: boolean; errorCode?: string; }>; - getPublishedFiles: (params: { name: string; version?: string }) => Promise<{ + getPublishedFiles: (params: { name: string; version?: string; catalogScope?: import('../shared/skillhubCatalog').SkillhubCatalogScope }) => Promise<{ success: boolean; slug?: string; version?: string; @@ -3209,13 +3223,13 @@ interface ElectronAPI { error?: string; errorCode?: string; }>; - readPublishedFile: (params: { name: string; path: string; version?: string }) => Promise<{ + readPublishedFile: (params: { name: string; path: string; version?: string; catalogScope?: import('../shared/skillhubCatalog').SkillhubCatalogScope }) => Promise<{ success: boolean; file?: { path: string; size: number; language: string; truncated: boolean; content: string }; error?: string; errorCode?: string; }>; - listPublishedVersions: (name: string) => Promise<{ + listPublishedVersions: (name: string, catalogScope?: import('../shared/skillhubCatalog').SkillhubCatalogScope) => Promise<{ success: boolean; versions?: unknown[]; error?: string; @@ -3227,7 +3241,7 @@ interface ElectronAPI { displayName?: string; summary?: string; description?: string; - categories?: string[]; + tags?: string[]; visibility?: 'private' | 'shared' | 'public'; /** 归属统一参数:团队 slug / od- 部门 id;null = 收回到个人 */ teamSlug?: string | null; @@ -3244,7 +3258,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 }>; @@ -3305,7 +3324,7 @@ interface ElectronAPI { myTotalCount?: number; error?: string; }>; - getScanStatus: (params: { slug: string; version?: string }) => Promise<{ + getScanStatus: (params: { slug: string; version?: string; catalogScope?: import('../shared/skillhubCatalog').SkillhubCatalogScope }) => Promise<{ success: boolean; status: string; gates?: Array<{ name: string; status: string; issues?: unknown[] }>; @@ -3342,6 +3361,7 @@ interface ElectronAPI { install: (params: { name: string; version?: string; + catalogScope?: import('../shared/skillhubCatalog').SkillhubCatalogScope; force?: boolean; /** 完整安装目标路径。不传 → global scope 默认路径。*/ installPath?: string; @@ -6354,6 +6374,7 @@ interface StoredInstall { origin?: 'installed' | 'published' | 'learned' | 'imported'; /** 是否由产品自动同步流程安装。用于区分普通市场安装与用户可 opt-out 的自动同步安装。 */ autoSynced?: boolean; + catalogScope?: import('../shared/skillhubCatalog').SkillhubCatalogScope; /** /learn 蒸馏产物的溯源(仅 origin='learned')。personal=true ⇒ publish 拦截。 */ provenance?: import('../shared/learnTypes').LearnProvenance; } @@ -6547,14 +6568,17 @@ interface SkillUsageDiagnosisContext { /* ── SkillHub v0.2.1 publish types ── */ type SkillhubSyncResult = - | { name: string; exists: false } + | { name: string; catalogScope?: 'market' | 'team'; exists: false } | { name: string; + catalogScope?: 'market' | 'team'; exists: true; isMine: boolean; + canManage: boolean; /** server 权威 authorId,用于本地 registry 回填及离线归属判定。 */ authorId?: string; authorName?: string; + publisherName?: string; latestVersion: string; folderHash: string; visibility: 'PUBLIC' | 'DEPARTMENT_SCOPED'; @@ -6571,11 +6595,14 @@ type SkillhubSyncResult = interface SkillhubInfoResult { name: string; + icon?: string | null; displayName: string; description: string; authorId: string; authorName: string; + publisherName?: string; isMine: boolean; + canManage: boolean; latestVersion: string; folderHash: string; visibility: 'PUBLIC' | 'DEPARTMENT_SCOPED'; @@ -6587,9 +6614,16 @@ interface SkillhubInfoResult { version: string; status?: string; }; + visibilityReview?: { + requestedVisibility: 'public'; + status: 'pending' | 'rejected'; + reason?: string; + }; visibleDeptIds: string[]; visibleDeptNames?: string[]; categories?: string[]; + tags?: Array<{ slug: string; name: string; source?: 'author' | 'platform' }>; + githubUrl?: string | null; changelog?: string; publishedAt: string; downloads: number; @@ -6597,6 +6631,7 @@ interface SkillhubInfoResult { currentUserDeptNames?: string[]; /** 跨设备识别:null = pre-feature 历史版本 */ latestPublishedFromDeviceId: string | null; + catalogScope?: import('../shared/skillhubCatalog').SkillhubCatalogScope; } interface SkillhubPublishParams { @@ -6607,8 +6642,7 @@ interface SkillhubPublishParams { displayName?: string; summary?: string; description?: string; - categoryMode?: 'auto' | 'manual'; - categories?: string[]; + tags?: string[]; visibility?: 'PUBLIC' | 'DEPARTMENT_SCOPED' | 'PRIVATE'; visibleSlugs?: string[]; /** 发布者为部门时的部门归属(od- 开头的飞书部门 ID,Hub 端自动转部门团队) */ @@ -6633,6 +6667,8 @@ type SkillhubPublishErrorCode = | 'OSS_OBJECT_NOT_FOUND' | 'API_KEY_MISSING' | 'CANCELLED' + | 'SKILL_HUB_READ_ONLY' + | 'INVALID_VISIBILITY' | 'INTERNAL'; type SkillhubPublishProgressEvent = diff --git a/apps/desktop/src/shared/__tests__/skillhubIdentityPolicy.test.ts b/apps/desktop/src/shared/__tests__/skillhubIdentityPolicy.test.ts new file mode 100644 index 00000000000..9bdc52751ab --- /dev/null +++ b/apps/desktop/src/shared/__tests__/skillhubIdentityPolicy.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { deriveSkillhubIdentityPolicy } from '../skillhubIdentityPolicy'; + +describe('skillhub identity policy', () => { + it('keeps personal publishing personal and excludes shared visibility', () => { + expect(deriveSkillhubIdentityPolicy({ membershipKind: 'personal', orgSlug: null })) + .toMatchObject({ + canWrite: true, + ownerType: 'personal', + allowedVisibilities: ['PUBLIC', 'PRIVATE'], + }); + }); + + it('fixes organization ownership and excludes private visibility', () => { + expect(deriveSkillhubIdentityPolicy({ membershipKind: 'org', orgSlug: 'acme' })) + .toMatchObject({ + canWrite: true, + ownerType: 'organization', + allowedVisibilities: ['PUBLIC', 'DEPARTMENT_SCOPED'], + }); + }); + + it('does not infer backing catalog behavior from an organization slug', () => { + expect(deriveSkillhubIdentityPolicy({ membershipKind: 'org', orgSlug: 'example-org' })) + .toEqual(deriveSkillhubIdentityPolicy({ membershipKind: 'org', orgSlug: 'another-org' })); + }); +}); diff --git a/apps/desktop/src/shared/learnTypes.ts b/apps/desktop/src/shared/learnTypes.ts index d13eda93681..24e130c33ae 100644 --- a/apps/desktop/src/shared/learnTypes.ts +++ b/apps/desktop/src/shared/learnTypes.ts @@ -69,6 +69,8 @@ export interface LearnRunPublic { input: string; /** sourceKind='hub' 时的市场 slug。 */ hubSlug?: string; + /** Hub 目录上下文;旧 run 缺失时默认公开目录。 */ + hubCatalogScope?: 'market' | 'team'; /** 蒸馏 session id(distilling 起有值,renderer 可跳转查看过程)。 */ sessionId?: string; /** 触发 /learn 的会话 id(用于把状态卡插回原会话)。 */ @@ -105,6 +107,7 @@ export interface LearnStartRequest { input: string; sourceKind: LearnSourceKind; hubSlug?: string; + hubCatalogScope?: 'market' | 'team'; originSessionId?: string; } diff --git a/apps/desktop/src/shared/skillhubCatalog.ts b/apps/desktop/src/shared/skillhubCatalog.ts new file mode 100644 index 00000000000..50164896480 --- /dev/null +++ b/apps/desktop/src/shared/skillhubCatalog.ts @@ -0,0 +1,23 @@ +export const SKILLHUB_CATALOG_SCOPES = ['market', 'team'] as const; +export type SkillhubCatalogScope = (typeof SKILLHUB_CATALOG_SCOPES)[number]; + +export function isSkillhubCatalogScope(value: unknown): value is SkillhubCatalogScope { + return typeof value === 'string' + && SKILLHUB_CATALOG_SCOPES.includes(value as SkillhubCatalogScope); +} + +export function skillhubCatalogKey(slug: string, scope?: SkillhubCatalogScope): string { + // Missing scope is the authenticated native/management view. Market and + // team are explicit catalog reads and must never alias this key. + return `${scope ?? 'native'}:${slug}`; +} + +/** Keeps follow-up reads on the generic catalog that produced the list item. */ +export function withSkillhubCatalogScope( + path: string, + scope: SkillhubCatalogScope | undefined, +): string { + if (!scope) return path; + const separator = path.includes('?') ? '&' : '?'; + return `${path}${separator}scope=${encodeURIComponent(scope)}`; +} diff --git a/apps/desktop/src/shared/skillhubCategory.ts b/apps/desktop/src/shared/skillhubCategory.ts index 792cf17a918..f1ef90a12ce 100644 --- a/apps/desktop/src/shared/skillhubCategory.ts +++ b/apps/desktop/src/shared/skillhubCategory.ts @@ -9,6 +9,7 @@ export interface MarketCategory { name: string; count: number; myCount: number; + source?: 'author' | 'platform'; children?: MarketCategory[]; } diff --git a/apps/desktop/src/shared/skillhubIdentityPolicy.ts b/apps/desktop/src/shared/skillhubIdentityPolicy.ts new file mode 100644 index 00000000000..1fe7ab2e4a9 --- /dev/null +++ b/apps/desktop/src/shared/skillhubIdentityPolicy.ts @@ -0,0 +1,41 @@ +export type SkillhubPublishVisibility = 'PUBLIC' | 'DEPARTMENT_SCOPED' | 'PRIVATE'; + +export interface SkillhubIdentity { + membershipKind: 'personal' | 'org'; + orgSlug: string | null; + orgName?: string | null; +} +export interface SkillhubIdentityPolicy { + canWrite: boolean; + ownerType: 'personal' | 'organization' | null; + allowedVisibilities: readonly SkillhubPublishVisibility[]; + readOnlyReason: 'signed-out' | null; +} + +/** UI projection only; authorization and organization-specific policy remain server-owned. */ +export function deriveSkillhubIdentityPolicy( + identity: SkillhubIdentity | null | undefined, +): SkillhubIdentityPolicy { + if (!identity) { + return { + canWrite: false, + ownerType: null, + allowedVisibilities: [], + readOnlyReason: 'signed-out', + }; + } + if (identity.membershipKind === 'org') { + return { + canWrite: true, + ownerType: 'organization', + allowedVisibilities: ['PUBLIC', 'DEPARTMENT_SCOPED'], + readOnlyReason: null, + }; + } + return { + canWrite: true, + ownerType: 'personal', + allowedVisibilities: ['PUBLIC', 'PRIVATE'], + readOnlyReason: 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/docs/design-rules/design-inventory.md b/docs/design-rules/design-inventory.md index e3d31d1a497..dd4e5428fe4 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。 @@ -39,8 +39,8 @@ | `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.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/TeamScopePicker.tsx, apps/desktop/src/renderer/features/skillhub/components/VisibilityEditorDialog.tsx | 41 | 0 | 50 | +| `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 | | `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 | @@ -141,4 +141,3 @@ Mobile 本轮不展开顶层 screen,**待 DS-9 增量**。 | `desktop.window.sidebar` | unassigned | legacy | DESIGN.md §15 CINDY 皮肤族 | 待 DS-4 标准组件落地后按 Pattern 迁 | 保持现状;发现问题记下一动作,本张不修视觉 | | `desktop.window.voice-dictionary-toast` | unassigned | legacy | — | 待 DS-4 标准组件落地后按 Pattern 迁 | 保持现状;发现问题记下一动作,本张不修视觉 | | `desktop.window.voice-overlay` | unassigned | legacy | — | 待 DS-4 标准组件落地后按 Pattern 迁 | 保持现状;发现问题记下一动作,本张不修视觉 | - diff --git a/docs/dev-rules/protocol-compatibility.md b/docs/dev-rules/protocol-compatibility.md index ac2da48128d..da42d0cd4d4 100644 --- a/docs/dev-rules/protocol-compatibility.md +++ b/docs/dev-rules/protocol-compatibility.md @@ -19,6 +19,7 @@ | device-link relay 层定义 | 客户端 `packages/device-link-protocol`;服务端仓同名本地 package,客户端重连、IPC allowlist、隧道 payload 在 `packages/device-link` | | Plugin 交付与 manifest | 客户端 `packages/plugin-protocol`;服务端仓同名本地 package,desktop、`packages/cindy-tools` 与 plugin-server 分别消费本仓实现 | | 模型目录 | 客户端由 `packages/model-providers/src/modelAccessBean.ts` 与 `modelAccessValidator.ts` 维护;model-access-server 在服务端仓维护对应 Bean/validator,双方只共享稳定 wire 语义,不共享实现 | +| Skill Hub | Desktop 的 `apps/desktop/src/main/skillhub` 与 `shared/skillhubCatalog.ts`;服务端仓 `packages/skill-hub-protocol` 与 `cindy-skill-hub-server` | | 插件来源 | 客户端不预装插件;一律通过 SkillHub 或用户手动安装 `.cindy` 包 | ## 1. 两仓本地协议演进 @@ -34,6 +35,20 @@ 应在两仓分别落地,并用相同的有效/无效 fixture 覆盖边界。 - 新业务域的契约优先放进所属业务仓库;不要建立新的公共协议仓来重新引入发布耦合。 +### Skill Hub 目录与管理契约 + +- `scope=market|team` 是公开与组织目录的通用读取上下文;列表得到的 scope 必须贯穿详情、 + 文件、版本、扫描、下载、Learn 和批量同步。同 slug 在不同 scope 下是两条独立记录。 +- 单条详情、批量同步等原生管理读取省略 scope,不得把省略值当成 `market`。本地 registry + 对已发布旧版客户端遗留且缺少 scope 的安装记录一次性回填为 `team`;新记录显式保存 + 来源目录,原生管理记录则以已迁移标记保留缺省 scope。 +- `isMine` 表示归属当前个人或组织,逐 Skill 写权限只看服务端 `canManage`,客户端不得用 + 账号级写能力与 `isMine` 推导管理权。 +- 作者标签通过 `tags: string[]` 传标签名称;`source=platform` 的治理标签只展示和筛选, + 不作为作者编辑项提交。 +- Cindy Skill Hub 客户端与服务端在首次对外发布前同步收紧以上契约,不为未发布过的中间 + 协议增加 fallback;已经发布的旧客户端仍使用原有 XD Skill Hub endpoint,不受此契约影响。 + ## 2. 插件来源 - 客户端不包含内建插件种子,不在安装包中预置插件,启动期也没有播种 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`);