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
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user