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 (
    - {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) => ( +
    1. +

      {q.question}

      +
      + {q.options.map((opt) => ( + + ))} +
      +
    2. + ))} +
    +
    + )} + + {/* ── 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 [] + } +}