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:
@@ -16,6 +16,7 @@ import { WechatPage } from './pages/admin/WechatPage';
|
||||
import { SystemPolicyPage } from './pages/admin/SystemPolicyPage';
|
||||
import { OrchestratorPage } from './pages/admin/OrchestratorPage';
|
||||
import { LlmProvidersPage } from './pages/admin/LlmProvidersPage';
|
||||
import { GoalRunPage } from './pages/admin/GoalRunPage';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
@@ -52,6 +53,7 @@ export function App() {
|
||||
<Route path="policy" element={<SystemPolicyPage />} />
|
||||
<Route path="orchestrator" element={<OrchestratorPage />} />
|
||||
<Route path="llm" element={<LlmProvidersPage />} />
|
||||
<Route path="goal-runs" element={<GoalRunPage />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -893,3 +893,126 @@ export async function patchMemoryV2Config(patch: Record<string, unknown>) {
|
||||
export async function fetchMemoryV2Runtime() {
|
||||
return adminFetch<MemoryV2RuntimeState>('/admin-api/memory-v2/runtime');
|
||||
}
|
||||
export type GoalRunRuntimeState = {
|
||||
enabled: boolean;
|
||||
canaryUserIds: string[];
|
||||
canaryMode: boolean;
|
||||
};
|
||||
|
||||
export type GoalRunSummaryResponse = {
|
||||
counts: Record<string, number>;
|
||||
runtime: GoalRunRuntimeState;
|
||||
};
|
||||
|
||||
export type GoalRunListItem = {
|
||||
id: string;
|
||||
userId: string;
|
||||
username: string | null;
|
||||
title: string;
|
||||
intentSummary: string;
|
||||
status: string;
|
||||
priority: number;
|
||||
sourceChannel: string;
|
||||
sourceSessionId: string | null;
|
||||
currentCheckpointId: string | null;
|
||||
checkpointCount: number;
|
||||
activeAgentRunCount: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
completedAt: number | null;
|
||||
};
|
||||
|
||||
export type GoalRunCheckpointRow = {
|
||||
id: string;
|
||||
goalRunId: string;
|
||||
sequence: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
agentRunId: string | null;
|
||||
outputSummary: string | null;
|
||||
userFeedback: string | null;
|
||||
approvedAt: number | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
startedAt: number | null;
|
||||
completedAt: number | null;
|
||||
};
|
||||
|
||||
export type GoalRunAgentRunRow = {
|
||||
id: string;
|
||||
status: string;
|
||||
requestId: string;
|
||||
goalCheckpointId: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
completedAt: number | null;
|
||||
};
|
||||
|
||||
export type GoalRunDetail = {
|
||||
id: string;
|
||||
userId: string;
|
||||
username: string | null;
|
||||
title: string;
|
||||
intentSummary: string;
|
||||
status: string;
|
||||
priority: number;
|
||||
sourceChannel: string;
|
||||
sourceSessionId: string | null;
|
||||
sourceMessageId: string | null;
|
||||
currentCheckpointId: string | null;
|
||||
context: unknown;
|
||||
memorySnapshot: unknown;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
completedAt: number | null;
|
||||
checkpoints: GoalRunCheckpointRow[];
|
||||
agentRuns: GoalRunAgentRunRow[];
|
||||
canaryEnabled: boolean;
|
||||
};
|
||||
|
||||
export async function fetchGoalRunSummary() {
|
||||
return adminFetch<GoalRunSummaryResponse>('/admin-api/goal-runs/summary');
|
||||
}
|
||||
|
||||
export async function fetchGoalRuns(params?: {
|
||||
status?: string;
|
||||
userId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.status) query.set('status', params.status);
|
||||
if (params?.userId) query.set('userId', params.userId);
|
||||
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
return adminFetch<{ items: GoalRunListItem[]; status: string | null; limit: number; offset: number }>(
|
||||
`/admin-api/goal-runs${suffix}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchGoalRunDetail(goalRunId: string) {
|
||||
return adminFetch<{ goal: GoalRunDetail }>(`/admin-api/goal-runs/${encodeURIComponent(goalRunId)}`);
|
||||
}
|
||||
|
||||
export async function approveGoalRunCheckpoint(
|
||||
goalRunId: string,
|
||||
checkpointId: string,
|
||||
feedback?: string | null,
|
||||
) {
|
||||
return adminFetch<{ ok: true; goal: GoalRunDetail }>(
|
||||
`/admin-api/goal-runs/${encodeURIComponent(goalRunId)}/checkpoints/${encodeURIComponent(checkpointId)}/approve`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ feedback: feedback ?? null }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelGoalRun(goalRunId: string) {
|
||||
return adminFetch<{ ok: true; goal: GoalRunDetail }>(
|
||||
`/admin-api/goal-runs/${encodeURIComponent(goalRunId)}/cancel`,
|
||||
{ method: 'POST', body: JSON.stringify({}) },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ const links = [
|
||||
{ to: '/admin/policy', label: '策略中心' },
|
||||
{ to: '/admin/orchestrator', label: '任务编排' },
|
||||
{ to: '/admin/llm', label: '统一大模型' },
|
||||
{ to: '/admin/goal-runs', label: 'Goal Run' },
|
||||
];
|
||||
|
||||
export function AdminLayout() {
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
approveGoalRunCheckpoint,
|
||||
cancelGoalRun,
|
||||
fetchGoalRunDetail,
|
||||
fetchGoalRunSummary,
|
||||
fetchGoalRuns,
|
||||
type GoalRunCheckpointRow,
|
||||
type GoalRunDetail,
|
||||
type GoalRunListItem,
|
||||
type GoalRunSummaryResponse,
|
||||
} from '../../api/admin';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'active', label: 'active' },
|
||||
{ value: 'awaiting_user', label: 'awaiting_user' },
|
||||
{ value: 'paused', label: 'paused' },
|
||||
{ value: 'completed', label: 'completed' },
|
||||
{ value: 'failed', label: 'failed' },
|
||||
{ value: 'cancelled', label: 'cancelled' },
|
||||
];
|
||||
|
||||
function formatTime(value: number | null | undefined) {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
const colors: Record<string, string> = {
|
||||
active: '#1f6feb',
|
||||
awaiting_user: '#b78103',
|
||||
paused: '#68716c',
|
||||
completed: '#1a7f37',
|
||||
failed: '#cf222e',
|
||||
cancelled: '#68716c',
|
||||
running: '#1f6feb',
|
||||
pending: '#68716c',
|
||||
approved: '#1a7f37',
|
||||
};
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
fontSize: 12,
|
||||
background: `${colors[status] ?? '#68716c'}14`,
|
||||
color: colors[status] ?? '#68716c',
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckpointList({
|
||||
checkpoints,
|
||||
onApprove,
|
||||
actionBusy,
|
||||
}: {
|
||||
checkpoints: GoalRunCheckpointRow[];
|
||||
onApprove: (checkpointId: string) => void;
|
||||
actionBusy: string | null;
|
||||
}) {
|
||||
if (!checkpoints.length) return <p style={{ color: '#68716c', margin: 0 }}>暂无阶段。</p>;
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{checkpoints.map((checkpoint) => (
|
||||
<div
|
||||
key={checkpoint.id}
|
||||
style={{
|
||||
border: '1px solid #e6ece8',
|
||||
borderRadius: 10,
|
||||
padding: 10,
|
||||
display: 'grid',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
|
||||
<strong>{checkpoint.sequence}. {checkpoint.title}</strong>
|
||||
{statusBadge(checkpoint.status)}
|
||||
</div>
|
||||
{checkpoint.description && (
|
||||
<p style={{ margin: 0, color: '#68716c' }}>{checkpoint.description}</p>
|
||||
)}
|
||||
{checkpoint.outputSummary && (
|
||||
<p style={{ margin: 0 }}>输出:{checkpoint.outputSummary}</p>
|
||||
)}
|
||||
<p style={{ margin: 0, color: '#68716c', fontSize: 13 }}>
|
||||
agent_run={checkpoint.agentRunId ?? '—'} · 更新 {formatTime(checkpoint.updatedAt)}
|
||||
</p>
|
||||
{checkpoint.status === 'awaiting_approval' && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={actionBusy === `approve:${checkpoint.id}`}
|
||||
onClick={() => onApprove(checkpoint.id)}
|
||||
>
|
||||
{actionBusy === `approve:${checkpoint.id}` ? '确认中…' : '管理员确认阶段'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GoalRunPage() {
|
||||
const [summary, setSummary] = useState<GoalRunSummaryResponse | null>(null);
|
||||
const [items, setItems] = useState<GoalRunListItem[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<GoalRunDetail | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [userFilter, setUserFilter] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||
|
||||
const reloadDetail = useCallback(async (goalRunId: string) => {
|
||||
const result = await fetchGoalRunDetail(goalRunId);
|
||||
setDetail(result.goal);
|
||||
}, []);
|
||||
|
||||
const reloadList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [summaryRes, listRes] = await Promise.all([
|
||||
fetchGoalRunSummary(),
|
||||
fetchGoalRuns({
|
||||
status: statusFilter || undefined,
|
||||
userId: userFilter.trim() || undefined,
|
||||
limit: 50,
|
||||
}),
|
||||
]);
|
||||
setSummary(summaryRes);
|
||||
setItems(listRes.items);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [statusFilter, userFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
void reloadList();
|
||||
}, [reloadList]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) {
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setDetailLoading(true);
|
||||
void fetchGoalRunDetail(selectedId)
|
||||
.then((result) => {
|
||||
if (!cancelled) setDetail(result.goal);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : '读取详情失败');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setDetailLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedId]);
|
||||
|
||||
const approveCheckpoint = async (checkpointId: string) => {
|
||||
if (!selectedId) return;
|
||||
setActionBusy(`approve:${checkpointId}`);
|
||||
setError(null);
|
||||
try {
|
||||
await approveGoalRunCheckpoint(selectedId, checkpointId);
|
||||
await reloadDetail(selectedId);
|
||||
await reloadList();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '确认阶段失败');
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelSelectedGoal = async () => {
|
||||
if (!selectedId) return;
|
||||
setActionBusy('cancel');
|
||||
setError(null);
|
||||
try {
|
||||
await cancelGoalRun(selectedId);
|
||||
await reloadDetail(selectedId);
|
||||
await reloadList();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '取消目标失败');
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !summary) return <p>加载中…</p>;
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
{error && <p className="alert">{error}</p>}
|
||||
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 style={{ marginTop: 0, marginBottom: 4 }}>Goal Run</h2>
|
||||
<p style={{ color: '#68716c', margin: 0 }}>
|
||||
长期目标任务与 checkpoint 生命周期(memindadm 只读观测)
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn secondary" onClick={() => void reloadList()}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
{summary && (
|
||||
<div style={{ marginTop: 16, display: 'grid', gap: 10 }}>
|
||||
<p style={{ margin: 0 }}>
|
||||
功能开关:
|
||||
<strong>{summary.runtime.enabled ? '已启用' : '未启用'}</strong>
|
||||
{summary.runtime.canaryMode
|
||||
? ` · Canary 用户 ${summary.runtime.canaryUserIds.length} 个`
|
||||
: ' · 全员'}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{Object.entries(summary.counts).map(([status, count]) => (
|
||||
<div key={status}>
|
||||
<p style={{ color: '#68716c', marginBottom: 4 }}>{status}</p>
|
||||
<strong style={{ fontSize: 24 }}>{count}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ color: '#68716c', fontSize: 13 }}>状态</span>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
style={{ minWidth: 160 }}
|
||||
>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value || 'all'} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: 4, flex: 1, minWidth: 220 }}>
|
||||
<span style={{ color: '#68716c', fontSize: 13 }}>用户 ID</span>
|
||||
<input
|
||||
value={userFilter}
|
||||
onChange={(event) => setUserFilter(event.target.value)}
|
||||
placeholder="可选,精确匹配 user_id"
|
||||
/>
|
||||
</label>
|
||||
<div style={{ alignSelf: 'end' }}>
|
||||
<button type="button" className="btn" onClick={() => void reloadList()}>
|
||||
筛选
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginTop: 0 }}>目标列表({items.length})</h3>
|
||||
{items.length === 0 ? (
|
||||
<p style={{ color: '#68716c' }}>暂无 Goal Run 记录。</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
border: selectedId === item.id ? '2px solid #1f6feb' : '1px solid #e6ece8',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
background: '#fff',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<strong>{item.title}</strong>
|
||||
{statusBadge(item.status)}
|
||||
</div>
|
||||
<p style={{ margin: '8px 0 0', color: '#68716c', fontSize: 13 }}>
|
||||
{item.username ?? item.userId} · 阶段 {item.checkpointCount} · 活跃 run {item.activeAgentRunCount}
|
||||
</p>
|
||||
<p style={{ margin: '4px 0 0', color: '#68716c', fontSize: 13 }}>
|
||||
更新 {formatTime(item.updatedAt)}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedId && (
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>目标详情</h3>
|
||||
{detailLoading && !detail ? (
|
||||
<p>加载详情…</p>
|
||||
) : detail ? (
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
<div>
|
||||
<p style={{ margin: 0 }}><strong>{detail.title}</strong></p>
|
||||
<p style={{ margin: '8px 0 0' }}>{detail.intentSummary}</p>
|
||||
<p style={{ margin: '8px 0 0', color: '#68716c', fontSize: 13 }}>
|
||||
id={detail.id} · user={detail.username ?? detail.userId}
|
||||
· canary={detail.canaryEnabled ? '是' : '否'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
{!['completed', 'cancelled'].includes(detail.status) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
disabled={actionBusy === 'cancel'}
|
||||
onClick={() => void cancelSelectedGoal()}
|
||||
>
|
||||
{actionBusy === 'cancel' ? '取消中…' : '取消目标'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<h4 style={{ marginBottom: 8 }}>Checkpoints</h4>
|
||||
<CheckpointList
|
||||
checkpoints={detail.checkpoints}
|
||||
onApprove={(checkpointId) => void approveCheckpoint(checkpointId)}
|
||||
actionBusy={actionBusy}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ marginBottom: 8 }}>关联 Agent Runs</h4>
|
||||
{detail.agentRuns.length === 0 ? (
|
||||
<p style={{ color: '#68716c', margin: 0 }}>暂无关联 run。</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{detail.agentRuns.map((run) => (
|
||||
<div
|
||||
key={run.id}
|
||||
style={{
|
||||
border: '1px solid #e6ece8',
|
||||
borderRadius: 10,
|
||||
padding: 10,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<code style={{ fontSize: 12 }}>{run.id}</code>
|
||||
{statusBadge(run.status)}
|
||||
<span style={{ color: '#68716c', fontSize: 13 }}>
|
||||
checkpoint={run.goalCheckpointId ?? '—'} · {formatTime(run.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: '#68716c' }}>未找到详情。</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +71,9 @@ export function SummaryPage() {
|
||||
<Link className="btn secondary" to="/admin/llm" style={{ textAlign: 'center', textDecoration: 'none' }}>
|
||||
统一大模型 / H5 路由
|
||||
</Link>
|
||||
<Link className="btn secondary" to="/admin/goal-runs" style={{ textAlign: 'center', textDecoration: 'none' }}>
|
||||
Goal Run 观测
|
||||
</Link>
|
||||
<Link className="btn secondary" to="/admin/users" style={{ textAlign: 'center', textDecoration: 'none' }}>
|
||||
进入用户管理
|
||||
</Link>
|
||||
|
||||
Reference in New Issue
Block a user