fix most web_fetches from getting blocked using a real user agent
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { useMemo, useState } from "preact/hooks";
|
||||
import type { JSX } from "preact";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
||||
import { getMessageAttachments, type Message } from "@/lib/api";
|
||||
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
||||
import { Globe2, Link2, Wrench } from "lucide-preact";
|
||||
import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact";
|
||||
|
||||
type Props = {
|
||||
messages: Message[];
|
||||
@@ -72,6 +74,17 @@ function formatToolTimestamp(...values: Array<string | null | undefined>) {
|
||||
}
|
||||
|
||||
type ToolCallVisualState = "initiated" | "completed" | "failed";
|
||||
type MessageRenderItem = { kind: "message"; message: Message } | { kind: "tool_group"; key: string; messages: Message[] };
|
||||
type ToolStackStyle = JSX.CSSProperties & {
|
||||
"--tool-stack-x"?: string;
|
||||
"--tool-stack-y"?: string;
|
||||
"--tool-stack-z"?: string;
|
||||
"--tool-stack-scale"?: string;
|
||||
"--tool-stack-opacity"?: string;
|
||||
"--tool-stack-delay"?: string;
|
||||
};
|
||||
|
||||
const COLLAPSED_TOOL_STACK_LIMIT = 4;
|
||||
|
||||
function getToolVisualState(metadata: ToolLogMetadata): ToolCallVisualState {
|
||||
if (metadata.status === "failed") return "failed";
|
||||
@@ -89,61 +102,222 @@ function getToolDetailLabel(message: Message, metadata: ToolLogMetadata, state:
|
||||
.join(" • ");
|
||||
}
|
||||
|
||||
function buildMessageRenderItems(messages: Message[]) {
|
||||
const items: MessageRenderItem[] = [];
|
||||
let toolRun: Message[] = [];
|
||||
|
||||
const flushToolRun = () => {
|
||||
if (!toolRun.length) return;
|
||||
if (toolRun.length === 1) {
|
||||
items.push({ kind: "message", message: toolRun[0] });
|
||||
} else {
|
||||
items.push({ kind: "tool_group", key: toolRun[0].id, messages: toolRun });
|
||||
}
|
||||
toolRun = [];
|
||||
};
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === "tool" && asToolLogMetadata(message.metadata)) {
|
||||
toolRun.push(message);
|
||||
continue;
|
||||
}
|
||||
|
||||
flushToolRun();
|
||||
items.push({ kind: "message", message });
|
||||
}
|
||||
|
||||
flushToolRun();
|
||||
return items;
|
||||
}
|
||||
|
||||
function getToolStackStyle(depth: number, totalVisible: number): ToolStackStyle {
|
||||
return {
|
||||
"--tool-stack-x": `${depth * 9}px`,
|
||||
"--tool-stack-y": `${depth * 8}px`,
|
||||
"--tool-stack-z": `${depth * -36}px`,
|
||||
"--tool-stack-scale": `${Math.max(0.88, 1 - depth * 0.035)}`,
|
||||
"--tool-stack-opacity": `${Math.max(0.48, 1 - depth * 0.15)}`,
|
||||
"--tool-stack-delay": `${depth * 44}ms`,
|
||||
zIndex: totalVisible - depth,
|
||||
};
|
||||
}
|
||||
|
||||
function getExpandedToolStyle(index: number): ToolStackStyle {
|
||||
return {
|
||||
"--tool-stack-delay": `${Math.min(index, 6) * 34}ms`,
|
||||
};
|
||||
}
|
||||
|
||||
function ToolCallCard({
|
||||
message,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
message: Message;
|
||||
className?: string;
|
||||
style?: JSX.CSSProperties;
|
||||
}) {
|
||||
const toolLogMetadata = asToolLogMetadata(message.metadata);
|
||||
if (!toolLogMetadata) return null;
|
||||
|
||||
const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name);
|
||||
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench;
|
||||
const toolState = getToolVisualState(toolLogMetadata);
|
||||
const isFailed = toolState === "failed";
|
||||
const isInitiated = toolState === "initiated";
|
||||
const toolSummary = getToolSummary(message, toolLogMetadata);
|
||||
const toolLabel = getToolLabel(message, toolLogMetadata);
|
||||
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, toolState);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
|
||||
isFailed
|
||||
? "border-rose-400/34 bg-[linear-gradient(90deg,hsl(350_72%_44%_/_0.18),hsl(342_66%_9%_/_0.72))]"
|
||||
: isInitiated
|
||||
? "border-amber-300/34 bg-[linear-gradient(90deg,hsl(43_74%_30%_/_0.34),hsl(260_48%_13%_/_0.74))]"
|
||||
: "border-cyan-400/34 bg-[linear-gradient(90deg,hsl(184_89%_21%_/_0.70),hsl(208_66%_12%_/_0.78))]",
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
title={`${toolSummary}\n${toolLabel} • ${toolDetailLabel}`}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border",
|
||||
isFailed
|
||||
? "border-rose-400/34 bg-rose-400/13 text-rose-300"
|
||||
: isInitiated
|
||||
? "border-amber-300/34 bg-amber-300/13 text-amber-200"
|
||||
: "border-cyan-300/34 bg-cyan-300/13 text-cyan-300"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 space-y-1">
|
||||
<span className={cn("block truncate text-sm leading-5", isFailed ? "text-rose-200" : "text-violet-50/95")}>{toolSummary}</span>
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4">
|
||||
<span className={cn("min-w-0 truncate font-semibold", isFailed ? "text-rose-300/85" : isInitiated ? "text-amber-200/90" : "text-cyan-200/90")}>
|
||||
{toolLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-violet-200/64">{toolDetailLabel}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallStack({
|
||||
groupKey,
|
||||
messages,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
groupKey: string;
|
||||
messages: Message[];
|
||||
expanded: boolean;
|
||||
onToggle: (groupKey: string) => void;
|
||||
}) {
|
||||
const visibleStackMessages = messages.slice(-COLLAPSED_TOOL_STACK_LIMIT).reverse();
|
||||
const hiddenCount = Math.max(0, messages.length - visibleStackMessages.length);
|
||||
const countLabel = `${messages.length} tool ${messages.length === 1 ? "call" : "calls"}`;
|
||||
|
||||
if (expanded) {
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="relative flex w-full max-w-[85%] flex-col gap-2.5 pr-5">
|
||||
<button
|
||||
type="button"
|
||||
className="tool-call-stack-toggle absolute -right-3 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full"
|
||||
aria-expanded="true"
|
||||
aria-label={`Collapse ${countLabel}`}
|
||||
title={`Collapse ${countLabel}`}
|
||||
onClick={() => onToggle(groupKey)}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
{messages.map((message, index) => (
|
||||
<ToolCallCard
|
||||
key={message.id}
|
||||
message={message}
|
||||
className="tool-call-stack-expanded-card w-full max-w-full"
|
||||
style={getExpandedToolStyle(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="tool-call-stack-shell relative inline-grid w-full max-w-[85%] min-w-0 pb-6 pr-9">
|
||||
{visibleStackMessages.map((message, index) => (
|
||||
<ToolCallCard
|
||||
key={message.id}
|
||||
message={message}
|
||||
className={cn("tool-call-stack-card col-start-1 row-start-1 w-full max-w-full", index > 0 && "pointer-events-none")}
|
||||
style={getToolStackStyle(index, visibleStackMessages.length)}
|
||||
/>
|
||||
))}
|
||||
{hiddenCount ? (
|
||||
<span className="absolute bottom-1 right-10 z-20 rounded-full border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm">
|
||||
+{hiddenCount}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="tool-call-stack-toggle absolute -right-3 top-1/2 z-20 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full"
|
||||
aria-expanded="false"
|
||||
aria-label={`Expand ${countLabel}`}
|
||||
title={`Expand ${countLabel}`}
|
||||
onClick={() => onToggle(groupKey)}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
|
||||
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
||||
const [expandedToolGroups, setExpandedToolGroups] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const toggleToolGroup = (groupKey: string) => {
|
||||
setExpandedToolGroups((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(groupKey)) next.delete(groupKey);
|
||||
else next.add(groupKey);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{messages.map((message) => {
|
||||
{renderItems.map((item) => {
|
||||
if (item.kind === "tool_group") {
|
||||
return (
|
||||
<ToolCallStack
|
||||
key={`tool-group-${item.key}`}
|
||||
groupKey={item.key}
|
||||
messages={item.messages}
|
||||
expanded={expandedToolGroups.has(item.key)}
|
||||
onToggle={toggleToolGroup}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { message } = item;
|
||||
const toolLogMetadata = asToolLogMetadata(message.metadata);
|
||||
if (message.role === "tool" && toolLogMetadata) {
|
||||
const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name);
|
||||
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench;
|
||||
const toolState = getToolVisualState(toolLogMetadata);
|
||||
const isFailed = toolState === "failed";
|
||||
const isInitiated = toolState === "initiated";
|
||||
const toolSummary = getToolSummary(message, toolLogMetadata);
|
||||
const toolLabel = getToolLabel(message, toolLogMetadata);
|
||||
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, toolState);
|
||||
return (
|
||||
<div key={message.id} className="flex justify-start">
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex max-w-[85%] min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
|
||||
isFailed
|
||||
? "border-rose-400/34 bg-[linear-gradient(90deg,hsl(350_72%_44%_/_0.18),hsl(342_66%_9%_/_0.72))]"
|
||||
: isInitiated
|
||||
? "border-amber-300/34 bg-[linear-gradient(90deg,hsl(43_74%_30%_/_0.34),hsl(260_48%_13%_/_0.74))]"
|
||||
: "border-cyan-400/34 bg-[linear-gradient(90deg,hsl(184_89%_21%_/_0.70),hsl(208_66%_12%_/_0.78))]"
|
||||
)}
|
||||
title={`${toolSummary}\n${toolLabel} • ${toolDetailLabel}`}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border",
|
||||
isFailed
|
||||
? "border-rose-400/34 bg-rose-400/13 text-rose-300"
|
||||
: isInitiated
|
||||
? "border-amber-300/34 bg-amber-300/13 text-amber-200"
|
||||
: "border-cyan-300/34 bg-cyan-300/13 text-cyan-300"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 space-y-1">
|
||||
<span className={cn("block truncate text-sm leading-5", isFailed ? "text-rose-200" : "text-violet-50/95")}>
|
||||
{toolSummary}
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4">
|
||||
<span className={cn("min-w-0 truncate font-semibold", isFailed ? "text-rose-300/85" : isInitiated ? "text-amber-200/90" : "text-cyan-200/90")}>
|
||||
{toolLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-violet-200/64">{toolDetailLabel}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<ToolCallCard message={message} className="max-w-[85%]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user