feat(web): live step list, option choice card and inline approval card; drop run mode switch
- session page shows the agent.plan TodoList (drawer: one-line progress); options and approval cards sit at their step; composer has a "先看方案再执行" toggle instead of the mode switch - settings expose option timeout, timeout action and approval validity; quick goals follow glossary Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
cf2c4ff0e6
commit
9649854ab3
15 files changed
+731
-205
No files matched your search
@@ -12,7 +12,6 @@ import {
|
||||
AgentRunDetail,
|
||||
type AgentRunEvent,
|
||||
type AgentRunMessage,
|
||||
AgentRunMode,
|
||||
type AgentRunStatus,
|
||||
Approval,
|
||||
type ApprovalStatus,
|
||||
@@ -33,9 +32,10 @@ import { usePiUpstreams } from "./pi.ts";
|
||||
|
||||
export const AGENT_BASE = "/api/v1/agent/runs";
|
||||
|
||||
/** 运行模式:execute = 授权内执行;plan_only = 只调查出方案,不执行写能力 */
|
||||
export const AgentMode = AgentRunMode;
|
||||
export type AgentMode = AgentRunMode;
|
||||
/** 会话选项(运行模式已取消):proposeFirst = 先看方案再执行 */
|
||||
export type AgentSessionOptions = { proposeFirst: boolean };
|
||||
/** 会话是否要求先看方案再执行(存在 run.scope 里) */
|
||||
export const isProposeFirst = (run: Pick<AgentRun, "scope">) => run.scope.proposeFirst === true;
|
||||
|
||||
export type AgentRunView = AgentRun;
|
||||
/** 服务端从业务表汇总的目标完成度(不采信模型文本) */
|
||||
@@ -196,9 +196,10 @@ export async function fetchRunEvents(id: string, after: number, signal?: AbortSi
|
||||
export function useCreateAgentRun() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { goal: string; mode: AgentMode }) => {
|
||||
if (agentMockEnabled()) return mockCreateRun(input.goal, input.mode);
|
||||
return apiMutate(AGENT_BASE, { body: { goal: input.goal, mode: input.mode } }).then((d) => AgentRun.parse(d));
|
||||
mutationFn: async (input: { goal: string; proposeFirst?: boolean }) => {
|
||||
const proposeFirst = input.proposeFirst === true;
|
||||
if (agentMockEnabled()) return mockCreateRun(input.goal, { proposeFirst });
|
||||
return apiMutate(AGENT_BASE, { body: { goal: input.goal, proposeFirst } }).then((d) => AgentRun.parse(d));
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: agentKeys.all }),
|
||||
});
|
||||
@@ -230,6 +231,21 @@ export function useAppendAgentMessage() {
|
||||
});
|
||||
}
|
||||
|
||||
/** 回答方案选择:选一个方案,或都不要(optionId=null + 原因);挂起的会话随即继续 */
|
||||
export function useChooseOption() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { runId: string; decisionId: string; optionId: string | null; reason?: string }) => {
|
||||
if (agentMockEnabled()) return mockDetail(input.runId).run;
|
||||
return apiMutate(`${AGENT_BASE}/:id/choice`, {
|
||||
pathParams: { id: input.runId },
|
||||
body: { decisionId: input.decisionId, optionId: input.optionId, reason: input.reason },
|
||||
}).then((d) => AgentRun.parse(d));
|
||||
},
|
||||
onSuccess: (_d, v) => qc.invalidateQueries({ queryKey: agentKeys.run(v.runId) }),
|
||||
});
|
||||
}
|
||||
|
||||
export const RUN_STATUS_LABEL: Record<AgentRunStatus, string> = {
|
||||
queued: "排队中",
|
||||
running: "运行中",
|
||||
@@ -292,13 +308,33 @@ export function useDecideApproval() {
|
||||
id: string;
|
||||
status: Extract<ApprovalStatus, "approved" | "rejected">;
|
||||
payloadHash: string;
|
||||
/** 拒绝理由 / 建议:写进待处理事项,会话内审批时 Agent 按它调整 */
|
||||
reason?: string;
|
||||
}) =>
|
||||
apiMutate(`/approvals/:id`, {
|
||||
method: "PATCH",
|
||||
pathParams: { id: input.id },
|
||||
body: { status: input.status, payloadHash: input.payloadHash },
|
||||
body: {
|
||||
status: input.status,
|
||||
payloadHash: input.payloadHash,
|
||||
...(input.reason ? { reason: input.reason } : {}),
|
||||
},
|
||||
}).then((d) => Approval.parse(d)),
|
||||
onSuccess: (a) => qc.invalidateQueries({ queryKey: ["agent", "approvals", a.planId] }),
|
||||
onSuccess: (a) => {
|
||||
void qc.invalidateQueries({ queryKey: ["agent", "approvals", a.planId] });
|
||||
void qc.invalidateQueries({ queryKey: ["agent", "approval", a.id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 单个审批的当前状态(会话内审批卡用;挂起期间事件流里还没有决定结果) */
|
||||
export function useApproval(id: string | null) {
|
||||
const mock = agentMockEnabled();
|
||||
return useQuery({
|
||||
queryKey: ["agent", "approval", id ?? ""],
|
||||
enabled: !!id && !mock,
|
||||
queryFn: ({ signal }) => apiParsed(Approval, "/approvals/:id", { pathParams: { id: id ?? "" }, signal }),
|
||||
refetchInterval: (q) => (q.state.data?.status === "pending" ? 5 * TTL_SECOND : false),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type AgentRunView,
|
||||
type GoalReport,
|
||||
type InvocationAudit,
|
||||
isProposeFirst,
|
||||
isRunActive,
|
||||
REPORT_STATE,
|
||||
RUN_STATUS_LABEL,
|
||||
@@ -46,12 +47,12 @@ import {
|
||||
} from "~/components/agentui/message.tsx";
|
||||
import { MessageBubble, MessageBubbleContent } from "~/components/agentui/message-bubble.tsx";
|
||||
import { StreamingCursor, StreamingResponse } from "~/components/agentui/streaming-response.tsx";
|
||||
import { type TodoItem, TodoList } from "~/components/agentui/todo-list.tsx";
|
||||
import { ToolApproval } from "~/components/agentui/tool-approval.tsx";
|
||||
import { ToolResult, ToolResultOutput, type ToolResultStatus } from "~/components/agentui/tool-result.tsx";
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
import { InlineApproval, OptionsCard } from "./agent-interaction.tsx";
|
||||
import { ToolCallIo } from "./tool-call-card-io.tsx";
|
||||
import type { AgentStreamState, StreamItem, ToolCallView } from "./use-agent-stream.ts";
|
||||
|
||||
@@ -149,18 +150,15 @@ export const isDenied = (call: ToolCallView) => call.status === "denied" || auth
|
||||
/** 工具调用卡片:脱敏输入/输出折叠、耗时、scope/风险徽标、幂等键(审计);服务端拒绝时换醒目拒绝态 */
|
||||
export function ToolCallCard({
|
||||
call,
|
||||
mode,
|
||||
audit,
|
||||
}: {
|
||||
call: ToolCallView;
|
||||
mode?: string;
|
||||
/** 与这次调用对应的审计记录(幂等键等);未取到为 undefined */
|
||||
audit?: InvocationAudit;
|
||||
}) {
|
||||
const meta = capabilityMeta(call.capabilityId);
|
||||
const authReason = authDenial(call);
|
||||
if (call.status === "denied" || authReason) {
|
||||
const writeKind = meta && !["read", "preview", "propose"].includes(meta.kind);
|
||||
return (
|
||||
<ToolApproval
|
||||
status="rejected"
|
||||
@@ -175,10 +173,8 @@ export function ToolCallCard({
|
||||
reason={authReason ?? call.reason ?? "未授权"}
|
||||
explanation={
|
||||
authReason
|
||||
? '有副作用的能力需要有效的持续授权或单次审批,由服务端在执行前核对,模型声称"用户已同意"无效。发布收录策略会生成持续授权,也可以为计划申请单次审批。'
|
||||
: mode === "plan_only" && writeKind
|
||||
? "本次是「只要方案」,只放行只读、预览、提案类能力。要执行,点「按方案执行」。"
|
||||
: "这个能力不在本次运行的白名单内,或已超出调用预算,调用没有执行。"
|
||||
? '有副作用的能力需要有效的持续授权或单次审批,由服务端在执行前核对,模型声称"用户已同意"无效。控制台发起的会话里,需审批的动作会在对应步骤出审批卡;也可以发布收录策略生成持续授权。'
|
||||
: "这个能力不在本次运行的白名单内,或已超出调用预算,调用没有执行。"
|
||||
}
|
||||
parameters={[
|
||||
{ id: "call", label: "调用 ID", value: call.toolCallId },
|
||||
@@ -317,13 +313,14 @@ function EndNotice({ item }: { item: Extract<StreamItem, { kind: "end" }> }) {
|
||||
export function UserMessage({
|
||||
text,
|
||||
at,
|
||||
mode,
|
||||
tag,
|
||||
note,
|
||||
animateIn,
|
||||
}: {
|
||||
text: string;
|
||||
at?: string;
|
||||
mode?: string;
|
||||
/** 会话选项标记(如"先看方案再执行") */
|
||||
tag?: string;
|
||||
note?: string;
|
||||
animateIn?: boolean;
|
||||
}) {
|
||||
@@ -333,9 +330,9 @@ export function UserMessage({
|
||||
<User />
|
||||
</MessageAvatar>
|
||||
<MessageContent>
|
||||
{at || mode ? (
|
||||
{at || tag ? (
|
||||
<MessageHeader>
|
||||
{mode ? <span>{mode === "plan_only" ? "只要方案" : "授权内执行"}</span> : null}
|
||||
{tag ? <span>{tag}</span> : null}
|
||||
{at ? <time dateTime={at}>{formatDateTime(at)}</time> : null}
|
||||
</MessageHeader>
|
||||
) : null}
|
||||
@@ -402,7 +399,8 @@ export function AgentConversation({
|
||||
after?: ReactNode;
|
||||
}) {
|
||||
const active = isRunActive(run.status) && !live.finished;
|
||||
const mode = run.mode ?? (run.scope.mode as string | undefined);
|
||||
const waiting = run.status === "waiting" || run.status === "running" || run.status === "queued";
|
||||
const stepTitle = (id: string | null) => (id ? (live.plan?.steps.find((s) => s.id === id)?.title ?? null) : null);
|
||||
// 首次载入(回放)的条目不播入场动画,之后新到的才播
|
||||
const baseline = useRef<number | null>(null);
|
||||
if (baseline.current === null && (live.events.length > 0 || !active)) baseline.current = live.items.length;
|
||||
@@ -444,7 +442,7 @@ export function AgentConversation({
|
||||
case "tool":
|
||||
node = (
|
||||
<AssistantRow animateIn={anim} avatar={false}>
|
||||
<ToolCallCard call={it.call} mode={mode} audit={auditByCall.get(it.call.toolCallId)} />
|
||||
<ToolCallCard call={it.call} audit={auditByCall.get(it.call.toolCallId)} />
|
||||
</AssistantRow>
|
||||
);
|
||||
break;
|
||||
@@ -457,6 +455,20 @@ export function AgentConversation({
|
||||
</MessageMarker>
|
||||
);
|
||||
break;
|
||||
case "options":
|
||||
node = (
|
||||
<AssistantRow animateIn={anim} avatar={false}>
|
||||
<OptionsCard runId={run.id} view={it.view} waiting={waiting} />
|
||||
</AssistantRow>
|
||||
);
|
||||
break;
|
||||
case "approval":
|
||||
node = (
|
||||
<AssistantRow animateIn={anim} avatar={false}>
|
||||
<InlineApproval view={it.view} stepTitle={stepTitle(it.view.stepId)} />
|
||||
</AssistantRow>
|
||||
);
|
||||
break;
|
||||
case "end":
|
||||
node = it.ok ? null : <EndNotice item={it} />;
|
||||
break;
|
||||
@@ -473,13 +485,17 @@ export function AgentConversation({
|
||||
|
||||
return (
|
||||
<div className="space-y-4 px-1 py-2">
|
||||
<UserMessage text={run.goal} at={run.createdAt} mode={mode} />
|
||||
<UserMessage text={run.goal} at={run.createdAt} tag={isProposeFirst(run) ? "先看方案再执行" : undefined} />
|
||||
{rows.map((r) => (r.node ? <div key={r.key}>{r.node}</div> : null))}
|
||||
{active && live.items[lastIndex]?.kind !== "text" && live.items[lastIndex]?.kind !== "thinking" ? (
|
||||
<AssistantRow avatar={false}>
|
||||
<span className="inline-flex items-center gap-2 text-meta text-muted-foreground">
|
||||
<MessageTyping label={run.status === "waiting" ? "等待中" : "正在处理"} />
|
||||
{run.status === "waiting" ? "等待中…" : run.status === "queued" ? "排队中…" : "正在处理…"}
|
||||
{run.status === "waiting"
|
||||
? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…`
|
||||
: run.status === "queued"
|
||||
? "排队中…"
|
||||
: "正在处理…"}
|
||||
</span>
|
||||
</AssistantRow>
|
||||
) : null}
|
||||
@@ -488,32 +504,6 @@ export function AgentConversation({
|
||||
);
|
||||
}
|
||||
|
||||
// ── plan_only:调查步骤清单 ──
|
||||
|
||||
export function PlanSteps({ live, active }: { live: AgentStreamState; active: boolean }) {
|
||||
const items: TodoItem[] = live.items
|
||||
.filter((i): i is Extract<StreamItem, { kind: "tool" }> => i.kind === "tool")
|
||||
.map(({ call }) => ({
|
||||
id: call.toolCallId,
|
||||
title: capabilityMeta(call.capabilityId)?.title ?? call.capabilityId,
|
||||
detail: <span className="font-mono">{call.capabilityId}</span>,
|
||||
status:
|
||||
call.status === "running"
|
||||
? "in-progress"
|
||||
: call.status === "succeeded"
|
||||
? "completed"
|
||||
: isDenied(call)
|
||||
? "cancelled"
|
||||
: "error",
|
||||
}));
|
||||
items.push({
|
||||
id: "report",
|
||||
title: "整理方案并汇报",
|
||||
status: active ? (items.some((i) => i.status === "in-progress") ? "pending" : "in-progress") : "completed",
|
||||
});
|
||||
return <TodoList title="调查步骤(只要方案)" items={items} defaultOpen={active} collapseOnComplete />;
|
||||
}
|
||||
|
||||
// ── 预算用量条 ──
|
||||
|
||||
function Meter({
|
||||
|
||||
@@ -114,7 +114,7 @@ export function AgentDrawer() {
|
||||
) : null}
|
||||
<div className="flex min-h-0 flex-1 flex-col px-4 py-3">
|
||||
{runId ? (
|
||||
<AgentRunPanel runId={runId} compact className="flex-1" onRunCreated={setAgentDrawerRun} />
|
||||
<AgentRunPanel runId={runId} compact className="flex-1" />
|
||||
) : (
|
||||
<GoalComposer
|
||||
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
|
||||
@@ -123,9 +123,9 @@ export function AgentDrawer() {
|
||||
prefix={prefix || undefined}
|
||||
defaultGoal={context?.goal ?? ""}
|
||||
pending={create.isPending}
|
||||
onSubmit={(goal, mode) =>
|
||||
onSubmit={(goal, opts) =>
|
||||
create.mutate(
|
||||
{ goal: `${prefix}${goal}`, mode },
|
||||
{ goal: `${prefix}${goal}`, ...opts },
|
||||
{
|
||||
onSuccess: (run) => setAgentDrawerRun(run.id),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import { type AgentPlanStep, CAPABILITY_CATALOG, type KnownCapabilityId, type RiskLevel } from "@umt/contracts";
|
||||
import { ListChecks, MessageCircleQuestion, Star } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useApproval, useChooseOption, useDecideApproval, useIsAdminSession } from "~/api/agent.ts";
|
||||
import { ApprovalCard, type ApprovalCardStatus } from "~/components/agentui/approval-card.tsx";
|
||||
import { type TodoItem, type TodoItemStatus, TodoList } from "~/components/agentui/todo-list.tsx";
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
import type { ApprovalView, OptionsView } from "./use-agent-stream.ts";
|
||||
|
||||
// 自动化优先的会话交互(AG-2):步骤清单(agent.plan)、方案选择(agent.propose_options)、步骤处的审批卡。
|
||||
// 事件形状见 packages/contracts/src/agent-events.ts;服务端逻辑见 apps/server/src/modules/agent/interaction.ts。
|
||||
|
||||
const STEP_STATUS: Record<AgentPlanStep["status"], TodoItemStatus> = {
|
||||
pending: "pending",
|
||||
running: "in-progress",
|
||||
done: "completed",
|
||||
skipped: "skipped",
|
||||
failed: "error",
|
||||
};
|
||||
|
||||
export function planTodoItems(steps: AgentPlanStep[]): TodoItem[] {
|
||||
return steps.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
status: STEP_STATUS[s.status],
|
||||
...(s.note ? { detail: <span className="block max-w-48 truncate">{s.note}</span> } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 步骤清单:会话页顶部完整列表(实时勾选);抽屉里压缩成一行进度 */
|
||||
export function RunPlan({
|
||||
plan,
|
||||
active,
|
||||
compact = false,
|
||||
}: {
|
||||
plan: { steps: AgentPlanStep[]; revision: number } | null;
|
||||
active: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
if (!plan?.steps.length) return null;
|
||||
const done = plan.steps.filter((s) => s.status === "done" || s.status === "skipped").length;
|
||||
const current = plan.steps.find((s) => s.status === "running") ?? plan.steps.find((s) => s.status === "pending");
|
||||
const failed = plan.steps.some((s) => s.status === "failed");
|
||||
if (compact) {
|
||||
const pct = Math.round((done / plan.steps.length) * 100);
|
||||
return (
|
||||
<section aria-label="步骤进度" className="rounded-xl border border-border/70 px-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2 text-meta">
|
||||
<ListChecks
|
||||
aria-hidden
|
||||
className={cn("size-3.5 shrink-0 text-muted-foreground", failed && "text-destructive")}
|
||||
/>
|
||||
<span className="shrink-0 text-foreground tabular-nums">
|
||||
步骤 {done}/{plan.steps.length}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
||||
{current ? `当前:${current.title}` : failed ? "有步骤失败" : "全部完成"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 h-1 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-[width] duration-layout ease-standard motion-reduce:transition-none",
|
||||
failed ? "bg-destructive" : "bg-foreground/60",
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TodoList
|
||||
title="步骤"
|
||||
items={planTodoItems(plan.steps)}
|
||||
defaultOpen
|
||||
collapseOnComplete={!active}
|
||||
maxHeightClassName="max-h-56"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 方案选择 ──
|
||||
|
||||
const NONE = "__none__";
|
||||
|
||||
/** agent.propose_options 的选择卡:单选一个方案,或"都不要"并写原因;选定后会话继续 */
|
||||
export function OptionsCard({ runId, view, waiting }: { runId: string; view: OptionsView; waiting: boolean }) {
|
||||
const name = useId();
|
||||
const choose = useChooseOption();
|
||||
const [picked, setPicked] = useState<string>(view.recommendedId ?? "");
|
||||
const [reason, setReason] = useState("");
|
||||
const [sent, setSent] = useState<{ optionId: string | null; reason: string } | null>(null);
|
||||
const resolved = view.resolved;
|
||||
// 已作答但会话还没恢复(事件里没有 options_resolved):按本地提交结果显示
|
||||
const closed = !!resolved || !!sent || !waiting;
|
||||
const chosenId = resolved ? resolved.choice : sent ? sent.optionId : null;
|
||||
const submit = () => {
|
||||
const optionId = picked === NONE ? null : picked;
|
||||
if (!picked) return;
|
||||
if (optionId === null && !reason.trim()) {
|
||||
toast.error("写一句不要的原因,Agent 按它调整");
|
||||
return;
|
||||
}
|
||||
choose.mutate(
|
||||
{ runId, decisionId: view.decisionId, optionId, ...(optionId === null ? { reason: reason.trim() } : {}) },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSent({ optionId, reason: reason.trim() });
|
||||
toast.success(optionId ? "已选定,Agent 继续执行" : "已退回,Agent 调整方案");
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
);
|
||||
};
|
||||
const outcome = resolved
|
||||
? resolved.outcome === "chosen"
|
||||
? `${resolved.by === "timeout" ? "超时自动选择" : "已选择"}:${view.options.find((o) => o.id === resolved.choice)?.title ?? resolved.choice}`
|
||||
: resolved.outcome === "stop"
|
||||
? "超时未选,按设置停止"
|
||||
: `都不要${resolved.reason ? `:${resolved.reason}` : ""}`
|
||||
: sent
|
||||
? sent.optionId
|
||||
? `已选择:${view.options.find((o) => o.id === sent.optionId)?.title ?? sent.optionId},等待 Agent 继续`
|
||||
: `都不要:${sent.reason},等待 Agent 调整`
|
||||
: !waiting
|
||||
? "会话已不在等待这个选择"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="选择方案"
|
||||
className={cn(
|
||||
"w-full min-w-0 rounded-xl border p-3",
|
||||
closed ? "border-border/70" : "border-warning/40 bg-warning/[0.06]",
|
||||
)}
|
||||
>
|
||||
<p className="flex items-start gap-2 text-caption font-medium text-foreground">
|
||||
<MessageCircleQuestion aria-hidden className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 break-words">{view.question}</span>
|
||||
</p>
|
||||
<fieldset className="mt-2.5 space-y-1.5" disabled={closed || choose.isPending}>
|
||||
<legend className="sr-only">{view.question}</legend>
|
||||
{view.options.map((o) => {
|
||||
const selected = closed ? chosenId === o.id : picked === o.id;
|
||||
return (
|
||||
<label
|
||||
key={o.id}
|
||||
className={cn(
|
||||
"flex min-w-0 cursor-pointer gap-2.5 rounded-lg border border-border/70 px-3 py-2 transition-colors duration-micro has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
|
||||
selected && "border-foreground/40 bg-foreground/[0.04]",
|
||||
closed && !selected && "opacity-60",
|
||||
closed && "cursor-default",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
value={o.id}
|
||||
checked={selected}
|
||||
onChange={() => setPicked(o.id)}
|
||||
className="mt-1 size-3.5 shrink-0 accent-foreground"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-caption font-medium text-foreground">{o.title}</span>
|
||||
{view.recommendedId === o.id ? (
|
||||
<StatusBadge>
|
||||
<Star className="size-3" />
|
||||
推荐
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
{o.estimatedCost ? <StatusBadge>估算 {o.estimatedCost}</StatusBadge> : null}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-meta break-words text-muted-foreground">{o.summary}</span>
|
||||
{o.risks.length ? (
|
||||
<ul className="mt-1 space-y-0.5 text-meta text-warning">
|
||||
{o.risks.map((r) => (
|
||||
<li key={r} className="break-words">
|
||||
风险:{r}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{closed ? null : (
|
||||
<label
|
||||
className={cn(
|
||||
"flex min-w-0 cursor-pointer gap-2.5 rounded-lg border border-dashed border-border px-3 py-2 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
|
||||
picked === NONE && "border-foreground/40",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
value={NONE}
|
||||
checked={picked === NONE}
|
||||
onChange={() => setPicked(NONE)}
|
||||
className="mt-1 size-3.5 shrink-0 accent-foreground"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 text-caption text-foreground">都不要,说明原因</span>
|
||||
</label>
|
||||
)}
|
||||
</fieldset>
|
||||
{!closed && picked === NONE ? (
|
||||
<Textarea
|
||||
className="mt-2 min-h-16 text-caption"
|
||||
value={reason}
|
||||
maxLength={1000}
|
||||
aria-label="不要的原因"
|
||||
placeholder="例如:体积太大,只要 WEB-DL"
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
{closed ? (
|
||||
<p className="mt-2 text-meta text-muted-foreground">{outcome}</p>
|
||||
) : (
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" onClick={submit} disabled={!picked || choose.isPending}>
|
||||
{picked === NONE ? "退回" : "选定"}
|
||||
</Button>
|
||||
<span className="text-meta text-muted-foreground">
|
||||
{formatDateTime(view.expiresAt)} 前没选,
|
||||
{view.timeoutAction === "recommended" ? "自动选推荐项" : "Agent 停止"}。
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 步骤处的审批卡 ──
|
||||
|
||||
const RISK: Record<RiskLevel, { label: string; tone: StatusTone }> = {
|
||||
low: { label: "低风险", tone: "neutral" },
|
||||
medium: { label: "中风险", tone: "warning" },
|
||||
high: { label: "高风险", tone: "destructive" },
|
||||
};
|
||||
const PLAN_LABEL: Record<string, string> = {
|
||||
acquisition_plan: "下载计划",
|
||||
organize_plan: "整理计划",
|
||||
policy_revision: "策略版本",
|
||||
};
|
||||
const RESOLVED_STATUS: Record<string, ApprovalCardStatus> = {
|
||||
approved: "approved",
|
||||
rejected: "rejected",
|
||||
revoked: "revoked",
|
||||
expired: "expired",
|
||||
invalid: "expired",
|
||||
consumed: "consumed",
|
||||
};
|
||||
|
||||
/** 需审批的能力调用:在对应步骤处出审批卡(计划摘要、payload hash、风险、到期);批准 / 拒绝后会话继续 */
|
||||
export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTitle?: string | null }) {
|
||||
const admin = useIsAdminSession();
|
||||
const live = useApproval(view.approvalId);
|
||||
const decide = useDecideApproval();
|
||||
const [reason, setReason] = useState("");
|
||||
const a = live.data;
|
||||
const expired = new Date(view.expiresAt).getTime() < Date.now();
|
||||
const serverStatus = a?.status ?? (view.resolved ? view.resolved.status : "pending");
|
||||
const status: ApprovalCardStatus = decide.isPending
|
||||
? "submitting"
|
||||
: serverStatus === "pending" && expired
|
||||
? "expired"
|
||||
: (RESOLVED_STATUS[serverStatus] ?? (serverStatus as ApprovalCardStatus));
|
||||
const cap = CAPABILITY_CATALOG[view.capabilityId as KnownCapabilityId];
|
||||
const act = (s: "approved" | "rejected") =>
|
||||
decide.mutate(
|
||||
{
|
||||
id: view.approvalId,
|
||||
status: s,
|
||||
payloadHash: a?.payloadHash ?? view.payloadHash,
|
||||
...(s === "rejected" && reason.trim() ? { reason: reason.trim() } : {}),
|
||||
},
|
||||
{
|
||||
onSuccess: () => toast.success(s === "approved" ? "已批准,Agent 继续执行" : "已拒绝,Agent 按理由调整"),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
);
|
||||
const decidedReason = a?.reason && a.status === "rejected" ? a.reason : view.resolved?.reason;
|
||||
return (
|
||||
<ApprovalCard
|
||||
status={status}
|
||||
title={`批准「${cap?.title ?? view.capabilityId}」?`}
|
||||
description={view.summary}
|
||||
badges={
|
||||
<>
|
||||
<StatusBadge tone={RISK[view.riskLevel].tone}>{RISK[view.riskLevel].label}</StatusBadge>
|
||||
{stepTitle ? <StatusBadge>步骤 · {stepTitle}</StatusBadge> : null}
|
||||
</>
|
||||
}
|
||||
onApprove={() => act("approved")}
|
||||
onReject={() => act("rejected")}
|
||||
disabledReason={admin ? undefined : "只有控制台管理员会话能批准或拒绝"}
|
||||
result={
|
||||
status === "rejected" && decidedReason
|
||||
? `已拒绝:${decidedReason}`
|
||||
: a?.decidedAt
|
||||
? `${status === "approved" || status === "consumed" ? "批准" : "处理"}于 ${formatDateTime(a.decidedAt)}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<dl className="grid grid-cols-[minmax(0,6rem)_minmax(0,1fr)] gap-x-3 gap-y-1 text-meta">
|
||||
<dt className="text-muted-foreground">计划</dt>
|
||||
<dd className="min-w-0 font-mono break-all text-foreground/85">
|
||||
{PLAN_LABEL[view.planType] ?? view.planType} · {view.planId}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">payload hash</dt>
|
||||
<dd className="min-w-0 font-mono break-all text-foreground/85">{view.payloadHash}</dd>
|
||||
<dt className="text-muted-foreground">版本</dt>
|
||||
<dd className="text-foreground/85 tabular-nums">r{view.expectedRevision}</dd>
|
||||
<dt className="text-muted-foreground">到期</dt>
|
||||
<dd className={cn("text-foreground/85", expired && status === "expired" && "text-destructive")}>
|
||||
{formatDateTime(view.expiresAt)}
|
||||
</dd>
|
||||
</dl>
|
||||
{status === "pending" && admin ? (
|
||||
<Textarea
|
||||
className="mt-2 min-h-14 text-caption"
|
||||
value={reason}
|
||||
maxLength={2000}
|
||||
aria-label="拒绝理由"
|
||||
placeholder="拒绝时写理由或建议(可选),Agent 按它调整"
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
</ApprovalCard>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { EventType } from "@umt/contracts";
|
||||
import { FlaskConical, Play, RefreshCw, Sparkles, WifiOff } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { FlaskConical, RefreshCw, Sparkles, WifiOff } from "lucide-react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type AgentMode,
|
||||
type AgentSessionOptions,
|
||||
agentKeys,
|
||||
agentMockEnabled,
|
||||
isProposeFirst,
|
||||
isRunActive,
|
||||
useAbortAgentRun,
|
||||
useAgentRun,
|
||||
useAppendAgentMessage,
|
||||
useCreateAgentRun,
|
||||
useRunApprovals,
|
||||
useRunInvocationAudit,
|
||||
} from "~/api/agent.ts";
|
||||
@@ -21,6 +21,8 @@ import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Label } from "~/components/ui/label.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { useSse, useSseStatus } from "~/lib/sse.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
@@ -29,50 +31,30 @@ import {
|
||||
ApprovalItem,
|
||||
BudgetBar,
|
||||
FinalReport,
|
||||
PlanSteps,
|
||||
RunAnnouncer,
|
||||
RunStatusBadge,
|
||||
runPlanIds,
|
||||
} from "./agent-conversation.tsx";
|
||||
import { RunPlan } from "./agent-interaction.tsx";
|
||||
import { SubagentTree } from "./agent-subagent-tree.tsx";
|
||||
import { useAgentStream } from "./use-agent-stream.ts";
|
||||
|
||||
const TRIGGER_LABEL = { user: "用户发起", event: "事件触发", schedule: "定时", subagent: "子会话" } as const;
|
||||
|
||||
export const MODE_HELP: Record<AgentMode, { label: string; help: string }> = {
|
||||
execute: {
|
||||
label: "授权内执行",
|
||||
help: "未授权的动作会被服务端拒绝并在对话里标出。",
|
||||
},
|
||||
plan_only: {
|
||||
label: "只要方案",
|
||||
help: "只调查、出方案,不执行写操作。之后可按方案执行。",
|
||||
},
|
||||
};
|
||||
|
||||
/** 运行模式分段切换 */
|
||||
export function ModeSwitch({ mode, onChange }: { mode: AgentMode; onChange: (m: AgentMode) => void }) {
|
||||
/** "先看方案再执行"开关(会话选项,默认关:自动化优先,按步骤直接执行,需授权的动作在步骤处审批) */
|
||||
export function ProposeFirstToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
const id = useId();
|
||||
return (
|
||||
<fieldset aria-label="运行模式" className="flex items-center gap-1">
|
||||
{(["execute", "plan_only"] as const).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
aria-pressed={mode === v}
|
||||
onClick={() => onChange(v)}
|
||||
className={cn(
|
||||
"h-8 cursor-pointer rounded-full border border-transparent px-3 text-sm text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
|
||||
mode === v && "pill-selected text-foreground",
|
||||
)}
|
||||
>
|
||||
{MODE_HELP[v].label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Switch id={id} checked={checked} onCheckedChange={onChange} />
|
||||
<Label htmlFor={id} className="cursor-pointer text-sm font-normal text-muted-foreground">
|
||||
先看方案再执行
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 新目标输入:execute / plan_only 切换 + 自增高输入框(Enter 发送,Shift+Enter 换行) */
|
||||
/** 新目标输入:"先看方案再执行"开关 + 自增高输入框(Enter 发送,Shift+Enter 换行) */
|
||||
export function GoalComposer({
|
||||
onSubmit,
|
||||
pending,
|
||||
@@ -81,7 +63,7 @@ export function GoalComposer({
|
||||
compact = false,
|
||||
suggestions,
|
||||
}: {
|
||||
onSubmit: (goal: string, mode: AgentMode) => void;
|
||||
onSubmit: (goal: string, opts: AgentSessionOptions) => void;
|
||||
pending: boolean;
|
||||
/** 上下文前缀(只读展示,提交时拼在目标前) */
|
||||
prefix?: string;
|
||||
@@ -91,7 +73,7 @@ export function GoalComposer({
|
||||
suggestions?: string[];
|
||||
}) {
|
||||
const [goal, setGoal] = useState(defaultGoal);
|
||||
const [mode, setMode] = useState<AgentMode>("execute");
|
||||
const [proposeFirst, setProposeFirst] = useState(false);
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const fill = (text: string) => {
|
||||
setGoal(text);
|
||||
@@ -107,7 +89,7 @@ export function GoalComposer({
|
||||
<PromptInput
|
||||
value={goal}
|
||||
onValueChange={setGoal}
|
||||
onSubmit={(g) => onSubmit(g, mode)}
|
||||
onSubmit={(g) => onSubmit(g, { proposeFirst })}
|
||||
pending={pending}
|
||||
minRows={compact ? 2 : 3}
|
||||
maxRows={10}
|
||||
@@ -125,10 +107,13 @@ export function GoalComposer({
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
toolbar={<ModeSwitch mode={mode} onChange={setMode} />}
|
||||
toolbar={<ProposeFirstToggle checked={proposeFirst} onChange={setProposeFirst} />}
|
||||
/>
|
||||
<p className="px-1 text-meta text-muted-foreground" aria-live="polite">
|
||||
{MODE_HELP[mode].help} Enter 发送,Shift+Enter 换行。
|
||||
{proposeFirst
|
||||
? "Agent 先调查并给出方案,你选定后才执行。"
|
||||
: "Agent 列出步骤后直接执行,需要审批的动作会停在对应步骤等你批准。"}{" "}
|
||||
Enter 发送,Shift+Enter 换行。
|
||||
</p>
|
||||
{suggestions?.length ? (
|
||||
<div className="pt-2">
|
||||
@@ -155,25 +140,21 @@ export function GoalComposer({
|
||||
);
|
||||
}
|
||||
|
||||
/** 一次运行:头部状态 + 预算条 + (只要方案的步骤清单)+ 流式对话 + 审批 + 最终汇报 + 输入框 */
|
||||
/** 一次运行:头部状态 + 预算条 + 步骤清单 + 流式对话(方案选择 / 审批卡在对应步骤处)+ 最终汇报 + 输入框 */
|
||||
export function AgentRunPanel({
|
||||
runId,
|
||||
compact = false,
|
||||
className,
|
||||
onRunCreated,
|
||||
}: {
|
||||
runId: string;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
/** 从本会话派生了新运行(按方案执行)时 */
|
||||
onRunCreated?: (id: string) => void;
|
||||
}) {
|
||||
const run = useAgentRun(runId);
|
||||
const active = isRunActive(run.data?.run.status);
|
||||
const live = useAgentStream(runId, active || !run.data);
|
||||
const abort = useAbortAgentRun();
|
||||
const append = useAppendAgentMessage();
|
||||
const create = useCreateAgentRun();
|
||||
const [msg, setMsg] = useState("");
|
||||
const [confirmStop, setConfirmStop] = useState(false);
|
||||
const sseStatus = useSseStatus();
|
||||
@@ -181,6 +162,8 @@ export function AgentRunPanel({
|
||||
const toolCount = live.items.filter((i) => i.kind === "tool" && i.call.status !== "running").length;
|
||||
const audit = useRunInvocationAudit(runId, toolCount > 0, toolCount);
|
||||
const approvals = useRunApprovals(runPlanIds(live.items, run.data?.report ?? null));
|
||||
// 会话内审批卡已在对应步骤处显示,这里只补本次运行计划上的其它审批
|
||||
const inline = new Set(live.items.flatMap((i) => (i.kind === "approval" ? [i.view.approvalId] : [])));
|
||||
// run 状态变化(queued → running → 终态)经 SSE 推送:到达即刷新快照,不等 3 秒轮询
|
||||
// 子会话:本会话收到子会话提示(agent.tool_call / agent.spawn)或任一子会话 / 父会话状态变化时刷新会话树
|
||||
const related = new Set([
|
||||
@@ -207,7 +190,7 @@ export function AgentRunPanel({
|
||||
const { run: r, report, messages } = run.data;
|
||||
const finished = !isRunActive(r.status) || live.finished;
|
||||
const streaming = active && !live.finished;
|
||||
const mode: AgentMode = (r.mode ?? (r.scope.mode as AgentMode | undefined)) || "execute";
|
||||
const proposeFirst = isProposeFirst(r);
|
||||
const shownStatus =
|
||||
// 事件流已结束但快照还没刷到终态时按运行中显示;waiting(等待下载/入库核验)照实显示
|
||||
(live.finished && (r.status === "queued" || r.status === "running")) ||
|
||||
@@ -226,21 +209,13 @@ export function AgentRunPanel({
|
||||
onError: (err) => toast.error(errorMessage(err)),
|
||||
},
|
||||
);
|
||||
const executePlan = () =>
|
||||
create.mutate(
|
||||
{ goal: `${r.goal}\n\n(按会话 ${r.id.slice(0, 8)} 的方案执行)`, mode: "execute" },
|
||||
{
|
||||
onSuccess: (n) => onRunCreated?.(n.id),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("flex min-h-0 flex-col gap-3", className)}>
|
||||
<RunAnnouncer status={shownStatus} finished={live.finished} />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<RunStatusBadge status={shownStatus} />
|
||||
<StatusBadge tone={mode === "plan_only" ? "warning" : "neutral"}>{MODE_HELP[mode].label}</StatusBadge>
|
||||
{proposeFirst ? <StatusBadge>先看方案再执行</StatusBadge> : null}
|
||||
<span className="min-w-0 text-meta text-muted-foreground">
|
||||
{TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)}
|
||||
{r.model ? ` · ${r.provider ?? ""}/${r.model}` : ""}
|
||||
@@ -259,7 +234,7 @@ export function AgentRunPanel({
|
||||
|
||||
<SubagentTree snapshot={run.data} />
|
||||
|
||||
{mode === "plan_only" ? <PlanSteps live={live} active={streaming} /> : null}
|
||||
<RunPlan plan={live.plan} active={streaming || r.status === "waiting"} compact={compact} />
|
||||
|
||||
{streaming && sseStatus === "reconnecting" ? (
|
||||
<div
|
||||
@@ -296,9 +271,11 @@ export function AgentRunPanel({
|
||||
userMessages={messages.map((m) => ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))}
|
||||
after={
|
||||
<>
|
||||
{approvals.items.map((a) => (
|
||||
<ApprovalItem key={a.id} approval={a} />
|
||||
))}
|
||||
{approvals.items
|
||||
.filter((a) => !inline.has(a.id))
|
||||
.map((a) => (
|
||||
<ApprovalItem key={a.id} approval={a} />
|
||||
))}
|
||||
{finished ? <FinalReport run={r} report={report} live={live} /> : null}
|
||||
</>
|
||||
}
|
||||
@@ -319,22 +296,12 @@ export function AgentRunPanel({
|
||||
maxLength={8000}
|
||||
aria-label="追加消息"
|
||||
placeholder={streaming ? "补充或纠正(运行中也可追加)" : "追加消息"}
|
||||
toolbar={
|
||||
mode === "plan_only" && finished ? (
|
||||
<Button type="button" size="sm" variant="outline" onClick={executePlan} disabled={create.isPending}>
|
||||
<Play />
|
||||
按方案执行
|
||||
</Button>
|
||||
) : (
|
||||
<span className="px-1 text-meta text-muted-foreground">本会话:{MODE_HELP[mode].label}</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="px-1 text-meta text-muted-foreground">
|
||||
{mode === "plan_only" && finished
|
||||
? "「按方案执行」会新开一次授权内执行的运行,仍受服务端授权校验。"
|
||||
: MODE_HELP[mode].help}{" "}
|
||||
Enter 发送,Shift+Enter 换行{streaming ? ",停止 = 中止本次运行" : ""}。
|
||||
{r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批")
|
||||
? `${r.summary}:在上面的卡片里处理,会话随即继续。`
|
||||
: "追加的消息在本轮结束后读取。"}{" "}
|
||||
Enter 发送,Shift+Enter 换行{streaming ? ",停止 = 结束本次运行" : ""}。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -101,8 +101,8 @@ export function AgentSessionList({
|
||||
) : null}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<RunStatusBadge status={r.status} />
|
||||
{(r.mode ?? r.scope.mode) === "plan_only" ? (
|
||||
<span className="text-meta text-muted-foreground">只要方案</span>
|
||||
{r.scope.proposeFirst === true ? (
|
||||
<span className="text-meta text-muted-foreground">先看方案</span>
|
||||
) : null}
|
||||
{r.parentRunId ? <span className="text-meta text-muted-foreground">子会话</span> : null}
|
||||
<time
|
||||
|
||||
@@ -57,7 +57,6 @@ function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map<str
|
||||
</Link>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
|
||||
<RunStatusBadge status={node.status} />
|
||||
{node.mode === "plan_only" ? <span className="text-meta text-muted-foreground">只要方案</span> : null}
|
||||
<span className="text-meta text-muted-foreground tabular-nums">
|
||||
{usageLine({
|
||||
toolCalls: node.toolCallCount,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AgentRunEvent, AgentRunEventBody, AgentUsageTotals } from "@umt/contracts";
|
||||
import type { AgentMode, AgentRunView, GoalReport, RunMessage, RunSnapshot } from "~/api/agent.ts";
|
||||
import type { AgentPlanStep, AgentRunEvent, AgentRunEventBody, AgentUsageTotals } from "@umt/contracts";
|
||||
import type { AgentRunView, AgentSessionOptions, GoalReport, RunMessage, RunSnapshot } from "~/api/agent.ts";
|
||||
|
||||
// Agent 会话 mock(W2-D 端点就绪前开发用;?mock=1 打开)。事件按时间逐步"到达",模拟流式输出。
|
||||
|
||||
@@ -33,7 +33,11 @@ function chunks(text: string, size = 6): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function script(goal: string, mode: AgentMode): Step[] {
|
||||
const MOCK_DECISION = "00000000-0000-4000-8000-00000000d001";
|
||||
const MOCK_APPROVAL = "00000000-0000-4000-8000-00000000a001";
|
||||
const MOCK_PLAN = "00000000-0000-4000-8000-00000000b001";
|
||||
|
||||
function script(goal: string, opts: AgentSessionOptions): Step[] {
|
||||
const s: Step[] = [];
|
||||
let t = 0;
|
||||
const push = (dt: number, body: AgentRunEventBody) => {
|
||||
@@ -43,7 +47,22 @@ function script(goal: string, mode: AgentMode): Step[] {
|
||||
const say = (text: string) => {
|
||||
for (const c of chunks(text)) push(60, { type: "text_delta", delta: c });
|
||||
};
|
||||
const steps: AgentPlanStep[] = [
|
||||
{ id: "s1", title: "查本地库存和版本目标", status: "running", note: null },
|
||||
{ id: "s2", title: "在站点搜 4K 候选", status: "pending", note: null },
|
||||
{ id: "s3", title: "选资源并生成下载计划", status: "pending", note: null },
|
||||
{ id: "s4", title: "提交下载", status: "pending", note: null },
|
||||
];
|
||||
let revision = 0;
|
||||
const plan = (id: string, status: AgentPlanStep["status"], next?: string) => {
|
||||
for (const st of steps) {
|
||||
if (st.id === id) st.status = status;
|
||||
if (next && st.id === next) st.status = "running";
|
||||
}
|
||||
push(150, { type: "plan_updated", steps: steps.map((x) => ({ ...x })), revision: ++revision });
|
||||
};
|
||||
push(0, { type: "run_started", goal, resumed: false });
|
||||
push(100, { type: "plan_updated", steps: steps.map((x) => ({ ...x })), revision: ++revision });
|
||||
say("先确认本地库存与版本目标,再去站点找候选。");
|
||||
push(400, {
|
||||
type: "tool_call_start",
|
||||
@@ -60,6 +79,7 @@ function script(goal: string, mode: AgentMode): Step[] {
|
||||
outputSummary: "作品 沙丘:第二部 (2024):已有 1080p WEB-DL(已核验);Slot「archival」缺 2160p;无下载中任务",
|
||||
recovery: false,
|
||||
});
|
||||
plan("s1", "done", "s2");
|
||||
push(300, {
|
||||
type: "tool_call_start",
|
||||
toolCallId: "t2",
|
||||
@@ -79,53 +99,102 @@ function script(goal: string, mode: AgentMode): Step[] {
|
||||
push(300, { type: "compaction", phase: "start", reason: "threshold" });
|
||||
push(700, { type: "compaction", phase: "end", reason: "threshold", status: "completed" });
|
||||
say("①符合 archival 的硬条件(REMUX、HDR),免费截止时间在 6 小时后。");
|
||||
if (mode === "execute") {
|
||||
plan("s2", "done", "s3");
|
||||
if (opts.proposeFirst) {
|
||||
push(300, {
|
||||
type: "tool_call_start",
|
||||
toolCallId: "t3",
|
||||
capabilityId: "download.plan",
|
||||
inputSummary: '{"sourceReleaseId":"…c3f1","slot":"archival"}',
|
||||
recovery: false,
|
||||
type: "options_proposed",
|
||||
invocationId: "mock-options",
|
||||
decisionId: MOCK_DECISION,
|
||||
question: "收哪一个 4K 版本?",
|
||||
options: [
|
||||
{
|
||||
id: "a",
|
||||
title: "① REMUX 78.4 GB",
|
||||
summary: "画质最好,符合 archival 的硬条件;免费还剩 6 小时。",
|
||||
risks: ["缓存卷使用率会到 86%"],
|
||||
estimatedCost: "78.4 GB",
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
title: "② WEB-DL 24.1 GB",
|
||||
summary: "体积小三分之二,不满足 archival 的 REMUX 条件。",
|
||||
risks: ["之后可能还要升级"],
|
||||
estimatedCost: "24.1 GB",
|
||||
},
|
||||
],
|
||||
recommendedId: "a",
|
||||
stepId: "s3",
|
||||
expiresAt: iso(Date.now() + 1_800_000),
|
||||
timeoutAction: "recommended",
|
||||
});
|
||||
push(800, {
|
||||
type: "tool_call_end",
|
||||
toolCallId: "t3",
|
||||
capabilityId: "download.plan",
|
||||
isError: false,
|
||||
outputSummary: "计划已预留:78.4 GB,缓存卷 cache-a 使用率 71% → 预计 86%(硬阈值 90%)",
|
||||
recovery: false,
|
||||
});
|
||||
push(300, {
|
||||
type: "tool_call_rejected",
|
||||
toolCallId: "t4",
|
||||
toolName: "qb_delete_torrent",
|
||||
capabilityId: "download.cleanup",
|
||||
reason: "未授权:删除下载器任务不在本次委托范围内(需要显式授权)",
|
||||
});
|
||||
push(300, {
|
||||
type: "tool_call_start",
|
||||
toolCallId: "t5",
|
||||
capabilityId: "download.submit",
|
||||
inputSummary: '{"planId":"…9a2e"}',
|
||||
recovery: false,
|
||||
});
|
||||
push(1000, {
|
||||
type: "tool_call_end",
|
||||
toolCallId: "t5",
|
||||
capabilityId: "download.submit",
|
||||
isError: false,
|
||||
outputSummary: "已提交到 qb-main,标签 umt-managed,任务 ID …41d0",
|
||||
recovery: false,
|
||||
});
|
||||
say("已提交 4K REMUX 下载。完成后会自动核验并 Copy 入库,原 1080p 保留。");
|
||||
} else {
|
||||
say("只要方案:建议提交①,预计占用缓存 78.4 GB,完成后 Copy 入库、保留 1080p。未执行任何写操作。");
|
||||
push(200, { type: "run_completed", finalText: "", usage: USAGE });
|
||||
return s;
|
||||
}
|
||||
push(300, {
|
||||
type: "tool_call_start",
|
||||
toolCallId: "t3",
|
||||
capabilityId: "download.plan",
|
||||
inputSummary: '{"sourceReleaseId":"…c3f1","slot":"archival"}',
|
||||
recovery: false,
|
||||
});
|
||||
push(800, {
|
||||
type: "tool_call_end",
|
||||
toolCallId: "t3",
|
||||
capabilityId: "download.plan",
|
||||
isError: false,
|
||||
outputSummary: "计划已预留:78.4 GB,缓存卷 cache-a 使用率 71% → 预计 86%(硬阈值 90%)",
|
||||
recovery: false,
|
||||
});
|
||||
plan("s3", "done", "s4");
|
||||
push(300, {
|
||||
type: "tool_call_rejected",
|
||||
toolCallId: "t4",
|
||||
toolName: "qb_delete_torrent",
|
||||
capabilityId: "download.cleanup",
|
||||
reason: "未授权:删除下载器任务不在本次委托范围内(需要显式授权)",
|
||||
});
|
||||
push(300, {
|
||||
type: "tool_call_start",
|
||||
toolCallId: "t5",
|
||||
capabilityId: "download.submit",
|
||||
inputSummary: '{"planId":"…9a2e"}',
|
||||
recovery: false,
|
||||
});
|
||||
push(1000, {
|
||||
type: "tool_call_end",
|
||||
toolCallId: "t5",
|
||||
capabilityId: "download.submit",
|
||||
isError: false,
|
||||
outputSummary: '{"error":{"code":"unauthorized","message":"已在步骤处发起审批,等待管理员批准"}}',
|
||||
recovery: false,
|
||||
});
|
||||
push(200, {
|
||||
type: "approval_requested",
|
||||
approvalId: MOCK_APPROVAL,
|
||||
decisionId: null,
|
||||
invocationId: "mock-submit",
|
||||
capabilityId: "download.submit",
|
||||
planType: "acquisition_plan",
|
||||
planId: MOCK_PLAN,
|
||||
payloadHash: "sha256:9a2e…",
|
||||
expectedRevision: 1,
|
||||
riskLevel: "high",
|
||||
summary: "下载《沙丘:第二部》:Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX(78.4 GB)",
|
||||
stepId: "s4",
|
||||
expiresAt: iso(Date.now() + 86_400_000),
|
||||
});
|
||||
say("提交下载需要审批,已在「提交下载」这一步发起。批准后继续。");
|
||||
push(200, { type: "run_completed", finalText: "", usage: USAGE });
|
||||
return s;
|
||||
}
|
||||
|
||||
function baseRun(id: string, goal: string, mode: AgentMode, createdAt: number, status: AgentRunView["status"]) {
|
||||
function baseRun(
|
||||
id: string,
|
||||
goal: string,
|
||||
opts: AgentSessionOptions,
|
||||
createdAt: number,
|
||||
status: AgentRunView["status"],
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
createdAt: iso(createdAt),
|
||||
@@ -138,7 +207,7 @@ function baseRun(id: string, goal: string, mode: AgentMode, createdAt: number, s
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet",
|
||||
thinkingLevel: "medium",
|
||||
scope: { mode },
|
||||
scope: { proposeFirst: opts.proposeFirst, interactive: true },
|
||||
budget: { maxToolCalls: 20, maxTokens: 200_000, maxDurationSec: 600, maxCostUsd: 1 },
|
||||
usage: {},
|
||||
evidenceVersion: null,
|
||||
@@ -151,18 +220,17 @@ function baseRun(id: string, goal: string, mode: AgentMode, createdAt: number, s
|
||||
startedAt: iso(createdAt),
|
||||
finishedAt: null,
|
||||
createdBy: "admin",
|
||||
mode,
|
||||
dedupeKey: null,
|
||||
depth: 0,
|
||||
spawnReason: null,
|
||||
} satisfies AgentRunView;
|
||||
}
|
||||
|
||||
function add(goal: string, mode: AgentMode, startedAt: number, n: number): Entry {
|
||||
function add(goal: string, opts: AgentSessionOptions, startedAt: number, n: number): Entry {
|
||||
const id = uuid(n);
|
||||
const e: Entry = {
|
||||
run: baseRun(id, goal, mode, startedAt, "running"),
|
||||
steps: script(goal, mode),
|
||||
run: baseRun(id, goal, opts, startedAt, "running"),
|
||||
steps: script(goal, opts),
|
||||
startedAt,
|
||||
abortedAt: null,
|
||||
appended: [],
|
||||
@@ -175,9 +243,9 @@ function seed() {
|
||||
if (seeded) return;
|
||||
seeded = true;
|
||||
const now = Date.now();
|
||||
add("把 沙丘:第二部 的 4K 收进来", "execute", now - 3_600_000, 1);
|
||||
add("对比一下 奥本海默 现有版本和站点上的 4K,只给方案", "plan_only", now - 7_200_000, 2);
|
||||
const failed = add("检查 qb-main 为什么占满磁盘", "execute", now - 86_400_000, 3);
|
||||
add("把 沙丘:第二部 的 4K 收进来", { proposeFirst: false }, now - 3_600_000, 1);
|
||||
add("对比一下 奥本海默 现有版本和站点上的 4K", { proposeFirst: true }, now - 7_200_000, 2);
|
||||
const failed = add("检查 qb-main 为什么占满磁盘", { proposeFirst: false }, now - 86_400_000, 3);
|
||||
failed.steps = [
|
||||
{ delayMs: 0, body: { type: "run_started", goal: failed.run.goal, resumed: false } },
|
||||
{
|
||||
@@ -356,8 +424,8 @@ export function mockEventsAfter(id: string, after: number): AgentRunEvent[] {
|
||||
return revealed(e).filter((x) => x.seq > after);
|
||||
}
|
||||
|
||||
export function mockCreateRun(goal: string, mode: AgentMode): AgentRunView {
|
||||
export function mockCreateRun(goal: string, opts: AgentSessionOptions): AgentRunView {
|
||||
seed();
|
||||
const e = add(goal, mode, Date.now(), store.size + 1);
|
||||
const e = add(goal, opts, Date.now(), store.size + 1);
|
||||
return view(e);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { quickGoalsFor } from "./quick-goals.ts";
|
||||
describe("抽屉快捷目标", () => {
|
||||
it("按页面给 3 个相关目标", () => {
|
||||
expect(quickGoalsFor("/console/library/abc")[0]).toBe("检查这部作品缺什么版本");
|
||||
expect(quickGoalsFor("/console/sites/hdsky")[0]).toBe("从这个站找 ");
|
||||
expect(quickGoalsFor("/console/sites/hdsky")[0]).toBe("从这个站点找 ");
|
||||
expect(quickGoalsFor("/console/tasks/t1")[0]).toBe("看看为什么没进展");
|
||||
for (const p of ["/console", "/console/library", "/console/files", "/nope"]) {
|
||||
expect(quickGoalsFor(p)).toHaveLength(3);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// 抽屉首屏的快捷目标:按当前页面给 3 个最常见的问法,点一下填进输入框(不直接提交)。
|
||||
// 以空格结尾的是"半句",填入后光标停在末尾等用户补完(如"从这个站找 ")。
|
||||
// 以空格结尾的是"半句",填入后光标停在末尾等用户补完(如"从这个站点找 ")。用词按 docs/design/glossary.md。
|
||||
|
||||
type Rule = { match: RegExp; goals: [string, string, string] };
|
||||
|
||||
const RULES: Rule[] = [
|
||||
{
|
||||
match: /^\/console\/library\/[^/]+/,
|
||||
goals: ["检查这部作品缺什么版本", "这部作品有没有更好的片源可以升级", "核对这部作品的文件和元数据是否一致"],
|
||||
goals: ["检查这部作品缺什么版本", "这部作品有没有更好的资源可以升级", "核对这部作品的文件和元数据是否一致"],
|
||||
},
|
||||
{
|
||||
match: /^\/console\/library\/?$/,
|
||||
@@ -14,7 +14,7 @@ const RULES: Rule[] = [
|
||||
},
|
||||
{
|
||||
match: /^\/console\/sites\/[^/]+/,
|
||||
goals: ["从这个站找 ", "看看这个站最近有哪些新的 4K 资源", "检查这个站的登录和抓取是否正常"],
|
||||
goals: ["从这个站点找 ", "看看这个站点最近有哪些新的 4K 资源", "检查这个站点的登录和抓取是否正常"],
|
||||
},
|
||||
{
|
||||
match: /^\/console\/sites\/?$/,
|
||||
@@ -38,7 +38,7 @@ const RULES: Rule[] = [
|
||||
},
|
||||
{
|
||||
match: /^\/console\/inbox/,
|
||||
goals: ["逐条说明待处理事项的原因和建议", "先处理最紧急的一项", "哪些事项可以直接按建议批量处理"],
|
||||
goals: ["逐条说明待处理事项的原因和建议", "先处理最紧急的一项待处理事项", "哪些待处理事项可以直接按建议批量处理"],
|
||||
},
|
||||
{
|
||||
match: /^\/console\/identity/,
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { type AgentRunEvent, type AgentUsageTotals, EventType } from "@umt/contracts";
|
||||
import {
|
||||
type AgentOptionItem,
|
||||
type AgentPlanStep,
|
||||
type AgentRunEvent,
|
||||
type AgentUsageTotals,
|
||||
EventType,
|
||||
} from "@umt/contracts";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { agentMockEnabled, fetchRunEvents } from "~/api/agent.ts";
|
||||
import { sse } from "~/lib/sse.ts";
|
||||
@@ -23,6 +29,8 @@ export type StreamItem =
|
||||
| { kind: "thinking"; key: string; text: string; at: string }
|
||||
| { kind: "tool"; key: string; call: ToolCallView }
|
||||
| { kind: "compaction"; key: string; at: string; reason: string; status: string | null; done: boolean }
|
||||
| { kind: "options"; key: string; at: string; view: OptionsView }
|
||||
| { kind: "approval"; key: string; at: string; view: ApprovalView }
|
||||
| {
|
||||
kind: "end";
|
||||
key: string;
|
||||
@@ -33,9 +41,43 @@ export type StreamItem =
|
||||
budget: string | null;
|
||||
};
|
||||
|
||||
/** agent.propose_options 的一次提问(options_proposed + options_resolved) */
|
||||
export type OptionsView = {
|
||||
decisionId: string;
|
||||
question: string;
|
||||
options: AgentOptionItem[];
|
||||
recommendedId: string | null;
|
||||
stepId: string | null;
|
||||
expiresAt: string;
|
||||
timeoutAction: "recommended" | "stop";
|
||||
resolved: {
|
||||
outcome: "chosen" | "rejected" | "stop";
|
||||
choice: string | null;
|
||||
reason: string | null;
|
||||
by: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/** 步骤处的审批卡(approval_requested + approval_resolved;挂起期间以审批对象的实时状态为准) */
|
||||
export type ApprovalView = {
|
||||
approvalId: string;
|
||||
capabilityId: string;
|
||||
planType: string;
|
||||
planId: string;
|
||||
payloadHash: string;
|
||||
expectedRevision: number;
|
||||
riskLevel: "low" | "medium" | "high";
|
||||
summary: string;
|
||||
stepId: string | null;
|
||||
expiresAt: string;
|
||||
resolved: { status: string; reason: string | null; decidedBy: string | null } | null;
|
||||
};
|
||||
|
||||
export type AgentStreamState = {
|
||||
events: AgentRunEvent[];
|
||||
items: StreamItem[];
|
||||
/** 最新的步骤清单(agent.plan);没有调用过为 null */
|
||||
plan: { steps: AgentPlanStep[]; revision: number } | null;
|
||||
usage: AgentUsageTotals | null;
|
||||
finished: boolean;
|
||||
error: string | null;
|
||||
@@ -50,6 +92,8 @@ const SSE_TYPES: string[] = [EventType.agentTextDelta, EventType.agentToolCall,
|
||||
export function foldEvents(events: AgentRunEvent[]): StreamItem[] {
|
||||
const items: StreamItem[] = [];
|
||||
const tools = new Map<string, ToolCallView>();
|
||||
const asks = new Map<string, OptionsView>();
|
||||
const approvals = new Map<string, ApprovalView>();
|
||||
for (const e of events) {
|
||||
const last = items[items.length - 1];
|
||||
switch (e.type) {
|
||||
@@ -146,6 +190,49 @@ export function foldEvents(events: AgentRunEvent[]): StreamItem[] {
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "options_proposed": {
|
||||
const view: OptionsView = {
|
||||
decisionId: e.decisionId,
|
||||
question: e.question,
|
||||
options: e.options,
|
||||
recommendedId: e.recommendedId,
|
||||
stepId: e.stepId,
|
||||
expiresAt: e.expiresAt,
|
||||
timeoutAction: e.timeoutAction,
|
||||
resolved: null,
|
||||
};
|
||||
asks.set(e.decisionId, view);
|
||||
items.push({ kind: "options", key: `o${e.decisionId}`, at: e.at, view });
|
||||
break;
|
||||
}
|
||||
case "options_resolved": {
|
||||
const v = asks.get(e.decisionId);
|
||||
if (v) v.resolved = { outcome: e.outcome, choice: e.choice, reason: e.reason, by: e.by };
|
||||
break;
|
||||
}
|
||||
case "approval_requested": {
|
||||
const view: ApprovalView = {
|
||||
approvalId: e.approvalId,
|
||||
capabilityId: e.capabilityId,
|
||||
planType: e.planType,
|
||||
planId: e.planId,
|
||||
payloadHash: e.payloadHash,
|
||||
expectedRevision: e.expectedRevision,
|
||||
riskLevel: e.riskLevel,
|
||||
summary: e.summary,
|
||||
stepId: e.stepId,
|
||||
expiresAt: e.expiresAt,
|
||||
resolved: null,
|
||||
};
|
||||
approvals.set(e.approvalId, view);
|
||||
items.push({ kind: "approval", key: `a${e.approvalId}`, at: e.at, view });
|
||||
break;
|
||||
}
|
||||
case "approval_resolved": {
|
||||
const v = approvals.get(e.approvalId);
|
||||
if (v) v.resolved = { status: e.status, reason: e.reason, decidedBy: e.decidedBy };
|
||||
break;
|
||||
}
|
||||
case "run_completed":
|
||||
items.push({
|
||||
kind: "end",
|
||||
@@ -255,6 +342,15 @@ export function useAgentStream(runId: string | undefined, active: boolean): Agen
|
||||
const items = foldEvents(events);
|
||||
const end = [...events].reverse().find((e) => e.type === "run_completed" || e.type === "run_failed");
|
||||
const usage = end && (end.type === "run_completed" || end.type === "run_failed") ? end.usage : null;
|
||||
return { events, items, usage, finished: !!end, error, connected, retry };
|
||||
return { events, items, plan: latestPlan(events), usage, finished: !!end, error, connected, retry };
|
||||
}, [events, error, connected, retry]);
|
||||
}
|
||||
|
||||
/** 最新的步骤清单(最后一条 plan_updated) */
|
||||
export function latestPlan(events: AgentRunEvent[]): AgentStreamState["plan"] {
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const e = events[i]!;
|
||||
if (e.type === "plan_updated") return { steps: e.steps, revision: e.revision };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -2,13 +2,14 @@
|
||||
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
|
||||
// 改造:SVG 状态图标保留几何(虚线圈=待办、弧线=进行中、勾=完成、叉=取消),pathLength 弹簧动画换成
|
||||
// CSS 过渡/animate-spin(motion-reduce 关闭);完成色 emerald → success,取消 rose → destructive,且都配 sr-only 文字;
|
||||
// 表头完成数去掉滚字;折叠用 Disclosure;maxHeight 改成 Tailwind 类;新增 `error` 状态(工具失败)与 `detail` 槽。
|
||||
// 表头完成数去掉滚字;折叠用 Disclosure;maxHeight 改成 Tailwind 类;新增 `error` 状态(工具失败)、
|
||||
// `skipped` 状态(Agent 步骤清单里跳过的步骤)与 `detail` 槽。
|
||||
import { ChevronDown, ListTodo } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { CHEVRON_CLASS, Disclosure } from "./disclosure.tsx";
|
||||
|
||||
export type TodoItemStatus = "pending" | "in-progress" | "completed" | "cancelled" | "error";
|
||||
export type TodoItemStatus = "pending" | "in-progress" | "completed" | "cancelled" | "error" | "skipped";
|
||||
|
||||
export interface TodoItem {
|
||||
id: string;
|
||||
@@ -23,6 +24,7 @@ const LABEL: Record<TodoItemStatus, string> = {
|
||||
completed: "已完成",
|
||||
cancelled: "被拒绝",
|
||||
error: "失败",
|
||||
skipped: "已跳过",
|
||||
};
|
||||
|
||||
function StatusIcon({ status }: { status: TodoItemStatus }) {
|
||||
@@ -45,7 +47,7 @@ function StatusIcon({ status }: { status: TodoItemStatus }) {
|
||||
fillOpacity={status === "completed" ? 0.1 : 0}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray={status === "pending" ? "2 3" : undefined}
|
||||
strokeDasharray={status === "pending" || status === "skipped" ? "2 3" : undefined}
|
||||
className={cn(status === "in-progress" && "opacity-20")}
|
||||
/>
|
||||
{status === "in-progress" ? (
|
||||
@@ -72,6 +74,9 @@ function StatusIcon({ status }: { status: TodoItemStatus }) {
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
) : null}
|
||||
{status === "skipped" ? (
|
||||
<path d="M8 12h8" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
) : null}
|
||||
{status === "cancelled" || status === "error" ? (
|
||||
<path
|
||||
d="M8.5 8.5 15.5 15.5M15.5 8.5 8.5 15.5"
|
||||
@@ -162,6 +167,7 @@ export function TodoList({
|
||||
s === "pending" && "text-muted-foreground",
|
||||
s === "in-progress" && "text-foreground",
|
||||
s === "completed" && "text-muted-foreground line-through decoration-foreground/30",
|
||||
s === "skipped" && "text-muted-foreground",
|
||||
(s === "cancelled" || s === "error") && "text-destructive",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -150,6 +150,27 @@ const ADVANCED_CARD: CardSpec = {
|
||||
kind: "number",
|
||||
hint: "超时后按「仍在等待」汇报结束",
|
||||
},
|
||||
{
|
||||
name: "agent.optionsTimeoutSec",
|
||||
label: "方案选择等待(秒)",
|
||||
kind: "number",
|
||||
hint: "Agent 给出方案后等你选择的最长时间",
|
||||
},
|
||||
{
|
||||
name: "agent.optionsTimeoutAction",
|
||||
label: "超时未选",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "recommended", label: "自动选推荐项" },
|
||||
{ value: "stop", label: "不选,Agent 收尾" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "agent.approvalTimeoutSec",
|
||||
label: "会话内审批有效期(秒)",
|
||||
kind: "number",
|
||||
hint: "步骤处的审批卡过期未批准,按拒绝处理",
|
||||
},
|
||||
{
|
||||
name: "agent.sessionDir",
|
||||
label: "会话目录",
|
||||
@@ -168,6 +189,9 @@ const ADVANCED_CARD: CardSpec = {
|
||||
fromSettings: (s) => ({
|
||||
"agent.goalPollSec": n(s.agent.goalPollSec),
|
||||
"agent.goalWaitHours": n(s.agent.goalWaitHours),
|
||||
"agent.optionsTimeoutSec": n(s.agent.optionsTimeoutSec),
|
||||
"agent.optionsTimeoutAction": s.agent.optionsTimeoutAction,
|
||||
"agent.approvalTimeoutSec": n(s.agent.approvalTimeoutSec),
|
||||
"agent.sessionDir": s.agent.sessionDir ?? "",
|
||||
"agent.fakeScript": s.agent.fakeScript ?? "",
|
||||
}),
|
||||
@@ -175,6 +199,9 @@ const ADVANCED_CARD: CardSpec = {
|
||||
agent: {
|
||||
goalPollSec: num(v["agent.goalPollSec"]),
|
||||
goalWaitHours: num(v["agent.goalWaitHours"]),
|
||||
optionsTimeoutSec: num(v["agent.optionsTimeoutSec"]),
|
||||
optionsTimeoutAction: v["agent.optionsTimeoutAction"] === "stop" ? "stop" : "recommended",
|
||||
approvalTimeoutSec: num(v["agent.approvalTimeoutSec"]),
|
||||
sessionDir: str(v["agent.sessionDir"]) || null,
|
||||
fakeScript: str(v["agent.fakeScript"]) || null,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AgentRunStatus } from "@umt/contracts";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { agentMockEnabled, useAgentRun } from "~/api/agent.ts";
|
||||
import { AgentRunPanel } from "~/components/agent/agent-run-panel.tsx";
|
||||
import { AgentSessionList } from "~/components/agent/agent-session-list.tsx";
|
||||
@@ -12,7 +12,6 @@ export default function AgentRunPage() {
|
||||
const { runId = "" } = useParams();
|
||||
const run = useAgentRun(runId);
|
||||
const mock = agentMockEnabled();
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<"" | AgentRunStatus>("");
|
||||
return (
|
||||
<>
|
||||
@@ -33,12 +32,7 @@ export default function AgentRunPage() {
|
||||
<AgentSessionList activeId={runId} status={status} onStatusChange={setStatus} className="h-full" />
|
||||
</aside>
|
||||
<div className="pg-slab flex h-[calc(100dvh-11rem)] min-h-[32rem] flex-col rounded-xl p-3 md:p-4 lg:h-[calc(100dvh-13rem)]">
|
||||
<AgentRunPanel
|
||||
key={runId}
|
||||
runId={runId}
|
||||
className="flex-1"
|
||||
onRunCreated={(id) => navigate(`/console/agent/${id}${mock ? "?mock=1" : ""}`)}
|
||||
/>
|
||||
<AgentRunPanel key={runId} runId={runId} className="flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -18,7 +18,10 @@ export default function AgentPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Agent 会话" description="交代目标,Agent 在授权范围内执行并汇报。只要方案时只调查,不执行。" />
|
||||
<PageHeader
|
||||
title="Agent 会话"
|
||||
description="交代目标,Agent 列出步骤后在授权范围内执行并汇报;需要审批的动作停在对应步骤等你批准。"
|
||||
/>
|
||||
{mock ? (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-xl border border-warning/40 bg-warning/10 px-4 py-3 text-caption text-warning">
|
||||
<FlaskConical className="h-4 w-4 shrink-0" />
|
||||
@@ -37,9 +40,9 @@ export default function AgentPage() {
|
||||
<h2 className="mb-3 text-body font-medium text-foreground">新会话</h2>
|
||||
<GoalComposer
|
||||
pending={create.isPending}
|
||||
onSubmit={(goal, mode) =>
|
||||
onSubmit={(goal, opts) =>
|
||||
create.mutate(
|
||||
{ goal, mode },
|
||||
{ goal, ...opts },
|
||||
{
|
||||
onSuccess: (run) => navigate(`/console/agent/${run.id}${mock ? "?mock=1" : ""}`),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
@@ -51,7 +54,9 @@ export default function AgentPage() {
|
||||
<div className="flex gap-3 rounded-xl border border-border/70 p-4 text-caption text-muted-foreground">
|
||||
<ShieldCheck aria-hidden className="mt-0.5 size-4 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p>每次工具调用都经服务端授权校验,不在白名单或没有授权的调用会被拒绝并在对话里标出。</p>
|
||||
<p>
|
||||
每次工具调用都经服务端授权校验。控制台发起的会话里,没有持续授权的下载、整理、策略发布会在对应步骤出审批卡。
|
||||
</p>
|
||||
<p>汇报分两栏:模型自述仅供参考,"系统核实的结果"取自下载计划、下载任务和整理记录。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user