feat(goal-run): add multi-checkpoint goal orchestration with H5 and admin surfaces.

Persist goal runs in MySQL, bind agent runs to checkpoints, expose awaiting-approval
UX in chat, and add admin inspection routes with local verify scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-01 17:03:16 +08:00
parent 43bc8bbc2b
commit 666db0b939
47 changed files with 4417 additions and 4 deletions
+22
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useChat } from '../context/ChatProvider';
import { INSUFFICIENT_BALANCE_NOTICE } from '../hooks/useTKMindChat';
import { useGoalRunBanner } from '../hooks/useGoalRunBanner';
import type { CapabilityMap, PortalUser, SessionSummary } from '../types';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { getSessionDisplayName } from '../utils/sessions';
@@ -15,6 +16,7 @@ import { WechatBindPrompt } from './WechatBindPrompt';
import { WechatAccountButton } from './WechatAccountButton';
import { ChatHeaderMoreMenu } from './ChatHeaderMoreMenu';
import { NotificationCenter } from './NotificationCenter';
import { GoalRunAwaitingBanner } from './GoalRunAwaitingBanner';
import type { MindSpaceSaveCategory } from '../types';
function RecentSessionRail({
@@ -212,8 +214,18 @@ export function ChatView({
openSubscribe,
uploadChatImage,
uploadChatAttachment,
followAgentRun,
goalRunEnabled,
} = useChat();
const online = useNetworkStatus();
const goalRunBanner = useGoalRunBanner({
userId: user?.id,
sessionId: session?.id,
chatState,
submit,
followAgentRun,
featureEnabled: goalRunEnabled,
});
const [sidebarOpen, setSidebarOpen] = useState(false);
const [packagePanelSession, setPackagePanelSession] = useState<SessionSummary | null>(null);
const [armedSessionId, setArmedSessionId] = useState<string | null>(null);
@@ -487,6 +499,16 @@ export function ChatView({
{user && <WechatBindPrompt returnTo={window.location.pathname} />}
{goalRunBanner.enabled && (
<GoalRunAwaitingBanner
items={goalRunBanner.items}
busy={goalRunBanner.bannerBusy}
onContinue={goalRunBanner.handleContinue}
onDismiss={goalRunBanner.dismissItem}
onRefresh={goalRunBanner.refresh}
/>
)}
{typeof balanceCents === 'number' && balanceCents <= 0 && (
<div className="banner banner-error">
<span>使</span>
+132
View File
@@ -0,0 +1,132 @@
import { useState } from 'react';
import { cancelGoalRun, type GoalRunAwaitingItem } from '../api/goalRun';
type GoalRunAwaitingBannerProps = {
items: GoalRunAwaitingItem[];
busy: boolean;
onContinue: (input: {
goalRunId: string;
checkpointId: string;
feedback?: string | null;
}) => Promise<void>;
onDismiss: (goalRunId: string) => void;
onRefresh: () => Promise<void>;
};
function GoalRunAwaitingCard({
item,
busy,
onContinue,
onDismiss,
onRefresh,
}: {
item: GoalRunAwaitingItem;
busy: boolean;
onContinue: GoalRunAwaitingBannerProps['onContinue'];
onDismiss: (goalRunId: string) => void;
onRefresh: () => Promise<void>;
}) {
const [feedback, setFeedback] = useState('');
const [action, setAction] = useState<'continue' | 'cancel' | null>(null);
const [localError, setLocalError] = useState<string | null>(null);
const { goal, checkpoint } = item;
const summary = checkpoint.outputSummary?.trim();
const handleContinue = async () => {
setLocalError(null);
setAction('continue');
try {
await onContinue({
goalRunId: goal.id,
checkpointId: checkpoint.id,
feedback: feedback.trim() || null,
});
onDismiss(goal.id);
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err));
await onRefresh();
} finally {
setAction(null);
}
};
const handleCancel = async () => {
setLocalError(null);
setAction('cancel');
try {
await cancelGoalRun(goal.id);
onDismiss(goal.id);
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err));
await onRefresh();
} finally {
setAction(null);
}
};
const cardBusy = busy || action != null;
return (
<div className="banner banner-info goal-run-awaiting-banner">
<div className="goal-run-awaiting-banner-body">
<strong>{goal.title}</strong>
<span>
{checkpoint.title}
</span>
{summary ? <span className="goal-run-awaiting-summary">{summary}</span> : null}
<textarea
className="goal-run-awaiting-feedback"
rows={2}
placeholder="可选:补充反馈或修改方向"
value={feedback}
disabled={cardBusy}
onChange={(event) => setFeedback(event.target.value)}
/>
{localError ? <span className="goal-run-awaiting-error">{localError}</span> : null}
</div>
<div className="goal-run-awaiting-actions">
<button
type="button"
className="banner-action"
disabled={cardBusy}
onClick={() => void handleContinue()}
>
{action === 'continue' ? '处理中…' : '确认并继续'}
</button>
<button
type="button"
className="banner-dismiss"
disabled={cardBusy}
onClick={() => void handleCancel()}
>
{action === 'cancel' ? '取消中…' : '取消目标'}
</button>
</div>
</div>
);
}
export function GoalRunAwaitingBanner({
items,
busy,
onContinue,
onDismiss,
onRefresh,
}: GoalRunAwaitingBannerProps) {
if (!items.length) return null;
return (
<div className="goal-run-awaiting-stack">
{items.map((item) => (
<GoalRunAwaitingCard
key={item.goal.id}
item={item}
busy={busy}
onContinue={onContinue}
onDismiss={onDismiss}
onRefresh={onRefresh}
/>
))}
</div>
);
}