feat(web): 智能体对话重做(AGUI-3)
- 会话流:用户消息 → 一行进度 → 结论正文 → 没做的事 → 一行实际结果。进度行把一段里的工具调用、思考和过程中的话 聚成一行(1 次也折叠),按能力 verb/noun 人话计数(「查了站点、2 个下载任务和媒体库 · 1 项没做 · 3 秒」), 展开每步一行;工具名、调用 ID、幂等键、输入输出进技术信息。去掉回合头、「模型说的」重复卡和系统核实大卡。 - agent.plan / agent.propose_options 不作为工具调用出现(旧事件回放同样跳过);被拒调用不出红卡, 回合末尾一行「没做:检测站点,需要你批准 [批准并重试]」(非计划类能力建一次性持续授权后重试)。 - 方案卡:每个方案一个按钮,点一下就选定;推荐项第一位、主按钮;按钮写服务端给的动作要点;风险折叠; 「都不要…」点开才出原因框;选完收成一行。审批卡只留摘要和批准 / 拒绝,计划 ID、hash、版本、到期进技术信息。 - 会话页去掉右侧栏和页头目标,步骤 / 子会话 / 模型 / 预算 / ID 进顶部概况条;输入区只有输入框和发送 / 停止, / 指令 /先给方案、/重跑(「先看方案再执行」开关原本只是新建时的本地状态,不需要迁移)。 - 抽屉去掉「当前 / 历史」Tab:头部「历史」弹层(最近 20 条)+「新对话」;上下文显示成名称标签,可去掉。 - 会话列表:搜索 + 全部 / 等你处理 / 失败;等你处理置顶,子会话收在父会话下,正常结束不加徽章。 - 入口页与空态给 3 个快捷目标。mock 加审查报告 3 个示例的演示数据;smoke 改测历史弹层。 - 组件测试:聚合、隐藏、拒绝一行、方案一键选定、历史置顶与子会话、指令。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
2ab6d452dd
commit
6fcf82afb0
17 files changed
+2381
-1846
No files matched your search
@@ -1,30 +1,31 @@
|
||||
import { ArrowLeft, Bot, ExternalLink, History, Maximize2, Minus, Plus, X } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
import { Bot, ExternalLink, History, Maximize2, Minus, SquarePen, X } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { agentMockEnabled, isRunActive, useAgentRun, useCreateAgentRun } from "~/api/agent.ts";
|
||||
import { useDownloadTask } from "~/api/downloads.ts";
|
||||
import { useLibraryDetail } from "~/api/library.ts";
|
||||
import { useRecord } from "~/api/records.ts";
|
||||
import { useSite } from "~/api/sites.ts";
|
||||
import { StreamingCursor } from "~/components/agentui/streaming-response.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.tsx";
|
||||
import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx";
|
||||
import { AgentSessionList } from "./agent-session-list.tsx";
|
||||
import {
|
||||
type AgentContext,
|
||||
type AgentDrawerTab,
|
||||
agentShortcutLabel,
|
||||
clearAgentContext,
|
||||
contextFromPath,
|
||||
contextPrefix,
|
||||
lastAgentDrawerTab,
|
||||
minimizeAgentDrawer,
|
||||
newAgentConversation,
|
||||
openAgentDrawer,
|
||||
openAgentHistoryRun,
|
||||
restoreAgentDrawer,
|
||||
setAgentDrawerOpen,
|
||||
setAgentDrawerRun,
|
||||
setAgentDrawerTab,
|
||||
shortGoalTitle,
|
||||
showAgentRunInDrawer,
|
||||
useAgentDrawer,
|
||||
@@ -36,108 +37,73 @@ import { useAgentStream } from "./use-agent-stream.ts";
|
||||
/** 悬浮按钮等"抽屉外但属于抽屉"的控件打这个属性:点它不算点外面(否则先被关掉又被重新打开) */
|
||||
export const AGENT_LAUNCHER_ATTR = "data-agent-launcher";
|
||||
|
||||
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>
|
||||
);
|
||||
/**
|
||||
* 上下文的显示名称:页面按钮给的 label(「作品:沙丘2」)去掉类别;按路由推断的只有 ID,按 ID 查名称。
|
||||
* 查不到时退回类别名(「这部作品」),不显示 ID。
|
||||
*/
|
||||
export function useContextName(ctx: AgentContext | null): string | null {
|
||||
const path = !!ctx?.fromPath;
|
||||
const entity = useLibraryDetail(path ? ctx?.entityId : undefined);
|
||||
const site = useSite(path ? ctx?.siteId : undefined);
|
||||
const task = useDownloadTask(path ? ctx?.downloadTaskId : undefined);
|
||||
const record = useRecord(path ? ctx?.recordId : undefined);
|
||||
if (!ctx) return null;
|
||||
if (!path) return ctx.label ? ctx.label.replace(/^(作品|资源|下载|整理记录|站点|任务)[::]\s*/, "") : null;
|
||||
if (ctx.entityId) return entity.data?.entity.title ?? "这部作品";
|
||||
if (ctx.siteId) return site.data?.name ?? ctx.siteId;
|
||||
if (ctx.downloadTaskId) return task.data?.name ?? "这个下载任务";
|
||||
if (ctx.recordId) return record.data?.media?.title ?? "这条整理记录";
|
||||
if (ctx.taskId) return "这个后台任务";
|
||||
return null;
|
||||
}
|
||||
|
||||
const TABS: { value: AgentDrawerTab; label: string }[] = [
|
||||
{ value: "current", label: "当前" },
|
||||
{ value: "history", label: "历史" },
|
||||
];
|
||||
|
||||
function DrawerTabs({ tab }: { tab: AgentDrawerTab }) {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="智能体视图"
|
||||
className="mt-2 inline-flex w-fit gap-1 rounded-full border border-border/70 p-0.5"
|
||||
>
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`agent-tab-${t.value}`}
|
||||
aria-selected={tab === t.value}
|
||||
aria-controls="agent-tabpanel"
|
||||
onClick={() => setAgentDrawerTab(t.value)}
|
||||
className={cn(
|
||||
"h-7 cursor-pointer rounded-full border border-transparent px-3 text-meta text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
|
||||
tab === t.value && "pill-selected text-foreground",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 历史 Tab 里打开的一条会话:返回列表 + 目标 + 在页面打开(次要);正文复用会话组件,进行中可继续输入 */
|
||||
function HistoryRunView({ runId }: { runId: string }) {
|
||||
const run = useAgentRun(runId);
|
||||
/** 历史弹层:最近 20 条,等你处理置顶,子会话收在父会话下;点一条在抽屉里打开 */
|
||||
function HistoryPopover({ activeId }: { activeId: string | null }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const mock = agentMockEnabled();
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="-ml-2 shrink-0" onClick={() => openAgentHistoryRun(null)}>
|
||||
<ArrowLeft />
|
||||
返回列表
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" aria-label="历史会话" title="历史会话">
|
||||
<History />
|
||||
历史
|
||||
</Button>
|
||||
<p className="min-w-0 flex-1 truncate text-caption text-muted-foreground" title={run.data?.run.goal}>
|
||||
{run.data ? shortGoalTitle(run.data.run.goal) : ""}
|
||||
</p>
|
||||
<Button asChild variant="ghost" size="xs" className="shrink-0 text-muted-foreground">
|
||||
<Link to={`/console/agent/${runId}${mock ? "?mock=1" : ""}`} onClick={() => setAgentDrawerOpen(false)}>
|
||||
<ExternalLink />
|
||||
在页面打开
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="flex h-[min(32rem,70dvh)] w-[min(22rem,calc(100vw-2rem))] flex-col p-2">
|
||||
<AgentSessionList
|
||||
remember
|
||||
limit={20}
|
||||
activeId={activeId ?? undefined}
|
||||
onOpen={(id) => {
|
||||
setOpen(false);
|
||||
showAgentRunInDrawer(id);
|
||||
}}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
<Button asChild variant="ghost" size="sm" className="mt-1 shrink-0 text-muted-foreground">
|
||||
<Link to={`/console/agent${mock ? "?mock=1" : ""}`} onClick={() => setAgentDrawerOpen(false)}>
|
||||
全部会话
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<AgentRunOpenContext.Provider value={openAgentHistoryRun}>
|
||||
<AgentRunPanel key={runId} runId={runId} compact className="flex-1" onCreated={showAgentRunInDrawer} />
|
||||
</AgentRunOpenContext.Provider>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent 侧栏抽屉:任意页面可打开,带当前页面上下文(entityId / siteId / taskId 等)作为 goal 前缀;
|
||||
* 顶部 Tab「当前 / 历史」:历史里点一条在抽屉内打开(不跳页),返回列表保留筛选与滚动位置;抽屉记住上次 Tab。
|
||||
* 运行中可最小化到右下角小窗,流式输出继续。
|
||||
* 挂载:在 console layout 里渲染一次 <AgentDrawer />;顶栏放 <AgentDrawerTrigger />。
|
||||
* 页面内打开:import { openAgentDrawer } from "~/components/agent/agent-store.ts"。
|
||||
* 智能体抽屉(AGUI-3):任意页面可打开,带当前页面上下文(作品 / 站点 / 下载任务等)。
|
||||
* 头部只有「历史」弹层和「新对话」;没有 Tab。运行中可最小化到右下角小窗,流式输出继续。
|
||||
* 挂载:在 console layout 里渲染一次 <AgentDrawer />。页面内打开:openAgentDrawer({ entityId, label, ... })。
|
||||
*/
|
||||
export function AgentDrawer() {
|
||||
const { open, minimized, context, runId, tab, historyRunId } = useAgentDrawer();
|
||||
const { open, minimized, context, runId } = useAgentDrawer();
|
||||
const create = useCreateAgentRun();
|
||||
const prefix = contextPrefix(context);
|
||||
const { pathname } = useLocation();
|
||||
const content = useRef<HTMLDivElement>(null);
|
||||
const history = tab === "history";
|
||||
const name = useContextName(context);
|
||||
const mock = agentMockEnabled();
|
||||
// 目标前缀带名称和 ID:名称给人看,ID 给模型用
|
||||
const prefix = contextPrefix(context && name ? { ...context, label: name } : context);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -146,7 +112,7 @@ export function AgentDrawer() {
|
||||
ref={content}
|
||||
side="right"
|
||||
className="w-full gap-0 p-0 sm:max-w-xl"
|
||||
// 打开即可输入:焦点直接给输入框(新目标或运行中的追加消息),没有输入框时退回抽屉本身
|
||||
// 打开即可输入:焦点直接给输入框,没有输入框时退回抽屉本身
|
||||
onOpenAutoFocus={(e) => {
|
||||
const ta = content.current?.querySelector<HTMLTextAreaElement>("textarea:not([disabled])");
|
||||
if (!ta) return;
|
||||
@@ -158,93 +124,66 @@ export function AgentDrawer() {
|
||||
if ((e.target as Element | null)?.closest?.(`[${AGENT_LAUNCHER_ATTR}]`)) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<SheetHeader className="gap-1 border-b border-[var(--shell-border)] px-5 pt-4 pb-3 pr-24">
|
||||
<SheetTitle className="flex items-center gap-2 text-lead">
|
||||
<SheetHeader className="flex-row items-center gap-1 border-b border-[var(--shell-border)] py-2.5 pr-12 pl-5">
|
||||
<SheetTitle className="mr-auto flex items-center gap-2 text-lead">
|
||||
<Bot className="h-4 w-4" />
|
||||
智能体
|
||||
</SheetTitle>
|
||||
<SheetDescription className={cn("break-words", (history || !context?.label) && "sr-only")}>
|
||||
{context?.label ? `当前对象:${context.label}` : `${agentShortcutLabel()} 打开或收起`}
|
||||
</SheetDescription>
|
||||
{context && !history ? <ContextChips context={context} /> : null}
|
||||
<DrawerTabs tab={tab} />
|
||||
</SheetHeader>
|
||||
{runId && !history ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="absolute top-4 right-14"
|
||||
aria-label="最小化(运行继续)"
|
||||
title="最小化(运行继续)"
|
||||
onClick={minimizeAgentDrawer}
|
||||
>
|
||||
<Minus />
|
||||
<SheetDescription className="sr-only">{`${agentShortcutLabel()} 打开或收起`}</SheetDescription>
|
||||
<HistoryPopover activeId={runId} />
|
||||
<Button variant="ghost" size="sm" onClick={newAgentConversation} disabled={!runId}>
|
||||
<SquarePen />
|
||||
新对话
|
||||
</Button>
|
||||
) : null}
|
||||
<div
|
||||
id="agent-tabpanel"
|
||||
role="tabpanel"
|
||||
aria-labelledby={`agent-tab-${tab}`}
|
||||
className="flex min-h-0 flex-1 flex-col px-4 py-3"
|
||||
>
|
||||
{history ? (
|
||||
historyRunId ? (
|
||||
<HistoryRunView runId={historyRunId} />
|
||||
) : (
|
||||
<AgentSessionList
|
||||
remember
|
||||
activeId={runId ?? undefined}
|
||||
onOpen={openAgentHistoryRun}
|
||||
className="flex-1"
|
||||
/>
|
||||
)
|
||||
) : runId ? (
|
||||
<AgentRunOpenContext.Provider value={openAgentHistoryRun}>
|
||||
<AgentRunPanel runId={runId} compact className="flex-1" onCreated={showAgentRunInDrawer} />
|
||||
{runId ? (
|
||||
<>
|
||||
<Button asChild variant="ghost" size="icon-sm" aria-label="在页面打开" title="在页面打开">
|
||||
<Link
|
||||
to={`/console/agent/${runId}${mock ? "?mock=1" : ""}`}
|
||||
onClick={() => setAgentDrawerOpen(false)}
|
||||
>
|
||||
<ExternalLink />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="最小化(运行继续)"
|
||||
title="最小化(运行继续)"
|
||||
onClick={minimizeAgentDrawer}
|
||||
>
|
||||
<Minus />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</SheetHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col px-4 py-3">
|
||||
{runId ? (
|
||||
<AgentRunOpenContext.Provider value={showAgentRunInDrawer}>
|
||||
<AgentRunPanel key={runId} runId={runId} compact className="flex-1" onCreated={showAgentRunInDrawer} />
|
||||
</AgentRunOpenContext.Provider>
|
||||
) : (
|
||||
<GoalComposer
|
||||
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
|
||||
compact
|
||||
suggestions={context?.goal ? undefined : quickGoalsFor(pathname)}
|
||||
prefix={prefix || undefined}
|
||||
defaultGoal={context?.goal ?? ""}
|
||||
pending={create.isPending}
|
||||
onSubmit={(goal, opts) =>
|
||||
create.mutate(
|
||||
{ goal: `${prefix}${goal}`, ...opts },
|
||||
{
|
||||
onSuccess: (run) => setAgentDrawerRun(run.id),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col justify-end">
|
||||
<GoalComposer
|
||||
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
|
||||
suggestions={context?.goal ? undefined : quickGoalsFor(pathname)}
|
||||
context={name}
|
||||
onClearContext={clearAgentContext}
|
||||
defaultGoal={context?.goal ?? ""}
|
||||
pending={create.isPending}
|
||||
onSubmit={(goal, opts) =>
|
||||
create.mutate(
|
||||
{ goal: `${prefix}${goal}`, ...opts },
|
||||
{
|
||||
onSuccess: (run) => showAgentRunInDrawer(run.id),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{history ? null : (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--shell-border)] px-5 py-2.5">
|
||||
{runId ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => openAgentDrawer(context)}>
|
||||
<Plus />
|
||||
新目标
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm" className="text-muted-foreground">
|
||||
<Link to={`/console/agent/${runId}`} onClick={() => setAgentDrawerOpen(false)}>
|
||||
<ExternalLink />
|
||||
在页面打开
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" onClick={() => setAgentDrawerTab("history")}>
|
||||
<History />
|
||||
历史会话
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
{minimized && runId ? <AgentMiniDock runId={runId} /> : null}
|
||||
@@ -268,7 +207,7 @@ function AgentMiniDock({ runId }: { runId: string }) {
|
||||
? "思考中…"
|
||||
: last?.kind === "end"
|
||||
? last.ok
|
||||
? "已完成,展开查看核实结果"
|
||||
? "已完成,展开查看结果"
|
||||
: last.message
|
||||
: "等待输出…";
|
||||
const status = run.data
|
||||
@@ -325,9 +264,7 @@ export function AgentDrawerTrigger({ className }: { className?: string }) {
|
||||
aria-label="打开智能体"
|
||||
title={`智能体(${agentShortcutLabel()})`}
|
||||
className={className}
|
||||
onClick={() =>
|
||||
minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname), lastAgentDrawerTab())
|
||||
}
|
||||
onClick={() => (minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname)))}
|
||||
>
|
||||
<Bot />
|
||||
</Button>
|
||||
|
||||
Reference in new issue
Block a user