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
@@ -0,0 +1,32 @@
|
||||
# agentui 许可证
|
||||
|
||||
本目录的组件改造自 [agentui](https://github.com/ashish200729/agentui)(<https://www.agentui.pro/>),
|
||||
取自提交 `b2b752bcadad032ce555f37dc7c8faabf395cefe`(2026-09-26)的 `components/agents/`。
|
||||
每个文件头注明了对应的上游文件与改造点:去掉 `motion` / `shiki` / Next.js 依赖,颜色、字号、圆角、
|
||||
时长、缓动换成本项目的语义 token(见 `docs/design/`),文案改中文,状态机按本项目的事件与对象裁剪。
|
||||
|
||||
上游许可证原文如下。
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Saurabh Chauhan
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
@@ -0,0 +1,139 @@
|
||||
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/approval-card/index.tsx + types.ts
|
||||
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
|
||||
// 改造:状态集按本项目 Approval 对齐——上游的 submitting 保留,changes-requested/answered 与多步问答模式去掉
|
||||
// (依赖 motion 的 Checkbox/Radio,且本项目审批只有批准/拒绝);新增 revoked/expired/consumed 终态;
|
||||
// 状态色换 warning/success/destructive 并配文字;按钮用项目 <Button>(胶囊,主操作 pill-primary);
|
||||
// 新增 `disabledReason`:按钮不可用时说明原因(如"只有控制台管理员会话能批准")。
|
||||
import { Check, Clock, LoaderCircle, MessageSquareText, Undo2, X } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { Disclosure } from "./disclosure.tsx";
|
||||
|
||||
export type ApprovalCardStatus =
|
||||
| "pending"
|
||||
| "submitting"
|
||||
| "approved"
|
||||
| "rejected"
|
||||
| "revoked"
|
||||
| "expired"
|
||||
| "consumed";
|
||||
|
||||
const LABEL: Record<ApprovalCardStatus, string> = {
|
||||
pending: "待批准",
|
||||
submitting: "提交中",
|
||||
approved: "已批准",
|
||||
rejected: "已拒绝",
|
||||
revoked: "已撤销",
|
||||
expired: "已过期",
|
||||
consumed: "已执行",
|
||||
};
|
||||
|
||||
const BADGE: Record<ApprovalCardStatus, string> = {
|
||||
pending: "border-warning/30 bg-warning/10 text-warning",
|
||||
submitting: "border-border bg-foreground/[0.04] text-muted-foreground",
|
||||
approved: "border-success/30 bg-success/10 text-success",
|
||||
consumed: "border-success/30 bg-success/10 text-success",
|
||||
rejected: "border-destructive/30 bg-destructive/10 text-destructive",
|
||||
revoked: "border-border bg-foreground/[0.04] text-muted-foreground",
|
||||
expired: "border-border bg-foreground/[0.04] text-muted-foreground",
|
||||
};
|
||||
|
||||
function StatusIcon({ status }: { status: ApprovalCardStatus }) {
|
||||
if (status === "submitting") return <LoaderCircle className="size-4 animate-spin motion-reduce:animate-none" />;
|
||||
if (status === "pending") return <MessageSquareText className="size-4" />;
|
||||
if (status === "rejected") return <X className="size-4" />;
|
||||
if (status === "expired") return <Clock className="size-4" />;
|
||||
if (status === "revoked") return <Undo2 className="size-4" />;
|
||||
return <Check className="size-4" />;
|
||||
}
|
||||
|
||||
export interface ApprovalCardProps {
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
children?: ReactNode;
|
||||
status?: ApprovalCardStatus;
|
||||
badges?: ReactNode;
|
||||
onApprove?: () => void;
|
||||
onReject?: () => void;
|
||||
approveLabel?: ReactNode;
|
||||
rejectLabel?: ReactNode;
|
||||
/** 按钮不可用的原因;给了就禁用按钮并显示 */
|
||||
disabledReason?: string;
|
||||
/** 终态时的结论文字 */
|
||||
result?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ApprovalCard({
|
||||
title = "需要批准",
|
||||
description,
|
||||
children,
|
||||
status = "pending",
|
||||
badges,
|
||||
onApprove,
|
||||
onReject,
|
||||
approveLabel = "批准",
|
||||
rejectLabel = "拒绝",
|
||||
disabledReason,
|
||||
result,
|
||||
className,
|
||||
}: ApprovalCardProps) {
|
||||
const busy = status === "submitting";
|
||||
const interactive = status === "pending" || busy;
|
||||
return (
|
||||
<section
|
||||
data-state={status}
|
||||
aria-busy={busy}
|
||||
aria-label={typeof title === "string" ? title : "审批"}
|
||||
className={cn(
|
||||
"w-full min-w-0 overflow-hidden rounded-xl border p-3 text-sm",
|
||||
status === "pending" ? "border-warning/40 bg-warning/[0.06]" : "border-border/70",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"mt-0.5 grid size-5 shrink-0 place-items-center text-muted-foreground",
|
||||
status === "pending" && "text-warning",
|
||||
(status === "approved" || status === "consumed") && "text-success",
|
||||
status === "rejected" && "text-destructive",
|
||||
)}
|
||||
>
|
||||
<StatusIcon status={status} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-start gap-x-3 gap-y-1">
|
||||
<h3 className="min-w-0 flex-1 text-body leading-5 font-medium text-foreground">{title}</h3>
|
||||
<span className={cn("shrink-0 rounded-full border px-2 py-0.5 text-label font-medium", BADGE[status])}>
|
||||
{LABEL[status]}
|
||||
</span>
|
||||
</div>
|
||||
{badges ? <div className="mt-1.5 flex flex-wrap items-center gap-1">{badges}</div> : null}
|
||||
{description ? <p className="mt-1.5 text-caption leading-5 text-muted-foreground">{description}</p> : null}
|
||||
{children ? <div className="mt-2.5">{children}</div> : null}
|
||||
<Disclosure open={interactive}>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" disabled={busy || !!disabledReason || !onApprove} onClick={onApprove}>
|
||||
{approveLabel}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy || !!disabledReason || !onReject}
|
||||
onClick={onReject}
|
||||
className="hover:text-destructive"
|
||||
>
|
||||
{rejectLabel}
|
||||
</Button>
|
||||
{disabledReason ? <span className="text-meta text-muted-foreground">{disabledReason}</span> : null}
|
||||
</div>
|
||||
</Disclosure>
|
||||
{!interactive ? <p className="mt-1.5 text-caption text-muted-foreground">{result ?? LABEL[status]}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/citations.tsx
|
||||
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
|
||||
// 改造:去掉 favicon 抓取(控制台链接都是站内),改成可选图标槽;站内链接走 react-router 的 Link(`to`),
|
||||
// 外链仍新窗口打开;新增 `meta`(右侧状态文字);入场/折叠去 motion,用 Disclosure 与 tw-animate;字号/圆角换 token。
|
||||
import { BookOpenText, ChevronDown, ExternalLink } from "lucide-react";
|
||||
import { type ReactNode, useId, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { CHEVRON_CLASS, Disclosure } from "./disclosure.tsx";
|
||||
|
||||
export interface CitationItem {
|
||||
id: string;
|
||||
title: ReactNode;
|
||||
/** 副标题(如状态、域名) */
|
||||
domain?: ReactNode;
|
||||
/** 右侧附加信息(如状态徽章) */
|
||||
meta?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
/** 站内路由 */
|
||||
to?: string;
|
||||
/** 外链 */
|
||||
url?: string;
|
||||
}
|
||||
|
||||
function targetId(prefix: string, id: string) {
|
||||
return `${prefix}-${id.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
||||
}
|
||||
|
||||
/** 行内角标:跳到同 idPrefix 的来源行 */
|
||||
export function Citation({
|
||||
citationId,
|
||||
index,
|
||||
idPrefix,
|
||||
className,
|
||||
}: {
|
||||
citationId: string;
|
||||
index: number;
|
||||
idPrefix: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={`#${targetId(idPrefix, citationId)}`}
|
||||
aria-label={`查看来源 ${index}`}
|
||||
className={cn(
|
||||
"mx-0.5 inline-flex min-w-4 -translate-y-0.5 items-center justify-center rounded-tight bg-foreground/[0.06] px-1 py-0.5 text-micro leading-none font-semibold text-muted-foreground no-underline outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{index}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ c, index, prefix }: { c: CitationItem; index: number; prefix: string }) {
|
||||
const body = (
|
||||
<>
|
||||
{c.icon ? (
|
||||
<span aria-hidden className="grid size-5 shrink-0 place-items-center text-muted-foreground [&_svg]:size-3.5">
|
||||
{c.icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="min-w-0 truncate text-caption font-medium text-foreground/85 transition-colors duration-micro group-hover/citation:text-foreground">
|
||||
{c.title}
|
||||
</span>
|
||||
{c.domain ? <span className="min-w-0 truncate text-meta text-muted-foreground">{c.domain}</span> : null}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{c.meta}
|
||||
<span className="grid size-5 place-items-center rounded-md bg-foreground/[0.05] text-micro font-semibold text-muted-foreground tabular-nums">
|
||||
{index}
|
||||
</span>
|
||||
{c.url ? <ExternalLink aria-hidden className="size-3.5 text-muted-foreground/60" /> : null}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
const cls =
|
||||
"group/citation flex min-w-0 items-center gap-2 rounded-md px-1.5 py-1 outline-none transition-colors duration-micro hover:bg-foreground/[0.04] focus-visible:ring-2 focus-visible:ring-ring";
|
||||
const id = targetId(prefix, c.id);
|
||||
if (c.to)
|
||||
return (
|
||||
<Link id={id} to={c.to} className={cls}>
|
||||
{body}
|
||||
</Link>
|
||||
);
|
||||
if (c.url)
|
||||
return (
|
||||
<a id={id} href={c.url} target="_blank" rel="noreferrer noopener" className={cls}>
|
||||
{body}
|
||||
</a>
|
||||
);
|
||||
return (
|
||||
<div id={id} className={cls}>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CitationList({
|
||||
citations,
|
||||
idPrefix,
|
||||
className,
|
||||
}: {
|
||||
citations: CitationItem[];
|
||||
idPrefix?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const base = useId();
|
||||
const prefix = idPrefix ?? `citation-${base.replace(/:/g, "")}`;
|
||||
return (
|
||||
<ol className={cn("grid gap-0.5", className)}>
|
||||
{citations.map((c, i) => (
|
||||
<li key={c.id} className="min-w-0 animate-in fade-in-0 duration-base motion-reduce:animate-none">
|
||||
<Row c={c} index={i + 1} prefix={prefix} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
/** 可折叠的来源列表(上游默认收起;这里给核实结果用,默认展开) */
|
||||
export function Citations({
|
||||
citations,
|
||||
title = "来源",
|
||||
defaultOpen = false,
|
||||
idPrefix,
|
||||
className,
|
||||
}: {
|
||||
citations: CitationItem[];
|
||||
title?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
idPrefix?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className={cn("w-full text-sm", className)}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="-ml-1 flex min-h-8 cursor-pointer items-center gap-2 rounded-lg px-1 text-left text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<BookOpenText aria-hidden className="size-4" />
|
||||
<span className="text-caption font-medium">{title}</span>
|
||||
<span className="rounded-full bg-foreground/[0.06] px-1.5 py-0.5 text-micro font-semibold tabular-nums">
|
||||
{citations.length}
|
||||
</span>
|
||||
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
|
||||
</button>
|
||||
<Disclosure id={id} open={open}>
|
||||
<CitationList citations={citations} idPrefix={idPrefix} className="mt-1" />
|
||||
</Disclosure>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/code-block.tsx + agent-code.tsx
|
||||
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
|
||||
// 改造:不引 shiki(包体大,且高亮主题带十六进制颜色),纯文本按行渲染;JSON 输入先尝试缩进美化;
|
||||
// 状态色:streaming 用中性色 + 转圈,complete 用 success;高亮行用 warning 洗层;行号/字号/圆角换 token;
|
||||
// 去掉 motion 按压;复制失败静默。
|
||||
import { Check, Copy, FileCode2, LoaderCircle } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
export type CodeLanguage = "text" | "json" | "bash" | "diff";
|
||||
|
||||
/** JSON 文本尝试缩进美化;不是合法 JSON 原样返回(摘要可能被截断) */
|
||||
export function prettyMaybeJson(s: string): { text: string; language: CodeLanguage } {
|
||||
const t = s.trim();
|
||||
if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) {
|
||||
try {
|
||||
return { text: JSON.stringify(JSON.parse(t), null, 2), language: "json" };
|
||||
} catch {
|
||||
return { text: s, language: "json" };
|
||||
}
|
||||
}
|
||||
return { text: s, language: "text" };
|
||||
}
|
||||
|
||||
export interface CodeBlockProps {
|
||||
code: string;
|
||||
language?: CodeLanguage;
|
||||
filename?: ReactNode;
|
||||
status?: "streaming" | "complete";
|
||||
showLineNumbers?: boolean;
|
||||
highlightLines?: number[];
|
||||
/** 最大高度(Tailwind 类,如 max-h-56) */
|
||||
maxHeightClassName?: string;
|
||||
wrap?: boolean;
|
||||
copyable?: boolean;
|
||||
/** 不显示顶栏(嵌在别的卡片里时) */
|
||||
bare?: boolean;
|
||||
tone?: "default" | "destructive";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CodeBlock({
|
||||
code,
|
||||
language = "text",
|
||||
filename,
|
||||
status = "complete",
|
||||
showLineNumbers = false,
|
||||
highlightLines = [],
|
||||
maxHeightClassName = "max-h-56",
|
||||
wrap = true,
|
||||
copyable = true,
|
||||
bare = false,
|
||||
tone = "default",
|
||||
className,
|
||||
}: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const t = useRef<number | undefined>(undefined);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (t.current) window.clearTimeout(t.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const streaming = status === "streaming";
|
||||
const hl = new Set(highlightLines);
|
||||
const lines = code.split("\n");
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard?.writeText(code);
|
||||
setCopied(true);
|
||||
if (t.current) window.clearTimeout(t.current);
|
||||
t.current = window.setTimeout(() => setCopied(false), 1600);
|
||||
} catch {
|
||||
// 非安全上下文没有剪贴板
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-state={status}
|
||||
aria-busy={streaming}
|
||||
className={cn(
|
||||
"w-full min-w-0 overflow-hidden rounded-lg border border-border/60 bg-foreground/[0.03] text-sm",
|
||||
tone === "destructive" && "border-destructive/30",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{bare ? null : (
|
||||
<div className="flex h-8 items-center gap-2 border-b border-border/50 px-2.5">
|
||||
<FileCode2 aria-hidden className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
{filename ? <span className="min-w-0 truncate text-meta text-foreground/80">{filename}</span> : null}
|
||||
<span className="text-micro font-medium tracking-wide text-muted-foreground uppercase">{language}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto inline-flex shrink-0 items-center gap-1 text-micro font-medium",
|
||||
streaming ? "text-muted-foreground" : "text-success",
|
||||
)}
|
||||
>
|
||||
{streaming ? (
|
||||
<LoaderCircle aria-hidden className="size-3 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Check aria-hidden className="size-3" />
|
||||
)}
|
||||
{streaming ? "输出中" : "完整"}
|
||||
</span>
|
||||
{copyable ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={copied ? "已复制" : "复制"}
|
||||
title={copied ? "已复制" : "复制"}
|
||||
onClick={copy}
|
||||
className="grid size-6 shrink-0 cursor-pointer place-items-center rounded-full text-muted-foreground outline-none transition-colors duration-micro hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("overflow-auto py-1.5", maxHeightClassName)}>
|
||||
<pre
|
||||
className={cn(
|
||||
"m-0 font-mono text-meta leading-5 text-foreground/85",
|
||||
tone === "destructive" && "text-destructive",
|
||||
!wrap && "min-w-max",
|
||||
)}
|
||||
>
|
||||
<code>
|
||||
{lines.map((line, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"grid min-h-5",
|
||||
showLineNumbers ? "grid-cols-[2.25rem_minmax(0,1fr)]" : "grid-cols-1",
|
||||
hl.has(i + 1) && "bg-warning/10",
|
||||
)}
|
||||
>
|
||||
{showLineNumbers ? (
|
||||
<span className="pr-2 text-right text-muted-foreground/50 tabular-nums select-none">{i + 1}</span>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"pr-3",
|
||||
showLineNumbers ? "pl-1" : "pl-3",
|
||||
wrap ? "break-all whitespace-pre-wrap" : "whitespace-pre",
|
||||
)}
|
||||
>
|
||||
{line || " "}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/agent-disclosure.tsx
|
||||
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
|
||||
// 改造:motion 的 clipPath/height 动画换成 CSS grid-rows 0fr→1fr 过渡(duration-layout / ease-standard),
|
||||
// reduced motion 下不做过渡;收起时 inert + aria-hidden,键盘与读屏都跳过。
|
||||
import type { ComponentPropsWithRef } from "react";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
export interface DisclosureProps extends ComponentPropsWithRef<"div"> {
|
||||
open: boolean;
|
||||
innerClassName?: string;
|
||||
}
|
||||
|
||||
/** 折叠区:展开/收起只动高度与透明度 */
|
||||
export function Disclosure({ open, className, innerClassName, children, ...props }: DisclosureProps) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
data-state={open ? "open" : "closed"}
|
||||
aria-hidden={!open}
|
||||
inert={!open}
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows,opacity] duration-layout ease-standard motion-reduce:transition-none",
|
||||
open ? "grid-rows-[1fr] opacity-100" : "pointer-events-none grid-rows-[0fr] opacity-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn("min-h-0 overflow-hidden", innerClassName)}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 折叠箭头:展开时转 180°(旋转过渡 duration-base) */
|
||||
export const CHEVRON_CLASS =
|
||||
"shrink-0 text-muted-foreground/60 transition-transform duration-base ease-standard motion-reduce:transition-none";
|
||||
@@ -0,0 +1,99 @@
|
||||
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/file-diff.tsx
|
||||
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
|
||||
// 改造:新增/删除行底色 emerald/rose → success/destructive 洗层(并保留 +/− 符号,不只靠颜色);不做语法高亮(去 shiki);
|
||||
// 折叠用 Disclosure;maxHeight 改 Tailwind 类;用于展示路径类变更(如整理记录的入库目标)。
|
||||
import { ChevronDown, FileCode2 } from "lucide-react";
|
||||
import { type ReactNode, useId, useState } from "react";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { CHEVRON_CLASS, Disclosure } from "./disclosure.tsx";
|
||||
|
||||
export type FileDiffLineType = "added" | "removed" | "context";
|
||||
|
||||
export interface FileDiffLine {
|
||||
id: string;
|
||||
type?: FileDiffLineType;
|
||||
content: ReactNode;
|
||||
/** 行尾附注(如状态) */
|
||||
note?: ReactNode;
|
||||
}
|
||||
|
||||
function Count({ value, type }: { value: number; type: "added" | "removed" }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<span className={cn("font-mono text-meta tabular-nums", type === "added" ? "text-success" : "text-destructive")}>
|
||||
{type === "added" ? "+" : "−"}
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileDiff({
|
||||
file,
|
||||
lines,
|
||||
defaultOpen = true,
|
||||
maxHeightClassName = "max-h-56",
|
||||
className,
|
||||
}: {
|
||||
file: ReactNode;
|
||||
lines: FileDiffLine[];
|
||||
defaultOpen?: boolean;
|
||||
maxHeightClassName?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const added = lines.filter((l) => l.type === "added").length;
|
||||
const removed = lines.filter((l) => l.type === "removed").length;
|
||||
return (
|
||||
<div className={cn("w-full min-w-0 overflow-hidden rounded-lg border border-border/60 text-sm", className)}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex h-8 w-full cursor-pointer items-center gap-2 px-2.5 text-left outline-none transition-colors duration-micro hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<FileCode2 aria-hidden className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span className="min-w-0 flex-1 truncate text-meta text-foreground/85">{file}</span>
|
||||
<Count value={added} type="added" />
|
||||
<Count value={removed} type="removed" />
|
||||
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
|
||||
</button>
|
||||
<Disclosure id={id} open={open}>
|
||||
<div className={cn("overflow-auto border-t border-border/50 py-1", maxHeightClassName)}>
|
||||
<ul className="font-mono text-meta leading-5">
|
||||
{lines.map((l) => (
|
||||
<li
|
||||
key={l.id}
|
||||
className={cn(
|
||||
"grid grid-cols-[1.25rem_minmax(0,1fr)_auto] gap-1 pr-2.5",
|
||||
l.type === "added" && "bg-success/[0.08]",
|
||||
l.type === "removed" && "bg-destructive/[0.08]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"text-center select-none",
|
||||
l.type === "added"
|
||||
? "text-success"
|
||||
: l.type === "removed"
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground/50",
|
||||
)}
|
||||
>
|
||||
{l.type === "added" ? "+" : l.type === "removed" ? "−" : " "}
|
||||
</span>
|
||||
<span className="min-w-0 break-all text-foreground/85">
|
||||
<span className="sr-only">{l.type === "added" ? "新增:" : l.type === "removed" ? "移除:" : ""}</span>
|
||||
{l.content}
|
||||
</span>
|
||||
{l.note ? <span className="shrink-0 font-sans text-muted-foreground">{l.note}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Disclosure>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/message-bubble.tsx
|
||||
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
|
||||
// 改造:去掉 motion 的 layout 动画与 render 克隆;变体底色换语义 token(soft = foreground 洗层,danger = destructive,
|
||||
// warning 为新增);折叠遮罩用 mask-image 渐变到 transparent(不写十六进制),箭头旋转用 duration-base。
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { type ComponentPropsWithRef, createContext, useContext, useId, useState } from "react";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { CHEVRON_CLASS } from "./disclosure.tsx";
|
||||
import { MessageSideContext } from "./message.tsx";
|
||||
|
||||
export type MessageBubbleVariant = "solid" | "soft" | "outline" | "ghost" | "danger" | "warning";
|
||||
|
||||
const BubbleContext = createContext<{ variant: MessageBubbleVariant; align: "start" | "end" }>({
|
||||
variant: "soft",
|
||||
align: "start",
|
||||
});
|
||||
|
||||
export function MessageBubble({
|
||||
variant = "soft",
|
||||
align,
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithRef<"div"> & { variant?: MessageBubbleVariant; align?: "start" | "end" }) {
|
||||
const side = useContext(MessageSideContext);
|
||||
const resolved = align ?? side ?? "start";
|
||||
return (
|
||||
<BubbleContext.Provider value={{ variant, align: resolved }}>
|
||||
<div
|
||||
data-slot="message-bubble"
|
||||
data-variant={variant}
|
||||
className={cn("flex w-full min-w-0 flex-col", resolved === "end" ? "items-end" : "items-start", className)}
|
||||
{...props}
|
||||
/>
|
||||
</BubbleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const VARIANT: Record<MessageBubbleVariant, string> = {
|
||||
solid: "bg-foreground text-background",
|
||||
soft: "border border-border/60 bg-foreground/[0.05]",
|
||||
outline: "border border-border/70",
|
||||
ghost: "w-full max-w-none rounded-none border-0 px-0 py-0",
|
||||
danger: "border border-destructive/40 bg-destructive/10 text-destructive",
|
||||
warning: "border border-warning/40 bg-warning/10 text-warning",
|
||||
};
|
||||
|
||||
export function MessageBubbleContent({ className, ...props }: ComponentPropsWithRef<"div">) {
|
||||
const { variant } = useContext(BubbleContext);
|
||||
return (
|
||||
<div
|
||||
data-slot="message-bubble-content"
|
||||
className={cn(
|
||||
"relative min-w-9 max-w-[85%] rounded-2xl px-3.5 py-2.5 text-sm leading-6 break-words text-foreground",
|
||||
VARIANT[variant],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const CLAMP = {
|
||||
2: "line-clamp-2",
|
||||
3: "line-clamp-3",
|
||||
4: "line-clamp-4",
|
||||
5: "line-clamp-5",
|
||||
6: "line-clamp-6",
|
||||
} as const;
|
||||
|
||||
/** 长消息折叠:收起时按行截断并渐隐 */
|
||||
export function MessageBubbleCollapsible({
|
||||
collapsedLines = 4,
|
||||
moreLabel = "展开",
|
||||
lessLabel = "收起",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentPropsWithRef<"div"> & { collapsedLines?: 2 | 3 | 4 | 5 | 6; moreLabel?: string; lessLabel?: string }) {
|
||||
const id = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div data-state={open ? "open" : "closed"} className={cn("w-full", className)} {...props}>
|
||||
<div
|
||||
id={id}
|
||||
className={cn(
|
||||
!open && CLAMP[collapsedLines],
|
||||
!open && "[mask-image:linear-gradient(to_bottom,black_68%,transparent)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="mt-1.5 inline-flex h-7 cursor-pointer items-center gap-1 rounded-full px-2 text-meta font-medium text-muted-foreground outline-none transition-colors duration-micro hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{open ? lessLabel : moreLabel}
|
||||
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/message-scroller.tsx
|
||||
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
|
||||
// 改造:保留"离底部 followThreshold 以内才跟随、滚轮/触摸/上翻键主动离开、ResizeObserver 跟随内容增长"的状态机;
|
||||
// 去掉 PreviewRail 导航;新增"回到底部"按钮(离开跟随时出现,点了恢复跟随);reduced motion 下不用平滑滚动;
|
||||
// 程序滚动的静默期取 --duration-layout 的值,不写字面量时长;log 只播报新增行(additions),流式正文不逐字播报。
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import {
|
||||
type ComponentPropsWithRef,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { useReducedMotion } from "./use-reduced-motion.ts";
|
||||
|
||||
export interface MessageScrollerProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
|
||||
children: ReactNode;
|
||||
/** 用户停在底部附近时,新内容自动滚到底 */
|
||||
followOutput?: boolean;
|
||||
/** 离底部多少像素内仍算"跟随中" */
|
||||
followThreshold?: number;
|
||||
onFollowChange?: (following: boolean) => void;
|
||||
/** 滚动区的无障碍名称 */
|
||||
label?: string;
|
||||
/** 还在等待流式内容 */
|
||||
busy?: boolean;
|
||||
viewportClassName?: string;
|
||||
contentClassName?: string;
|
||||
/** "回到底部"按钮文案 */
|
||||
jumpLabel?: string;
|
||||
}
|
||||
|
||||
function layoutMs(el: Element | null): number {
|
||||
if (!el) return 0;
|
||||
const v = getComputedStyle(el).getPropertyValue("--duration-layout").trim();
|
||||
const n = Number.parseFloat(v);
|
||||
return Number.isFinite(n) ? (v.endsWith("ms") ? n : n * 1000) : 0;
|
||||
}
|
||||
|
||||
export function MessageScroller({
|
||||
followOutput = true,
|
||||
followThreshold = 56,
|
||||
onFollowChange,
|
||||
label = "对话记录",
|
||||
busy,
|
||||
viewportClassName,
|
||||
contentClassName,
|
||||
jumpLabel = "回到底部",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageScrollerProps) {
|
||||
const reduce = useReducedMotion();
|
||||
const viewportRef = useRef<HTMLElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const followingRef = useRef(followOutput);
|
||||
const programmatic = useRef(false);
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
const [following, setFollowingState] = useState(true);
|
||||
|
||||
const setFollowing = useCallback(
|
||||
(next: boolean) => {
|
||||
if (followingRef.current === next) return;
|
||||
followingRef.current = next;
|
||||
setFollowingState(next);
|
||||
onFollowChange?.(next);
|
||||
},
|
||||
[onFollowChange],
|
||||
);
|
||||
|
||||
const scrollToEnd = useCallback((behavior: ScrollBehavior) => {
|
||||
const vp = viewportRef.current;
|
||||
if (!vp) return;
|
||||
programmatic.current = true;
|
||||
if (typeof vp.scrollTo === "function") vp.scrollTo({ top: vp.scrollHeight, behavior });
|
||||
else vp.scrollTop = vp.scrollHeight;
|
||||
if (timer.current) window.clearTimeout(timer.current);
|
||||
// 平滑滚动的静默期:等 scrollend,兜底取 3 × --duration-layout
|
||||
const done = () => {
|
||||
programmatic.current = false;
|
||||
vp.removeEventListener("scrollend", done);
|
||||
};
|
||||
vp.addEventListener("scrollend", done, { once: true });
|
||||
timer.current = window.setTimeout(done, behavior === "smooth" ? layoutMs(vp) * 3 : 0);
|
||||
}, []);
|
||||
|
||||
const lastTop = useRef(0);
|
||||
const onScroll = useCallback(() => {
|
||||
const vp = viewportRef.current;
|
||||
if (!vp) return;
|
||||
const up = vp.scrollTop < lastTop.current - 1;
|
||||
lastTop.current = vp.scrollTop;
|
||||
const nearEnd = vp.scrollHeight - vp.scrollTop - vp.clientHeight <= followThreshold;
|
||||
// 回到底部附近:恢复跟随;离开底部:只有"往上翻"才算用户离开(程序平滑滚动与内容增长只会往下)
|
||||
if (nearEnd) setFollowing(true);
|
||||
else if (up && !programmatic.current) setFollowing(false);
|
||||
}, [followThreshold, setFollowing]);
|
||||
|
||||
const leaveLiveEdge = useCallback(() => {
|
||||
programmatic.current = false;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
followingRef.current = followOutput;
|
||||
if (!followOutput) return;
|
||||
const f = requestAnimationFrame(() => scrollToEnd("auto"));
|
||||
return () => cancelAnimationFrame(f);
|
||||
}, [followOutput, scrollToEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
const content = contentRef.current;
|
||||
if (!content || typeof ResizeObserver === "undefined") return;
|
||||
// 首次铺满内容(回放历史)直接跳到底,之后的增长才平滑跟随
|
||||
let first = true;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!followOutput || !followingRef.current) return;
|
||||
scrollToEnd(reduce || first ? "auto" : "smooth");
|
||||
first = false;
|
||||
});
|
||||
ro.observe(content);
|
||||
return () => ro.disconnect();
|
||||
}, [followOutput, reduce, scrollToEnd]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timer.current) window.clearTimeout(timer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-slot="message-scroller" className={cn("relative min-h-0", className)} {...props}>
|
||||
<section
|
||||
ref={viewportRef}
|
||||
aria-label={label}
|
||||
// biome-ignore lint/a11y/noNoninteractiveTabindex: 滚动区要能用键盘聚焦后方向键滚动
|
||||
tabIndex={0}
|
||||
onScroll={onScroll}
|
||||
onWheel={leaveLiveEdge}
|
||||
onTouchStart={leaveLiveEdge}
|
||||
onKeyDown={(e) => {
|
||||
if (["ArrowUp", "PageUp", "Home"].includes(e.key)) leaveLiveEdge();
|
||||
}}
|
||||
className={cn(
|
||||
"h-full overflow-y-auto overscroll-contain outline-none [overflow-anchor:none] [scrollbar-gutter:stable] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
||||
viewportClassName,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions"
|
||||
aria-busy={busy}
|
||||
className={contentClassName}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
{!following ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFollowing(true);
|
||||
scrollToEnd(reduce ? "auto" : "smooth");
|
||||
}}
|
||||
className="pg-float absolute bottom-3 left-1/2 z-10 inline-flex h-8 -translate-x-1/2 cursor-pointer items-center gap-1.5 rounded-full px-3 text-meta font-medium text-foreground outline-none animate-in fade-in-0 slide-in-from-bottom-1 duration-base motion-reduce:animate-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<ArrowDown aria-hidden className="size-3.5" />
|
||||
{jumpLabel}
|
||||
{busy ? <span className="sr-only">(仍在输出)</span> : null}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/message.tsx + message-context.tsx
|
||||
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
|
||||
// 改造:motion.article 的弹簧入场换成 tw-animate 的 animate-in(duration-base / ease-pop,motion-reduce 关闭);
|
||||
// 头像底色、字号(text-label / text-meta)、圆角换语义 token;新增 from="system" 行(居中提示,用于压缩/中止等系统消息);
|
||||
// MessageTyping 三点动画用 animate-pulse + 具名时长的 animation-delay。
|
||||
import { type ComponentPropsWithRef, createContext, useContext } from "react";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
export type MessageFrom = "user" | "assistant" | "system";
|
||||
export type MessageSide = "start" | "end";
|
||||
|
||||
export const MessageSideContext = createContext<MessageSide | undefined>(undefined);
|
||||
const MessageContext = createContext<{ from: MessageFrom }>({ from: "assistant" });
|
||||
|
||||
export interface MessageProps extends ComponentPropsWithRef<"article"> {
|
||||
from: MessageFrom;
|
||||
/** 挂载时播一次入场(新到达的消息用;回放历史时不播) */
|
||||
animateIn?: boolean;
|
||||
}
|
||||
|
||||
const FROM_LABEL: Record<MessageFrom, string> = { user: "用户消息", assistant: "Agent 消息", system: "系统消息" };
|
||||
|
||||
export function Message({ from, animateIn = false, className, children, ...props }: MessageProps) {
|
||||
return (
|
||||
<MessageSideContext.Provider value={from === "user" ? "end" : "start"}>
|
||||
<MessageContext.Provider value={{ from }}>
|
||||
<article
|
||||
data-slot="message"
|
||||
data-from={from}
|
||||
aria-label={props["aria-label"] ?? FROM_LABEL[from]}
|
||||
{...props}
|
||||
className={cn(
|
||||
"group/message flex w-full min-w-0 items-start gap-2.5",
|
||||
from === "user" ? "flex-row-reverse" : "flex-row",
|
||||
animateIn &&
|
||||
"animate-in fade-in-0 slide-in-from-bottom-1 duration-base ease-pop motion-reduce:animate-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</article>
|
||||
</MessageContext.Provider>
|
||||
</MessageSideContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageGroup({
|
||||
spacing = "default",
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithRef<"div"> & { spacing?: "compact" | "default" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="message-group"
|
||||
className={cn("flex w-full flex-col", spacing === "compact" ? "gap-1.5" : "gap-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageAvatar({
|
||||
placeholder = false,
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithRef<"div"> & { placeholder?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="message-avatar"
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"mt-0.5 grid size-7 shrink-0 place-items-center overflow-hidden rounded-full bg-foreground/[0.06] text-meta font-medium text-muted-foreground [&_svg]:size-3.5",
|
||||
placeholder && "invisible",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageContent({ className, ...props }: ComponentPropsWithRef<"div">) {
|
||||
const { from } = useContext(MessageContext);
|
||||
return (
|
||||
<div
|
||||
data-slot="message-content"
|
||||
className={cn("flex min-w-0 flex-1 flex-col gap-1.5", from === "user" ? "items-end" : "items-start", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageHeader({ className, ...props }: ComponentPropsWithRef<"div">) {
|
||||
const { from } = useContext(MessageContext);
|
||||
return (
|
||||
<div
|
||||
data-slot="message-header"
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-1 text-label leading-none text-muted-foreground",
|
||||
from === "user" ? "justify-end" : "justify-start",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageFooter({ className, ...props }: ComponentPropsWithRef<"div">) {
|
||||
const { from } = useContext(MessageContext);
|
||||
return (
|
||||
<div
|
||||
data-slot="message-footer"
|
||||
className={cn(
|
||||
"flex min-h-5 items-center gap-1 px-1 text-label text-muted-foreground",
|
||||
from === "user" ? "justify-end" : "justify-start",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 居中的系统提示条(上下文压缩、续跑等) */
|
||||
export function MessageMarker({ className, ...props }: ComponentPropsWithRef<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="message-marker"
|
||||
role="note"
|
||||
className={cn(
|
||||
"mx-auto flex w-fit max-w-[88%] items-center gap-1.5 rounded-full border border-border/60 bg-foreground/[0.04] px-2.5 py-1 text-center text-meta text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const DOT_DELAY = ["", "[animation-delay:var(--duration-micro)]", "[animation-delay:var(--duration-layout)]"];
|
||||
|
||||
export function MessageTyping({
|
||||
label = "正在回复",
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithRef<"span"> & { label?: string }) {
|
||||
return (
|
||||
<span data-slot="message-typing" className={cn("inline-flex h-5 items-center gap-1", className)} {...props}>
|
||||
<span className="sr-only">{label}</span>
|
||||
{DOT_DELAY.map((d, i) => (
|
||||
<span
|
||||
key={i}
|
||||
aria-hidden
|
||||
className={cn("size-1 rounded-full bg-current opacity-60 animate-pulse motion-reduce:animate-none", d)}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// 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` 槽(放模式切换等);
|
||||
// 外框用 border + focus-within 提亮(不做玻璃叠玻璃),按钮用项目 <Button>。
|
||||
import { ArrowUp, Square } from "lucide-react";
|
||||
import {
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
type TextareaHTMLAttributes,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
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;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 行高 24px(text-sm leading-6),与测量层一致 */
|
||||
const LINE = 24;
|
||||
|
||||
export function PromptInput({
|
||||
value,
|
||||
onValueChange,
|
||||
onSubmit,
|
||||
loading = false,
|
||||
onStop,
|
||||
pending = false,
|
||||
minRows = 2,
|
||||
maxRows = 8,
|
||||
toolbar,
|
||||
submitLabel = "发送",
|
||||
stopLabel = "停止",
|
||||
header,
|
||||
className,
|
||||
disabled,
|
||||
placeholder,
|
||||
"aria-label": ariaLabel = "输入",
|
||||
...rest
|
||||
}: PromptInputProps) {
|
||||
const ta = useRef<HTMLTextAreaElement>(null);
|
||||
const measure = useRef<HTMLDivElement>(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 canSubmit = !!value.trim() && !disabled && !pending;
|
||||
const submit = (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
onSubmit(value.trim());
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
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,
|
||||
)}
|
||||
>
|
||||
{header}
|
||||
<div className="relative">
|
||||
<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"
|
||||
>
|
||||
{`${value}`}
|
||||
</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" />
|
||||
{stopLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon-sm"
|
||||
disabled={!canSubmit}
|
||||
aria-label={submitLabel}
|
||||
title={`${submitLabel}(Enter)`}
|
||||
>
|
||||
<ArrowUp />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/streaming-response.tsx
|
||||
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
|
||||
// 改造:上游是"整段替换"模型,这里由调用方把 text_delta 累积成全文再传入;新增流式光标(animate-pulse,
|
||||
// reduced motion 下静止);去掉点赞/点踩(本项目不收集反馈)与 motion 按压动画;来源列表接 Citations;
|
||||
// error 态用 destructive 语义色。
|
||||
import { Check, Copy, RotateCcw } from "lucide-react";
|
||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { type CitationItem, CitationList } from "./citations.tsx";
|
||||
|
||||
export type StreamingResponseStatus = "streaming" | "complete" | "error";
|
||||
|
||||
export interface StreamingResponseProps {
|
||||
children: ReactNode;
|
||||
status?: StreamingResponseStatus;
|
||||
/** 复制按钮复制的纯文本 */
|
||||
copyText?: string;
|
||||
onRetry?: () => void;
|
||||
sources?: CitationItem[];
|
||||
/** 外层已有 aria-live(MessageScroller 的 log)时设 false,避免重复播报 */
|
||||
announce?: boolean;
|
||||
showActions?: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
/** 复制成功提示的停留时长(交互停留,非动效) */
|
||||
const COPIED_HOLD_MS = 1600;
|
||||
|
||||
function ActionButton({ label, onClick, children }: { label: string; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
className="grid size-7 cursor-pointer place-items-center rounded-md text-muted-foreground outline-none transition-[background-color,color,transform] duration-micro hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.95]"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 流式光标:只在 streaming 时出现 */
|
||||
export function StreamingCursor({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="streaming-cursor"
|
||||
className={cn(
|
||||
"ml-0.5 inline-block h-4 w-0.5 translate-y-0.5 rounded-full bg-foreground/70 animate-pulse motion-reduce:animate-none",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function StreamingResponse({
|
||||
children,
|
||||
status = "streaming",
|
||||
copyText,
|
||||
onRetry,
|
||||
sources = [],
|
||||
announce = true,
|
||||
showActions = true,
|
||||
className,
|
||||
contentClassName,
|
||||
}: StreamingResponseProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const t = useRef<number | undefined>(undefined);
|
||||
const streaming = status === "streaming";
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (t.current) window.clearTimeout(t.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const copy = useCallback(async () => {
|
||||
if (!copyText) return;
|
||||
try {
|
||||
await navigator.clipboard?.writeText(copyText);
|
||||
setCopied(true);
|
||||
if (t.current) window.clearTimeout(t.current);
|
||||
t.current = window.setTimeout(() => setCopied(false), COPIED_HOLD_MS);
|
||||
} catch {
|
||||
// 剪贴板不可用(非安全上下文)时静默
|
||||
}
|
||||
}, [copyText]);
|
||||
const actions = showActions && !streaming && (copyText || onRetry);
|
||||
|
||||
return (
|
||||
<div data-state={status} aria-busy={streaming} className={cn("w-full min-w-0", className)}>
|
||||
<div
|
||||
aria-live={announce ? "polite" : "off"}
|
||||
className={cn(
|
||||
"text-sm leading-6 break-words whitespace-pre-wrap text-foreground/90",
|
||||
status === "error" && "text-destructive",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{streaming ? <StreamingCursor /> : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="mt-1 flex items-center gap-0.5 animate-in fade-in-0 duration-base motion-reduce:animate-none">
|
||||
{copyText ? (
|
||||
<ActionButton label={copied ? "已复制" : "复制这段回复"} onClick={copy}>
|
||||
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
</ActionButton>
|
||||
) : null}
|
||||
{onRetry ? (
|
||||
<ActionButton label="重试" onClick={onRetry}>
|
||||
<RotateCcw className="size-3.5" />
|
||||
</ActionButton>
|
||||
) : null}
|
||||
<span className="sr-only" aria-live="polite">
|
||||
{copied ? "已复制" : ""}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{sources.length && !streaming ? <CitationList citations={sources} className="mt-2" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/todo-list.tsx
|
||||
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
|
||||
// 改造:SVG 状态图标保留几何(虚线圈=待办、弧线=进行中、勾=完成、叉=取消),pathLength 弹簧动画换成
|
||||
// CSS 过渡/animate-spin(motion-reduce 关闭);完成色 emerald → success,取消 rose → destructive,且都配 sr-only 文字;
|
||||
// 表头完成数去掉滚字;折叠用 Disclosure;maxHeight 改成 Tailwind 类;新增 `error` 状态(工具失败)与 `detail` 槽。
|
||||
import { ChevronDown, ListTodo } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { CHEVRON_CLASS, Disclosure } from "./disclosure.tsx";
|
||||
|
||||
export type TodoItemStatus = "pending" | "in-progress" | "completed" | "cancelled" | "error";
|
||||
|
||||
export interface TodoItem {
|
||||
id: string;
|
||||
title: ReactNode;
|
||||
status?: TodoItemStatus;
|
||||
detail?: ReactNode;
|
||||
}
|
||||
|
||||
const LABEL: Record<TodoItemStatus, string> = {
|
||||
pending: "待办",
|
||||
"in-progress": "进行中",
|
||||
completed: "已完成",
|
||||
cancelled: "被拒绝",
|
||||
error: "失败",
|
||||
};
|
||||
|
||||
function StatusIcon({ status }: { status: TodoItemStatus }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
viewBox="0 0 24 24"
|
||||
className={cn(
|
||||
"mx-0.5 size-5 shrink-0 overflow-visible text-muted-foreground",
|
||||
status === "in-progress" && "text-foreground",
|
||||
status === "completed" && "text-success",
|
||||
(status === "cancelled" || status === "error") && "text-destructive",
|
||||
)}
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
fill="currentColor"
|
||||
fillOpacity={status === "completed" ? 0.1 : 0}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray={status === "pending" ? "2 3" : undefined}
|
||||
className={cn(status === "in-progress" && "opacity-20")}
|
||||
/>
|
||||
{status === "in-progress" ? (
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
pathLength={1}
|
||||
strokeDasharray="0.3 1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
className="origin-center animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
) : null}
|
||||
{status === "completed" ? (
|
||||
<path
|
||||
d="M7.5 12.25 10.5 15.25 16.75 8.75"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
) : null}
|
||||
{status === "cancelled" || status === "error" ? (
|
||||
<path
|
||||
d="M8.5 8.5 15.5 15.5M15.5 8.5 8.5 15.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
) : null}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TodoListProps {
|
||||
items: TodoItem[];
|
||||
title?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
collapseOnComplete?: boolean;
|
||||
/** 列表最大高度(Tailwind 类) */
|
||||
maxHeightClassName?: string;
|
||||
emptyText?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TodoList({
|
||||
items,
|
||||
title = "步骤",
|
||||
defaultOpen = true,
|
||||
collapseOnComplete = false,
|
||||
maxHeightClassName = "max-h-64",
|
||||
emptyText = "还没有步骤",
|
||||
className,
|
||||
}: TodoListProps) {
|
||||
const id = useId();
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const done = items.filter((i) => i.status === "completed").length;
|
||||
const settled =
|
||||
items.length > 0 && items.every((i) => i.status && i.status !== "pending" && i.status !== "in-progress");
|
||||
const prevSettled = useRef(settled);
|
||||
useEffect(() => {
|
||||
if (!prevSettled.current && settled && collapseOnComplete) setOpen(false);
|
||||
prevSettled.current = settled;
|
||||
}, [settled, collapseOnComplete]);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="步骤清单"
|
||||
className={cn("w-full min-w-0 overflow-hidden rounded-xl border border-border/70", className)}
|
||||
>
|
||||
<button
|
||||
id={`${id}-t`}
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={`${id}-c`}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex h-10 w-full cursor-pointer items-center gap-2.5 rounded-xl px-3 text-left outline-none transition-colors duration-micro hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<ListTodo aria-hidden className={cn("size-4 text-muted-foreground", settled && "text-success")} />
|
||||
<h3 className="min-w-0 flex-1 truncate text-caption font-medium text-foreground/90">{title}</h3>
|
||||
<span
|
||||
className={cn("shrink-0 text-meta font-medium text-muted-foreground tabular-nums", settled && "text-success")}
|
||||
>
|
||||
<span className="sr-only">
|
||||
已完成 {done} / 共 {items.length}
|
||||
</span>
|
||||
<span aria-hidden>
|
||||
{done}/{items.length}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
|
||||
</button>
|
||||
<Disclosure id={`${id}-c`} role="region" aria-labelledby={`${id}-t`} open={open}>
|
||||
<div className={cn("overflow-y-auto px-2 pb-2", maxHeightClassName)}>
|
||||
{items.length ? (
|
||||
<ol className="space-y-0">
|
||||
{items.map((item) => {
|
||||
const s = item.status ?? "pending";
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
className="flex min-h-9 min-w-0 items-center gap-2.5 rounded-lg px-1.5 py-1 animate-in fade-in-0 slide-in-from-bottom-1 duration-base motion-reduce:animate-none"
|
||||
>
|
||||
<StatusIcon status={s} />
|
||||
<span className="sr-only">{LABEL[s]}:</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-caption leading-5",
|
||||
s === "pending" && "text-muted-foreground",
|
||||
s === "in-progress" && "text-foreground",
|
||||
s === "completed" && "text-muted-foreground line-through decoration-foreground/30",
|
||||
(s === "cancelled" || s === "error") && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
{item.detail ? (
|
||||
<span className="shrink-0 text-meta text-muted-foreground">{item.detail}</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="px-1.5 py-2 text-caption text-muted-foreground">{emptyText}</p>
|
||||
)}
|
||||
</div>
|
||||
</Disclosure>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/tool-approval.tsx
|
||||
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
|
||||
// 改造:状态徽章 amber/blue/emerald/rose → warning/中性/success/destructive;去掉 "Always allow"(本项目的持续授权
|
||||
// 只在设置页按策略版本发放,不在对话里一键放行);新增 `rejected` 醒目态:服务端拒绝了一次工具调用时,
|
||||
// 整卡换 destructive 材质、role=alert、给出拒绝原因与授权说明;新增 `badges` 槽(scope/风险级别);参数区用 Disclosure。
|
||||
import { Check, ChevronDown, CircleAlert, LoaderCircle, ShieldAlert, ShieldCheck, X } from "lucide-react";
|
||||
import { type ReactNode, useId, useState } from "react";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { CHEVRON_CLASS, Disclosure } from "./disclosure.tsx";
|
||||
|
||||
export type ToolApprovalStatus =
|
||||
| "pending"
|
||||
| "approving"
|
||||
| "approved"
|
||||
| "denied"
|
||||
| "rejected"
|
||||
| "running"
|
||||
| "complete"
|
||||
| "error";
|
||||
|
||||
export interface ToolApprovalParameter {
|
||||
id: string;
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
}
|
||||
|
||||
const COPY: Record<ToolApprovalStatus, string> = {
|
||||
pending: "待授权",
|
||||
approving: "提交中",
|
||||
approved: "已批准",
|
||||
denied: "已拒绝",
|
||||
rejected: "服务端拒绝",
|
||||
running: "执行中",
|
||||
complete: "已完成",
|
||||
error: "失败",
|
||||
};
|
||||
|
||||
const BADGE: Record<ToolApprovalStatus, string> = {
|
||||
pending: "border-warning/30 bg-warning/10 text-warning",
|
||||
approving: "border-border bg-foreground/[0.04] text-muted-foreground",
|
||||
running: "border-border bg-foreground/[0.04] text-muted-foreground",
|
||||
approved: "border-success/30 bg-success/10 text-success",
|
||||
complete: "border-success/30 bg-success/10 text-success",
|
||||
denied: "border-destructive/30 bg-destructive/10 text-destructive",
|
||||
rejected: "border-destructive/40 bg-destructive/15 text-destructive",
|
||||
error: "border-destructive/30 bg-destructive/10 text-destructive",
|
||||
};
|
||||
|
||||
export interface ToolApprovalProps {
|
||||
tool: ReactNode;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
parameters?: ToolApprovalParameter[];
|
||||
status?: ToolApprovalStatus;
|
||||
badges?: ReactNode;
|
||||
/** rejected 态:拒绝原因(服务端给出) */
|
||||
reason?: ReactNode;
|
||||
/** rejected 态:为什么会被拒绝、怎样才能放行 */
|
||||
explanation?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
onApprove?: () => void;
|
||||
onDeny?: () => void;
|
||||
/** 为什么按钮不可用(如非管理员会话) */
|
||||
actionsDisabledReason?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ToolApproval({
|
||||
tool,
|
||||
title = "允许执行这个工具?",
|
||||
description,
|
||||
parameters = [],
|
||||
status = "pending",
|
||||
badges,
|
||||
reason,
|
||||
explanation,
|
||||
defaultOpen = false,
|
||||
onApprove,
|
||||
onDeny,
|
||||
actionsDisabledReason,
|
||||
className,
|
||||
}: ToolApprovalProps) {
|
||||
const id = useId();
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const busy = status === "approving" || status === "running";
|
||||
const rejected = status === "rejected";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-state={status}
|
||||
aria-busy={busy}
|
||||
role={rejected ? "alert" : undefined}
|
||||
className={cn(
|
||||
"w-full min-w-0 overflow-hidden rounded-xl border text-sm",
|
||||
rejected ? "border-destructive/50 bg-destructive/10" : "border-border/70 bg-foreground/[0.02]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg border",
|
||||
rejected
|
||||
? "border-destructive/40 bg-destructive/15 text-destructive"
|
||||
: "border-border/60 text-muted-foreground",
|
||||
status === "error" && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{busy ? (
|
||||
<LoaderCircle className="size-4 animate-spin motion-reduce:animate-none" />
|
||||
) : rejected ? (
|
||||
<ShieldAlert className="size-4" />
|
||||
) : status === "error" ? (
|
||||
<CircleAlert className="size-4" />
|
||||
) : status === "denied" ? (
|
||||
<X className="size-4" />
|
||||
) : status === "approved" || status === "complete" ? (
|
||||
<Check className="size-4" />
|
||||
) : (
|
||||
<ShieldCheck className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-start justify-between gap-x-3 gap-y-1">
|
||||
<div className="min-w-0">
|
||||
<div className={cn("font-medium", rejected ? "text-destructive" : "text-foreground")}>{title}</div>
|
||||
<div className="mt-0.5 font-mono text-meta break-all text-muted-foreground">{tool}</div>
|
||||
</div>
|
||||
<span className={cn("shrink-0 rounded-full border px-2 py-0.5 text-label font-medium", BADGE[status])}>
|
||||
{COPY[status]}
|
||||
</span>
|
||||
</div>
|
||||
{badges ? <div className="mt-1.5 flex flex-wrap items-center gap-1">{badges}</div> : null}
|
||||
{rejected && reason ? (
|
||||
<p className="mt-2 text-caption font-medium break-words text-destructive">原因:{reason}</p>
|
||||
) : null}
|
||||
{description ? <p className="mt-1.5 text-caption leading-5 text-muted-foreground">{description}</p> : null}
|
||||
{rejected && explanation ? (
|
||||
<p className="mt-1.5 text-meta leading-5 text-muted-foreground">{explanation}</p>
|
||||
) : null}
|
||||
{parameters.length ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="mt-2 inline-flex cursor-pointer items-center gap-1 rounded-md text-meta font-medium text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{open ? "收起详情" : "查看详情"}
|
||||
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{parameters.length ? (
|
||||
<Disclosure id={id} open={open}>
|
||||
<dl className="mx-3 mb-3 grid gap-1.5 rounded-lg border border-border/50 p-2.5">
|
||||
{parameters.map((p) => (
|
||||
<div key={p.id} className="grid grid-cols-[minmax(0,7rem)_minmax(0,1fr)] items-start gap-3 text-meta">
|
||||
<dt className="text-muted-foreground">{p.label}</dt>
|
||||
<dd className="min-w-0 font-mono break-all text-foreground/85">{p.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</Disclosure>
|
||||
) : null}
|
||||
{status === "pending" && (onApprove || onDeny) ? (
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-border/60 px-3 py-2.5">
|
||||
<Button size="sm" onClick={onApprove} disabled={!!actionsDisabledReason || !onApprove}>
|
||||
批准
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onDeny} disabled={!!actionsDisabledReason || !onDeny}>
|
||||
拒绝
|
||||
</Button>
|
||||
{actionsDisabledReason ? (
|
||||
<span className="text-meta text-muted-foreground">{actionsDisabledReason}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/tool-result.tsx
|
||||
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
|
||||
// 改造:状态色 blue/emerald/rose → 中性/success/destructive(并配文字);ActionSwapRollText 滚字去掉;
|
||||
// 折叠用 Disclosure;新增 `badges` 槽(scope/风险/幂等等审计徽标)与 `summary`(收起时的一行结果);
|
||||
// 默认不在完成后自动展开历史卡片(回放时 defaultOpen=false),运行中的卡片展开、完成后按 collapseOnComplete 收起。
|
||||
import { Ban, Braces, ChevronDown, CircleCheck, CircleX, LoaderCircle, SquareTerminal, Wrench } from "lucide-react";
|
||||
import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { CodeBlock, prettyMaybeJson } from "./code-block.tsx";
|
||||
import { CHEVRON_CLASS, Disclosure } from "./disclosure.tsx";
|
||||
|
||||
export type ToolResultStatus = "running" | "success" | "error" | "cancelled";
|
||||
export type ToolResultKind = "terminal" | "request" | "custom";
|
||||
|
||||
const STATUS: Record<ToolResultStatus, { label: string; cls: string }> = {
|
||||
running: { label: "执行中", cls: "text-muted-foreground" },
|
||||
success: { label: "成功", cls: "text-success" },
|
||||
error: { label: "失败", cls: "text-destructive" },
|
||||
cancelled: { label: "已取消", cls: "text-muted-foreground" },
|
||||
};
|
||||
|
||||
function StatusIcon({ status }: { status: ToolResultStatus }) {
|
||||
if (status === "running") return <LoaderCircle className="size-3 animate-spin motion-reduce:animate-none" />;
|
||||
if (status === "success") return <CircleCheck className="size-3" />;
|
||||
if (status === "error") return <CircleX className="size-3" />;
|
||||
return <Ban className="size-3" />;
|
||||
}
|
||||
|
||||
function KindIcon({ kind }: { kind: ToolResultKind }) {
|
||||
if (kind === "terminal") return <SquareTerminal className="size-4" />;
|
||||
if (kind === "request") return <Braces className="size-4" />;
|
||||
return <Wrench className="size-4" />;
|
||||
}
|
||||
|
||||
export interface ToolResultProps {
|
||||
/** 能力/工具 ID(等宽显示) */
|
||||
tool: ReactNode;
|
||||
title: ReactNode;
|
||||
children?: ReactNode;
|
||||
status?: ToolResultStatus;
|
||||
kind?: ToolResultKind;
|
||||
/** 耗时等 */
|
||||
meta?: ReactNode;
|
||||
/** 标题行下方的徽标 */
|
||||
badges?: ReactNode;
|
||||
/** 收起时显示的一行摘要 */
|
||||
summary?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
collapseOnComplete?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ToolResult({
|
||||
tool,
|
||||
title,
|
||||
children,
|
||||
status = "running",
|
||||
kind = "custom",
|
||||
meta,
|
||||
badges,
|
||||
summary,
|
||||
icon,
|
||||
defaultOpen = status === "running",
|
||||
collapseOnComplete = true,
|
||||
className,
|
||||
}: ToolResultProps) {
|
||||
const id = useId();
|
||||
const [open, setOpenState] = useState(defaultOpen);
|
||||
const prev = useRef(status);
|
||||
const setOpen = useCallback((v: boolean) => setOpenState(v), []);
|
||||
useEffect(() => {
|
||||
if (prev.current !== "running" && status === "running") setOpen(true);
|
||||
if (prev.current === "running" && status !== "running" && collapseOnComplete) setOpen(false);
|
||||
prev.current = status;
|
||||
}, [collapseOnComplete, setOpen, status]);
|
||||
const st = STATUS[status];
|
||||
|
||||
return (
|
||||
<div
|
||||
data-state={status}
|
||||
aria-busy={status === "running"}
|
||||
className={cn("w-full min-w-0 rounded-xl border border-border/70 text-sm", className)}
|
||||
>
|
||||
<button
|
||||
id={`${id}-t`}
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={`${id}-c`}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="group flex min-h-10 w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl px-3 py-1.5 text-left outline-none transition-colors duration-micro hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span aria-hidden className="grid size-4 shrink-0 place-items-center text-muted-foreground">
|
||||
{icon ?? <KindIcon kind={kind} />}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="min-w-0 truncate text-caption font-medium text-foreground/90">{title}</span>
|
||||
<span className="min-w-0 truncate font-mono text-label text-muted-foreground">{tool}</span>
|
||||
{meta ? <span className="shrink-0 text-label text-muted-foreground tabular-nums">{meta}</span> : null}
|
||||
</span>
|
||||
<span className={cn("inline-flex shrink-0 items-center gap-1 text-label font-medium", st.cls)}>
|
||||
<StatusIcon status={status} />
|
||||
{st.label}
|
||||
</span>
|
||||
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
|
||||
</button>
|
||||
{badges ? <div className="flex flex-wrap items-center gap-1 px-3 pb-2 pl-9">{badges}</div> : null}
|
||||
{!open && summary ? (
|
||||
<p className="-mt-1 truncate px-3 pb-2 pl-9 text-meta text-muted-foreground">{summary}</p>
|
||||
) : null}
|
||||
<Disclosure id={`${id}-c`} role="region" aria-labelledby={`${id}-t`} open={open}>
|
||||
<div className="space-y-2 border-t border-border/60 px-3 py-2.5">{children}</div>
|
||||
</Disclosure>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 工具输入/输出块:JSON 自动缩进,长内容块内滚动 */
|
||||
export function ToolResultOutput({
|
||||
label,
|
||||
children,
|
||||
tone,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
children: string;
|
||||
tone?: "destructive";
|
||||
}) {
|
||||
const { text, language } = prettyMaybeJson(children);
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 text-meta text-muted-foreground">{label}</p>
|
||||
<CodeBlock code={text} language={language} bare tone={tone === "destructive" ? "destructive" : "default"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Adapted from agentui (https://github.com/ashish200729/agentui) — replaces `useReducedMotion` from motion/react.
|
||||
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
|
||||
// 改造:不引 motion 库,直接读 prefers-reduced-motion;样式层的减弱动效由 motion-reduce: 变体负责,这里只给 JS 滚动用。
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const QUERY = "(prefers-reduced-motion: reduce)";
|
||||
|
||||
function subscribe(cb: () => void) {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return () => {};
|
||||
const mq = window.matchMedia(QUERY);
|
||||
mq.addEventListener("change", cb);
|
||||
return () => mq.removeEventListener("change", cb);
|
||||
}
|
||||
|
||||
export function useReducedMotion(): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => (typeof window !== "undefined" && window.matchMedia ? window.matchMedia(QUERY).matches : false),
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user