feat: 新增 Agent OS 管理与运行时能力
This commit is contained in:
@@ -4,6 +4,17 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/agent_park"
|
|||||||
# Webhook API - Generate a secure key for production
|
# Webhook API - Generate a secure key for production
|
||||||
WEBHOOK_API_KEY="sk_live_your_secure_api_key_min_32_chars"
|
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
|
||||||
N8N_AI_SEARCH_WEBHOOK="https://n8n.mzaxd.fun/webhook/ai-search"
|
N8N_AI_SEARCH_WEBHOOK="https://n8n.mzaxd.fun/webhook/ai-search"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
"LOG_LEVEL": "error",
|
"LOG_LEVEL": "error",
|
||||||
"DISABLE_CONSOLE_OUTPUT": "true",
|
"DISABLE_CONSOLE_OUTPUT": "true",
|
||||||
"N8N_API_URL": "https://n8n.mzaxd.fun",
|
"N8N_API_URL": "https://n8n.mzaxd.fun",
|
||||||
"N8N_API_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmMzliZjQ1NC1kMjliLTQ2OGQtYjA4ZC0yYTE1YjJlODA0NzkiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiwianRpIjoiMWM2YmQ2NTEtMWIwOC00YTFmLTg1MzMtMGU5MzliNGI0NTM1IiwiaWF0IjoxNzY5OTIxMjgwfQ.U1i3dXDGJyYIVATii86rKXgbcK2OyRmt3aHa5CyA5k8",
|
"N8N_API_KEY": "${N8N_API_KEY}",
|
||||||
"HTTP_PROXY": "",
|
"HTTP_PROXY": "",
|
||||||
"HTTPS_PROXY": "",
|
"HTTPS_PROXY": "",
|
||||||
"http_proxy": "",
|
"http_proxy": "",
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { AdminGoalConsole } from '@/components/admin/AdminGoalConsole'
|
||||||
|
|
||||||
|
export default function AdminChatPage() {
|
||||||
|
return <AdminGoalConsole />
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-6">
|
||||||
|
<section className="neo-card p-6 bg-primary text-black">
|
||||||
|
<h1 className="font-display text-3xl font-bold">{t('title')}</h1>
|
||||||
|
<p className="text-sm mt-2 max-w-4xl">{t('subtitle')}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav className="neo-card p-4 flex flex-wrap gap-3">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className="neo-btn bg-white dark:bg-surface-dark px-3 py-2 text-xs"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<section className="grid gap-4 md:grid-cols-2">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<Link key={card.href} href={card.href} className="neo-card p-5 hover:translate-x-[2px] hover:translate-y-[2px] transition-transform">
|
||||||
|
<h2 className="font-display text-xl font-bold mb-2">{card.title}</h2>
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300">{card.description}</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<AdminPolicyProposalForm />
|
||||||
|
<AdminJsonPanel
|
||||||
|
title={t('policyProposalsTitle')}
|
||||||
|
endpoint="/api/admin/policies/proposals"
|
||||||
|
description={t('policyProposalsDescription')}
|
||||||
|
/>
|
||||||
|
<AdminJsonPanel
|
||||||
|
title={t('approvalTicketsTitle')}
|
||||||
|
endpoint="/api/admin/approvals"
|
||||||
|
description={t('approvalTicketsDescription')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AdminJsonPanel
|
||||||
|
title={t('agentsTitle')}
|
||||||
|
endpoint="/api/admin/resources/agents"
|
||||||
|
description={t('agentsDescription')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AdminJsonPanel
|
||||||
|
title={t('mcpTitle')}
|
||||||
|
endpoint="/api/admin/resources/mcp"
|
||||||
|
description={t('mcpDescription')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<section className="neo-card p-4 flex flex-wrap gap-3">
|
||||||
|
{links.map((link) => (
|
||||||
|
<Link key={link.href} href={link.href} className="neo-btn bg-white dark:bg-surface-dark px-3 py-2 text-xs">
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<AdminJsonPanel
|
||||||
|
title={t('resourcesOverviewTitle')}
|
||||||
|
endpoint="/api/admin/resources"
|
||||||
|
description={t('resourcesOverviewDescription')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AdminJsonPanel
|
||||||
|
title={t('skillsTitle')}
|
||||||
|
endpoint="/api/admin/resources/skills"
|
||||||
|
description={t('skillsDescription')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AdminJsonPanel
|
||||||
|
title={t('toolsTitle')}
|
||||||
|
endpoint="/api/admin/resources/tools"
|
||||||
|
description={t('toolsDescription')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AdminJsonPanel
|
||||||
|
title={t('runDetailTitle', { id })}
|
||||||
|
endpoint={`/api/agent/runs/${id}`}
|
||||||
|
description={t('runDetailDescription')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<AdminJsonPanel
|
||||||
|
title={t('runsListTitle')}
|
||||||
|
endpoint="/api/admin/runs"
|
||||||
|
description={t('runsListDescription')}
|
||||||
|
/>
|
||||||
|
<AdminJsonPanel
|
||||||
|
title={t('runsControlTitle')}
|
||||||
|
endpoint="/api/agent/runs/REPLACE_RUN_ID/control"
|
||||||
|
method="POST"
|
||||||
|
bodyTemplate={JSON.stringify(
|
||||||
|
{
|
||||||
|
action: 'pause',
|
||||||
|
note: t('runsControlDefaultNote'),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}
|
||||||
|
description={t('runsControlDescription')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { notFound } from "next/navigation"
|
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 Link from "next/link"
|
||||||
import { LocaleSwitcher } from "@/components/locale/LocaleSwitcher"
|
import { LocaleSwitcher } from "@/components/locale/LocaleSwitcher"
|
||||||
import { AnnouncementBar } from "@/components/layout/AnnouncementBar"
|
import { AnnouncementBar } from "@/components/layout/AnnouncementBar"
|
||||||
@@ -43,11 +44,19 @@ export default async function LocaleLayout({
|
|||||||
const t = await getTranslations('layout')
|
const t = await getTranslations('layout')
|
||||||
const tNav = await getTranslations('navigation')
|
const tNav = await getTranslations('navigation')
|
||||||
const tHome = await getTranslations('home')
|
const tHome = await getTranslations('home')
|
||||||
|
const messages = await getMessages()
|
||||||
|
const showAdminTab = process.env.NODE_ENV === 'development'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<NextIntlClientProvider messages={messages}>
|
||||||
{/* Top announcement bar */}
|
<div className="min-h-screen flex flex-col">
|
||||||
<AnnouncementBar text={t('announcement')} href={t('announcementHref')} locale={locale} />
|
{/* Top announcement bar */}
|
||||||
|
<AnnouncementBar
|
||||||
|
text={t('announcement')}
|
||||||
|
href={t('announcementHref')}
|
||||||
|
locale={locale}
|
||||||
|
closeLabel={t('closeAnnouncement')}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<header className="w-full border-b-2 border-black dark:border-gray-600 bg-background-light dark:bg-background-dark sticky top-0 z-50">
|
<header className="w-full border-b-2 border-black dark:border-gray-600 bg-background-light dark:bg-background-dark sticky top-0 z-50">
|
||||||
@@ -87,6 +96,14 @@ export default async function LocaleLayout({
|
|||||||
>
|
>
|
||||||
{tNav('about')}
|
{tNav('about')}
|
||||||
</Link>
|
</Link>
|
||||||
|
{showAdminTab ? (
|
||||||
|
<Link
|
||||||
|
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
|
||||||
|
href={`/${locale}/admin`}
|
||||||
|
>
|
||||||
|
{tNav('admin')}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Right side */}
|
{/* Right side */}
|
||||||
@@ -175,9 +192,9 @@ export default async function LocaleLayout({
|
|||||||
<div>
|
<div>
|
||||||
<h4 className="font-display font-bold text-lg mb-4">{t('resources')}</h4>
|
<h4 className="font-display font-bold text-lg mb-4">{t('resources')}</h4>
|
||||||
<ul className="space-y-2 text-sm font-sans text-gray-600 dark:text-gray-400">
|
<ul className="space-y-2 text-sm font-sans text-gray-600 dark:text-gray-400">
|
||||||
<li><Link className="hover:text-black dark:hover:text-white" href="#">Newsletter</Link></li>
|
<li><Link className="hover:text-black dark:hover:text-white" href="#">{t('resourceNewsletter')}</Link></li>
|
||||||
<li><Link className="hover:text-black dark:hover:text-white" href="#">Newsletter</Link></li>
|
<li><Link className="hover:text-black dark:hover:text-white" href="#">{t('resourceUpdates')}</Link></li>
|
||||||
<li><Link className="hover:text-black dark:hover:text-white" href="#">Documentation</Link></li>
|
<li><Link className="hover:text-black dark:hover:text-white" href="#">{t('resourceDocumentation')}</Link></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -204,6 +221,7 @@ export default async function LocaleLayout({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
</NextIntlClientProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -331,7 +331,8 @@ export function ProjectsResultsClient({
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
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()
|
const data = await response.json()
|
||||||
@@ -369,12 +370,13 @@ export function ProjectsResultsClient({
|
|||||||
setTraditionalLimit(nextLimit)
|
setTraditionalLimit(nextLimit)
|
||||||
replaceProjectsUrl(safePage, nextSort, false, nextLimit)
|
replaceProjectsUrl(safePage, nextSort, false, nextLimit)
|
||||||
} catch (error) {
|
} 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 {
|
} finally {
|
||||||
setLoadingTraditional(false)
|
setLoadingTraditional(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projectType, replaceProjectsUrl, search, selectedDomains, selectedProductForms, selectedTags]
|
[locale, projectType, replaceProjectsUrl, search, selectedDomains, selectedProductForms, selectedTags]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleTraditionalPageChange = useCallback(
|
const handleTraditionalPageChange = useCallback(
|
||||||
@@ -431,7 +433,8 @@ export function ProjectsResultsClient({
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
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()
|
const data = await response.json()
|
||||||
@@ -477,7 +480,8 @@ export function ProjectsResultsClient({
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!cancelled) {
|
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([])
|
setAiResults([])
|
||||||
setAiPagination((prev) => ({
|
setAiPagination((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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<HTMLFormElement>) {
|
||||||
|
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 (
|
||||||
|
<section className="neo-card p-6 space-y-4">
|
||||||
|
<h2 className="font-display text-2xl font-bold">{t('goalConsoleTitle')}</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{t('goalConsoleDescription')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="font-display text-xs uppercase font-bold mb-1 block">{t('adminKeyLabel')}</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={adminKey}
|
||||||
|
onChange={(event) => setAdminKey(event.target.value)}
|
||||||
|
className="neo-input w-full p-2 text-sm"
|
||||||
|
placeholder={t('adminKeyPlaceholder')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="font-display text-xs uppercase font-bold mb-1 block">{t('goalLabel')}</span>
|
||||||
|
<textarea
|
||||||
|
value={goal}
|
||||||
|
onChange={(event) => setGoal(event.target.value)}
|
||||||
|
className="neo-input w-full p-2 h-28 text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="neo-btn bg-primary text-black px-4 py-2 text-sm disabled:opacity-60"
|
||||||
|
disabled={!adminKey || !goal || loading}
|
||||||
|
>
|
||||||
|
{loading ? t('submitting') : t('submitGoal')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<pre className="neo-card p-3 bg-black text-green-300 text-xs overflow-auto max-h-96 whitespace-pre-wrap">
|
||||||
|
{responseText || t('noRunYet')}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<section className="neo-card p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-display text-xl font-bold">{title}</h3>
|
||||||
|
{description ? <p className="text-sm text-gray-600 dark:text-gray-300 mt-1">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="font-display text-xs uppercase font-bold mb-1 block">{t('adminKeyLabel')}</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={adminKey}
|
||||||
|
onChange={(event) => setAdminKey(event.target.value)}
|
||||||
|
className="neo-input w-full p-2 text-sm"
|
||||||
|
placeholder={t('adminKeyPlaceholder')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{method === 'POST' ? (
|
||||||
|
<label className="block">
|
||||||
|
<span className="font-display text-xs uppercase font-bold mb-1 block">{t('requestJsonLabel')}</span>
|
||||||
|
<textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(event) => setBody(event.target.value)}
|
||||||
|
className="neo-input w-full p-2 h-40 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFetch}
|
||||||
|
disabled={!adminKey || loading}
|
||||||
|
className="neo-btn bg-primary text-black px-4 py-2 text-sm disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loading ? t('loading') : t('executeEndpoint', { method, endpoint })}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<pre className="neo-card p-3 bg-black text-green-300 text-xs overflow-auto max-h-96 whitespace-pre-wrap">
|
||||||
|
{responseText || t('noResponseYet')}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<HTMLFormElement>) {
|
||||||
|
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 (
|
||||||
|
<section className="neo-card p-6 space-y-4">
|
||||||
|
<h2 className="font-display text-2xl font-bold">{t('policyTitle')}</h2>
|
||||||
|
|
||||||
|
<form className="space-y-3" onSubmit={handleProposalSubmit}>
|
||||||
|
<label className="block">
|
||||||
|
<span className="font-display text-xs uppercase font-bold mb-1 block">{t('adminKeyLabel')}</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="neo-input w-full p-2 text-sm"
|
||||||
|
value={adminKey}
|
||||||
|
onChange={(event) => setAdminKey(event.target.value)}
|
||||||
|
placeholder={t('adminKeyPlaceholder')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="font-display text-xs uppercase font-bold mb-1 block">{t('proposalJsonLabel')}</span>
|
||||||
|
<textarea
|
||||||
|
className="neo-input w-full p-2 h-56 text-xs font-mono"
|
||||||
|
value={payload}
|
||||||
|
onChange={(event) => setPayload(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" className="neo-btn bg-primary text-black px-4 py-2 text-sm">
|
||||||
|
{t('submitProposal')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="border-t-2 border-black dark:border-gray-600 pt-4 space-y-2">
|
||||||
|
<h3 className="font-display text-lg font-bold">{t('approvalDecisionTitle')}</h3>
|
||||||
|
<input
|
||||||
|
className="neo-input w-full p-2 text-sm"
|
||||||
|
placeholder={t('approvalTicketIdPlaceholder')}
|
||||||
|
value={approvalId}
|
||||||
|
onChange={(event) => setApprovalId(event.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="neo-input w-full p-2 text-sm"
|
||||||
|
placeholder={t('decisionCommentPlaceholder')}
|
||||||
|
value={decisionComment}
|
||||||
|
onChange={(event) => setDecisionComment(event.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="neo-btn bg-green-300 text-black px-4 py-2 text-sm"
|
||||||
|
onClick={() => handleDecision('approved')}
|
||||||
|
disabled={!adminKey || !approvalId}
|
||||||
|
>
|
||||||
|
{t('approve')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="neo-btn bg-red-300 text-black px-4 py-2 text-sm"
|
||||||
|
onClick={() => handleDecision('rejected')}
|
||||||
|
disabled={!adminKey || !approvalId}
|
||||||
|
>
|
||||||
|
{t('reject')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre className="neo-card p-3 bg-black text-green-300 text-xs overflow-auto max-h-96 whitespace-pre-wrap">
|
||||||
|
{responseText || t('noResponseYet')}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,9 +7,10 @@ interface AnnouncementBarProps {
|
|||||||
text: string
|
text: string
|
||||||
href: string
|
href: string
|
||||||
locale: 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)
|
const [isVisible, setIsVisible] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,7 +44,7 @@ export function AnnouncementBar({ text, href, locale }: AnnouncementBarProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="absolute right-4 hover:bg-black/10 rounded-full p-1 transition-colors"
|
className="absolute right-4 hover:bg-black/10 rounded-full p-1 transition-colors"
|
||||||
aria-label="Close announcement"
|
aria-label={closeLabel}
|
||||||
>
|
>
|
||||||
<span className="material-icons text-sm">close</span>
|
<span className="material-icons text-sm">close</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -13,9 +13,18 @@ export interface GitHubTextStats {
|
|||||||
license?: string | null // 文本值(如 "MIT")或徽章 URL
|
license?: string | null // 文本值(如 "MIT")或徽章 URL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GitHubTextStatsLabels {
|
||||||
|
header: string
|
||||||
|
starsTitle: string
|
||||||
|
forksTitle: string
|
||||||
|
issuesTitle: string
|
||||||
|
licenseTitle: string
|
||||||
|
}
|
||||||
|
|
||||||
interface GitHubTextStatsCardProps {
|
interface GitHubTextStatsCardProps {
|
||||||
stats: GitHubTextStats
|
stats: GitHubTextStats
|
||||||
className?: string
|
className?: string
|
||||||
|
labels?: GitHubTextStatsLabels
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,7 +59,19 @@ function renderStatValue(value: string) {
|
|||||||
*
|
*
|
||||||
* 设计原型来源: design/detail.html 第 223-289 行
|
* 设计原型来源: 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) {
|
if (!stats.stars && !stats.forks && !stats.issues && !stats.license) {
|
||||||
return null
|
return null
|
||||||
@@ -63,7 +84,7 @@ export function GitHubTextStatsCard({ stats, className = '' }: GitHubTextStatsCa
|
|||||||
<svg className="w-4 h-4 text-primary" fill="currentColor" viewBox="0 0 16 16">
|
<svg className="w-4 h-4 text-primary" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-3.14-.95-3.14-.95-.43-.92-.1-1.25-.1-1.25.2-.05.41.08.95.08 2.76 1.89 3.78 1.89 3.78 1.69 2.88 4.44 2.05 5.53-.16.47-.86-.94-1.25-1.14-.42-.26-.89-.04-1.25.23-.89.65-2.18.95-3.3 1.01-.22.01-.44.05-.66.12-.26.11-.53.03-.72-.13-.22-.18-.44-.49-.44-.49-.25-.95-.08-1.25.23-.72.72-1.87 2.05-2.84 2.85-.13.11-.24.26-.24.42 0 .33.27.76.76.76 1.16 0 .87.72 1.96 2.05 2.4 2.82.2.06.43.09.65.05.31-.06.6-.17.87-.33.26-.16.48-.36.63-.57.23-.21.47-.44.64-.67.19-.26.35-.54.48-.83.11-.26.17-.54.17-.82 0-.47-.27-.91-.66-1.1-.67-.34-1.45-.51-2.32-.51-.88 0-1.67.18-2.35.53-.26.13-.5.25-.73.36-.22.1-.42.23-.58.37-.14.13-.26.26-.34.4-.07.14-.1.29-.1.44 0 .18.09.34.25.46.13.11.29.18.46.18.19 0 .38-.06.55-.17.15-.11.28-.24.39-.39.1-.15.18-.31.23-.48.05-.18.06-.36.06-.54 0-.3-.17-.57-.44-.74-.23-.17-.5-.26-.78-.26-.28 0-.55.09-.79.26-.23.17-.41.39-.54.64-.12.24-.17.5-.17.77 0 .26.17.5.43.67.25.17.57.26.89.26.31 0 .6-.09.85-.26.23-.17.41-.39.54-.64.12-.24.18-.5.18-.77 0-.26-.17-.5-.43-.67-.26-.17-.57-.26-.89-.26-.31 0-.6.09-.85.26-.23.17-.41.39-.54-.64-.12.24-.17.5-.17.77zM8 15c-3.86 0-7-3.14-7-7s3.14-7 7-7 7 3.14 7 7-3.14 7-7 7z"/>
|
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-3.14-.95-3.14-.95-.43-.92-.1-1.25-.1-1.25.2-.05.41.08.95.08 2.76 1.89 3.78 1.89 3.78 1.69 2.88 4.44 2.05 5.53-.16.47-.86-.94-1.25-1.14-.42-.26-.89-.04-1.25.23-.89.65-2.18.95-3.3 1.01-.22.01-.44.05-.66.12-.26.11-.53.03-.72-.13-.22-.18-.44-.49-.44-.49-.25-.95-.08-1.25.23-.72.72-1.87 2.05-2.84 2.85-.13.11-.24.26-.24.42 0 .33.27.76.76.76 1.16 0 .87.72 1.96 2.05 2.4 2.82.2.06.43.09.65.05.31-.06.6-.17.87-.33.26-.16.48-.36.63-.57.23-.21.47-.44.64-.67.19-.26.35-.54.48-.83.11-.26.17-.54.17-.82 0-.47-.27-.91-.66-1.1-.67-.34-1.45-.51-2.32-.51-.88 0-1.67.18-2.35.53-.26.13-.5.25-.73.36-.22.1-.42.23-.58.37-.14.13-.26.26-.34.4-.07.14-.1.29-.1.44 0 .18.09.34.25.46.13.11.29.18.46.18.19 0 .38-.06.55-.17.15-.11.28-.24.39-.39.1-.15.18-.31.23-.48.05-.18.06-.36.06-.54 0-.3-.17-.57-.44-.74-.23-.17-.5-.26-.78-.26-.28 0-.55.09-.79.26-.23.17-.41.39-.54.64-.12.24-.17.5-.17.77 0 .26.17.5.43.67.25.17.57.26.89.26.31 0 .6-.09.85-.26.23-.17.41-.39.54-.64.12-.24.18-.5.18-.77 0-.26-.17-.5-.43-.67-.26-.17-.57-.26-.89-.26-.31 0-.6.09-.85.26-.23.17-.41.39-.54-.64-.12.24-.17.5-.17.77zM8 15c-3.86 0-7-3.14-7-7s3.14-7 7-7 7 3.14 7 7-3.14 7-7 7z"/>
|
||||||
</svg>
|
</svg>
|
||||||
GitHub Statistics
|
{labels.header}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Stats Grid - 2 columns */}
|
{/* Stats Grid - 2 columns */}
|
||||||
@@ -73,7 +94,7 @@ export function GitHubTextStatsCard({ stats, className = '' }: GitHubTextStatsCa
|
|||||||
<Link
|
<Link
|
||||||
href={`https://github.com/${stats.owner}/${stats.repo}/stargazers`}
|
href={`https://github.com/${stats.owner}/${stats.repo}/stargazers`}
|
||||||
className="group"
|
className="group"
|
||||||
title="View stars on GitHub"
|
title={labels.starsTitle}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 hover:bg-white dark:hover:bg-black hover:bg-opacity-50 p-2 rounded transition-colors">
|
<div className="flex items-center gap-2 hover:bg-white dark:hover:bg-black hover:bg-opacity-50 p-2 rounded transition-colors">
|
||||||
<svg className="w-4 h-4 text-primary flex-shrink-0" fill="currentColor" viewBox="0 0 16 16">
|
<svg className="w-4 h-4 text-primary flex-shrink-0" fill="currentColor" viewBox="0 0 16 16">
|
||||||
@@ -89,7 +110,7 @@ export function GitHubTextStatsCard({ stats, className = '' }: GitHubTextStatsCa
|
|||||||
<Link
|
<Link
|
||||||
href={`https://github.com/${stats.owner}/${stats.repo}/network/members`}
|
href={`https://github.com/${stats.owner}/${stats.repo}/network/members`}
|
||||||
className="group"
|
className="group"
|
||||||
title="View forks on GitHub"
|
title={labels.forksTitle}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 hover:bg-white dark:hover:bg-black hover:bg-opacity-50 p-2 rounded transition-colors">
|
<div className="flex items-center gap-2 hover:bg-white dark:hover:bg-black hover:bg-opacity-50 p-2 rounded transition-colors">
|
||||||
<svg className="w-4 h-4 text-blue-500 flex-shrink-0" fill="currentColor" viewBox="0 0 16 16">
|
<svg className="w-4 h-4 text-blue-500 flex-shrink-0" fill="currentColor" viewBox="0 0 16 16">
|
||||||
@@ -105,7 +126,7 @@ export function GitHubTextStatsCard({ stats, className = '' }: GitHubTextStatsCa
|
|||||||
<Link
|
<Link
|
||||||
href={`https://github.com/${stats.owner}/${stats.repo}/issues`}
|
href={`https://github.com/${stats.owner}/${stats.repo}/issues`}
|
||||||
className="group"
|
className="group"
|
||||||
title="View issues on GitHub"
|
title={labels.issuesTitle}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 hover:bg-white dark:hover:bg-black hover:bg-opacity-50 p-2 rounded transition-colors">
|
<div className="flex items-center gap-2 hover:bg-white dark:hover:bg-black hover:bg-opacity-50 p-2 rounded transition-colors">
|
||||||
<svg className="w-4 h-4 text-green-600 flex-shrink-0" fill="currentColor" viewBox="0 0 16 16">
|
<svg className="w-4 h-4 text-green-600 flex-shrink-0" fill="currentColor" viewBox="0 0 16 16">
|
||||||
@@ -122,7 +143,7 @@ export function GitHubTextStatsCard({ stats, className = '' }: GitHubTextStatsCa
|
|||||||
<Link
|
<Link
|
||||||
href={`https://github.com/${stats.owner}/${stats.repo}/blob/main/LICENSE`}
|
href={`https://github.com/${stats.owner}/${stats.repo}/blob/main/LICENSE`}
|
||||||
className="group"
|
className="group"
|
||||||
title="View license on GitHub"
|
title={labels.licenseTitle}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 hover:bg-white dark:hover:bg-black hover:bg-opacity-50 p-2 rounded transition-colors">
|
<div className="flex items-center gap-2 hover:bg-white dark:hover:bg-black hover:bg-opacity-50 p-2 rounded transition-colors">
|
||||||
<svg className="w-4 h-4 text-purple-600 flex-shrink-0" fill="currentColor" viewBox="0 0 16 16">
|
<svg className="w-4 h-4 text-purple-600 flex-shrink-0" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { Components } from 'react-markdown'
|
|||||||
interface MarkdownContentProps {
|
interface MarkdownContentProps {
|
||||||
content: string
|
content: string
|
||||||
className?: string
|
className?: string
|
||||||
|
noContentText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const BLOCK_LEVEL_TAGS = new Set([
|
const BLOCK_LEVEL_TAGS = new Set([
|
||||||
@@ -258,11 +259,11 @@ const components: Components = {
|
|||||||
) : null,
|
) : null,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MarkdownContent({ content, className = '' }: MarkdownContentProps) {
|
export function MarkdownContent({ content, className = '', noContentText }: MarkdownContentProps) {
|
||||||
|
|
||||||
if (!content || content.trim() === '') {
|
if (!content || content.trim() === '') {
|
||||||
return (
|
return (
|
||||||
<p className="text-gray-500 dark:text-gray-400 italic">No content available.</p>
|
<p className="text-gray-500 dark:text-gray-400 italic">{noContentText ?? 'No content available.'}</p>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function getProjectIcon(tags: Array<{ name: string | null }>): string {
|
|||||||
|
|
||||||
export function ProjectCard({ project, locale, featured = false, translations }: ProjectCardProps) {
|
export function ProjectCard({ project, locale, featured = false, translations }: ProjectCardProps) {
|
||||||
// 使用传入的翻译文本,如果没有提供则使用默认值
|
// 使用传入的翻译文本,如果没有提供则使用默认值
|
||||||
const viewDetailsText = translations?.viewDetails || 'View Details'
|
const viewDetailsText = translations?.viewDetails || (locale === 'zh' ? '查看详情' : 'View Details')
|
||||||
const icon = getProjectIcon(project.tags)
|
const icon = getProjectIcon(project.tags)
|
||||||
const displayName = locale === 'en' && project.nameEn ? project.nameEn : project.name
|
const displayName = locale === 'en' && project.nameEn ? project.nameEn : project.name
|
||||||
const displayDescription = locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description
|
const displayDescription = locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description
|
||||||
@@ -109,7 +109,7 @@ export function ProjectCard({ project, locale, featured = false, translations }:
|
|||||||
</svg>
|
</svg>
|
||||||
<Image
|
<Image
|
||||||
src={badges.stars}
|
src={badges.stars}
|
||||||
alt="Stars"
|
alt={locale === 'zh' ? 'Star' : 'Stars'}
|
||||||
width={70}
|
width={70}
|
||||||
height={20}
|
height={20}
|
||||||
unoptimized
|
unoptimized
|
||||||
@@ -135,9 +135,10 @@ interface SubmitProjectCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SubmitProjectCard({ locale, translations }: SubmitProjectCardProps) {
|
export function SubmitProjectCard({ locale, translations }: SubmitProjectCardProps) {
|
||||||
const submitProjectTitle = translations?.submitProjectTitle || 'Submit Your Project'
|
const submitProjectTitle = translations?.submitProjectTitle || (locale === 'zh' ? '提交你的项目' : 'Submit Your Project')
|
||||||
const submitProjectDescription = translations?.submitProjectDescription || 'Help us grow the AI ecosystem'
|
const submitProjectDescription =
|
||||||
const submitNow = translations?.submitNow || 'Submit Now'
|
translations?.submitProjectDescription || (locale === 'zh' ? '帮助我们一起完善 AI 生态' : 'Help us grow the AI ecosystem')
|
||||||
|
const submitNow = translations?.submitNow || (locale === 'zh' ? '立即提交' : 'Submit Now')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="bg-primary border-2 border-black dark:border-gray-600 p-6 shadow-neo hover:shadow-neo-hover hover:-translate-y-1 transition-all duration-200 flex flex-col h-full justify-center items-center text-center group">
|
<article className="bg-primary border-2 border-black dark:border-gray-600 p-6 shadow-neo hover:shadow-neo-hover hover:-translate-y-1 transition-all duration-200 flex flex-col h-full justify-center items-center text-center group">
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
|
|||||||
const categoryEn =
|
const categoryEn =
|
||||||
fixedTypeTag?.nameEn || fixedTypeTag?.name || project.tags[0]?.nameEn || project.tags[0]?.name || 'AI Agent'
|
fixedTypeTag?.nameEn || fixedTypeTag?.name || project.tags[0]?.nameEn || project.tags[0]?.name || 'AI Agent'
|
||||||
const displayCategory = locale === 'en' ? categoryEn : category
|
const displayCategory = locale === 'en' ? categoryEn : category
|
||||||
|
const getTagName = (tag: { name: string; nameEn?: string | null }) =>
|
||||||
|
locale === 'en' && tag.nameEn ? tag.nameEn : tag.name
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -77,7 +79,7 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
|
|||||||
<div className="flex flex-wrap items-center gap-4 text-sm font-mono text-gray-600 dark:text-gray-400 mb-8 pb-8 border-b border-gray-300 dark:border-gray-700">
|
<div className="flex flex-wrap items-center gap-4 text-sm font-mono text-gray-600 dark:text-gray-400 mb-8 pb-8 border-b border-gray-300 dark:border-gray-700">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="material-icons text-base">calendar_today</span>
|
<span className="material-icons text-base">calendar_today</span>
|
||||||
<span>Added {formatDate(project.createdAt, locale)}</span>
|
<span>{t('addedOn', { date: formatDate(project.createdAt, locale) })}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden sm:inline text-gray-300">|</span>
|
<span className="hidden sm:inline text-gray-300">|</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -98,7 +100,7 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
|
|||||||
key={tag.id}
|
key={tag.id}
|
||||||
className="px-3 py-1 border border-black dark:border-gray-500 text-xs font-display font-bold uppercase bg-white dark:bg-gray-800"
|
className="px-3 py-1 border border-black dark:border-gray-500 text-xs font-display font-bold uppercase bg-white dark:bg-gray-800"
|
||||||
>
|
>
|
||||||
{tag.name}
|
{getTagName(tag)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -112,7 +114,7 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Full content with Markdown rendering */}
|
{/* Full content with Markdown rendering */}
|
||||||
{displayContent && <MarkdownContent content={displayContent} />}
|
<MarkdownContent content={displayContent ?? ''} noContentText={t('noContentAvailable')} />
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
{/* Share and Feedback Section - Temporarily disabled for debugging */}
|
{/* Share and Feedback Section - Temporarily disabled for debugging */}
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ export async function ProjectSidebar({ project, locale }: ProjectSidebarProps) {
|
|||||||
issues: githubInfo.issues,
|
issues: githubInfo.issues,
|
||||||
license: githubInfo.license
|
license: githubInfo.license
|
||||||
}}
|
}}
|
||||||
|
labels={{
|
||||||
|
header: t('githubStatsTitle'),
|
||||||
|
starsTitle: t('githubStatsViewStars'),
|
||||||
|
forksTitle: t('githubStatsViewForks'),
|
||||||
|
issuesTitle: t('githubStatsViewIssues'),
|
||||||
|
licenseTitle: t('githubStatsViewLicense'),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
|
||||||
interface RelatedProjectsProps {
|
interface RelatedProjectsProps {
|
||||||
projects: Array<{
|
projects: Array<{
|
||||||
@@ -29,7 +30,10 @@ function getProjectIcon(tags: Array<{ name: string }>): string {
|
|||||||
return '✨'
|
return '✨'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RelatedProjects({ projects, locale }: RelatedProjectsProps) {
|
export async function RelatedProjects({ projects, locale }: RelatedProjectsProps) {
|
||||||
|
const tProject = await getTranslations('project')
|
||||||
|
const tCommon = await getTranslations('common')
|
||||||
|
|
||||||
if (projects.length === 0) {
|
if (projects.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -37,12 +41,12 @@ export function RelatedProjects({ projects, locale }: RelatedProjectsProps) {
|
|||||||
return (
|
return (
|
||||||
<section className="mt-20 pt-12 border-t border-black dark:border-gray-700">
|
<section className="mt-20 pt-12 border-t border-black dark:border-gray-700">
|
||||||
<div className="flex justify-between items-end mb-8">
|
<div className="flex justify-between items-end mb-8">
|
||||||
<h2 className="font-display text-3xl font-bold uppercase">Related Projects</h2>
|
<h2 className="font-display text-3xl font-bold uppercase">{tProject('relatedProjects')}</h2>
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}/projects`}
|
href={`/${locale}/projects`}
|
||||||
className="font-display text-xs font-bold uppercase hover:underline flex items-center gap-1 group"
|
className="font-display text-xs font-bold uppercase hover:underline flex items-center gap-1 group"
|
||||||
>
|
>
|
||||||
View All{' '}
|
{tProject('viewAll')}{' '}
|
||||||
<span className="material-icons text-sm group-hover:translate-x-1 transition-transform">arrow_forward</span>
|
<span className="material-icons text-sm group-hover:translate-x-1 transition-transform">arrow_forward</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,6 +56,8 @@ export function RelatedProjects({ projects, locale }: RelatedProjectsProps) {
|
|||||||
const displayDescription =
|
const displayDescription =
|
||||||
locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description
|
locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description
|
||||||
const icon = getProjectIcon(project.tags)
|
const icon = getProjectIcon(project.tags)
|
||||||
|
const getTagName = (tag: { name: string; nameEn?: string | null }) =>
|
||||||
|
locale === 'en' && tag.nameEn ? tag.nameEn : tag.name
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -72,13 +78,13 @@ export function RelatedProjects({ projects, locale }: RelatedProjectsProps) {
|
|||||||
key={tag.id}
|
key={tag.id}
|
||||||
className="text-[10px] uppercase font-bold border border-gray-300 dark:border-gray-600 px-2 py-1"
|
className="text-[10px] uppercase font-bold border border-gray-300 dark:border-gray-600 px-2 py-1"
|
||||||
>
|
>
|
||||||
{tag.name}
|
{getTagName(tag)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-auto pt-4 border-t border-gray-100 dark:border-gray-700">
|
<div className="mt-auto pt-4 border-t border-gray-100 dark:border-gray-700">
|
||||||
<span className="text-xs font-display font-bold uppercase flex items-center">
|
<span className="text-xs font-display font-bold uppercase flex items-center">
|
||||||
View Details{' '}
|
{tCommon('viewDetails')}{' '}
|
||||||
<span className="material-icons text-sm ml-1 group-hover:translate-x-1 transition-transform">
|
<span className="material-icons text-sm ml-1 group-hover:translate-x-1 transition-transform">
|
||||||
arrow_forward
|
arrow_forward
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
interface ShareButtonsProps {
|
interface ShareButtonsProps {
|
||||||
displayName: string
|
displayName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShareButtons({ displayName }: ShareButtonsProps) {
|
export function ShareButtons({ displayName }: ShareButtonsProps) {
|
||||||
|
const t = useTranslations('project')
|
||||||
|
|
||||||
const handleShare = () => {
|
const handleShare = () => {
|
||||||
const url = encodeURIComponent(window.location.href)
|
const url = encodeURIComponent(window.location.href)
|
||||||
const text = encodeURIComponent(`Check out ${displayName} on Agent Park`)
|
const text = encodeURIComponent(t('shareTweetText', { name: displayName }))
|
||||||
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank')
|
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,7 +24,7 @@ export function ShareButtons({ displayName }: ShareButtonsProps) {
|
|||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<button
|
<button
|
||||||
className="p-2 border border-black dark:border-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
className="p-2 border border-black dark:border-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
title="Share on X"
|
title={t('shareOnX')}
|
||||||
onClick={handleShare}
|
onClick={handleShare}
|
||||||
id="share-button"
|
id="share-button"
|
||||||
name="share"
|
name="share"
|
||||||
@@ -29,7 +33,7 @@ export function ShareButtons({ displayName }: ShareButtonsProps) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="p-2 border border-black dark:border-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
className="p-2 border border-black dark:border-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
title="Copy Link"
|
title={t('copyLink')}
|
||||||
onClick={handleCopyLink}
|
onClick={handleCopyLink}
|
||||||
id="copy-link-button"
|
id="copy-link-button"
|
||||||
name="copyLink"
|
name="copyLink"
|
||||||
@@ -38,13 +42,13 @@ export function ShareButtons({ displayName }: ShareButtonsProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-display font-bold text-gray-500">DID THIS AGENT HELP YOU?</span>
|
<span className="text-sm font-display font-bold text-gray-500">{t('feedbackQuestion')}</span>
|
||||||
<button
|
<button
|
||||||
className="px-4 py-2 bg-primary text-black font-display font-bold text-xs uppercase border border-black hover:bg-yellow-400 transition-colors shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] active:shadow-none active:translate-x-[2px] active:translate-y-[2px]"
|
className="px-4 py-2 bg-primary text-black font-display font-bold text-xs uppercase border border-black hover:bg-yellow-400 transition-colors shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] active:shadow-none active:translate-x-[2px] active:translate-y-[2px]"
|
||||||
id="feedback-button"
|
id="feedback-button"
|
||||||
name="feedback"
|
name="feedback"
|
||||||
>
|
>
|
||||||
Yes, it rocks
|
{t('feedbackYes')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
const TOKEN_TTL_MS = 1000 * 60 * 60 * 8
|
||||||
|
|
||||||
|
type AdminTokenEntry = {
|
||||||
|
token: string
|
||||||
|
operator: string
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var __agentParkAdminTokens: Map<string, AdminTokenEntry> | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTokenStore(): Map<string, AdminTokenEntry> {
|
||||||
|
if (!globalThis.__agentParkAdminTokens) {
|
||||||
|
globalThis.__agentParkAdminTokens = new Map<string, AdminTokenEntry>()
|
||||||
|
}
|
||||||
|
return globalThis.__agentParkAdminTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExpectedAdminKey(): string | undefined {
|
||||||
|
return process.env.ADMIN_CONSOLE_KEY || process.env.WEBHOOK_API_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidAdminKey(input: string | null | undefined): boolean {
|
||||||
|
const expected = getExpectedAdminKey()
|
||||||
|
if (!input || !expected) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputBuffer = Buffer.from(input)
|
||||||
|
const expectedBuffer = Buffer.from(expected)
|
||||||
|
|
||||||
|
if (inputBuffer.length !== expectedBuffer.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto.timingSafeEqual(inputBuffer, expectedBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function issueAdminToken(operator: string): AdminTokenEntry {
|
||||||
|
const entry: AdminTokenEntry = {
|
||||||
|
token: crypto.randomUUID(),
|
||||||
|
operator,
|
||||||
|
expiresAt: Date.now() + TOKEN_TTL_MS,
|
||||||
|
}
|
||||||
|
|
||||||
|
getTokenStore().set(entry.token, entry)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyAdminToken(token: string | null | undefined): AdminTokenEntry | null {
|
||||||
|
if (!token) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = getTokenStore().get(token)
|
||||||
|
if (!entry) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.expiresAt <= Date.now()) {
|
||||||
|
getTokenStore().delete(token)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAdminOperator(params: {
|
||||||
|
bearerToken?: string | null
|
||||||
|
adminKey?: string | null
|
||||||
|
fallbackOperator?: string | null
|
||||||
|
}): { authorized: boolean; operator?: string } {
|
||||||
|
const bearerEntry = verifyAdminToken(params.bearerToken)
|
||||||
|
if (bearerEntry) {
|
||||||
|
return { authorized: true, operator: bearerEntry.operator }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValidAdminKey(params.adminKey)) {
|
||||||
|
return {
|
||||||
|
authorized: true,
|
||||||
|
operator: params.fallbackOperator || 'admin-key',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { authorized: false }
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const DEFAULT_ENDPOINT = 'https://open.bigmodel.cn/api/coding/paas/v4/chat/completions'
|
||||||
|
const DEFAULT_MODEL = 'glm-4.7'
|
||||||
|
const DEFAULT_TIMEOUT_MS = 120_000
|
||||||
|
const DEFAULT_MAX_TOKENS = 1200
|
||||||
|
|
||||||
|
const UsageSchema = z
|
||||||
|
.object({
|
||||||
|
prompt_tokens: z.number().int().nonnegative().optional(),
|
||||||
|
completion_tokens: z.number().int().nonnegative().optional(),
|
||||||
|
total_tokens: z.number().int().nonnegative().optional(),
|
||||||
|
})
|
||||||
|
.partial()
|
||||||
|
|
||||||
|
const OpenAiCompatResponseSchema = z.object({
|
||||||
|
choices: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
message: z
|
||||||
|
.object({
|
||||||
|
content: z
|
||||||
|
.union([
|
||||||
|
z.string(),
|
||||||
|
z.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
type: z.string().optional(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(1),
|
||||||
|
usage: UsageSchema.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AgentModelConfig = {
|
||||||
|
endpoint: string
|
||||||
|
model: string
|
||||||
|
apiKey: string
|
||||||
|
timeoutMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AgentModelJsonCallResult<T> = {
|
||||||
|
data: T
|
||||||
|
model: string
|
||||||
|
latencyMs: number
|
||||||
|
promptTokens?: number
|
||||||
|
completionTokens?: number
|
||||||
|
totalTokens?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEndpoint(input: string): string {
|
||||||
|
const trimmed = input.trim().replace(/\.+$/, '')
|
||||||
|
if (trimmed.endsWith('/chat/completions')) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
return `${trimmed.replace(/\/$/, '')}/chat/completions`
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTextContent(content: unknown): string {
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
.map((item) => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof item === 'object' &&
|
||||||
|
item !== null &&
|
||||||
|
'text' in item &&
|
||||||
|
typeof (item as { text?: unknown }).text === 'string'
|
||||||
|
) {
|
||||||
|
return (item as { text: string }).text
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeParseJsonBlock(input: string): unknown {
|
||||||
|
const trimmed = input.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error('Model returned empty content.')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed)
|
||||||
|
} catch {
|
||||||
|
const codeBlockMatch = trimmed.match(/```json\s*([\s\S]*?)```/i)
|
||||||
|
if (codeBlockMatch && codeBlockMatch[1]) {
|
||||||
|
return JSON.parse(codeBlockMatch[1].trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstBrace = trimmed.indexOf('{')
|
||||||
|
const lastBrace = trimmed.lastIndexOf('}')
|
||||||
|
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
||||||
|
const candidate = trimmed.slice(firstBrace, lastBrace + 1)
|
||||||
|
return JSON.parse(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Failed to parse JSON payload from model output.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAgentModelConfig(): AgentModelConfig | null {
|
||||||
|
const apiKey =
|
||||||
|
process.env.AGENT_OS_MODEL_API_KEY ||
|
||||||
|
process.env.BIGMODEL_API_KEY ||
|
||||||
|
process.env.ZHIPU_API_KEY
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpointRaw =
|
||||||
|
process.env.AGENT_OS_MODEL_ENDPOINT ||
|
||||||
|
process.env.BIGMODEL_API_ENDPOINT ||
|
||||||
|
process.env.BIGMODEL_BASE_URL ||
|
||||||
|
DEFAULT_ENDPOINT
|
||||||
|
|
||||||
|
const model =
|
||||||
|
process.env.AGENT_OS_MODEL_NAME || process.env.BIGMODEL_MODEL || process.env.MODEL_NAME || DEFAULT_MODEL
|
||||||
|
|
||||||
|
const timeoutInput = Number(process.env.AGENT_OS_MODEL_TIMEOUT_MS || DEFAULT_TIMEOUT_MS)
|
||||||
|
const timeoutMs = Number.isFinite(timeoutInput) && timeoutInput > 0 ? timeoutInput : DEFAULT_TIMEOUT_MS
|
||||||
|
|
||||||
|
return {
|
||||||
|
endpoint: normalizeEndpoint(endpointRaw),
|
||||||
|
model,
|
||||||
|
apiKey,
|
||||||
|
timeoutMs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function callAgentModelForJson<T>(input: {
|
||||||
|
config: AgentModelConfig
|
||||||
|
systemPrompt: string
|
||||||
|
userPrompt: string
|
||||||
|
schema: z.ZodType<T, z.ZodTypeDef, unknown>
|
||||||
|
temperature?: number
|
||||||
|
maxTokens?: number
|
||||||
|
}): Promise<AgentModelJsonCallResult<T>> {
|
||||||
|
const start = Date.now()
|
||||||
|
const abortController = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => abortController.abort(), input.config.timeoutMs)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(input.config.endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${input.config.apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: input.config.model,
|
||||||
|
temperature: input.temperature ?? 0.1,
|
||||||
|
max_tokens: input.maxTokens ?? DEFAULT_MAX_TOKENS,
|
||||||
|
thinking: { type: 'disabled' },
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: input.systemPrompt },
|
||||||
|
{ role: 'user', content: input.userPrompt },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
signal: abortController.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorBody = await response.text()
|
||||||
|
throw new Error(
|
||||||
|
`Model endpoint request failed: ${response.status} ${response.statusText}; body=${errorBody}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = OpenAiCompatResponseSchema.parse(await response.json())
|
||||||
|
const firstChoice = payload.choices[0]
|
||||||
|
if (!firstChoice) {
|
||||||
|
throw new Error('Model returned no choices.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawText = extractTextContent(firstChoice.message.content)
|
||||||
|
const parsed = maybeParseJsonBlock(rawText)
|
||||||
|
const validated = input.schema.parse(parsed)
|
||||||
|
const latencyMs = Date.now() - start
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: validated,
|
||||||
|
model: input.config.model,
|
||||||
|
latencyMs,
|
||||||
|
promptTokens: payload.usage?.prompt_tokens,
|
||||||
|
completionTokens: payload.usage?.completion_tokens,
|
||||||
|
totalTokens: payload.usage?.total_tokens,
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type { CapabilityId, ResourceManifest } from './types'
|
||||||
|
|
||||||
|
export type N8nProductionWorkflowReference = {
|
||||||
|
workflowId: string
|
||||||
|
name: string
|
||||||
|
active: boolean
|
||||||
|
nodeCount: number
|
||||||
|
capability: CapabilityId
|
||||||
|
summary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const N8N_PRODUCTION_WORKFLOWS: N8nProductionWorkflowReference[] = [
|
||||||
|
{
|
||||||
|
workflowId: 'hughGsWismCpk7jd',
|
||||||
|
name: '每日Github Trending项目计划新增',
|
||||||
|
active: true,
|
||||||
|
nodeCount: 18,
|
||||||
|
capability: 'discovery_trending',
|
||||||
|
summary:
|
||||||
|
'Scheduled trending source scrape, AI relevance filter, dedup check, and webhook task creation.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
workflowId: 'iw9vx9ih5Lt0Mobk',
|
||||||
|
name: 'Topic项目计划新增',
|
||||||
|
active: true,
|
||||||
|
nodeCount: 20,
|
||||||
|
capability: 'discovery_topic',
|
||||||
|
summary:
|
||||||
|
'Topic candidate generation, dedup pipeline, AI chain filtering, and low-recall alert hooks.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
workflowId: 'bAxNZKGq2ApUUiw9',
|
||||||
|
name: '前沿信号聚合(多源+AI Agent过滤)',
|
||||||
|
active: true,
|
||||||
|
nodeCount: 37,
|
||||||
|
capability: 'signal_aggregation',
|
||||||
|
summary:
|
||||||
|
'Multi-source signals (HN/GitHub/arXiv/Reddit/ProductHunt/HF), AI relevance filtering, and feedback loop to discovery.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
workflowId: '1AvejnM5n-WPApU1vFt9C',
|
||||||
|
name: '项目描述向量化',
|
||||||
|
active: true,
|
||||||
|
nodeCount: 11,
|
||||||
|
capability: 'embedding_refresh',
|
||||||
|
summary: 'Batch fetch unembedded projects, call embedding API, and update vectors in DB.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
workflowId: 'F5cQ06DykBfpeyfqL-pd7',
|
||||||
|
name: 'RAG项目搜索',
|
||||||
|
active: true,
|
||||||
|
nodeCount: 6,
|
||||||
|
capability: 'rag_search',
|
||||||
|
summary: 'Webhook query to embedding model, vector similarity SQL retrieval, and normalized response.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
workflowId: 'ewx9Gs6cjrTXvwD0',
|
||||||
|
name: 'GitHub Star 每日刷新',
|
||||||
|
active: true,
|
||||||
|
nodeCount: 7,
|
||||||
|
capability: 'github_metrics_refresh',
|
||||||
|
summary: 'Daily repository stat refresh and writeback to project metrics fields.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
workflowId: '1Ig1CyVMsGJFaHOe',
|
||||||
|
name: '项目分析入库(多源)',
|
||||||
|
active: true,
|
||||||
|
nodeCount: 20,
|
||||||
|
capability: 'project_ingest',
|
||||||
|
summary:
|
||||||
|
'Task queue consume, agent-browser assisted collection, content summarization, tag classification, and ingestion completion callbacks.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const WORKFLOW_TO_SKILL_ID: Record<string, string> = {
|
||||||
|
hughGsWismCpk7jd: 'skill.discovery.trending.daily-plan',
|
||||||
|
iw9vx9ih5Lt0Mobk: 'skill.discovery.topic.daily-plan',
|
||||||
|
bAxNZKGq2ApUUiw9: 'skill.discovery.signal.multi-source',
|
||||||
|
'1AvejnM5n-WPApU1vFt9C': 'skill.knowledge.embedding.refresh',
|
||||||
|
'F5cQ06DykBfpeyfqL-pd7': 'skill.search.rag.similarity',
|
||||||
|
ewx9Gs6cjrTXvwD0: 'skill.metrics.github-stars.refresh',
|
||||||
|
'1Ig1CyVMsGJFaHOe': 'skill.ingest.project.multi-source',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listN8nProductionWorkflowReferences(): N8nProductionWorkflowReference[] {
|
||||||
|
return N8N_PRODUCTION_WORKFLOWS.map((workflow) => ({ ...workflow }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listN8nBackedSkillManifests(): ResourceManifest[] {
|
||||||
|
return N8N_PRODUCTION_WORKFLOWS.map((workflow) => ({
|
||||||
|
id: WORKFLOW_TO_SKILL_ID[workflow.workflowId] || `skill.n8n.${workflow.workflowId}`,
|
||||||
|
kind: 'skill',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: workflow.active ? 'active' : 'disabled',
|
||||||
|
owner: 'n8n.production',
|
||||||
|
inputSchemaRef: `schema://skills/${workflow.capability}/input`,
|
||||||
|
outputSchemaRef: `schema://skills/${workflow.capability}/output`,
|
||||||
|
riskLevel: 'L1',
|
||||||
|
defaultPolicy: 'allow',
|
||||||
|
tags: ['skill', 'n8n', workflow.capability],
|
||||||
|
latencyHintMs: 500,
|
||||||
|
metadata: {
|
||||||
|
source: 'n8n.production',
|
||||||
|
workflowId: workflow.workflowId,
|
||||||
|
workflowName: workflow.name,
|
||||||
|
nodeCount: workflow.nodeCount,
|
||||||
|
summary: workflow.summary,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { orchestrateGoal } from './orchestrator'
|
||||||
|
import {
|
||||||
|
createPolicyProposal,
|
||||||
|
decideApproval,
|
||||||
|
getRun,
|
||||||
|
listTasks,
|
||||||
|
listToolCalls,
|
||||||
|
resetAgentOsStateForTests,
|
||||||
|
} from './store'
|
||||||
|
|
||||||
|
describe('agent-os orchestrator', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAgentOsStateForTests()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates run/task/tool-call records for a goal', async () => {
|
||||||
|
const result = await orchestrateGoal({
|
||||||
|
goal: 'Review code quality and inspect logs',
|
||||||
|
createdBy: 'test-user',
|
||||||
|
})
|
||||||
|
|
||||||
|
const run = getRun(result.runId)
|
||||||
|
const tasks = listTasks(result.runId)
|
||||||
|
const toolCalls = listToolCalls(result.runId)
|
||||||
|
|
||||||
|
expect(run).not.toBeNull()
|
||||||
|
expect(tasks.length).toBeGreaterThan(0)
|
||||||
|
expect(toolCalls.length).toBe(tasks.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses preferred agent when provided', async () => {
|
||||||
|
const result = await orchestrateGoal({
|
||||||
|
goal: 'Collect GitHub discovery signals',
|
||||||
|
createdBy: 'test-user',
|
||||||
|
preferredAgentId: 'agent.discovery.scout',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.agent.id).toBe('agent.discovery.scout')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds low-trust fallback reason when mcp web reader is denied by approved proposal', async () => {
|
||||||
|
const firstRun = await orchestrateGoal({
|
||||||
|
goal: 'Read this website and inspect the project details',
|
||||||
|
createdBy: 'test-user',
|
||||||
|
preferredAgentId: 'agent.discovery.scout',
|
||||||
|
})
|
||||||
|
|
||||||
|
const firstCall = listToolCalls(firstRun.runId)[0]
|
||||||
|
expect(firstCall?.resourceId).toBe('mcp.agent_browser.read')
|
||||||
|
|
||||||
|
const proposal = createPolicyProposal({
|
||||||
|
title: 'Deny agent-browser MCP for discovery scout',
|
||||||
|
description: 'Force fallback for trust-level validation.',
|
||||||
|
targetType: 'agent',
|
||||||
|
targetId: 'agent.discovery.scout',
|
||||||
|
createdBy: 'test-user',
|
||||||
|
changes: {
|
||||||
|
policyOverrides: {
|
||||||
|
'mcp.agent_browser.read': 'deny',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
decideApproval({
|
||||||
|
approvalId: proposal.approvalTicket.id,
|
||||||
|
decision: 'approved',
|
||||||
|
decidedBy: 'test-user',
|
||||||
|
comment: 'Approved in test',
|
||||||
|
})
|
||||||
|
|
||||||
|
const secondRun = await orchestrateGoal({
|
||||||
|
goal: 'Read this website and inspect the project details',
|
||||||
|
createdBy: 'test-user',
|
||||||
|
preferredAgentId: 'agent.discovery.scout',
|
||||||
|
})
|
||||||
|
const secondCall = listToolCalls(secondRun.runId)[0]
|
||||||
|
expect(secondCall?.resourceId).toBe('tool.web.read')
|
||||||
|
expect(secondCall?.reasons.some((reason) => reason.startsWith('fallback_low_trust:'))).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to heuristic planner when model env is missing', async () => {
|
||||||
|
const previousApiKey = process.env.AGENT_OS_MODEL_API_KEY
|
||||||
|
const previousBigmodelApiKey = process.env.BIGMODEL_API_KEY
|
||||||
|
const previousZhipuApiKey = process.env.ZHIPU_API_KEY
|
||||||
|
delete process.env.AGENT_OS_MODEL_API_KEY
|
||||||
|
delete process.env.BIGMODEL_API_KEY
|
||||||
|
delete process.env.ZHIPU_API_KEY
|
||||||
|
|
||||||
|
const runResult = await orchestrateGoal({
|
||||||
|
goal: 'Need rag retrieval for agent projects',
|
||||||
|
createdBy: 'test-user',
|
||||||
|
})
|
||||||
|
|
||||||
|
const run = getRun(runResult.runId)
|
||||||
|
expect(run?.planning?.source).toBe('heuristic')
|
||||||
|
expect(run?.planning?.warning).toContain('fallback')
|
||||||
|
|
||||||
|
if (previousApiKey) {
|
||||||
|
process.env.AGENT_OS_MODEL_API_KEY = previousApiKey
|
||||||
|
}
|
||||||
|
if (previousBigmodelApiKey) {
|
||||||
|
process.env.BIGMODEL_API_KEY = previousBigmodelApiKey
|
||||||
|
}
|
||||||
|
if (previousZhipuApiKey) {
|
||||||
|
process.env.ZHIPU_API_KEY = previousZhipuApiKey
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,671 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { callAgentModelForJson, getAgentModelConfig } from './model-client'
|
||||||
|
import { evaluatePolicy } from './policy-engine'
|
||||||
|
import {
|
||||||
|
getAgentProfileById,
|
||||||
|
getResourceManifestById,
|
||||||
|
listAgentProfiles,
|
||||||
|
listResourceManifests,
|
||||||
|
} from './resource-pool'
|
||||||
|
import {
|
||||||
|
addMemoryEntry,
|
||||||
|
addTask,
|
||||||
|
addToolCall,
|
||||||
|
createPolicyProposal,
|
||||||
|
createRun,
|
||||||
|
setRunPendingApprovals,
|
||||||
|
updateRunStatus,
|
||||||
|
} from './store'
|
||||||
|
import type {
|
||||||
|
AgentManifest,
|
||||||
|
AgentRun,
|
||||||
|
CapabilityId,
|
||||||
|
CapabilityIntent,
|
||||||
|
PolicyEffect,
|
||||||
|
ResourceManifest,
|
||||||
|
} from './types'
|
||||||
|
import { CAPABILITY_IDS } from './types'
|
||||||
|
|
||||||
|
const CAPABILITY_ALIAS_MAP: Record<string, CapabilityId> = {
|
||||||
|
web_read: 'web_read',
|
||||||
|
read_web: 'web_read',
|
||||||
|
code_review: 'code_review',
|
||||||
|
code_check: 'code_review',
|
||||||
|
log_review: 'log_review',
|
||||||
|
log_analysis: 'log_review',
|
||||||
|
knowledge_retrieve: 'knowledge_retrieve',
|
||||||
|
retrieve: 'knowledge_retrieve',
|
||||||
|
knowledge_write: 'knowledge_write',
|
||||||
|
ingest_knowledge: 'knowledge_write',
|
||||||
|
task_spawn: 'task_spawn',
|
||||||
|
spawn_task: 'task_spawn',
|
||||||
|
discovery_trending: 'discovery_trending',
|
||||||
|
search_trends: 'discovery_trending',
|
||||||
|
discovery_topic: 'discovery_topic',
|
||||||
|
topic_discovery: 'discovery_topic',
|
||||||
|
signal_aggregation: 'signal_aggregation',
|
||||||
|
signal_filter: 'signal_aggregation',
|
||||||
|
rag_search: 'rag_search',
|
||||||
|
rag_retrieve: 'rag_search',
|
||||||
|
embedding_refresh: 'embedding_refresh',
|
||||||
|
vectorize: 'embedding_refresh',
|
||||||
|
github_metrics_refresh: 'github_metrics_refresh',
|
||||||
|
metrics_refresh: 'github_metrics_refresh',
|
||||||
|
project_ingest: 'project_ingest',
|
||||||
|
ingest_project: 'project_ingest',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlannerIntentRawSchema = z.object({
|
||||||
|
capability: z.string().optional(),
|
||||||
|
intent: z.string().optional(),
|
||||||
|
reason: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
context: z.record(z.unknown()).optional(),
|
||||||
|
parameters: z.record(z.unknown()).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
function toCapabilityId(value: string | undefined): CapabilityId | null {
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const normalized = normalizeText(value).replace(/\s+/g, '_')
|
||||||
|
return CAPABILITY_ALIAS_MAP[normalized] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlannerIntents(intents: z.infer<typeof PlannerIntentRawSchema>[]): CapabilityIntent[] {
|
||||||
|
const normalized: CapabilityIntent[] = []
|
||||||
|
|
||||||
|
intents.forEach((item) => {
|
||||||
|
const capability = toCapabilityId(item.capability || item.intent)
|
||||||
|
if (!capability) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized.push({
|
||||||
|
capability,
|
||||||
|
reason: item.reason || item.description || `Planned by model for ${capability}.`,
|
||||||
|
context: item.context || item.parameters,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlannerResponseSchema = z
|
||||||
|
.object({
|
||||||
|
intents: z.array(PlannerIntentRawSchema).min(1).max(12).optional(),
|
||||||
|
plan: z.array(PlannerIntentRawSchema).min(1).max(12).optional(),
|
||||||
|
summary: z.string().min(8).max(1200).optional(),
|
||||||
|
suggestedAgentId: z.string().max(200).optional(),
|
||||||
|
riskNotes: z.union([z.string().min(1).max(1000), z.array(z.string().min(1).max(300)).max(10)]).optional(),
|
||||||
|
answer: z.string().max(1200).optional(),
|
||||||
|
})
|
||||||
|
.superRefine((value, ctx) => {
|
||||||
|
if (!value.intents && !value.plan) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Missing intents/plan field',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.transform((value, ctx) => {
|
||||||
|
const normalizedIntents = normalizePlannerIntents(value.intents || value.plan || [])
|
||||||
|
if (normalizedIntents.length === 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'No valid capability intents found after normalization.',
|
||||||
|
})
|
||||||
|
return z.NEVER
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
intents: normalizedIntents,
|
||||||
|
summary: value.summary || value.answer,
|
||||||
|
suggestedAgentId: value.suggestedAgentId && value.suggestedAgentId.trim().length > 0
|
||||||
|
? value.suggestedAgentId
|
||||||
|
: undefined,
|
||||||
|
riskNotes: Array.isArray(value.riskNotes)
|
||||||
|
? value.riskNotes
|
||||||
|
: value.riskNotes
|
||||||
|
? [value.riskNotes]
|
||||||
|
: [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const INTENT_TO_RESOURCE_MAP: Record<CapabilityId, string[]> = {
|
||||||
|
web_read: ['mcp.agent_browser.read', 'tool.web.read', 'tool.web.search'],
|
||||||
|
code_review: ['tool.lsp.query', 'tool.fs.grep', 'tool.git.status_diff', 'tool.lint.run'],
|
||||||
|
log_review: ['tool.logs.query', 'tool.logs.trace'],
|
||||||
|
knowledge_retrieve: ['tool.knowledge.retrieve', 'tool.memory.read', 'tool.db.query_ro'],
|
||||||
|
knowledge_write: ['tool.knowledge.ingest_proposal', 'tool.memory.write_proposal'],
|
||||||
|
task_spawn: ['tool.task.spawn'],
|
||||||
|
discovery_trending: [
|
||||||
|
'skill.discovery.trending.daily-plan',
|
||||||
|
'mcp.agent_browser.read',
|
||||||
|
'tool.web.search',
|
||||||
|
],
|
||||||
|
discovery_topic: ['skill.discovery.topic.daily-plan', 'mcp.agent_browser.read', 'tool.web.search'],
|
||||||
|
signal_aggregation: [
|
||||||
|
'skill.discovery.signal.multi-source',
|
||||||
|
'mcp.agent_browser.read',
|
||||||
|
'tool.web.search',
|
||||||
|
],
|
||||||
|
rag_search: ['skill.search.rag.similarity', 'tool.knowledge.retrieve', 'tool.db.query_ro'],
|
||||||
|
embedding_refresh: [
|
||||||
|
'skill.knowledge.embedding.refresh',
|
||||||
|
'tool.knowledge.ingest_proposal',
|
||||||
|
'tool.db.write_staging',
|
||||||
|
],
|
||||||
|
github_metrics_refresh: ['skill.metrics.github-stars.refresh', 'tool.db.write_staging'],
|
||||||
|
project_ingest: [
|
||||||
|
'skill.ingest.project.multi-source',
|
||||||
|
'tool.db.write_staging',
|
||||||
|
'tool.knowledge.ingest_proposal',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOW_PRIORITY: Record<PolicyEffect, number> = {
|
||||||
|
allow: 3,
|
||||||
|
ask: 2,
|
||||||
|
deny: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value: string): string {
|
||||||
|
return value.toLowerCase().trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveCapabilityIntentsHeuristic(goal: string): CapabilityIntent[] {
|
||||||
|
const normalized = normalizeText(goal)
|
||||||
|
const intents: CapabilityIntent[] = []
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes('网页') ||
|
||||||
|
normalized.includes('website') ||
|
||||||
|
normalized.includes('web') ||
|
||||||
|
normalized.includes('readme')
|
||||||
|
) {
|
||||||
|
intents.push({ capability: 'web_read', reason: 'Goal mentions website exploration.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes('trend') ||
|
||||||
|
normalized.includes('trending') ||
|
||||||
|
normalized.includes('daily github') ||
|
||||||
|
normalized.includes('每日 github')
|
||||||
|
) {
|
||||||
|
intents.push({
|
||||||
|
capability: 'discovery_trending',
|
||||||
|
reason: 'Goal maps to daily trending discovery workflow.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes('topic') || normalized.includes('话题')) {
|
||||||
|
intents.push({
|
||||||
|
capability: 'discovery_topic',
|
||||||
|
reason: 'Goal maps to topic-based discovery workflow.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes('signal') ||
|
||||||
|
normalized.includes('前沿') ||
|
||||||
|
normalized.includes('聚合') ||
|
||||||
|
normalized.includes('hot topic')
|
||||||
|
) {
|
||||||
|
intents.push({
|
||||||
|
capability: 'signal_aggregation',
|
||||||
|
reason: 'Goal maps to multi-source signal aggregation.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes('代码') ||
|
||||||
|
normalized.includes('review') ||
|
||||||
|
normalized.includes('审查') ||
|
||||||
|
normalized.includes('lint')
|
||||||
|
) {
|
||||||
|
intents.push({ capability: 'code_review', reason: 'Goal mentions code quality checks.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes('日志') || normalized.includes('log') || normalized.includes('trace')) {
|
||||||
|
intents.push({ capability: 'log_review', reason: 'Goal requires log or trace analysis.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes('知识') ||
|
||||||
|
normalized.includes('rag') ||
|
||||||
|
normalized.includes('memory') ||
|
||||||
|
normalized.includes('knowledge')
|
||||||
|
) {
|
||||||
|
intents.push({
|
||||||
|
capability: normalized.includes('rag') ? 'rag_search' : 'knowledge_retrieve',
|
||||||
|
reason: 'Goal references retrieval from knowledge memory layers.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes('向量') ||
|
||||||
|
normalized.includes('embedding') ||
|
||||||
|
normalized.includes('vectorize')
|
||||||
|
) {
|
||||||
|
intents.push({
|
||||||
|
capability: 'embedding_refresh',
|
||||||
|
reason: 'Goal references vector refresh workflow.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes('star') || normalized.includes('metrics') || normalized.includes('指标')) {
|
||||||
|
intents.push({
|
||||||
|
capability: 'github_metrics_refresh',
|
||||||
|
reason: 'Goal references repository metric refresh.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes('写入') ||
|
||||||
|
normalized.includes('ingest') ||
|
||||||
|
normalized.includes('入库') ||
|
||||||
|
normalized.includes('内化')
|
||||||
|
) {
|
||||||
|
intents.push({
|
||||||
|
capability: normalized.includes('入库') ? 'project_ingest' : 'knowledge_write',
|
||||||
|
reason: 'Goal references write-oriented operations.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intents.length === 0) {
|
||||||
|
intents.push({
|
||||||
|
capability: 'knowledge_retrieve',
|
||||||
|
reason: 'Fallback intent for generic orchestration tasks.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const unique = new Map<CapabilityId, CapabilityIntent>()
|
||||||
|
intents.forEach((intent) => {
|
||||||
|
if (!unique.has(intent.capability)) {
|
||||||
|
unique.set(intent.capability, intent)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return Array.from(unique.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deriveCapabilityIntents(goal: string): Promise<{
|
||||||
|
intents: CapabilityIntent[]
|
||||||
|
planning: AgentRun['planning']
|
||||||
|
summary?: string
|
||||||
|
suggestedAgentId?: string
|
||||||
|
riskNotes: string[]
|
||||||
|
}> {
|
||||||
|
const config = getAgentModelConfig()
|
||||||
|
if (!config) {
|
||||||
|
return {
|
||||||
|
intents: deriveCapabilityIntentsHeuristic(goal),
|
||||||
|
planning: {
|
||||||
|
source: 'heuristic',
|
||||||
|
warning: 'Model config missing; fallback to heuristic planner.',
|
||||||
|
},
|
||||||
|
riskNotes: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const capabilityCatalog = CAPABILITY_IDS.join(', ')
|
||||||
|
const modelResult = await callAgentModelForJson({
|
||||||
|
config,
|
||||||
|
temperature: 0.1,
|
||||||
|
schema: PlannerResponseSchema,
|
||||||
|
systemPrompt: [
|
||||||
|
'You are the Agent Park Gateway planner.',
|
||||||
|
'Output strictly JSON with this structure: {"intents":[{"capability":"...","reason":"...","context":{}}],"summary":"...","riskNotes":["..."],"suggestedAgentId":"optional"}',
|
||||||
|
`Allowed capabilities: ${capabilityCatalog}.`,
|
||||||
|
'Prefer n8n-backed discovery and ingestion capabilities when relevant.',
|
||||||
|
'Always keep production write/deploy risks explicit in riskNotes.',
|
||||||
|
'Never use keys other than capability/reason/context inside intents.',
|
||||||
|
].join(' '),
|
||||||
|
userPrompt: [
|
||||||
|
'Convert this goal into capability intents for execution.',
|
||||||
|
'Goal:',
|
||||||
|
goal,
|
||||||
|
'Return concise reasons and include suggestedAgentId only if strong confidence.',
|
||||||
|
].join('\n'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
intents: modelResult.data.intents,
|
||||||
|
summary: modelResult.data.summary,
|
||||||
|
suggestedAgentId: modelResult.data.suggestedAgentId,
|
||||||
|
riskNotes: modelResult.data.riskNotes || [],
|
||||||
|
planning: {
|
||||||
|
source: 'model',
|
||||||
|
model: modelResult.model,
|
||||||
|
latencyMs: modelResult.latencyMs,
|
||||||
|
promptTokens: modelResult.promptTokens,
|
||||||
|
completionTokens: modelResult.completionTokens,
|
||||||
|
totalTokens: modelResult.totalTokens,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
intents: deriveCapabilityIntentsHeuristic(goal),
|
||||||
|
planning: {
|
||||||
|
source: 'heuristic',
|
||||||
|
model: config.model,
|
||||||
|
warning:
|
||||||
|
error instanceof Error
|
||||||
|
? `Model planner failed, fallback to heuristic: ${error.message}`
|
||||||
|
: 'Model planner failed, fallback to heuristic.',
|
||||||
|
},
|
||||||
|
riskNotes: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickAgentByGoal(input: {
|
||||||
|
goal: string
|
||||||
|
intents: CapabilityIntent[]
|
||||||
|
preferredAgentId?: string
|
||||||
|
suggestedAgentId?: string
|
||||||
|
}): AgentManifest {
|
||||||
|
const configured = listAgentProfiles()
|
||||||
|
const byId = (id: string | undefined) =>
|
||||||
|
(id && configured.find((agent) => agent.id === id)) || null
|
||||||
|
|
||||||
|
const preferred = byId(input.preferredAgentId)
|
||||||
|
if (preferred) {
|
||||||
|
return preferred
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggested = byId(input.suggestedAgentId)
|
||||||
|
if (suggested) {
|
||||||
|
return suggested
|
||||||
|
}
|
||||||
|
|
||||||
|
const intentSet = new Set(input.intents.map((intent) => intent.capability))
|
||||||
|
if (
|
||||||
|
intentSet.has('discovery_trending') ||
|
||||||
|
intentSet.has('discovery_topic') ||
|
||||||
|
intentSet.has('signal_aggregation')
|
||||||
|
) {
|
||||||
|
return configured.find((agent) => agent.id === 'agent.discovery.scout') || configured[0]!
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intentSet.has('code_review') || intentSet.has('log_review')) {
|
||||||
|
return configured.find((agent) => agent.id === 'agent.quality.reviewer') || configured[0]!
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
intentSet.has('knowledge_retrieve') ||
|
||||||
|
intentSet.has('knowledge_write') ||
|
||||||
|
intentSet.has('embedding_refresh') ||
|
||||||
|
intentSet.has('rag_search')
|
||||||
|
) {
|
||||||
|
return configured.find((agent) => agent.id === 'agent.knowledge.librarian') || configured[0]!
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeText(input.goal)
|
||||||
|
if (normalized.includes('discover') || normalized.includes('发现')) {
|
||||||
|
return configured.find((agent) => agent.id === 'agent.discovery.scout') || configured[0]!
|
||||||
|
}
|
||||||
|
|
||||||
|
return configured.find((agent) => agent.id === 'agent.orchestrator.core') || configured[0]!
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractIntentCommand(intent: CapabilityIntent): string | undefined {
|
||||||
|
const value = intent.context?.command
|
||||||
|
return typeof value === 'string' ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractIntentPaths(intent: CapabilityIntent): string[] | undefined {
|
||||||
|
const raw = intent.context?.paths
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return raw.filter((value): value is string => typeof value === 'string')
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectResource(
|
||||||
|
intent: CapabilityIntent,
|
||||||
|
agent: AgentManifest,
|
||||||
|
repeatedCallCount: number
|
||||||
|
): {
|
||||||
|
selected?: ResourceManifest
|
||||||
|
effect: PolicyEffect
|
||||||
|
reasons: string[]
|
||||||
|
candidates: string[]
|
||||||
|
} {
|
||||||
|
const preferredCandidates = INTENT_TO_RESOURCE_MAP[intent.capability] || []
|
||||||
|
const allResources = listResourceManifests()
|
||||||
|
|
||||||
|
const candidates = preferredCandidates
|
||||||
|
.map((resourceId) => getResourceManifestById(resourceId))
|
||||||
|
.filter((resource): resource is ResourceManifest => Boolean(resource))
|
||||||
|
.filter((resource) => resource.status === 'active')
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
const taggedFallback = allResources.filter(
|
||||||
|
(resource) =>
|
||||||
|
resource.status === 'active' &&
|
||||||
|
resource.tags.some((tag) => agent.enabledResourceTags.includes(tag))
|
||||||
|
)
|
||||||
|
|
||||||
|
const fallbackCandidate = taggedFallback[0]
|
||||||
|
if (!fallbackCandidate) {
|
||||||
|
return {
|
||||||
|
effect: 'deny',
|
||||||
|
reasons: ['no_candidates_for_intent'],
|
||||||
|
candidates: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackDecision = evaluatePolicy(agent, fallbackCandidate, {
|
||||||
|
workspaceRoot: process.cwd(),
|
||||||
|
repeatedCallCount,
|
||||||
|
command: extractIntentCommand(intent),
|
||||||
|
paths: extractIntentPaths(intent),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
selected: fallbackCandidate,
|
||||||
|
effect: fallbackDecision.effect,
|
||||||
|
reasons: [...fallbackDecision.reasons, 'fallback_by_tag_scope'],
|
||||||
|
candidates: taggedFallback.map((resource) => resource.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let selected: ResourceManifest | undefined
|
||||||
|
let selectedEffect: PolicyEffect = 'deny'
|
||||||
|
let selectedReasons: string[] = ['no_allowed_candidate']
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const decision = evaluatePolicy(agent, candidate, {
|
||||||
|
workspaceRoot: process.cwd(),
|
||||||
|
repeatedCallCount,
|
||||||
|
command: extractIntentCommand(intent),
|
||||||
|
paths: extractIntentPaths(intent),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (
|
||||||
|
!selected ||
|
||||||
|
ALLOW_PRIORITY[decision.effect] > ALLOW_PRIORITY[selectedEffect] ||
|
||||||
|
(ALLOW_PRIORITY[decision.effect] === ALLOW_PRIORITY[selectedEffect] &&
|
||||||
|
candidate.riskLevel < (selected?.riskLevel || 'L3'))
|
||||||
|
) {
|
||||||
|
selected = candidate
|
||||||
|
selectedEffect = decision.effect
|
||||||
|
selectedReasons = decision.reasons
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decision.effect === 'allow') {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent.capability === 'web_read' && selected && selected.id !== candidates[0]?.id) {
|
||||||
|
selectedReasons = [...selectedReasons, `fallback_low_trust:${selected.id}`]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
selected,
|
||||||
|
effect: selectedEffect,
|
||||||
|
reasons: selectedReasons,
|
||||||
|
candidates: candidates.map((resource) => resource.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldCreateApprovalTicket(selection: {
|
||||||
|
effect: PolicyEffect
|
||||||
|
selected?: ResourceManifest
|
||||||
|
}): boolean {
|
||||||
|
if (selection.effect === 'ask') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return selection.effect === 'deny' && selection.selected?.riskLevel === 'L3'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function orchestrateGoal(input: {
|
||||||
|
goal: string
|
||||||
|
createdBy: string
|
||||||
|
preferredAgentId?: string
|
||||||
|
}) {
|
||||||
|
const derived = await deriveCapabilityIntents(input.goal)
|
||||||
|
const agent = pickAgentByGoal({
|
||||||
|
goal: input.goal,
|
||||||
|
intents: derived.intents,
|
||||||
|
preferredAgentId: input.preferredAgentId,
|
||||||
|
suggestedAgentId: derived.suggestedAgentId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const run = createRun({
|
||||||
|
goal: input.goal,
|
||||||
|
createdBy: input.createdBy,
|
||||||
|
agentId: agent.id,
|
||||||
|
status: 'PLAN_COMPILED',
|
||||||
|
notes: ['Run created by Agent Gateway orchestrator.'],
|
||||||
|
planning: derived.planning,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (derived.summary) {
|
||||||
|
updateRunStatus(run.id, 'PLAN_COMPILED', `Planner summary: ${derived.summary}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (derived.riskNotes.length > 0) {
|
||||||
|
updateRunStatus(run.id, 'PLAN_COMPILED', `Planner risk notes: ${derived.riskNotes.join(' | ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let pendingApprovals = 0
|
||||||
|
let hasFailure = false
|
||||||
|
const approvalTicketIds: string[] = []
|
||||||
|
|
||||||
|
derived.intents.forEach((intent, index) => {
|
||||||
|
const selected = selectResource(intent, agent, index)
|
||||||
|
const requiresApproval = shouldCreateApprovalTicket(selected)
|
||||||
|
|
||||||
|
let taskStatus: 'queued' | 'running' | 'completed' | 'failed' | 'blocked_approval' | 'cancelled'
|
||||||
|
if (requiresApproval) {
|
||||||
|
taskStatus = 'blocked_approval'
|
||||||
|
} else if (selected.effect === 'deny') {
|
||||||
|
taskStatus = 'failed'
|
||||||
|
} else {
|
||||||
|
taskStatus = 'completed'
|
||||||
|
}
|
||||||
|
|
||||||
|
let taskMessage = selected.selected
|
||||||
|
? `Selected ${selected.selected.id} for ${intent.capability}`
|
||||||
|
: `No resource selected for ${intent.capability}`
|
||||||
|
|
||||||
|
if (requiresApproval) {
|
||||||
|
const proposal = createPolicyProposal({
|
||||||
|
title: `Run override request for ${selected.selected?.id || 'unknown-resource'}`,
|
||||||
|
description: [
|
||||||
|
`Run ${run.id} requires approval for ${intent.capability}.`,
|
||||||
|
`Requested resource: ${selected.selected?.id || 'none'}.`,
|
||||||
|
`Policy decision: ${selected.effect}.`,
|
||||||
|
].join(' '),
|
||||||
|
targetType: 'resource',
|
||||||
|
targetId: selected.selected?.id || 'resource.unknown',
|
||||||
|
changes: {
|
||||||
|
runtimeRunId: run.id,
|
||||||
|
intent: intent.capability,
|
||||||
|
reasons: selected.reasons,
|
||||||
|
},
|
||||||
|
createdBy: input.createdBy,
|
||||||
|
})
|
||||||
|
pendingApprovals += 1
|
||||||
|
approvalTicketIds.push(proposal.approvalTicket.id)
|
||||||
|
taskMessage += ` (approvalTicket=${proposal.approvalTicket.id})`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.effect === 'deny' && !requiresApproval) {
|
||||||
|
hasFailure = true
|
||||||
|
taskMessage += ' (hard denied by policy)'
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = addTask(run.id, {
|
||||||
|
intent,
|
||||||
|
status: taskStatus,
|
||||||
|
selectedResourceId: selected.selected?.id,
|
||||||
|
policyEffect: selected.effect,
|
||||||
|
message: taskMessage,
|
||||||
|
})
|
||||||
|
|
||||||
|
addToolCall(run.id, {
|
||||||
|
taskId: task.id,
|
||||||
|
agentId: agent.id,
|
||||||
|
resourceId: selected.selected?.id || 'none',
|
||||||
|
policyEffect: selected.effect,
|
||||||
|
success: selected.effect !== 'deny',
|
||||||
|
inputSummary: `intent=${intent.capability}`,
|
||||||
|
outputSummary: selected.selected ? `resource=${selected.selected.id}` : 'selection_failed',
|
||||||
|
reasons: selected.reasons,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
setRunPendingApprovals(run.id, pendingApprovals)
|
||||||
|
|
||||||
|
if (pendingApprovals > 0) {
|
||||||
|
updateRunStatus(
|
||||||
|
run.id,
|
||||||
|
'APPROVAL_WAITING',
|
||||||
|
`Run paused for ${pendingApprovals} approval-required action(s).`
|
||||||
|
)
|
||||||
|
} else if (hasFailure) {
|
||||||
|
updateRunStatus(run.id, 'FAILED', 'Run failed due to policy-denied actions.')
|
||||||
|
} else {
|
||||||
|
updateRunStatus(run.id, 'COMPLETED', 'Run completed with policy-safe selections.')
|
||||||
|
}
|
||||||
|
|
||||||
|
addMemoryEntry({
|
||||||
|
layer: 'working',
|
||||||
|
key: `run:${run.id}:goal`,
|
||||||
|
value: input.goal,
|
||||||
|
evidence: `createdBy=${input.createdBy}`,
|
||||||
|
createdBy: input.createdBy,
|
||||||
|
})
|
||||||
|
|
||||||
|
addMemoryEntry({
|
||||||
|
layer: 'project',
|
||||||
|
key: 'agent_os.last_goal',
|
||||||
|
value: input.goal,
|
||||||
|
evidence: `runId=${run.id}`,
|
||||||
|
createdBy: input.createdBy,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (derived.summary) {
|
||||||
|
addMemoryEntry({
|
||||||
|
layer: 'knowledge',
|
||||||
|
key: `run:${run.id}:planner_summary`,
|
||||||
|
value: derived.summary,
|
||||||
|
evidence: `agent=${agent.id}`,
|
||||||
|
createdBy: input.createdBy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
runId: run.id,
|
||||||
|
agent,
|
||||||
|
intents: derived.intents,
|
||||||
|
pendingApprovals,
|
||||||
|
planning: derived.planning,
|
||||||
|
approvalTicketIds,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { evaluatePolicy } from './policy-engine'
|
||||||
|
import { getAgentProfileById, getResourceManifestById } from './resource-pool'
|
||||||
|
|
||||||
|
function mustGetAgent(agentId: string) {
|
||||||
|
const agent = getAgentProfileById(agentId)
|
||||||
|
if (!agent) {
|
||||||
|
throw new Error(`Missing agent profile: ${agentId}`)
|
||||||
|
}
|
||||||
|
return agent
|
||||||
|
}
|
||||||
|
|
||||||
|
function mustGetResource(resourceId: string) {
|
||||||
|
const resource = getResourceManifestById(resourceId)
|
||||||
|
if (!resource) {
|
||||||
|
throw new Error(`Missing resource manifest: ${resourceId}`)
|
||||||
|
}
|
||||||
|
return resource
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('agent-os policy engine', () => {
|
||||||
|
it('denies git push command by command rule', () => {
|
||||||
|
const agent = mustGetAgent('agent.orchestrator.core')
|
||||||
|
const resource = mustGetResource('tool.shell.exec')
|
||||||
|
|
||||||
|
const result = evaluatePolicy(agent, resource, { command: 'git push origin main' })
|
||||||
|
|
||||||
|
expect(result.effect).toBe('deny')
|
||||||
|
expect(result.reasons.some((reason) => reason.includes('command_rule'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('denies .env reads by path rule', () => {
|
||||||
|
const agent = mustGetAgent('agent.orchestrator.core')
|
||||||
|
const resource = mustGetResource('tool.fs.read')
|
||||||
|
|
||||||
|
const result = evaluatePolicy(agent, resource, { paths: ['/tmp/.env.local'] })
|
||||||
|
|
||||||
|
expect(result.effect).toBe('deny')
|
||||||
|
expect(result.reasons.some((reason) => reason.includes('path_rule'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('degrades repeated calls via doom loop guard', () => {
|
||||||
|
const agent = mustGetAgent('agent.discovery.scout')
|
||||||
|
const resource = mustGetResource('tool.web.read')
|
||||||
|
|
||||||
|
const result = evaluatePolicy(agent, resource, { repeatedCallCount: 5 })
|
||||||
|
|
||||||
|
expect(result.effect).toBe('deny')
|
||||||
|
expect(result.reasons.some((reason) => reason.includes('doom_loop'))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import type { AgentManifest, PolicyDecision, PolicyEffect, ResourceManifest } from './types'
|
||||||
|
|
||||||
|
interface PolicyContext {
|
||||||
|
command?: string
|
||||||
|
paths?: string[]
|
||||||
|
workspaceRoot?: string
|
||||||
|
repeatedCallCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVERITY: Record<PolicyEffect, number> = {
|
||||||
|
allow: 0,
|
||||||
|
ask: 1,
|
||||||
|
deny: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseMoreStrict(current: PolicyEffect, next: PolicyEffect): PolicyEffect {
|
||||||
|
return SEVERITY[next] > SEVERITY[current] ? next : current
|
||||||
|
}
|
||||||
|
|
||||||
|
function wildcardToRegex(pattern: string): RegExp {
|
||||||
|
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
const wildcardReplaced = escaped.replace(/\*/g, '.*')
|
||||||
|
return new RegExp(`^${wildcardReplaced}$`, 'i')
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesPattern(value: string, pattern: string): boolean {
|
||||||
|
return wildcardToRegex(pattern).test(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateCommandRules(
|
||||||
|
agent: AgentManifest,
|
||||||
|
resource: ResourceManifest,
|
||||||
|
command: string | undefined
|
||||||
|
): PolicyDecision {
|
||||||
|
const reasons: string[] = []
|
||||||
|
let effect: PolicyEffect = resource.defaultPolicy
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
return { effect, reasons }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rule of agent.commandRules) {
|
||||||
|
if (rule.resourceId !== resource.id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (matchesPattern(command, rule.pattern)) {
|
||||||
|
effect = chooseMoreStrict(effect, rule.effect)
|
||||||
|
reasons.push(`command_rule:${rule.pattern}->${rule.effect}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { effect, reasons }
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluatePathRules(
|
||||||
|
agent: AgentManifest,
|
||||||
|
resource: ResourceManifest,
|
||||||
|
paths: string[] | undefined
|
||||||
|
): PolicyDecision {
|
||||||
|
const reasons: string[] = []
|
||||||
|
let effect: PolicyEffect = resource.defaultPolicy
|
||||||
|
|
||||||
|
if (!paths || paths.length === 0) {
|
||||||
|
return { effect, reasons }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const inputPath of paths) {
|
||||||
|
const baseName = path.basename(inputPath)
|
||||||
|
for (const rule of agent.pathRules) {
|
||||||
|
if (rule.resourceId !== resource.id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (matchesPattern(baseName, rule.pattern)) {
|
||||||
|
effect = chooseMoreStrict(effect, rule.effect)
|
||||||
|
reasons.push(`path_rule:${rule.pattern}->${rule.effect}:${baseName}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { effect, reasons }
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateExternalDirectory(
|
||||||
|
resource: ResourceManifest,
|
||||||
|
ctx: PolicyContext
|
||||||
|
): PolicyDecision {
|
||||||
|
if (!ctx.workspaceRoot || !ctx.paths || ctx.paths.length === 0) {
|
||||||
|
return { effect: resource.defaultPolicy, reasons: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
let effect: PolicyEffect = resource.defaultPolicy
|
||||||
|
const reasons: string[] = []
|
||||||
|
|
||||||
|
for (const inputPath of ctx.paths) {
|
||||||
|
const resolvedPath = path.resolve(inputPath)
|
||||||
|
const relative = path.relative(ctx.workspaceRoot, resolvedPath)
|
||||||
|
const outsideWorkspace = relative.startsWith('..') || path.isAbsolute(relative)
|
||||||
|
|
||||||
|
if (outsideWorkspace) {
|
||||||
|
effect = chooseMoreStrict(effect, 'ask')
|
||||||
|
reasons.push(`external_directory:${resolvedPath}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { effect, reasons }
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateDoomLoop(
|
||||||
|
resource: ResourceManifest,
|
||||||
|
repeatedCallCount: number | undefined
|
||||||
|
): PolicyDecision {
|
||||||
|
if (!repeatedCallCount || repeatedCallCount < 3) {
|
||||||
|
return { effect: resource.defaultPolicy, reasons: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasons = [`doom_loop:repeat_count=${repeatedCallCount}`]
|
||||||
|
if (repeatedCallCount >= 5) {
|
||||||
|
return { effect: 'deny', reasons }
|
||||||
|
}
|
||||||
|
return { effect: 'ask', reasons }
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateAgentOverrides(agent: AgentManifest, resource: ResourceManifest): PolicyDecision {
|
||||||
|
const override = agent.policyOverrides[resource.id]
|
||||||
|
if (!override) {
|
||||||
|
return { effect: resource.defaultPolicy, reasons: [] }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
effect: chooseMoreStrict(resource.defaultPolicy, override),
|
||||||
|
reasons: [`agent_override:${override}`],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluatePolicy(
|
||||||
|
agent: AgentManifest,
|
||||||
|
resource: ResourceManifest,
|
||||||
|
ctx: PolicyContext = {}
|
||||||
|
): PolicyDecision {
|
||||||
|
const reasons: string[] = []
|
||||||
|
let effect: PolicyEffect = resource.defaultPolicy
|
||||||
|
|
||||||
|
const overrideDecision = evaluateAgentOverrides(agent, resource)
|
||||||
|
effect = chooseMoreStrict(effect, overrideDecision.effect)
|
||||||
|
reasons.push(...overrideDecision.reasons)
|
||||||
|
|
||||||
|
const commandDecision = evaluateCommandRules(agent, resource, ctx.command)
|
||||||
|
effect = chooseMoreStrict(effect, commandDecision.effect)
|
||||||
|
reasons.push(...commandDecision.reasons)
|
||||||
|
|
||||||
|
const pathDecision = evaluatePathRules(agent, resource, ctx.paths)
|
||||||
|
effect = chooseMoreStrict(effect, pathDecision.effect)
|
||||||
|
reasons.push(...pathDecision.reasons)
|
||||||
|
|
||||||
|
const externalDirDecision = evaluateExternalDirectory(resource, ctx)
|
||||||
|
effect = chooseMoreStrict(effect, externalDirDecision.effect)
|
||||||
|
reasons.push(...externalDirDecision.reasons)
|
||||||
|
|
||||||
|
const doomLoopDecision = evaluateDoomLoop(resource, ctx.repeatedCallCount)
|
||||||
|
effect = chooseMoreStrict(effect, doomLoopDecision.effect)
|
||||||
|
reasons.push(...doomLoopDecision.reasons)
|
||||||
|
|
||||||
|
return {
|
||||||
|
effect,
|
||||||
|
reasons,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { getAgentProfileById } from './resource-pool'
|
||||||
|
import { createPolicyProposal, decideApproval, resetAgentOsStateForTests } from './store'
|
||||||
|
|
||||||
|
describe('agent-os runtime policy patch', () => {
|
||||||
|
it('applies approved proposal patch to agent profile', () => {
|
||||||
|
resetAgentOsStateForTests()
|
||||||
|
|
||||||
|
const before = getAgentProfileById('agent.discovery.scout')
|
||||||
|
expect(before?.policyOverrides['mcp.agent_browser.read']).toBe('allow')
|
||||||
|
|
||||||
|
const proposal = createPolicyProposal({
|
||||||
|
title: 'Deny MCP browser for scout',
|
||||||
|
description: 'Test runtime patch',
|
||||||
|
targetType: 'agent',
|
||||||
|
targetId: 'agent.discovery.scout',
|
||||||
|
createdBy: 'tester',
|
||||||
|
changes: {
|
||||||
|
policyOverrides: {
|
||||||
|
'mcp.agent_browser.read': 'deny',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
decideApproval({
|
||||||
|
approvalId: proposal.approvalTicket.id,
|
||||||
|
decision: 'approved',
|
||||||
|
decidedBy: 'approver',
|
||||||
|
comment: 'approved in test',
|
||||||
|
})
|
||||||
|
|
||||||
|
const after = getAgentProfileById('agent.discovery.scout')
|
||||||
|
expect(after?.policyOverrides['mcp.agent_browser.read']).toBe('deny')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import type { AgentManifest, PolicyEffect, PolicyProposal } from './types'
|
||||||
|
|
||||||
|
type RuntimeAgentPatch = {
|
||||||
|
policyOverrides?: Record<string, PolicyEffect>
|
||||||
|
commandRules?: AgentManifest['commandRules']
|
||||||
|
pathRules?: AgentManifest['pathRules']
|
||||||
|
enabledResourceTags?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const PolicyEffectSchema = z.enum(['allow', 'ask', 'deny'])
|
||||||
|
|
||||||
|
const RuntimePatchSchema = z
|
||||||
|
.object({
|
||||||
|
policyOverrides: z.record(PolicyEffectSchema).optional(),
|
||||||
|
commandRules: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
resourceId: z.string().min(1),
|
||||||
|
pattern: z.string().min(1),
|
||||||
|
effect: PolicyEffectSchema,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
pathRules: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
resourceId: z.string().min(1),
|
||||||
|
pattern: z.string().min(1),
|
||||||
|
effect: PolicyEffectSchema,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
enabledResourceTags: z.array(z.string().min(1)).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var __agentParkRuntimePolicyPatchByAgentId: Map<string, RuntimeAgentPatch> | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuntimePatchStore(): Map<string, RuntimeAgentPatch> {
|
||||||
|
if (!globalThis.__agentParkRuntimePolicyPatchByAgentId) {
|
||||||
|
globalThis.__agentParkRuntimePolicyPatchByAgentId = new Map<string, RuntimeAgentPatch>()
|
||||||
|
}
|
||||||
|
return globalThis.__agentParkRuntimePolicyPatchByAgentId
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergePatch(base: RuntimeAgentPatch, incoming: RuntimeAgentPatch): RuntimeAgentPatch {
|
||||||
|
return {
|
||||||
|
policyOverrides: {
|
||||||
|
...(base.policyOverrides || {}),
|
||||||
|
...(incoming.policyOverrides || {}),
|
||||||
|
},
|
||||||
|
commandRules: incoming.commandRules || base.commandRules,
|
||||||
|
pathRules: incoming.pathRules || base.pathRules,
|
||||||
|
enabledResourceTags: incoming.enabledResourceTags || base.enabledResourceTags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyApprovedProposalPatch(proposal: PolicyProposal): void {
|
||||||
|
if (proposal.targetType !== 'agent') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = RuntimePatchSchema.safeParse(proposal.changes)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = getRuntimePatchStore()
|
||||||
|
const current = store.get(proposal.targetId) || {}
|
||||||
|
store.set(proposal.targetId, mergePatch(current, parsed.data))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRuntimePatchForAgent(agentId: string): RuntimeAgentPatch | undefined {
|
||||||
|
return getRuntimePatchStore().get(agentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeAgentWithRuntimePatch(agent: AgentManifest): AgentManifest {
|
||||||
|
const patch = getRuntimePatchForAgent(agent.id)
|
||||||
|
if (!patch) {
|
||||||
|
return {
|
||||||
|
...agent,
|
||||||
|
policyOverrides: { ...agent.policyOverrides },
|
||||||
|
commandRules: [...agent.commandRules],
|
||||||
|
pathRules: [...agent.pathRules],
|
||||||
|
enabledResourceTags: [...agent.enabledResourceTags],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...agent,
|
||||||
|
policyOverrides: {
|
||||||
|
...agent.policyOverrides,
|
||||||
|
...(patch.policyOverrides || {}),
|
||||||
|
},
|
||||||
|
commandRules: patch.commandRules ? [...patch.commandRules] : [...agent.commandRules],
|
||||||
|
pathRules: patch.pathRules ? [...patch.pathRules] : [...agent.pathRules],
|
||||||
|
enabledResourceTags: patch.enabledResourceTags
|
||||||
|
? [...patch.enabledResourceTags]
|
||||||
|
: [...agent.enabledResourceTags],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetRuntimePolicyPatchesForTests(): void {
|
||||||
|
globalThis.__agentParkRuntimePolicyPatchByAgentId = undefined
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,646 @@
|
|||||||
|
import { listN8nBackedSkillManifests, listN8nProductionWorkflowReferences } from './n8n-production-workflows'
|
||||||
|
import { mergeAgentWithRuntimePatch } from './policy-runtime'
|
||||||
|
import type {
|
||||||
|
AgentManifest,
|
||||||
|
PolicyEffect,
|
||||||
|
ResourceManifest,
|
||||||
|
ResourceKind,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
const BASE_RESOURCE_MANIFESTS: ResourceManifest[] = [
|
||||||
|
{
|
||||||
|
id: 'tool.fs.read',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/fs/read/input',
|
||||||
|
outputSchemaRef: 'schema://tools/fs/read/output',
|
||||||
|
riskLevel: 'L0',
|
||||||
|
defaultPolicy: 'allow',
|
||||||
|
tags: ['filesystem', 'read'],
|
||||||
|
latencyHintMs: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.fs.list',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/fs/list/input',
|
||||||
|
outputSchemaRef: 'schema://tools/fs/list/output',
|
||||||
|
riskLevel: 'L0',
|
||||||
|
defaultPolicy: 'allow',
|
||||||
|
tags: ['filesystem', 'read'],
|
||||||
|
latencyHintMs: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.fs.glob',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/fs/glob/input',
|
||||||
|
outputSchemaRef: 'schema://tools/fs/glob/output',
|
||||||
|
riskLevel: 'L0',
|
||||||
|
defaultPolicy: 'allow',
|
||||||
|
tags: ['filesystem', 'read'],
|
||||||
|
latencyHintMs: 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.fs.grep',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/fs/grep/input',
|
||||||
|
outputSchemaRef: 'schema://tools/fs/grep/output',
|
||||||
|
riskLevel: 'L0',
|
||||||
|
defaultPolicy: 'allow',
|
||||||
|
tags: ['filesystem', 'read'],
|
||||||
|
latencyHintMs: 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.fs.write',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/fs/write/input',
|
||||||
|
outputSchemaRef: 'schema://tools/fs/write/output',
|
||||||
|
riskLevel: 'L2',
|
||||||
|
defaultPolicy: 'ask',
|
||||||
|
tags: ['filesystem', 'write'],
|
||||||
|
latencyHintMs: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.fs.edit',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/fs/edit/input',
|
||||||
|
outputSchemaRef: 'schema://tools/fs/edit/output',
|
||||||
|
riskLevel: 'L2',
|
||||||
|
defaultPolicy: 'ask',
|
||||||
|
tags: ['filesystem', 'write'],
|
||||||
|
latencyHintMs: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.fs.patch',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/fs/patch/input',
|
||||||
|
outputSchemaRef: 'schema://tools/fs/patch/output',
|
||||||
|
riskLevel: 'L2',
|
||||||
|
defaultPolicy: 'ask',
|
||||||
|
tags: ['filesystem', 'write'],
|
||||||
|
latencyHintMs: 70,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.shell.exec',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/shell/exec/input',
|
||||||
|
outputSchemaRef: 'schema://tools/shell/exec/output',
|
||||||
|
riskLevel: 'L2',
|
||||||
|
defaultPolicy: 'ask',
|
||||||
|
tags: ['shell', 'execution'],
|
||||||
|
latencyHintMs: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.git.status_diff',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/git/status_diff/input',
|
||||||
|
outputSchemaRef: 'schema://tools/git/status_diff/output',
|
||||||
|
riskLevel: 'L0',
|
||||||
|
defaultPolicy: 'allow',
|
||||||
|
tags: ['git', 'read'],
|
||||||
|
latencyHintMs: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.git.branch_commit',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/git/branch_commit/input',
|
||||||
|
outputSchemaRef: 'schema://tools/git/branch_commit/output',
|
||||||
|
riskLevel: 'L2',
|
||||||
|
defaultPolicy: 'ask',
|
||||||
|
tags: ['git', 'write'],
|
||||||
|
latencyHintMs: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.git.push_pr',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/git/push_pr/input',
|
||||||
|
outputSchemaRef: 'schema://tools/git/push_pr/output',
|
||||||
|
riskLevel: 'L3',
|
||||||
|
defaultPolicy: 'deny',
|
||||||
|
tags: ['git', 'write', 'production'],
|
||||||
|
latencyHintMs: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.build.run',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/build/run/input',
|
||||||
|
outputSchemaRef: 'schema://tools/build/run/output',
|
||||||
|
riskLevel: 'L2',
|
||||||
|
defaultPolicy: 'ask',
|
||||||
|
tags: ['build'],
|
||||||
|
latencyHintMs: 3000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.test.run',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/test/run/input',
|
||||||
|
outputSchemaRef: 'schema://tools/test/run/output',
|
||||||
|
riskLevel: 'L2',
|
||||||
|
defaultPolicy: 'ask',
|
||||||
|
tags: ['test'],
|
||||||
|
latencyHintMs: 5000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.lint.run',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/lint/run/input',
|
||||||
|
outputSchemaRef: 'schema://tools/lint/run/output',
|
||||||
|
riskLevel: 'L1',
|
||||||
|
defaultPolicy: 'ask',
|
||||||
|
tags: ['lint'],
|
||||||
|
latencyHintMs: 2000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.lsp.query',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/lsp/query/input',
|
||||||
|
outputSchemaRef: 'schema://tools/lsp/query/output',
|
||||||
|
riskLevel: 'L0',
|
||||||
|
defaultPolicy: 'allow',
|
||||||
|
tags: ['code', 'analysis'],
|
||||||
|
latencyHintMs: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.web.read',
|
||||||
|
kind: 'mcp_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.browser',
|
||||||
|
inputSchemaRef: 'schema://tools/web/read/input',
|
||||||
|
outputSchemaRef: 'schema://tools/web/read/output',
|
||||||
|
riskLevel: 'L1',
|
||||||
|
defaultPolicy: 'allow',
|
||||||
|
tags: ['web', 'mcp', 'agent-browser'],
|
||||||
|
latencyHintMs: 400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.web.search',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/web/search/input',
|
||||||
|
outputSchemaRef: 'schema://tools/web/search/output',
|
||||||
|
riskLevel: 'L1',
|
||||||
|
defaultPolicy: 'ask',
|
||||||
|
tags: ['web', 'search'],
|
||||||
|
latencyHintMs: 400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.db.query_ro',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/db/query_ro/input',
|
||||||
|
outputSchemaRef: 'schema://tools/db/query_ro/output',
|
||||||
|
riskLevel: 'L1',
|
||||||
|
defaultPolicy: 'ask',
|
||||||
|
tags: ['database', 'read'],
|
||||||
|
latencyHintMs: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.db.write_staging',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/db/write_staging/input',
|
||||||
|
outputSchemaRef: 'schema://tools/db/write_staging/output',
|
||||||
|
riskLevel: 'L2',
|
||||||
|
defaultPolicy: 'ask',
|
||||||
|
tags: ['database', 'write'],
|
||||||
|
latencyHintMs: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.db.write_prod',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/db/write_prod/input',
|
||||||
|
outputSchemaRef: 'schema://tools/db/write_prod/output',
|
||||||
|
riskLevel: 'L3',
|
||||||
|
defaultPolicy: 'deny',
|
||||||
|
tags: ['database', 'write', 'production'],
|
||||||
|
latencyHintMs: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.logs.query',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/logs/query/input',
|
||||||
|
outputSchemaRef: 'schema://tools/logs/query/output',
|
||||||
|
riskLevel: 'L1',
|
||||||
|
defaultPolicy: 'ask',
|
||||||
|
tags: ['logs'],
|
||||||
|
latencyHintMs: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.logs.trace',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/logs/trace/input',
|
||||||
|
outputSchemaRef: 'schema://tools/logs/trace/output',
|
||||||
|
riskLevel: 'L1',
|
||||||
|
defaultPolicy: 'ask',
|
||||||
|
tags: ['logs', 'trace'],
|
||||||
|
latencyHintMs: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.ci.trigger',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/ci/trigger/input',
|
||||||
|
outputSchemaRef: 'schema://tools/ci/trigger/output',
|
||||||
|
riskLevel: 'L2',
|
||||||
|
defaultPolicy: 'ask',
|
||||||
|
tags: ['ci'],
|
||||||
|
latencyHintMs: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.deploy.preview',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/deploy/preview/input',
|
||||||
|
outputSchemaRef: 'schema://tools/deploy/preview/output',
|
||||||
|
riskLevel: 'L2',
|
||||||
|
defaultPolicy: 'ask',
|
||||||
|
tags: ['deploy'],
|
||||||
|
latencyHintMs: 500,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.deploy.prod',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/deploy/prod/input',
|
||||||
|
outputSchemaRef: 'schema://tools/deploy/prod/output',
|
||||||
|
riskLevel: 'L3',
|
||||||
|
defaultPolicy: 'deny',
|
||||||
|
tags: ['deploy', 'production'],
|
||||||
|
latencyHintMs: 500,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.skill.load',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/skill/load/input',
|
||||||
|
outputSchemaRef: 'schema://tools/skill/load/output',
|
||||||
|
riskLevel: 'L0',
|
||||||
|
defaultPolicy: 'allow',
|
||||||
|
tags: ['skill'],
|
||||||
|
latencyHintMs: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.task.spawn',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/task/spawn/input',
|
||||||
|
outputSchemaRef: 'schema://tools/task/spawn/output',
|
||||||
|
riskLevel: 'L1',
|
||||||
|
defaultPolicy: 'ask',
|
||||||
|
tags: ['task', 'agent'],
|
||||||
|
latencyHintMs: 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.memory.read',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/memory/read/input',
|
||||||
|
outputSchemaRef: 'schema://tools/memory/read/output',
|
||||||
|
riskLevel: 'L0',
|
||||||
|
defaultPolicy: 'allow',
|
||||||
|
tags: ['memory', 'read'],
|
||||||
|
latencyHintMs: 25,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.memory.write_proposal',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/memory/write_proposal/input',
|
||||||
|
outputSchemaRef: 'schema://tools/memory/write_proposal/output',
|
||||||
|
riskLevel: 'L1',
|
||||||
|
defaultPolicy: 'ask',
|
||||||
|
tags: ['memory', 'write'],
|
||||||
|
latencyHintMs: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.knowledge.retrieve',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/knowledge/retrieve/input',
|
||||||
|
outputSchemaRef: 'schema://tools/knowledge/retrieve/output',
|
||||||
|
riskLevel: 'L0',
|
||||||
|
defaultPolicy: 'allow',
|
||||||
|
tags: ['knowledge', 'read'],
|
||||||
|
latencyHintMs: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.knowledge.ingest_proposal',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/knowledge/ingest_proposal/input',
|
||||||
|
outputSchemaRef: 'schema://tools/knowledge/ingest_proposal/output',
|
||||||
|
riskLevel: 'L1',
|
||||||
|
defaultPolicy: 'ask',
|
||||||
|
tags: ['knowledge', 'write'],
|
||||||
|
latencyHintMs: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool.human.approval',
|
||||||
|
kind: 'base_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.os',
|
||||||
|
inputSchemaRef: 'schema://tools/human/approval/input',
|
||||||
|
outputSchemaRef: 'schema://tools/human/approval/output',
|
||||||
|
riskLevel: 'L0',
|
||||||
|
defaultPolicy: 'allow',
|
||||||
|
tags: ['approval', 'control'],
|
||||||
|
latencyHintMs: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mcp.agent_browser.read',
|
||||||
|
kind: 'mcp_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'agent.browser',
|
||||||
|
inputSchemaRef: 'schema://mcp/agent_browser/read/input',
|
||||||
|
outputSchemaRef: 'schema://mcp/agent_browser/read/output',
|
||||||
|
riskLevel: 'L1',
|
||||||
|
defaultPolicy: 'allow',
|
||||||
|
tags: ['mcp', 'web', 'agent-browser'],
|
||||||
|
latencyHintMs: 350,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mcp.n8n-mcp',
|
||||||
|
kind: 'mcp_tool',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'integration.team',
|
||||||
|
inputSchemaRef: 'schema://mcp/n8n/input',
|
||||||
|
outputSchemaRef: 'schema://mcp/n8n/output',
|
||||||
|
riskLevel: 'L1',
|
||||||
|
defaultPolicy: 'ask',
|
||||||
|
tags: ['mcp', 'n8n'],
|
||||||
|
latencyHintMs: 350,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const AGENT_PROFILES: AgentManifest[] = [
|
||||||
|
{
|
||||||
|
id: 'agent.orchestrator.core',
|
||||||
|
name: 'Orchestrator Core Agent',
|
||||||
|
model: 'glm-4.7',
|
||||||
|
owner: 'platform.team',
|
||||||
|
budgetDaily: 120,
|
||||||
|
enabledResourceTags: [
|
||||||
|
'control',
|
||||||
|
'approval',
|
||||||
|
'task',
|
||||||
|
'memory',
|
||||||
|
'knowledge',
|
||||||
|
'skill',
|
||||||
|
'n8n',
|
||||||
|
'read',
|
||||||
|
'analysis',
|
||||||
|
],
|
||||||
|
policyOverrides: {
|
||||||
|
'tool.task.spawn': 'ask',
|
||||||
|
'tool.shell.exec': 'ask',
|
||||||
|
'tool.git.push_pr': 'deny',
|
||||||
|
'tool.db.write_prod': 'deny',
|
||||||
|
'tool.deploy.prod': 'deny',
|
||||||
|
},
|
||||||
|
commandRules: [
|
||||||
|
{
|
||||||
|
resourceId: 'tool.shell.exec',
|
||||||
|
pattern: 'git push *',
|
||||||
|
effect: 'deny',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resourceId: 'tool.shell.exec',
|
||||||
|
pattern: 'git *',
|
||||||
|
effect: 'allow',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pathRules: [
|
||||||
|
{
|
||||||
|
resourceId: 'tool.fs.read',
|
||||||
|
pattern: '*.env*',
|
||||||
|
effect: 'deny',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resourceId: 'tool.fs.write',
|
||||||
|
pattern: '*.env*',
|
||||||
|
effect: 'deny',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'agent.discovery.scout',
|
||||||
|
name: 'Discovery Scout Agent',
|
||||||
|
model: 'glm-4.7',
|
||||||
|
owner: 'discovery.team',
|
||||||
|
budgetDaily: 100,
|
||||||
|
enabledResourceTags: ['discovery', 'web', 'mcp', 'read', 'skill', 'n8n'],
|
||||||
|
policyOverrides: {
|
||||||
|
'mcp.agent_browser.read': 'allow',
|
||||||
|
'tool.web.read': 'allow',
|
||||||
|
'tool.db.write_prod': 'deny',
|
||||||
|
'tool.deploy.prod': 'deny',
|
||||||
|
},
|
||||||
|
commandRules: [],
|
||||||
|
pathRules: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'agent.quality.reviewer',
|
||||||
|
name: 'Quality Reviewer Agent',
|
||||||
|
model: 'glm-4.7',
|
||||||
|
owner: 'quality.team',
|
||||||
|
budgetDaily: 80,
|
||||||
|
enabledResourceTags: ['logs', 'read', 'analysis', 'test', 'lint', 'skill', 'n8n'],
|
||||||
|
policyOverrides: {
|
||||||
|
'tool.logs.query': 'allow',
|
||||||
|
'tool.logs.trace': 'allow',
|
||||||
|
'tool.db.write_prod': 'deny',
|
||||||
|
'tool.deploy.prod': 'deny',
|
||||||
|
},
|
||||||
|
commandRules: [],
|
||||||
|
pathRules: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'agent.knowledge.librarian',
|
||||||
|
name: 'Knowledge Librarian Agent',
|
||||||
|
model: 'glm-4.7',
|
||||||
|
owner: 'knowledge.team',
|
||||||
|
budgetDaily: 90,
|
||||||
|
enabledResourceTags: ['knowledge', 'memory', 'read', 'write', 'skill', 'n8n'],
|
||||||
|
policyOverrides: {
|
||||||
|
'tool.knowledge.retrieve': 'allow',
|
||||||
|
'tool.memory.read': 'allow',
|
||||||
|
'tool.db.write_prod': 'deny',
|
||||||
|
'tool.deploy.prod': 'deny',
|
||||||
|
},
|
||||||
|
commandRules: [],
|
||||||
|
pathRules: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const POLICY_PROFILES = [
|
||||||
|
{
|
||||||
|
id: 'policy.default.safe',
|
||||||
|
mode: 'safe',
|
||||||
|
description: 'Default deny for high-risk operations; approvals required for ask-level actions.',
|
||||||
|
highRiskRequiresApproval: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function toAgentProfileResource(agent: AgentManifest): ResourceManifest {
|
||||||
|
return {
|
||||||
|
id: `profile.${agent.id}`,
|
||||||
|
kind: 'agent_profile',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: agent.owner,
|
||||||
|
inputSchemaRef: 'schema://agent_profile/input',
|
||||||
|
outputSchemaRef: 'schema://agent_profile/output',
|
||||||
|
riskLevel: 'L1',
|
||||||
|
defaultPolicy: 'ask',
|
||||||
|
tags: ['agent', 'profile'],
|
||||||
|
latencyHintMs: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPolicyProfileResource(
|
||||||
|
policyProfile: (typeof POLICY_PROFILES)[number]
|
||||||
|
): ResourceManifest {
|
||||||
|
return {
|
||||||
|
id: `profile.${policyProfile.id}`,
|
||||||
|
kind: 'policy_profile',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'platform.team',
|
||||||
|
inputSchemaRef: 'schema://policy_profile/input',
|
||||||
|
outputSchemaRef: 'schema://policy_profile/output',
|
||||||
|
riskLevel: 'L1',
|
||||||
|
defaultPolicy: 'ask',
|
||||||
|
tags: ['policy', 'profile'],
|
||||||
|
latencyHintMs: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function listUnifiedResources(): ResourceManifest[] {
|
||||||
|
const agentProfileResources = listAgentProfiles().map((agent) => toAgentProfileResource(agent))
|
||||||
|
const policyProfileResources = POLICY_PROFILES.map((policy) => toPolicyProfileResource(policy))
|
||||||
|
const n8nSkillResources = listN8nBackedSkillManifests()
|
||||||
|
return [...BASE_RESOURCE_MANIFESTS, ...n8nSkillResources, ...agentProfileResources, ...policyProfileResources]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listResourceManifests(kind?: ResourceKind): ResourceManifest[] {
|
||||||
|
const resources = listUnifiedResources()
|
||||||
|
if (!kind) {
|
||||||
|
return resources
|
||||||
|
}
|
||||||
|
return resources.filter((resource) => resource.kind === kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getResourceManifestById(resourceId: string): ResourceManifest | null {
|
||||||
|
return listUnifiedResources().find((resource) => resource.id === resourceId) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAgentProfiles(): AgentManifest[] {
|
||||||
|
return AGENT_PROFILES.map((profile) => mergeAgentWithRuntimePatch(profile))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAgentProfileById(agentId: string): AgentManifest | null {
|
||||||
|
return listAgentProfiles().find((profile) => profile.id === agentId) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPolicyProfiles() {
|
||||||
|
return POLICY_PROFILES.map((profile) => ({ ...profile }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDefaultToolsPolicy(): Array<{
|
||||||
|
id: string
|
||||||
|
defaultPolicy: PolicyEffect
|
||||||
|
riskLevel: ResourceManifest['riskLevel']
|
||||||
|
}> {
|
||||||
|
return BASE_RESOURCE_MANIFESTS.filter(
|
||||||
|
(resource) => resource.kind === 'base_tool' || resource.kind === 'mcp_tool'
|
||||||
|
).map((resource) => ({
|
||||||
|
id: resource.id,
|
||||||
|
defaultPolicy: resource.defaultPolicy,
|
||||||
|
riskLevel: resource.riskLevel,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listN8nProductionWorkflows() {
|
||||||
|
return listN8nProductionWorkflowReferences()
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const AdminAuthTokenRequestSchema = z.object({
|
||||||
|
adminKey: z.string().min(32),
|
||||||
|
operator: z.string().min(2).max(80).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CreatePolicyProposalRequestSchema = z.object({
|
||||||
|
title: z.string().min(3).max(200),
|
||||||
|
description: z.string().min(5).max(3000),
|
||||||
|
targetType: z.enum(['agent', 'resource', 'policy_profile']),
|
||||||
|
targetId: z.string().min(1).max(200),
|
||||||
|
changes: z.record(z.unknown()),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ApprovalDecisionRequestSchema = z.object({
|
||||||
|
decision: z.enum(['approved', 'rejected']),
|
||||||
|
comment: z.string().max(1000).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const AgentGoalRequestSchema = z.object({
|
||||||
|
goal: z.string().min(3).max(5000),
|
||||||
|
preferredAgentId: z.string().max(200).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const RunControlRequestSchema = z.object({
|
||||||
|
action: z.enum(['pause', 'resume', 'rollback', 'fail', 'complete']),
|
||||||
|
note: z.string().max(1000).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AdminAuthTokenRequest = z.infer<typeof AdminAuthTokenRequestSchema>
|
||||||
|
export type CreatePolicyProposalRequest = z.infer<typeof CreatePolicyProposalRequestSchema>
|
||||||
|
export type ApprovalDecisionRequest = z.infer<typeof ApprovalDecisionRequestSchema>
|
||||||
|
export type AgentGoalRequest = z.infer<typeof AgentGoalRequestSchema>
|
||||||
|
export type RunControlRequest = z.infer<typeof RunControlRequestSchema>
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
import { applyApprovedProposalPatch, resetRuntimePolicyPatchesForTests } from './policy-runtime'
|
||||||
|
import type {
|
||||||
|
AgentRun,
|
||||||
|
AgentTask,
|
||||||
|
ApprovalStatus,
|
||||||
|
ApprovalTicket,
|
||||||
|
MemoryEntry,
|
||||||
|
PolicyProposal,
|
||||||
|
ProposalStatus,
|
||||||
|
RunStatus,
|
||||||
|
ToolCallAuditRecord,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
interface AgentOsState {
|
||||||
|
runs: Map<string, AgentRun>
|
||||||
|
tasksByRun: Map<string, AgentTask[]>
|
||||||
|
toolCallsByRun: Map<string, ToolCallAuditRecord[]>
|
||||||
|
proposals: Map<string, PolicyProposal>
|
||||||
|
approvalTickets: Map<string, ApprovalTicket>
|
||||||
|
memoryEntries: MemoryEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var __agentParkAgentOsState: AgentOsState | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowIso(): string {
|
||||||
|
return new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getState(): AgentOsState {
|
||||||
|
if (!globalThis.__agentParkAgentOsState) {
|
||||||
|
globalThis.__agentParkAgentOsState = {
|
||||||
|
runs: new Map<string, AgentRun>(),
|
||||||
|
tasksByRun: new Map<string, AgentTask[]>(),
|
||||||
|
toolCallsByRun: new Map<string, ToolCallAuditRecord[]>(),
|
||||||
|
proposals: new Map<string, PolicyProposal>(),
|
||||||
|
approvalTickets: new Map<string, ApprovalTicket>(),
|
||||||
|
memoryEntries: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return globalThis.__agentParkAgentOsState
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRun(input: {
|
||||||
|
goal: string
|
||||||
|
createdBy: string
|
||||||
|
agentId: string
|
||||||
|
status?: RunStatus
|
||||||
|
notes?: string[]
|
||||||
|
planning?: AgentRun['planning']
|
||||||
|
}): AgentRun {
|
||||||
|
const state = getState()
|
||||||
|
const timestamp = nowIso()
|
||||||
|
|
||||||
|
const run: AgentRun = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
goal: input.goal,
|
||||||
|
createdBy: input.createdBy,
|
||||||
|
agentId: input.agentId,
|
||||||
|
status: input.status || 'GOAL_ACCEPTED',
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
pendingApprovals: 0,
|
||||||
|
notes: input.notes || [],
|
||||||
|
planning: input.planning,
|
||||||
|
}
|
||||||
|
|
||||||
|
state.runs.set(run.id, run)
|
||||||
|
state.tasksByRun.set(run.id, [])
|
||||||
|
state.toolCallsByRun.set(run.id, [])
|
||||||
|
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRunStatus(runId: string, status: RunStatus, note?: string): AgentRun | null {
|
||||||
|
const state = getState()
|
||||||
|
const run = state.runs.get(runId)
|
||||||
|
if (!run) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: AgentRun = {
|
||||||
|
...run,
|
||||||
|
status,
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
notes: note ? [...run.notes, note] : run.notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
state.runs.set(runId, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRuns(): AgentRun[] {
|
||||||
|
const state = getState()
|
||||||
|
return Array.from(state.runs.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRun(runId: string): AgentRun | null {
|
||||||
|
return getState().runs.get(runId) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addTask(
|
||||||
|
runId: string,
|
||||||
|
task: Omit<AgentTask, 'id' | 'createdAt' | 'updatedAt' | 'runId'>
|
||||||
|
): AgentTask {
|
||||||
|
const state = getState()
|
||||||
|
const timestamp = nowIso()
|
||||||
|
|
||||||
|
const createdTask: AgentTask = {
|
||||||
|
...task,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
runId,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = state.tasksByRun.get(runId) || []
|
||||||
|
state.tasksByRun.set(runId, [...existing, createdTask])
|
||||||
|
|
||||||
|
return createdTask
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTaskStatus(runId: string, taskId: string, status: AgentTask['status']): AgentTask | null {
|
||||||
|
const state = getState()
|
||||||
|
const tasks = state.tasksByRun.get(runId)
|
||||||
|
if (!tasks) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let updatedTask: AgentTask | null = null
|
||||||
|
const nextTasks = tasks.map((task) => {
|
||||||
|
if (task.id !== taskId) {
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedTask = {
|
||||||
|
...task,
|
||||||
|
status,
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedTask
|
||||||
|
})
|
||||||
|
|
||||||
|
state.tasksByRun.set(runId, nextTasks)
|
||||||
|
return updatedTask
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listTasks(runId?: string): AgentTask[] {
|
||||||
|
const state = getState()
|
||||||
|
if (runId) {
|
||||||
|
return [...(state.tasksByRun.get(runId) || [])]
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(state.tasksByRun.values()).flat()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addToolCall(
|
||||||
|
runId: string,
|
||||||
|
record: Omit<ToolCallAuditRecord, 'id' | 'createdAt' | 'runId'>
|
||||||
|
): ToolCallAuditRecord {
|
||||||
|
const state = getState()
|
||||||
|
const created: ToolCallAuditRecord = {
|
||||||
|
...record,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
runId,
|
||||||
|
createdAt: nowIso(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = state.toolCallsByRun.get(runId) || []
|
||||||
|
state.toolCallsByRun.set(runId, [...existing, created])
|
||||||
|
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listToolCalls(runId?: string): ToolCallAuditRecord[] {
|
||||||
|
const state = getState()
|
||||||
|
if (runId) {
|
||||||
|
return [...(state.toolCallsByRun.get(runId) || [])]
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(state.toolCallsByRun.values()).flat()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPolicyProposal(input: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
targetType: PolicyProposal['targetType']
|
||||||
|
targetId: string
|
||||||
|
changes: Record<string, unknown>
|
||||||
|
createdBy: string
|
||||||
|
}): { proposal: PolicyProposal; approvalTicket: ApprovalTicket } {
|
||||||
|
const state = getState()
|
||||||
|
const timestamp = nowIso()
|
||||||
|
|
||||||
|
const proposal: PolicyProposal = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
targetType: input.targetType,
|
||||||
|
targetId: input.targetId,
|
||||||
|
changes: input.changes,
|
||||||
|
status: 'pending_approval',
|
||||||
|
createdBy: input.createdBy,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
const approvalTicket: ApprovalTicket = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
proposalId: proposal.id,
|
||||||
|
status: 'pending',
|
||||||
|
requestedBy: input.createdBy,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
proposal.approvalTicketId = approvalTicket.id
|
||||||
|
|
||||||
|
state.proposals.set(proposal.id, proposal)
|
||||||
|
state.approvalTickets.set(approvalTicket.id, approvalTicket)
|
||||||
|
|
||||||
|
return { proposal, approvalTicket }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPolicyProposals(): PolicyProposal[] {
|
||||||
|
return Array.from(getState().proposals.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getApprovalTicket(approvalId: string): ApprovalTicket | null {
|
||||||
|
return getState().approvalTickets.get(approvalId) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listApprovalTickets(): ApprovalTicket[] {
|
||||||
|
return Array.from(getState().approvalTickets.values()).sort((a, b) =>
|
||||||
|
b.createdAt.localeCompare(a.createdAt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decideApproval(input: {
|
||||||
|
approvalId: string
|
||||||
|
decision: ApprovalStatus
|
||||||
|
decidedBy: string
|
||||||
|
comment?: string
|
||||||
|
}): { approvalTicket: ApprovalTicket; proposal?: PolicyProposal } | null {
|
||||||
|
const state = getState()
|
||||||
|
const ticket = state.approvalTickets.get(input.approvalId)
|
||||||
|
if (!ticket) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = nowIso()
|
||||||
|
const nextTicket: ApprovalTicket = {
|
||||||
|
...ticket,
|
||||||
|
status: input.decision,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
decidedBy: input.decidedBy,
|
||||||
|
decisionComment: input.comment,
|
||||||
|
}
|
||||||
|
|
||||||
|
state.approvalTickets.set(input.approvalId, nextTicket)
|
||||||
|
|
||||||
|
const proposal = state.proposals.get(ticket.proposalId)
|
||||||
|
if (!proposal) {
|
||||||
|
return { approvalTicket: nextTicket }
|
||||||
|
}
|
||||||
|
|
||||||
|
const proposalStatus: ProposalStatus = input.decision === 'approved' ? 'approved' : 'rejected'
|
||||||
|
const nextProposal: PolicyProposal = {
|
||||||
|
...proposal,
|
||||||
|
status: proposalStatus,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
state.proposals.set(nextProposal.id, nextProposal)
|
||||||
|
if (proposalStatus === 'approved') {
|
||||||
|
applyApprovedProposalPatch(nextProposal)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
approvalTicket: nextTicket,
|
||||||
|
proposal: nextProposal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addMemoryEntry(input: Omit<MemoryEntry, 'id' | 'createdAt'>): MemoryEntry {
|
||||||
|
const state = getState()
|
||||||
|
const entry: MemoryEntry = {
|
||||||
|
...input,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
createdAt: nowIso(),
|
||||||
|
}
|
||||||
|
state.memoryEntries.push(entry)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listMemoryEntries(layer?: MemoryEntry['layer']): MemoryEntry[] {
|
||||||
|
const entries = getState().memoryEntries
|
||||||
|
if (!layer) {
|
||||||
|
return [...entries]
|
||||||
|
}
|
||||||
|
return entries.filter((entry) => entry.layer === layer)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRunPendingApprovals(runId: string, pendingApprovals: number): AgentRun | null {
|
||||||
|
const state = getState()
|
||||||
|
const run = state.runs.get(runId)
|
||||||
|
if (!run) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: AgentRun = {
|
||||||
|
...run,
|
||||||
|
pendingApprovals,
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
status: pendingApprovals > 0 ? 'APPROVAL_WAITING' : run.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
state.runs.set(runId, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetAgentOsStateForTests(): void {
|
||||||
|
globalThis.__agentParkAgentOsState = undefined
|
||||||
|
resetRuntimePolicyPatchesForTests()
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
export type ResourceKind =
|
||||||
|
| 'base_tool'
|
||||||
|
| 'mcp_tool'
|
||||||
|
| 'skill'
|
||||||
|
| 'agent_profile'
|
||||||
|
| 'policy_profile'
|
||||||
|
|
||||||
|
export type ResourceStatus = 'active' | 'disabled' | 'deprecated'
|
||||||
|
export type RiskLevel = 'L0' | 'L1' | 'L2' | 'L3'
|
||||||
|
export type PolicyEffect = 'allow' | 'ask' | 'deny'
|
||||||
|
|
||||||
|
export const CAPABILITY_IDS = [
|
||||||
|
'web_read',
|
||||||
|
'code_review',
|
||||||
|
'log_review',
|
||||||
|
'knowledge_retrieve',
|
||||||
|
'knowledge_write',
|
||||||
|
'task_spawn',
|
||||||
|
'discovery_trending',
|
||||||
|
'discovery_topic',
|
||||||
|
'signal_aggregation',
|
||||||
|
'rag_search',
|
||||||
|
'embedding_refresh',
|
||||||
|
'github_metrics_refresh',
|
||||||
|
'project_ingest',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type CapabilityId = (typeof CAPABILITY_IDS)[number]
|
||||||
|
|
||||||
|
export interface ResourceManifest {
|
||||||
|
id: string
|
||||||
|
kind: ResourceKind
|
||||||
|
version: string
|
||||||
|
status: ResourceStatus
|
||||||
|
owner: string
|
||||||
|
inputSchemaRef: string
|
||||||
|
outputSchemaRef: string
|
||||||
|
riskLevel: RiskLevel
|
||||||
|
defaultPolicy: PolicyEffect
|
||||||
|
tags: string[]
|
||||||
|
costHint?: number
|
||||||
|
latencyHintMs?: number
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapabilityIntent {
|
||||||
|
capability: CapabilityId
|
||||||
|
reason: string
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolSelectionDecision {
|
||||||
|
intent: CapabilityIntent
|
||||||
|
selectedResourceId?: string
|
||||||
|
candidateResourceIds: string[]
|
||||||
|
effect: PolicyEffect
|
||||||
|
reasons: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentManifest {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
model: string
|
||||||
|
owner: string
|
||||||
|
budgetDaily: number
|
||||||
|
enabledResourceTags: string[]
|
||||||
|
policyOverrides: Record<string, PolicyEffect>
|
||||||
|
commandRules: Array<{
|
||||||
|
resourceId: string
|
||||||
|
pattern: string
|
||||||
|
effect: PolicyEffect
|
||||||
|
}>
|
||||||
|
pathRules: Array<{
|
||||||
|
resourceId: string
|
||||||
|
pattern: string
|
||||||
|
effect: PolicyEffect
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RunStatus =
|
||||||
|
| 'GOAL_ACCEPTED'
|
||||||
|
| 'PLAN_COMPILED'
|
||||||
|
| 'TASK_DISPATCHED'
|
||||||
|
| 'TOOL_EXECUTING'
|
||||||
|
| 'EVIDENCE_VALIDATING'
|
||||||
|
| 'QUALITY_REVIEWING'
|
||||||
|
| 'MEMORY_COMMITTING'
|
||||||
|
| 'APPROVAL_WAITING'
|
||||||
|
| 'COMPLETED'
|
||||||
|
| 'FAILED'
|
||||||
|
| 'PAUSED'
|
||||||
|
| 'ROLLED_BACK'
|
||||||
|
|
||||||
|
export type TaskStatus =
|
||||||
|
| 'queued'
|
||||||
|
| 'running'
|
||||||
|
| 'completed'
|
||||||
|
| 'failed'
|
||||||
|
| 'blocked_approval'
|
||||||
|
| 'cancelled'
|
||||||
|
|
||||||
|
export interface AgentRun {
|
||||||
|
id: string
|
||||||
|
goal: string
|
||||||
|
createdBy: string
|
||||||
|
agentId: string
|
||||||
|
status: RunStatus
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
pendingApprovals: number
|
||||||
|
notes: string[]
|
||||||
|
planning?: {
|
||||||
|
source: 'model' | 'heuristic'
|
||||||
|
model?: string
|
||||||
|
latencyMs?: number
|
||||||
|
promptTokens?: number
|
||||||
|
completionTokens?: number
|
||||||
|
totalTokens?: number
|
||||||
|
warning?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentTask {
|
||||||
|
id: string
|
||||||
|
runId: string
|
||||||
|
intent: CapabilityIntent
|
||||||
|
status: TaskStatus
|
||||||
|
selectedResourceId?: string
|
||||||
|
policyEffect: PolicyEffect
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolCallAuditRecord {
|
||||||
|
id: string
|
||||||
|
runId: string
|
||||||
|
taskId: string
|
||||||
|
agentId: string
|
||||||
|
resourceId: string
|
||||||
|
policyEffect: PolicyEffect
|
||||||
|
success: boolean
|
||||||
|
inputSummary: string
|
||||||
|
outputSummary: string
|
||||||
|
reasons: string[]
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApprovalStatus = 'pending' | 'approved' | 'rejected'
|
||||||
|
|
||||||
|
export interface ApprovalTicket {
|
||||||
|
id: string
|
||||||
|
proposalId: string
|
||||||
|
status: ApprovalStatus
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
requestedBy: string
|
||||||
|
decidedBy?: string
|
||||||
|
decisionComment?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProposalStatus = 'draft' | 'pending_approval' | 'approved' | 'rejected'
|
||||||
|
|
||||||
|
export interface PolicyProposal {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
targetType: 'agent' | 'resource' | 'policy_profile'
|
||||||
|
targetId: string
|
||||||
|
changes: Record<string, unknown>
|
||||||
|
status: ProposalStatus
|
||||||
|
createdBy: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
approvalTicketId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoryEntry {
|
||||||
|
id: string
|
||||||
|
layer: 'working' | 'project' | 'knowledge'
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
evidence?: string
|
||||||
|
createdAt: string
|
||||||
|
createdBy: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicyDecision {
|
||||||
|
effect: PolicyEffect
|
||||||
|
reasons: string[]
|
||||||
|
}
|
||||||
+90
-2
@@ -122,7 +122,21 @@
|
|||||||
"paginationLast": "Last page",
|
"paginationLast": "Last page",
|
||||||
"paginationSummary": "Page {current} / {totalPages} · {total} total",
|
"paginationSummary": "Page {current} / {totalPages} · {total} total",
|
||||||
"showFilterPanel": "Show Filters",
|
"showFilterPanel": "Show Filters",
|
||||||
"hideFilterPanel": "Hide Filters"
|
"hideFilterPanel": "Hide Filters",
|
||||||
|
"relatedProjects": "Related Projects",
|
||||||
|
"viewAll": "View All",
|
||||||
|
"addedOn": "Added {date}",
|
||||||
|
"noContentAvailable": "No content available.",
|
||||||
|
"githubStatsTitle": "GitHub Statistics",
|
||||||
|
"githubStatsViewStars": "View stars on GitHub",
|
||||||
|
"githubStatsViewForks": "View forks on GitHub",
|
||||||
|
"githubStatsViewIssues": "View issues on GitHub",
|
||||||
|
"githubStatsViewLicense": "View license on GitHub",
|
||||||
|
"shareTweetText": "Check out {name} on Agent Park",
|
||||||
|
"shareOnX": "Share on X",
|
||||||
|
"copyLink": "Copy Link",
|
||||||
|
"feedbackQuestion": "Did this agent help you?",
|
||||||
|
"feedbackYes": "Yes, it helped"
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"metaTitle": "About Agent Park",
|
"metaTitle": "About Agent Park",
|
||||||
@@ -215,7 +229,8 @@
|
|||||||
"projects": "Projects",
|
"projects": "Projects",
|
||||||
"signals": "Signals",
|
"signals": "Signals",
|
||||||
"about": "About",
|
"about": "About",
|
||||||
"submitProject": "SUBMIT PROJECT"
|
"submitProject": "SUBMIT PROJECT",
|
||||||
|
"admin": "Admin"
|
||||||
},
|
},
|
||||||
"notFound": {
|
"notFound": {
|
||||||
"title": "Page Not Found",
|
"title": "Page Not Found",
|
||||||
@@ -233,11 +248,84 @@
|
|||||||
"subscribeConsent": "Subscribe to Agent Park updates",
|
"subscribeConsent": "Subscribe to Agent Park updates",
|
||||||
"footerDesc": "Discover and explore high-quality AI projects from across the web. Curated for developers and enthusiasts.",
|
"footerDesc": "Discover and explore high-quality AI projects from across the web. Curated for developers and enthusiasts.",
|
||||||
"resources": "Resources",
|
"resources": "Resources",
|
||||||
|
"resourceNewsletter": "Newsletter",
|
||||||
|
"resourceUpdates": "Updates",
|
||||||
|
"resourceDocumentation": "Documentation",
|
||||||
"legal": "Legal",
|
"legal": "Legal",
|
||||||
"privacyPolicy": "Privacy Policy",
|
"privacyPolicy": "Privacy Policy",
|
||||||
"termsOfService": "Terms of Service",
|
"termsOfService": "Terms of Service",
|
||||||
"followUs": "Follow Us",
|
"followUs": "Follow Us",
|
||||||
|
"closeAnnouncement": "Close announcement",
|
||||||
"copyright": "© 2025 Agent Park. All rights reserved.",
|
"copyright": "© 2025 Agent Park. All rights reserved.",
|
||||||
"designedFor": "DESIGNED FOR AI BUILDERS"
|
"designedFor": "DESIGNED FOR AI BUILDERS"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"title": "Agent Control Console",
|
||||||
|
"subtitle": "Unified operations for agent goal orchestration, resource pools (Skill/MCP/tools), policy proposal approvals, and runtime audit logs.",
|
||||||
|
"navChat": "Agent Chat",
|
||||||
|
"navResources": "Resource Pool",
|
||||||
|
"navPolicies": "Policy Proposals",
|
||||||
|
"navRuns": "Run Ops",
|
||||||
|
"navLogs": "Audit Logs",
|
||||||
|
"cardChatTitle": "Orchestrator Chat",
|
||||||
|
"cardChatDescription": "Submit goals and let the gateway derive intents and select resources.",
|
||||||
|
"cardResourceTitle": "Resource Pool Management",
|
||||||
|
"cardResourceDescription": "Inspect and govern tools, MCP bindings, skills, and agent profiles.",
|
||||||
|
"cardPolicyTitle": "Policy Proposal Approval",
|
||||||
|
"cardPolicyDescription": "Manage access policy changes via proposal -> approval -> activation flow.",
|
||||||
|
"cardLogsTitle": "Audit Logs",
|
||||||
|
"cardLogsDescription": "Inspect task-level and tool-call-level runtime records.",
|
||||||
|
"goalDefault": "Explore agent projects on GitHub, and run code and log quality checks.",
|
||||||
|
"unknownError": "Unknown error",
|
||||||
|
"goalConsoleTitle": "Agent Gateway Chat Console",
|
||||||
|
"goalConsoleDescription": "Submit a high-level goal. The orchestrator derives capability intents, selects resources from the pool, and records run/task/tool-call audit trails.",
|
||||||
|
"adminKeyLabel": "Admin key",
|
||||||
|
"adminKeyPlaceholder": "ADMIN_CONSOLE_KEY",
|
||||||
|
"goalLabel": "Goal",
|
||||||
|
"submitting": "Submitting...",
|
||||||
|
"submitGoal": "Submit Goal",
|
||||||
|
"noRunYet": "No run result yet.",
|
||||||
|
"requestJsonLabel": "Request JSON",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"executeEndpoint": "Execute {method} {endpoint}",
|
||||||
|
"noResponseYet": "No response yet.",
|
||||||
|
"policyDefaultTitle": "Block orchestrator git push",
|
||||||
|
"policyDefaultDescription": "Tighten shell command policy for orchestrator and deny git push patterns.",
|
||||||
|
"policyTitle": "Policy Proposal & Approval",
|
||||||
|
"proposalJsonLabel": "Proposal JSON",
|
||||||
|
"submitProposal": "Submit Proposal",
|
||||||
|
"approvalDecisionTitle": "Approval Decision",
|
||||||
|
"approvalTicketIdPlaceholder": "Approval ticket ID",
|
||||||
|
"decisionCommentPlaceholder": "Approval comment",
|
||||||
|
"approve": "Approve",
|
||||||
|
"reject": "Reject",
|
||||||
|
"logsTaskTitle": "Task Logs",
|
||||||
|
"logsTaskDescription": "Task-level runtime audit stream.",
|
||||||
|
"logsToolCallTitle": "Tool Call Logs",
|
||||||
|
"logsToolCallDescription": "Tool-call-level audit records with policy decisions.",
|
||||||
|
"policyProposalsTitle": "Policy Proposal List",
|
||||||
|
"policyProposalsDescription": "Inspect policy proposals and approval status.",
|
||||||
|
"approvalTicketsTitle": "Approval Ticket List",
|
||||||
|
"approvalTicketsDescription": "Inspect proposal-to-ticket bindings.",
|
||||||
|
"agentsTitle": "Agent Profiles",
|
||||||
|
"agentsDescription": "Inspect Agent Manifest, budget, tag domains, and policy overrides.",
|
||||||
|
"mcpTitle": "MCP Resource Bindings",
|
||||||
|
"mcpDescription": "Inspect MCP tools in the resource pool and Agent Browser priority settings.",
|
||||||
|
"skillsTitle": "Skill Registry",
|
||||||
|
"skillsDescription": "Inspect skill inventory and versions in the pool.",
|
||||||
|
"toolsTitle": "Base Tools + MCP Tools",
|
||||||
|
"toolsDescription": "Inspect tool risk levels and default allow/ask/deny strategy.",
|
||||||
|
"resourcesTabTools": "Tools",
|
||||||
|
"resourcesTabSkills": "Skills",
|
||||||
|
"resourcesTabAgents": "Agents",
|
||||||
|
"resourcesOverviewTitle": "Resource Pool Overview",
|
||||||
|
"resourcesOverviewDescription": "Unified view of base tools, MCP tools, skills, agent profiles, and policy profiles.",
|
||||||
|
"runDetailTitle": "Run Detail: {id}",
|
||||||
|
"runDetailDescription": "Inspect intent, task, and tool-call audit records for this run.",
|
||||||
|
"runsListTitle": "Run List",
|
||||||
|
"runsListDescription": "Inspect gateway run instances and orchestration status.",
|
||||||
|
"runsControlTitle": "Run Control (POST)",
|
||||||
|
"runsControlDescription": "Replace REPLACE_RUN_ID in endpoint with a real run ID before control actions.",
|
||||||
|
"runsControlDefaultNote": "Manual pause from admin console"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+90
-2
@@ -122,7 +122,21 @@
|
|||||||
"paginationLast": "末页",
|
"paginationLast": "末页",
|
||||||
"paginationSummary": "第 {current} / {totalPages} 页 · 共 {total} 条",
|
"paginationSummary": "第 {current} / {totalPages} 页 · 共 {total} 条",
|
||||||
"showFilterPanel": "展开筛选器",
|
"showFilterPanel": "展开筛选器",
|
||||||
"hideFilterPanel": "收起筛选器"
|
"hideFilterPanel": "收起筛选器",
|
||||||
|
"relatedProjects": "相关项目",
|
||||||
|
"viewAll": "查看全部",
|
||||||
|
"addedOn": "收录于 {date}",
|
||||||
|
"noContentAvailable": "暂无内容。",
|
||||||
|
"githubStatsTitle": "GitHub 统计",
|
||||||
|
"githubStatsViewStars": "在 GitHub 查看 Star",
|
||||||
|
"githubStatsViewForks": "在 GitHub 查看 Fork",
|
||||||
|
"githubStatsViewIssues": "在 GitHub 查看 Issues",
|
||||||
|
"githubStatsViewLicense": "在 GitHub 查看许可证",
|
||||||
|
"shareTweetText": "来 Agent Park 看看 {name}",
|
||||||
|
"shareOnX": "分享到 X",
|
||||||
|
"copyLink": "复制链接",
|
||||||
|
"feedbackQuestion": "这个 Agent 对你有帮助吗?",
|
||||||
|
"feedbackYes": "有,挺有用"
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"metaTitle": "关于 Agent Park",
|
"metaTitle": "关于 Agent Park",
|
||||||
@@ -215,7 +229,8 @@
|
|||||||
"projects": "项目列表",
|
"projects": "项目列表",
|
||||||
"signals": "前沿信号",
|
"signals": "前沿信号",
|
||||||
"about": "关于",
|
"about": "关于",
|
||||||
"submitProject": "提交项目"
|
"submitProject": "提交项目",
|
||||||
|
"admin": "管理台"
|
||||||
},
|
},
|
||||||
"notFound": {
|
"notFound": {
|
||||||
"title": "页面未找到",
|
"title": "页面未找到",
|
||||||
@@ -233,11 +248,84 @@
|
|||||||
"subscribeConsent": "订阅 Agent Park 更新",
|
"subscribeConsent": "订阅 Agent Park 更新",
|
||||||
"footerDesc": "发现和探索来自全网的高质量 AI 项目。为开发者和爱好者精心策划。",
|
"footerDesc": "发现和探索来自全网的高质量 AI 项目。为开发者和爱好者精心策划。",
|
||||||
"resources": "资源",
|
"resources": "资源",
|
||||||
|
"resourceNewsletter": "新闻订阅",
|
||||||
|
"resourceUpdates": "更新日志",
|
||||||
|
"resourceDocumentation": "文档",
|
||||||
"legal": "法律",
|
"legal": "法律",
|
||||||
"privacyPolicy": "隐私政策",
|
"privacyPolicy": "隐私政策",
|
||||||
"termsOfService": "服务条款",
|
"termsOfService": "服务条款",
|
||||||
"followUs": "关注我们",
|
"followUs": "关注我们",
|
||||||
|
"closeAnnouncement": "关闭公告",
|
||||||
"copyright": "© 2025 Agent Park. 保留所有权利。",
|
"copyright": "© 2025 Agent Park. 保留所有权利。",
|
||||||
"designedFor": "专为 AI 构建者设计"
|
"designedFor": "专为 AI 构建者设计"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"title": "Agent 管理控制台",
|
||||||
|
"subtitle": "统一管理 Agent 目标编排、资源池(Skill/MCP/工具)、策略提案审批与运行日志审计。",
|
||||||
|
"navChat": "Agent 对话",
|
||||||
|
"navResources": "资源池",
|
||||||
|
"navPolicies": "策略提案",
|
||||||
|
"navRuns": "运行管理",
|
||||||
|
"navLogs": "运行日志",
|
||||||
|
"cardChatTitle": "总控 Agent 对话",
|
||||||
|
"cardChatDescription": "下发目标,由 Gateway 自动拆解能力意图并选择资源。",
|
||||||
|
"cardResourceTitle": "资源池管理",
|
||||||
|
"cardResourceDescription": "查看并治理工具、MCP、Skill 与 Agent Profile。",
|
||||||
|
"cardPolicyTitle": "策略提案审批",
|
||||||
|
"cardPolicyDescription": "按“提案 -> 审批 -> 生效”流程管理权限与策略。",
|
||||||
|
"cardLogsTitle": "日志审计",
|
||||||
|
"cardLogsDescription": "查看任务级与工具调用级审计记录。",
|
||||||
|
"goalDefault": "探索 GitHub 上的 Agent 项目,并进行代码与日志质量检查。",
|
||||||
|
"unknownError": "未知错误",
|
||||||
|
"goalConsoleTitle": "Agent 网关对话控制台",
|
||||||
|
"goalConsoleDescription": "提交高层目标后,编排器会自动拆解能力意图、从资源池选择工具,并记录 run/task/tool-call 审计链。",
|
||||||
|
"adminKeyLabel": "管理员密钥",
|
||||||
|
"adminKeyPlaceholder": "ADMIN_CONSOLE_KEY",
|
||||||
|
"goalLabel": "目标",
|
||||||
|
"submitting": "提交中...",
|
||||||
|
"submitGoal": "提交目标",
|
||||||
|
"noRunYet": "暂无运行结果。",
|
||||||
|
"requestJsonLabel": "请求 JSON",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"executeEndpoint": "执行 {method} {endpoint}",
|
||||||
|
"noResponseYet": "暂无响应。",
|
||||||
|
"policyDefaultTitle": "禁止编排器执行 git push",
|
||||||
|
"policyDefaultDescription": "收紧编排器的 shell 命令策略,拒绝 git push 模式。",
|
||||||
|
"policyTitle": "策略提案与审批",
|
||||||
|
"proposalJsonLabel": "提案 JSON",
|
||||||
|
"submitProposal": "提交提案",
|
||||||
|
"approvalDecisionTitle": "审批决策",
|
||||||
|
"approvalTicketIdPlaceholder": "审批单 ID",
|
||||||
|
"decisionCommentPlaceholder": "审批备注",
|
||||||
|
"approve": "批准",
|
||||||
|
"reject": "驳回",
|
||||||
|
"logsTaskTitle": "任务日志",
|
||||||
|
"logsTaskDescription": "任务级运行审计流。",
|
||||||
|
"logsToolCallTitle": "工具调用日志",
|
||||||
|
"logsToolCallDescription": "包含策略决策的工具调用级审计记录。",
|
||||||
|
"policyProposalsTitle": "策略提案列表",
|
||||||
|
"policyProposalsDescription": "查看策略提案及其当前审批状态。",
|
||||||
|
"approvalTicketsTitle": "审批单列表",
|
||||||
|
"approvalTicketsDescription": "查看提案与审批单绑定关系。",
|
||||||
|
"agentsTitle": "Agent 配置",
|
||||||
|
"agentsDescription": "查看 Agent Manifest、预算、标签域与策略覆盖。",
|
||||||
|
"mcpTitle": "MCP 资源绑定",
|
||||||
|
"mcpDescription": "查看资源池中的 MCP 工具及 Agent Browser 优先读取配置。",
|
||||||
|
"skillsTitle": "Skill 注册表",
|
||||||
|
"skillsDescription": "查看资源池中 Skill 清单与版本。",
|
||||||
|
"toolsTitle": "基础工具 + MCP 工具",
|
||||||
|
"toolsDescription": "查看工具风险等级与默认 allow/ask/deny 策略。",
|
||||||
|
"resourcesTabTools": "工具",
|
||||||
|
"resourcesTabSkills": "技能",
|
||||||
|
"resourcesTabAgents": "Agent",
|
||||||
|
"resourcesOverviewTitle": "资源池总览",
|
||||||
|
"resourcesOverviewDescription": "统一查看基础工具、MCP 工具、技能、Agent 配置与策略配置。",
|
||||||
|
"runDetailTitle": "运行详情:{id}",
|
||||||
|
"runDetailDescription": "查看运行的意图、任务和工具调用审计记录。",
|
||||||
|
"runsListTitle": "运行列表",
|
||||||
|
"runsListDescription": "查看网关运行实例及当前编排状态。",
|
||||||
|
"runsControlTitle": "运行控制(POST)",
|
||||||
|
"runsControlDescription": "执行控制动作前,请先将 endpoint 中的 REPLACE_RUN_ID 替换为真实运行 ID。",
|
||||||
|
"runsControlDefaultNote": "来自管理台的手动暂停"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user