feat(search): refine compliance discovery interactions

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-08-10 10:10:01 +08:00
parent 00f55c2db3
commit 1dfe3756a9
8 changed files with 143 additions and 96 deletions

View file

@ -63,7 +63,8 @@ x-astron-compliance: # 可选,平台私有合规元数据
```
> 合规元数据先按 SkillHub/Astron 私有扩展实现,字段名采用 `x-astron-compliance`。
> 当前第一阶段支持发布校验和版本级 `complianceSnapshot` 固化;详情展示、审核 diff、搜索 facet
> 当前第一阶段支持发布校验和版本级 `complianceSnapshot` 固化;这些信息表示“技能作者声明的合规映射”,
> SkillHub 校验证据引用的格式和可访问性,但不等同于第三方认证或平台背书。详情展示、审核 diff、搜索投影
> 和 Runtime trace 集成按后续阶段推进。设计边界、分阶段实现和 Runtime 职责划分见
> [24-compliance-metadata-design.md](24-compliance-metadata-design.md)。

View file

@ -20,6 +20,10 @@ Issue #556 提出的方向是让 SkillHub 支持“可标准映射、可审计
> SkillHub 负责“这个技能版本声明了什么合规能力”;Agent Runtime 负责“这次执行实际用了哪个技能版本”。两者通过 `skillVersionId + complianceSnapshotDigest` 关联。
这里的 compliance 是作者随技能包提交的声明型元数据。SkillHub 第一阶段只验证字段结构、取值格式、
包内证据文件是否存在、外部证据 URL 是否是合法 HTTP(S) URL,并生成不可变快照摘要;它不验证外部标准内容是否真实适用,
也不代表第三方审计、认证通过或平台背书。
## 2. 职责边界
### 2.1 SkillHub 职责

View file

@ -1,5 +1,8 @@
/** @vitest-environment jsdom */
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ComplianceSnapshotPanel } from './compliance-snapshot-panel'
vi.mock('react-i18next', async () => {
@ -8,14 +11,32 @@ vi.mock('react-i18next', async () => {
...actual,
useTranslation: () => ({
t: (key: string, values?: Record<string, number>) =>
key === 'compliance.mappingCount' ? `${values?.count} mappings` : key,
key === 'compliance.mappingCount'
? `${values?.count} mappings`
: key === 'common.expand'
? '展开详情'
: key === 'common.collapse'
? '收起详情'
: key === 'compliance.title'
? '合规声明'
: key,
}),
}
})
describe('ComplianceSnapshotPanel', () => {
it('renders compliance mappings and evidence', () => {
const html = renderToStaticMarkup(
afterEach(() => {
cleanup()
})
it('renders nothing when there are no compliance mappings', () => {
const html = renderToStaticMarkup(<ComplianceSnapshotPanel snapshot={{ schemaVersion: '1.0', items: [], digest: 'sha256:empty' }} />)
expect(html).toBe('')
})
it('renders a compact summary by default and expands on demand', () => {
render(
<ComplianceSnapshotPanel
snapshot={{
schemaVersion: '1.0',
@ -28,21 +49,37 @@ describe('ComplianceSnapshotPanel', () => {
title: 'Command and Scripting Interpreter',
evidence: [{ type: 'packaged-file', path: 'references/standards.md', sha256: 'abc' }],
},
{
standard: 'nist-csf',
version: '2.0',
controlId: 'PR.DS-01',
title: 'Data-at-rest protection',
evidence: [],
},
{
standard: 'soc2',
version: '2023',
controlId: 'CC6.1',
title: 'Logical Access Security',
evidence: [],
},
],
}}
/>,
)
expect(html).toContain('compliance.title')
expect(html).toContain('1 mappings')
expect(html).toContain('mitre-attack')
expect(html).toContain('T1059')
expect(html).toContain('references/standards.md')
})
const toggle = screen.getByRole('button', { name: '展开详情' })
expect(toggle).toBeTruthy()
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(screen.getByText('mitre-attack · T1059')).toBeTruthy()
expect(screen.getByText('+1')).toBeTruthy()
expect(screen.queryByText('references/standards.md')).toBeNull()
it('renders nothing when there are no compliance mappings', () => {
const html = renderToStaticMarkup(<ComplianceSnapshotPanel snapshot={{ schemaVersion: '1.0', items: [], digest: 'sha256:empty' }} />)
fireEvent.click(toggle)
expect(html).toBe('')
const expandedToggle = screen.getByRole('button', { name: '收起详情' })
expect(expandedToggle.getAttribute('aria-expanded')).toBe('true')
expect(screen.getByText('references/standards.md')).toBeTruthy()
expect(screen.getByText('soc2')).toBeTruthy()
})
})

View file

@ -1,4 +1,5 @@
import { ExternalLink, ShieldCheck } from 'lucide-react'
import { useState } from 'react'
import { ChevronDown, ChevronUp, ExternalLink, ShieldCheck } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { ComplianceSnapshot } from '@/api/types'
import { cn } from '@/shared/lib/utils'
@ -6,6 +7,7 @@ import { cn } from '@/shared/lib/utils'
interface ComplianceSnapshotPanelProps {
snapshot?: ComplianceSnapshot | null
className?: string
defaultExpanded?: boolean
}
function shortDigest(digest?: string) {
@ -18,61 +20,98 @@ function shortDigest(digest?: string) {
return `${digest.slice(0, 17)}…`
}
export function ComplianceSnapshotPanel({ snapshot, className }: ComplianceSnapshotPanelProps) {
export function ComplianceSnapshotPanel({ snapshot, className, defaultExpanded = false }: ComplianceSnapshotPanelProps) {
const { t } = useTranslation()
const items = snapshot?.items?.filter((item) => item.standard || item.controlId) ?? []
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
if (items.length === 0) {
return null
}
return (
<div className={cn('rounded-2xl border border-emerald-500/20 bg-emerald-500/5 p-4', className)}>
<div className={cn('rounded-2xl border border-emerald-500/20 bg-emerald-500/5 p-4', className)} data-compliance-snapshot-panel>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-foreground">
<ShieldCheck className="h-4 w-4 text-emerald-500" />
{t('compliance.title')}
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300">
{t('compliance.mappingCount', { count: items.length })}
</span>
{snapshot?.schemaVersion ? (
<span className="rounded-full bg-secondary px-2 py-0.5 font-mono text-xs text-secondary-foreground">
{snapshot.schemaVersion}
</span>
) : null}
</div>
<div className="font-mono text-xs text-muted-foreground" title={snapshot?.digest}>
{shortDigest(snapshot?.digest)}
<div className="flex items-center gap-2">
<div className="font-mono text-xs text-muted-foreground" title={snapshot?.digest}>
{shortDigest(snapshot?.digest)}
</div>
<button
type="button"
className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-background/80 px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-background"
aria-expanded={isExpanded}
data-compliance-snapshot-toggle
onClick={() => setIsExpanded((value) => !value)}
>
{isExpanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
{isExpanded ? t('common.collapse') : t('common.expand')}
</button>
</div>
</div>
<div className="mt-3 grid gap-2">
{items.map((item, index) => (
<div key={`${item.standard ?? 'standard'}-${item.version ?? 'version'}-${item.controlId ?? index}`} className="rounded-xl border border-border/60 bg-background/70 p-3">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full bg-secondary px-2 py-0.5 font-mono text-xs text-secondary-foreground">
{item.standard ?? t('compliance.unknownStandard')}
</span>
{item.version ? (
<span className="font-mono text-xs text-muted-foreground">{item.version}</span>
) : null}
<span className="font-mono text-sm font-semibold text-foreground">{item.controlId ?? '—'}</span>
</div>
{item.title ? (
<div className="mt-1 text-sm text-muted-foreground">{item.title}</div>
) : null}
{item.evidence && item.evidence.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{item.evidence.map((evidence, evidenceIndex) => (
<span
key={`${evidence.type ?? 'evidence'}-${evidence.path ?? evidence.url ?? evidenceIndex}`}
className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-xs text-muted-foreground"
title={evidence.sha256}
>
{evidence.url ? <ExternalLink className="h-3 w-3" /> : null}
{evidence.path ?? evidence.url ?? evidence.type ?? t('compliance.evidence')}
</span>
))}
</div>
) : null}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{items.slice(0, isExpanded ? items.length : 2).map((item, index) => (
<span
key={`${item.standard ?? 'standard'}-${item.version ?? 'version'}-${item.controlId ?? index}`}
className="inline-flex items-center gap-1 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2.5 py-1 text-xs font-medium text-emerald-800 dark:text-emerald-200"
>
<ShieldCheck className="h-3 w-3" />
{[item.standard, item.controlId].filter(Boolean).join(' · ')}
</span>
))}
{!isExpanded && items.length > 2 ? (
<span className="inline-flex items-center rounded-full border border-dashed border-border/70 px-2.5 py-1 text-xs text-muted-foreground">
+{items.length - 2}
</span>
) : null}
</div>
{isExpanded ? (
<div className="mt-3 grid gap-2" data-compliance-snapshot-detail>
{items.map((item, index) => (
<div key={`${item.standard ?? 'standard'}-${item.version ?? 'version'}-${item.controlId ?? index}`} className="rounded-xl border border-border/60 bg-background/70 p-3">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full bg-secondary px-2 py-0.5 font-mono text-xs text-secondary-foreground">
{item.standard ?? t('compliance.unknownStandard')}
</span>
{item.version ? (
<span className="font-mono text-xs text-muted-foreground">{item.version}</span>
) : null}
<span className="font-mono text-sm font-semibold text-foreground">{item.controlId ?? '—'}</span>
</div>
{item.title ? (
<div className="mt-1 text-sm text-muted-foreground">{item.title}</div>
) : null}
{item.evidence && item.evidence.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{item.evidence.map((evidence, evidenceIndex) => (
<span
key={`${evidence.type ?? 'evidence'}-${evidence.path ?? evidence.url ?? evidenceIndex}`}
className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-xs text-muted-foreground"
title={evidence.sha256}
>
{evidence.url ? <ExternalLink className="h-3 w-3" /> : null}
{evidence.path ?? evidence.url ?? evidence.type ?? t('compliance.evidence')}
</span>
))}
</div>
) : null}
</div>
))}
</div>
) : null}
</div>
)
}

View file

@ -1,10 +1,14 @@
{
"compliance": {
"title": "Compliance mappings",
"title": "Compliance claims",
"mappingCount": "{{count}} items",
"unknownStandard": "Unknown standard",
"evidence": "Evidence"
},
"common": {
"expand": "Expand details",
"collapse": "Collapse details"
},
"nav": {
"landing": "Home",
"home": "Skill Center",
@ -199,8 +203,7 @@
"enterKeyword": "Please enter a search keyword",
"results": "{{count}} skills found",
"resultCount": "Found <1>{{count}}</1> results",
"loadingMore": "Updating search results...",
"complianceSuggestions": "Compliance:"
"loadingMore": "Updating search results..."
},
"searchBar": {
"placeholder": "Search skills...",

View file

@ -1,10 +1,14 @@
{
"compliance": {
"title": "合规映射",
"title": "合规声明",
"mappingCount": "{{count}} 项",
"unknownStandard": "未知标准",
"evidence": "证据"
},
"common": {
"expand": "展开详情",
"collapse": "收起详情"
},
"nav": {
"landing": "首页",
"home": "技能中心",
@ -199,8 +203,7 @@
"enterKeyword": "请输入搜索关键词",
"results": "找到 {{count}} 个技能",
"resultCount": "找到 <1>{{count}}</1> 个结果",
"loadingMore": "正在更新搜索结果...",
"complianceSuggestions": "合规检索:"
"loadingMore": "正在更新搜索结果..."
},
"searchBar": {
"placeholder": "搜索技能...",

View file

@ -200,25 +200,6 @@ describe('SearchPage', () => {
})
})
it('offers compliance search suggestions that update the query', () => {
renderToStaticMarkup(<SearchPage />)
findButton('MITRE T1059').onClick?.()
expect(navigateMock).toHaveBeenCalledWith({
to: '/search',
search: {
q: 'MITRE T1059',
namespace: 'team-ai',
label: 'code-generation',
sort: 'downloads',
page: 0,
starredOnly: false,
},
replace: true,
})
})
it('preserves the active label when paging and when toggling starred-only', () => {
renderToStaticMarkup(<SearchPage />)

View file

@ -18,7 +18,6 @@ import { Button } from '@/shared/ui/button'
import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style'
const PAGE_SIZE = 12
const COMPLIANCE_SEARCH_SUGGESTIONS = ['MITRE T1059', 'NIST CSF', 'SOC2', 'GDPR']
function blurActiveElement() {
if (typeof document === 'undefined' || typeof HTMLElement === 'undefined') {
@ -166,13 +165,6 @@ export function SearchPage() {
})
}
const handleComplianceSuggestion = (query: string) => {
setQueryInput(formatNamespaceSearchInput(namespace, query))
startTransition(() => {
navigate({ to: '/search', search: { q: query, namespace, label: selectedLabel, sort, page: 0, starredOnly }, replace: true })
})
}
const handleSortChange = (newSort: string) => {
navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort: newSort, page: 0, starredOnly } })
}
@ -313,19 +305,6 @@ export function SearchPage() {
</Button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="shrink-0 text-sm font-medium text-muted-foreground">{t('search.complianceSuggestions')}</span>
{COMPLIANCE_SEARCH_SUGGESTIONS.map((suggestion) => (
<Button
key={suggestion}
variant={q.toLowerCase() === suggestion.toLowerCase() ? 'default' : 'outline'}
size="sm"
onClick={() => handleComplianceSuggestion(suggestion)}
>
{suggestion}
</Button>
))}
</div>
</div>
{/* Results */}