diff --git a/.env.example b/.env.example
index 058e52b..8d41947 100644
--- a/.env.example
+++ b/.env.example
@@ -4,6 +4,17 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/agent_park"
# Webhook API - Generate a secure key for production
WEBHOOK_API_KEY="sk_live_your_secure_api_key_min_32_chars"
+# Admin Console key for Agent OS endpoints and dashboard
+ADMIN_CONSOLE_KEY="sk_admin_your_secure_admin_key_min_32_chars"
+
+# Agent OS model runtime (OpenAI-compatible)
+# BigModel GLM example endpoint:
+# https://open.bigmodel.cn/api/coding/paas/v4/chat/completions
+AGENT_OS_MODEL_ENDPOINT="https://open.bigmodel.cn/api/coding/paas/v4/chat/completions"
+AGENT_OS_MODEL_NAME="glm-4.7"
+AGENT_OS_MODEL_API_KEY="sk_your_model_api_key"
+AGENT_OS_MODEL_TIMEOUT_MS="30000"
+
# n8n AI Search Webhook
N8N_AI_SEARCH_WEBHOOK="https://n8n.mzaxd.fun/webhook/ai-search"
diff --git a/.opencode/mcp.json b/.opencode/mcp.json
index 5eca4d4..c5e3fb9 100644
--- a/.opencode/mcp.json
+++ b/.opencode/mcp.json
@@ -9,7 +9,7 @@
"LOG_LEVEL": "error",
"DISABLE_CONSOLE_OUTPUT": "true",
"N8N_API_URL": "https://n8n.mzaxd.fun",
- "N8N_API_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmMzliZjQ1NC1kMjliLTQ2OGQtYjA4ZC0yYTE1YjJlODA0NzkiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiwianRpIjoiMWM2YmQ2NTEtMWIwOC00YTFmLTg1MzMtMGU5MzliNGI0NTM1IiwiaWF0IjoxNzY5OTIxMjgwfQ.U1i3dXDGJyYIVATii86rKXgbcK2OyRmt3aHa5CyA5k8",
+ "N8N_API_KEY": "${N8N_API_KEY}",
"HTTP_PROXY": "",
"HTTPS_PROXY": "",
"http_proxy": "",
diff --git a/docs/agent-os-runtime.md b/docs/agent-os-runtime.md
new file mode 100644
index 0000000..7e094e4
--- /dev/null
+++ b/docs/agent-os-runtime.md
@@ -0,0 +1,51 @@
+# Agent OS Runtime Notes
+
+## Live Model Configuration
+
+Set these environment variables before running the Agent OS goal APIs:
+
+- `AGENT_OS_MODEL_ENDPOINT` (OpenAI-compatible chat completions endpoint)
+- `AGENT_OS_MODEL_NAME` (default: `glm-4.7`)
+- `AGENT_OS_MODEL_API_KEY`
+- `AGENT_OS_MODEL_TIMEOUT_MS` (optional, default `30000`)
+
+`/api/agent/goals` uses model planning first. If model config is missing or call fails, it falls back to heuristic planning and annotates `run.planning.warning`.
+
+## n8n Production Workflow Mapping
+
+`/api/admin/resources/skills` exposes `n8nProductionWorkflows` and corresponding n8n-backed skill manifests:
+
+- Daily Trending discovery
+- Topic discovery
+- Multi-source signal aggregation
+- Project description embedding refresh
+- RAG similarity retrieval
+- GitHub stars refresh
+- Multi-source project ingest
+
+## Runtime Policy Patch Activation
+
+Policy proposals approved through:
+
+- `POST /api/admin/policies/proposals`
+- `POST /api/admin/approvals/:id/decision`
+
+now immediately apply runtime patches for `targetType=agent` (policy overrides, command rules, path rules, enabled resource tags). This ensures approval changes affect subsequent runs.
+
+## Live E2E
+
+Playwright suite:
+
+- `e2e/agent-os-live.spec.ts`
+
+Required env:
+
+- `E2E_ADMIN_KEY` (or `ADMIN_CONSOLE_KEY`)
+- one of `AGENT_OS_MODEL_API_KEY` / `BIGMODEL_API_KEY` / `ZHIPU_API_KEY`
+
+Run:
+
+```bash
+pnpm test:e2e
+```
+
diff --git a/e2e/agent-os-live.spec.ts b/e2e/agent-os-live.spec.ts
new file mode 100644
index 0000000..1e5d0a6
--- /dev/null
+++ b/e2e/agent-os-live.spec.ts
@@ -0,0 +1,137 @@
+import { expect, test } from '@playwright/test'
+
+function resolveAdminKey(): string | undefined {
+ return process.env.E2E_ADMIN_KEY || process.env.ADMIN_CONSOLE_KEY
+}
+
+function hasLiveModelKey(): boolean {
+ return Boolean(
+ process.env.AGENT_OS_MODEL_API_KEY || process.env.BIGMODEL_API_KEY || process.env.ZHIPU_API_KEY
+ )
+}
+
+test.describe('Agent OS Live E2E', () => {
+ test.skip(!resolveAdminKey(), 'E2E_ADMIN_KEY or ADMIN_CONSOLE_KEY is required')
+ test.skip(!hasLiveModelKey(), 'Live model API key is required for this suite')
+
+ test('runs model planning and policy approval loop with audit records', async ({
+ request,
+ page,
+ }) => {
+ const adminKey = resolveAdminKey()!
+
+ const tokenResponse = await request.post('/api/admin/auth/token', {
+ data: {
+ adminKey,
+ operator: 'playwright-e2e',
+ },
+ })
+ expect(tokenResponse.ok()).toBeTruthy()
+ const tokenPayload = await tokenResponse.json()
+ expect(tokenPayload.success).toBe(true)
+ const bearerToken = tokenPayload.token as string
+
+ const headers = {
+ Authorization: `Bearer ${bearerToken}`,
+ 'Content-Type': 'application/json',
+ }
+
+ const firstRunResponse = await request.post('/api/agent/goals', {
+ headers,
+ data: {
+ goal: 'Please discover AI agent projects from trending signals and summarize with RAG retrieval.',
+ preferredAgentId: 'agent.discovery.scout',
+ },
+ })
+ expect(firstRunResponse.ok()).toBeTruthy()
+ const firstRun = await firstRunResponse.json()
+
+ expect(firstRun.success).toBe(true)
+ expect(firstRun.planning?.source).toBe('model')
+ expect(Array.isArray(firstRun.tasks)).toBe(true)
+ expect(firstRun.tasks.length).toBeGreaterThan(0)
+ expect(Array.isArray(firstRun.toolCalls)).toBe(true)
+ expect(firstRun.toolCalls.length).toBeGreaterThan(0)
+
+ const runId = firstRun.run?.id as string
+ expect(typeof runId).toBe('string')
+
+ const runDetailResponse = await request.get(`/api/agent/runs/${runId}`, { headers })
+ expect(runDetailResponse.ok()).toBeTruthy()
+ const runDetail = await runDetailResponse.json()
+ expect(runDetail.success).toBe(true)
+ expect(runDetail.run?.id).toBe(runId)
+ expect(runDetail.tasks.length).toBeGreaterThan(0)
+
+ const proposalResponse = await request.post('/api/admin/policies/proposals', {
+ headers,
+ data: {
+ title: 'Deny MCP browser for discovery scout',
+ description: 'Policy proposal created by live e2e to validate approval effectiveness.',
+ targetType: 'agent',
+ targetId: 'agent.discovery.scout',
+ changes: {
+ policyOverrides: {
+ 'mcp.agent_browser.read': 'deny',
+ },
+ },
+ },
+ })
+ expect(proposalResponse.ok()).toBeTruthy()
+ const proposalPayload = await proposalResponse.json()
+ expect(proposalPayload.success).toBe(true)
+ const approvalId = proposalPayload.approvalTicket?.id as string
+ expect(typeof approvalId).toBe('string')
+
+ const approveResponse = await request.post(`/api/admin/approvals/${approvalId}/decision`, {
+ headers,
+ data: {
+ decision: 'approved',
+ comment: 'approved by playwright e2e',
+ },
+ })
+ expect(approveResponse.ok()).toBeTruthy()
+ const approvePayload = await approveResponse.json()
+ expect(approvePayload.success).toBe(true)
+ expect(approvePayload.approvalTicket?.status).toBe('approved')
+
+ const secondRunResponse = await request.post('/api/agent/goals', {
+ headers,
+ data: {
+ goal: 'Use web_read capability to read website https://example.com and extract key points.',
+ preferredAgentId: 'agent.discovery.scout',
+ },
+ })
+ expect(secondRunResponse.ok()).toBeTruthy()
+ const secondRun = await secondRunResponse.json()
+ expect(secondRun.success).toBe(true)
+
+ const webReadCalls = secondRun.toolCalls.filter(
+ (call: { resourceId: string }) =>
+ call.resourceId === 'mcp.agent_browser.read' ||
+ call.resourceId === 'tool.web.read' ||
+ call.resourceId === 'tool.web.search'
+ )
+ expect(webReadCalls.length).toBeGreaterThan(0)
+ expect(webReadCalls.some((call: { resourceId: string }) => call.resourceId === 'tool.web.read')).toBe(
+ true
+ )
+
+ const allReasons: string[] = secondRun.toolCalls.flatMap((call: { reasons?: string[] }) =>
+ Array.isArray(call.reasons) ? call.reasons : []
+ )
+ expect(allReasons.some((reason) => reason.startsWith('fallback_low_trust:'))).toBe(true)
+
+ const secondRunId = secondRun.run?.id as string
+ const logResponse = await request.get(`/api/admin/logs/tool-calls?runId=${secondRunId}`, {
+ headers,
+ })
+ expect(logResponse.ok()).toBeTruthy()
+ const logPayload = await logResponse.json()
+ expect(logPayload.success).toBe(true)
+ expect(logPayload.toolCalls.length).toBeGreaterThan(0)
+
+ await page.goto('/en/admin/chat')
+ await expect(page.getByText('Agent Gateway Chat Console')).toBeVisible()
+ })
+})
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..a58f753
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,32 @@
+import { defineConfig, devices } from '@playwright/test'
+
+const PORT = Number(process.env.PLAYWRIGHT_PORT || 3100)
+const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${PORT}`
+
+export default defineConfig({
+ testDir: './e2e',
+ timeout: 120_000,
+ fullyParallel: false,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 1 : 0,
+ reporter: [['list']],
+ use: {
+ baseURL: BASE_URL,
+ trace: 'on-first-retry',
+ screenshot: 'only-on-failure',
+ video: 'retain-on-failure',
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+ webServer: {
+ command: `pnpm dev --port ${PORT}`,
+ url: BASE_URL,
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+})
+
diff --git a/src/app/[locale]/admin/chat/page.tsx b/src/app/[locale]/admin/chat/page.tsx
new file mode 100644
index 0000000..623006b
--- /dev/null
+++ b/src/app/[locale]/admin/chat/page.tsx
@@ -0,0 +1,5 @@
+import { AdminGoalConsole } from '@/components/admin/AdminGoalConsole'
+
+export default function AdminChatPage() {
+ return
+}
diff --git a/src/app/[locale]/admin/layout.tsx b/src/app/[locale]/admin/layout.tsx
new file mode 100644
index 0000000..704543c
--- /dev/null
+++ b/src/app/[locale]/admin/layout.tsx
@@ -0,0 +1,44 @@
+import Link from 'next/link'
+import { getTranslations } from 'next-intl/server'
+
+export default async function AdminLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: Promise<{ locale: string }>
+}) {
+ const { locale } = await params
+ const t = await getTranslations('admin')
+
+ const navItems = [
+ { href: `/${locale}/admin/chat`, label: t('navChat') },
+ { href: `/${locale}/admin/resources`, label: t('navResources') },
+ { href: `/${locale}/admin/policies/proposals`, label: t('navPolicies') },
+ { href: `/${locale}/admin/runs`, label: t('navRuns') },
+ { href: `/${locale}/admin/logs`, label: t('navLogs') },
+ ]
+
+ return (
+
+
+ {t('title')}
+ {t('subtitle')}
+
+
+
+
+ {children}
+
+ )
+}
diff --git a/src/app/[locale]/admin/page.tsx b/src/app/[locale]/admin/page.tsx
new file mode 100644
index 0000000..d81505c
--- /dev/null
+++ b/src/app/[locale]/admin/page.tsx
@@ -0,0 +1,45 @@
+import Link from 'next/link'
+import { getTranslations } from 'next-intl/server'
+
+export default async function AdminHomePage({
+ params,
+}: {
+ params: Promise<{ locale: string }>
+}) {
+ const { locale } = await params
+ const t = await getTranslations('admin')
+
+ const cards = [
+ {
+ href: `/${locale}/admin/chat`,
+ title: t('cardChatTitle'),
+ description: t('cardChatDescription'),
+ },
+ {
+ href: `/${locale}/admin/resources`,
+ title: t('cardResourceTitle'),
+ description: t('cardResourceDescription'),
+ },
+ {
+ href: `/${locale}/admin/policies/proposals`,
+ title: t('cardPolicyTitle'),
+ description: t('cardPolicyDescription'),
+ },
+ {
+ href: `/${locale}/admin/logs`,
+ title: t('cardLogsTitle'),
+ description: t('cardLogsDescription'),
+ },
+ ]
+
+ return (
+
+ {cards.map((card) => (
+
+ {card.title}
+ {card.description}
+
+ ))}
+
+ )
+}
diff --git a/src/app/[locale]/admin/policies/proposals/page.tsx b/src/app/[locale]/admin/policies/proposals/page.tsx
new file mode 100644
index 0000000..b547350
--- /dev/null
+++ b/src/app/[locale]/admin/policies/proposals/page.tsx
@@ -0,0 +1,23 @@
+import { AdminPolicyProposalForm } from '@/components/admin/AdminPolicyProposalForm'
+import { AdminJsonPanel } from '@/components/admin/AdminJsonPanel'
+import { getTranslations } from 'next-intl/server'
+
+export default async function AdminPolicyProposalsPage() {
+ const t = await getTranslations('admin')
+
+ return (
+
+ )
+}
diff --git a/src/app/[locale]/admin/resources/agents/page.tsx b/src/app/[locale]/admin/resources/agents/page.tsx
new file mode 100644
index 0000000..e443242
--- /dev/null
+++ b/src/app/[locale]/admin/resources/agents/page.tsx
@@ -0,0 +1,14 @@
+import { AdminJsonPanel } from '@/components/admin/AdminJsonPanel'
+import { getTranslations } from 'next-intl/server'
+
+export default async function AdminAgentsPage() {
+ const t = await getTranslations('admin')
+
+ return (
+
+ )
+}
diff --git a/src/app/[locale]/admin/resources/mcp/page.tsx b/src/app/[locale]/admin/resources/mcp/page.tsx
new file mode 100644
index 0000000..aad1544
--- /dev/null
+++ b/src/app/[locale]/admin/resources/mcp/page.tsx
@@ -0,0 +1,14 @@
+import { AdminJsonPanel } from '@/components/admin/AdminJsonPanel'
+import { getTranslations } from 'next-intl/server'
+
+export default async function AdminMcpPage() {
+ const t = await getTranslations('admin')
+
+ return (
+
+ )
+}
diff --git a/src/app/[locale]/admin/resources/page.tsx b/src/app/[locale]/admin/resources/page.tsx
new file mode 100644
index 0000000..8a62ff3
--- /dev/null
+++ b/src/app/[locale]/admin/resources/page.tsx
@@ -0,0 +1,37 @@
+import Link from 'next/link'
+import { AdminJsonPanel } from '@/components/admin/AdminJsonPanel'
+import { getTranslations } from 'next-intl/server'
+
+export default async function AdminResourcesPage({
+ params,
+}: {
+ params: Promise<{ locale: string }>
+}) {
+ const { locale } = await params
+ const t = await getTranslations('admin')
+
+ const links = [
+ { href: `/${locale}/admin/resources/tools`, label: t('resourcesTabTools') },
+ { href: `/${locale}/admin/resources/mcp`, label: 'MCP' },
+ { href: `/${locale}/admin/resources/skills`, label: t('resourcesTabSkills') },
+ { href: `/${locale}/admin/resources/agents`, label: t('resourcesTabAgents') },
+ ]
+
+ return (
+
+
+ {links.map((link) => (
+
+ {link.label}
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/src/app/[locale]/admin/resources/skills/page.tsx b/src/app/[locale]/admin/resources/skills/page.tsx
new file mode 100644
index 0000000..25b0536
--- /dev/null
+++ b/src/app/[locale]/admin/resources/skills/page.tsx
@@ -0,0 +1,14 @@
+import { AdminJsonPanel } from '@/components/admin/AdminJsonPanel'
+import { getTranslations } from 'next-intl/server'
+
+export default async function AdminSkillsPage() {
+ const t = await getTranslations('admin')
+
+ return (
+
+ )
+}
diff --git a/src/app/[locale]/admin/resources/tools/page.tsx b/src/app/[locale]/admin/resources/tools/page.tsx
new file mode 100644
index 0000000..07f4f72
--- /dev/null
+++ b/src/app/[locale]/admin/resources/tools/page.tsx
@@ -0,0 +1,14 @@
+import { AdminJsonPanel } from '@/components/admin/AdminJsonPanel'
+import { getTranslations } from 'next-intl/server'
+
+export default async function AdminToolsPage() {
+ const t = await getTranslations('admin')
+
+ return (
+
+ )
+}
diff --git a/src/app/[locale]/admin/runs/[id]/page.tsx b/src/app/[locale]/admin/runs/[id]/page.tsx
new file mode 100644
index 0000000..a142d6b
--- /dev/null
+++ b/src/app/[locale]/admin/runs/[id]/page.tsx
@@ -0,0 +1,19 @@
+import { AdminJsonPanel } from '@/components/admin/AdminJsonPanel'
+import { getTranslations } from 'next-intl/server'
+
+export default async function AdminRunDetailPage({
+ params,
+}: {
+ params: Promise<{ locale: string; id: string }>
+}) {
+ const { id } = await params
+ const t = await getTranslations('admin')
+
+ return (
+
+ )
+}
diff --git a/src/app/[locale]/admin/runs/page.tsx b/src/app/[locale]/admin/runs/page.tsx
new file mode 100644
index 0000000..616c49d
--- /dev/null
+++ b/src/app/[locale]/admin/runs/page.tsx
@@ -0,0 +1,30 @@
+import { AdminJsonPanel } from '@/components/admin/AdminJsonPanel'
+import { getTranslations } from 'next-intl/server'
+
+export default async function AdminRunsPage() {
+ const t = await getTranslations('admin')
+
+ return (
+
+ )
+}
diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx
index 03fe52f..ad8668e 100644
--- a/src/app/[locale]/layout.tsx
+++ b/src/app/[locale]/layout.tsx
@@ -1,5 +1,6 @@
import { notFound } from "next/navigation"
-import { setRequestLocale, getTranslations } from 'next-intl/server'
+import { NextIntlClientProvider } from 'next-intl'
+import { setRequestLocale, getMessages, getTranslations } from 'next-intl/server'
import Link from "next/link"
import { LocaleSwitcher } from "@/components/locale/LocaleSwitcher"
import { AnnouncementBar } from "@/components/layout/AnnouncementBar"
@@ -43,11 +44,19 @@ export default async function LocaleLayout({
const t = await getTranslations('layout')
const tNav = await getTranslations('navigation')
const tHome = await getTranslations('home')
+ const messages = await getMessages()
+ const showAdminTab = process.env.NODE_ENV === 'development'
return (
-
- {/* Top announcement bar */}
-
+
+
+ {/* Top announcement bar */}
+
{/* Navigation */}
-
+
+
)
}
diff --git a/src/app/[locale]/projects/ProjectsResultsClient.tsx b/src/app/[locale]/projects/ProjectsResultsClient.tsx
index 0badf37..be4a66b 100644
--- a/src/app/[locale]/projects/ProjectsResultsClient.tsx
+++ b/src/app/[locale]/projects/ProjectsResultsClient.tsx
@@ -331,7 +331,8 @@ export function ProjectsResultsClient({
})
if (!response.ok) {
- throw new Error(response.statusText || 'Projects fetch failed')
+ const projectsFetchFailedText = locale === 'en' ? 'Failed to load projects' : '项目加载失败'
+ throw new Error(`${projectsFetchFailedText} (${response.status})`)
}
const data = await response.json()
@@ -369,12 +370,13 @@ export function ProjectsResultsClient({
setTraditionalLimit(nextLimit)
replaceProjectsUrl(safePage, nextSort, false, nextLimit)
} catch (error) {
- setTraditionalError(error instanceof Error ? error.message : 'Projects fetch failed')
+ const projectsFetchFailedText = locale === 'en' ? 'Failed to load projects' : '项目加载失败'
+ setTraditionalError(error instanceof Error ? error.message : projectsFetchFailedText)
} finally {
setLoadingTraditional(false)
}
},
- [projectType, replaceProjectsUrl, search, selectedDomains, selectedProductForms, selectedTags]
+ [locale, projectType, replaceProjectsUrl, search, selectedDomains, selectedProductForms, selectedTags]
)
const handleTraditionalPageChange = useCallback(
@@ -431,7 +433,8 @@ export function ProjectsResultsClient({
})
if (!response.ok) {
- throw new Error(response.statusText || 'AI search failed')
+ const aiSearchFailedText = locale === 'en' ? 'AI search failed' : 'AI 搜索失败'
+ throw new Error(`${aiSearchFailedText} (${response.status})`)
}
const data = await response.json()
@@ -477,7 +480,8 @@ export function ProjectsResultsClient({
}
} catch (error) {
if (!cancelled) {
- setAiError(error instanceof Error ? error.message : 'AI search failed')
+ const aiSearchFailedText = locale === 'en' ? 'AI search failed' : 'AI 搜索失败'
+ setAiError(error instanceof Error ? error.message : aiSearchFailedText)
setAiResults([])
setAiPagination((prev) => ({
...prev,
diff --git a/src/app/api/admin/_lib/auth.ts b/src/app/api/admin/_lib/auth.ts
new file mode 100644
index 0000000..afbbfae
--- /dev/null
+++ b/src/app/api/admin/_lib/auth.ts
@@ -0,0 +1,48 @@
+import { NextResponse } from 'next/server'
+import { resolveAdminOperator } from '@/lib/agent-os/admin-auth'
+
+export function parseBearerToken(authHeader: string | null): string | null {
+ if (!authHeader) {
+ return null
+ }
+
+ const [type, token] = authHeader.split(' ')
+ if (!type || !token) {
+ return null
+ }
+
+ return type.toLowerCase() === 'bearer' ? token : null
+}
+
+export function requireAdminAccess(request: Request):
+ | { authorized: true; operator: string }
+ | { authorized: false; response: NextResponse } {
+ const bearerToken = parseBearerToken(request.headers.get('authorization'))
+ const adminKey = request.headers.get('x-admin-key')
+ const operatorHint = request.headers.get('x-operator') || request.headers.get('x-client-id')
+
+ const resolved = resolveAdminOperator({
+ bearerToken,
+ adminKey,
+ fallbackOperator: operatorHint,
+ })
+
+ if (!resolved.authorized || !resolved.operator) {
+ return {
+ authorized: false,
+ response: NextResponse.json(
+ {
+ success: false,
+ error: 'Unauthorized',
+ details: ['Missing or invalid admin credentials'],
+ },
+ { status: 401 }
+ ),
+ }
+ }
+
+ return {
+ authorized: true,
+ operator: resolved.operator,
+ }
+}
diff --git a/src/app/api/admin/approvals/[id]/decision/route.ts b/src/app/api/admin/approvals/[id]/decision/route.ts
new file mode 100644
index 0000000..f41a023
--- /dev/null
+++ b/src/app/api/admin/approvals/[id]/decision/route.ts
@@ -0,0 +1,64 @@
+import { NextResponse } from 'next/server'
+import { ApprovalDecisionRequestSchema } from '@/lib/agent-os/schemas'
+import { decideApproval } from '@/lib/agent-os/store'
+import { requireAdminAccess } from '../../../_lib/auth'
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const auth = requireAdminAccess(request)
+ if (!auth.authorized) {
+ return auth.response
+ }
+
+ const { id } = await params
+
+ try {
+ const body = await request.json()
+ const validation = ApprovalDecisionRequestSchema.safeParse(body)
+
+ if (!validation.success) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'Validation error',
+ details: validation.error.errors,
+ },
+ { status: 400 }
+ )
+ }
+
+ const updated = decideApproval({
+ approvalId: id,
+ decision: validation.data.decision,
+ decidedBy: auth.operator,
+ comment: validation.data.comment,
+ })
+
+ if (!updated) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'Approval ticket not found',
+ },
+ { status: 404 }
+ )
+ }
+
+ return NextResponse.json({
+ success: true,
+ approvalTicket: updated.approvalTicket,
+ proposal: updated.proposal,
+ })
+ } catch (error) {
+ console.error('[AdminApprovals] Failed to decide approval:', error)
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'Internal server error',
+ },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/app/api/admin/approvals/route.ts b/src/app/api/admin/approvals/route.ts
new file mode 100644
index 0000000..adbe1f5
--- /dev/null
+++ b/src/app/api/admin/approvals/route.ts
@@ -0,0 +1,40 @@
+import { NextResponse } from 'next/server'
+import { listApprovalTickets, listPolicyProposals } from '@/lib/agent-os/store'
+import { requireAdminAccess } from '../_lib/auth'
+
+export async function GET(request: Request) {
+ const auth = requireAdminAccess(request)
+ if (!auth.authorized) {
+ return auth.response
+ }
+
+ const proposals = listPolicyProposals()
+ const proposalById = new Map(proposals.map((proposal) => [proposal.id, proposal]))
+ const approvals = listApprovalTickets().map((ticket) => {
+ const proposal = proposalById.get(ticket.proposalId)
+ return {
+ approvalTicketId: ticket.id,
+ proposalId: ticket.proposalId,
+ approvalStatus: ticket.status,
+ requestedBy: ticket.requestedBy,
+ decidedBy: ticket.decidedBy || null,
+ decisionComment: ticket.decisionComment || null,
+ createdAt: ticket.createdAt,
+ updatedAt: ticket.updatedAt,
+ proposal: proposal
+ ? {
+ title: proposal.title,
+ status: proposal.status,
+ targetType: proposal.targetType,
+ targetId: proposal.targetId,
+ }
+ : null,
+ }
+ })
+
+ return NextResponse.json({
+ success: true,
+ operator: auth.operator,
+ approvals,
+ })
+}
diff --git a/src/app/api/admin/auth/token/route.ts b/src/app/api/admin/auth/token/route.ts
new file mode 100644
index 0000000..bd7f5ae
--- /dev/null
+++ b/src/app/api/admin/auth/token/route.ts
@@ -0,0 +1,52 @@
+import { NextResponse } from 'next/server'
+import { AdminAuthTokenRequestSchema } from '@/lib/agent-os/schemas'
+import { issueAdminToken, isValidAdminKey } from '@/lib/agent-os/admin-auth'
+
+export async function POST(request: Request) {
+ try {
+ const body = await request.json()
+ const validation = AdminAuthTokenRequestSchema.safeParse(body)
+
+ if (!validation.success) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'Validation error',
+ details: validation.error.errors,
+ },
+ { status: 400 }
+ )
+ }
+
+ const { adminKey, operator } = validation.data
+
+ if (!isValidAdminKey(adminKey)) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'Unauthorized',
+ details: ['Invalid admin key'],
+ },
+ { status: 401 }
+ )
+ }
+
+ const token = issueAdminToken(operator || 'admin-console')
+
+ return NextResponse.json({
+ success: true,
+ token: token.token,
+ operator: token.operator,
+ expiresAt: new Date(token.expiresAt).toISOString(),
+ })
+ } catch (error) {
+ console.error('[AdminAuth] Failed to issue token:', error)
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'Internal server error',
+ },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/app/api/admin/policies/proposals/route.ts b/src/app/api/admin/policies/proposals/route.ts
new file mode 100644
index 0000000..ff6fa63
--- /dev/null
+++ b/src/app/api/admin/policies/proposals/route.ts
@@ -0,0 +1,60 @@
+import { NextResponse } from 'next/server'
+import { CreatePolicyProposalRequestSchema } from '@/lib/agent-os/schemas'
+import { createPolicyProposal, listPolicyProposals } from '@/lib/agent-os/store'
+import { requireAdminAccess } from '../../_lib/auth'
+
+export async function GET(request: Request) {
+ const auth = requireAdminAccess(request)
+ if (!auth.authorized) {
+ return auth.response
+ }
+
+ return NextResponse.json({
+ success: true,
+ operator: auth.operator,
+ proposals: listPolicyProposals(),
+ })
+}
+
+export async function POST(request: Request) {
+ const auth = requireAdminAccess(request)
+ if (!auth.authorized) {
+ return auth.response
+ }
+
+ try {
+ const body = await request.json()
+ const validation = CreatePolicyProposalRequestSchema.safeParse(body)
+
+ if (!validation.success) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'Validation error',
+ details: validation.error.errors,
+ },
+ { status: 400 }
+ )
+ }
+
+ const created = createPolicyProposal({
+ ...validation.data,
+ createdBy: auth.operator,
+ })
+
+ return NextResponse.json({
+ success: true,
+ proposal: created.proposal,
+ approvalTicket: created.approvalTicket,
+ })
+ } catch (error) {
+ console.error('[AdminPolicies] Failed to create proposal:', error)
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'Internal server error',
+ },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/app/api/admin/resources/agents/route.ts b/src/app/api/admin/resources/agents/route.ts
new file mode 100644
index 0000000..98be034
--- /dev/null
+++ b/src/app/api/admin/resources/agents/route.ts
@@ -0,0 +1,16 @@
+import { NextResponse } from 'next/server'
+import { listAgentProfiles } from '@/lib/agent-os/resource-pool'
+import { requireAdminAccess } from '../../_lib/auth'
+
+export async function GET(request: Request) {
+ const auth = requireAdminAccess(request)
+ if (!auth.authorized) {
+ return auth.response
+ }
+
+ return NextResponse.json({
+ success: true,
+ operator: auth.operator,
+ agents: listAgentProfiles(),
+ })
+}
diff --git a/src/app/api/admin/resources/mcp/route.ts b/src/app/api/admin/resources/mcp/route.ts
new file mode 100644
index 0000000..d3c486c
--- /dev/null
+++ b/src/app/api/admin/resources/mcp/route.ts
@@ -0,0 +1,18 @@
+import { NextResponse } from 'next/server'
+import { listResourceManifests } from '@/lib/agent-os/resource-pool'
+import { requireAdminAccess } from '../../_lib/auth'
+
+export async function GET(request: Request) {
+ const auth = requireAdminAccess(request)
+ if (!auth.authorized) {
+ return auth.response
+ }
+
+ const mcpResources = listResourceManifests().filter((resource) => resource.kind === 'mcp_tool')
+
+ return NextResponse.json({
+ success: true,
+ operator: auth.operator,
+ mcpResources,
+ })
+}
diff --git a/src/app/api/admin/resources/route.ts b/src/app/api/admin/resources/route.ts
new file mode 100644
index 0000000..1b828c3
--- /dev/null
+++ b/src/app/api/admin/resources/route.ts
@@ -0,0 +1,33 @@
+import { NextResponse } from 'next/server'
+import {
+ listAgentProfiles,
+ listN8nProductionWorkflows,
+ listPolicyProfiles,
+ listResourceManifests,
+} from '@/lib/agent-os/resource-pool'
+import { requireAdminAccess } from '../_lib/auth'
+
+export async function GET(request: Request) {
+ const auth = requireAdminAccess(request)
+ if (!auth.authorized) {
+ return auth.response
+ }
+
+ const resources = listResourceManifests()
+ const tools = resources.filter((item) => item.kind === 'base_tool' || item.kind === 'mcp_tool')
+
+ return NextResponse.json({
+ success: true,
+ operator: auth.operator,
+ summary: {
+ totalResources: resources.length,
+ totalTools: tools.length,
+ totalSkills: resources.filter((item) => item.kind === 'skill').length,
+ totalAgents: listAgentProfiles().length,
+ totalPolicyProfiles: listPolicyProfiles().length,
+ totalN8nProductionWorkflows: listN8nProductionWorkflows().length,
+ },
+ resources,
+ n8nProductionWorkflows: listN8nProductionWorkflows(),
+ })
+}
diff --git a/src/app/api/admin/resources/skills/route.ts b/src/app/api/admin/resources/skills/route.ts
new file mode 100644
index 0000000..0a2df62
--- /dev/null
+++ b/src/app/api/admin/resources/skills/route.ts
@@ -0,0 +1,22 @@
+import { NextResponse } from 'next/server'
+import {
+ listN8nProductionWorkflows,
+ listResourceManifests,
+} from '@/lib/agent-os/resource-pool'
+import { requireAdminAccess } from '../../_lib/auth'
+
+export async function GET(request: Request) {
+ const auth = requireAdminAccess(request)
+ if (!auth.authorized) {
+ return auth.response
+ }
+
+ const skills = listResourceManifests().filter((resource) => resource.kind === 'skill')
+
+ return NextResponse.json({
+ success: true,
+ operator: auth.operator,
+ skills,
+ n8nProductionWorkflows: listN8nProductionWorkflows(),
+ })
+}
diff --git a/src/app/api/admin/resources/tools/route.ts b/src/app/api/admin/resources/tools/route.ts
new file mode 100644
index 0000000..7e17d50
--- /dev/null
+++ b/src/app/api/admin/resources/tools/route.ts
@@ -0,0 +1,20 @@
+import { NextResponse } from 'next/server'
+import { listResourceManifests } from '@/lib/agent-os/resource-pool'
+import { requireAdminAccess } from '../../_lib/auth'
+
+export async function GET(request: Request) {
+ const auth = requireAdminAccess(request)
+ if (!auth.authorized) {
+ return auth.response
+ }
+
+ const tools = listResourceManifests().filter(
+ (resource) => resource.kind === 'base_tool' || resource.kind === 'mcp_tool'
+ )
+
+ return NextResponse.json({
+ success: true,
+ operator: auth.operator,
+ tools,
+ })
+}
diff --git a/src/app/api/admin/runs/route.ts b/src/app/api/admin/runs/route.ts
new file mode 100644
index 0000000..d272c91
--- /dev/null
+++ b/src/app/api/admin/runs/route.ts
@@ -0,0 +1,16 @@
+import { NextResponse } from 'next/server'
+import { listRuns } from '@/lib/agent-os/store'
+import { requireAdminAccess } from '../_lib/auth'
+
+export async function GET(request: Request) {
+ const auth = requireAdminAccess(request)
+ if (!auth.authorized) {
+ return auth.response
+ }
+
+ return NextResponse.json({
+ success: true,
+ operator: auth.operator,
+ runs: listRuns(),
+ })
+}
diff --git a/src/app/api/agent/goals/route.ts b/src/app/api/agent/goals/route.ts
new file mode 100644
index 0000000..e8abd07
--- /dev/null
+++ b/src/app/api/agent/goals/route.ts
@@ -0,0 +1,56 @@
+import { NextResponse } from 'next/server'
+import { AgentGoalRequestSchema } from '@/lib/agent-os/schemas'
+import { orchestrateGoal } from '@/lib/agent-os/orchestrator'
+import { getRun, listTasks, listToolCalls } from '@/lib/agent-os/store'
+import { requireAdminAccess } from '../../admin/_lib/auth'
+
+export async function POST(request: Request) {
+ const auth = requireAdminAccess(request)
+ if (!auth.authorized) {
+ return auth.response
+ }
+
+ try {
+ const body = await request.json()
+ const validation = AgentGoalRequestSchema.safeParse(body)
+
+ if (!validation.success) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'Validation error',
+ details: validation.error.errors,
+ },
+ { status: 400 }
+ )
+ }
+
+ const orchestration = await orchestrateGoal({
+ ...validation.data,
+ createdBy: auth.operator,
+ })
+
+ const run = getRun(orchestration.runId)
+
+ return NextResponse.json({
+ success: true,
+ run,
+ intents: orchestration.intents,
+ selectedAgent: orchestration.agent,
+ tasks: listTasks(orchestration.runId),
+ toolCalls: listToolCalls(orchestration.runId),
+ pendingApprovals: orchestration.pendingApprovals,
+ planning: orchestration.planning,
+ approvalTicketIds: orchestration.approvalTicketIds,
+ })
+ } catch (error) {
+ console.error('[AgentGoals] Failed to orchestrate goal:', error)
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'Internal server error',
+ },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/app/api/agent/runs/[id]/control/route.ts b/src/app/api/agent/runs/[id]/control/route.ts
new file mode 100644
index 0000000..f5a0e3c
--- /dev/null
+++ b/src/app/api/agent/runs/[id]/control/route.ts
@@ -0,0 +1,71 @@
+import { NextResponse } from 'next/server'
+import { RunControlRequestSchema } from '@/lib/agent-os/schemas'
+import { getRun, updateRunStatus } from '@/lib/agent-os/store'
+import { requireAdminAccess } from '../../../../admin/_lib/auth'
+
+const ACTION_TO_STATUS = {
+ pause: 'PAUSED',
+ resume: 'TASK_DISPATCHED',
+ rollback: 'ROLLED_BACK',
+ fail: 'FAILED',
+ complete: 'COMPLETED',
+} as const
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const auth = requireAdminAccess(request)
+ if (!auth.authorized) {
+ return auth.response
+ }
+
+ const { id } = await params
+
+ if (!getRun(id)) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'Run not found',
+ },
+ { status: 404 }
+ )
+ }
+
+ try {
+ const body = await request.json()
+ const validation = RunControlRequestSchema.safeParse(body)
+
+ if (!validation.success) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'Validation error',
+ details: validation.error.errors,
+ },
+ { status: 400 }
+ )
+ }
+
+ const next = updateRunStatus(
+ id,
+ ACTION_TO_STATUS[validation.data.action],
+ validation.data.note || `Run action: ${validation.data.action}`
+ )
+
+ return NextResponse.json({
+ success: true,
+ operator: auth.operator,
+ run: next,
+ })
+ } catch (error) {
+ console.error('[AgentRuns] Failed to control run:', error)
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'Internal server error',
+ },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/app/api/agent/runs/[id]/route.ts b/src/app/api/agent/runs/[id]/route.ts
new file mode 100644
index 0000000..05fc294
--- /dev/null
+++ b/src/app/api/agent/runs/[id]/route.ts
@@ -0,0 +1,34 @@
+import { NextResponse } from 'next/server'
+import { getRun, listTasks, listToolCalls } from '@/lib/agent-os/store'
+import { requireAdminAccess } from '../../../admin/_lib/auth'
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const auth = requireAdminAccess(request)
+ if (!auth.authorized) {
+ return auth.response
+ }
+
+ const { id } = await params
+ const run = getRun(id)
+
+ if (!run) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'Run not found',
+ },
+ { status: 404 }
+ )
+ }
+
+ return NextResponse.json({
+ success: true,
+ operator: auth.operator,
+ run,
+ tasks: listTasks(id),
+ toolCalls: listToolCalls(id),
+ })
+}
diff --git a/src/components/admin/AdminGoalConsole.tsx b/src/components/admin/AdminGoalConsole.tsx
new file mode 100644
index 0000000..11f58a4
--- /dev/null
+++ b/src/components/admin/AdminGoalConsole.tsx
@@ -0,0 +1,91 @@
+'use client'
+
+import { FormEvent, useState } from 'react'
+import { useTranslations } from 'next-intl'
+
+export function AdminGoalConsole() {
+ const t = useTranslations('admin')
+ const [adminKey, setAdminKey] = useState('')
+ const [goal, setGoal] = useState(() => t('goalDefault'))
+ const [loading, setLoading] = useState(false)
+ const [responseText, setResponseText] = useState('')
+
+ async function handleSubmit(event: FormEvent) {
+ event.preventDefault()
+ setLoading(true)
+ setResponseText('')
+
+ try {
+ const response = await fetch('/api/agent/goals', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-admin-key': adminKey,
+ 'x-operator': 'admin-console-chat',
+ },
+ body: JSON.stringify({ goal }),
+ })
+
+ const json = await response.json()
+ setResponseText(JSON.stringify(json, null, 2))
+ } catch (error) {
+ setResponseText(
+ JSON.stringify(
+ {
+ success: false,
+ error: error instanceof Error ? error.message : t('unknownError'),
+ },
+ null,
+ 2
+ )
+ )
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+ {t('goalConsoleTitle')}
+
+ {t('goalConsoleDescription')}
+
+
+
+
+
+ {responseText || t('noRunYet')}
+
+
+ )
+}
diff --git a/src/components/admin/AdminJsonPanel.tsx b/src/components/admin/AdminJsonPanel.tsx
new file mode 100644
index 0000000..334a90d
--- /dev/null
+++ b/src/components/admin/AdminJsonPanel.tsx
@@ -0,0 +1,104 @@
+'use client'
+
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+
+type AdminJsonPanelProps = {
+ title: string
+ endpoint: string
+ method?: 'GET' | 'POST'
+ bodyTemplate?: string
+ description?: string
+}
+
+export function AdminJsonPanel({
+ title,
+ endpoint,
+ method = 'GET',
+ bodyTemplate,
+ description,
+}: AdminJsonPanelProps) {
+ const t = useTranslations('admin')
+ const [adminKey, setAdminKey] = useState('')
+ const [body, setBody] = useState(bodyTemplate || '')
+ const [loading, setLoading] = useState(false)
+ const [responseText, setResponseText] = useState('')
+
+ async function handleFetch() {
+ setLoading(true)
+ setResponseText('')
+
+ try {
+ const parsedBody = body ? JSON.parse(body) : undefined
+ const response = await fetch(endpoint, {
+ method,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-admin-key': adminKey,
+ 'x-operator': 'admin-console-ui',
+ },
+ body: method === 'POST' ? JSON.stringify(parsedBody || {}) : undefined,
+ })
+
+ const json = await response.json()
+ setResponseText(JSON.stringify(json, null, 2))
+ } catch (error) {
+ setResponseText(
+ JSON.stringify(
+ {
+ success: false,
+ error: error instanceof Error ? error.message : t('unknownError'),
+ },
+ null,
+ 2
+ )
+ )
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+
{title}
+ {description ?
{description}
: null}
+
+
+
+
+ {method === 'POST' ? (
+
+ ) : null}
+
+
+
+
+ {responseText || t('noResponseYet')}
+
+
+ )
+}
diff --git a/src/components/admin/AdminPolicyProposalForm.tsx b/src/components/admin/AdminPolicyProposalForm.tsx
new file mode 100644
index 0000000..434383e
--- /dev/null
+++ b/src/components/admin/AdminPolicyProposalForm.tsx
@@ -0,0 +1,148 @@
+'use client'
+
+import { FormEvent, useState } from 'react'
+import { useTranslations } from 'next-intl'
+
+export function AdminPolicyProposalForm() {
+ const t = useTranslations('admin')
+ const initialPayload = {
+ title: t('policyDefaultTitle'),
+ description: t('policyDefaultDescription'),
+ targetType: 'agent',
+ targetId: 'agent.orchestrator.core',
+ changes: {
+ commandRules: [
+ {
+ resourceId: 'tool.shell.exec',
+ pattern: 'git push *',
+ effect: 'deny',
+ },
+ ],
+ },
+ }
+
+ const [adminKey, setAdminKey] = useState('')
+ const [payload, setPayload] = useState(JSON.stringify(initialPayload, null, 2))
+ const [approvalId, setApprovalId] = useState('')
+ const [decisionComment, setDecisionComment] = useState('')
+ const [responseText, setResponseText] = useState('')
+
+ async function handleProposalSubmit(event: FormEvent) {
+ event.preventDefault()
+ try {
+ const parsed = JSON.parse(payload)
+ const response = await fetch('/api/admin/policies/proposals', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-admin-key': adminKey,
+ 'x-operator': 'policy-admin',
+ },
+ body: JSON.stringify(parsed),
+ })
+ const json = await response.json()
+ setResponseText(JSON.stringify(json, null, 2))
+ } catch (error) {
+ setResponseText(
+ JSON.stringify(
+ {
+ success: false,
+ error: error instanceof Error ? error.message : t('unknownError'),
+ },
+ null,
+ 2
+ )
+ )
+ }
+ }
+
+ async function handleDecision(decision: 'approved' | 'rejected') {
+ if (!approvalId) {
+ return
+ }
+
+ const response = await fetch(`/api/admin/approvals/${approvalId}/decision`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-admin-key': adminKey,
+ 'x-operator': 'policy-approver',
+ },
+ body: JSON.stringify({ decision, comment: decisionComment }),
+ })
+
+ const json = await response.json()
+ setResponseText(JSON.stringify(json, null, 2))
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/layout/AnnouncementBar.tsx b/src/components/layout/AnnouncementBar.tsx
index 2084ef3..6d4f2af 100644
--- a/src/components/layout/AnnouncementBar.tsx
+++ b/src/components/layout/AnnouncementBar.tsx
@@ -7,9 +7,10 @@ interface AnnouncementBarProps {
text: string
href: string
locale: string
+ closeLabel: string
}
-export function AnnouncementBar({ text, href, locale }: AnnouncementBarProps) {
+export function AnnouncementBar({ text, href, locale, closeLabel }: AnnouncementBarProps) {
const [isVisible, setIsVisible] = useState(true)
useEffect(() => {
@@ -43,7 +44,7 @@ export function AnnouncementBar({ text, href, locale }: AnnouncementBarProps) {
diff --git a/src/components/project/GitHubTextStatsCard.tsx b/src/components/project/GitHubTextStatsCard.tsx
index 1451b84..6668638 100644
--- a/src/components/project/GitHubTextStatsCard.tsx
+++ b/src/components/project/GitHubTextStatsCard.tsx
@@ -13,9 +13,18 @@ export interface GitHubTextStats {
license?: string | null // 文本值(如 "MIT")或徽章 URL
}
+interface GitHubTextStatsLabels {
+ header: string
+ starsTitle: string
+ forksTitle: string
+ issuesTitle: string
+ licenseTitle: string
+}
+
interface GitHubTextStatsCardProps {
stats: GitHubTextStats
className?: string
+ labels?: GitHubTextStatsLabels
}
/**
@@ -50,7 +59,19 @@ function renderStatValue(value: string) {
*
* 设计原型来源: design/detail.html 第 223-289 行
*/
-export function GitHubTextStatsCard({ stats, className = '' }: GitHubTextStatsCardProps) {
+const DEFAULT_LABELS: GitHubTextStatsLabels = {
+ header: 'GitHub Statistics',
+ starsTitle: 'View stars on GitHub',
+ forksTitle: 'View forks on GitHub',
+ issuesTitle: 'View issues on GitHub',
+ licenseTitle: 'View license on GitHub',
+}
+
+export function GitHubTextStatsCard({
+ stats,
+ className = '',
+ labels = DEFAULT_LABELS,
+}: GitHubTextStatsCardProps) {
// 如果没有任何统计数据,不显示卡片
if (!stats.stars && !stats.forks && !stats.issues && !stats.license) {
return null
@@ -63,7 +84,7 @@ export function GitHubTextStatsCard({ stats, className = '' }: GitHubTextStatsCa
- GitHub Statistics
+ {labels.header}
{/* Stats Grid - 2 columns */}
@@ -73,7 +94,7 @@ export function GitHubTextStatsCard({ stats, className = '' }: GitHubTextStatsCa