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:
rootandClaude Opus 5.5 committed 2026-09-29 22:41:34 +08:00
1 parent 2ab6d452dd
commit 6fcf82afb0
17 files changed
+2381 -1846

No files matched your search

+122 -31
View File
@@ -1,45 +1,56 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/prompt-input.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:保留自增高(隐藏测量层 + minRows/maxRows)、Enter 发送 / Shift+Enter 换行 / 输入法组字中不发送、
// loading 时发送键换成"停止";去掉模型选择与 MorphPopover 动作菜单,换成 `toolbar` 槽(放模式切换等);
// loading 时发送键换成"停止";去掉模型选择与 MorphPopover 动作菜单;
// AGUI-3:输入区只留输入框和发送 / 停止,新增 `/` 指令菜单(commands)与上下文标签(context,可移除);
// 外框用 border + focus-within 提亮(不做玻璃叠玻璃),按钮用项目 <Button>。
import { ArrowUp, Square } from "lucide-react";
import { ArrowUp, Square, X } from "lucide-react";
import {
type FormEvent,
type KeyboardEvent,
type ReactNode,
type TextareaHTMLAttributes,
useCallback,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils.ts";
export type PromptCommand = { name: string; description: string };
export interface PromptInputProps
extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "onChange" | "onSubmit" | "children"> {
value: string;
onValueChange: (v: string) => void;
onSubmit: (v: string) => void;
/** 运行中:发送键变"停止" */
/** 运行中:发送键旁出现"停止" */
loading?: boolean;
onStop?: () => void;
/** 提交中(禁用发送) */
pending?: boolean;
minRows?: number;
maxRows?: number;
/** 输入框下方左侧工具条 */
toolbar?: ReactNode;
submitLabel?: string;
stopLabel?: string;
/** 顶部附加内容(如上下文前缀) */
header?: ReactNode;
/** 以 / 开头时弹出的指令菜单 */
commands?: PromptCommand[];
/** 上下文标签(对象名称);给了 onClearContext 就带 × */
context?: string | null;
onClearContext?: () => void;
className?: string;
}
/** 行高 24px(text-sm leading-6),与测量层一致 */
const LINE = 24;
/** 正在输入指令(以 / 开头、还没空格)时匹配的指令 */
export function commandMatches(value: string, commands: PromptCommand[]): PromptCommand[] {
if (!value.startsWith("/") || /\s/.test(value)) return [];
return commands.filter((c) => c.name.startsWith(value));
}
export function PromptInput({
value,
onValueChange,
@@ -49,10 +60,11 @@ export function PromptInput({
pending = false,
minRows = 2,
maxRows = 8,
toolbar,
submitLabel = "发送",
stopLabel = "停止",
header,
commands = [],
context,
onClearContext,
className,
disabled,
placeholder,
@@ -61,6 +73,9 @@ export function PromptInput({
}: PromptInputProps) {
const ta = useRef<HTMLTextAreaElement>(null);
const measure = useRef<HTMLDivElement>(null);
const menuId = useId();
const [active, setActive] = useState(0);
const [dismissed, setDismissed] = useState<string | null>(null);
const resize = useCallback(() => {
const t = ta.current;
const m = measure.current;
@@ -71,6 +86,14 @@ export function PromptInput({
// biome-ignore lint/correctness/useExhaustiveDependencies: 内容变化时重新测量
useLayoutEffect(resize, [value, resize]);
const matches = dismissed === value ? [] : commandMatches(value, commands);
const menuOpen = matches.length > 0;
const pick = (c: PromptCommand) => {
onValueChange(`${c.name} `);
setActive(0);
requestAnimationFrame(() => ta.current?.focus());
};
const canSubmit = !!value.trim() && !disabled && !pending;
const submit = (e?: FormEvent) => {
e?.preventDefault();
@@ -78,6 +101,23 @@ export function PromptInput({
onSubmit(value.trim());
};
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (menuOpen) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setActive((i) => (i + (e.key === "ArrowDown" ? 1 : -1) + matches.length) % matches.length);
return;
}
if ((e.key === "Enter" || e.key === "Tab") && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
pick(matches[Math.min(active, matches.length - 1)]!);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setDismissed(value);
return;
}
}
if (e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing) return;
e.preventDefault();
submit();
@@ -92,30 +132,81 @@ export function PromptInput({
className,
)}
>
{header}
<div className="relative">
{menuOpen ? (
<div
ref={measure}
aria-hidden
className="pointer-events-none invisible absolute inset-x-0 top-0 px-2 pt-1.5 text-sm leading-6 break-words whitespace-pre-wrap"
id={menuId}
role="listbox"
aria-label="指令"
className="pg-float absolute inset-x-2 bottom-full z-20 mb-1.5 flex flex-col gap-0.5 rounded-xl p-1"
>
{`${value}​`}
{matches.map((c, i) => (
<div
key={c.name}
role="option"
tabIndex={-1}
aria-selected={i === active}
// 按下就选(不等 click),避免输入框先失焦
onMouseDown={(e) => {
e.preventDefault();
pick(c);
}}
className={cn(
"flex w-full cursor-pointer items-baseline gap-2 rounded-lg px-2.5 py-1.5 text-left text-caption outline-none",
i === active ? "bg-foreground/[0.06] text-foreground" : "text-foreground/85",
)}
>
<span className="shrink-0 font-medium">{c.name}</span>
<span className="min-w-0 truncate text-meta text-muted-foreground">{c.description}</span>
</div>
))}
</div>
) : null}
{context ? (
<div className="mb-1 flex px-1">
<span className="inline-flex max-w-full min-w-0 items-center gap-1 rounded-full border border-border/70 py-0.5 pr-1 pl-2 text-meta text-muted-foreground">
<span className="min-w-0 truncate" title={context}>
{context}
</span>
{onClearContext ? (
<button
type="button"
aria-label={`去掉上下文「${context}」`}
onClick={onClearContext}
className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-full outline-none hover:bg-foreground/[0.08] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<X aria-hidden className="size-3" />
</button>
) : null}
</span>
</div>
) : null}
<div className="flex items-end gap-1.5">
<div className="relative min-w-0 flex-1">
<div
ref={measure}
aria-hidden
className="pointer-events-none invisible absolute inset-x-0 top-0 px-2 py-1 text-sm leading-6 break-words whitespace-pre-wrap"
>
{`${value}​`}
</div>
<textarea
ref={ta}
value={value}
disabled={disabled}
placeholder={placeholder}
aria-label={ariaLabel}
aria-autocomplete={commands.length ? "list" : undefined}
aria-controls={menuOpen ? menuId : undefined}
rows={minRows}
{...rest}
onChange={(e) => {
onValueChange(e.target.value);
setActive(0);
}}
onKeyDown={onKeyDown}
className="block w-full resize-none overflow-y-auto bg-transparent px-2 py-1 text-sm leading-6 text-foreground outline-none placeholder:text-muted-foreground/70"
/>
</div>
<textarea
ref={ta}
value={value}
disabled={disabled}
placeholder={placeholder}
aria-label={ariaLabel}
rows={minRows}
{...rest}
onChange={(e) => onValueChange(e.target.value)}
onKeyDown={onKeyDown}
className="block w-full resize-none overflow-y-auto bg-transparent px-2 pt-1.5 text-sm leading-6 text-foreground outline-none placeholder:text-muted-foreground/70"
/>
</div>
<div className="mt-1 flex min-h-8 flex-wrap items-center gap-1.5">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">{toolbar}</div>
{loading && onStop ? (
<Button type="button" size="sm" variant="destructive" onClick={onStop} aria-label={stopLabel}>
<Square className="size-3.5" />