feat(client): slices 8-14 — exec/delivery/audit panels + §6 task orchestration wiring

Wireframe coverage (product-package 11-product-prototype-wireframes.md):
  §8  ExecutionFeedbackPanel — sub-steps + sk_tool_calls + events + artifacts (Slice 8)
  §10 DeliveryResultPanel — deliverables + quality + next-actions (Slice 9)
  §audit TaskDetailDrawer — usage / resources / approvals / security tabs (Slice 10)

§6 task orchestration wired (mcp-server contract v2.0):
  Slice 11 — heicode-tasks proxy (5 routes) + typed client + store mode
             (mock | live | loading | error) + ModePill + optimistic updates
  Slices 12/13/14 — lazy-load /execution, /delivery, /audit?tab=... on panel
             mount with shouldFetchPanel gate (skips for seed mock ids)

§7.8.5 deeplink: lib/managerLink.ts centralizes path→URL resolution so the
Manager domain (currently code.xinghanlab.com) can be flipped in one line.
Wires onClick into manager_actions / openManager / deliverable buttons.

Type-check: bunx tsc -b --noEmit clean.
Bun bundle: bun build src/server/api/heicode-tasks.ts → 86 modules, 0.55 MB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 17:39:47 +08:00
co-authored by Claude Opus 4.7
parent 9ffe261374
commit 15ac00130a
9 changed files with 1504 additions and 30 deletions
@@ -0,0 +1,558 @@
// desktop/src/components/tasks/TaskRightPanels.tsx
//
// Right-side panels for the task workspace, switched by status:
// running / awaiting_approval → ExecutionFeedbackPanel (wireframe §8)
// completed → DeliveryResultPanel (wireframe §10)
// draft / configuring → falls back to TaskCardPanel (in HeicodeTasksHome)
//
// Plus TaskDetailDrawer for usage / resources / approvals / security
// (wireframe §audit + 10-frontend §任务用量与审计). All data is mock —
// shapes match what mcp-server / agent-manager are expected to deliver
// (see Heicode-对接进度与待办.md §7.5).
import { useEffect, useState } from 'react'
import { useTranslation } from '../../i18n'
import { openManagerLink } from '../../lib/managerLink'
import {
useHeicodeTaskStore,
type Artifact,
type Deliverable,
type ExecutionEvent,
type HeicodeTask,
type QualityCheck,
type SkToolCall,
type SubStep,
type TaskAudit,
} from '../../stores/heicodeTaskStore'
// ─── Execution feedback (Slice 8) ─────────────────────────────
export function ExecutionFeedbackPanel({ task }: { task: HeicodeTask }) {
const t = useTranslation()
const ex = task.execution
const loadExecution = useHeicodeTaskStore((s) => s.loadExecution)
// Slice 12 lazy-load: when the task is real (live mode + non-seed id),
// fetch /api/user/tasks/{id}/execution; the store gate skips this for
// mock seed rows so the demo data stays untouched.
useEffect(() => {
void loadExecution(task.id)
}, [task.id, loadExecution])
return (
<aside className="hidden w-96 shrink-0 overflow-auto border-l border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-5 lg:block">
<PanelHeader
title={t('tasks.execution.title')}
rightCaption={ex?.spend_today ? `${t('tasks.execution.spend')} ${ex.spend_today}` : undefined}
/>
{!ex ? (
<p className="mt-4 text-xs text-[var(--color-text-tertiary)]">
{t('tasks.execution.empty')}
</p>
) : (
<>
<Section label={t('tasks.execution.subSteps')}>
<ol className="flex flex-col gap-2">
{ex.sub_steps.map((s) => (
<SubStepRow key={s.id} step={s} />
))}
</ol>
</Section>
<Section label={t('tasks.execution.skTools')}>
<ul className="flex flex-col gap-1.5">
{ex.sk_tool_calls.map((c) => (
<SkCallRow key={c.id} call={c} />
))}
</ul>
</Section>
<Section label={t('tasks.execution.events')}>
<ul className="flex flex-col gap-1.5">
{ex.events.map((e) => (
<EventRow key={e.id} ev={e} />
))}
</ul>
</Section>
<Section label={t('tasks.execution.artifacts')}>
<div className="flex flex-wrap gap-1.5">
{ex.artifacts.map((a) => (
<ArtifactChip key={a.id} a={a} />
))}
</div>
</Section>
</>
)}
</aside>
)
}
const SUB_STEP_TONE: Record<SubStep['status'], string> = {
done: 'border-[var(--color-success)]/40 bg-[var(--color-success)]/10 text-[var(--color-success)]',
running: 'border-[var(--color-primary)]/40 bg-[var(--color-primary)]/10 text-[var(--color-primary)]',
waiting: 'border-[var(--color-border)] bg-[var(--color-surface-container)] text-[var(--color-text-tertiary)]',
failed: 'border-[var(--color-error)]/40 bg-[var(--color-error)]/10 text-[var(--color-error)]',
skipped: 'border-[var(--color-border)] bg-[var(--color-surface-container)] text-[var(--color-text-tertiary)]',
}
const SUB_STEP_DOT: Record<SubStep['status'], string> = {
done: 'bg-[var(--color-success)]',
running: 'bg-[var(--color-primary)] animate-pulse',
waiting: 'bg-[var(--color-text-tertiary)]/40',
failed: 'bg-[var(--color-error)]',
skipped: 'bg-[var(--color-text-tertiary)]/40',
}
function SubStepRow({ step }: { step: SubStep }) {
const t = useTranslation()
return (
<li className="flex items-start gap-2.5">
<span
className={`mt-1 h-2 w-2 shrink-0 rounded-full ${SUB_STEP_DOT[step.status]}`}
aria-hidden
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="text-sm text-[var(--color-text-primary)] truncate">{step.label}</span>
<span
className={`shrink-0 rounded-full border px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider ${SUB_STEP_TONE[step.status]}`}
>
{t(`tasks.subStep.${step.status}` as const)}
</span>
</div>
{step.caption ? (
<div className="mt-0.5 text-[11px] text-[var(--color-text-tertiary)] truncate">
{step.caption}
</div>
) : null}
</div>
</li>
)
}
const SK_CALL_TONE: Record<SkToolCall['status'], string> = {
running: 'text-[var(--color-primary)]',
ok: 'text-[var(--color-success)]',
failed: 'text-[var(--color-error)]',
}
function SkCallRow({ call }: { call: SkToolCall }) {
const t = useTranslation()
return (
<li className="flex items-center justify-between gap-2 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container)] px-2.5 py-1.5">
<div className="min-w-0">
<div className="text-xs font-mono text-[var(--color-text-primary)] truncate">
{call.tool}
</div>
{call.caption ? (
<div className="text-[10px] text-[var(--color-text-tertiary)] truncate">
{call.caption}
</div>
) : null}
</div>
<span className={`shrink-0 text-[10px] uppercase tracking-wider ${SK_CALL_TONE[call.status]}`}>
{t(`tasks.skCall.${call.status}` as const)}
</span>
</li>
)
}
function EventRow({ ev }: { ev: ExecutionEvent }) {
return (
<li className="flex items-start gap-2 text-xs">
<span className="mt-0.5 shrink-0 text-[10px] text-[var(--color-text-tertiary)]">
{formatHm(ev.at)}
</span>
<span className="text-[var(--color-text-secondary)] leading-relaxed">{ev.text}</span>
</li>
)
}
const ARTIFACT_DOT: Record<Artifact['kind'], string> = {
doc: 'bg-[var(--color-secondary)]',
api: 'bg-[var(--color-primary)]',
diff: 'bg-[var(--color-warning)]',
report: 'bg-[var(--color-success)]',
}
function ArtifactChip({ a }: { a: Artifact }) {
return (
<button
type="button"
className="flex items-center gap-1.5 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container)] px-2 py-1 text-[11px] text-[var(--color-text-secondary)] hover:border-[var(--color-border-hi,rgba(255,255,255,0.14))] hover:text-[var(--color-text-primary)]"
>
<span className={`h-1.5 w-1.5 rounded-full ${ARTIFACT_DOT[a.kind]}`} aria-hidden />
{a.label}
</button>
)
}
// ─── Delivery result (Slice 9) ────────────────────────────────
export function DeliveryResultPanel({ task }: { task: HeicodeTask }) {
const t = useTranslation()
const d = task.delivery
const loadDelivery = useHeicodeTaskStore((s) => s.loadDelivery)
// Slice 13 lazy-load on mount; gate inside the store skips for seed rows.
useEffect(() => {
void loadDelivery(task.id)
}, [task.id, loadDelivery])
if (!d) return null
return (
<aside className="hidden w-96 shrink-0 overflow-auto border-l border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-5 lg:block">
<PanelHeader title={t('tasks.delivery.title')} />
<p className="mt-3 text-sm leading-relaxed text-[var(--color-text-secondary)]">
{d.summary}
</p>
<Section label={t('tasks.delivery.deliverables')}>
<div className="grid grid-cols-2 gap-2">
{d.deliverables.map((x) => (
<DeliverableCard key={x.id} d={x} />
))}
</div>
</Section>
<Section label={t('tasks.delivery.quality')}>
<ul className="flex flex-col gap-1.5">
{d.quality.map((q) => (
<QualityRow key={q.id} q={q} />
))}
</ul>
</Section>
<Section label={t('tasks.delivery.nextActions')}>
<div className="flex flex-wrap gap-1.5">
{d.next_actions.map((a) => (
<button
key={a.intent}
type="button"
className="rounded-[var(--radius-sm)] border border-[var(--color-primary)]/30 bg-[var(--color-primary)]/10 px-2.5 py-1 text-[11px] text-[var(--color-primary)] hover:bg-[var(--color-primary)]/20"
>
{a.label}
</button>
))}
</div>
</Section>
</aside>
)
}
const DELIVERABLE_KIND_TONE: Record<Deliverable['kind'], string> = {
'product-spec': 'border-[var(--color-secondary)]/30',
'code-diff': 'border-[var(--color-warning)]/30',
'test-env': 'border-[var(--color-success)]/30',
'prod-env': 'border-[var(--color-error)]/30',
}
function DeliverableCard({ d }: { d: Deliverable }) {
return (
<div
className={`flex flex-col gap-1.5 rounded-[var(--radius-md)] border bg-[var(--color-surface-container)] p-2.5 ${DELIVERABLE_KIND_TONE[d.kind]}`}
>
<div className="text-xs font-medium text-[var(--color-text-primary)]">{d.label}</div>
{d.hint ? (
<div className="text-[10px] text-[var(--color-text-tertiary)] leading-snug">{d.hint}</div>
) : null}
<div className="mt-auto flex flex-wrap gap-1">
{d.primary_action ? (
<button
type="button"
onClick={() => openManagerLink(d.primary_action!.deeplink)}
className="rounded-[var(--radius-sm)] border border-[var(--color-primary)]/30 bg-[var(--color-primary)]/10 px-2 py-0.5 text-[10px] text-[var(--color-primary)] hover:bg-[var(--color-primary)]/20"
title={d.primary_action.deeplink}
>
{d.primary_action.label}
</button>
) : null}
{d.secondary_action ? (
<button
type="button"
onClick={() => openManagerLink(d.secondary_action!.deeplink)}
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-2 py-0.5 text-[10px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
title={d.secondary_action.deeplink}
>
{d.secondary_action.label}
</button>
) : null}
</div>
</div>
)
}
const QUALITY_TONE: Record<QualityCheck['status'], string> = {
pass: 'text-[var(--color-success)]',
fixed: 'text-[var(--color-warning)]',
pending: 'text-[var(--color-text-tertiary)]',
failed: 'text-[var(--color-error)]',
}
function QualityRow({ q }: { q: QualityCheck }) {
const t = useTranslation()
return (
<li className="flex items-start justify-between gap-2 text-xs">
<div className="min-w-0">
<div className="text-[var(--color-text-primary)]">{q.label}</div>
{q.detail ? (
<div className="text-[10px] text-[var(--color-text-tertiary)] leading-snug">
{q.detail}
</div>
) : null}
</div>
<span className={`shrink-0 text-[10px] uppercase tracking-wider ${QUALITY_TONE[q.status]}`}>
{t(`tasks.quality.${q.status}` as const)}
</span>
</li>
)
}
// ─── Detail drawer (Slice 10) ─────────────────────────────────
type DetailTab = 'usage' | 'resources' | 'approvals' | 'security'
export function TaskDetailDrawer({
task,
open,
onClose,
}: {
task: HeicodeTask
open: boolean
onClose: () => void
}) {
const t = useTranslation()
const [tab, setTab] = useState<DetailTab>('usage')
const audit = task.audit
const loadAudit = useHeicodeTaskStore((s) => s.loadAudit)
// Slice 14 lazy-load. Re-fetches on tab change so heavy lists (logs,
// approvals) come back filtered server-side rather than dumping the
// whole bundle. The store gate skips for seed rows.
useEffect(() => {
if (!open) return
void loadAudit(task.id, tab)
}, [open, task.id, tab, loadAudit])
if (!open) return null
return (
<div className="absolute inset-0 z-30 flex">
<button
type="button"
aria-label="close"
onClick={onClose}
className="flex-1 bg-black/40"
/>
<div className="flex w-[420px] flex-col border-l border-[var(--color-border)] bg-[var(--color-surface-container)] shadow-2xl">
<header className="flex items-center justify-between border-b border-[var(--color-border)] px-5 py-3">
<div>
<div className="text-[10px] uppercase tracking-[0.2em] text-[var(--color-text-tertiary)]">
{t('tasks.detail.title')}
</div>
<div className="mt-0.5 truncate text-sm font-semibold text-[var(--color-text-primary)]">
{task.name}
</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded-[var(--radius-md)] border border-[var(--color-border)] px-2 py-1 text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
>
{t('tasks.detail.close')}
</button>
</header>
<nav className="flex shrink-0 gap-0.5 border-b border-[var(--color-border)] px-3 pt-2">
{(['usage', 'resources', 'approvals', 'security'] as DetailTab[]).map((k) => (
<button
key={k}
type="button"
onClick={() => setTab(k)}
className={`relative px-3 py-2 text-xs ${
tab === k
? 'text-[var(--color-text-primary)]'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
}`}
>
{t(`tasks.detail.tab.${k}` as const)}
{tab === k ? (
<span className="absolute -bottom-px left-3 right-3 h-0.5 bg-[var(--color-primary)]" />
) : null}
</button>
))}
</nav>
<div className="flex-1 overflow-auto px-5 py-4">
{!audit ? (
<p className="text-xs text-[var(--color-text-tertiary)]">{t('tasks.detail.empty')}</p>
) : (
<DrawerBody tab={tab} audit={audit} />
)}
</div>
</div>
</div>
)
}
function DrawerBody({ tab, audit }: { tab: DetailTab; audit: TaskAudit }) {
const t = useTranslation()
if (tab === 'usage') {
if (audit.usage.length === 0)
return <p className="text-xs text-[var(--color-text-tertiary)]">{t('tasks.detail.empty')}</p>
return (
<ul className="flex flex-col gap-2">
{audit.usage.map((u) => (
<li
key={u.id}
className="flex items-start justify-between gap-3 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2"
>
<div className="min-w-0">
<div className="text-xs text-[var(--color-text-primary)]">{u.caption}</div>
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-[var(--color-text-tertiary)]">
<span className="font-mono">{u.model}</span>
<span>·</span>
<span>{formatHm(u.at)}</span>
</div>
</div>
<span className="shrink-0 text-xs font-medium text-[var(--color-text-secondary)]">
{u.cost}
</span>
</li>
))}
</ul>
)
}
if (tab === 'resources') {
if (audit.resources.length === 0)
return <p className="text-xs text-[var(--color-text-tertiary)]">{t('tasks.detail.empty')}</p>
return (
<ul className="flex flex-col gap-2">
{audit.resources.map((r) => (
<li
key={r.id}
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2"
>
<div className="text-xs text-[var(--color-text-primary)] font-mono">{r.resource}</div>
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-[var(--color-text-tertiary)]">
<span>{r.action}</span>
<span>·</span>
<span>{r.role}</span>
<span>·</span>
<span>{formatHm(r.at)}</span>
</div>
</li>
))}
</ul>
)
}
if (tab === 'approvals') {
if (audit.approvals.length === 0)
return <p className="text-xs text-[var(--color-text-tertiary)]">{t('tasks.detail.empty')}</p>
return (
<ul className="flex flex-col gap-2">
{audit.approvals.map((a) => (
<li
key={a.id}
className="flex items-start justify-between gap-3 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2"
>
<div className="min-w-0">
<div className="text-xs text-[var(--color-text-primary)]">{a.operation}</div>
<div className="mt-0.5 text-[10px] text-[var(--color-text-tertiary)]">
{formatHm(a.at)}
{a.ttl_minutes
? ' · ' + t('tasks.detail.approval.ttl').replace('{minutes}', String(a.ttl_minutes))
: ''}
</div>
</div>
<span
className={`shrink-0 text-[10px] uppercase tracking-wider ${
a.decision === 'approve'
? 'text-[var(--color-success)]'
: a.decision === 'reject'
? 'text-[var(--color-error)]'
: 'text-[var(--color-text-tertiary)]'
}`}
>
{t(`tasks.detail.approval.${a.decision}` as const)}
</span>
</li>
))}
</ul>
)
}
// security
if (audit.security.length === 0)
return <p className="text-xs text-[var(--color-text-tertiary)]">{t('tasks.detail.empty')}</p>
return (
<ul className="flex flex-col gap-2">
{audit.security.map((s) => (
<li
key={s.id}
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2"
>
<div className="flex items-center gap-2 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)]">
{t(`tasks.detail.security.${s.kind}` as const)}
<span>·</span>
<span>{formatHm(s.at)}</span>
</div>
<div className="mt-1 text-xs text-[var(--color-text-secondary)] leading-relaxed">
{s.text}
</div>
</li>
))}
</ul>
)
}
// ─── Shared bits ──────────────────────────────────────────────
function PanelHeader({
title,
rightCaption,
}: {
title: string
rightCaption?: string
}) {
return (
<div className="flex items-center justify-between gap-2">
<h2
className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-tertiary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{title}
</h2>
{rightCaption ? (
<span className="text-[10px] text-[var(--color-text-tertiary)]">{rightCaption}</span>
) : null}
</div>
)
}
function Section({
label,
children,
}: {
label: string
children: React.ReactNode
}) {
return (
<div className="mt-4">
<div className="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">
{label}
</div>
{children}
</div>
)
}
function formatHm(ts: number): string {
const d = new Date(ts)
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return `${hh}:${mm}`
}
+45
View File
@@ -1020,6 +1020,51 @@ export const en = {
'tasks.status.completed': 'Completed',
'tasks.status.failed': 'Failed',
'tasks.status.paused': 'Paused',
// Slice 8: execution feedback panel (wireframe §8)
'tasks.execution.title': 'Execution feedback',
'tasks.execution.subSteps': 'Current Agnet sub-steps',
'tasks.execution.skTools': 'Active SK tool calls',
'tasks.execution.events': 'Latest events',
'tasks.execution.artifacts': 'Current artifacts',
'tasks.execution.spend': 'This run',
'tasks.execution.openDetail': 'View detail',
'tasks.execution.empty': 'No execution feedback yet — waiting for Agnet to deploy.',
'tasks.subStep.done': 'Done',
'tasks.subStep.running': 'Running',
'tasks.subStep.waiting': 'Waiting',
'tasks.subStep.failed': 'Failed',
'tasks.subStep.skipped': 'Skipped',
'tasks.skCall.running': 'Running',
'tasks.skCall.ok': 'OK',
'tasks.skCall.failed': 'Failed',
// Slice 9: delivery result panel (wireframe §10)
'tasks.delivery.title': 'Delivery',
'tasks.delivery.deliverables': 'Deliverables',
'tasks.delivery.quality': 'Quality results',
'tasks.delivery.nextActions': 'Next actions',
'tasks.quality.pass': 'Pass',
'tasks.quality.fixed': 'Fixed',
'tasks.quality.pending': 'Pending',
'tasks.quality.failed': 'Failed',
// Slice 10: detail drawer
'tasks.detail.open': 'View detail',
'tasks.detail.close': 'Close',
'tasks.detail.title': 'Task detail',
'tasks.detail.tab.usage': 'Model usage',
'tasks.detail.tab.resources': 'Resource access',
'tasks.detail.tab.approvals': 'Approvals',
'tasks.detail.tab.security': 'Security log',
'tasks.detail.empty': 'No records yet.',
'tasks.detail.approval.approve': 'Approved',
'tasks.detail.approval.reject': 'Rejected',
'tasks.detail.approval.expired': 'Expired',
'tasks.detail.approval.ttl': '{minutes} min TTL',
'tasks.detail.security.lease': 'Short-lived credential issued',
'tasks.detail.security.revoke': 'Credential revoked',
'tasks.detail.security.rotate': 'Key rotated',
'login.footnote': 'Heicode talks directly to TaijiAICloud; your API key never leaves this machine.',
'login.tags.recommended': 'Recommended',
'login.tags.comingSoon': 'Coming soon',
+45
View File
@@ -1022,6 +1022,51 @@ export const zh: Record<TranslationKey, string> = {
'tasks.status.completed': '已完成',
'tasks.status.failed': '失败',
'tasks.status.paused': '已暂停',
// ─── Slice 8: 执行反馈面板(wireframe §8)──────────────
'tasks.execution.title': '执行反馈',
'tasks.execution.subSteps': '当前 Agnet 子环节',
'tasks.execution.skTools': '正在调用的能力',
'tasks.execution.events': '最新动态',
'tasks.execution.artifacts': '当前产物',
'tasks.execution.spend': '本次消耗',
'tasks.execution.openDetail': '查看详情',
'tasks.execution.empty': '尚无执行反馈,等待 Agnet 部署完成。',
'tasks.subStep.done': '已完成',
'tasks.subStep.running': '进行中',
'tasks.subStep.waiting': '等待',
'tasks.subStep.failed': '失败',
'tasks.subStep.skipped': '已跳过',
'tasks.skCall.running': '调用中',
'tasks.skCall.ok': '完成',
'tasks.skCall.failed': '失败',
// ─── Slice 9: 交付结果面板(wireframe §10)─────────────
'tasks.delivery.title': '交付结果',
'tasks.delivery.deliverables': '交付物',
'tasks.delivery.quality': '质量结果',
'tasks.delivery.nextActions': '继续动作',
'tasks.quality.pass': '通过',
'tasks.quality.fixed': '已修复',
'tasks.quality.pending': '待确认',
'tasks.quality.failed': '失败',
// ─── Slice 10: 任务详情抽屉(用量 / 资源 / 审批 / 安全)
'tasks.detail.open': '查看详情',
'tasks.detail.close': '关闭',
'tasks.detail.title': '任务详情',
'tasks.detail.tab.usage': '模型用量',
'tasks.detail.tab.resources': '资源访问',
'tasks.detail.tab.approvals': '审批记录',
'tasks.detail.tab.security': '安全记录',
'tasks.detail.empty': '暂无记录。',
'tasks.detail.approval.approve': '批准',
'tasks.detail.approval.reject': '拒绝',
'tasks.detail.approval.expired': '已过期',
'tasks.detail.approval.ttl': '{minutes} 分钟有效',
'tasks.detail.security.lease': '短期凭证签发',
'tasks.detail.security.revoke': '凭证撤销',
'tasks.detail.security.rotate': '密钥轮换',
'login.footnote': 'Heicode 直接连 TaijiAICloud,API Key 仅保存在你这台机器上。',
'login.tags.recommended': '推荐',
'login.tags.comingSoon': '即将开放',
+115
View File
@@ -0,0 +1,115 @@
// desktop/src/lib/heicodeTasksApi.ts
//
// Thin typed client for the local /api/heicode-tasks/* proxy, which forwards
// to mcp-server §6 task-orchestration endpoints (Heicode-接口契约文档.md §6).
//
// Field shapes are the canonical HeicodeTask model from heicodeTaskStore.ts.
// We keep the API surface tiny (5 methods, 1 per upstream endpoint) so the
// store can swap mock seeds for real data without touching component code.
import type {
DeliveryResult,
ExecutionState,
HeicodeTask,
TaskAudit,
} from '../stores/heicodeTaskStore'
const BASE = '/api/heicode-tasks'
interface Envelope<T> {
success: boolean
message?: string
data?: T
}
async function request<T>(
method: 'GET' | 'POST',
path: string,
body?: unknown,
): Promise<T> {
const res = await fetch(BASE + path, {
method,
headers: body !== undefined ? { 'Content-Type': 'application/json' } : {},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
})
let env: Envelope<T>
try {
env = (await res.json()) as Envelope<T>
} catch {
throw new Error(`heicode-tasks ${method} ${path}: HTTP ${res.status} (non-JSON)`)
}
if (!res.ok || env.success === false) {
throw new Error(env.message ?? `HTTP ${res.status}`)
}
if (env.data === undefined) {
throw new Error('heicode-tasks: response missing data')
}
return env.data
}
export const heicodeTasksApi = {
submitIntent(intent: string, name?: string): Promise<HeicodeTask> {
return request<HeicodeTask>('POST', '/intent', { intent, ...(name && { name }) })
},
list(opts?: {
status?: HeicodeTask['status']
limit?: number
offset?: number
}): Promise<{ items: HeicodeTask[]; total: number; offset: number; limit: number }> {
const qs = new URLSearchParams()
if (opts?.status) qs.set('status', opts.status)
if (opts?.limit !== undefined) qs.set('limit', String(opts.limit))
if (opts?.offset !== undefined) qs.set('offset', String(opts.offset))
const suffix = qs.toString() ? '?' + qs.toString() : ''
return request('GET', '/list' + suffix)
},
get(id: string): Promise<HeicodeTask> {
return request<HeicodeTask>('GET', '/' + encodeURIComponent(id))
},
answer(id: string, questionId: string, optionId: string): Promise<HeicodeTask> {
return request<HeicodeTask>(
'POST',
'/' + encodeURIComponent(id) + '/answer',
{ question_id: questionId, option_id: optionId },
)
},
appendMessage(id: string, text: string): Promise<HeicodeTask> {
return request<HeicodeTask>(
'POST',
'/' + encodeURIComponent(id) + '/messages',
{ text },
)
},
// Slice 12 — wireframe §8 execution feedback (mcp-server §7.8.2 端点 1)
execution(id: string): Promise<ExecutionState> {
return request<ExecutionState>(
'GET',
'/' + encodeURIComponent(id) + '/execution',
)
},
// Slice 13 — wireframe §10 delivery result (mcp-server §7.8.2 端点 2)
delivery(id: string): Promise<DeliveryResult> {
return request<DeliveryResult>(
'GET',
'/' + encodeURIComponent(id) + '/delivery',
)
},
// Slice 14 — wireframe §audit (mcp-server §7.8.2 端点 3, supports ?tab=)
audit(
id: string,
tab?: 'usage' | 'resources' | 'approvals' | 'security',
): Promise<TaskAudit> {
const qs = tab ? '?tab=' + tab : ''
return request<TaskAudit>(
'GET',
'/' + encodeURIComponent(id) + '/audit' + qs,
)
},
}
+31
View File
@@ -0,0 +1,31 @@
// desktop/src/lib/managerLink.ts
//
// Resolve a Heicode Manager deeplink (path-only or absolute URL) into a real
// URL and open it in the user's browser.
//
// Why a helper: mcp-server team's §7.8.5 reminder — task-card / deliverable
// `deeplink` fields are intentionally path-only (`/manager/resources?from=task`,
// `/agnet/deployments/portal-test`); the Manager domain (currently
// `code.xinghanlab.com`) may change. Keep all path→URL composition here so
// flipping the base URL is a one-line change.
//
// Future hook: read base from a runtime setting / env once Manager domain
// becomes user-configurable. For now hard-coded to production.
const MANAGER_BASE_URL = 'https://code.xinghanlab.com'
export function resolveManagerUrl(deeplink: string): string {
if (!deeplink) return MANAGER_BASE_URL
if (/^https?:\/\//i.test(deeplink)) return deeplink
const path = deeplink.startsWith('/') ? deeplink : '/' + deeplink
return MANAGER_BASE_URL + path
}
export function openManagerLink(deeplink: string): void {
const url = resolveManagerUrl(deeplink)
void import('@tauri-apps/plugin-shell')
.then(({ open }) => open(url))
.catch(() => {
window.open(url, '_blank', 'noopener,noreferrer')
})
}
+90 -10
View File
@@ -4,7 +4,7 @@
// + 最近任务) 和 §3 (追问对话 + 任务卡)。Slice 7 是 mock skeleton,
// 数据来自 useHeicodeTaskStore 的 seed。
import { useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from '../i18n'
import {
useHeicodeTaskStore,
@@ -13,6 +13,12 @@ import {
type HeicodeTask,
type HeicodeTaskStatus,
} from '../stores/heicodeTaskStore'
import {
DeliveryResultPanel,
ExecutionFeedbackPanel,
TaskDetailDrawer,
} from '../components/tasks/TaskRightPanels'
import { openManagerLink } from '../lib/managerLink'
const STATUS_TONE: Record<HeicodeTaskStatus, string> = {
draft:
@@ -37,6 +43,12 @@ export function HeicodeTasksHome() {
const currentTaskId = useHeicodeTaskStore((s) => s.currentTaskId)
const submitIntent = useHeicodeTaskStore((s) => s.submitIntent)
const openTask = useHeicodeTaskStore((s) => s.openTask)
const loadTasks = useHeicodeTaskStore((s) => s.loadTasks)
// Hydrate from mcp-server §6 on mount; fail-soft (mock seeds stay visible).
useEffect(() => {
void loadTasks()
}, [loadTasks])
const currentTask = useMemo(
() => (currentTaskId ? tasks.find((x) => x.id === currentTaskId) ?? null : null),
@@ -180,14 +192,46 @@ function Home({
</section>
{/* Manager footer hint */}
<footer className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 text-xs text-[var(--color-text-tertiary)]">
{tCopy('tasks.home.managerHint')}
<footer className="flex items-center justify-between gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 text-xs text-[var(--color-text-tertiary)]">
<span className="flex-1">{tCopy('tasks.home.managerHint')}</span>
<ModePill />
</footer>
</div>
</div>
)
}
// Tiny status pill showing whether the store is hydrated from mcp-server §6
// or still on mock seeds. Helps QA tell at a glance which path is hit.
function ModePill() {
const mode = useHeicodeTaskStore((s) => s.mode)
const error = useHeicodeTaskStore((s) => s.errorMessage)
const tone =
mode === 'live'
? 'border-[var(--color-success)]/30 bg-[var(--color-success)]/10 text-[var(--color-success)]'
: mode === 'error'
? 'border-[var(--color-error)]/30 bg-[var(--color-error)]/10 text-[var(--color-error)]'
: mode === 'loading'
? 'border-[var(--color-secondary)]/30 bg-[var(--color-secondary)]/10 text-[var(--color-secondary)]'
: 'border-[var(--color-border)] bg-[var(--color-surface-container)] text-[var(--color-text-tertiary)]'
const label =
mode === 'live'
? 'live · §6'
: mode === 'error'
? 'mock · 离线'
: mode === 'loading'
? '加载中…'
: 'mock'
return (
<span
className={`shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${tone}`}
title={error ?? undefined}
>
{label}
</span>
)
}
// ─── Workspace (wireframe §3 + task card) ────────────────────
function TaskWorkspace({ task }: { task: HeicodeTask }) {
@@ -196,6 +240,7 @@ function TaskWorkspace({ task }: { task: HeicodeTask }) {
const answerFollowup = useHeicodeTaskStore((s) => s.answerFollowup)
const appendMessage = useHeicodeTaskStore((s) => s.appendMessage)
const [draft, setDraft] = useState('')
const [detailOpen, setDetailOpen] = useState(false)
const handleSend = () => {
const text = draft.trim()
@@ -204,6 +249,9 @@ function TaskWorkspace({ task }: { task: HeicodeTask }) {
setDraft('')
}
const showDetailButton = !!task.audit
const rightPanel = pickRightPanel(task)
return (
<div className="relative flex flex-1 flex-col overflow-hidden bg-[var(--color-surface)]">
{/* Workspace header */}
@@ -220,11 +268,22 @@ function TaskWorkspace({ task }: { task: HeicodeTask }) {
{task.name}
</h1>
</div>
<span
className={`shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${STATUS_TONE[task.status]}`}
>
{t(`tasks.status.${task.status}` as const)}
</span>
<div className="flex items-center gap-2">
{showDetailButton ? (
<button
type="button"
onClick={() => setDetailOpen(true)}
className="rounded-[var(--radius-md)] border border-[var(--color-border)] px-2 py-1 text-xs text-[var(--color-text-secondary)] hover:border-[var(--color-border-hi,rgba(255,255,255,0.14))] hover:text-[var(--color-text-primary)]"
>
{t('tasks.detail.open')}
</button>
) : null}
<span
className={`shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${STATUS_TONE[task.status]}`}
>
{t(`tasks.status.${task.status}` as const)}
</span>
</div>
</header>
<div className="flex flex-1 min-h-0 overflow-hidden">
@@ -271,13 +330,30 @@ function TaskWorkspace({ task }: { task: HeicodeTask }) {
</div>
</div>
{/* Task card sidebar */}
{task.card ? <TaskCardPanel task={task} /> : null}
{/* Right side panel: status-driven */}
{rightPanel === 'execution' ? <ExecutionFeedbackPanel task={task} /> : null}
{rightPanel === 'delivery' ? <DeliveryResultPanel task={task} /> : null}
{rightPanel === 'card' ? <TaskCardPanel task={task} /> : null}
</div>
<TaskDetailDrawer
task={task}
open={detailOpen}
onClose={() => setDetailOpen(false)}
/>
</div>
)
}
function pickRightPanel(task: HeicodeTask): 'execution' | 'delivery' | 'card' | null {
if (task.status === 'completed' && task.delivery) return 'delivery'
if ((task.status === 'running' || task.status === 'awaiting_approval') && task.execution) {
return 'execution'
}
if (task.card) return 'card'
return null
}
function ThreadTurn({
turn,
onAnswer,
@@ -396,6 +472,7 @@ function TaskCardPanel({ task }: { task: HeicodeTask }) {
<button
key={a.label}
type="button"
onClick={() => openManagerLink(a.deeplink)}
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container)] px-2 py-1 text-[11px] text-[var(--color-text-secondary)] hover:border-[var(--color-border-hi,rgba(255,255,255,0.14))] hover:text-[var(--color-text-primary)]"
title={`Manager · ${a.deeplink}`}
>
@@ -414,6 +491,9 @@ function TaskCardPanel({ task }: { task: HeicodeTask }) {
</button>
<button
type="button"
onClick={() =>
openManagerLink(manager_actions[0]?.deeplink ?? '/manager')
}
className="rounded-[var(--radius-md)] border border-[var(--color-primary)]/40 bg-[var(--color-primary)] px-3 py-1.5 text-xs font-medium text-[var(--color-on-primary)] hover:bg-[var(--color-primary-fixed-dim)]"
>
{t('tasks.card.openManager')}
+430 -20
View File
@@ -11,6 +11,7 @@
// and keep the same shapes.
import { create } from 'zustand'
import { heicodeTasksApi } from '../lib/heicodeTasksApi'
export type HeicodeTaskStatus =
| 'draft'
@@ -43,6 +44,122 @@ export type ChatTurn =
| { kind: 'user'; text: string; at: number }
| { kind: 'heicode'; text: string; at: number; followups?: FollowupQuestion[] }
// ─── Slice 8: execution feedback ─────────────────────────────
// wireframe §8. Mock now; swap for agent-manager events when P5 stub
// lands (mcp-server §7.5 阻塞项 #2).
export type SubStepStatus = 'done' | 'running' | 'waiting' | 'failed' | 'skipped'
export type SubStep = {
id: string
label: string
status: SubStepStatus
caption?: string
}
export type SkToolCall = {
id: string
tool: string
caption?: string
status: 'running' | 'ok' | 'failed'
ended_at?: number
}
export type ExecutionEvent = {
id: string
at: number
text: string
level?: 'info' | 'warn' | 'error'
}
export type Artifact = {
id: string
label: string
kind: 'doc' | 'api' | 'diff' | 'report'
}
export type ExecutionState = {
sub_steps: SubStep[]
sk_tool_calls: SkToolCall[]
events: ExecutionEvent[]
artifacts: Artifact[]
spend_today?: string
}
// ─── Slice 9: delivery result ────────────────────────────────
// wireframe §10. Mock now; swap for agent-manager final-result events.
export type DeliverableKind =
| 'product-spec'
| 'code-diff'
| 'test-env'
| 'prod-env'
export type Deliverable = {
id: string
label: string
kind: DeliverableKind
hint?: string
primary_action?: { label: string; deeplink: string }
secondary_action?: { label: string; deeplink: string }
}
export type QualityCheck = {
id: string
label: string
status: 'pass' | 'fixed' | 'pending' | 'failed'
detail?: string
}
export type DeliveryResult = {
summary: string
deliverables: Deliverable[]
quality: QualityCheck[]
next_actions: Array<{ label: string; intent: string }>
}
// ─── Slice 10: detail drawer (usage / audit) ─────────────────
// wireframe §audit + 10-frontend §任务用量与审计.
export type UsageRecord = {
id: string
at: number
model: string
caption: string
cost: string
status: 'ok' | 'failed'
}
export type ResourceAccess = {
id: string
at: number
resource: string
action: string
role: string
}
export type ApprovalRecord = {
id: string
at: number
operation: string
decision: 'approve' | 'reject' | 'expired'
ttl_minutes?: number
}
export type SecurityRecord = {
id: string
at: number
text: string
kind: 'rotate' | 'lease' | 'revoke'
}
export type TaskAudit = {
usage: UsageRecord[]
resources: ResourceAccess[]
approvals: ApprovalRecord[]
security: SecurityRecord[]
}
export type HeicodeTask = {
id: string
name: string
@@ -53,17 +170,39 @@ export type HeicodeTask = {
intent: string
thread: ChatTurn[]
card?: TaskCard
execution?: ExecutionState
delivery?: DeliveryResult
audit?: TaskAudit
}
// `mode` records whether the store is hydrated from the real mcp-server §6
// task-orchestration endpoints or still showing seeded mock data. UI doesn't
// branch on it today, but exposing it lets headers / banners signal "demo
// data" later if needed.
export type StoreMode = 'mock' | 'live' | 'loading' | 'error'
type State = {
tasks: HeicodeTask[]
currentTaskId: string | null
mode: StoreMode
errorMessage: string | null
submitIntent: (intent: string) => string
loadTasks: () => Promise<void>
loadExecution: (taskId: string) => Promise<void>
loadDelivery: (taskId: string) => Promise<void>
loadAudit: (
taskId: string,
tab?: 'usage' | 'resources' | 'approvals' | 'security',
) => Promise<void>
submitIntent: (intent: string) => Promise<string>
openTask: (taskId: string) => void
closeTask: () => void
answerFollowup: (taskId: string, questionId: string, optionId: string) => void
appendMessage: (taskId: string, text: string) => void
answerFollowup: (
taskId: string,
questionId: string,
optionId: string,
) => Promise<void>
appendMessage: (taskId: string, text: string) => Promise<void>
}
const now = Date.now()
@@ -143,6 +282,50 @@ const SEEDED: HeicodeTask[] = [
{ label: '查看预算与审计', deeplink: '/audit' },
],
},
execution: {
sub_steps: [
{ id: 's1', label: '需求和原型', status: 'done', caption: 'Product 已生成 5 项需求' },
{ id: 's2', label: '后端开发', status: 'running', caption: 'Backend 正在生成 task / comment 模块' },
{ id: 's3', label: '前端开发', status: 'waiting', caption: '等待接口确认' },
{ id: 's4', label: '代码检查', status: 'waiting', caption: '等待代码变更' },
{ id: 's5', label: '测试部署', status: 'waiting', caption: '等待构建' },
],
sk_tool_calls: [
{ id: 'sk1', tool: 'e2e-test', caption: '占位调用 · 待后端就绪', status: 'running' },
{ id: 'sk2', tool: 'api-review', caption: '后端接口 schema 校对', status: 'ok', ended_at: now - 22 * 60_000 },
{ id: 'sk3', tool: 'deploy-check', caption: '检查 aks-test 命名空间', status: 'ok', ended_at: now - 40 * 60_000 },
],
events: [
{ id: 'e1', at: now - 14 * 60_000, text: 'Backend:开始生成 task/comment 数据模型' },
{ id: 'e2', at: now - 22 * 60_000, text: 'api-review:接口草案 v0.2 通过' },
{ id: 'e3', at: now - 40 * 60_000, text: 'Product:第一版需求清单已确认' },
],
artifacts: [
{ id: 'a1', label: '产品说明', kind: 'doc' },
{ id: 'a2', label: '接口草案 v0.2', kind: 'api' },
{ id: 'a3', label: '代码变更(进行中)', kind: 'diff' },
{ id: 'a4', label: '检查报告(待生成)', kind: 'report' },
],
spend_today: '¥12.30',
},
audit: {
usage: [
{ id: 'u1', at: now - 30 * 60_000, model: 'heicode-code', caption: 'Backend · 数据模型生成', cost: '¥3.40', status: 'ok' },
{ id: 'u2', at: now - 65 * 60_000, model: 'heicode-code', caption: 'Product · 需求拆解', cost: '¥5.10', status: 'ok' },
{ id: 'u3', at: now - 110 * 60_000, model: 'heicode-fast', caption: 'api-review · 接口草案校对', cost: '¥1.80', status: 'ok' },
{ id: 'u4', at: now - 180 * 60_000, model: 'heicode-fast', caption: 'deploy-check · aks-test 探测', cost: '¥2.00', status: 'ok' },
],
resources: [
{ id: 'r1', at: now - 4 * 3600_000, resource: 'repo-main / main', action: 'read', role: 'Backend' },
{ id: 'r2', at: now - 4 * 3600_000, resource: 'aks-test', action: 'inspect', role: 'Ops' },
{ id: 'r3', at: now - 4 * 3600_000, resource: 'db-dev', action: 'list-schema', role: 'Backend' },
],
approvals: [],
security: [
{ id: 'sec1', at: now - 4 * 3600_000, text: 'aks-test 短期凭证签发,TTL 15 分钟', kind: 'lease' },
{ id: 'sec2', at: now - 3.5 * 3600_000, text: 'aks-test 短期凭证到期,已撤销', kind: 'revoke' },
],
},
},
{
id: 'task-feishu-002',
@@ -160,6 +343,90 @@ const SEEDED: HeicodeTask[] = [
},
],
},
{
id: 'task-portal-003',
name: '团队站内信门户',
status: 'completed',
status_caption: '已交付 · 测试环境上线',
created_at: now - 5 * 24 * 3600_000,
updated_at: now - 2 * 3600_000,
intent: '做一个公司内部站内信门户,能给指定群组发公告,登录复用现有 SSO。',
thread: [
{
kind: 'user',
text: '做一个公司内部站内信门户,能给指定群组发公告,登录复用现有 SSO。',
at: now - 5 * 24 * 3600_000,
},
],
delivery: {
summary: '第一版已完成,Agnet 已整理交付物并完成测试环境部署。',
deliverables: [
{
id: 'd1',
label: '产品说明',
kind: 'product-spec',
hint: '15 页 · 含原型描述',
primary_action: { label: '查看', deeplink: '/artifacts/portal/spec' },
secondary_action: { label: '导出 PDF', deeplink: '/artifacts/portal/spec.pdf' },
},
{
id: 'd2',
label: '代码变更',
kind: 'code-diff',
hint: '+1,840 / -120 · 27 文件',
primary_action: { label: '查看 diff', deeplink: '/artifacts/portal/diff' },
},
{
id: 'd3',
label: '测试环境',
kind: 'test-env',
hint: 'aks-test · portal.test.local',
primary_action: { label: '打开服务', deeplink: 'https://portal.test.local' },
secondary_action: { label: '部署记录', deeplink: '/agnet/deployments/portal-test' },
},
{
id: 'd4',
label: '生产环境',
kind: 'prod-env',
hint: '待你确认后可继续',
primary_action: { label: '请求生产部署', deeplink: '/agnet/deploy/portal-prod' },
},
],
quality: [
{ id: 'q1', label: '代码检查', status: 'pass', detail: 'Reviewer 通过' },
{ id: 'q2', label: '安全检查', status: 'fixed', detail: '1 个问题已修复 · CSRF token 缺失' },
{ id: 'q3', label: '测试环境', status: 'pass', detail: '部署成功 · 烟雾测试通过' },
{ id: 'q4', label: '生产环境', status: 'pending', detail: '待你确认后可继续' },
],
next_actions: [
{ label: '继续迭代', intent: 'continue-iterate' },
{ label: '发起维护任务', intent: 'maintenance' },
{ label: '查看 Agnet 部署信息', intent: 'view-deployment' },
],
},
audit: {
usage: [
{ id: 'u1', at: now - 4 * 3600_000, model: 'heicode-code', caption: 'Frontend · 页面生成', cost: '¥18.20', status: 'ok' },
{ id: 'u2', at: now - 12 * 3600_000, model: 'heicode-code', caption: 'Backend · API 实现', cost: '¥22.40', status: 'ok' },
{ id: 'u3', at: now - 36 * 3600_000, model: 'heicode-code', caption: 'Reviewer · 代码检查', cost: '¥6.80', status: 'ok' },
{ id: 'u4', at: now - 60 * 3600_000, model: 'heicode-fast', caption: 'Product · 需求拆解', cost: '¥3.10', status: 'ok' },
],
resources: [
{ id: 'r1', at: now - 5 * 24 * 3600_000, resource: 'repo-portal / main', action: 'write', role: 'Backend' },
{ id: 'r2', at: now - 4 * 24 * 3600_000, resource: 'aks-test', action: 'deploy', role: 'Ops' },
{ id: 'r3', at: now - 4 * 24 * 3600_000, resource: 'db-dev', action: 'migrate', role: 'Backend' },
],
approvals: [
{ id: 'ap1', at: now - 4 * 24 * 3600_000, operation: 'aks-test 部署', decision: 'approve', ttl_minutes: 15 },
{ id: 'ap2', at: now - 3 * 24 * 3600_000, operation: 'db-dev 写入', decision: 'approve', ttl_minutes: 30 },
],
security: [
{ id: 'sec1', at: now - 4 * 24 * 3600_000, text: 'aks-test 短期凭证签发,TTL 15 分钟', kind: 'lease' },
{ id: 'sec2', at: now - 4 * 24 * 3600_000, text: 'aks-test 短期凭证到期,已撤销', kind: 'revoke' },
{ id: 'sec3', at: now - 3 * 24 * 3600_000, text: 'GitHub PAT 自动轮换', kind: 'rotate' },
],
},
},
]
const seedFollowups = (): FollowupQuestion[] => [
@@ -191,19 +458,120 @@ const seedFollowups = (): FollowupQuestion[] => [
},
]
export const useHeicodeTaskStore = create<State>((set) => ({
// Replace one task (by id) in the array; if absent, prepend.
function upsert(tasks: HeicodeTask[], t: HeicodeTask): HeicodeTask[] {
const idx = tasks.findIndex(x => x.id === t.id)
if (idx === -1) return [t, ...tasks]
const next = tasks.slice()
next[idx] = t
return next
}
// Slice 12/13/14 lazy-fetch gate: only hit upstream when (a) the store is
// in `live` mode (real /list returned data, or a real submitIntent ran)
// AND (b) the task id doesn't look like a seeded / optimistic mock id.
// Real mcp-server task ids are UUIDs (per §6 contract), seed ids start
// with `task-`.
function shouldFetchPanel(
state: { mode: StoreMode },
taskId: string,
): boolean {
if (state.mode !== 'live') return false
if (taskId.startsWith('task-')) return false
return true
}
function errMsg(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
export const useHeicodeTaskStore = create<State>((set, get) => ({
tasks: SEEDED,
currentTaskId: null,
mode: 'mock',
errorMessage: null,
submitIntent: (intent) => {
// Lazy-fetch helpers (Slice 12/13/14). Each fetches one panel's data and
// patches the matching task in place. Skip when the store is on seeded
// mock data (id starts with `task-`) — those rows already have local
// demo fixtures and the upstream endpoint would 404.
loadExecution: async (taskId) => {
if (!shouldFetchPanel(get(), taskId)) return
try {
const ex = await heicodeTasksApi.execution(taskId)
set(s => ({
tasks: s.tasks.map(t =>
t.id === taskId ? { ...t, execution: ex, updated_at: Date.now() } : t,
),
mode: 'live',
errorMessage: null,
}))
} catch (err) {
set({ mode: 'error', errorMessage: errMsg(err) })
}
},
loadDelivery: async (taskId) => {
if (!shouldFetchPanel(get(), taskId)) return
try {
const d = await heicodeTasksApi.delivery(taskId)
set(s => ({
tasks: s.tasks.map(t =>
t.id === taskId ? { ...t, delivery: d, updated_at: Date.now() } : t,
),
mode: 'live',
errorMessage: null,
}))
} catch (err) {
set({ mode: 'error', errorMessage: errMsg(err) })
}
},
loadAudit: async (taskId, tab) => {
if (!shouldFetchPanel(get(), taskId)) return
try {
const a = await heicodeTasksApi.audit(taskId, tab)
set(s => ({
tasks: s.tasks.map(t =>
t.id === taskId ? { ...t, audit: a, updated_at: Date.now() } : t,
),
mode: 'live',
errorMessage: null,
}))
} catch (err) {
set({ mode: 'error', errorMessage: errMsg(err) })
}
},
// Hydrate from mcp-server §6 GET /api/user/tasks. Called by the home
// component on mount; safely no-ops if user isn't logged in (proxy returns
// 401 → we drop back to seeded mock + a soft error banner if we add one).
loadTasks: async () => {
set({ mode: 'loading', errorMessage: null })
try {
const data = await heicodeTasksApi.list({ limit: 50 })
set({
tasks: data.items.length > 0 ? data.items : SEEDED,
mode: data.items.length > 0 ? 'live' : 'mock',
})
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
// Keep seeded mock visible so the UI still has something to show.
set({ mode: 'error', errorMessage: msg })
}
},
// Optimistic insert + real round-trip. On success we replace the temp row
// with the server's authoritative task (real id + real followups).
submitIntent: async (intent) => {
const trimmed = intent.trim()
if (!trimmed) return ''
const id = `task-${Date.now().toString(36)}`
const t: HeicodeTask = {
id,
const tempId = `task-${Date.now().toString(36)}`
const optimistic: HeicodeTask = {
id: tempId,
name: trimmed.length > 28 ? trimmed.slice(0, 26) + '…' : trimmed,
status: 'draft',
status_caption: '草案 · 等你回答追问',
status_caption: '提交中…',
created_at: Date.now(),
updated_at: Date.now(),
intent: trimmed,
@@ -217,25 +585,44 @@ export const useHeicodeTaskStore = create<State>((set) => ({
},
],
}
set((s) => ({ tasks: [t, ...s.tasks], currentTaskId: id }))
return id
set(s => ({ tasks: [optimistic, ...s.tasks], currentTaskId: tempId }))
try {
const real = await heicodeTasksApi.submitIntent(trimmed)
set(s => ({
tasks: s.tasks.map(t => (t.id === tempId ? real : t)),
currentTaskId: real.id,
mode: 'live',
errorMessage: null,
}))
return real.id
} catch (err) {
// Leave the optimistic row in place so the user can keep working in
// mock mode; surface the error so the home page can show a hint.
const msg = err instanceof Error ? err.message : String(err)
set({ mode: 'error', errorMessage: msg })
return tempId
}
},
openTask: (taskId) => set({ currentTaskId: taskId }),
closeTask: () => set({ currentTaskId: null }),
answerFollowup: (taskId, questionId, optionId) =>
set((s) => ({
tasks: s.tasks.map((t) => {
// Optimistic answer + real round-trip. Server response replaces the task
// (it may add new heicode turns / generate the task card when all
// followups are answered).
answerFollowup: async (taskId, questionId, optionId) => {
set(s => ({
tasks: s.tasks.map(t => {
if (t.id !== taskId) return t
return {
...t,
updated_at: Date.now(),
thread: t.thread.map((turn) =>
thread: t.thread.map(turn =>
turn.kind === 'heicode' && turn.followups
? {
...turn,
followups: turn.followups.map((q) =>
followups: turn.followups.map(q =>
q.id === questionId ? { ...q, answer: optionId } : q,
),
}
@@ -243,13 +630,26 @@ export const useHeicodeTaskStore = create<State>((set) => ({
),
}
}),
})),
}))
appendMessage: (taskId, text) => {
// Local-only optimistic temp tasks (id starts with 'task-' + ts) won't
// exist on the server; skip the round-trip.
if (get().mode !== 'live' && taskId.startsWith('task-')) return
try {
const real = await heicodeTasksApi.answer(taskId, questionId, optionId)
set(s => ({ tasks: upsert(s.tasks, real), mode: 'live', errorMessage: null }))
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
set({ mode: 'error', errorMessage: msg })
}
},
appendMessage: async (taskId, text) => {
const trimmed = text.trim()
if (!trimmed) return
set((s) => ({
tasks: s.tasks.map((t) =>
set(s => ({
tasks: s.tasks.map(t =>
t.id === taskId
? {
...t,
@@ -262,5 +662,15 @@ export const useHeicodeTaskStore = create<State>((set) => ({
: t,
),
}))
if (get().mode !== 'live' && taskId.startsWith('task-')) return
try {
const real = await heicodeTasksApi.appendMessage(taskId, trimmed)
set(s => ({ tasks: upsert(s.tasks, real), mode: 'live', errorMessage: null }))
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
set({ mode: 'error', errorMessage: msg })
}
},
}))
+186
View File
@@ -0,0 +1,186 @@
/**
* Heicode Tasks proxy — wraps mcp-server §6 task-orchestration endpoints
* (Heicode-接口契约文档.md §6, contract v2.0 / 2026-05-08).
*
* Five upstream endpoints:
* POST /api/user/tasks/intent
* GET /api/user/tasks
* GET /api/user/tasks/{id}
* POST /api/user/tasks/{id}/answer
* POST /api/user/tasks/{id}/messages
*
* Why proxy from the desktop's local Bun server (rather than calling
* mcp-server directly from the browser):
* - Reuses the active-provider's mcpAuth.accessToken without exposing it
* to the frontend
* - Keeps managerLoginUrl resolution server-side (single source of truth)
* - Gives us a place to translate upstream errors into the cc-haha
* ApiError taxonomy without leaking provider internals
*/
import { ProviderService } from '../services/providerService.js'
import { ApiError } from '../middleware/errorHandler.js'
const providerService = new ProviderService()
const DEFAULT_MANAGER_BASE = 'https://apimtaiji.azure-api.net/api/mcp'
interface UpstreamCtx {
baseUrl: string
accessToken: string
}
async function resolveUpstream(): Promise<UpstreamCtx> {
const { providers, activeId } = await providerService.listProviders()
const active = activeId ? providers.find(p => p.id === activeId) : null
const tok = active?.mcpAuth?.accessToken
if (!active || !tok) {
throw ApiError.unauthorized(
'Heicode 未登录或缺少 mcp-server 凭证;请先通过登录页登入。',
)
}
const base = (active.mcpAuth?.managerLoginUrl ?? DEFAULT_MANAGER_BASE).replace(
/\/+$/,
'',
)
return { baseUrl: base, accessToken: tok }
}
async function forwardJson(
method: 'GET' | 'POST',
path: string,
body: unknown | undefined,
): Promise<Response> {
const { baseUrl, accessToken } = await resolveUpstream()
const url = baseUrl + path
const init: RequestInit = {
method,
headers: {
Authorization: `Bearer ${accessToken}`,
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
}
let upstream: Response
try {
upstream = await fetch(url, init)
} catch (err) {
console.error('[heicode-tasks] upstream fetch failed', { url, err })
throw ApiError.badGateway('mcp-server 不可达;请检查网络或稍后再试。')
}
const text = await upstream.text()
// Pass through JSON body + status; mcp-server already returns the
// {success, message, data} envelope cc-haha frontend expects.
return new Response(text, {
status: upstream.status,
headers: { 'Content-Type': 'application/json; charset=utf-8' },
})
}
async function readBody(req: Request): Promise<unknown> {
const text = await req.text()
if (!text) return {}
try {
return JSON.parse(text)
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
}
export async function handleHeicodeTasksApi(
req: Request,
url: URL,
segments: string[],
): Promise<Response> {
try {
// segments: ['api', 'heicode-tasks', ...]
const action = segments[2] // 'intent' | 'list' | <task_id>
const sub = segments[3] // 'answer' | 'messages' | undefined
// POST /api/heicode-tasks/intent
if (action === 'intent' && req.method === 'POST') {
const body = await readBody(req)
return await forwardJson('POST', '/api/user/tasks/intent', body)
}
// GET /api/heicode-tasks/list?status=&limit=&offset=
if (action === 'list' && req.method === 'GET') {
const qs = url.search // includes leading '?' or empty
return await forwardJson('GET', '/api/user/tasks' + qs, undefined)
}
// GET /api/heicode-tasks/:id
if (action && !sub && req.method === 'GET') {
return await forwardJson(
'GET',
'/api/user/tasks/' + encodeURIComponent(action),
undefined,
)
}
// POST /api/heicode-tasks/:id/answer
if (action && sub === 'answer' && req.method === 'POST') {
const body = await readBody(req)
return await forwardJson(
'POST',
'/api/user/tasks/' + encodeURIComponent(action) + '/answer',
body,
)
}
// POST /api/heicode-tasks/:id/messages
if (action && sub === 'messages' && req.method === 'POST') {
const body = await readBody(req)
return await forwardJson(
'POST',
'/api/user/tasks/' + encodeURIComponent(action) + '/messages',
body,
)
}
// GET /api/heicode-tasks/:id/execution (Slice 12, §7.8.2)
if (action && sub === 'execution' && req.method === 'GET') {
return await forwardJson(
'GET',
'/api/user/tasks/' + encodeURIComponent(action) + '/execution',
undefined,
)
}
// GET /api/heicode-tasks/:id/delivery (Slice 13, §7.8.2)
if (action && sub === 'delivery' && req.method === 'GET') {
return await forwardJson(
'GET',
'/api/user/tasks/' + encodeURIComponent(action) + '/delivery',
undefined,
)
}
// GET /api/heicode-tasks/:id/audit?tab=... (Slice 14, §7.8.2)
if (action && sub === 'audit' && req.method === 'GET') {
const qs = url.search
return await forwardJson(
'GET',
'/api/user/tasks/' + encodeURIComponent(action) + '/audit' + qs,
undefined,
)
}
return new Response(
JSON.stringify({ success: false, message: 'Not found' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } },
)
} catch (err) {
if (err instanceof ApiError) {
return new Response(
JSON.stringify({ success: false, message: err.message }),
{ status: err.statusCode, headers: { 'Content-Type': 'application/json' } },
)
}
console.error('[heicode-tasks] unexpected error', { err })
return new Response(
JSON.stringify({ success: false, message: 'Internal error' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } },
)
}
}
+4
View File
@@ -19,6 +19,7 @@ import { handleSkillsApi } from './api/skills.js'
import { handleComputerUseApi } from './api/computer-use.js'
import { handleHahaOAuthApi } from './api/haha-oauth.js'
import { handleHeicodeAuthApi } from './api/heicode-auth.js'
import { handleHeicodeTasksApi } from './api/heicode-tasks.js'
import { handleMcpApi } from './api/mcp.js'
export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
@@ -76,6 +77,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
case 'heicode-auth':
return handleHeicodeAuthApi(req, url, segments)
case 'heicode-tasks':
return handleHeicodeTasksApi(req, url, segments)
case 'adapters':
return handleAdaptersApi(req, url, segments)