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,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" />
|
||||
|
||||
Reference in new issue
Block a user