From 8f23ec49cfec3b54fc9072e22619adaa7da6ca6a Mon Sep 17 00:00:00 2001 From: mzaxd Date: Sun, 1 Mar 2026 22:33:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20Agent=20OS=20?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E4=B8=8E=E8=BF=90=E8=A1=8C=E6=97=B6=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 11 + .opencode/mcp.json | 2 +- docs/agent-os-runtime.md | 51 ++ e2e/agent-os-live.spec.ts | 137 ++++ playwright.config.ts | 32 + src/app/[locale]/admin/chat/page.tsx | 5 + src/app/[locale]/admin/layout.tsx | 44 ++ src/app/[locale]/admin/page.tsx | 45 ++ .../admin/policies/proposals/page.tsx | 23 + .../[locale]/admin/resources/agents/page.tsx | 14 + src/app/[locale]/admin/resources/mcp/page.tsx | 14 + src/app/[locale]/admin/resources/page.tsx | 37 + .../[locale]/admin/resources/skills/page.tsx | 14 + .../[locale]/admin/resources/tools/page.tsx | 14 + src/app/[locale]/admin/runs/[id]/page.tsx | 19 + src/app/[locale]/admin/runs/page.tsx | 30 + src/app/[locale]/layout.tsx | 34 +- .../projects/ProjectsResultsClient.tsx | 14 +- src/app/api/admin/_lib/auth.ts | 48 ++ .../admin/approvals/[id]/decision/route.ts | 64 ++ src/app/api/admin/approvals/route.ts | 40 ++ src/app/api/admin/auth/token/route.ts | 52 ++ src/app/api/admin/policies/proposals/route.ts | 60 ++ src/app/api/admin/resources/agents/route.ts | 16 + src/app/api/admin/resources/mcp/route.ts | 18 + src/app/api/admin/resources/route.ts | 33 + src/app/api/admin/resources/skills/route.ts | 22 + src/app/api/admin/resources/tools/route.ts | 20 + src/app/api/admin/runs/route.ts | 16 + src/app/api/agent/goals/route.ts | 56 ++ src/app/api/agent/runs/[id]/control/route.ts | 71 ++ src/app/api/agent/runs/[id]/route.ts | 34 + src/components/admin/AdminGoalConsole.tsx | 91 +++ src/components/admin/AdminJsonPanel.tsx | 104 +++ .../admin/AdminPolicyProposalForm.tsx | 148 ++++ src/components/layout/AnnouncementBar.tsx | 5 +- .../project/GitHubTextStatsCard.tsx | 33 +- src/components/project/MarkdownContent.tsx | 5 +- src/components/project/ProjectCard.tsx | 11 +- src/components/project/ProjectDetail.tsx | 8 +- src/components/project/ProjectSidebar.tsx | 7 + src/components/project/RelatedProjects.tsx | 16 +- src/components/project/ShareButtons.tsx | 14 +- src/lib/agent-os/admin-auth.ts | 90 +++ src/lib/agent-os/model-client.ts | 212 ++++++ src/lib/agent-os/n8n-production-workflows.ts | 111 +++ src/lib/agent-os/orchestrator.test.ts | 111 +++ src/lib/agent-os/orchestrator.ts | 671 ++++++++++++++++++ src/lib/agent-os/policy-engine.test.ts | 51 ++ src/lib/agent-os/policy-engine.ts | 167 +++++ src/lib/agent-os/policy-runtime.test.ts | 36 + src/lib/agent-os/policy-runtime.ts | 110 +++ src/lib/agent-os/resource-pool.ts | 646 +++++++++++++++++ src/lib/agent-os/schemas.ts | 35 + src/lib/agent-os/store.ts | 331 +++++++++ src/lib/agent-os/types.ts | 191 +++++ src/messages/en.json | 92 ++- src/messages/zh.json | 92 ++- 58 files changed, 4432 insertions(+), 46 deletions(-) create mode 100644 docs/agent-os-runtime.md create mode 100644 e2e/agent-os-live.spec.ts create mode 100644 playwright.config.ts create mode 100644 src/app/[locale]/admin/chat/page.tsx create mode 100644 src/app/[locale]/admin/layout.tsx create mode 100644 src/app/[locale]/admin/page.tsx create mode 100644 src/app/[locale]/admin/policies/proposals/page.tsx create mode 100644 src/app/[locale]/admin/resources/agents/page.tsx create mode 100644 src/app/[locale]/admin/resources/mcp/page.tsx create mode 100644 src/app/[locale]/admin/resources/page.tsx create mode 100644 src/app/[locale]/admin/resources/skills/page.tsx create mode 100644 src/app/[locale]/admin/resources/tools/page.tsx create mode 100644 src/app/[locale]/admin/runs/[id]/page.tsx create mode 100644 src/app/[locale]/admin/runs/page.tsx create mode 100644 src/app/api/admin/_lib/auth.ts create mode 100644 src/app/api/admin/approvals/[id]/decision/route.ts create mode 100644 src/app/api/admin/approvals/route.ts create mode 100644 src/app/api/admin/auth/token/route.ts create mode 100644 src/app/api/admin/policies/proposals/route.ts create mode 100644 src/app/api/admin/resources/agents/route.ts create mode 100644 src/app/api/admin/resources/mcp/route.ts create mode 100644 src/app/api/admin/resources/route.ts create mode 100644 src/app/api/admin/resources/skills/route.ts create mode 100644 src/app/api/admin/resources/tools/route.ts create mode 100644 src/app/api/admin/runs/route.ts create mode 100644 src/app/api/agent/goals/route.ts create mode 100644 src/app/api/agent/runs/[id]/control/route.ts create mode 100644 src/app/api/agent/runs/[id]/route.ts create mode 100644 src/components/admin/AdminGoalConsole.tsx create mode 100644 src/components/admin/AdminJsonPanel.tsx create mode 100644 src/components/admin/AdminPolicyProposalForm.tsx create mode 100644 src/lib/agent-os/admin-auth.ts create mode 100644 src/lib/agent-os/model-client.ts create mode 100644 src/lib/agent-os/n8n-production-workflows.ts create mode 100644 src/lib/agent-os/orchestrator.test.ts create mode 100644 src/lib/agent-os/orchestrator.ts create mode 100644 src/lib/agent-os/policy-engine.test.ts create mode 100644 src/lib/agent-os/policy-engine.ts create mode 100644 src/lib/agent-os/policy-runtime.test.ts create mode 100644 src/lib/agent-os/policy-runtime.ts create mode 100644 src/lib/agent-os/resource-pool.ts create mode 100644 src/lib/agent-os/schemas.ts create mode 100644 src/lib/agent-os/store.ts create mode 100644 src/lib/agent-os/types.ts 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 */}
@@ -87,6 +96,14 @@ export default async function LocaleLayout({ > {tNav('about')} + {showAdminTab ? ( + + {tNav('admin')} + + ) : null} {/* Right side */} @@ -175,9 +192,9 @@ export default async function LocaleLayout({

{t('resources')}

    -
  • Newsletter
  • -
  • Newsletter
  • -
  • Documentation
  • +
  • {t('resourceNewsletter')}
  • +
  • {t('resourceUpdates')}
  • +
  • {t('resourceDocumentation')}
@@ -204,6 +221,7 @@ export default async function LocaleLayout({
-
+ + ) } 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')} +

+ +
+ + +