Files
UHD_Media_Tool/apps/web/app/components/agentui/prompt-input.tsx
T
rootandClaude Opus 5.5 6fcf82afb0 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]>
2026-09-29 22:41:34 +08:00

229 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 动作菜单;
// AGUI-3:输入区只留输入框和发送 / 停止,新增 `/` 指令菜单(commands)与上下文标签(context,可移除);
// 外框用 border + focus-within 提亮(不做玻璃叠玻璃),按钮用项目 <Button>。
import { ArrowUp, Square, X } from "lucide-react";
import {
type FormEvent,
type KeyboardEvent,
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;
submitLabel?: string;
stopLabel?: string;
/** 以 / 开头时弹出的指令菜单 */
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,
onSubmit,
loading = false,
onStop,
pending = false,
minRows = 2,
maxRows = 8,
submitLabel = "发送",
stopLabel = "停止",
commands = [],
context,
onClearContext,
className,
disabled,
placeholder,
"aria-label": ariaLabel = "输入",
...rest
}: 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;
if (!t || !m) return;
const h = Math.min(Math.max(m.scrollHeight, minRows * LINE), maxRows * LINE);
t.style.height = `${h}px`;
}, [minRows, maxRows]);
// 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();
if (!canSubmit) return;
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();
};
return (
<form
onSubmit={submit}
className={cn(
"relative w-full min-w-0 rounded-2xl border border-border/80 p-2 transition-colors duration-base focus-within:border-foreground/30",
disabled && "opacity-60",
className,
)}
>
{menuOpen ? (
<div
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"
>
{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>
{loading && onStop ? (
<Button type="button" size="sm" variant="destructive" onClick={onStop} aria-label={stopLabel}>
<Square className="size-3.5" />
{stopLabel}
</Button>
) : null}
<Button
type="submit"
size="icon-sm"
disabled={!canSubmit}
aria-label={submitLabel}
title={`${submitLabel}(Enter)`}
>
<ArrowUp />
</Button>
</div>
</form>
);
}