- 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]>
160 lines
5.3 KiB
TypeScript
160 lines
5.3 KiB
TypeScript
// 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>
|
|
);
|
|
}
|