Files
UHD_Media_Tool/apps/web/app/components/agentui/citations.tsx
T
rootandClaude Opus 5.5 8352e5e881 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]>
2026-09-27 23:08:17 +08:00

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>
);
}