feat: Implement core web application structure including landing page, skill registry, layout, routing, and internationalization. (#87)

This commit is contained in:
dongmucat 2026-03-18 17:20:49 +08:00 • committed by GitHub
parent f5db6edde9
commit 42e6fff693
9 changed files with 386 additions and 4 deletions

View file

@ -10,6 +10,14 @@ export function Layout() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { user, isLoading } = useAuth()
if (pathname === '/registry/skill') {
return (
<Suspense fallback={null}>
<Outlet />
</Suspense>
)
}
const navItems: Array<{
label: string
to: string

View file

@ -60,6 +60,7 @@ const SearchPage = createLazyRouteComponent(() => import('@/pages/search'), 'Sea
const TermsOfServicePage = createLazyRouteComponent(() => import('@/pages/terms'), 'TermsOfServicePage')
const NamespacePage = createLazyRouteComponent(() => import('@/pages/namespace'), 'NamespacePage')
const SkillDetailPage = createLazyRouteComponent(() => import('@/pages/skill-detail'), 'SkillDetailPage')
const RegistrySkillPage = createLazyRouteComponent(() => import('@/pages/registry-skill'), 'RegistrySkillPage')
const DashboardPage = createLazyRouteComponent(() => import('@/pages/dashboard'), 'DashboardPage')
const MySkillsPage = createLazyRouteComponent(() => import('@/pages/dashboard/my-skills'), 'MySkillsPage')
const PublishPage = createLazyRouteComponent(() => import('@/pages/dashboard/publish'), 'PublishPage')
@ -214,6 +215,12 @@ const skillDetailRoute = createRoute({
component: SkillDetailPage,
})
const registrySkillRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/registry/skill',
component: RegistrySkillPage,
})
const dashboardRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'dashboard',
@ -360,6 +367,7 @@ const routeTree = rootRoute.addChildren([
termsRoute,
namespaceRoute,
skillDetailRoute,
registrySkillRoute,
dashboardRoute,
dashboardSkillsRoute,
dashboardPublishRoute,

193
web/src/docs/skill.md Normal file
View file

@ -0,0 +1,193 @@
---
name: skillhub-registry
description: Use this when you need to search, inspect, install, or publish agent skills against a SkillHub registry. SkillHub is a self-hosted skill registry with a ClawHub-compatible API layer, so prefer the `clawhub` CLI for registry operations instead of making raw HTTP calls.
---
# SkillHub Registry
Use this skill when you need to work with a SkillHub deployment: search skills, inspect metadata, install a package, or publish a new version.
> Important: Prefer the `clawhub` CLI for registry workflows. SkillHub exposes a ClawHub-compatible API surface and a discovery endpoint at `/.well-known/clawhub.json`, so the CLI is the safest path for auth, resolution, and download behavior. Only fall back to raw HTTP when debugging the server itself.
## What SkillHub Is
SkillHub is a self-hosted, enterprise-oriented skill registry. It stores versioned skill packages, supports namespace-based governance, and keeps `SKILL.md` compatibility with OpenSkills-style packages.
Key facts:
- Internal coordinates use `@{namespace}/{skill_slug}`.
- ClawHub-compatible clients use a canonical slug instead.
- `latest` always means the latest published version, never draft or pending review.
- Public skills in `@global` can be downloaded anonymously.
- Team namespace skills and non-public skills require authentication.
## Configure The CLI
Point `clawhub` at the SkillHub base URL:
```bash
export CLAWHUB_REGISTRY_URL=https://skillhub.your-company.com
```
If you need authenticated access, provide an API token:
```bash
export CLAWHUB_API_TOKEN=sk_your_api_token_here
```
Optional local check:
```bash
curl https://skillhub.your-company.com/.well-known/clawhub.json
```
Expected response:
```json
{ "apiBase": "/api/v1" }
```
## Coordinate Rules
SkillHub has two naming forms:
| SkillHub coordinate | Canonical slug for `clawhub` |
|---|---|
| `@global/my-skill` | `my-skill` |
| `@team-name/my-skill` | `team-name--my-skill` |
Rules:
- `--` is the namespace separator in the compatibility layer.
- If there is no `--`, the skill is treated as `@global/...`.
- `latest` resolves to the latest published version only.
Examples:
```bash
npx clawhub install my-skill
npx clawhub install my-skill@1.2.0
npx clawhub install team-name--my-skill
```
## Common Workflows
### Search
```bash
npx clawhub search email
```
Use an empty query when you want a broad listing:
```bash
npx clawhub search ""
```
### Inspect A Skill
```bash
npx clawhub info my-skill
npx clawhub info team-name--my-skill
```
### Install
```bash
npx clawhub install my-skill
npx clawhub install my-skill@1.2.0
npx clawhub install team-name--my-skill
```
### Publish
Prepare a skill package directory, then publish it:
```bash
npx clawhub publish ./my-skill
```
Publishing requires authentication and sufficient permissions in the target namespace.
## Authentication And Visibility
Download and search permissions depend on namespace and visibility:
- `@global` + `PUBLIC`: anonymous search, inspect, and download are allowed.
- Team namespace + `PUBLIC`: authentication required for download.
- `NAMESPACE_ONLY`: authenticated namespace members only.
- `PRIVATE`: owner or explicitly authorized users only.
- Publish, star, and other write operations always require authentication.
If a request fails with `403`, check:
- whether the skill belongs to a team namespace,
- whether the skill is `NAMESPACE_ONLY` or `PRIVATE`,
- whether your token is valid,
- whether you have namespace publish permissions.
## Skill Package Contract
SkillHub expects OpenSkills-style packages with `SKILL.md` as the entry point.
Minimum valid `SKILL.md` frontmatter:
```yaml
---
name: my-skill
description: When to use this skill
---
```
Required structure:
```text
my-skill/
├── SKILL.md
├── references/
├── scripts/
└── assets/
```
Contract notes:
- `name` and `description` are required.
- `name` becomes the immutable skill slug on first publish.
- `description` becomes the registry summary.
- `references/`, `scripts/`, and `assets/` are optional.
- The package is treated as a text-first resource bundle, not a binary artifact bucket.
## Publishing Guidance
Before publishing:
1. Ensure `SKILL.md` exists at the package root.
2. Keep the skill name in kebab-case.
3. Make sure the version you are publishing is semver-compatible.
4. Avoid relying on `latest` as a rollback tool; SkillHub keeps `latest` automatically pinned to the newest published version.
5. Use custom tags like `beta` or `stable` for release channels when needed.
## When To Use Raw HTTP
Use direct HTTP only for server debugging, contract testing, or compatibility work. Relevant endpoints exposed by the current codebase include:
- `GET /.well-known/clawhub.json`
- `GET /api/v1/search`
- `GET /api/v1/resolve`
- `GET /api/v1/download/{slug}`
- `GET /api/v1/skills/{slug}`
- `POST /api/v1/publish`
- `GET /api/v1/whoami`
For normal registry usage, stay on the `clawhub` CLI.
## Project References
Read these local documents when you need more detail about SkillHub behavior:
- `docs/00-product-direction.md`
- `docs/06-api-design.md`
- `docs/07-skill-protocol.md`
- `docs/14-skill-lifecycle.md`
- `docs/openclaw-integration.md`
- `README.md`

View file

@ -99,8 +99,20 @@
"quickStart": {
"title": "Quick Start",
"subtitle": "快速开始",
"description": "Get started with SkillHub in just a few simple steps",
"description": "Choose how you want to work, then copy the setup instruction and continue",
"tip": "💡 Tip: Visit the skill detail page for one-click install commands with environment variables",
"tabs": {
"agent": "I am Agent",
"human": "I am Human"
},
"agent": {
"description": "Send a prompt to your Agent to set up the SkillHub Registry",
"command": "Read https://www.example.com/registry/skill and follow the instructions to setup SkillHub Skills Registry"
},
"human": {
"description": "Use the CLI tool to install Skills",
"command": "npx clawhub search <keyword>"
},
"steps": {
"configureEnv": {
"title": "1. Configure Environment Variables",

View file

@ -99,8 +99,20 @@
"quickStart": {
"title": "快速开始",
"subtitle": "Quick Start",
"description": "只需几个简单步骤,即可开始使用 SkillHub",
"description": "选择你的使用方式,复制说明后继续完成接入",
"tip": "💡 提示:访问技能详情页可获取带环境变量的一键复制安装命令",
"tabs": {
"agent": "我是 Agent",
"human": "我是 Human"
},
"agent": {
"description": "发送提示词给你的 Agent,以设置SkillHub Registry",
"command": "Read https://www.example.com/registry/skill and follow the instructions to setup SkillHub Skills Registry"
},
"human": {
"description": "使用CLI工具安装Skills",
"command": "npx clawhub search <keyword>"
},
"steps": {
"configureEnv": {
"title": "1. 配置环境变量",

View file

@ -2,7 +2,7 @@ import { Link, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { normalizeSearchQuery } from '@/shared/lib/search-query'
import { PackageOpen, Terminal, Shield, Users, GitBranch, Search as SearchIcon, Settings } from 'lucide-react'
import { QuickStartSection } from '@/shared/components/quick-start'
import { LandingQuickStartSection } from '@/shared/components/landing-quick-start'
import { SkillCard } from '@/features/skill/skill-card'
import { SkeletonList } from '@/shared/components/skeleton-loader'
import { useSearchSkills } from '@/shared/hooks/use-skill-queries'
@ -195,7 +195,7 @@ export function LandingPage() {
{/* Quick Start */}
<div ref={quickStartView.ref} className={`scroll-fade-up${quickStartView.inView ? ' in-view' : ''}`}>
<QuickStartSection variant="landing" ns="landing" />
<LandingQuickStartSection />
</div>
{/* Popular Downloads Section */}

View file

@ -0,0 +1,9 @@
import skillContent from '@/docs/skill.md?raw'
export function RegistrySkillPage() {
return (
<pre className="whitespace-pre-wrap break-words p-0 m-0 font-mono text-sm">
{skillContent}
</pre>
)
}

View file

@ -0,0 +1,136 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Bot, Check, Copy, UserRound } from 'lucide-react'
type LandingQuickStartTabId = 'agent' | 'human'
interface LandingQuickStartTab {
id: LandingQuickStartTabId
label: string
description: string
command: string
}
function CompactCopyButton({ text }: { text: string }) {
const { t } = useTranslation()
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy:', err)
}
}
const label = copied ? (t('copyButton.copied') || 'Copied') : (t('copyButton.copy') || 'Copy')
return (
<button
type="button"
onClick={handleCopy}
aria-label={label}
title={label}
className="absolute right-2.5 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-xl border bg-white transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 cursor-pointer"
style={{ borderColor: 'hsl(var(--border))', color: 'hsl(var(--foreground))' }}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</button>
)
}
export function LandingQuickStartSection() {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<LandingQuickStartTabId>('agent')
const tabs: LandingQuickStartTab[] = [
{
id: 'agent',
label: t('landing.quickStart.tabs.agent'),
description: t('landing.quickStart.agent.description'),
command: t('landing.quickStart.agent.command'),
},
{
id: 'human',
label: t('landing.quickStart.tabs.human'),
description: t('landing.quickStart.human.description'),
command: t('landing.quickStart.human.command'),
},
]
const currentTab = tabs.find((tab) => tab.id === activeTab) ?? tabs[0]
return (
<section className="relative z-10 w-full px-6 py-14 md:py-16" style={{ background: 'var(--bg-page, hsl(var(--background)))' }}>
<div className="max-w-4xl mx-auto">
<div className="text-center mb-7 md:mb-8">
<h2 className="text-3xl md:text-4xl font-bold tracking-tight mb-3" style={{ color: 'hsl(var(--foreground))' }}>
{t('landing.quickStart.title')}
</h2>
<p className="text-base md:text-lg max-w-2xl mx-auto leading-relaxed" style={{ color: 'hsl(var(--text-secondary))' }}>
{t('landing.quickStart.description', { defaultValue: t('landing.quickStart.subtitle') })}
</p>
</div>
<div
className="mx-auto max-w-2xl rounded-[28px] border bg-white p-3 shadow-[0_24px_60px_-28px_rgba(15,23,42,0.25)]"
style={{ borderColor: 'hsl(var(--border-card))' }}
>
<div
className="grid grid-cols-2 gap-2 rounded-2xl p-1.5"
style={{ background: 'linear-gradient(180deg, rgba(248,250,252,0.98) 0%, rgba(241,245,249,0.92) 100%)' }}
>
{tabs.map((tab) => {
const isActive = tab.id === currentTab.id
const Icon = tab.id === 'agent' ? Bot : UserRound
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
aria-pressed={isActive}
className="flex min-h-11 items-center justify-center gap-2 rounded-[14px] px-4 py-3 text-base font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 cursor-pointer"
style={{
background: isActive ? 'rgba(255,255,255,0.96)' : 'transparent',
color: isActive ? 'hsl(var(--foreground))' : 'hsl(var(--muted-foreground))',
boxShadow: isActive ? '0 6px 18px rgba(15, 23, 42, 0.08)' : 'none',
}}
>
<Icon className="h-4 w-4" strokeWidth={1.75} />
<span>{tab.label}</span>
</button>
)
})}
</div>
<div className="px-4 pb-4 pt-8 md:px-8 md:pb-6 md:pt-9">
<p
className="mx-auto mb-6 max-w-xl text-center text-xl font-medium leading-relaxed md:text-[1.75rem]"
style={{ color: 'hsl(var(--foreground))' }}
>
{currentTab.description}
</p>
<div
className="relative rounded-2xl border bg-slate-50/90 px-4 py-3 pr-14 shadow-[inset_0_1px_0_rgba(255,255,255,0.7)]"
style={{ borderColor: 'hsl(var(--border))' }}
>
<div className="overflow-x-auto whitespace-nowrap">
<code
className="font-mono text-sm md:text-base"
style={{ color: currentTab.id === 'agent' ? '#16A34A' : '#0F172A' }}
>
{currentTab.command}
</code>
</div>
<CompactCopyButton text={currentTab.command} />
</div>
</div>
</div>
</div>
</section>
)
}

4
web/src/types/markdown.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module '*.md?raw' {
const value: string
export default value
}