From a1529f18c1ab5ae21fcd8381b12f09f5c2d51e6f Mon Sep 17 00:00:00 2001
From: chenchen
Date: Tue, 12 May 2026 13:33:01 +0800
Subject: [PATCH] feat(manager): wire UI to mcp-server contract instead of
local controllers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
.../src/features/agnet-console/pages.tsx | 10 +-
.../dashboard/components/home-hero.tsx | 106 +++---
.../src/features/tasks/task-card-view.tsx | 246 +++++++++----
heicode/web/default/src/i18n/locales/en.json | 8 +
heicode/web/default/src/i18n/locales/zh.json | 8 +
heicode/web/default/src/lib/heicode-mcp.ts | 334 ++++++++++++++++++
6 files changed, 586 insertions(+), 126 deletions(-)
create mode 100644 heicode/web/default/src/lib/heicode-mcp.ts
diff --git a/heicode/web/default/src/features/agnet-console/pages.tsx b/heicode/web/default/src/features/agnet-console/pages.tsx
index f7d5d4d..eeca57d 100644
--- a/heicode/web/default/src/features/agnet-console/pages.tsx
+++ b/heicode/web/default/src/features/agnet-console/pages.tsx
@@ -39,7 +39,6 @@ import {
SelectValue,
} from '@/components/ui/select'
import {
- getAgnetAuditLogs,
getAgnetDeploymentEvents,
getAgnetSnapshots,
createGitSource,
@@ -54,6 +53,11 @@ import {
type GitSourcePayload,
type GitSourceUsage,
} 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 { toast } from 'sonner'
@@ -818,8 +822,8 @@ export function AgnetAuditPage() {
const [actionFilter, setActionFilter] = useState('')
const { data = [], isLoading } = useQuery({
- queryKey: ['agnet', 'audit'],
- queryFn: getAgnetAuditLogs,
+ queryKey: ['heicode', 'agnet', 'audit'],
+ queryFn: () => listMcpAuditLogs({ limit: 200 }),
refetchInterval: 60_000,
})
diff --git a/heicode/web/default/src/features/dashboard/components/home-hero.tsx b/heicode/web/default/src/features/dashboard/components/home-hero.tsx
index ed14dee..6d6b59f 100644
--- a/heicode/web/default/src/features/dashboard/components/home-hero.tsx
+++ b/heicode/web/default/src/features/dashboard/components/home-hero.tsx
@@ -18,10 +18,9 @@
* 还没接,等接通后这个 stash 流程换成 POST /api/task。
*/
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 { useTranslation } from 'react-i18next'
-import { toast } from 'sonner'
import {
ArrowRight,
ArrowUpRight,
@@ -41,9 +40,11 @@ import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
import {
- listAgnetDeploymentsQuiet,
- type AgnetDeployment,
-} from '@/features/agnet-console/api'
+ createTaskFromIntent,
+ listHeicodeTasks,
+ type HeicodeTask,
+} from '@/lib/heicode-mcp'
+import { toast as sonnerToast } from 'sonner'
const DRAFT_STORAGE_KEY = 'heicode_idea_draft'
@@ -102,11 +103,10 @@ function StatusBadge({ phase }: { phase: string }) {
)
}
-function formatRelativeTime(value?: string): string {
- if (!value) return '—'
- const date = new Date(value)
- if (Number.isNaN(date.getTime())) return value
- const diff = Date.now() - date.getTime()
+function formatRelativeTimeMs(ms?: number): string {
+ if (!ms) return '—'
+ const diff = Date.now() - ms
+ if (diff < 0) return '—'
const sec = Math.round(diff / 1000)
if (sec < 60) return `${sec}s`
const min = Math.round(sec / 60)
@@ -121,9 +121,16 @@ function IdeaInput({ t }: { t: ReturnType['t'] }) {
if (typeof window === 'undefined') return ''
return window.localStorage.getItem(DRAFT_STORAGE_KEY) ?? ''
})
+ const [submitting, setSubmitting] = useState(false)
const taRef = useRef(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()
if (!trimmed) {
taRef.current?.focus()
@@ -134,8 +141,17 @@ function IdeaInput({ t }: { t: ReturnType['t'] }) {
} catch {
/* localStorage unavailable — ignore */
}
- toast.success(t('Idea captured. Open the desktop client to continue.'))
- }, [value, t])
+ setSubmitting(true)
+ 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 (
['t'] }) {
}
function ContinueTasks({
- deployments,
+ tasks,
isLoading,
t,
}: {
- deployments: AgnetDeployment[]
+ tasks: HeicodeTask[]
isLoading: boolean
t: ReturnType['t']
}) {
const recent = useMemo(() => {
- const sorted = [...deployments].sort((a, b) => {
- 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
- })
+ const sorted = [...tasks].sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0))
return sorted.slice(0, 4)
- }, [deployments])
+ }, [tasks])
return (
@@ -279,24 +291,25 @@ function ContinueTasks({
) : (
- {recent.map((dep) => (
-
+ {recent.map((task) => (
+
- {dep.orchestration_plan?.objective ||
- dep.orchestration_plan?.template_hint ||
- t('No objective')}
+ {task.name || task.intent || t('Untitled task')}
- {t('Updated')} {formatRelativeTime(dep.updated_at || dep.created_at)} {t('ago')}
+ {task.status_caption || task.status}
+ {' · '}
+ {t('Updated')}{' '}
+ {formatRelativeTimeMs(task.updated_at)} {t('ago')}
-
+
))}
@@ -307,26 +320,25 @@ function ContinueTasks({
}
function TodayFocus({
- deployments,
+ tasks,
isLoading,
t,
}: {
- deployments: AgnetDeployment[]
+ tasks: HeicodeTask[]
isLoading: boolean
t: ReturnType['t']
}) {
const buckets = useMemo(() => {
- const failed = deployments.filter(
- (d) => classifyStatus(d.phase || d.status || '') === 'failed'
- )
- const pending = deployments.filter(
- (d) => classifyStatus(d.phase || d.status || '') === 'pending'
- )
- const running = deployments.filter(
- (d) => classifyStatus(d.phase || d.status || '') === 'running'
+ const failed = tasks.filter((task) => classifyStatus(task.status) === 'failed')
+ // "Pending confirmation" maps to HeicodeTask.status = 'configuring'
+ // (followups not all answered yet) AND tasks awaiting_approval.
+ const pending = tasks.filter(
+ (task) =>
+ task.status === 'configuring' || task.status === 'awaiting_approval'
)
+ const running = tasks.filter((task) => classifyStatus(task.status) === 'running')
return { failed, pending, running }
- }, [deployments])
+ }, [tasks])
const focusItems: Array<{
Icon: typeof XCircle
@@ -504,18 +516,22 @@ function HelperEntries({
export function HomeHeroView() {
const { t } = useTranslation()
- const { data = [], isLoading } = useQuery({
- queryKey: ['agnet', 'deployments'],
- queryFn: listAgnetDeploymentsQuiet,
+ const { data, isLoading } = useQuery({
+ queryKey: ['heicode', 'tasks', 'recent'],
+ queryFn: () => listHeicodeTasks({ limit: 20 }),
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 (
diff --git a/heicode/web/default/src/features/tasks/task-card-view.tsx b/heicode/web/default/src/features/tasks/task-card-view.tsx
index f2635e3..b33a75c 100644
--- a/heicode/web/default/src/features/tasks/task-card-view.tsx
+++ b/heicode/web/default/src/features/tasks/task-card-view.tsx
@@ -1,19 +1,20 @@
/**
- * Task Card — implements docs/product-package/10 §"任务卡" + 11 §3
- * 任务卡 wireframe. Renders one AgnetDeployment as the user-facing
- * task object: 目标 / 第一版范围 / 自动生成 / 待确认上下文 + Manager 辅助按钮.
+ * Task Card route — implements docs/product-package/10 §"任务卡" + 11 §3
+ * 任务卡 wireframe, backed by mcp-server §6 HeicodeTask object:
*
- * Source of data: existing /api/agnet/deployments list (filtered by id).
- * When backend gains a /api/task/$id idea-task endpoint we'll switch the
- * data source over; the card shape stays the same.
+ * - status = "configuring" → render the open follow-up questions
+ * (each option becomes a clickable chip; clicking POSTs answer to
+ * /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
- * permission manifest, no resource_grant editor. Only show脱敏 summary.
+ * Forbidden per §10 高级展开: no JSON editor, no permission manifest, no
+ * resource_grant editor. Only the user-facing summary fields.
*/
-import { useMemo } from 'react'
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 { toast } from 'sonner'
import {
ArrowLeft,
ArrowRight,
@@ -21,6 +22,7 @@ import {
CircleDashed,
GitBranch,
ListChecks,
+ MessageSquare,
PencilLine,
PlayCircle,
Rocket,
@@ -34,35 +36,33 @@ import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
import {
- listAgnetDeploymentsQuiet,
- type AgnetDeployment,
-} from '@/features/agnet-console/api'
+ answerHeicodeTask,
+ getHeicodeTask,
+ type HeicodeFollowup,
+ type HeicodeTask,
+ type HeicodeTaskStatus,
+} from '@/lib/heicode-mcp'
const route = getRouteApi('/_authenticated/tasks/$id')
type StatusKey = 'running' | 'success' | 'failed' | 'pending'
-const STATUS_MAP: Record = {
+const STATUS_MAP: Record = {
+ draft: 'pending',
+ configuring: 'pending',
+ awaiting_approval: 'pending',
running: 'running',
- active: 'running',
- in_progress: 'running',
- succeeded: 'success',
- success: 'success',
+ paused: 'pending',
completed: 'success',
failed: 'failed',
- error: 'failed',
- rejected: 'failed',
- pending: 'pending',
- queued: 'pending',
- awaiting: 'pending',
}
function classifyStatus(s: string): StatusKey {
- return STATUS_MAP[(s || '').toLowerCase()] ?? 'pending'
+ return STATUS_MAP[s] ?? 'pending'
}
-function StatusBadge({ phase }: { phase: string }) {
- const k = classifyStatus(phase)
+function StatusBadge({ status }: { status: HeicodeTaskStatus | string }) {
+ const k = classifyStatus(status)
const map: Record = {
running: {
cls: 'bg-[color-mix(in_oklch,var(--primary)_22%,transparent)] text-primary ring-primary/40',
@@ -90,42 +90,55 @@ function StatusBadge({ phase }: { phase: string }) {
)}
>
- {phase || 'pending'}
+ {status}
)
}
-/**
- * Derive the "第一版范围" (first-version scope) bullet list from the
- * orchestration_plan.agents goals. If the plan didn't capture explicit
- * scope items we fall back to a single "see objective" line so the card
- * never renders a blank section.
- */
-function deriveFirstVersionScope(dep: AgnetDeployment, fallback: string): string[] {
- const agents = dep.orchestration_plan?.agents ?? []
- const goals = agents
- .map((a) => (a.goal || '').trim())
- .filter((g) => g.length > 0)
- if (goals.length > 0) return goals.slice(0, 6)
- return [fallback]
+function readScopeArray(card: HeicodeTask['card'], key: string): string[] {
+ if (!card) return []
+ const v = (card as Record)[key]
+ if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string')
+ if (typeof v === 'string' && v.trim()) return [v]
+ return []
+}
+
+/** Collect open follow-ups from the most recent Heicode thread entry. */
+function collectOpenFollowups(task: HeicodeTask): HeicodeFollowup[] {
+ for (let i = task.thread.length - 1; i >= 0; i--) {
+ const entry = task.thread[i]!
+ if (entry.kind === 'heicode' && entry.followups && entry.followups.length > 0) {
+ return entry.followups
+ }
+ }
+ return []
}
export function TaskCardView() {
const { t } = useTranslation()
const { id } = route.useParams()
+ const queryClient = useQueryClient()
- // Source: the existing deployments list (filter by id). When the backend
- // gains a /api/task/$id idea-task endpoint we swap this for a single
- // useQuery against that endpoint.
- const { data: deployments = [], isLoading } = useQuery({
- queryKey: ['agnet', 'deployments'],
- queryFn: listAgnetDeploymentsQuiet,
+ const { data: task, isLoading } = useQuery({
+ queryKey: ['heicode', 'task', id],
+ queryFn: () => getHeicodeTask(id),
+ refetchInterval: 15_000,
})
- const dep = useMemo(
- () => deployments.find((d) => d.deployment_id === id),
- [deployments, id]
- )
+ const answerMutation = useMutation({
+ mutationFn: ({ qid, oid }: { qid: string; oid: string }) =>
+ 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) {
return (
@@ -137,7 +150,7 @@ export function TaskCardView() {
)
}
- if (!dep) {
+ if (!task) {
return (
@@ -155,23 +168,34 @@ export function TaskCardView() {
)
}
- const phase = dep.phase || dep.status
+ const openFollowups = collectOpenFollowups(task)
const objective =
- dep.orchestration_plan?.objective ||
- dep.orchestration_plan?.template_hint ||
+ (task.card && (task.card as Record).objective as string | undefined) ||
+ task.intent ||
+ task.name ||
t('No objective')
- const firstVersionScope = deriveFirstVersionScope(dep, objective)
-
- // Static text — Heicode platform always produces these artifacts. Per
- // §11 §3 wireframe the card literally lists them so the user knows the
- // platform is doing the heavy lifting.
- const autoGenerated = [
- t('Product brief'),
- t('Prototype description'),
- t('Development tasks'),
- t('Check list'),
- t('Deployment steps'),
- ]
+ const firstVersionScope =
+ readScopeArray(task.card, 'first_version_scope').length > 0
+ ? readScopeArray(task.card, 'first_version_scope')
+ : [task.intent || objective]
+ const autoGenerated =
+ readScopeArray(task.card, 'auto_generated').length > 0
+ ? readScopeArray(task.card, 'auto_generated')
+ : [
+ t('Product brief'),
+ t('Prototype description'),
+ t('Development tasks'),
+ t('Check list'),
+ 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 (
@@ -183,10 +207,75 @@ export function TaskCardView() {
- {dep.deployment_id}
+ {task.id}
+ {/* ── Follow-up questions (only while status=configuring) ─────────── */}
+ {task.status === 'configuring' && openFollowups.length > 0 && (
+
+
+
+
+
+
+
+ {t('Heicode is asking')}
+
+
+ {task.status_caption || t('Answer a few questions so Heicode can draft the right plan.')}
+
+
+
+
+
+ {openFollowups.map((q) => (
+
+ {q.question}
+
+ {q.options.map((opt) => (
+ 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' && (
+
+ {t('high-risk')}
+
+ )}
+
+ ))}
+
+
+ ))}
+
+
+ )}
+
+ {/* ── Task card (objective / scope / auto / pending) ───────────── */}
{objective}
+ {task.intent && task.intent !== objective && (
+ {task.intent}
+ )}
-
+
- {/* 第一版范围 */}
@@ -224,7 +315,6 @@ export function TaskCardView() {
- {/* 自动生成 */}
@@ -241,7 +331,6 @@ export function TaskCardView() {
- {/* 需要 Manager 辅助完成 */}
{t('Needs Manager assistance')}
@@ -274,7 +363,6 @@ export function TaskCardView() {
- {/* CTA footer */}
- {/* 待确认上下文 — surfaced as a small follow-up card under the main one */}
{t('Pending context')}
-
- {t(
- '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.'
- )}
-
+
+ {pendingContext.map((line, i) => (
+
+
+ {line}
+
+ ))}
+
)
diff --git a/heicode/web/default/src/i18n/locales/en.json b/heicode/web/default/src/i18n/locales/en.json
index 20672a8..eb23616 100644
--- a/heicode/web/default/src/i18n/locales/en.json
+++ b/heicode/web/default/src/i18n/locales/en.json
@@ -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.": "All follow-ups answered. Heicode generated the recommendation summary.",
"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.": "Answer a few questions so Heicode can draft the right plan.",
"Anthropic": "Anthropic",
"API Addresses": "API Addresses",
"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 tasks": "Failed tasks",
"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 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 task",
"Failed to create user": "Failed to create user",
"Failed to delete account": "Failed to delete account",
"Failed to delete API key": "Failed to delete API key",
@@ -1754,12 +1758,14 @@
"Heicode auto-generates": "Heicode auto-generates",
"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 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.",
"Helpers": "Helpers",
"Hidden — verify to reveal": "Hidden — verify to reveal",
"Hide": "Hide",
"Hide API key": "Hide API key",
"High Performance": "High Performance",
+ "high-risk": "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 drafted. Answer the follow-ups to build the recommendation.",
"Task ID": "Task ID",
"Task ID:": "Task ID:",
"Task logs": "Task logs",
@@ -3704,6 +3711,7 @@
"Unsaved changes": "Unsaved changes",
"Until": "Until",
"Untitled": "Untitled",
+ "Untitled task": "Untitled task",
"Untrusted upstream data:": "Untrusted upstream data:",
"Unused": "Unused",
"Update": "Update",
diff --git a/heicode/web/default/src/i18n/locales/zh.json b/heicode/web/default/src/i18n/locales/zh.json
index 4fdbbb1..e22f072 100644
--- a/heicode/web/default/src/i18n/locales/zh.json
+++ b/heicode/web/default/src/i18n/locales/zh.json
@@ -204,6 +204,7 @@
"All": "全部",
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "所有编辑都是覆盖操作。留空字段将保持当前值不变。",
"All files exceed the maximum size.": "所有文件都超过最大尺寸。",
+ "All follow-ups answered. Heicode generated the recommendation summary.": "所有追问已回答,Heicode 已生成推荐摘要。",
"All Groups": "所有分组",
"All Models": "所有模型",
"All models in use are properly configured.": "所有正在使用的模型都已正确配置。",
@@ -274,6 +275,7 @@
"Announcements": "公告",
"Announcements saved successfully": "公告保存成功",
"Answer": "答案",
+ "Answer a few questions so Heicode can draft the right plan.": "回答几个问题,让 Heicode 起草合适的执行计划。",
"Anthropic": "Anthropic",
"API Addresses": "API 地址",
"API Base URL (Important: Not Chat API) *": "API 基础 URL (重要:非聊天 API) *",
@@ -1413,6 +1415,7 @@
"Failed (visible window)": "失败(当前窗口)",
"Failed tasks": "失败任务",
"Failed to adjust quota": "调整额度失败",
+ "Failed to answer follow-up": "回答追问失败",
"Failed to apply overwrite.": "应用覆盖失败。",
"Failed to bind email": "绑定邮箱失败",
"Failed to change password": "修改密码失败",
@@ -1431,6 +1434,7 @@
"Failed to create deployment": "创建部署失败",
"Failed to create provider": "创建提供商失败",
"Failed to create redemption code": "创建兑换码失败",
+ "Failed to create task": "创建任务失败",
"Failed to create user": "创建用户失败",
"Failed to delete account": "删除账号失败",
"Failed to delete API key": "删除API密钥失败",
@@ -1754,12 +1758,14 @@
"Heicode auto-generates": "Heicode 将自动生成",
"Heicode desktop client": "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 只暂存想法。主要的任务对话和推进发生在客户端。",
"Helpers": "辅助入口",
"Hidden — verify to reveal": "隐藏 — 验证以显示",
"Hide": "隐藏",
"Hide API key": "隐藏 API 密钥",
"High Performance": "高性能",
+ "high-risk": "高风险",
"High-risk action": "高危操作",
"High-risk operation confirmation": "高危操作确认",
"High-risk rules": "高危规则",
@@ -3453,6 +3459,7 @@
"Target group": "目标分组",
"Task": "任务",
"Task card": "任务卡",
+ "Task drafted. Answer the follow-ups to build the recommendation.": "已生成任务草案,回答几个追问就能拿到推荐摘要。",
"Task ID": "任务 ID",
"Task ID:": "任务 ID:",
"Task logs": "任务日志",
@@ -3704,6 +3711,7 @@
"Unsaved changes": "未保存的更改",
"Until": "至",
"Untitled": "未命名",
+ "Untitled task": "未命名任务",
"Untrusted upstream data:": "不受信任的上游数据:",
"Unused": "未使用",
"Update": "更新",
diff --git a/heicode/web/default/src/lib/heicode-mcp.ts b/heicode/web/default/src/lib/heicode-mcp.ts
new file mode 100644
index 0000000..a430104
--- /dev/null
+++ b/heicode/web/default/src/lib/heicode-mcp.ts
@@ -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(
+ path: string,
+ init: RequestInit = {}
+): Promise {
+ 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 = { success: boolean; data?: T; message?: string }
+
+export async function createTaskFromIntent(intent: string, name?: string): Promise {
+ const env = await mcpFetch>('/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>(
+ `/api/user/tasks${suffix}`
+ )
+ return env.data ?? { items: [], total: 0, offset: 0, limit: 0 }
+}
+
+export async function getHeicodeTask(id: string): Promise {
+ try {
+ const env = await mcpFetch>(`/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 {
+ const env = await mcpFetch>(
+ `/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 {
+ try {
+ const env = await mcpFetch>('/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>
+ count: number
+} | null> {
+ try {
+ const env = await mcpFetch>
+ 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>> {
+ const env = await mcpFetch>
+ 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>> {
+ const env = await mcpFetch>
+ 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
+ budget?: {
+ max_usd?: number
+ consumed_usd?: number
+ remaining_usd?: number
+ }
+}
+
+export async function listMcpAgnetDeployments(params?: {
+ status?: string
+ limit?: number
+ binding_scope?: string
+}): Promise {
+ 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>(
+ `/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
+ 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 {
+ 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>(
+ `/api/agnet/audit-logs${suffix}`
+ )
+ return env.data?.items ?? []
+ } catch {
+ return []
+ }
+}