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:
@@ -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}`
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '即将开放',
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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' } },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user