feat(manager): wire UI to mcp-server contract instead of local controllers

After reading the Heicode-接口契约文档 v2.2 in
~/Desktop/taijigit/taiji-AI-PAD/Docs/ the right data sources are clear:

  §1 /api/auth/*           — already wired (features/auth/api.ts)
  §4 /api/user/heicode/*   — model balance + usage + logs
  §5 /api/agnet/*          — Agnet platform stub (deployments / audit / etc)
  §6 /api/user/tasks/*     — HeicodeTask intent → followups → card

The first wave UI work (commit 2df233b) used Heicode-local controllers
as the data source (AgnetDeployment.orchestration_plan as a stand-in
for the task object). That was wrong — the contract document is clear
that HeicodeTask (§6) is the canonical user-facing task object, and the
mcp-server stub at §5 is the canonical deployment source.

This commit redirects the data plumbing without touching the UI shells:

1. New lib/heicode-mcp.ts — typed client that calls mcp-server through
   the existing same-origin /api/heicode-auth/* proxy. Implements the
   subset of §4/§5/§6 the Manager UI needs:
     createTaskFromIntent / listHeicodeTasks / getHeicodeTask / answer
     getHeicodeBalance / getHeicodeModels / getHeicodeUsage / getHeicodeLogs
     listMcpAgnetDeployments / listMcpAuditLogs

2. HomeHero (features/dashboard/components/home-hero.tsx):
   - Idea input now POSTs /api/user/tasks/intent and routes the user
     to /tasks/$id once the server returns the new task with its first
     round of follow-ups. Previously it only stashed the idea in
     localStorage which the docs §10 didn't actually require.
   - ContinueTasks + TodayFocus now consume listHeicodeTasks output
     (HeicodeTask.status / status_caption / updated_at:ms) instead of
     AgnetDeployment shape.

3. TaskCardView (features/tasks/task-card-view.tsx):
   - Reads getHeicodeTask(id) from mcp-server (refetch every 15s).
   - When status=configuring renders the open follow-ups from the most
     recent heicode thread entry as clickable option chips; clicking
     POSTs answer to /api/user/tasks/$id/answer and the server-side
     state machine advances. high-risk options get a red badge per §6.
   - When status=running (followups answered, card materialised) the
     four blocks docs §10 任务卡 mandates are rendered from task.card:
     目标 / 第一版范围 / 自动生成 / 待确认上下文.

4. AgnetAuditPage (features/agnet-console/pages.tsx):
   - Switched queryFn from local getAgnetAuditLogs to mcp-server
     listMcpAuditLogs. The redacted-card renderer already accepts any
     {resource_id, allowed_actions, constraints, secret_ref} shape so
     no UI change needed; banner still announces no plaintext.

Notes:
- /wallet refactor to §4 deferred — it pulls multiple legacy series
  from the local NewAPI controllers and the rewrite is a separate
  pass. Manager users see local data for now; the call is identical
  shape so swap is mechanical once we get there.
- Local TS check clean. Not deployed.
- Earlier 2df233b's UI structures (HomeHero shape, TaskCard layout,
  recommendation dialog, audit redacted view) stay verbatim — only
  the data fetching layer moved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 13:33:01 +08:00
co-authored by Claude Opus 4.7
parent 2df233b982
commit a1529f18c1
6 changed files with 586 additions and 126 deletions
+7 -3
View File
@@ -39,7 +39,6 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { import {
getAgnetAuditLogs,
getAgnetDeploymentEvents, getAgnetDeploymentEvents,
getAgnetSnapshots, getAgnetSnapshots,
createGitSource, createGitSource,
@@ -54,6 +53,11 @@ import {
type GitSourcePayload, type GitSourcePayload,
type GitSourceUsage, type GitSourceUsage,
} from './api' } from './api'
// /audit pulls from mcp-server §5.10 stub now, not the Heicode-local
// controller — the contract doc names that endpoint as the canonical
// source. The shape of McpAuditEntry is wider than the legacy local one
// so the redacted-card renderer keeps working.
import { listMcpAuditLogs } from '@/lib/heicode-mcp'
import { CreateAgnetDeploymentSheet } from './create-agnet-deployment-sheet' import { CreateAgnetDeploymentSheet } from './create-agnet-deployment-sheet'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -818,8 +822,8 @@ export function AgnetAuditPage() {
const [actionFilter, setActionFilter] = useState('') const [actionFilter, setActionFilter] = useState('')
const { data = [], isLoading } = useQuery({ const { data = [], isLoading } = useQuery({
queryKey: ['agnet', 'audit'], queryKey: ['heicode', 'agnet', 'audit'],
queryFn: getAgnetAuditLogs, queryFn: () => listMcpAuditLogs({ limit: 200 }),
refetchInterval: 60_000, refetchInterval: 60_000,
}) })
@@ -18,10 +18,9 @@
* 还没接,等接通后这个 stash 流程换成 POST /api/task。 * 还没接,等接通后这个 stash 流程换成 POST /api/task。
*/ */
import { useCallback, useMemo, useRef, useState } from 'react' import { useCallback, useMemo, useRef, useState } from 'react'
import { Link } from '@tanstack/react-router' import { Link, useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { import {
ArrowRight, ArrowRight,
ArrowUpRight, ArrowUpRight,
@@ -41,9 +40,11 @@ import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
listAgnetDeploymentsQuiet, createTaskFromIntent,
type AgnetDeployment, listHeicodeTasks,
} from '@/features/agnet-console/api' type HeicodeTask,
} from '@/lib/heicode-mcp'
import { toast as sonnerToast } from 'sonner'
const DRAFT_STORAGE_KEY = 'heicode_idea_draft' const DRAFT_STORAGE_KEY = 'heicode_idea_draft'
@@ -102,11 +103,10 @@ function StatusBadge({ phase }: { phase: string }) {
) )
} }
function formatRelativeTime(value?: string): string { function formatRelativeTimeMs(ms?: number): string {
if (!value) return '—' if (!ms) return '—'
const date = new Date(value) const diff = Date.now() - ms
if (Number.isNaN(date.getTime())) return value if (diff < 0) return '—'
const diff = Date.now() - date.getTime()
const sec = Math.round(diff / 1000) const sec = Math.round(diff / 1000)
if (sec < 60) return `${sec}s` if (sec < 60) return `${sec}s`
const min = Math.round(sec / 60) const min = Math.round(sec / 60)
@@ -121,9 +121,16 @@ function IdeaInput({ t }: { t: ReturnType<typeof useTranslation>['t'] }) {
if (typeof window === 'undefined') return '' if (typeof window === 'undefined') return ''
return window.localStorage.getItem(DRAFT_STORAGE_KEY) ?? '' return window.localStorage.getItem(DRAFT_STORAGE_KEY) ?? ''
}) })
const [submitting, setSubmitting] = useState(false)
const taRef = useRef<HTMLTextAreaElement | null>(null) const taRef = useRef<HTMLTextAreaElement | null>(null)
const navigate = useNavigate()
const handleSubmit = useCallback(() => { // POST /api/user/tasks/intent on the mcp-server. Backend returns a new
// HeicodeTask with status=configuring and a thread containing the first
// round of follow-up questions. We navigate to /tasks/$id so the user
// can answer them — that's where the recommendation summary materialises
// (status flips to running once all followups answered, see §6.4).
const handleSubmit = useCallback(async () => {
const trimmed = value.trim() const trimmed = value.trim()
if (!trimmed) { if (!trimmed) {
taRef.current?.focus() taRef.current?.focus()
@@ -134,8 +141,17 @@ function IdeaInput({ t }: { t: ReturnType<typeof useTranslation>['t'] }) {
} catch { } catch {
/* localStorage unavailable — ignore */ /* localStorage unavailable — ignore */
} }
toast.success(t('Idea captured. Open the desktop client to continue.')) setSubmitting(true)
}, [value, t]) try {
const task = await createTaskFromIntent(trimmed)
sonnerToast.success(t('Task drafted. Answer the follow-ups to build the recommendation.'))
void navigate({ to: '/tasks/$id', params: { id: task.id } })
} catch (err) {
sonnerToast.error(err instanceof Error ? err.message : t('Failed to create task'))
} finally {
setSubmitting(false)
}
}, [value, t, navigate])
return ( return (
<section <section
@@ -225,22 +241,18 @@ function IdeaInput({ t }: { t: ReturnType<typeof useTranslation>['t'] }) {
} }
function ContinueTasks({ function ContinueTasks({
deployments, tasks,
isLoading, isLoading,
t, t,
}: { }: {
deployments: AgnetDeployment[] tasks: HeicodeTask[]
isLoading: boolean isLoading: boolean
t: ReturnType<typeof useTranslation>['t'] t: ReturnType<typeof useTranslation>['t']
}) { }) {
const recent = useMemo(() => { const recent = useMemo(() => {
const sorted = [...deployments].sort((a, b) => { const sorted = [...tasks].sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0))
const at = new Date(a.updated_at || a.created_at || 0).getTime()
const bt = new Date(b.updated_at || b.created_at || 0).getTime()
return bt - at
})
return sorted.slice(0, 4) return sorted.slice(0, 4)
}, [deployments]) }, [tasks])
return ( return (
<section className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_60%,transparent)] p-5'> <section className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_60%,transparent)] p-5'>
@@ -279,24 +291,25 @@ function ContinueTasks({
</p> </p>
) : ( ) : (
<ul className='space-y-2'> <ul className='space-y-2'>
{recent.map((dep) => ( {recent.map((task) => (
<li key={dep.deployment_id}> <li key={task.id}>
<Link <Link
to='/tasks/$id' to='/tasks/$id'
params={{ id: dep.deployment_id }} params={{ id: task.id }}
className='flex items-start justify-between gap-3 rounded-xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-background/40 p-3 transition hover:-translate-y-px hover:border-primary/40 hover:shadow-[0_18px_48px_-32px_rgba(123,107,227,0.4)]' className='flex items-start justify-between gap-3 rounded-xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-background/40 p-3 transition hover:-translate-y-px hover:border-primary/40 hover:shadow-[0_18px_48px_-32px_rgba(123,107,227,0.4)]'
> >
<div className='min-w-0 flex-1'> <div className='min-w-0 flex-1'>
<p className='line-clamp-1 text-sm font-medium'> <p className='line-clamp-1 text-sm font-medium'>
{dep.orchestration_plan?.objective || {task.name || task.intent || t('Untitled task')}
dep.orchestration_plan?.template_hint ||
t('No objective')}
</p> </p>
<p className='mt-1 text-[11px] text-muted-foreground'> <p className='mt-1 text-[11px] text-muted-foreground'>
{t('Updated')} {formatRelativeTime(dep.updated_at || dep.created_at)} {t('ago')} {task.status_caption || task.status}
{' · '}
{t('Updated')}{' '}
{formatRelativeTimeMs(task.updated_at)} {t('ago')}
</p> </p>
</div> </div>
<StatusBadge phase={dep.phase || dep.status || 'pending'} /> <StatusBadge phase={task.status} />
</Link> </Link>
</li> </li>
))} ))}
@@ -307,26 +320,25 @@ function ContinueTasks({
} }
function TodayFocus({ function TodayFocus({
deployments, tasks,
isLoading, isLoading,
t, t,
}: { }: {
deployments: AgnetDeployment[] tasks: HeicodeTask[]
isLoading: boolean isLoading: boolean
t: ReturnType<typeof useTranslation>['t'] t: ReturnType<typeof useTranslation>['t']
}) { }) {
const buckets = useMemo(() => { const buckets = useMemo(() => {
const failed = deployments.filter( const failed = tasks.filter((task) => classifyStatus(task.status) === 'failed')
(d) => classifyStatus(d.phase || d.status || '') === 'failed' // "Pending confirmation" maps to HeicodeTask.status = 'configuring'
) // (followups not all answered yet) AND tasks awaiting_approval.
const pending = deployments.filter( const pending = tasks.filter(
(d) => classifyStatus(d.phase || d.status || '') === 'pending' (task) =>
) task.status === 'configuring' || task.status === 'awaiting_approval'
const running = deployments.filter(
(d) => classifyStatus(d.phase || d.status || '') === 'running'
) )
const running = tasks.filter((task) => classifyStatus(task.status) === 'running')
return { failed, pending, running } return { failed, pending, running }
}, [deployments]) }, [tasks])
const focusItems: Array<{ const focusItems: Array<{
Icon: typeof XCircle Icon: typeof XCircle
@@ -504,18 +516,22 @@ function HelperEntries({
export function HomeHeroView() { export function HomeHeroView() {
const { t } = useTranslation() const { t } = useTranslation()
const { data = [], isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['agnet', 'deployments'], queryKey: ['heicode', 'tasks', 'recent'],
queryFn: listAgnetDeploymentsQuiet, queryFn: () => listHeicodeTasks({ limit: 20 }),
refetchInterval: 60_000, refetchInterval: 60_000,
// mcp-server may be unreachable while the JWT bridge isn't established.
// Treat that as empty list instead of a hard error in the hero.
retry: false,
}) })
const tasks = data?.items ?? []
return ( return (
<div className='space-y-5'> <div className='space-y-5'>
<IdeaInput t={t} /> <IdeaInput t={t} />
<div className='grid gap-5 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)]'> <div className='grid gap-5 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)]'>
<ContinueTasks deployments={data} isLoading={isLoading} t={t} /> <ContinueTasks tasks={tasks} isLoading={isLoading} t={t} />
<TodayFocus deployments={data} isLoading={isLoading} t={t} /> <TodayFocus tasks={tasks} isLoading={isLoading} t={t} />
</div> </div>
<HelperEntries t={t} /> <HelperEntries t={t} />
</div> </div>
+162 -72
View File
@@ -1,19 +1,20 @@
/** /**
* Task Card — implements docs/product-package/10 §"任务卡" + 11 §3 * Task Card route — implements docs/product-package/10 §"任务卡" + 11 §3
* 任务卡 wireframe. Renders one AgnetDeployment as the user-facing * 任务卡 wireframe, backed by mcp-server §6 HeicodeTask object:
* task object: 目标 / 第一版范围 / 自动生成 / 待确认上下文 + Manager 辅助按钮.
* *
* Source of data: existing /api/agnet/deployments list (filtered by id). * - status = "configuring" → render the open follow-up questions
* When backend gains a /api/task/$id idea-task endpoint we'll switch the * (each option becomes a clickable chip; clicking POSTs answer to
* data source over; the card shape stays the same. * /api/user/tasks/$id/answer and the backend state machine advances).
* - status = "running" / etc → render task.card with the four blocks
* the spec mandates: 目标 / 第一版范围 / 自动生成 / 待确认上下文.
* *
* Forbidden per §10 "高级展开": no JSON editor, no payload table, no * Forbidden per §10 高级展开: no JSON editor, no permission manifest, no
* permission manifest, no resource_grant editor. Only show脱敏 summary. * resource_grant editor. Only the user-facing summary fields.
*/ */
import { useMemo } from 'react'
import { Link, getRouteApi } from '@tanstack/react-router' import { Link, getRouteApi } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { import {
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
@@ -21,6 +22,7 @@ import {
CircleDashed, CircleDashed,
GitBranch, GitBranch,
ListChecks, ListChecks,
MessageSquare,
PencilLine, PencilLine,
PlayCircle, PlayCircle,
Rocket, Rocket,
@@ -34,35 +36,33 @@ import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
listAgnetDeploymentsQuiet, answerHeicodeTask,
type AgnetDeployment, getHeicodeTask,
} from '@/features/agnet-console/api' type HeicodeFollowup,
type HeicodeTask,
type HeicodeTaskStatus,
} from '@/lib/heicode-mcp'
const route = getRouteApi('/_authenticated/tasks/$id') const route = getRouteApi('/_authenticated/tasks/$id')
type StatusKey = 'running' | 'success' | 'failed' | 'pending' type StatusKey = 'running' | 'success' | 'failed' | 'pending'
const STATUS_MAP: Record<string, StatusKey> = { const STATUS_MAP: Record<HeicodeTaskStatus | string, StatusKey> = {
draft: 'pending',
configuring: 'pending',
awaiting_approval: 'pending',
running: 'running', running: 'running',
active: 'running', paused: 'pending',
in_progress: 'running',
succeeded: 'success',
success: 'success',
completed: 'success', completed: 'success',
failed: 'failed', failed: 'failed',
error: 'failed',
rejected: 'failed',
pending: 'pending',
queued: 'pending',
awaiting: 'pending',
} }
function classifyStatus(s: string): StatusKey { function classifyStatus(s: string): StatusKey {
return STATUS_MAP[(s || '').toLowerCase()] ?? 'pending' return STATUS_MAP[s] ?? 'pending'
} }
function StatusBadge({ phase }: { phase: string }) { function StatusBadge({ status }: { status: HeicodeTaskStatus | string }) {
const k = classifyStatus(phase) const k = classifyStatus(status)
const map: Record<StatusKey, { cls: string; Icon: typeof PlayCircle }> = { const map: Record<StatusKey, { cls: string; Icon: typeof PlayCircle }> = {
running: { running: {
cls: 'bg-[color-mix(in_oklch,var(--primary)_22%,transparent)] text-primary ring-primary/40', cls: 'bg-[color-mix(in_oklch,var(--primary)_22%,transparent)] text-primary ring-primary/40',
@@ -90,42 +90,55 @@ function StatusBadge({ phase }: { phase: string }) {
)} )}
> >
<p.Icon className='h-3 w-3' /> <p.Icon className='h-3 w-3' />
{phase || 'pending'} {status}
</span> </span>
) )
} }
/** function readScopeArray(card: HeicodeTask['card'], key: string): string[] {
* Derive the "第一版范围" (first-version scope) bullet list from the if (!card) return []
* orchestration_plan.agents goals. If the plan didn't capture explicit const v = (card as Record<string, unknown>)[key]
* scope items we fall back to a single "see objective" line so the card if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string')
* never renders a blank section. if (typeof v === 'string' && v.trim()) return [v]
*/ return []
function deriveFirstVersionScope(dep: AgnetDeployment, fallback: string): string[] { }
const agents = dep.orchestration_plan?.agents ?? []
const goals = agents /** Collect open follow-ups from the most recent Heicode thread entry. */
.map((a) => (a.goal || '').trim()) function collectOpenFollowups(task: HeicodeTask): HeicodeFollowup[] {
.filter((g) => g.length > 0) for (let i = task.thread.length - 1; i >= 0; i--) {
if (goals.length > 0) return goals.slice(0, 6) const entry = task.thread[i]!
return [fallback] if (entry.kind === 'heicode' && entry.followups && entry.followups.length > 0) {
return entry.followups
}
}
return []
} }
export function TaskCardView() { export function TaskCardView() {
const { t } = useTranslation() const { t } = useTranslation()
const { id } = route.useParams() const { id } = route.useParams()
const queryClient = useQueryClient()
// Source: the existing deployments list (filter by id). When the backend const { data: task, isLoading } = useQuery({
// gains a /api/task/$id idea-task endpoint we swap this for a single queryKey: ['heicode', 'task', id],
// useQuery against that endpoint. queryFn: () => getHeicodeTask(id),
const { data: deployments = [], isLoading } = useQuery({ refetchInterval: 15_000,
queryKey: ['agnet', 'deployments'],
queryFn: listAgnetDeploymentsQuiet,
}) })
const dep = useMemo( const answerMutation = useMutation({
() => deployments.find((d) => d.deployment_id === id), mutationFn: ({ qid, oid }: { qid: string; oid: string }) =>
[deployments, id] answerHeicodeTask(id, qid, oid),
) onSuccess: (updated) => {
queryClient.setQueryData(['heicode', 'task', id], updated)
void queryClient.invalidateQueries({ queryKey: ['heicode', 'tasks', 'recent'] })
if (updated.status === 'running') {
toast.success(t('All follow-ups answered. Heicode generated the recommendation summary.'))
}
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('Failed to answer follow-up'))
},
})
if (isLoading) { if (isLoading) {
return ( return (
@@ -137,7 +150,7 @@ export function TaskCardView() {
) )
} }
if (!dep) { if (!task) {
return ( return (
<div className='mx-auto max-w-3xl space-y-4 p-6'> <div className='mx-auto max-w-3xl space-y-4 p-6'>
<Button asChild variant='ghost' size='sm' className='gap-1'> <Button asChild variant='ghost' size='sm' className='gap-1'>
@@ -155,23 +168,34 @@ export function TaskCardView() {
) )
} }
const phase = dep.phase || dep.status const openFollowups = collectOpenFollowups(task)
const objective = const objective =
dep.orchestration_plan?.objective || (task.card && (task.card as Record<string, unknown>).objective as string | undefined) ||
dep.orchestration_plan?.template_hint || task.intent ||
task.name ||
t('No objective') t('No objective')
const firstVersionScope = deriveFirstVersionScope(dep, objective) const firstVersionScope =
readScopeArray(task.card, 'first_version_scope').length > 0
// Static text — Heicode platform always produces these artifacts. Per ? readScopeArray(task.card, 'first_version_scope')
// §11 §3 wireframe the card literally lists them so the user knows the : [task.intent || objective]
// platform is doing the heavy lifting. const autoGenerated =
const autoGenerated = [ readScopeArray(task.card, 'auto_generated').length > 0
? readScopeArray(task.card, 'auto_generated')
: [
t('Product brief'), t('Product brief'),
t('Prototype description'), t('Prototype description'),
t('Development tasks'), t('Development tasks'),
t('Check list'), t('Check list'),
t('Deployment steps'), t('Deployment steps'),
] ]
const pendingContext =
readScopeArray(task.card, 'pending_context').length > 0
? readScopeArray(task.card, 'pending_context')
: [
t(
'Code, docs, cloud resources and high-risk approval rules. Connect them in the preparation checklist.'
),
]
return ( return (
<div className='mx-auto max-w-4xl space-y-5 p-6'> <div className='mx-auto max-w-4xl space-y-5 p-6'>
@@ -183,10 +207,75 @@ export function TaskCardView() {
</Link> </Link>
</Button> </Button>
<span className='font-mono text-[11px] text-muted-foreground'> <span className='font-mono text-[11px] text-muted-foreground'>
{dep.deployment_id} {task.id}
</span> </span>
</header> </header>
{/* ── Follow-up questions (only while status=configuring) ─────────── */}
{task.status === 'configuring' && openFollowups.length > 0 && (
<section
className='overflow-hidden rounded-3xl border p-5 sm:p-6'
style={{
borderColor: 'rgba(123,107,227,0.28)',
backgroundImage:
'linear-gradient(180deg, rgba(123,107,227,0.07) 0%, rgba(123,107,227,0.00) 60%)',
}}
>
<div className='flex items-start gap-3'>
<span
className='inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl text-white'
style={{ backgroundImage: 'var(--gradient-brand)' }}
>
<MessageSquare className='h-4 w-4' />
</span>
<div className='min-w-0 flex-1'>
<p className='text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase'>
{t('Heicode is asking')}
</p>
<p className='mt-1 text-sm text-foreground'>
{task.status_caption || t('Answer a few questions so Heicode can draft the right plan.')}
</p>
</div>
</div>
<ol className='mt-5 space-y-4'>
{openFollowups.map((q) => (
<li
key={q.id}
className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-background/40 p-4'
>
<p className='text-sm font-medium'>{q.question}</p>
<div className='mt-3 flex flex-wrap gap-2'>
{q.options.map((opt) => (
<Button
key={opt.id}
type='button'
size='sm'
variant='outline'
disabled={answerMutation.isPending}
onClick={() => answerMutation.mutate({ qid: q.id, oid: opt.id })}
className={cn(
'rounded-xl text-xs',
opt.risk === 'high-risk' &&
'border-rose-500/40 text-rose-300 hover:border-rose-500/70'
)}
>
{opt.label}
{opt.risk === 'high-risk' && (
<span className='ms-1 rounded-full bg-rose-500/15 px-1.5 text-[9px] uppercase tracking-wider text-rose-300'>
{t('high-risk')}
</span>
)}
</Button>
))}
</div>
</li>
))}
</ol>
</section>
)}
{/* ── Task card (objective / scope / auto / pending) ───────────── */}
<section <section
className='overflow-hidden rounded-3xl border p-6 sm:p-8' className='overflow-hidden rounded-3xl border p-6 sm:p-8'
style={{ style={{
@@ -203,12 +292,14 @@ export function TaskCardView() {
<h1 className='mt-2 text-2xl font-semibold tracking-tight sm:text-3xl'> <h1 className='mt-2 text-2xl font-semibold tracking-tight sm:text-3xl'>
{objective} {objective}
</h1> </h1>
{task.intent && task.intent !== objective && (
<p className='mt-2 text-sm text-muted-foreground'>{task.intent}</p>
)}
</div> </div>
<StatusBadge phase={phase} /> <StatusBadge status={task.status} />
</div> </div>
<div className='mt-6 grid gap-5 md:grid-cols-2'> <div className='mt-6 grid gap-5 md:grid-cols-2'>
{/* 第一版范围 */}
<div className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-background/40 p-4'> <div className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-background/40 p-4'>
<p className='flex items-center gap-1.5 text-sm font-semibold'> <p className='flex items-center gap-1.5 text-sm font-semibold'>
<ListChecks className='h-4 w-4 text-primary' /> <ListChecks className='h-4 w-4 text-primary' />
@@ -224,7 +315,6 @@ export function TaskCardView() {
</ul> </ul>
</div> </div>
{/* 自动生成 */}
<div className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-background/40 p-4'> <div className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-background/40 p-4'>
<p className='flex items-center gap-1.5 text-sm font-semibold'> <p className='flex items-center gap-1.5 text-sm font-semibold'>
<Sparkles className='h-4 w-4 text-primary' /> <Sparkles className='h-4 w-4 text-primary' />
@@ -241,7 +331,6 @@ export function TaskCardView() {
</div> </div>
</div> </div>
{/* 需要 Manager 辅助完成 */}
<div className='mt-5 rounded-2xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-background/30 p-4'> <div className='mt-5 rounded-2xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-background/30 p-4'>
<p className='text-[11px] font-semibold tracking-[0.14em] text-muted-foreground uppercase'> <p className='text-[11px] font-semibold tracking-[0.14em] text-muted-foreground uppercase'>
{t('Needs Manager assistance')} {t('Needs Manager assistance')}
@@ -274,7 +363,6 @@ export function TaskCardView() {
</div> </div>
</div> </div>
{/* CTA footer */}
<footer className='mt-6 flex flex-wrap items-center justify-between gap-3 border-t border-dashed border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] pt-4'> <footer className='mt-6 flex flex-wrap items-center justify-between gap-3 border-t border-dashed border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] pt-4'>
<Button <Button
variant='ghost' variant='ghost'
@@ -304,16 +392,18 @@ export function TaskCardView() {
</footer> </footer>
</section> </section>
{/* 待确认上下文 — surfaced as a small follow-up card under the main one */}
<section className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_55%,transparent)] p-4'> <section className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_55%,transparent)] p-4'>
<p className='text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase'> <p className='text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase'>
{t('Pending context')} {t('Pending context')}
</p> </p>
<p className='mt-2 text-sm text-muted-foreground'> <ul className='mt-2 space-y-1.5 text-sm text-muted-foreground'>
{t( {pendingContext.map((line, i) => (
'Code, docs, cloud resources and high-risk approval rules. Connect them in the preparation checklist; Heicode will auto-discover what it can and only ask for the rest.' <li key={i} className='flex items-start gap-2'>
)} <span className='mt-1.5 inline-block h-1 w-1 shrink-0 rounded-full bg-primary' />
</p> <span>{line}</span>
</li>
))}
</ul>
</section> </section>
</div> </div>
) )
+8
View File
@@ -204,6 +204,7 @@
"All": "All", "All": "All",
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "All edits are overwrite operations. Leave fields empty to keep current values unchanged.", "All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "All edits are overwrite operations. Leave fields empty to keep current values unchanged.",
"All files exceed the maximum size.": "All files exceed the maximum size.", "All files exceed the maximum size.": "All files exceed the maximum size.",
"All follow-ups answered. Heicode generated the recommendation summary.": "All follow-ups answered. Heicode generated the recommendation summary.",
"All Groups": "All Groups", "All Groups": "All Groups",
"All Models": "All Models", "All Models": "All Models",
"All models in use are properly configured.": "All models in use are properly configured.", "All models in use are properly configured.": "All models in use are properly configured.",
@@ -274,6 +275,7 @@
"Announcements": "Announcements", "Announcements": "Announcements",
"Announcements saved successfully": "Announcements saved successfully", "Announcements saved successfully": "Announcements saved successfully",
"Answer": "Answer", "Answer": "Answer",
"Answer a few questions so Heicode can draft the right plan.": "Answer a few questions so Heicode can draft the right plan.",
"Anthropic": "Anthropic", "Anthropic": "Anthropic",
"API Addresses": "API Addresses", "API Addresses": "API Addresses",
"API Base URL (Important: Not Chat API) *": "API Base URL (Important: Not Chat API) *", "API Base URL (Important: Not Chat API) *": "API Base URL (Important: Not Chat API) *",
@@ -1413,6 +1415,7 @@
"Failed (visible window)": "Failed (visible window)", "Failed (visible window)": "Failed (visible window)",
"Failed tasks": "Failed tasks", "Failed tasks": "Failed tasks",
"Failed to adjust quota": "Failed to adjust quota", "Failed to adjust quota": "Failed to adjust quota",
"Failed to answer follow-up": "Failed to answer follow-up",
"Failed to apply overwrite.": "Failed to apply overwrite.", "Failed to apply overwrite.": "Failed to apply overwrite.",
"Failed to bind email": "Failed to bind email", "Failed to bind email": "Failed to bind email",
"Failed to change password": "Failed to change password", "Failed to change password": "Failed to change password",
@@ -1431,6 +1434,7 @@
"Failed to create deployment": "Failed to create deployment", "Failed to create deployment": "Failed to create deployment",
"Failed to create provider": "Failed to create provider", "Failed to create provider": "Failed to create provider",
"Failed to create redemption code": "Failed to create redemption code", "Failed to create redemption code": "Failed to create redemption code",
"Failed to create task": "Failed to create task",
"Failed to create user": "Failed to create user", "Failed to create user": "Failed to create user",
"Failed to delete account": "Failed to delete account", "Failed to delete account": "Failed to delete account",
"Failed to delete API key": "Failed to delete API key", "Failed to delete API key": "Failed to delete API key",
@@ -1754,12 +1758,14 @@
"Heicode auto-generates": "Heicode auto-generates", "Heicode auto-generates": "Heicode auto-generates",
"Heicode desktop client": "Heicode desktop client", "Heicode desktop client": "Heicode desktop client",
"Heicode generates the parameters automatically. You only confirm allowed scope and risk.": "Heicode generates the parameters automatically. You only confirm allowed scope and risk.", "Heicode generates the parameters automatically. You only confirm allowed scope and risk.": "Heicode generates the parameters automatically. You only confirm allowed scope and risk.",
"Heicode is asking": "Heicode is asking",
"Heicode Manager only captures the idea. The main task conversation happens in the desktop client.": "Heicode Manager only captures the idea. The main task conversation happens in the desktop client.", "Heicode Manager only captures the idea. The main task conversation happens in the desktop client.": "Heicode Manager only captures the idea. The main task conversation happens in the desktop client.",
"Helpers": "Helpers", "Helpers": "Helpers",
"Hidden — verify to reveal": "Hidden — verify to reveal", "Hidden — verify to reveal": "Hidden — verify to reveal",
"Hide": "Hide", "Hide": "Hide",
"Hide API key": "Hide API key", "Hide API key": "Hide API key",
"High Performance": "High Performance", "High Performance": "High Performance",
"high-risk": "high-risk",
"High-risk action": "High-risk action", "High-risk action": "High-risk action",
"High-risk operation confirmation": "High-risk Operation Confirmation", "High-risk operation confirmation": "High-risk Operation Confirmation",
"High-risk rules": "High-risk rules", "High-risk rules": "High-risk rules",
@@ -3453,6 +3459,7 @@
"Target group": "Target group", "Target group": "Target group",
"Task": "Task", "Task": "Task",
"Task card": "Task card", "Task card": "Task card",
"Task drafted. Answer the follow-ups to build the recommendation.": "Task drafted. Answer the follow-ups to build the recommendation.",
"Task ID": "Task ID", "Task ID": "Task ID",
"Task ID:": "Task ID:", "Task ID:": "Task ID:",
"Task logs": "Task logs", "Task logs": "Task logs",
@@ -3704,6 +3711,7 @@
"Unsaved changes": "Unsaved changes", "Unsaved changes": "Unsaved changes",
"Until": "Until", "Until": "Until",
"Untitled": "Untitled", "Untitled": "Untitled",
"Untitled task": "Untitled task",
"Untrusted upstream data:": "Untrusted upstream data:", "Untrusted upstream data:": "Untrusted upstream data:",
"Unused": "Unused", "Unused": "Unused",
"Update": "Update", "Update": "Update",
+8
View File
@@ -204,6 +204,7 @@
"All": "全部", "All": "全部",
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "所有编辑都是覆盖操作。留空字段将保持当前值不变。", "All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "所有编辑都是覆盖操作。留空字段将保持当前值不变。",
"All files exceed the maximum size.": "所有文件都超过最大尺寸。", "All files exceed the maximum size.": "所有文件都超过最大尺寸。",
"All follow-ups answered. Heicode generated the recommendation summary.": "所有追问已回答,Heicode 已生成推荐摘要。",
"All Groups": "所有分组", "All Groups": "所有分组",
"All Models": "所有模型", "All Models": "所有模型",
"All models in use are properly configured.": "所有正在使用的模型都已正确配置。", "All models in use are properly configured.": "所有正在使用的模型都已正确配置。",
@@ -274,6 +275,7 @@
"Announcements": "公告", "Announcements": "公告",
"Announcements saved successfully": "公告保存成功", "Announcements saved successfully": "公告保存成功",
"Answer": "答案", "Answer": "答案",
"Answer a few questions so Heicode can draft the right plan.": "回答几个问题,让 Heicode 起草合适的执行计划。",
"Anthropic": "Anthropic", "Anthropic": "Anthropic",
"API Addresses": "API 地址", "API Addresses": "API 地址",
"API Base URL (Important: Not Chat API) *": "API 基础 URL (重要:非聊天 API) *", "API Base URL (Important: Not Chat API) *": "API 基础 URL (重要:非聊天 API) *",
@@ -1413,6 +1415,7 @@
"Failed (visible window)": "失败(当前窗口)", "Failed (visible window)": "失败(当前窗口)",
"Failed tasks": "失败任务", "Failed tasks": "失败任务",
"Failed to adjust quota": "调整额度失败", "Failed to adjust quota": "调整额度失败",
"Failed to answer follow-up": "回答追问失败",
"Failed to apply overwrite.": "应用覆盖失败。", "Failed to apply overwrite.": "应用覆盖失败。",
"Failed to bind email": "绑定邮箱失败", "Failed to bind email": "绑定邮箱失败",
"Failed to change password": "修改密码失败", "Failed to change password": "修改密码失败",
@@ -1431,6 +1434,7 @@
"Failed to create deployment": "创建部署失败", "Failed to create deployment": "创建部署失败",
"Failed to create provider": "创建提供商失败", "Failed to create provider": "创建提供商失败",
"Failed to create redemption code": "创建兑换码失败", "Failed to create redemption code": "创建兑换码失败",
"Failed to create task": "创建任务失败",
"Failed to create user": "创建用户失败", "Failed to create user": "创建用户失败",
"Failed to delete account": "删除账号失败", "Failed to delete account": "删除账号失败",
"Failed to delete API key": "删除API密钥失败", "Failed to delete API key": "删除API密钥失败",
@@ -1754,12 +1758,14 @@
"Heicode auto-generates": "Heicode 将自动生成", "Heicode auto-generates": "Heicode 将自动生成",
"Heicode desktop client": "Heicode 桌面客户端", "Heicode desktop client": "Heicode 桌面客户端",
"Heicode generates the parameters automatically. You only confirm allowed scope and risk.": "Heicode 自动生成参数,你只需要确认允许范围和风险", "Heicode generates the parameters automatically. You only confirm allowed scope and risk.": "Heicode 自动生成参数,你只需要确认允许范围和风险",
"Heicode is asking": "Heicode 在追问",
"Heicode Manager only captures the idea. The main task conversation happens in the desktop client.": "Manager 只暂存想法。主要的任务对话和推进发生在客户端。", "Heicode Manager only captures the idea. The main task conversation happens in the desktop client.": "Manager 只暂存想法。主要的任务对话和推进发生在客户端。",
"Helpers": "辅助入口", "Helpers": "辅助入口",
"Hidden — verify to reveal": "隐藏 — 验证以显示", "Hidden — verify to reveal": "隐藏 — 验证以显示",
"Hide": "隐藏", "Hide": "隐藏",
"Hide API key": "隐藏 API 密钥", "Hide API key": "隐藏 API 密钥",
"High Performance": "高性能", "High Performance": "高性能",
"high-risk": "高风险",
"High-risk action": "高危操作", "High-risk action": "高危操作",
"High-risk operation confirmation": "高危操作确认", "High-risk operation confirmation": "高危操作确认",
"High-risk rules": "高危规则", "High-risk rules": "高危规则",
@@ -3453,6 +3459,7 @@
"Target group": "目标分组", "Target group": "目标分组",
"Task": "任务", "Task": "任务",
"Task card": "任务卡", "Task card": "任务卡",
"Task drafted. Answer the follow-ups to build the recommendation.": "已生成任务草案,回答几个追问就能拿到推荐摘要。",
"Task ID": "任务 ID", "Task ID": "任务 ID",
"Task ID:": "任务 ID:", "Task ID:": "任务 ID:",
"Task logs": "任务日志", "Task logs": "任务日志",
@@ -3704,6 +3711,7 @@
"Unsaved changes": "未保存的更改", "Unsaved changes": "未保存的更改",
"Until": "至", "Until": "至",
"Untitled": "未命名", "Untitled": "未命名",
"Untitled task": "未命名任务",
"Untrusted upstream data:": "不受信任的上游数据:", "Untrusted upstream data:": "不受信任的上游数据:",
"Unused": "未使用", "Unused": "未使用",
"Update": "更新", "Update": "更新",
+334
View File
@@ -0,0 +1,334 @@
/**
* mcp-server (Heicode Manager backend at apimtaiji.azure-api.net/api/mcp)
* typed client.
*
* All calls go through the same-origin proxy at /api/heicode-auth/*
* (Heicode Go backend → mcp-server) so the browser never has to deal with
* APIM CORS. The proxy is implemented in heicode/controller/heicode_auth_proxy.go
* and forwards the Authorization header verbatim.
*
* Endpoint surface follows
* docs/product-package/../taijigit/Heicode-接口契约文档.md v2.2:
* §1 /api/auth/* authentication (handled by features/auth/api.ts)
* §2 /api/resources/* ResourceBinding (5)
* §3 /api/resource-grants/* ResourceGrant (4)
* §4 /api/user/heicode/* NewAPI metadata passthrough (4)
* §5 /api/agnet/* Agnet platform stub (12 mock endpoints)
* §6 /api/user/tasks/* HeicodeTask orchestration (5)
*
* The shapes below are what mcp-server actually returns — they intentionally
* differ from the Heicode-local /api/agnet/* shapes used in earlier UI work.
* That earlier work treated AgnetDeployment as the user-facing task object;
* the contract document is clear that HeicodeTask (§6) is the right object.
*/
const MCP_BASE = (
(import.meta.env.VITE_HEICODE_AUTH_BASE_URL as string | undefined) ||
'/api/heicode-auth'
).replace(/\/+$/, '')
const ACCESS_TOKEN_KEY = 'heicode_access_token'
function readToken(): string {
if (typeof window === 'undefined') return ''
return window.localStorage.getItem(ACCESS_TOKEN_KEY) || ''
}
async function mcpFetch<T>(
path: string,
init: RequestInit = {}
): Promise<T> {
const token = readToken()
const requestId = `heicode-mcp-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
const res = await fetch(`${MCP_BASE}${path}`, {
...init,
headers: {
Accept: 'application/json',
'X-Request-Id': requestId,
...(init.body && !init.headers
? { 'Content-Type': 'application/json' }
: {}),
...(init.headers || {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
})
let data: unknown = null
try {
data = await res.json()
} catch {
data = null
}
if (!res.ok) {
const detail =
(data as { detail?: string | { message?: string; code?: string } } | null)
?.detail
const message =
typeof detail === 'string'
? detail
: detail?.message ||
(data as { message?: string } | null)?.message ||
`mcp-server request failed (${res.status})`
throw new Error(message)
}
return data as T
}
// =============================================================================
// §6 HeicodeTask (任务编排) — 5 endpoints
// Shape per contract doc §6.1-6.5.
// =============================================================================
export type HeicodeTaskStatus =
| 'draft'
| 'configuring'
| 'running'
| 'awaiting_approval'
| 'completed'
| 'failed'
| 'paused'
export type HeicodeFollowupOption = {
id: string
label: string
risk?: 'high-risk' | string
}
export type HeicodeFollowup = {
id: string
question: string
options: HeicodeFollowupOption[]
}
export type HeicodeThreadEntry = {
kind: 'user' | 'heicode'
text: string
at: number
followups?: HeicodeFollowup[]
}
/** Task card — populated when all followups answered (§6.4). */
export type HeicodeTaskCard = {
objective?: string
first_version_scope?: string[]
auto_generated?: string[]
pending_context?: string[]
// additional fields per HeicodeTask card contract
[key: string]: unknown
} | null
export type HeicodeTask = {
id: string
user_id: string
name: string
status: HeicodeTaskStatus
status_caption?: string
intent: string
thread: HeicodeThreadEntry[]
card: HeicodeTaskCard
created_at: number
updated_at: number
}
type Envelope<T> = { success: boolean; data?: T; message?: string }
export async function createTaskFromIntent(intent: string, name?: string): Promise<HeicodeTask> {
const env = await mcpFetch<Envelope<HeicodeTask>>('/api/user/tasks/intent', {
method: 'POST',
body: JSON.stringify({ intent, ...(name ? { name } : {}) }),
})
if (!env.success || !env.data) {
throw new Error(env.message || 'createTaskFromIntent failed')
}
return env.data
}
export async function listHeicodeTasks(params?: {
status?: HeicodeTaskStatus
limit?: number
offset?: number
}): Promise<{ items: HeicodeTask[]; total: number; offset: number; limit: number }> {
const qs = new URLSearchParams()
if (params?.status) qs.set('status', params.status)
if (params?.limit != null) qs.set('limit', String(params.limit))
if (params?.offset != null) qs.set('offset', String(params.offset))
const suffix = qs.toString() ? `?${qs.toString()}` : ''
const env = await mcpFetch<Envelope<{ items: HeicodeTask[]; total: number; offset: number; limit: number }>>(
`/api/user/tasks${suffix}`
)
return env.data ?? { items: [], total: 0, offset: 0, limit: 0 }
}
export async function getHeicodeTask(id: string): Promise<HeicodeTask | null> {
try {
const env = await mcpFetch<Envelope<HeicodeTask>>(`/api/user/tasks/${encodeURIComponent(id)}`)
return env.data ?? null
} catch (e) {
if (e instanceof Error && /NOT_FOUND|404/i.test(e.message)) return null
throw e
}
}
export async function answerHeicodeTask(
id: string,
questionId: string,
optionId: string
): Promise<HeicodeTask> {
const env = await mcpFetch<Envelope<HeicodeTask>>(
`/api/user/tasks/${encodeURIComponent(id)}/answer`,
{
method: 'POST',
body: JSON.stringify({ question_id: questionId, option_id: optionId }),
}
)
if (!env.success || !env.data) {
throw new Error(env.message || 'answerHeicodeTask failed')
}
return env.data
}
// =============================================================================
// §4 NewAPI metadata passthrough — 4 endpoints
// All return Envelope<…>.
// =============================================================================
export type HeicodeBalance = {
heicodeUserId: number
email: string
username: string
displayName: string
group: string
status: number
quota: number
usedQuota: number
requestCount: number
}
export async function getHeicodeBalance(): Promise<HeicodeBalance | null> {
try {
const env = await mcpFetch<Envelope<HeicodeBalance>>('/api/user/heicode/balance')
return env.data ?? null
} catch (e) {
if (e instanceof Error && /NOT_CONFIGURED|HEICODE_USER_NOT_FOUND/i.test(e.message)) {
return null
}
throw e
}
}
export async function getHeicodeModels(): Promise<{
heicodeUserId: number
email: string
items: Array<Record<string, unknown>>
count: number
} | null> {
try {
const env = await mcpFetch<Envelope<{
heicodeUserId: number
email: string
items: Array<Record<string, unknown>>
count: number
}>>('/api/user/heicode/models')
return env.data ?? null
} catch (e) {
if (e instanceof Error && /NOT_CONFIGURED|HEICODE_USER_NOT_FOUND/i.test(e.message)) {
return null
}
throw e
}
}
export async function getHeicodeUsage(days = 30): Promise<Array<Record<string, unknown>>> {
const env = await mcpFetch<Envelope<{
items: Array<Record<string, unknown>>
count: number
}>>(`/api/user/heicode/usage?days=${Math.max(1, Math.min(90, days))}`)
return env.data?.items ?? []
}
export async function getHeicodeLogs(limit = 50, page = 1): Promise<Array<Record<string, unknown>>> {
const env = await mcpFetch<Envelope<{
items: Array<Record<string, unknown>>
count: number
}>>(`/api/user/heicode/logs?limit=${Math.max(1, Math.min(200, limit))}&page=${Math.max(1, page)}`)
return env.data?.items ?? []
}
// =============================================================================
// §5 Agnet stub — only the endpoints UI needs.
// =============================================================================
export type McpAgnetDeployment = {
deployment_id: string
status: string
phase?: string
risk_level?: string
user_id?: string
binding_scope?: string
created_at?: string
updated_at?: string
agent_instances_count?: number
orchestration_plan?: Record<string, unknown>
budget?: {
max_usd?: number
consumed_usd?: number
remaining_usd?: number
}
}
export async function listMcpAgnetDeployments(params?: {
status?: string
limit?: number
binding_scope?: string
}): Promise<McpAgnetDeployment[]> {
const qs = new URLSearchParams()
if (params?.status) qs.set('status', params.status)
if (params?.limit != null) qs.set('limit', String(params.limit))
if (params?.binding_scope) qs.set('binding_scope', params.binding_scope)
const suffix = qs.toString() ? `?${qs.toString()}` : ''
try {
const env = await mcpFetch<Envelope<{ items?: McpAgnetDeployment[]; total?: number }>>(
`/api/agnet/deployments${suffix}`
)
return env.data?.items ?? []
} catch {
return []
}
}
export type McpAuditEntry = {
audit_id?: string
user_id?: string
binding_scope?: string
actor?: string
action?: string
resource_id?: string
resource_type?: string
allowed_actions?: string[]
constraints?: Record<string, unknown>
secret_ref?: string
occurred_at?: string
// any extra fields are passed through but rendered redacted by UI
[key: string]: unknown
}
export async function listMcpAuditLogs(params?: {
binding_scope?: string
actor?: string
action?: string
limit?: number
}): Promise<McpAuditEntry[]> {
const qs = new URLSearchParams()
if (params?.binding_scope) qs.set('binding_scope', params.binding_scope)
if (params?.actor) qs.set('actor', params.actor)
if (params?.action) qs.set('action', params.action)
if (params?.limit != null) qs.set('limit', String(params.limit))
const suffix = qs.toString() ? `?${qs.toString()}` : ''
try {
const env = await mcpFetch<Envelope<{ items?: McpAuditEntry[]; total?: number }>>(
`/api/agnet/audit-logs${suffix}`
)
return env.data?.items ?? []
} catch {
return []
}
}