feat(web): W4-F rebuild agent conversation and drawer with agentui components
- Port agentui (MIT, ashish200729/agentui@b2b752b) Message/MessageBubble/MessageScroller, StreamingResponse, ToolResult/ToolApproval, TodoList, ApprovalCard, CodeBlock/FileDiff, Citations, PromptInput into components/agentui; drop motion/shiki/Next, switch colors, type scale, radii, durations and easing to semantic tokens, honour reduced motion - Conversation: streaming cursor, collapsible thinking, tool cards with kind/scope/risk/ idempotency badges and audit idempotency key, prominent server-rejection state for both whitelist rejections and unauthorized errors, compaction markers, budget meters with over-limit state, approval cards (admin session only), two-column final report (model said vs system verified with task/record links), follow-scroll with jump-to-bottom - Composer: Enter send / Shift+Enter newline, stop = abort (confirmed), append, execute/plan_only switch, "execute this plan" from finished plan_only runs - Session list sidebar with status filter and usage; reconnect/error banners with retry - Drawer: route-derived context (entity/site/task), minimize to a live mini dock - Fix stream refetch race when the run switches from loading to finished Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
29cd10b27e
commit
8352e5e881
26 files changed
+3390
-585
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -1,94 +1,216 @@
|
||||
import { Bot, ExternalLink, Plus } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { Bot, ExternalLink, Maximize2, Minus, Plus, X } from "lucide-react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useCreateAgentRun } from "~/api/agent.ts";
|
||||
import { isRunActive, useAgentRun, useCreateAgentRun } from "~/api/agent.ts";
|
||||
import { StreamingCursor } from "~/components/agentui/streaming-response.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.tsx";
|
||||
import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx";
|
||||
import {
|
||||
type AgentContext,
|
||||
contextFromPath,
|
||||
contextPrefix,
|
||||
minimizeAgentDrawer,
|
||||
openAgentDrawer,
|
||||
restoreAgentDrawer,
|
||||
setAgentDrawerOpen,
|
||||
setAgentDrawerRun,
|
||||
splitContextGoal,
|
||||
useAgentDrawer,
|
||||
} from "./agent-store.ts";
|
||||
import { useAgentStream } from "./use-agent-stream.ts";
|
||||
|
||||
const CONTEXT_KEYS: [keyof AgentContext, string][] = [
|
||||
["entityId", "作品"],
|
||||
["releaseId", "资源"],
|
||||
["downloadTaskId", "下载任务"],
|
||||
["recordId", "整理记录"],
|
||||
["siteId", "站点"],
|
||||
["taskId", "任务"],
|
||||
];
|
||||
|
||||
function ContextChips({ context }: { context: AgentContext }) {
|
||||
const chips = CONTEXT_KEYS.filter(([k]) => context[k]);
|
||||
if (!chips.length) return null;
|
||||
return (
|
||||
<ul aria-label="当前页面上下文" className="mt-1.5 flex flex-wrap gap-1">
|
||||
{chips.map(([k, label]) => (
|
||||
<li
|
||||
key={k}
|
||||
className="max-w-full truncate rounded-full border border-border/70 px-2 py-0.5 font-mono text-label text-muted-foreground"
|
||||
title={String(context[k])}
|
||||
>
|
||||
{label} · {String(context[k]).slice(0, 12)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent 侧栏抽屉:任意页面可打开,带当前页面上下文(entityId 等)作为 goal 前缀。
|
||||
* Agent 侧栏抽屉:任意页面可打开,带当前页面上下文(entityId / siteId / taskId 等)作为 goal 前缀;
|
||||
* 运行中可最小化到右下角小窗,流式输出继续。
|
||||
* 挂载:在 console layout 里渲染一次 <AgentDrawer />;顶栏放 <AgentDrawerTrigger />。
|
||||
* 页面内打开:import { openAgentDrawer } from "~/components/agent/agent-store.ts"。
|
||||
*/
|
||||
export function AgentDrawer() {
|
||||
const { open, context, runId } = useAgentDrawer();
|
||||
const { open, minimized, context, runId } = useAgentDrawer();
|
||||
const create = useCreateAgentRun();
|
||||
const prefix = contextPrefix(context);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setAgentDrawerOpen}>
|
||||
<SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-lg">
|
||||
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-14">
|
||||
<SheetTitle className="flex items-center gap-2 text-lead">
|
||||
<Bot className="h-4 w-4" />
|
||||
Agent
|
||||
</SheetTitle>
|
||||
<SheetDescription className="break-words">
|
||||
{context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内边执行边汇报。"}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={setAgentDrawerOpen}>
|
||||
<SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-xl">
|
||||
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-24">
|
||||
<SheetTitle className="flex items-center gap-2 text-lead">
|
||||
<Bot className="h-4 w-4" />
|
||||
Agent
|
||||
</SheetTitle>
|
||||
<SheetDescription className="break-words">
|
||||
{context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内边执行边汇报。"}
|
||||
</SheetDescription>
|
||||
{context ? <ContextChips context={context} /> : null}
|
||||
</SheetHeader>
|
||||
{runId ? (
|
||||
<AgentRunPanel runId={runId} compact />
|
||||
) : (
|
||||
<GoalComposer
|
||||
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
|
||||
compact
|
||||
prefix={prefix || undefined}
|
||||
defaultGoal={context?.goal ?? ""}
|
||||
pending={create.isPending}
|
||||
onSubmit={(goal, mode) =>
|
||||
create.mutate(
|
||||
{ goal: `${prefix}${goal}`, mode },
|
||||
{
|
||||
onSuccess: (run) => setAgentDrawerRun(run.id),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--shell-border)] px-5 py-3">
|
||||
{runId ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => openAgentDrawer(context)}>
|
||||
<Plus />
|
||||
新目标
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={`/console/agent/${runId}`} onClick={() => setAgentDrawerOpen(false)}>
|
||||
<ExternalLink />
|
||||
在会话页打开
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="absolute top-4 right-14"
|
||||
aria-label="最小化(运行继续)"
|
||||
title="最小化(运行继续)"
|
||||
onClick={minimizeAgentDrawer}
|
||||
>
|
||||
<Minus />
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="flex min-h-0 flex-1 flex-col px-4 py-3">
|
||||
{runId ? (
|
||||
<AgentRunPanel runId={runId} compact className="flex-1" onRunCreated={setAgentDrawerRun} />
|
||||
) : (
|
||||
<GoalComposer
|
||||
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
|
||||
compact
|
||||
prefix={prefix || undefined}
|
||||
defaultGoal={context?.goal ?? ""}
|
||||
pending={create.isPending}
|
||||
onSubmit={(goal, mode) =>
|
||||
create.mutate(
|
||||
{ goal: `${prefix}${goal}`, mode },
|
||||
{
|
||||
onSuccess: (run) => setAgentDrawerRun(run.id),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--shell-border)] px-5 py-3">
|
||||
{runId ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => openAgentDrawer(context)}>
|
||||
<Plus />
|
||||
新目标
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={`/console/agent/${runId}`} onClick={() => setAgentDrawerOpen(false)}>
|
||||
<ExternalLink />
|
||||
在会话页打开
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/console/agent" onClick={() => setAgentDrawerOpen(false)}>
|
||||
全部会话
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/console/agent" onClick={() => setAgentDrawerOpen(false)}>
|
||||
全部会话
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
{minimized && runId ? <AgentMiniDock runId={runId} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 顶栏按钮:打开抽屉(不带上下文;页面内按钮用 openAgentDrawer 传上下文) */
|
||||
export function AgentDrawerTrigger() {
|
||||
/** 最小化后的小窗:保持事件流订阅,显示状态与最新一句输出 */
|
||||
function AgentMiniDock({ runId }: { runId: string }) {
|
||||
const run = useAgentRun(runId);
|
||||
const active = isRunActive(run.data?.run.status);
|
||||
const live = useAgentStream(runId, active || !run.data);
|
||||
const last = live.items[live.items.length - 1];
|
||||
const streaming = active && !live.finished;
|
||||
const line =
|
||||
last?.kind === "text"
|
||||
? last.text.trim().split("\n").pop()
|
||||
: last?.kind === "tool"
|
||||
? `${isDenied(last.call) ? "被拒绝" : last.call.status === "running" ? "调用" : "完成"} · ${capabilityMeta(last.call.capabilityId)?.title ?? last.call.capabilityId}`
|
||||
: last?.kind === "thinking"
|
||||
? "思考中…"
|
||||
: last?.kind === "end"
|
||||
? last.ok
|
||||
? "已完成,展开查看核实结果"
|
||||
: last.message
|
||||
: "等待输出…";
|
||||
const status = run.data
|
||||
? live.finished && isRunActive(run.data.run.status)
|
||||
? "running"
|
||||
: run.data.run.status
|
||||
: null;
|
||||
return (
|
||||
<Button variant="ghost" size="icon-sm" aria-label="打开 Agent" title="Agent" onClick={() => openAgentDrawer(null)}>
|
||||
<section
|
||||
aria-label="Agent 小窗"
|
||||
className="pg-float fixed right-4 bottom-4 z-40 w-80 max-w-[calc(100vw-2rem)] rounded-2xl p-3 animate-in fade-in-0 slide-in-from-bottom-2 duration-base ease-pop motion-reduce:animate-none"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot aria-hidden className="size-4 shrink-0 text-muted-foreground" />
|
||||
{status ? <RunStatusBadge status={status} /> : null}
|
||||
<span className="min-w-0 flex-1 truncate text-meta text-muted-foreground">
|
||||
{run.data ? splitContextGoal(run.data.run.goal).text : ""}
|
||||
</span>
|
||||
<Button variant="ghost" size="icon-xs" aria-label="展开 Agent" title="展开" onClick={restoreAgentDrawer}>
|
||||
<Maximize2 />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="关闭小窗(运行在后台继续)"
|
||||
title="关闭小窗(运行在后台继续)"
|
||||
onClick={() => setAgentDrawerOpen(false)}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={restoreAgentDrawer}
|
||||
className="mt-2 block w-full cursor-pointer rounded-lg px-1 text-left text-caption leading-5 text-foreground/90 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span className="line-clamp-2 break-words">
|
||||
{line}
|
||||
{streaming && last?.kind === "text" ? <StreamingCursor /> : null}
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 顶栏按钮:有最小化的运行时恢复它;否则按当前路由带上下文打开(页面内按钮用 openAgentDrawer 传更完整的上下文) */
|
||||
export function AgentDrawerTrigger() {
|
||||
const { minimized, runId } = useAgentDrawer();
|
||||
const { pathname } = useLocation();
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="打开 Agent"
|
||||
title="Agent"
|
||||
onClick={() => (minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname)))}
|
||||
>
|
||||
<Bot />
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { authDenial, isDenied, pairAudit, runPlanIds } from "./agent-conversation.tsx";
|
||||
import { contextFromPath, contextPrefix, splitContextGoal } from "./agent-store.ts";
|
||||
import type { StreamItem, ToolCallView } from "./use-agent-stream.ts";
|
||||
|
||||
const call = (p: Partial<ToolCallView>): ToolCallView => ({
|
||||
toolCallId: "t1",
|
||||
capabilityId: "task.retry",
|
||||
inputSummary: null,
|
||||
outputSummary: null,
|
||||
status: "succeeded",
|
||||
reason: null,
|
||||
startedAt: "2026-09-27T00:00:00.000Z",
|
||||
endedAt: null,
|
||||
recovery: false,
|
||||
...p,
|
||||
});
|
||||
|
||||
describe("拒绝判定", () => {
|
||||
it("授权校验失败(tool_call_end + unauthorized)按拒绝展示", () => {
|
||||
const c = call({
|
||||
status: "failed",
|
||||
outputSummary: '{"error":{"code":"unauthorized","message":"缺少 task.retry 的持续授权"}}',
|
||||
});
|
||||
expect(authDenial(c)).toBe("缺少 task.retry 的持续授权");
|
||||
expect(isDenied(c)).toBe(true);
|
||||
});
|
||||
it("截断的摘要也能认出授权错误;普通失败不算拒绝", () => {
|
||||
expect(authDenial(call({ status: "failed", outputSummary: '{"error":{"code":"forbidden","message":"x"}…' }))).toBe(
|
||||
"x",
|
||||
);
|
||||
expect(isDenied(call({ status: "failed", outputSummary: '{"error":{"code":"internal"}}' }))).toBe(false);
|
||||
expect(isDenied(call({ status: "denied", reason: "不在白名单" }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("计划与审计", () => {
|
||||
const plan = "32320000-0000-4000-8000-000700000002";
|
||||
it("从提交输入与下载计划输出里收集计划 ID", () => {
|
||||
const items: StreamItem[] = [
|
||||
{ kind: "tool", key: "a", call: call({ capabilityId: "download.submit", inputSummary: `{"planId":"${plan}"}` }) },
|
||||
{
|
||||
kind: "tool",
|
||||
key: "b",
|
||||
call: call({
|
||||
toolCallId: "t2",
|
||||
capabilityId: "download.plan",
|
||||
outputSummary: '{"plan":{"id":"33000000-0000-4000-8000-000000000701","status":"draft"…',
|
||||
}),
|
||||
},
|
||||
];
|
||||
expect(runPlanIds(items, null).sort()).toEqual([plan, "33000000-0000-4000-8000-000000000701"]);
|
||||
});
|
||||
it("同能力的审计按先后与工具调用配对", () => {
|
||||
const calls = [
|
||||
call({ toolCallId: "a", capabilityId: "download.submit", startedAt: "2026-09-27T00:00:01.000Z" }),
|
||||
call({ toolCallId: "b", capabilityId: "download.submit", startedAt: "2026-09-27T00:00:02.000Z" }),
|
||||
];
|
||||
const audit = ["k1", "k2"].map((k, i) => ({
|
||||
capabilityId: "download.submit",
|
||||
at: `2026-09-27T00:00:0${i + 1}.500Z`,
|
||||
outcome: "succeeded",
|
||||
idempotencyKey: k,
|
||||
durationMs: 1,
|
||||
grantId: null,
|
||||
approvalId: null,
|
||||
}));
|
||||
const m = pairAudit(calls, audit);
|
||||
expect(m.get("a")?.idempotencyKey).toBe("k1");
|
||||
expect(m.get("b")?.idempotencyKey).toBe("k2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("侧栏上下文", () => {
|
||||
it("按路由推断上下文并生成前缀,列表里能拆回正文", () => {
|
||||
const ctx = contextFromPath("/console/engine/5b4e76e0-f27f-4e7a-8e08-71486387df1d");
|
||||
expect(ctx?.taskId).toBe("5b4e76e0-f27f-4e7a-8e08-71486387df1d");
|
||||
expect(contextFromPath("/console/sites/hdhome")?.siteId).toBe("hdhome");
|
||||
expect(contextFromPath("/console/agent")).toBeNull();
|
||||
const goal = `${contextPrefix(ctx)}排查这个任务`;
|
||||
expect(splitContextGoal(goal)).toEqual({ context: expect.stringContaining("taskId="), text: "排查这个任务" });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { EventType } from "@umt/contracts";
|
||||
import { Send, Square } from "lucide-react";
|
||||
import { FlaskConical, Play, RefreshCw, WifiOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -11,21 +11,67 @@ import {
|
||||
useAbortAgentRun,
|
||||
useAgentRun,
|
||||
useAppendAgentMessage,
|
||||
useCreateAgentRun,
|
||||
useRunApprovals,
|
||||
useRunInvocationAudit,
|
||||
} from "~/api/agent.ts";
|
||||
import { MessageScroller } from "~/components/agentui/message-scroller.tsx";
|
||||
import { PromptInput } from "~/components/agentui/prompt-input.tsx";
|
||||
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 { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
import { useSse, useSseStatus } from "~/lib/sse.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
import { AgentConversation, BudgetBar, FinalReport, RunStatusBadge } from "./agent-conversation.tsx";
|
||||
import {
|
||||
AgentConversation,
|
||||
ApprovalItem,
|
||||
BudgetBar,
|
||||
FinalReport,
|
||||
PlanSteps,
|
||||
RunAnnouncer,
|
||||
RunStatusBadge,
|
||||
runPlanIds,
|
||||
} from "./agent-conversation.tsx";
|
||||
import { useAgentStream } from "./use-agent-stream.ts";
|
||||
|
||||
const TRIGGER_LABEL = { user: "用户发起", event: "事件触发", schedule: "定时", subagent: "子代理" } as const;
|
||||
|
||||
/** 目标输入:execute / plan_only 分段切换 + 文本框 */
|
||||
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 }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/** 新目标输入:execute / plan_only 切换 + 自增高输入框(Enter 发送,Shift+Enter 换行) */
|
||||
export function GoalComposer({
|
||||
onSubmit,
|
||||
pending,
|
||||
@@ -35,96 +81,71 @@ export function GoalComposer({
|
||||
}: {
|
||||
onSubmit: (goal: string, mode: AgentMode) => void;
|
||||
pending: boolean;
|
||||
/** 上下文前缀(只读展示) */
|
||||
/** 上下文前缀(只读展示,提交时拼在目标前) */
|
||||
prefix?: string;
|
||||
defaultGoal?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [goal, setGoal] = useState(defaultGoal);
|
||||
const [mode, setMode] = useState<AgentMode>("execute");
|
||||
const submit = () => {
|
||||
const g = goal.trim();
|
||||
if (!g) {
|
||||
toast.error("先写下目标");
|
||||
return;
|
||||
}
|
||||
onSubmit(g, mode);
|
||||
};
|
||||
return (
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
{prefix ? (
|
||||
<p
|
||||
className="truncate rounded-lg border border-border/70 px-2.5 py-1 font-mono text-meta text-muted-foreground"
|
||||
title={prefix}
|
||||
>
|
||||
{prefix}
|
||||
</p>
|
||||
) : null}
|
||||
<Textarea
|
||||
<div className="space-y-1.5">
|
||||
<PromptInput
|
||||
value={goal}
|
||||
onChange={(e) => setGoal(e.target.value)}
|
||||
placeholder="例如:把 沙丘:第二部 的 4K 收进来"
|
||||
rows={compact ? 3 : 3}
|
||||
onValueChange={setGoal}
|
||||
onSubmit={(g) => onSubmit(g, mode)}
|
||||
pending={pending}
|
||||
minRows={compact ? 2 : 3}
|
||||
maxRows={10}
|
||||
maxLength={4000}
|
||||
aria-label="目标"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<fieldset aria-label="运行模式" className="flex items-center gap-1">
|
||||
{(
|
||||
[
|
||||
["execute", "授权内执行"],
|
||||
["plan_only", "只要方案"],
|
||||
] as const
|
||||
).map(([v, label]) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
aria-pressed={mode === v}
|
||||
onClick={() => setMode(v)}
|
||||
className={cn(
|
||||
"h-8 cursor-pointer rounded-full border border-transparent px-3 text-sm text-muted-foreground transition-colors duration-micro hover:text-foreground",
|
||||
mode === v && "pill-selected text-foreground",
|
||||
)}
|
||||
placeholder="例如:把 沙丘:第二部 的 4K 收进来"
|
||||
submitLabel={pending ? "提交中" : "开始"}
|
||||
header={
|
||||
prefix ? (
|
||||
<p
|
||||
className="mx-1 mb-1 truncate rounded-lg border border-border/70 px-2.5 py-1 font-mono text-meta text-muted-foreground"
|
||||
title={prefix}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
<Button type="submit" size="sm" disabled={pending}>
|
||||
<Send />
|
||||
{pending ? "提交中…" : "开始"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{mode === "plan_only"
|
||||
? "只调查、出方案,不执行任何写操作。"
|
||||
: "在已授权范围内执行;未授权的动作会被服务端拒绝并在对话里标出。"}
|
||||
{prefix}
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
toolbar={<ModeSwitch mode={mode} onChange={setMode} />}
|
||||
/>
|
||||
<p className="px-1 text-meta text-muted-foreground" aria-live="polite">
|
||||
{MODE_HELP[mode].help} Enter 发送,Shift+Enter 换行。
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一次运行:头部状态 + 预算条 + 流式对话 + 最终汇报 + 追加消息 */
|
||||
export function AgentRunPanel({ runId, compact = false }: { runId: string; compact?: boolean }) {
|
||||
/** 一次运行:头部状态 + 预算条 + (只要方案的步骤清单)+ 流式对话 + 审批 + 最终汇报 + 输入框 */
|
||||
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();
|
||||
const qc = useQueryClient();
|
||||
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));
|
||||
// run 状态变化(queued → running → 终态)经 SSE 推送:到达即刷新快照,不等 3 秒轮询
|
||||
useSse(
|
||||
[EventType.agentRunStatus],
|
||||
@@ -139,41 +160,50 @@ export function AgentRunPanel({ runId, compact = false }: { runId: string; compa
|
||||
if (run.error || !run.data) return <ErrorState plain error={run.error} onRetry={() => void run.refetch()} />;
|
||||
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 shownStatus =
|
||||
// 事件流已结束但快照还没刷到终态时按运行中显示;waiting(等待下载/入库核验)照实显示
|
||||
(live.finished && (r.status === "queued" || r.status === "running")) ||
|
||||
(r.status === "queued" && live.events.length > 0)
|
||||
? "running"
|
||||
: r.status;
|
||||
|
||||
const send = (text: string) =>
|
||||
append.mutate(
|
||||
{ id: r.id, text },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setMsg("");
|
||||
void run.refetch();
|
||||
},
|
||||
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="space-y-4">
|
||||
<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={
|
||||
(live.finished && isRunActive(r.status)) || (r.status === "queued" && live.events.length > 0)
|
||||
? "running"
|
||||
: r.status
|
||||
}
|
||||
/>
|
||||
<span className="text-meta text-muted-foreground">
|
||||
<RunStatusBadge status={shownStatus} />
|
||||
<StatusBadge tone={mode === "plan_only" ? "warning" : "neutral"}>{MODE_HELP[mode].label}</StatusBadge>
|
||||
<span className="min-w-0 text-meta text-muted-foreground">
|
||||
{TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)}
|
||||
{r.model ? ` · ${r.provider ?? ""}/${r.model}` : ""}
|
||||
</span>
|
||||
{agentMockEnabled() ? <StatusBadge tone="warning">mock 数据</StatusBadge> : null}
|
||||
{active ? (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="destructive" size="sm" className="ml-auto" disabled={abort.isPending}>
|
||||
<Square />
|
||||
中止
|
||||
</Button>
|
||||
}
|
||||
title="中止这次运行?"
|
||||
description="停止后续步骤;已经完成的外部动作(如已提交的下载)不会被撤销。"
|
||||
confirmLabel="中止"
|
||||
destructive
|
||||
onConfirm={() =>
|
||||
abort.mutateAsync(r.id).then(
|
||||
() => toast.success("已请求中止"),
|
||||
(e) => toast.error(errorMessage(e)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{agentMockEnabled() ? (
|
||||
<StatusBadge tone="warning">
|
||||
<FlaskConical className="size-3" />
|
||||
mock 数据
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -181,52 +211,104 @@ export function AgentRunPanel({ runId, compact = false }: { runId: string; compa
|
||||
<BudgetBar run={r} live={live} />
|
||||
</div>
|
||||
|
||||
<AgentConversation
|
||||
run={r}
|
||||
live={live}
|
||||
userMessages={messages.map((m) => ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))}
|
||||
compact={compact}
|
||||
/>
|
||||
{mode === "plan_only" ? <PlanSteps live={live} active={streaming} /> : null}
|
||||
|
||||
{finished ? <FinalReport run={r} report={report} live={live} /> : null}
|
||||
|
||||
<form
|
||||
className="flex items-end gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const text = msg.trim();
|
||||
if (!text) return;
|
||||
append.mutate(
|
||||
{ id: r.id, text },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setMsg("");
|
||||
void run.refetch();
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err)),
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
value={msg}
|
||||
onChange={(e) => setMsg(e.target.value)}
|
||||
rows={1}
|
||||
maxLength={8000}
|
||||
placeholder={active ? "补充或纠正(运行中也可追加)" : "追加消息,继续这次会话"}
|
||||
aria-label="追加消息"
|
||||
className="min-h-9 flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
disabled={append.isPending || !msg.trim()}
|
||||
aria-label="发送"
|
||||
{streaming && sseStatus === "reconnecting" ? (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-center gap-2 rounded-xl border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning"
|
||||
>
|
||||
<Send />
|
||||
</Button>
|
||||
</form>
|
||||
<WifiOff aria-hidden className="size-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1">
|
||||
实时连接中断,正在重连;恢复后按 after= 补读漏掉的事件,期间每 2.5 秒轮询兜底。
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{live.error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-wrap items-center gap-2 rounded-xl border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive"
|
||||
>
|
||||
<span className="min-w-0 flex-1 break-words">事件读取失败:{live.error}</span>
|
||||
<Button size="xs" variant="outline" onClick={live.retry}>
|
||||
<RefreshCw />
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<MessageScroller
|
||||
className={cn("min-h-64 flex-1", compact && "min-h-48")}
|
||||
viewportClassName="pr-1"
|
||||
busy={streaming}
|
||||
label="Agent 会话记录"
|
||||
>
|
||||
<AgentConversation
|
||||
run={r}
|
||||
live={live}
|
||||
audit={audit.data}
|
||||
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} />
|
||||
))}
|
||||
{finished ? <FinalReport run={r} report={report} live={live} /> : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</MessageScroller>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<PromptInput
|
||||
value={msg}
|
||||
onValueChange={setMsg}
|
||||
onSubmit={send}
|
||||
pending={append.isPending}
|
||||
loading={streaming}
|
||||
onStop={() => setConfirmStop(true)}
|
||||
stopLabel={abort.isPending ? "停止中…" : "停止"}
|
||||
minRows={1}
|
||||
maxRows={6}
|
||||
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 ? ",停止 = 中止本次运行" : ""}。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmStop}
|
||||
onOpenChange={setConfirmStop}
|
||||
title="停止这次运行?"
|
||||
description="停止后续步骤;已经完成的外部动作(如已提交的下载)不会被撤销。"
|
||||
confirmLabel="停止"
|
||||
destructive
|
||||
onConfirm={() =>
|
||||
abort.mutateAsync(r.id).then(
|
||||
() => toast.success("已请求中止"),
|
||||
(e) => {
|
||||
toast.error(errorMessage(e));
|
||||
throw e;
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { AgentRunStatus } from "@umt/contracts";
|
||||
import { Bot } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { type AgentRunView, agentMockEnabled, RUN_STATUS_LABEL, useAgentRuns } from "~/api/agent.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { RunStatusBadge } from "./agent-conversation.tsx";
|
||||
import { splitContextGoal } from "./agent-store.ts";
|
||||
|
||||
const FILTERS: { value: "" | AgentRunStatus; label: string }[] = [
|
||||
{ value: "", label: "全部" },
|
||||
{ value: "running", label: RUN_STATUS_LABEL.running },
|
||||
{ value: "waiting", label: RUN_STATUS_LABEL.waiting },
|
||||
{ value: "succeeded", label: RUN_STATUS_LABEL.succeeded },
|
||||
{ value: "failed", label: RUN_STATUS_LABEL.failed },
|
||||
{ value: "budget_exceeded", label: RUN_STATUS_LABEL.budget_exceeded },
|
||||
{ value: "cancelled", label: RUN_STATUS_LABEL.cancelled },
|
||||
];
|
||||
|
||||
function usageText(r: AgentRunView) {
|
||||
const parts = [`${r.toolCallCount} 次调用`];
|
||||
if (r.usage.totalTokens !== undefined)
|
||||
parts.push(
|
||||
r.usage.totalTokens >= 1000
|
||||
? `${(r.usage.totalTokens / 1000).toFixed(1)}k token`
|
||||
: `${r.usage.totalTokens} token`,
|
||||
);
|
||||
if (r.usage.costUsd !== undefined) parts.push(`$${r.usage.costUsd.toFixed(3)}`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** 会话列表(侧栏):状态筛选 + 每条的目标/状态/模式/时间/用量 */
|
||||
export function AgentSessionList({
|
||||
activeId,
|
||||
status,
|
||||
onStatusChange,
|
||||
className,
|
||||
}: {
|
||||
activeId?: string;
|
||||
status: "" | AgentRunStatus;
|
||||
onStatusChange: (s: "" | AgentRunStatus) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const runs = useAgentRuns(status || undefined);
|
||||
const mock = agentMockEnabled();
|
||||
const rows = runs.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
return (
|
||||
<nav aria-label="Agent 会话列表" className={cn("flex min-h-0 flex-col", className)}>
|
||||
<fieldset aria-label="按状态筛选" className="scroll-bleed mb-2 flex flex-wrap gap-1">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.value || "all"}
|
||||
type="button"
|
||||
aria-pressed={status === f.value}
|
||||
onClick={() => onStatusChange(f.value)}
|
||||
className={cn(
|
||||
"h-7 cursor-pointer rounded-full border border-transparent px-2.5 text-meta text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
|
||||
status === f.value && "pill-selected text-foreground",
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{runs.isPending ? (
|
||||
<LoadingState rows={4} />
|
||||
) : runs.error ? (
|
||||
<ErrorState plain error={runs.error} onRetry={() => void runs.refetch()} />
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState
|
||||
plain
|
||||
icon={Bot}
|
||||
title={status ? "没有这个状态的会话" : "还没有会话"}
|
||||
description={status ? "换个筛选看看。" : "交代一个目标开始;也可以在任意页面右上角打开 Agent 侧栏。"}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{rows.map((r) => {
|
||||
const current = r.id === activeId;
|
||||
return (
|
||||
<li key={r.id}>
|
||||
<Link
|
||||
to={`/console/agent/${r.id}${mock ? "?mock=1" : ""}`}
|
||||
aria-current={current ? "page" : undefined}
|
||||
className={cn(
|
||||
"block min-w-0 rounded-xl border border-transparent px-3 py-2 outline-none transition-colors duration-micro hover:bg-foreground/[0.04] focus-visible:ring-2 focus-visible:ring-ring",
|
||||
current && "pill-selected",
|
||||
)}
|
||||
>
|
||||
<p className="line-clamp-2 text-caption break-words text-foreground">
|
||||
{splitContextGoal(r.goal).text}
|
||||
</p>
|
||||
{splitContextGoal(r.goal).context ? (
|
||||
<p className="mt-0.5 truncate font-mono text-label text-muted-foreground">
|
||||
上下文 · {splitContextGoal(r.goal).context?.split(" · ")[0]}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<RunStatusBadge status={r.status} />
|
||||
{(r.mode ?? r.scope.mode) === "plan_only" ? (
|
||||
<span className="text-meta text-muted-foreground">只要方案</span>
|
||||
) : null}
|
||||
<time
|
||||
dateTime={r.createdAt}
|
||||
className="ml-auto text-meta whitespace-nowrap text-muted-foreground"
|
||||
>
|
||||
{relTime(r.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-meta text-muted-foreground tabular-nums">{usageText(r)}</p>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{runs.hasNextPage ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2 w-full"
|
||||
disabled={runs.isFetchingNextPage}
|
||||
onClick={() => void runs.fetchNextPage()}
|
||||
>
|
||||
{runs.isFetchingNextPage ? "加载中…" : "加载更多"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -10,28 +10,64 @@ export type AgentContext = {
|
||||
downloadTaskId?: string;
|
||||
recordId?: string;
|
||||
siteId?: string;
|
||||
/** 引擎任务(OpenWorkflow run)等后台任务 */
|
||||
taskId?: string;
|
||||
/** 预填目标 */
|
||||
goal?: string;
|
||||
};
|
||||
|
||||
type State = { open: boolean; context: AgentContext | null; runId: string | null };
|
||||
type State = { open: boolean; minimized: boolean; context: AgentContext | null; runId: string | null };
|
||||
|
||||
let state: State = { open: false, context: null, runId: null };
|
||||
let state: State = { open: false, minimized: false, context: null, runId: null };
|
||||
const listeners = new Set<() => void>();
|
||||
const emit = () => {
|
||||
for (const l of listeners) l();
|
||||
};
|
||||
|
||||
export function openAgentDrawer(context: AgentContext | null = null) {
|
||||
state = { open: true, context, runId: null };
|
||||
state = { open: true, minimized: false, context, runId: null };
|
||||
emit();
|
||||
}
|
||||
|
||||
export function setAgentDrawerOpen(open: boolean) {
|
||||
state = { ...state, open };
|
||||
state = { ...state, open, minimized: false };
|
||||
emit();
|
||||
}
|
||||
|
||||
/** 最小化:抽屉收起,运行继续在右下角的小窗里流式显示 */
|
||||
export function minimizeAgentDrawer() {
|
||||
state = { ...state, open: false, minimized: true };
|
||||
emit();
|
||||
}
|
||||
|
||||
/** 从小窗恢复抽屉 */
|
||||
export function restoreAgentDrawer() {
|
||||
state = { ...state, open: true, minimized: false };
|
||||
emit();
|
||||
}
|
||||
|
||||
/** 按当前路由推断页面上下文(顶栏按钮用;页面内按钮直接传更完整的上下文) */
|
||||
export function contextFromPath(pathname: string): AgentContext | null {
|
||||
const m = pathname.match(/^\/console\/(library|sites|tasks|organize|engine)\/([^/?#]+)/);
|
||||
if (!m) return null;
|
||||
const [, kind, id] = m as unknown as [string, string, string];
|
||||
const short = id.slice(0, 8);
|
||||
switch (kind) {
|
||||
case "library":
|
||||
return { label: `作品 ${short}`, entityId: id };
|
||||
case "sites":
|
||||
return { label: `站点 ${id}`, siteId: id };
|
||||
case "tasks":
|
||||
return { label: `下载任务 ${short}`, downloadTaskId: id };
|
||||
case "organize":
|
||||
return { label: `整理记录 ${short}`, recordId: id };
|
||||
case "engine":
|
||||
return { label: `引擎任务 ${short}`, taskId: id };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setAgentDrawerRun(runId: string | null) {
|
||||
state = { ...state, runId };
|
||||
emit();
|
||||
@@ -58,5 +94,12 @@ export function contextPrefix(ctx: AgentContext | null): string {
|
||||
if (ctx.downloadTaskId) parts.push(`downloadTaskId=${ctx.downloadTaskId}`);
|
||||
if (ctx.recordId) parts.push(`organizationRecordId=${ctx.recordId}`);
|
||||
if (ctx.siteId) parts.push(`siteId=${ctx.siteId}`);
|
||||
if (ctx.taskId) parts.push(`taskId=${ctx.taskId}`);
|
||||
return parts.length ? `[上下文 ${parts.join(" · ")}] ` : "";
|
||||
}
|
||||
|
||||
/** 拆出目标里的上下文前缀(列表、小窗只显示目标正文,前缀另行标注) */
|
||||
export function splitContextGoal(goal: string): { context: string | null; text: string } {
|
||||
const m = /^\[上下文 ([^\]]*)\]\s*/.exec(goal);
|
||||
return m ? { context: m[1] ?? null, text: goal.slice(m[0].length) } : { context: null, text: goal };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type AgentRunEvent, type AgentUsageTotals, EventType } from "@umt/contracts";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { agentMockEnabled, fetchRunEvents } from "~/api/agent.ts";
|
||||
import { sse } from "~/lib/sse.ts";
|
||||
|
||||
@@ -40,6 +40,8 @@ export type AgentStreamState = {
|
||||
finished: boolean;
|
||||
error: string | null;
|
||||
connected: "sse" | "poll" | "mock" | "idle";
|
||||
/** 读取失败后手动重试(从最后收到的 seq 之后补读) */
|
||||
retry: () => void;
|
||||
};
|
||||
|
||||
const SSE_TYPES: string[] = [EventType.agentTextDelta, EventType.agentToolCall, EventType.agentRunStatus];
|
||||
@@ -177,9 +179,8 @@ export function useAgentStream(runId: string | undefined, active: boolean): Agen
|
||||
const [events, setEvents] = useState<AgentRunEvent[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connected, setConnected] = useState<AgentStreamState["connected"]>("idle");
|
||||
const [nonce, setNonce] = useState(0);
|
||||
const lastSeq = useRef(-1);
|
||||
const inflight = useRef(false);
|
||||
const pending = useRef(false);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 换 run 时重置
|
||||
useEffect(() => {
|
||||
@@ -188,10 +189,14 @@ export function useAgentStream(runId: string | undefined, active: boolean): Agen
|
||||
lastSeq.current = -1;
|
||||
}, [runId]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: nonce 变化 = 手动重试,重新建立补读
|
||||
useEffect(() => {
|
||||
if (!runId) return;
|
||||
let stopped = false;
|
||||
const ctrl = new AbortController();
|
||||
// 在途/待补标记按本次 effect 独立:active 变化重建 effect 时,被中止的旧请求不会把新一轮的补读挡在门外
|
||||
const inflight = { current: false };
|
||||
const pending = { current: false };
|
||||
|
||||
const pull = async () => {
|
||||
if (inflight.current) {
|
||||
@@ -243,12 +248,13 @@ export function useAgentStream(runId: string | undefined, active: boolean): Agen
|
||||
unsubscribe?.();
|
||||
setConnected("idle");
|
||||
};
|
||||
}, [runId, active]);
|
||||
}, [runId, active, nonce]);
|
||||
|
||||
const retry = useCallback(() => setNonce((n) => n + 1), []);
|
||||
return useMemo(() => {
|
||||
const items = foldEvents(events);
|
||||
const end = [...events].reverse().find((e) => e.type === "run_completed" || e.type === "run_failed");
|
||||
const usage = end && (end.type === "run_completed" || end.type === "run_failed") ? end.usage : null;
|
||||
return { events, items, usage, finished: !!end, error, connected };
|
||||
}, [events, error, connected]);
|
||||
return { events, items, usage, finished: !!end, error, connected, retry };
|
||||
}, [events, error, connected, retry]);
|
||||
}
|
||||
Reference in new issue
Block a user