426 lines
12 KiB
TypeScript
426 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import type {
|
||
MessageState,
|
||
ThreadAssistantMessagePart,
|
||
ToolCallMessagePart,
|
||
ToolCallMessagePartStatus,
|
||
} from "@assistant-ui/react";
|
||
import { useAuiState } from "@assistant-ui/react";
|
||
import {
|
||
BrainIcon,
|
||
CheckCircle2Icon,
|
||
ChevronDownIcon,
|
||
ClockIcon,
|
||
FileTextIcon,
|
||
PanelRightCloseIcon,
|
||
WrenchIcon,
|
||
XCircleIcon,
|
||
} from "lucide-react";
|
||
import {
|
||
createContext,
|
||
type ReactNode,
|
||
useCallback,
|
||
useContext,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Collapsible,
|
||
CollapsibleContent,
|
||
CollapsibleTrigger,
|
||
} from "@/components/ui/collapsible";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
type ActivityContextValue = {
|
||
open: boolean;
|
||
selectedId: string | null;
|
||
openItem: (id: string) => void;
|
||
close: () => void;
|
||
};
|
||
|
||
const ActivityContext = createContext<ActivityContextValue | null>(null);
|
||
|
||
export function ActivityProvider({ children }: { children: ReactNode }) {
|
||
const [open, setOpen] = useState(false);
|
||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||
const openItem = useCallback((id: string) => {
|
||
setSelectedId(id);
|
||
setOpen(true);
|
||
}, []);
|
||
const close = useCallback(() => setOpen(false), []);
|
||
const value = useMemo(
|
||
() => ({
|
||
open,
|
||
selectedId,
|
||
openItem,
|
||
close,
|
||
}),
|
||
[open, selectedId, openItem, close],
|
||
);
|
||
|
||
return (
|
||
<ActivityContext.Provider value={value}>
|
||
{children}
|
||
</ActivityContext.Provider>
|
||
);
|
||
}
|
||
|
||
export function useActivityPanel() {
|
||
const value = useContext(ActivityContext);
|
||
if (!value) {
|
||
throw new Error("useActivityPanel must be used within ActivityProvider");
|
||
}
|
||
return value;
|
||
}
|
||
|
||
type ActivityItem = {
|
||
id: string;
|
||
kind: "reasoning" | "tool";
|
||
title: string;
|
||
summary: string;
|
||
status: ToolCallMessagePartStatus["type"];
|
||
argsText?: string;
|
||
result?: unknown;
|
||
resultSummary?: string;
|
||
rawResult?: string;
|
||
fileLinks?: string[];
|
||
};
|
||
|
||
export function ActivityPanel() {
|
||
const { open, selectedId, openItem, close } = useActivityPanel();
|
||
const messages = useAuiState((s) => s.thread.messages);
|
||
const items = useMemo(() => collectActivityItems(messages), [messages]);
|
||
const selectedMessageId = selectedId ? activityMessageId(selectedId) : null;
|
||
const visibleItems = selectedMessageId
|
||
? items.filter((item) => activityMessageId(item.id) === selectedMessageId)
|
||
: items;
|
||
const prevCountRef = useRef(0);
|
||
|
||
useEffect(() => {
|
||
if (items.length > prevCountRef.current) {
|
||
openItem(items.at(-1)?.id ?? items[0]?.id ?? "");
|
||
}
|
||
prevCountRef.current = items.length;
|
||
}, [items, openItem]);
|
||
|
||
if (!open) return null;
|
||
|
||
return (
|
||
<aside className="fixed inset-y-0 right-0 z-30 flex w-[min(23rem,calc(100vw-1rem))] flex-col border-l bg-background shadow-xl lg:static lg:z-auto lg:h-full lg:w-92 lg:shrink-0 lg:shadow-none">
|
||
<header className="flex h-16 shrink-0 items-center justify-between border-b px-4">
|
||
<div className="min-w-0">
|
||
<h2 className="font-semibold text-base">活动</h2>
|
||
<p className="text-muted-foreground text-xs">
|
||
{visibleItems.length > 0
|
||
? `${visibleItems.length} 个步骤`
|
||
: "暂无链路"}
|
||
</p>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="size-8"
|
||
onClick={close}
|
||
aria-label="关闭活动面板"
|
||
>
|
||
<PanelRightCloseIcon className="size-4" />
|
||
</Button>
|
||
</header>
|
||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||
{visibleItems.length === 0 ? (
|
||
<p className="text-muted-foreground text-sm">
|
||
当前这轮对话还没有工具链路。
|
||
</p>
|
||
) : (
|
||
<div className="flex flex-col gap-2">
|
||
{visibleItems.map((item) => (
|
||
<ActivityPanelItem
|
||
key={item.id}
|
||
item={item}
|
||
open={selectedId === item.id}
|
||
onOpenChange={(nextOpen) => {
|
||
if (nextOpen) openItem(item.id);
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function activityMessageId(activityId: string) {
|
||
return activityId.split(":", 1)[0] ?? activityId;
|
||
}
|
||
|
||
function ActivityPanelItem({
|
||
item,
|
||
open,
|
||
onOpenChange,
|
||
}: {
|
||
item: ActivityItem;
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
}) {
|
||
const Icon = item.kind === "tool" ? WrenchIcon : BrainIcon;
|
||
|
||
return (
|
||
<Collapsible
|
||
open={open}
|
||
onOpenChange={onOpenChange}
|
||
className="rounded-lg border bg-card text-card-foreground"
|
||
>
|
||
<CollapsibleTrigger className="flex w-full items-start gap-3 px-3 py-3 text-left">
|
||
<ActivityStatusIcon status={item.status} />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||
<span className="truncate font-medium text-sm">{item.title}</span>
|
||
</div>
|
||
<p className="mt-1 line-clamp-2 text-muted-foreground text-xs">
|
||
{item.summary}
|
||
</p>
|
||
</div>
|
||
<ChevronDownIcon
|
||
className={cn(
|
||
"mt-0.5 size-4 shrink-0 text-muted-foreground transition-transform",
|
||
!open && "-rotate-90",
|
||
)}
|
||
/>
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent>
|
||
<div className="flex flex-col gap-3 border-t px-3 py-3 text-xs">
|
||
{item.argsText ? (
|
||
<ActivityPre title="输入参数" value={item.argsText} />
|
||
) : null}
|
||
{item.resultSummary ? (
|
||
<ActivityResultSummary value={item.resultSummary} />
|
||
) : null}
|
||
{item.fileLinks?.length ? (
|
||
<ActivityFileLinks paths={item.fileLinks} />
|
||
) : null}
|
||
{item.rawResult ? (
|
||
<Collapsible>
|
||
<CollapsibleTrigger className="flex items-center gap-1 font-medium text-muted-foreground transition-colors hover:text-foreground">
|
||
<ChevronDownIcon className="size-3" />
|
||
原始结果
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent>
|
||
<ActivityPre title="" value={item.rawResult} className="mt-2" />
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
) : null}
|
||
{item.kind === "reasoning" ? (
|
||
<p className="text-muted-foreground">
|
||
这里展示的是思考状态摘要,不展示模型完整隐藏思考过程。
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
);
|
||
}
|
||
|
||
function ActivityStatusIcon({ status }: { status: ActivityItem["status"] }) {
|
||
if (status === "running") {
|
||
return <ClockIcon className="mt-0.5 size-4 shrink-0 animate-pulse" />;
|
||
}
|
||
if (status === "incomplete") {
|
||
return <XCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />;
|
||
}
|
||
return (
|
||
<CheckCircle2Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||
);
|
||
}
|
||
|
||
function ActivityPre({
|
||
title,
|
||
value,
|
||
className,
|
||
}: {
|
||
title: string;
|
||
value: string;
|
||
className?: string;
|
||
}) {
|
||
return (
|
||
<div className={className}>
|
||
{title ? (
|
||
<p className="mb-1 font-medium text-muted-foreground">{title}</p>
|
||
) : null}
|
||
<pre className="max-h-80 overflow-auto rounded-md bg-muted px-3 py-2 whitespace-pre-wrap">
|
||
{value}
|
||
</pre>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ActivityResultSummary({ value }: { value: string }) {
|
||
return (
|
||
<div className="rounded-md border bg-muted/30 px-3 py-2">
|
||
<div className="mb-1 flex items-center gap-1.5 font-medium text-muted-foreground">
|
||
<FileTextIcon className="size-3.5" />
|
||
结果摘要
|
||
</div>
|
||
<p className="whitespace-pre-wrap">{value}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function collectActivityItems(messages: readonly MessageState[]) {
|
||
const items: ActivityItem[] = [];
|
||
|
||
for (const message of messages) {
|
||
if (message.role !== "assistant") continue;
|
||
const parts = message.content as readonly ThreadAssistantMessagePart[];
|
||
for (const [index, part] of parts.entries()) {
|
||
const id = `${message.id}:${index}`;
|
||
if (part.type === "reasoning") {
|
||
const status = getPartStatus(message, part);
|
||
items.push({
|
||
id,
|
||
kind: "reasoning",
|
||
title: status === "running" ? "思考中" : "思考完成",
|
||
summary:
|
||
part.text.trim() ||
|
||
(status === "running"
|
||
? "模型正在整理下一步行动。"
|
||
: "思考并执行完成。"),
|
||
status,
|
||
});
|
||
}
|
||
if (part.type === "tool-call") {
|
||
items.push({
|
||
id,
|
||
kind: "tool",
|
||
title: part.toolName,
|
||
summary: summarizeTool(message, part),
|
||
status: getPartStatus(message, part),
|
||
argsText: part.argsText,
|
||
result: decodeJsonString(part.result),
|
||
resultSummary: summarizeToolResult(decodeJsonString(part.result)),
|
||
rawResult:
|
||
part.result === undefined
|
||
? undefined
|
||
: formatValue(decodeJsonString(part.result)),
|
||
fileLinks: extractLocalPaths(
|
||
[
|
||
part.argsText,
|
||
part.result === undefined
|
||
? undefined
|
||
: formatValue(decodeJsonString(part.result)),
|
||
].filter(Boolean) as string[],
|
||
),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
return items;
|
||
}
|
||
|
||
function ActivityFileLinks({ paths }: { paths: string[] }) {
|
||
return (
|
||
<div className="rounded-md border bg-muted/30 px-3 py-2">
|
||
<div className="mb-1 flex items-center gap-1.5 font-medium text-muted-foreground">
|
||
<FileTextIcon className="size-3.5" />
|
||
相关文件
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
{paths.map((filePath) => (
|
||
<a
|
||
key={filePath}
|
||
href={`/api/claw/files?path=${encodeURIComponent(filePath)}`}
|
||
className="truncate text-primary hover:underline"
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
title={filePath}
|
||
>
|
||
{basename(filePath)}
|
||
</a>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function getPartStatus(
|
||
message: Extract<MessageState, { role: "assistant" }>,
|
||
part: ThreadAssistantMessagePart,
|
||
): ToolCallMessagePartStatus["type"] {
|
||
if (message.status.type === "incomplete") return "incomplete";
|
||
if (part.type === "tool-call" && part.result === undefined) return "running";
|
||
if (message.status.type === "running") return "running";
|
||
return "complete";
|
||
}
|
||
|
||
function summarizeTool(
|
||
message: Extract<MessageState, { role: "assistant" }>,
|
||
part: ToolCallMessagePart,
|
||
) {
|
||
const status = getPartStatus(message, part);
|
||
if (status === "running") return "调用工具中";
|
||
if (status === "incomplete") return "工具调用未完成";
|
||
if (part.result === undefined) return "已提交工具参数";
|
||
return "工具调用完成";
|
||
}
|
||
|
||
function decodeJsonString(value: unknown): unknown {
|
||
if (typeof value !== "string") return value;
|
||
try {
|
||
return JSON.parse(value);
|
||
} catch {
|
||
return value;
|
||
}
|
||
}
|
||
|
||
function formatValue(value: unknown) {
|
||
const decoded = decodeJsonString(value);
|
||
return typeof decoded === "string"
|
||
? decoded
|
||
: JSON.stringify(decoded, null, 2);
|
||
}
|
||
|
||
function summarizeToolResult(value: unknown) {
|
||
const decoded = decodeJsonString(value);
|
||
if (decoded === undefined) return undefined;
|
||
if (typeof decoded === "string") return trimText(decoded, 600);
|
||
if (!decoded || typeof decoded !== "object") return String(decoded);
|
||
|
||
const record = decoded as Record<string, unknown>;
|
||
const parts: string[] = [];
|
||
if (typeof record.tool === "string") parts.push(`工具: ${record.tool}`);
|
||
if (typeof record.ok === "boolean")
|
||
parts.push(record.ok ? "状态: 成功" : "状态: 失败");
|
||
if (typeof record.error === "string") parts.push(`错误: ${record.error}`);
|
||
if (typeof record.content === "string")
|
||
parts.push(trimText(record.content, 600));
|
||
if (!parts.length) parts.push(trimText(JSON.stringify(record, null, 2), 600));
|
||
return parts.join("\n");
|
||
}
|
||
|
||
function trimText(text: string, maxLength: number) {
|
||
const normalized = text.trim();
|
||
if (normalized.length <= maxLength) return normalized;
|
||
return `${normalized.slice(0, maxLength)}...`;
|
||
}
|
||
|
||
function extractLocalPaths(values: string[]) {
|
||
const paths = new Set<string>();
|
||
for (const value of values) {
|
||
for (const match of value.matchAll(/\/Users\/[^\s"',)]+/g)) {
|
||
paths.add(match[0]);
|
||
}
|
||
}
|
||
return [...paths];
|
||
}
|
||
|
||
function basename(filePath: string) {
|
||
return filePath.split("/").filter(Boolean).at(-1) ?? filePath;
|
||
}
|