Files
heicode-win/heicode/web/default/src/features/dashboard/components/home-hero.tsx
T
chenchenandClaude Opus 4.7 a1529f18c1 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>
2026-05-12 13:33:01 +08:00

540 lines
18 KiB
TypeScript
Vendored
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Heicode 首页主视图 — 实现 docs/product-package/10-frontend-detail-spec.md
* 「初始首页」+ 11-product-prototype-wireframes.md §4 Manager 辅助控制台原型。
*
* 四块对应 §10:
* 主输入 — "你想把什么想法变成可以上线的软件?"
* 继续任务 — 最近任务、运行状态、待审批提示
* 今日焦点 — 当前最重要任务、失败任务、待确认事项
* 辅助入口 — 客户端下载、账户安全、准备清单、最近审计
*
* 设计原则(§10 §"设计原则" 1-8):
* 1. 第一屏只强调"你想做什么"
* 2. 主流程围绕当前任务,不围绕后台模块
* 8. 状态必须覆盖空态、加载、错误、成功
*
* Manager 不承担主开发对话(§13 §5.1)— 主输入只把想法暂存到 localStorage
* 草稿,提示用户打开客户端继续推进。后端 /api/task 之类的 idea-task 接口
* 还没接,等接通后这个 stash 流程换成 POST /api/task。
*/
import { useCallback, useMemo, useRef, useState } from 'react'
import { Link, useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
ArrowRight,
ArrowUpRight,
CheckCircle2,
CircleDashed,
Cpu,
Download,
GitBranch,
PlayCircle,
Rocket,
ShieldCheck,
Sparkles,
UserCog,
XCircle,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
import {
createTaskFromIntent,
listHeicodeTasks,
type HeicodeTask,
} from '@/lib/heicode-mcp'
import { toast as sonnerToast } from 'sonner'
const DRAFT_STORAGE_KEY = 'heicode_idea_draft'
type StatusKey = 'running' | 'success' | 'failed' | 'pending'
const STATUS_MAP: Record<string, StatusKey> = {
running: 'running',
active: 'running',
in_progress: 'running',
succeeded: 'success',
success: 'success',
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'
}
function StatusBadge({ phase }: { phase: string }) {
const k = classifyStatus(phase)
const palette: Record<StatusKey, { cls: string; Icon: typeof PlayCircle }> = {
running: {
cls: 'bg-[color-mix(in_oklch,var(--primary)_22%,transparent)] text-primary ring-primary/40',
Icon: PlayCircle,
},
success: {
cls: 'bg-emerald-500/15 text-emerald-400 ring-emerald-500/30',
Icon: CheckCircle2,
},
failed: {
cls: 'bg-rose-500/15 text-rose-400 ring-rose-500/30',
Icon: XCircle,
},
pending: {
cls: 'bg-amber-500/15 text-amber-400 ring-amber-500/30',
Icon: CircleDashed,
},
}
const p = palette[k]
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] ring-1 ring-inset',
p.cls
)}
>
<p.Icon className='h-3 w-3' />
{phase || 'pending'}
</span>
)
}
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)
if (min < 60) return `${min}m`
const hr = Math.round(min / 60)
if (hr < 24) return `${hr}h`
return `${Math.round(hr / 24)}d`
}
function IdeaInput({ t }: { t: ReturnType<typeof useTranslation>['t'] }) {
const [value, setValue] = useState<string>(() => {
if (typeof window === 'undefined') return ''
return window.localStorage.getItem(DRAFT_STORAGE_KEY) ?? ''
})
const [submitting, setSubmitting] = useState(false)
const taRef = useRef<HTMLTextAreaElement | null>(null)
const navigate = useNavigate()
// 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()
return
}
try {
window.localStorage.setItem(DRAFT_STORAGE_KEY, trimmed)
} catch {
/* localStorage unavailable — ignore */
}
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 (
<section
className='relative overflow-hidden rounded-3xl border p-6 sm:p-8'
style={{
borderColor: 'rgba(123,107,227,0.28)',
backgroundColor: 'rgba(123,107,227,0.04)',
backgroundImage:
'radial-gradient(circle at 0% 0%, rgba(184,136,229,0.16), transparent 60%), radial-gradient(circle at 100% 100%, rgba(107,124,224,0.14), transparent 55%)',
}}
>
<div className='relative z-10 flex flex-col gap-5'>
<div className='flex items-start gap-3'>
<span
className='inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl text-white shadow-[0_10px_30px_-12px_rgba(123,107,227,0.6)]'
style={{ backgroundImage: 'var(--gradient-brand)' }}
>
<Sparkles className='h-5 w-5' />
</span>
<div className='min-w-0 flex-1'>
<h2
className='text-2xl font-semibold tracking-tight sm:text-3xl'
style={{
backgroundImage: 'var(--gradient-brand)',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
color: 'transparent',
}}
>
{t('What idea would you like to turn into shippable software?')}
</h2>
<p className='mt-2 text-sm text-muted-foreground'>
{t(
'Describe the product, feature, or fix. Heicode will draft the task, recommend resources, and dispatch the Agnet team.'
)}
</p>
</div>
</div>
<textarea
ref={taRef}
value={value}
onChange={(e) => setValue(e.target.value)}
rows={5}
spellCheck={false}
placeholder={t(
'e.g. A task-management SaaS for small teams: login, projects, tasks, comments, notifications, deploy to Azure.'
)}
className='w-full resize-none rounded-2xl border px-4 py-3 text-sm leading-relaxed shadow-[inset_0_1px_0_rgba(123,107,227,0.10)] outline-none transition focus:ring-2 focus:ring-[color-mix(in_oklch,var(--primary)_40%,transparent)]'
style={{
borderColor: 'rgba(123,107,227,0.22)',
backgroundColor: 'rgba(255,255,255,0.04)',
}}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault()
handleSubmit()
}
}}
/>
<div className='flex flex-wrap items-center justify-between gap-3'>
<p className='text-xs text-muted-foreground'>
{t(
'Heicode Manager only captures the idea. The main task conversation happens in the desktop client.'
)}
</p>
<Button
type='button'
onClick={handleSubmit}
className='h-10 gap-2 rounded-xl px-5 text-sm font-semibold text-white shadow-[0_18px_48px_-18px_rgba(123,107,227,0.65)] transition-transform hover:translate-y-[-1px] active:translate-y-0 active:scale-[0.99]'
style={{
backgroundImage: 'var(--gradient-brand-btn)',
border: '1px solid rgba(255,255,255,0.16)',
}}
>
<Sparkles className='h-4 w-4' />
{t('Save idea')}
<span className='ms-1 hidden text-[10px] font-medium opacity-80 sm:inline'>
{t('⌘/Ctrl + Enter')}
</span>
</Button>
</div>
</div>
</section>
)
}
function ContinueTasks({
tasks,
isLoading,
t,
}: {
tasks: HeicodeTask[]
isLoading: boolean
t: ReturnType<typeof useTranslation>['t']
}) {
const recent = useMemo(() => {
const sorted = [...tasks].sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0))
return sorted.slice(0, 4)
}, [tasks])
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'>
<header className='mb-4 flex items-center justify-between'>
<div>
<p className='text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase'>
{t('Continue working')}
</p>
<h3 className='mt-0.5 text-base font-semibold'>
{t('Recent tasks')}
</h3>
</div>
<Button
asChild
variant='ghost'
size='sm'
className='gap-1 text-primary'
>
<Link to='/deployments'>
{t('All tasks')}
<ArrowUpRight className='h-3.5 w-3.5' />
</Link>
</Button>
</header>
{isLoading ? (
<div className='space-y-2'>
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className='h-16 rounded-xl' />
))}
</div>
) : recent.length === 0 ? (
<p className='rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-background/40 p-4 text-center text-xs text-muted-foreground'>
{t(
'No tasks yet. Capture an idea above, then confirm the recommendation in the desktop client to launch Agnet.'
)}
</p>
) : (
<ul className='space-y-2'>
{recent.map((task) => (
<li key={task.id}>
<Link
to='/tasks/$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)]'
>
<div className='min-w-0 flex-1'>
<p className='line-clamp-1 text-sm font-medium'>
{task.name || task.intent || t('Untitled task')}
</p>
<p className='mt-1 text-[11px] text-muted-foreground'>
{task.status_caption || task.status}
{' · '}
{t('Updated')}{' '}
{formatRelativeTimeMs(task.updated_at)} {t('ago')}
</p>
</div>
<StatusBadge phase={task.status} />
</Link>
</li>
))}
</ul>
)}
</section>
)
}
function TodayFocus({
tasks,
isLoading,
t,
}: {
tasks: HeicodeTask[]
isLoading: boolean
t: ReturnType<typeof useTranslation>['t']
}) {
const buckets = useMemo(() => {
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 }
}, [tasks])
const focusItems: Array<{
Icon: typeof XCircle
tone: 'failed' | 'pending' | 'running' | 'idle'
title: string
count: number
hint: string
}> = [
{
Icon: XCircle,
tone: 'failed',
title: t('Failed tasks'),
count: buckets.failed.length,
hint: t('Review and decide next action'),
},
{
Icon: CircleDashed,
tone: 'pending',
title: t('Pending confirmation'),
count: buckets.pending.length,
hint: t('Awaiting recommendation summary review'),
},
{
Icon: PlayCircle,
tone: 'running',
title: t('Running'),
count: buckets.running.length,
hint: t('Active Agnet sub-loops'),
},
]
const toneCls: Record<'failed' | 'pending' | 'running' | 'idle', string> = {
failed: 'text-rose-400 bg-rose-500/10 ring-rose-500/25',
pending: 'text-amber-400 bg-amber-500/10 ring-amber-500/25',
running: 'text-primary bg-[color-mix(in_oklch,var(--primary)_18%,transparent)] ring-primary/30',
idle: 'text-muted-foreground bg-muted/40 ring-border',
}
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'>
<header className='mb-4'>
<p className='text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase'>
{t("Today's focus")}
</p>
<h3 className='mt-0.5 text-base font-semibold'>
{t('What needs your attention')}
</h3>
</header>
{isLoading ? (
<div className='grid gap-2 sm:grid-cols-3'>
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className='h-20 rounded-xl' />
))}
</div>
) : (
<div className='grid gap-3 sm:grid-cols-3'>
{focusItems.map((it) => (
<div
key={it.title}
className='rounded-xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-background/40 p-3'
>
<div className='flex items-center gap-2'>
<span
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-lg ring-1 ring-inset',
toneCls[it.tone]
)}
>
<it.Icon className='h-3.5 w-3.5' />
</span>
<p className='text-xs font-medium text-muted-foreground'>
{it.title}
</p>
</div>
<p className='mt-2 text-2xl font-semibold'>{it.count}</p>
<p className='mt-0.5 text-[11px] text-muted-foreground'>
{it.hint}
</p>
</div>
))}
</div>
)}
</section>
)
}
function HelperEntries({
t,
}: {
t: ReturnType<typeof useTranslation>['t']
}) {
const entries: Array<{
Icon: typeof GitBranch
title: string
desc: string
to: string
}> = [
{
Icon: GitBranch,
title: t('Preparation checklist'),
desc: t('Connect code, SK, docs and cloud accounts'),
to: '/sk-sources',
},
{
Icon: Download,
title: t('Heicode desktop client'),
desc: t('Download macOS / Windows builds'),
to: '/desktop-client',
},
{
Icon: Cpu,
title: t('Models and balance'),
desc: t('Available models, quota and recent usage'),
to: '/wallet',
},
{
Icon: UserCog,
title: t('Account security'),
desc: t('Tokens, password and active sessions'),
to: '/profile',
},
{
Icon: ShieldCheck,
title: t('Recent audit'),
desc: t('Approvals, scope changes and credential rotations'),
to: '/audit',
},
{
Icon: Rocket,
title: t('Task overview'),
desc: t('Status of every Agnet task you launched'),
to: '/deployments',
},
]
return (
<section>
<header className='mb-3'>
<p className='text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase'>
{t('Helpers')}
</p>
<h3 className='mt-0.5 text-base font-semibold'>
{t('Other things you can do')}
</h3>
</header>
<div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-3'>
{entries.map((e) => (
<Link
key={e.to}
to={e.to}
className='group flex items-start gap-3 rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_55%,transparent)] p-4 transition hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-[0_18px_48px_-32px_rgba(123,107,227,0.55)]'
>
<span
className='inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-primary'
style={{
backgroundColor: 'rgba(123,107,227,0.12)',
border: '1px solid rgba(123,107,227,0.22)',
}}
>
<e.Icon className='h-4 w-4' />
</span>
<div className='min-w-0 flex-1'>
<p className='flex items-center gap-1 text-sm font-medium'>
{e.title}
<ArrowRight className='h-3.5 w-3.5 -translate-x-0.5 opacity-0 transition group-hover:translate-x-0 group-hover:opacity-70' />
</p>
<p className='mt-0.5 text-xs text-muted-foreground'>{e.desc}</p>
</div>
</Link>
))}
</div>
</section>
)
}
export function HomeHeroView() {
const { t } = useTranslation()
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 (
<div className='space-y-5'>
<IdeaInput t={t} />
<div className='grid gap-5 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)]'>
<ContinueTasks tasks={tasks} isLoading={isLoading} t={t} />
<TodayFocus tasks={tasks} isLoading={isLoading} t={t} />
</div>
<HelperEntries t={t} />
</div>
)
}