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,
|
AgentRunDetail,
|
||||||
type AgentRunEvent,
|
type AgentRunEvent,
|
||||||
type AgentRunMessage,
|
type AgentRunMessage,
|
||||||
AgentRunMode,
|
|
||||||
type AgentRunStatus,
|
type AgentRunStatus,
|
||||||
Approval,
|
Approval,
|
||||||
type ApprovalStatus,
|
type ApprovalStatus,
|
||||||
@@ -33,9 +32,10 @@ import { usePiUpstreams } from "./pi.ts";
|
|||||||
|
|
||||||
export const AGENT_BASE = "/api/v1/agent/runs";
|
export const AGENT_BASE = "/api/v1/agent/runs";
|
||||||
|
|
||||||
/** 运行模式:execute = 授权内执行;plan_only = 只调查出方案,不执行写能力 */
|
/** 会话选项(运行模式已取消):proposeFirst = 先看方案再执行 */
|
||||||
export const AgentMode = AgentRunMode;
|
export type AgentSessionOptions = { proposeFirst: boolean };
|
||||||
export type AgentMode = AgentRunMode;
|
/** 会话是否要求先看方案再执行(存在 run.scope 里) */
|
||||||
|
export const isProposeFirst = (run: Pick<AgentRun, "scope">) => run.scope.proposeFirst === true;
|
||||||
|
|
||||||
export type AgentRunView = AgentRun;
|
export type AgentRunView = AgentRun;
|
||||||
/** 服务端从业务表汇总的目标完成度(不采信模型文本) */
|
/** 服务端从业务表汇总的目标完成度(不采信模型文本) */
|
||||||
@@ -196,9 +196,10 @@ export async function fetchRunEvents(id: string, after: number, signal?: AbortSi
|
|||||||
export function useCreateAgentRun() {
|
export function useCreateAgentRun() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (input: { goal: string; mode: AgentMode }) => {
|
mutationFn: async (input: { goal: string; proposeFirst?: boolean }) => {
|
||||||
if (agentMockEnabled()) return mockCreateRun(input.goal, input.mode);
|
const proposeFirst = input.proposeFirst === true;
|
||||||
return apiMutate(AGENT_BASE, { body: { goal: input.goal, mode: input.mode } }).then((d) => AgentRun.parse(d));
|
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 }),
|
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> = {
|
export const RUN_STATUS_LABEL: Record<AgentRunStatus, string> = {
|
||||||
queued: "排队中",
|
queued: "排队中",
|
||||||
running: "运行中",
|
running: "运行中",
|
||||||
@@ -292,13 +308,33 @@ export function useDecideApproval() {
|
|||||||
id: string;
|
id: string;
|
||||||
status: Extract<ApprovalStatus, "approved" | "rejected">;
|
status: Extract<ApprovalStatus, "approved" | "rejected">;
|
||||||
payloadHash: string;
|
payloadHash: string;
|
||||||
|
/** 拒绝理由 / 建议:写进待处理事项,会话内审批时 Agent 按它调整 */
|
||||||
|
reason?: string;
|
||||||
}) =>
|
}) =>
|
||||||
apiMutate(`/approvals/:id`, {
|
apiMutate(`/approvals/:id`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
pathParams: { id: input.id },
|
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)),
|
}).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 AgentRunView,
|
||||||
type GoalReport,
|
type GoalReport,
|
||||||
type InvocationAudit,
|
type InvocationAudit,
|
||||||
|
isProposeFirst,
|
||||||
isRunActive,
|
isRunActive,
|
||||||
REPORT_STATE,
|
REPORT_STATE,
|
||||||
RUN_STATUS_LABEL,
|
RUN_STATUS_LABEL,
|
||||||
@@ -46,12 +47,12 @@ import {
|
|||||||
} from "~/components/agentui/message.tsx";
|
} from "~/components/agentui/message.tsx";
|
||||||
import { MessageBubble, MessageBubbleContent } from "~/components/agentui/message-bubble.tsx";
|
import { MessageBubble, MessageBubbleContent } from "~/components/agentui/message-bubble.tsx";
|
||||||
import { StreamingCursor, StreamingResponse } from "~/components/agentui/streaming-response.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 { ToolApproval } from "~/components/agentui/tool-approval.tsx";
|
||||||
import { ToolResult, ToolResultOutput, type ToolResultStatus } from "~/components/agentui/tool-result.tsx";
|
import { ToolResult, ToolResultOutput, type ToolResultStatus } from "~/components/agentui/tool-result.tsx";
|
||||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||||
|
import { InlineApproval, OptionsCard } from "./agent-interaction.tsx";
|
||||||
import { ToolCallIo } from "./tool-call-card-io.tsx";
|
import { ToolCallIo } from "./tool-call-card-io.tsx";
|
||||||
import type { AgentStreamState, StreamItem, ToolCallView } from "./use-agent-stream.ts";
|
import type { AgentStreamState, StreamItem, ToolCallView } from "./use-agent-stream.ts";
|
||||||
|
|
||||||
@@ -149,18 +150,15 @@ export const isDenied = (call: ToolCallView) => call.status === "denied" || auth
|
|||||||
/** 工具调用卡片:脱敏输入/输出折叠、耗时、scope/风险徽标、幂等键(审计);服务端拒绝时换醒目拒绝态 */
|
/** 工具调用卡片:脱敏输入/输出折叠、耗时、scope/风险徽标、幂等键(审计);服务端拒绝时换醒目拒绝态 */
|
||||||
export function ToolCallCard({
|
export function ToolCallCard({
|
||||||
call,
|
call,
|
||||||
mode,
|
|
||||||
audit,
|
audit,
|
||||||
}: {
|
}: {
|
||||||
call: ToolCallView;
|
call: ToolCallView;
|
||||||
mode?: string;
|
|
||||||
/** 与这次调用对应的审计记录(幂等键等);未取到为 undefined */
|
/** 与这次调用对应的审计记录(幂等键等);未取到为 undefined */
|
||||||
audit?: InvocationAudit;
|
audit?: InvocationAudit;
|
||||||
}) {
|
}) {
|
||||||
const meta = capabilityMeta(call.capabilityId);
|
const meta = capabilityMeta(call.capabilityId);
|
||||||
const authReason = authDenial(call);
|
const authReason = authDenial(call);
|
||||||
if (call.status === "denied" || authReason) {
|
if (call.status === "denied" || authReason) {
|
||||||
const writeKind = meta && !["read", "preview", "propose"].includes(meta.kind);
|
|
||||||
return (
|
return (
|
||||||
<ToolApproval
|
<ToolApproval
|
||||||
status="rejected"
|
status="rejected"
|
||||||
@@ -175,10 +173,8 @@ export function ToolCallCard({
|
|||||||
reason={authReason ?? call.reason ?? "未授权"}
|
reason={authReason ?? call.reason ?? "未授权"}
|
||||||
explanation={
|
explanation={
|
||||||
authReason
|
authReason
|
||||||
? '有副作用的能力需要有效的持续授权或单次审批,由服务端在执行前核对,模型声称"用户已同意"无效。发布收录策略会生成持续授权,也可以为计划申请单次审批。'
|
? '有副作用的能力需要有效的持续授权或单次审批,由服务端在执行前核对,模型声称"用户已同意"无效。控制台发起的会话里,需审批的动作会在对应步骤出审批卡;也可以发布收录策略生成持续授权。'
|
||||||
: mode === "plan_only" && writeKind
|
: "这个能力不在本次运行的白名单内,或已超出调用预算,调用没有执行。"
|
||||||
? "本次是「只要方案」,只放行只读、预览、提案类能力。要执行,点「按方案执行」。"
|
|
||||||
: "这个能力不在本次运行的白名单内,或已超出调用预算,调用没有执行。"
|
|
||||||
}
|
}
|
||||||
parameters={[
|
parameters={[
|
||||||
{ id: "call", label: "调用 ID", value: call.toolCallId },
|
{ id: "call", label: "调用 ID", value: call.toolCallId },
|
||||||
@@ -317,13 +313,14 @@ function EndNotice({ item }: { item: Extract<StreamItem, { kind: "end" }> }) {
|
|||||||
export function UserMessage({
|
export function UserMessage({
|
||||||
text,
|
text,
|
||||||
at,
|
at,
|
||||||
mode,
|
tag,
|
||||||
note,
|
note,
|
||||||
animateIn,
|
animateIn,
|
||||||
}: {
|
}: {
|
||||||
text: string;
|
text: string;
|
||||||
at?: string;
|
at?: string;
|
||||||
mode?: string;
|
/** 会话选项标记(如"先看方案再执行") */
|
||||||
|
tag?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
animateIn?: boolean;
|
animateIn?: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -333,9 +330,9 @@ export function UserMessage({
|
|||||||
<User />
|
<User />
|
||||||
</MessageAvatar>
|
</MessageAvatar>
|
||||||
<MessageContent>
|
<MessageContent>
|
||||||
{at || mode ? (
|
{at || tag ? (
|
||||||
<MessageHeader>
|
<MessageHeader>
|
||||||
{mode ? <span>{mode === "plan_only" ? "只要方案" : "授权内执行"}</span> : null}
|
{tag ? <span>{tag}</span> : null}
|
||||||
{at ? <time dateTime={at}>{formatDateTime(at)}</time> : null}
|
{at ? <time dateTime={at}>{formatDateTime(at)}</time> : null}
|
||||||
</MessageHeader>
|
</MessageHeader>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -402,7 +399,8 @@ export function AgentConversation({
|
|||||||
after?: ReactNode;
|
after?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const active = isRunActive(run.status) && !live.finished;
|
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);
|
const baseline = useRef<number | null>(null);
|
||||||
if (baseline.current === null && (live.events.length > 0 || !active)) baseline.current = live.items.length;
|
if (baseline.current === null && (live.events.length > 0 || !active)) baseline.current = live.items.length;
|
||||||
@@ -444,7 +442,7 @@ export function AgentConversation({
|
|||||||
case "tool":
|
case "tool":
|
||||||
node = (
|
node = (
|
||||||
<AssistantRow animateIn={anim} avatar={false}>
|
<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>
|
</AssistantRow>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
@@ -457,6 +455,20 @@ export function AgentConversation({
|
|||||||
</MessageMarker>
|
</MessageMarker>
|
||||||
);
|
);
|
||||||
break;
|
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":
|
case "end":
|
||||||
node = it.ok ? null : <EndNotice item={it} />;
|
node = it.ok ? null : <EndNotice item={it} />;
|
||||||
break;
|
break;
|
||||||
@@ -473,13 +485,17 @@ export function AgentConversation({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 px-1 py-2">
|
<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))}
|
{rows.map((r) => (r.node ? <div key={r.key}>{r.node}</div> : null))}
|
||||||
{active && live.items[lastIndex]?.kind !== "text" && live.items[lastIndex]?.kind !== "thinking" ? (
|
{active && live.items[lastIndex]?.kind !== "text" && live.items[lastIndex]?.kind !== "thinking" ? (
|
||||||
<AssistantRow avatar={false}>
|
<AssistantRow avatar={false}>
|
||||||
<span className="inline-flex items-center gap-2 text-meta text-muted-foreground">
|
<span className="inline-flex items-center gap-2 text-meta text-muted-foreground">
|
||||||
<MessageTyping label={run.status === "waiting" ? "等待中" : "正在处理"} />
|
<MessageTyping label={run.status === "waiting" ? "等待中" : "正在处理"} />
|
||||||
{run.status === "waiting" ? "等待中…" : run.status === "queued" ? "排队中…" : "正在处理…"}
|
{run.status === "waiting"
|
||||||
|
? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…`
|
||||||
|
: run.status === "queued"
|
||||||
|
? "排队中…"
|
||||||
|
: "正在处理…"}
|
||||||
</span>
|
</span>
|
||||||
</AssistantRow>
|
</AssistantRow>
|
||||||
) : null}
|
) : 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({
|
function Meter({
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export function AgentDrawer() {
|
|||||||
) : null}
|
) : null}
|
||||||
<div className="flex min-h-0 flex-1 flex-col px-4 py-3">
|
<div className="flex min-h-0 flex-1 flex-col px-4 py-3">
|
||||||
{runId ? (
|
{runId ? (
|
||||||
<AgentRunPanel runId={runId} compact className="flex-1" onRunCreated={setAgentDrawerRun} />
|
<AgentRunPanel runId={runId} compact className="flex-1" />
|
||||||
) : (
|
) : (
|
||||||
<GoalComposer
|
<GoalComposer
|
||||||
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
|
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
|
||||||
@@ -123,9 +123,9 @@ export function AgentDrawer() {
|
|||||||
prefix={prefix || undefined}
|
prefix={prefix || undefined}
|
||||||
defaultGoal={context?.goal ?? ""}
|
defaultGoal={context?.goal ?? ""}
|
||||||
pending={create.isPending}
|
pending={create.isPending}
|
||||||
onSubmit={(goal, mode) =>
|
onSubmit={(goal, opts) =>
|
||||||
create.mutate(
|
create.mutate(
|
||||||
{ goal: `${prefix}${goal}`, mode },
|
{ goal: `${prefix}${goal}`, ...opts },
|
||||||
{
|
{
|
||||||
onSuccess: (run) => setAgentDrawerRun(run.id),
|
onSuccess: (run) => setAgentDrawerRun(run.id),
|
||||||
onError: (e) => toast.error(errorMessage(e)),
|
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 { useQueryClient } from "@tanstack/react-query";
|
||||||
import { EventType } from "@umt/contracts";
|
import { EventType } from "@umt/contracts";
|
||||||
import { FlaskConical, Play, RefreshCw, Sparkles, WifiOff } from "lucide-react";
|
import { FlaskConical, RefreshCw, Sparkles, WifiOff } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useId, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
type AgentMode,
|
type AgentSessionOptions,
|
||||||
agentKeys,
|
agentKeys,
|
||||||
agentMockEnabled,
|
agentMockEnabled,
|
||||||
|
isProposeFirst,
|
||||||
isRunActive,
|
isRunActive,
|
||||||
useAbortAgentRun,
|
useAbortAgentRun,
|
||||||
useAgentRun,
|
useAgentRun,
|
||||||
useAppendAgentMessage,
|
useAppendAgentMessage,
|
||||||
useCreateAgentRun,
|
|
||||||
useRunApprovals,
|
useRunApprovals,
|
||||||
useRunInvocationAudit,
|
useRunInvocationAudit,
|
||||||
} from "~/api/agent.ts";
|
} 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 { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||||
import { Button } from "~/components/ui/button.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 { errorMessage } from "~/lib/api.ts";
|
||||||
import { useSse, useSseStatus } from "~/lib/sse.ts";
|
import { useSse, useSseStatus } from "~/lib/sse.ts";
|
||||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||||
@@ -29,50 +31,30 @@ import {
|
|||||||
ApprovalItem,
|
ApprovalItem,
|
||||||
BudgetBar,
|
BudgetBar,
|
||||||
FinalReport,
|
FinalReport,
|
||||||
PlanSteps,
|
|
||||||
RunAnnouncer,
|
RunAnnouncer,
|
||||||
RunStatusBadge,
|
RunStatusBadge,
|
||||||
runPlanIds,
|
runPlanIds,
|
||||||
} from "./agent-conversation.tsx";
|
} from "./agent-conversation.tsx";
|
||||||
|
import { RunPlan } from "./agent-interaction.tsx";
|
||||||
import { SubagentTree } from "./agent-subagent-tree.tsx";
|
import { SubagentTree } from "./agent-subagent-tree.tsx";
|
||||||
import { useAgentStream } from "./use-agent-stream.ts";
|
import { useAgentStream } from "./use-agent-stream.ts";
|
||||||
|
|
||||||
const TRIGGER_LABEL = { user: "用户发起", event: "事件触发", schedule: "定时", subagent: "子会话" } as const;
|
const TRIGGER_LABEL = { user: "用户发起", event: "事件触发", schedule: "定时", subagent: "子会话" } as const;
|
||||||
|
|
||||||
export const MODE_HELP: Record<AgentMode, { label: string; help: string }> = {
|
/** "先看方案再执行"开关(会话选项,默认关:自动化优先,按步骤直接执行,需授权的动作在步骤处审批) */
|
||||||
execute: {
|
export function ProposeFirstToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||||
label: "授权内执行",
|
const id = useId();
|
||||||
help: "未授权的动作会被服务端拒绝并在对话里标出。",
|
|
||||||
},
|
|
||||||
plan_only: {
|
|
||||||
label: "只要方案",
|
|
||||||
help: "只调查、出方案,不执行写操作。之后可按方案执行。",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 运行模式分段切换 */
|
|
||||||
export function ModeSwitch({ mode, onChange }: { mode: AgentMode; onChange: (m: AgentMode) => void }) {
|
|
||||||
return (
|
return (
|
||||||
<fieldset aria-label="运行模式" className="flex items-center gap-1">
|
<div className="flex items-center gap-2 px-1">
|
||||||
{(["execute", "plan_only"] as const).map((v) => (
|
<Switch id={id} checked={checked} onCheckedChange={onChange} />
|
||||||
<button
|
<Label htmlFor={id} className="cursor-pointer text-sm font-normal text-muted-foreground">
|
||||||
key={v}
|
先看方案再执行
|
||||||
type="button"
|
</Label>
|
||||||
aria-pressed={mode === v}
|
</div>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 新目标输入:execute / plan_only 切换 + 自增高输入框(Enter 发送,Shift+Enter 换行) */
|
/** 新目标输入:"先看方案再执行"开关 + 自增高输入框(Enter 发送,Shift+Enter 换行) */
|
||||||
export function GoalComposer({
|
export function GoalComposer({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
pending,
|
pending,
|
||||||
@@ -81,7 +63,7 @@ export function GoalComposer({
|
|||||||
compact = false,
|
compact = false,
|
||||||
suggestions,
|
suggestions,
|
||||||
}: {
|
}: {
|
||||||
onSubmit: (goal: string, mode: AgentMode) => void;
|
onSubmit: (goal: string, opts: AgentSessionOptions) => void;
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
/** 上下文前缀(只读展示,提交时拼在目标前) */
|
/** 上下文前缀(只读展示,提交时拼在目标前) */
|
||||||
prefix?: string;
|
prefix?: string;
|
||||||
@@ -91,7 +73,7 @@ export function GoalComposer({
|
|||||||
suggestions?: string[];
|
suggestions?: string[];
|
||||||
}) {
|
}) {
|
||||||
const [goal, setGoal] = useState(defaultGoal);
|
const [goal, setGoal] = useState(defaultGoal);
|
||||||
const [mode, setMode] = useState<AgentMode>("execute");
|
const [proposeFirst, setProposeFirst] = useState(false);
|
||||||
const box = useRef<HTMLDivElement>(null);
|
const box = useRef<HTMLDivElement>(null);
|
||||||
const fill = (text: string) => {
|
const fill = (text: string) => {
|
||||||
setGoal(text);
|
setGoal(text);
|
||||||
@@ -107,7 +89,7 @@ export function GoalComposer({
|
|||||||
<PromptInput
|
<PromptInput
|
||||||
value={goal}
|
value={goal}
|
||||||
onValueChange={setGoal}
|
onValueChange={setGoal}
|
||||||
onSubmit={(g) => onSubmit(g, mode)}
|
onSubmit={(g) => onSubmit(g, { proposeFirst })}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
minRows={compact ? 2 : 3}
|
minRows={compact ? 2 : 3}
|
||||||
maxRows={10}
|
maxRows={10}
|
||||||
@@ -125,10 +107,13 @@ export function GoalComposer({
|
|||||||
</p>
|
</p>
|
||||||
) : null
|
) : 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">
|
<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>
|
</p>
|
||||||
{suggestions?.length ? (
|
{suggestions?.length ? (
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
@@ -155,25 +140,21 @@ export function GoalComposer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 一次运行:头部状态 + 预算条 + (只要方案的步骤清单)+ 流式对话 + 审批 + 最终汇报 + 输入框 */
|
/** 一次运行:头部状态 + 预算条 + 步骤清单 + 流式对话(方案选择 / 审批卡在对应步骤处)+ 最终汇报 + 输入框 */
|
||||||
export function AgentRunPanel({
|
export function AgentRunPanel({
|
||||||
runId,
|
runId,
|
||||||
compact = false,
|
compact = false,
|
||||||
className,
|
className,
|
||||||
onRunCreated,
|
|
||||||
}: {
|
}: {
|
||||||
runId: string;
|
runId: string;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
/** 从本会话派生了新运行(按方案执行)时 */
|
|
||||||
onRunCreated?: (id: string) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const run = useAgentRun(runId);
|
const run = useAgentRun(runId);
|
||||||
const active = isRunActive(run.data?.run.status);
|
const active = isRunActive(run.data?.run.status);
|
||||||
const live = useAgentStream(runId, active || !run.data);
|
const live = useAgentStream(runId, active || !run.data);
|
||||||
const abort = useAbortAgentRun();
|
const abort = useAbortAgentRun();
|
||||||
const append = useAppendAgentMessage();
|
const append = useAppendAgentMessage();
|
||||||
const create = useCreateAgentRun();
|
|
||||||
const [msg, setMsg] = useState("");
|
const [msg, setMsg] = useState("");
|
||||||
const [confirmStop, setConfirmStop] = useState(false);
|
const [confirmStop, setConfirmStop] = useState(false);
|
||||||
const sseStatus = useSseStatus();
|
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 toolCount = live.items.filter((i) => i.kind === "tool" && i.call.status !== "running").length;
|
||||||
const audit = useRunInvocationAudit(runId, toolCount > 0, toolCount);
|
const audit = useRunInvocationAudit(runId, toolCount > 0, toolCount);
|
||||||
const approvals = useRunApprovals(runPlanIds(live.items, run.data?.report ?? null));
|
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 秒轮询
|
// run 状态变化(queued → running → 终态)经 SSE 推送:到达即刷新快照,不等 3 秒轮询
|
||||||
// 子会话:本会话收到子会话提示(agent.tool_call / agent.spawn)或任一子会话 / 父会话状态变化时刷新会话树
|
// 子会话:本会话收到子会话提示(agent.tool_call / agent.spawn)或任一子会话 / 父会话状态变化时刷新会话树
|
||||||
const related = new Set([
|
const related = new Set([
|
||||||
@@ -207,7 +190,7 @@ export function AgentRunPanel({
|
|||||||
const { run: r, report, messages } = run.data;
|
const { run: r, report, messages } = run.data;
|
||||||
const finished = !isRunActive(r.status) || live.finished;
|
const finished = !isRunActive(r.status) || live.finished;
|
||||||
const streaming = active && !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 =
|
const shownStatus =
|
||||||
// 事件流已结束但快照还没刷到终态时按运行中显示;waiting(等待下载/入库核验)照实显示
|
// 事件流已结束但快照还没刷到终态时按运行中显示;waiting(等待下载/入库核验)照实显示
|
||||||
(live.finished && (r.status === "queued" || r.status === "running")) ||
|
(live.finished && (r.status === "queued" || r.status === "running")) ||
|
||||||
@@ -226,21 +209,13 @@ export function AgentRunPanel({
|
|||||||
onError: (err) => toast.error(errorMessage(err)),
|
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 (
|
return (
|
||||||
<div className={cn("flex min-h-0 flex-col gap-3", className)}>
|
<div className={cn("flex min-h-0 flex-col gap-3", className)}>
|
||||||
<RunAnnouncer status={shownStatus} finished={live.finished} />
|
<RunAnnouncer status={shownStatus} finished={live.finished} />
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<RunStatusBadge status={shownStatus} />
|
<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">
|
<span className="min-w-0 text-meta text-muted-foreground">
|
||||||
{TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)}
|
{TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)}
|
||||||
{r.model ? ` · ${r.provider ?? ""}/${r.model}` : ""}
|
{r.model ? ` · ${r.provider ?? ""}/${r.model}` : ""}
|
||||||
@@ -259,7 +234,7 @@ export function AgentRunPanel({
|
|||||||
|
|
||||||
<SubagentTree snapshot={run.data} />
|
<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" ? (
|
{streaming && sseStatus === "reconnecting" ? (
|
||||||
<div
|
<div
|
||||||
@@ -296,9 +271,11 @@ export function AgentRunPanel({
|
|||||||
userMessages={messages.map((m) => ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))}
|
userMessages={messages.map((m) => ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))}
|
||||||
after={
|
after={
|
||||||
<>
|
<>
|
||||||
{approvals.items.map((a) => (
|
{approvals.items
|
||||||
<ApprovalItem key={a.id} approval={a} />
|
.filter((a) => !inline.has(a.id))
|
||||||
))}
|
.map((a) => (
|
||||||
|
<ApprovalItem key={a.id} approval={a} />
|
||||||
|
))}
|
||||||
{finished ? <FinalReport run={r} report={report} live={live} /> : null}
|
{finished ? <FinalReport run={r} report={report} live={live} /> : null}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -319,22 +296,12 @@ export function AgentRunPanel({
|
|||||||
maxLength={8000}
|
maxLength={8000}
|
||||||
aria-label="追加消息"
|
aria-label="追加消息"
|
||||||
placeholder={streaming ? "补充或纠正(运行中也可追加)" : "追加消息"}
|
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">
|
<p className="px-1 text-meta text-muted-foreground">
|
||||||
{mode === "plan_only" && finished
|
{r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批")
|
||||||
? "「按方案执行」会新开一次授权内执行的运行,仍受服务端授权校验。"
|
? `${r.summary}:在上面的卡片里处理,会话随即继续。`
|
||||||
: MODE_HELP[mode].help}{" "}
|
: "追加的消息在本轮结束后读取。"}{" "}
|
||||||
Enter 发送,Shift+Enter 换行{streaming ? ",停止 = 中止本次运行" : ""}。
|
Enter 发送,Shift+Enter 换行{streaming ? ",停止 = 结束本次运行" : ""}。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -101,8 +101,8 @@ export function AgentSessionList({
|
|||||||
) : null}
|
) : null}
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||||
<RunStatusBadge status={r.status} />
|
<RunStatusBadge status={r.status} />
|
||||||
{(r.mode ?? r.scope.mode) === "plan_only" ? (
|
{r.scope.proposeFirst === true ? (
|
||||||
<span className="text-meta text-muted-foreground">只要方案</span>
|
<span className="text-meta text-muted-foreground">先看方案</span>
|
||||||
) : null}
|
) : null}
|
||||||
{r.parentRunId ? <span className="text-meta text-muted-foreground">子会话</span> : null}
|
{r.parentRunId ? <span className="text-meta text-muted-foreground">子会话</span> : null}
|
||||||
<time
|
<time
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map<str
|
|||||||
</Link>
|
</Link>
|
||||||
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
|
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
|
||||||
<RunStatusBadge status={node.status} />
|
<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">
|
<span className="text-meta text-muted-foreground tabular-nums">
|
||||||
{usageLine({
|
{usageLine({
|
||||||
toolCalls: node.toolCallCount,
|
toolCalls: node.toolCallCount,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AgentRunEvent, AgentRunEventBody, AgentUsageTotals } from "@umt/contracts";
|
import type { AgentPlanStep, AgentRunEvent, AgentRunEventBody, AgentUsageTotals } from "@umt/contracts";
|
||||||
import type { AgentMode, AgentRunView, GoalReport, RunMessage, RunSnapshot } from "~/api/agent.ts";
|
import type { AgentRunView, AgentSessionOptions, GoalReport, RunMessage, RunSnapshot } from "~/api/agent.ts";
|
||||||
|
|
||||||
// Agent 会话 mock(W2-D 端点就绪前开发用;?mock=1 打开)。事件按时间逐步"到达",模拟流式输出。
|
// Agent 会话 mock(W2-D 端点就绪前开发用;?mock=1 打开)。事件按时间逐步"到达",模拟流式输出。
|
||||||
|
|
||||||
@@ -33,7 +33,11 @@ function chunks(text: string, size = 6): string[] {
|
|||||||
return out;
|
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[] = [];
|
const s: Step[] = [];
|
||||||
let t = 0;
|
let t = 0;
|
||||||
const push = (dt: number, body: AgentRunEventBody) => {
|
const push = (dt: number, body: AgentRunEventBody) => {
|
||||||
@@ -43,7 +47,22 @@ function script(goal: string, mode: AgentMode): Step[] {
|
|||||||
const say = (text: string) => {
|
const say = (text: string) => {
|
||||||
for (const c of chunks(text)) push(60, { type: "text_delta", delta: c });
|
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(0, { type: "run_started", goal, resumed: false });
|
||||||
|
push(100, { type: "plan_updated", steps: steps.map((x) => ({ ...x })), revision: ++revision });
|
||||||
say("先确认本地库存与版本目标,再去站点找候选。");
|
say("先确认本地库存与版本目标,再去站点找候选。");
|
||||||
push(400, {
|
push(400, {
|
||||||
type: "tool_call_start",
|
type: "tool_call_start",
|
||||||
@@ -60,6 +79,7 @@ function script(goal: string, mode: AgentMode): Step[] {
|
|||||||
outputSummary: "作品 沙丘:第二部 (2024):已有 1080p WEB-DL(已核验);Slot「archival」缺 2160p;无下载中任务",
|
outputSummary: "作品 沙丘:第二部 (2024):已有 1080p WEB-DL(已核验);Slot「archival」缺 2160p;无下载中任务",
|
||||||
recovery: false,
|
recovery: false,
|
||||||
});
|
});
|
||||||
|
plan("s1", "done", "s2");
|
||||||
push(300, {
|
push(300, {
|
||||||
type: "tool_call_start",
|
type: "tool_call_start",
|
||||||
toolCallId: "t2",
|
toolCallId: "t2",
|
||||||
@@ -79,53 +99,102 @@ function script(goal: string, mode: AgentMode): Step[] {
|
|||||||
push(300, { type: "compaction", phase: "start", reason: "threshold" });
|
push(300, { type: "compaction", phase: "start", reason: "threshold" });
|
||||||
push(700, { type: "compaction", phase: "end", reason: "threshold", status: "completed" });
|
push(700, { type: "compaction", phase: "end", reason: "threshold", status: "completed" });
|
||||||
say("①符合 archival 的硬条件(REMUX、HDR),免费截止时间在 6 小时后。");
|
say("①符合 archival 的硬条件(REMUX、HDR),免费截止时间在 6 小时后。");
|
||||||
if (mode === "execute") {
|
plan("s2", "done", "s3");
|
||||||
|
if (opts.proposeFirst) {
|
||||||
push(300, {
|
push(300, {
|
||||||
type: "tool_call_start",
|
type: "options_proposed",
|
||||||
toolCallId: "t3",
|
invocationId: "mock-options",
|
||||||
capabilityId: "download.plan",
|
decisionId: MOCK_DECISION,
|
||||||
inputSummary: '{"sourceReleaseId":"…c3f1","slot":"archival"}',
|
question: "收哪一个 4K 版本?",
|
||||||
recovery: false,
|
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, {
|
push(200, { type: "run_completed", finalText: "", usage: USAGE });
|
||||||
type: "tool_call_end",
|
return s;
|
||||||
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(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 });
|
push(200, { type: "run_completed", finalText: "", usage: USAGE });
|
||||||
return s;
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
createdAt: iso(createdAt),
|
createdAt: iso(createdAt),
|
||||||
@@ -138,7 +207,7 @@ function baseRun(id: string, goal: string, mode: AgentMode, createdAt: number, s
|
|||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
model: "claude-sonnet",
|
model: "claude-sonnet",
|
||||||
thinkingLevel: "medium",
|
thinkingLevel: "medium",
|
||||||
scope: { mode },
|
scope: { proposeFirst: opts.proposeFirst, interactive: true },
|
||||||
budget: { maxToolCalls: 20, maxTokens: 200_000, maxDurationSec: 600, maxCostUsd: 1 },
|
budget: { maxToolCalls: 20, maxTokens: 200_000, maxDurationSec: 600, maxCostUsd: 1 },
|
||||||
usage: {},
|
usage: {},
|
||||||
evidenceVersion: null,
|
evidenceVersion: null,
|
||||||
@@ -151,18 +220,17 @@ function baseRun(id: string, goal: string, mode: AgentMode, createdAt: number, s
|
|||||||
startedAt: iso(createdAt),
|
startedAt: iso(createdAt),
|
||||||
finishedAt: null,
|
finishedAt: null,
|
||||||
createdBy: "admin",
|
createdBy: "admin",
|
||||||
mode,
|
|
||||||
dedupeKey: null,
|
dedupeKey: null,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
spawnReason: null,
|
spawnReason: null,
|
||||||
} satisfies AgentRunView;
|
} 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 id = uuid(n);
|
||||||
const e: Entry = {
|
const e: Entry = {
|
||||||
run: baseRun(id, goal, mode, startedAt, "running"),
|
run: baseRun(id, goal, opts, startedAt, "running"),
|
||||||
steps: script(goal, mode),
|
steps: script(goal, opts),
|
||||||
startedAt,
|
startedAt,
|
||||||
abortedAt: null,
|
abortedAt: null,
|
||||||
appended: [],
|
appended: [],
|
||||||
@@ -175,9 +243,9 @@ function seed() {
|
|||||||
if (seeded) return;
|
if (seeded) return;
|
||||||
seeded = true;
|
seeded = true;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
add("把 沙丘:第二部 的 4K 收进来", "execute", now - 3_600_000, 1);
|
add("把 沙丘:第二部 的 4K 收进来", { proposeFirst: false }, now - 3_600_000, 1);
|
||||||
add("对比一下 奥本海默 现有版本和站点上的 4K,只给方案", "plan_only", now - 7_200_000, 2);
|
add("对比一下 奥本海默 现有版本和站点上的 4K", { proposeFirst: true }, now - 7_200_000, 2);
|
||||||
const failed = add("检查 qb-main 为什么占满磁盘", "execute", now - 86_400_000, 3);
|
const failed = add("检查 qb-main 为什么占满磁盘", { proposeFirst: false }, now - 86_400_000, 3);
|
||||||
failed.steps = [
|
failed.steps = [
|
||||||
{ delayMs: 0, body: { type: "run_started", goal: failed.run.goal, resumed: false } },
|
{ 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);
|
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();
|
seed();
|
||||||
const e = add(goal, mode, Date.now(), store.size + 1);
|
const e = add(goal, opts, Date.now(), store.size + 1);
|
||||||
return view(e);
|
return view(e);
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ import { quickGoalsFor } from "./quick-goals.ts";
|
|||||||
describe("抽屉快捷目标", () => {
|
describe("抽屉快捷目标", () => {
|
||||||
it("按页面给 3 个相关目标", () => {
|
it("按页面给 3 个相关目标", () => {
|
||||||
expect(quickGoalsFor("/console/library/abc")[0]).toBe("检查这部作品缺什么版本");
|
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("看看为什么没进展");
|
expect(quickGoalsFor("/console/tasks/t1")[0]).toBe("看看为什么没进展");
|
||||||
for (const p of ["/console", "/console/library", "/console/files", "/nope"]) {
|
for (const p of ["/console", "/console/library", "/console/files", "/nope"]) {
|
||||||
expect(quickGoalsFor(p)).toHaveLength(3);
|
expect(quickGoalsFor(p)).toHaveLength(3);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
// 抽屉首屏的快捷目标:按当前页面给 3 个最常见的问法,点一下填进输入框(不直接提交)。
|
// 抽屉首屏的快捷目标:按当前页面给 3 个最常见的问法,点一下填进输入框(不直接提交)。
|
||||||
// 以空格结尾的是"半句",填入后光标停在末尾等用户补完(如"从这个站找 ")。
|
// 以空格结尾的是"半句",填入后光标停在末尾等用户补完(如"从这个站点找 ")。用词按 docs/design/glossary.md。
|
||||||
|
|
||||||
type Rule = { match: RegExp; goals: [string, string, string] };
|
type Rule = { match: RegExp; goals: [string, string, string] };
|
||||||
|
|
||||||
const RULES: Rule[] = [
|
const RULES: Rule[] = [
|
||||||
{
|
{
|
||||||
match: /^\/console\/library\/[^/]+/,
|
match: /^\/console\/library\/[^/]+/,
|
||||||
goals: ["检查这部作品缺什么版本", "这部作品有没有更好的片源可以升级", "核对这部作品的文件和元数据是否一致"],
|
goals: ["检查这部作品缺什么版本", "这部作品有没有更好的资源可以升级", "核对这部作品的文件和元数据是否一致"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
match: /^\/console\/library\/?$/,
|
match: /^\/console\/library\/?$/,
|
||||||
@@ -14,7 +14,7 @@ const RULES: Rule[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
match: /^\/console\/sites\/[^/]+/,
|
match: /^\/console\/sites\/[^/]+/,
|
||||||
goals: ["从这个站找 ", "看看这个站最近有哪些新的 4K 资源", "检查这个站的登录和抓取是否正常"],
|
goals: ["从这个站点找 ", "看看这个站点最近有哪些新的 4K 资源", "检查这个站点的登录和抓取是否正常"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
match: /^\/console\/sites\/?$/,
|
match: /^\/console\/sites\/?$/,
|
||||||
@@ -38,7 +38,7 @@ const RULES: Rule[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
match: /^\/console\/inbox/,
|
match: /^\/console\/inbox/,
|
||||||
goals: ["逐条说明待处理事项的原因和建议", "先处理最紧急的一项", "哪些事项可以直接按建议批量处理"],
|
goals: ["逐条说明待处理事项的原因和建议", "先处理最紧急的一项待处理事项", "哪些待处理事项可以直接按建议批量处理"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
match: /^\/console\/identity/,
|
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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { agentMockEnabled, fetchRunEvents } from "~/api/agent.ts";
|
import { agentMockEnabled, fetchRunEvents } from "~/api/agent.ts";
|
||||||
import { sse } from "~/lib/sse.ts";
|
import { sse } from "~/lib/sse.ts";
|
||||||
@@ -23,6 +29,8 @@ export type StreamItem =
|
|||||||
| { kind: "thinking"; key: string; text: string; at: string }
|
| { kind: "thinking"; key: string; text: string; at: string }
|
||||||
| { kind: "tool"; key: string; call: ToolCallView }
|
| { kind: "tool"; key: string; call: ToolCallView }
|
||||||
| { kind: "compaction"; key: string; at: string; reason: string; status: string | null; done: boolean }
|
| { 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";
|
kind: "end";
|
||||||
key: string;
|
key: string;
|
||||||
@@ -33,9 +41,43 @@ export type StreamItem =
|
|||||||
budget: string | null;
|
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 = {
|
export type AgentStreamState = {
|
||||||
events: AgentRunEvent[];
|
events: AgentRunEvent[];
|
||||||
items: StreamItem[];
|
items: StreamItem[];
|
||||||
|
/** 最新的步骤清单(agent.plan);没有调用过为 null */
|
||||||
|
plan: { steps: AgentPlanStep[]; revision: number } | null;
|
||||||
usage: AgentUsageTotals | null;
|
usage: AgentUsageTotals | null;
|
||||||
finished: boolean;
|
finished: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -50,6 +92,8 @@ const SSE_TYPES: string[] = [EventType.agentTextDelta, EventType.agentToolCall,
|
|||||||
export function foldEvents(events: AgentRunEvent[]): StreamItem[] {
|
export function foldEvents(events: AgentRunEvent[]): StreamItem[] {
|
||||||
const items: StreamItem[] = [];
|
const items: StreamItem[] = [];
|
||||||
const tools = new Map<string, ToolCallView>();
|
const tools = new Map<string, ToolCallView>();
|
||||||
|
const asks = new Map<string, OptionsView>();
|
||||||
|
const approvals = new Map<string, ApprovalView>();
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
const last = items[items.length - 1];
|
const last = items[items.length - 1];
|
||||||
switch (e.type) {
|
switch (e.type) {
|
||||||
@@ -146,6 +190,49 @@ export function foldEvents(events: AgentRunEvent[]): StreamItem[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
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":
|
case "run_completed":
|
||||||
items.push({
|
items.push({
|
||||||
kind: "end",
|
kind: "end",
|
||||||
@@ -255,6 +342,15 @@ export function useAgentStream(runId: string | undefined, active: boolean): Agen
|
|||||||
const items = foldEvents(events);
|
const items = foldEvents(events);
|
||||||
const end = [...events].reverse().find((e) => e.type === "run_completed" || e.type === "run_failed");
|
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;
|
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]);
|
}, [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
|
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
|
||||||
// 改造:SVG 状态图标保留几何(虚线圈=待办、弧线=进行中、勾=完成、叉=取消),pathLength 弹簧动画换成
|
// 改造:SVG 状态图标保留几何(虚线圈=待办、弧线=进行中、勾=完成、叉=取消),pathLength 弹簧动画换成
|
||||||
// CSS 过渡/animate-spin(motion-reduce 关闭);完成色 emerald → success,取消 rose → destructive,且都配 sr-only 文字;
|
// 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 { ChevronDown, ListTodo } from "lucide-react";
|
||||||
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
|
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
|
||||||
import { cn } from "~/lib/utils.ts";
|
import { cn } from "~/lib/utils.ts";
|
||||||
import { CHEVRON_CLASS, Disclosure } from "./disclosure.tsx";
|
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 {
|
export interface TodoItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,6 +24,7 @@ const LABEL: Record<TodoItemStatus, string> = {
|
|||||||
completed: "已完成",
|
completed: "已完成",
|
||||||
cancelled: "被拒绝",
|
cancelled: "被拒绝",
|
||||||
error: "失败",
|
error: "失败",
|
||||||
|
skipped: "已跳过",
|
||||||
};
|
};
|
||||||
|
|
||||||
function StatusIcon({ status }: { status: TodoItemStatus }) {
|
function StatusIcon({ status }: { status: TodoItemStatus }) {
|
||||||
@@ -45,7 +47,7 @@ function StatusIcon({ status }: { status: TodoItemStatus }) {
|
|||||||
fillOpacity={status === "completed" ? 0.1 : 0}
|
fillOpacity={status === "completed" ? 0.1 : 0}
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="1.5"
|
strokeWidth="1.5"
|
||||||
strokeDasharray={status === "pending" ? "2 3" : undefined}
|
strokeDasharray={status === "pending" || status === "skipped" ? "2 3" : undefined}
|
||||||
className={cn(status === "in-progress" && "opacity-20")}
|
className={cn(status === "in-progress" && "opacity-20")}
|
||||||
/>
|
/>
|
||||||
{status === "in-progress" ? (
|
{status === "in-progress" ? (
|
||||||
@@ -72,6 +74,9 @@ function StatusIcon({ status }: { status: TodoItemStatus }) {
|
|||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{status === "skipped" ? (
|
||||||
|
<path d="M8 12h8" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
) : null}
|
||||||
{status === "cancelled" || status === "error" ? (
|
{status === "cancelled" || status === "error" ? (
|
||||||
<path
|
<path
|
||||||
d="M8.5 8.5 15.5 15.5M15.5 8.5 8.5 15.5"
|
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 === "pending" && "text-muted-foreground",
|
||||||
s === "in-progress" && "text-foreground",
|
s === "in-progress" && "text-foreground",
|
||||||
s === "completed" && "text-muted-foreground line-through decoration-foreground/30",
|
s === "completed" && "text-muted-foreground line-through decoration-foreground/30",
|
||||||
|
s === "skipped" && "text-muted-foreground",
|
||||||
(s === "cancelled" || s === "error") && "text-destructive",
|
(s === "cancelled" || s === "error") && "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -150,6 +150,27 @@ const ADVANCED_CARD: CardSpec = {
|
|||||||
kind: "number",
|
kind: "number",
|
||||||
hint: "超时后按「仍在等待」汇报结束",
|
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",
|
name: "agent.sessionDir",
|
||||||
label: "会话目录",
|
label: "会话目录",
|
||||||
@@ -168,6 +189,9 @@ const ADVANCED_CARD: CardSpec = {
|
|||||||
fromSettings: (s) => ({
|
fromSettings: (s) => ({
|
||||||
"agent.goalPollSec": n(s.agent.goalPollSec),
|
"agent.goalPollSec": n(s.agent.goalPollSec),
|
||||||
"agent.goalWaitHours": n(s.agent.goalWaitHours),
|
"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.sessionDir": s.agent.sessionDir ?? "",
|
||||||
"agent.fakeScript": s.agent.fakeScript ?? "",
|
"agent.fakeScript": s.agent.fakeScript ?? "",
|
||||||
}),
|
}),
|
||||||
@@ -175,6 +199,9 @@ const ADVANCED_CARD: CardSpec = {
|
|||||||
agent: {
|
agent: {
|
||||||
goalPollSec: num(v["agent.goalPollSec"]),
|
goalPollSec: num(v["agent.goalPollSec"]),
|
||||||
goalWaitHours: num(v["agent.goalWaitHours"]),
|
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,
|
sessionDir: str(v["agent.sessionDir"]) || null,
|
||||||
fakeScript: str(v["agent.fakeScript"]) || null,
|
fakeScript: str(v["agent.fakeScript"]) || null,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { AgentRunStatus } from "@umt/contracts";
|
import type { AgentRunStatus } from "@umt/contracts";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { useState } from "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 { agentMockEnabled, useAgentRun } from "~/api/agent.ts";
|
||||||
import { AgentRunPanel } from "~/components/agent/agent-run-panel.tsx";
|
import { AgentRunPanel } from "~/components/agent/agent-run-panel.tsx";
|
||||||
import { AgentSessionList } from "~/components/agent/agent-session-list.tsx";
|
import { AgentSessionList } from "~/components/agent/agent-session-list.tsx";
|
||||||
@@ -12,7 +12,6 @@ export default function AgentRunPage() {
|
|||||||
const { runId = "" } = useParams();
|
const { runId = "" } = useParams();
|
||||||
const run = useAgentRun(runId);
|
const run = useAgentRun(runId);
|
||||||
const mock = agentMockEnabled();
|
const mock = agentMockEnabled();
|
||||||
const navigate = useNavigate();
|
|
||||||
const [status, setStatus] = useState<"" | AgentRunStatus>("");
|
const [status, setStatus] = useState<"" | AgentRunStatus>("");
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -33,12 +32,7 @@ export default function AgentRunPage() {
|
|||||||
<AgentSessionList activeId={runId} status={status} onStatusChange={setStatus} className="h-full" />
|
<AgentSessionList activeId={runId} status={status} onStatusChange={setStatus} className="h-full" />
|
||||||
</aside>
|
</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)]">
|
<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
|
<AgentRunPanel key={runId} runId={runId} className="flex-1" />
|
||||||
key={runId}
|
|
||||||
runId={runId}
|
|
||||||
className="flex-1"
|
|
||||||
onRunCreated={(id) => navigate(`/console/agent/${id}${mock ? "?mock=1" : ""}`)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ export default function AgentPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Agent 会话" description="交代目标,Agent 在授权范围内执行并汇报。只要方案时只调查,不执行。" />
|
<PageHeader
|
||||||
|
title="Agent 会话"
|
||||||
|
description="交代目标,Agent 列出步骤后在授权范围内执行并汇报;需要审批的动作停在对应步骤等你批准。"
|
||||||
|
/>
|
||||||
{mock ? (
|
{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">
|
<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" />
|
<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>
|
<h2 className="mb-3 text-body font-medium text-foreground">新会话</h2>
|
||||||
<GoalComposer
|
<GoalComposer
|
||||||
pending={create.isPending}
|
pending={create.isPending}
|
||||||
onSubmit={(goal, mode) =>
|
onSubmit={(goal, opts) =>
|
||||||
create.mutate(
|
create.mutate(
|
||||||
{ goal, mode },
|
{ goal, ...opts },
|
||||||
{
|
{
|
||||||
onSuccess: (run) => navigate(`/console/agent/${run.id}${mock ? "?mock=1" : ""}`),
|
onSuccess: (run) => navigate(`/console/agent/${run.id}${mock ? "?mock=1" : ""}`),
|
||||||
onError: (e) => toast.error(errorMessage(e)),
|
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">
|
<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" />
|
<ShieldCheck aria-hidden className="mt-0.5 size-4 shrink-0" />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p>每次工具调用都经服务端授权校验,不在白名单或没有授权的调用会被拒绝并在对话里标出。</p>
|
<p>
|
||||||
|
每次工具调用都经服务端授权校验。控制台发起的会话里,没有持续授权的下载、整理、策略发布会在对应步骤出审批卡。
|
||||||
|
</p>
|
||||||
<p>汇报分两栏:模型自述仅供参考,"系统核实的结果"取自下载计划、下载任务和整理记录。</p>
|
<p>汇报分两栏:模型自述仅供参考,"系统核实的结果"取自下载计划、下载任务和整理记录。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user