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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user