mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-01 14:11:41 +08:00
feat(agent): improve diagnostic log viewer
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Button, Segmented } from "antd";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, Segmented, Tooltip } from "antd";
|
||||
import copyToClipboard from "copy-to-clipboard";
|
||||
import { Copy, Trash2 } from "lucide-react";
|
||||
import { CheckCircle2, ChevronDown, CircleAlert, CircleDot, Copy, Trash2, TriangleAlert } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import type { AgentEventLog } from "@/stores/use-agent-store";
|
||||
import { formatLogJson, formatLogText, type AgentLogContext } from "./agent-event-formatters";
|
||||
import { AgentScrollToBottom } from "./agent-scroll-to-bottom";
|
||||
|
||||
type LogFilter = "all" | "error" | "warning" | "info";
|
||||
type DisplayLog = AgentEventLog & { count: number; detail: string; displayText: string; level: Exclude<LogFilter, "all">; signature: string; success: boolean };
|
||||
const SCROLL_BOTTOM_THRESHOLD = 48;
|
||||
|
||||
export function AgentLogView({
|
||||
logs,
|
||||
@@ -23,9 +28,71 @@ export function AgentLogView({
|
||||
onCopyBlocked: (text: string) => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState<"text" | "json">("text");
|
||||
const [filter, setFilter] = useState<LogFilter>("all");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const followLogsRef = useRef(true);
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||
const [newLogCount, setNewLogCount] = useState(0);
|
||||
const content = mode === "text" ? formatLogText(logs, context) : formatLogJson(logs, context);
|
||||
const lastError = [...logs].reverse().find((item) => /错误|失败|error/i.test(`${item.title}\n${item.text}`));
|
||||
const displayLogs = useMemo(() => prepareLogs(logs), [logs]);
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
all: displayLogs.reduce((sum, item) => sum + item.count, 0),
|
||||
error: displayLogs.filter((item) => item.level === "error").reduce((sum, item) => sum + item.count, 0),
|
||||
warning: displayLogs.filter((item) => item.level === "warning").reduce((sum, item) => sum + item.count, 0),
|
||||
info: displayLogs.filter((item) => item.level === "info").reduce((sum, item) => sum + item.count, 0),
|
||||
}),
|
||||
[displayLogs],
|
||||
);
|
||||
const visibleLogs = filter === "all" ? displayLogs : displayLogs.filter((item) => item.level === filter);
|
||||
const visibleLogCount = visibleLogs.reduce((sum, item) => sum + item.count, 0);
|
||||
const previousVisibleCountRef = useRef(visibleLogCount);
|
||||
const lastError = [...logs].reverse().find((item) => logLevel(item) === "error");
|
||||
const updateScrollState = useCallback(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight <= SCROLL_BOTTOM_THRESHOLD;
|
||||
followLogsRef.current = atBottom;
|
||||
setShowScrollToBottom(!atBottom);
|
||||
if (atBottom) setNewLogCount(0);
|
||||
}, []);
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
followLogsRef.current = true;
|
||||
list.scrollTo({ top: list.scrollHeight, behavior });
|
||||
setShowScrollToBottom(false);
|
||||
setNewLogCount(0);
|
||||
}, []);
|
||||
const handleLastLogToggle = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
requestAnimationFrame(updateScrollState);
|
||||
return;
|
||||
}
|
||||
if (!followLogsRef.current) return;
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => scrollToBottom("auto")));
|
||||
}, [scrollToBottom, updateScrollState]);
|
||||
useEffect(() => {
|
||||
if (mode !== "text") return;
|
||||
const frame = requestAnimationFrame(() => scrollToBottom("auto"));
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [filter, mode, scrollToBottom]);
|
||||
useEffect(() => {
|
||||
const previousCount = previousVisibleCountRef.current;
|
||||
const addedCount = Math.max(0, visibleLogCount - previousCount);
|
||||
previousVisibleCountRef.current = visibleLogCount;
|
||||
if (mode !== "text") return;
|
||||
if (visibleLogCount < previousCount) setNewLogCount(0);
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (followLogsRef.current) scrollToBottom("auto");
|
||||
else {
|
||||
if (addedCount) setNewLogCount((count) => count + addedCount);
|
||||
updateScrollState();
|
||||
}
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [mode, scrollToBottom, updateScrollState, visibleLogCount]);
|
||||
const copy = async (value = content, tip = "日志已复制") => {
|
||||
if (await copyToClipboard(value)) {
|
||||
onCopied(tip);
|
||||
@@ -33,15 +100,14 @@ export function AgentLogView({
|
||||
}
|
||||
textareaRef.current?.focus();
|
||||
textareaRef.current?.select();
|
||||
onCopyBlocked("已选中日志,请手动复制");
|
||||
onCopyBlocked(mode === "json" ? "已选中日志,请手动复制" : "复制失败,请切换到原始 JSON 后手动复制");
|
||||
};
|
||||
const connectionLabel = context.connected ? "在线" : context.enabled ? "连接中" : "未启用";
|
||||
return (
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<div className="flex min-h-full flex-col gap-3">
|
||||
<div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden px-4 py-3">
|
||||
<div className="flex h-full min-h-0 flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-base font-semibold leading-6">运行日志</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Segmented
|
||||
size="small"
|
||||
value={mode}
|
||||
@@ -51,31 +117,312 @@ export function AgentLogView({
|
||||
{ label: "原始 JSON", value: "json" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs" style={{ color: theme.node.muted }}>
|
||||
{logs.length} 条
|
||||
</span>
|
||||
<Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void copy()}>
|
||||
复制
|
||||
</Button>
|
||||
<Button size="small" disabled={!lastError} onClick={() => lastError && void copy(formatLogText([lastError], context), "最近错误已复制")}>
|
||||
最近错误
|
||||
</Button>
|
||||
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} disabled={!logs.length} onClick={onClear}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-y py-2.5" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className={`mt-1.5 size-2 shrink-0 rounded-full ${context.connected ? "bg-emerald-500" : context.enabled ? "bg-amber-500" : "bg-current opacity-30"}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<span className="font-medium">{connectionLabel}</span>
|
||||
<span className="truncate" style={{ color: theme.node.muted }}>
|
||||
{context.activity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate font-mono text-[11px] leading-4" style={{ color: theme.node.faint }} title={context.endpoint}>
|
||||
{context.endpoint}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-[11px] leading-4" style={{ color: theme.node.muted }}>
|
||||
<div>{context.messages} 条消息</div>
|
||||
<div>{context.pendingTool ? `工具:${context.pendingTool}` : "无待处理工具"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
readOnly
|
||||
value={content}
|
||||
className="thin-scrollbar min-h-[360px] flex-1 resize-none rounded-lg border bg-transparent p-3 font-mono text-xs leading-5 outline-none"
|
||||
style={{ borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
|
||||
{mode === "text" ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Segmented
|
||||
size="small"
|
||||
value={filter}
|
||||
onChange={(value) => setFilter(value as LogFilter)}
|
||||
options={[
|
||||
{ label: `全部 ${counts.all}`, value: "all" },
|
||||
{ label: `错误 ${counts.error}`, value: "error" },
|
||||
{ label: `警告 ${counts.warning}`, value: "warning" },
|
||||
{ label: `信息 ${counts.info}`, value: "info" },
|
||||
]}
|
||||
/>
|
||||
<LogActions logs={logs} lastError={lastError} onClear={onClear} onCopy={(value, tip) => void copy(value, tip)} context={context} />
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div ref={listRef} tabIndex={0} aria-label="排查日志列表" className="thin-scrollbar h-full overflow-y-auto border-y focus-visible:outline-none" style={{ borderColor: theme.node.stroke }} onScroll={updateScrollState}>
|
||||
{visibleLogs.map((item, index) => (
|
||||
<LogRow key={item.id} item={item} theme={theme} onToggle={index === visibleLogs.length - 1 ? handleLastLogToggle : undefined} />
|
||||
))}
|
||||
{!visibleLogs.length ? (
|
||||
<div className="px-3 py-10 text-center text-sm" style={{ color: theme.node.muted }}>
|
||||
{logs.length ? "当前筛选下没有日志" : "暂无事件日志"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{showScrollToBottom ? (
|
||||
<AgentScrollToBottom
|
||||
theme={theme}
|
||||
title={newLogCount ? `${newLogCount} 条新日志` : "查看最新日志"}
|
||||
ariaLabel={newLogCount ? `${newLogCount} 条新日志,查看最新日志` : "查看最新日志"}
|
||||
className="!bottom-52"
|
||||
onClick={() => scrollToBottom()}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs" style={{ color: theme.node.muted }}>
|
||||
完整诊断数据 · {logs.length} 条
|
||||
</span>
|
||||
<LogActions logs={logs} lastError={lastError} onClear={onClear} onCopy={(value, tip) => void copy(value, tip)} context={context} />
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
readOnly
|
||||
value={content}
|
||||
className="thin-scrollbar min-h-0 flex-1 resize-none rounded-md border bg-transparent p-3 font-mono text-xs leading-5 outline-none"
|
||||
style={{ borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogActions({ logs, lastError, context, onClear, onCopy }: { logs: AgentEventLog[]; lastError?: AgentEventLog; context: AgentLogContext; onClear: () => void; onCopy: (value?: string, tip?: string) => void }) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<Tooltip title="复制全部日志">
|
||||
<Button type="text" size="small" shape="circle" aria-label="复制全部日志" icon={<Copy className="size-3.5" />} onClick={() => onCopy()} />
|
||||
</Tooltip>
|
||||
<Tooltip title="复制最近错误">
|
||||
<Button type="text" size="small" shape="circle" aria-label="复制最近错误" disabled={!lastError} icon={<CircleAlert className="size-3.5" />} onClick={() => lastError && onCopy(formatLogText([lastError], context), "最近错误已复制")} />
|
||||
</Tooltip>
|
||||
<Tooltip title="清空日志">
|
||||
<Button danger type="text" size="small" shape="circle" aria-label="清空日志" disabled={!logs.length} icon={<Trash2 className="size-3.5" />} onClick={onClear} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogRow({ item, theme, onToggle }: { item: DisplayLog; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onToggle?: (open: boolean) => void }) {
|
||||
const tone = item.level === "error" ? "text-red-600 dark:text-red-400" : item.level === "warning" ? "text-amber-600 dark:text-amber-400" : item.success ? "text-emerald-600 dark:text-emerald-400" : "";
|
||||
const Icon = item.level === "error" ? CircleAlert : item.level === "warning" ? TriangleAlert : item.success ? CheckCircle2 : CircleDot;
|
||||
return (
|
||||
<details className="group border-b last:border-b-0" style={{ borderColor: theme.node.stroke }} onToggle={(event) => onToggle?.(event.currentTarget.open)}>
|
||||
<summary className="cursor-pointer list-none px-1 py-2.5 transition hover:bg-black/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-current/20 dark:hover:bg-white/10 [&::-webkit-details-marker]:hidden">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Icon className={`mt-0.5 size-4 shrink-0 ${tone}`} style={tone ? undefined : { color: theme.node.muted }} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 font-mono text-[10px] leading-5" style={{ color: theme.node.faint }}>
|
||||
{item.time}
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium leading-5">{item.title}</span>
|
||||
{item.count > 1 ? (
|
||||
<span className="shrink-0 text-[10px] leading-4" style={{ color: theme.node.muted }}>
|
||||
重复 {item.count} 次
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{item.displayText !== item.title ? (
|
||||
<div className="line-clamp-2 whitespace-pre-wrap break-words text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||
{item.displayText}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<ChevronDown className="mt-1 size-3.5 shrink-0 transition-transform group-open:rotate-180" style={{ color: theme.node.faint }} />
|
||||
</div>
|
||||
</summary>
|
||||
<div className="pb-3 pl-[34px] pr-2">
|
||||
<div className="mb-1 text-[10px] font-medium" style={{ color: theme.node.faint }}>
|
||||
详细信息
|
||||
</div>
|
||||
<pre className="thin-scrollbar max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-md border p-2.5 font-mono text-[11px] leading-5" style={{ borderColor: theme.node.stroke, background: theme.node.panel, color: theme.node.text }}>
|
||||
{item.detail}
|
||||
</pre>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function prepareLogs(logs: AgentEventLog[]) {
|
||||
return logs.flatMap(expandLog).reduce<DisplayLog[]>((result, item) => {
|
||||
const previous = result.at(-1);
|
||||
if (previous && previous.signature === item.signature) {
|
||||
previous.count += 1;
|
||||
previous.time = item.time;
|
||||
previous.detail = item.detail;
|
||||
return result;
|
||||
}
|
||||
result.push(item);
|
||||
return result;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function expandLog(item: AgentEventLog): DisplayLog[] {
|
||||
const entries = parseJsonEntries(item.raw ?? item.text);
|
||||
return (entries.length ? entries : [undefined]).map((entry, index) => {
|
||||
const displayText = compactLogText(entry === undefined ? item.text : logSummary(entry)) || item.title;
|
||||
const level = logLevel(item, entry);
|
||||
const title = entry === undefined ? item.title : logTitle(item.title, entry);
|
||||
return {
|
||||
...item,
|
||||
id: entries.length > 1 ? `${item.id}-${index}` : item.id,
|
||||
time: logTime(item.time, entry),
|
||||
title,
|
||||
text: displayText,
|
||||
raw: entry ?? item.raw,
|
||||
count: 1,
|
||||
detail: entry === undefined ? stripAnsi(safeString(item.raw ?? item.text)) || item.text : safeJson(entry),
|
||||
displayText,
|
||||
level,
|
||||
signature: `${level}\n${title}\n${logSignature(displayText)}`,
|
||||
success: level === "info" && /完成|成功|已连接|收到回复/.test(`${title}\n${displayText}`),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function logLevel(item: AgentEventLog, entry?: unknown): DisplayLog["level"] {
|
||||
const entries = entry === undefined ? parseJsonEntries(item.raw ?? item.text) : [entry];
|
||||
const declared = entries.map(declaredLogLevel).filter(Boolean);
|
||||
if (declared.includes("error")) return "error";
|
||||
if (declared.includes("warning")) return "warning";
|
||||
if (declared.includes("info")) return "info";
|
||||
const text = `${item.title}\n${item.text}\n${safeString(item.raw)}`;
|
||||
if (/错误|失败|异常|中断|断开|拒绝|\berror\b|\bfailed\b|\bfatal\b|exception/i.test(text)) return "error";
|
||||
if (/警告|重试|未找到|不可用|\bwarn(?:ing)?\b|deprecated|missing|unavailable/i.test(text)) return "warning";
|
||||
return "info";
|
||||
}
|
||||
|
||||
function declaredLogLevel(value: unknown): DisplayLog["level"] | "" {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
|
||||
const level = String((value as Record<string, unknown>).level || "").toLowerCase();
|
||||
if (["error", "fatal"].includes(level)) return "error";
|
||||
if (["warn", "warning"].includes(level)) return "warning";
|
||||
if (["info", "debug", "trace"].includes(level)) return "info";
|
||||
return "";
|
||||
}
|
||||
|
||||
function logTitle(fallback: string, value: unknown) {
|
||||
if (fallback !== "日志" || !value || typeof value !== "object" || Array.isArray(value)) return fallback;
|
||||
const target = String((value as Record<string, unknown>).target || "").toLowerCase();
|
||||
if (target.includes("skill")) return "技能加载";
|
||||
if (target.includes("plugin")) return "插件";
|
||||
if (target.includes("mcp") || target.includes("rmcp")) return "MCP";
|
||||
if (target.includes("shell")) return "终端";
|
||||
if (target.includes("state_db")) return "会话存储";
|
||||
return "Codex";
|
||||
}
|
||||
|
||||
function logTime(fallback: string, value: unknown) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return fallback;
|
||||
const timestamp = (value as Record<string, unknown>).timestamp;
|
||||
if (typeof timestamp !== "string") return fallback;
|
||||
const date = new Date(timestamp);
|
||||
return Number.isNaN(date.getTime()) ? fallback : date.toLocaleTimeString();
|
||||
}
|
||||
|
||||
function parseJsonEntries(value: unknown): unknown[] {
|
||||
if (value && typeof value === "object") return [value];
|
||||
if (typeof value !== "string") return [];
|
||||
const text = stripAnsi(value).trim();
|
||||
if (!text) return [];
|
||||
try {
|
||||
return [JSON.parse(text)];
|
||||
} catch {
|
||||
const entries: unknown[] = [];
|
||||
let start = -1;
|
||||
let depth = 0;
|
||||
let quoted = false;
|
||||
let escaped = false;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const character = text[index];
|
||||
if (start < 0) {
|
||||
if (character !== "{" && character !== "[") continue;
|
||||
start = index;
|
||||
depth = 1;
|
||||
continue;
|
||||
}
|
||||
if (quoted) {
|
||||
if (escaped) escaped = false;
|
||||
else if (character === "\\") escaped = true;
|
||||
else if (character === '"') quoted = false;
|
||||
continue;
|
||||
}
|
||||
if (character === '"') quoted = true;
|
||||
else if (character === "{" || character === "[") depth += 1;
|
||||
else if (character === "}" || character === "]") depth -= 1;
|
||||
if (depth !== 0) continue;
|
||||
try {
|
||||
entries.push(JSON.parse(text.slice(start, index + 1)));
|
||||
} catch {
|
||||
// Ignore non-JSON fragments and continue scanning the stderr chunk.
|
||||
}
|
||||
start = -1;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
function logSummary(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value)) return value.map(logSummary).filter(Boolean).slice(0, 3).join(" · ");
|
||||
if (!value || typeof value !== "object") return String(value ?? "");
|
||||
const record = value as Record<string, unknown>;
|
||||
const fields = record.fields && typeof record.fields === "object" && !Array.isArray(record.fields) ? (record.fields as Record<string, unknown>) : null;
|
||||
if (fields) {
|
||||
for (const key of ["message", "msg", "reason", "summary", "text", "error"]) {
|
||||
if (fields[key] === undefined) continue;
|
||||
const summary = logSummary(fields[key]);
|
||||
if (summary) return summary;
|
||||
}
|
||||
}
|
||||
for (const key of ["message", "msg", "reason", "summary", "text", "error"]) {
|
||||
if (record[key] === undefined) continue;
|
||||
const summary = logSummary(record[key]);
|
||||
if (summary) return summary;
|
||||
}
|
||||
return [record.method, record.type, record.tool, record.path, record.url].filter((item) => typeof item === "string" && item).join(" · ") || safeJson(value);
|
||||
}
|
||||
|
||||
function compactLogText(value: string) {
|
||||
return stripAnsi(value).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function logSignature(value: string) {
|
||||
return value
|
||||
.replace(/[a-z]:\\[^\r\n]*?skill\.md/gi, "<SKILL.md>")
|
||||
.replace(/file:\/\/\/[^\s"']+/gi, "<路径>")
|
||||
.replace(/[0-9a-f]{8}-[0-9a-f-]{27,}/gi, "<ID>");
|
||||
}
|
||||
|
||||
function stripAnsi(value: string) {
|
||||
return value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "");
|
||||
}
|
||||
|
||||
function safeString(value: unknown) {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === undefined || value === null) return "";
|
||||
return safeJson(value);
|
||||
}
|
||||
|
||||
function safeJson(value: unknown) {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Button, Tooltip } from "antd";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
|
||||
export function AgentScrollToBottom({
|
||||
theme,
|
||||
title,
|
||||
ariaLabel = title,
|
||||
className = "",
|
||||
onClick,
|
||||
}: {
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
title: string;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip title={title} placement="top">
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
aria-label={ariaLabel}
|
||||
className={`!absolute bottom-6 left-1/2 z-10 !h-8 !w-8 !min-w-8 -translate-x-1/2 backdrop-blur transition hover:-translate-y-0.5 ${className}`}
|
||||
style={{ background: theme.toolbar.panel, border: `1px solid ${theme.node.stroke}`, color: theme.node.text }}
|
||||
icon={<ChevronDown className="size-4" />}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user