Files
zk-data-agent/frontend/app/components/assistant-ui/activity-panel.tsx
T
2026-05-14 20:55:30 +08:00

1216 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import type {
MessageState,
ThreadAssistantMessagePart,
ToolCallMessagePart,
ToolCallMessagePartStatus,
} from "@assistant-ui/react";
import { useAuiState } from "@assistant-ui/react";
import {
BrainIcon,
CheckCircle2Icon,
ChevronDownIcon,
ClockIcon,
CloudUploadIcon,
DownloadIcon,
FileTextIcon,
Loader2Icon,
MessageSquareTextIcon,
PanelRightCloseIcon,
WrenchIcon,
XCircleIcon,
} from "lucide-react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
type ClawRunStatus,
fetchLatestRunStatus,
} from "@/components/assistant-ui/thread-list";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
ACTIVE_SESSION_CHANGED_EVENT,
readActiveSessionId,
writeActiveSessionId,
} from "@/lib/claw-active-session";
import { cn } from "@/lib/utils";
type ActivityContextValue = {
open: boolean;
mode: "activity" | "files";
selectedId: string | null;
sessionId: string | null;
filesRefreshToken: number;
openActivity: () => void;
openItem: (id: string) => void;
openFiles: (sessionId?: string | null) => void;
close: () => void;
};
const ActivityContext = createContext<ActivityContextValue | null>(null);
export function ActivityProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<"activity" | "files">("activity");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const [filesRefreshToken, setFilesRefreshToken] = useState(0);
const openActivity = useCallback(() => {
setMode("activity");
setSelectedId(null);
setOpen(true);
}, []);
const openItem = useCallback((id: string) => {
setMode("activity");
setSelectedId(id);
setOpen(true);
}, []);
const openFiles = useCallback((nextSessionId?: string | null) => {
setMode("files");
setSessionId(nextSessionId ?? readActiveSessionId());
setFilesRefreshToken((value) => value + 1);
setOpen(true);
}, []);
const close = useCallback(() => setOpen(false), []);
const value = useMemo(
() => ({
open,
mode,
selectedId,
sessionId,
filesRefreshToken,
openActivity,
openItem,
openFiles,
close,
}),
[
open,
mode,
selectedId,
sessionId,
filesRefreshToken,
openActivity,
openItem,
openFiles,
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[];
};
type LiveRunEvent = NonNullable<ClawRunStatus["events"]>[number];
export function ActivityPanel() {
const {
open,
mode,
selectedId,
sessionId,
filesRefreshToken,
openActivity,
openItem,
close,
} = useActivityPanel();
const messages = useAuiState((s) => s.thread.messages);
const [activeSessionId, setActiveSessionId] = useState<string | null>(() =>
readActiveSessionId(),
);
const liveRunStatus = useLiveRunStatus(activeSessionId, mode);
const messageItems = useMemo(
() => collectActivityItems(messages),
[messages],
);
const liveItems = useMemo(
() => collectLiveRunItems(liveRunStatus),
[liveRunStatus],
);
const selectedMessageId = selectedId ? activityMessageId(selectedId) : null;
const liveRunActive = hasActiveRun(liveRunStatus) && liveItems.length > 0;
const selectedMessageItems = selectedMessageId
? messageItems.filter(
(item) => activityMessageId(item.id) === selectedMessageId,
)
: [];
const shouldUseLiveItems = Boolean(activeSessionId && liveRunActive);
const items = shouldUseLiveItems ? liveItems : messageItems;
const selectedItems = selectedMessageId
? items.filter((item) => activityMessageId(item.id) === selectedMessageId)
: [];
const visibleItems = shouldUseLiveItems
? liveItems
: selectedMessageId && selectedMessageItems.length > 0
? selectedMessageItems
: selectedMessageId && selectedItems.length > 0
? selectedItems
: items;
const selectedActivityMissing = Boolean(
selectedMessageId &&
!shouldUseLiveItems &&
messageItems.length > 0 &&
selectedMessageItems.length === 0 &&
selectedItems.length === 0,
);
const prevLatestIdRef = useRef<string | null>(null);
useEffect(() => {
const update = () => setActiveSessionId(readActiveSessionId());
const handleChanged = (event: Event) => {
const detail = (event as CustomEvent<{ sessionId?: string | null }>)
.detail;
setActiveSessionId(detail?.sessionId ?? readActiveSessionId());
};
window.addEventListener(ACTIVE_SESSION_CHANGED_EVENT, handleChanged);
window.addEventListener("focus", update);
update();
return () => {
window.removeEventListener(ACTIVE_SESSION_CHANGED_EVENT, handleChanged);
window.removeEventListener("focus", update);
};
}, []);
useEffect(() => {
const latestId = items.at(-1)?.id ?? null;
if (
mode === "activity" &&
!selectedId &&
latestId &&
latestId !== prevLatestIdRef.current
) {
openActivity();
}
prevLatestIdRef.current = latestId;
}, [items, mode, openActivity, selectedId]);
if (!open) return null;
if (mode === "files") {
return (
<SessionFilesPanel
key={`${sessionId ?? "active"}:${filesRefreshToken}`}
sessionId={sessionId}
onClose={close}
/>
);
}
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">
{selectedActivityMissing ? (
<p className="rounded-md bg-muted/50 px-3 py-2 text-muted-foreground text-xs">
</p>
) : null}
{visibleItems.map((item) => (
<ActivityPanelItem
key={item.id}
item={item}
open={selectedId === item.id}
onOpenChange={(nextOpen) => {
if (nextOpen) openItem(item.id);
}}
/>
))}
</div>
)}
</div>
</aside>
);
}
type SessionFile = {
name: string;
path: string;
kind: "input" | "output";
source?: "local" | "jupyter";
size: number;
modified_at: string;
download_url: string;
online_doc_url?: string | null;
online_doc_title?: string | null;
online_doc_kind?: string | null;
online_doc_updated_at?: number | null;
};
type SessionFilesPayload = {
session_id?: string | null;
input?: SessionFile[];
output?: SessionFile[];
error?: string;
};
function SessionFilesPanel({
sessionId,
onClose,
}: {
sessionId: string | null;
onClose: () => void;
}) {
const [payload, setPayload] = useState<SessionFilesPayload | null>(null);
const [status, setStatus] = useState("正在读取聊天中的文件...");
useEffect(() => {
let cancelled = false;
async function loadFiles() {
const activeSessionId = normalizeSessionId(
sessionId ?? readActiveSessionId(),
);
setStatus("正在读取聊天中的文件...");
try {
let { response, payload: nextPayload } =
await fetchSessionFiles(activeSessionId);
if (
activeSessionId &&
response.ok &&
sessionFilesCount(nextPayload) === 0
) {
const latest = await fetchSessionFiles(null);
if (
latest.response.ok &&
latest.payload.session_id &&
latest.payload.session_id !== activeSessionId
) {
response = latest.response;
nextPayload = latest.payload;
}
}
if (cancelled) return;
if (!response.ok) {
setPayload({ input: [], output: [], error: nextPayload.error });
setStatus(nextPayload.error ?? "读取文件失败");
return;
}
if (nextPayload.session_id) {
writeActiveSessionId(nextPayload.session_id);
}
setPayload(nextPayload);
setStatus("");
} catch (err) {
if (!cancelled) {
setPayload({ input: [], output: [] });
setStatus(err instanceof Error ? err.message : "读取文件失败");
}
}
}
loadFiles();
const handleRefresh = () => {
loadFiles();
};
window.addEventListener("claw-sessions-changed", handleRefresh);
window.addEventListener("claw-session-files-changed", handleRefresh);
window.addEventListener("focus", handleRefresh);
return () => {
cancelled = true;
window.removeEventListener("claw-sessions-changed", handleRefresh);
window.removeEventListener("claw-session-files-changed", handleRefresh);
window.removeEventListener("focus", handleRefresh);
};
}, [sessionId]);
const inputFiles = payload?.input ?? [];
const outputFiles = payload?.output ?? [];
const total = inputFiles.length + outputFiles.length;
return (
<aside className="fixed inset-y-0 right-0 z-30 flex w-[min(25rem,calc(100vw-1rem))] flex-col border-l bg-background shadow-xl lg:static lg:z-auto lg:h-full lg:w-96 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">
{status || `${total} 个文件`}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={onClose}
aria-label="关闭文件面板"
>
<PanelRightCloseIcon className="size-4" />
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{!status && total === 0 ? (
<p className="text-muted-foreground text-sm">
</p>
) : null}
<FileSection title="输入" files={inputFiles} />
<FileSection title="输出" files={outputFiles} />
</div>
</aside>
);
}
async function fetchSessionFiles(sessionId: string | null) {
const url = new URL("/api/claw/files", window.location.origin);
if (sessionId) url.searchParams.set("session_id", sessionId);
const response = await fetch(url, { cache: "no-store" });
const payload = (await response.json()) as SessionFilesPayload;
return { response, payload };
}
function sessionFilesCount(payload: SessionFilesPayload) {
return (payload.input?.length ?? 0) + (payload.output?.length ?? 0);
}
function normalizeSessionId(value: unknown) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed || null;
}
function FileSection({
title,
files,
}: {
title: string;
files: SessionFile[];
}) {
return (
<section className="mb-5">
<div className="mb-2 flex items-center justify-between">
<h3 className="font-medium text-sm">{title}</h3>
<span className="text-muted-foreground text-xs">{files.length}</span>
</div>
{files.length ? (
<div className="flex flex-col gap-2">
{files.map((file) => (
<SessionFileRow key={`${file.kind}:${file.path}`} file={file} />
))}
</div>
) : (
<p className="rounded-lg border border-dashed px-3 py-4 text-center text-muted-foreground text-xs">
{title}
</p>
)}
</section>
);
}
function SessionFileRow({ file }: { file: SessionFile }) {
const [onlineDoc, setOnlineDoc] = useState<{
status: "idle" | "loading" | "auth" | "done" | "error";
message?: string;
url?: string | null;
kind?: string | null;
}>({
status: file.online_doc_url ? "done" : "idle",
message: file.online_doc_url
? `已转${onlineFileKindLabel(file.online_doc_kind, file.name)}`
: undefined,
url: file.online_doc_url ?? null,
kind: file.online_doc_kind ?? null,
});
const canCreateOnlineDoc = isOnlineDocSupported(file.name);
const onlineDocUrl = onlineDoc.url ?? file.online_doc_url ?? null;
async function createOnlineDoc() {
if (!canCreateOnlineDoc || onlineDoc.status === "loading") return;
let pendingWindow: Window | null = null;
try {
pendingWindow = window.open("about:blank", "_blank");
if (pendingWindow) {
pendingWindow.document.write("正在创建飞书在线文件...");
}
} catch {
pendingWindow = null;
}
setOnlineDoc({ status: "loading", message: "正在转换..." });
try {
const response = await fetch("/api/claw/files/online-doc", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ path: file.path, title: file.name }),
});
const payload = (await response.json().catch(() => ({}))) as Record<
string,
unknown
>;
if (response.status === 409) {
const loginResponse = await fetch("/api/claw/integrations/feishu", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action: "login" }),
});
const loginPayload = (await loginResponse
.json()
.catch(() => ({}))) as FeishuLoginPayload;
if (!loginResponse.ok) {
pendingWindow?.close();
setOnlineDoc({
status: "error",
message:
extractErrorMessage(
loginPayload as unknown as Record<string, unknown>,
) ?? "飞书授权启动失败",
});
return;
}
const loginUrl = loginPayload.login?.login_url ?? null;
if (loginUrl) {
if (pendingWindow) {
try {
pendingWindow.opener = null;
} catch {
// 有些浏览器不允许改 opener,不影响后续跳转。
}
pendingWindow.location.href = loginUrl;
} else {
window.open(loginUrl, "_blank", "noopener,noreferrer");
}
} else if (pendingWindow) {
pendingWindow.close();
}
setOnlineDoc({
status: "auth",
message: loginUrl ? "完成授权后再点一次" : "需要先完成飞书授权",
});
return;
}
if (!response.ok) {
pendingWindow?.close();
setOnlineDoc({
status: "error",
message: extractErrorMessage(payload) ?? "转换失败",
});
return;
}
const url = typeof payload.url === "string" ? payload.url : null;
const kind = typeof payload.kind === "string" ? payload.kind : null;
if (url) {
if (pendingWindow) {
try {
pendingWindow.opener = null;
} catch {
// 有些浏览器不允许改 opener,不影响后续跳转。
}
pendingWindow.location.href = url;
} else {
window.open(url, "_blank", "noopener,noreferrer");
}
} else {
pendingWindow?.close();
}
setOnlineDoc({
status: "done",
message: url
? `已生成${onlineFileKindLabel(kind, file.name)}`
: "已生成",
url,
kind,
});
window.dispatchEvent(new CustomEvent("claw-session-files-changed"));
} catch (error) {
pendingWindow?.close();
setOnlineDoc({
status: "error",
message: error instanceof Error ? error.message : "转换失败",
});
}
}
return (
<div className="flex items-center gap-3 rounded-lg border px-3 py-2">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<FileTextIcon className="size-4" />
</div>
<div className="min-w-0 flex-1">
{onlineDocUrl ? (
<a
href={onlineDocUrl}
target="_blank"
rel="noreferrer"
className="block truncate font-medium text-primary text-sm underline-offset-4 hover:underline"
title={`${file.name} · 打开${onlineFileKindLabel(onlineDoc.kind ?? file.online_doc_kind, file.name)}`}
>
{file.name}
</a>
) : (
<div className="truncate font-medium text-sm" title={file.name}>
{file.name}
</div>
)}
<div className="text-muted-foreground text-xs">
{file.source === "jupyter" ? "JUPYTER · " : ""}
{fileExtension(file.name).toUpperCase() || "FILE"} ·{" "}
{formatBytes(file.size)}
</div>
{onlineDoc.message ? (
<div
className={cn(
"truncate text-xs",
onlineDoc.status === "error"
? "text-destructive"
: "text-muted-foreground",
)}
title={onlineDoc.message}
>
{onlineDoc.message}
</div>
) : null}
</div>
<a
href={file.download_url}
className="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={`下载 ${file.name}`}
title="下载"
>
<DownloadIcon className="size-4" />
</a>
<button
type="button"
className="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={`针对 ${file.name} 提问`}
title="针对文件提问"
onClick={() => askAboutFile(file)}
>
<MessageSquareTextIcon className="size-4" />
</button>
{canCreateOnlineDoc ? (
<button
type="button"
className="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`${file.name} 转为飞书在线文件`}
title="转为飞书在线文件"
disabled={onlineDoc.status === "loading"}
onClick={createOnlineDoc}
>
{onlineDoc.status === "loading" ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<CloudUploadIcon className="size-4" />
)}
</button>
) : null}
</div>
);
}
type FeishuLoginPayload = {
login?: {
login_url?: string | null;
user_code?: string | null;
};
};
function isOnlineDocSupported(fileName: string) {
return ["csv", "docx", "md", "txt", "xlsx"].includes(
fileExtension(fileName).toLowerCase(),
);
}
function onlineFileKindLabel(
kind: string | null | undefined,
fileName: string,
) {
const normalized = kind?.toLowerCase();
if (normalized === "sheet") return "在线表格";
if (["csv", "xlsx"].includes(fileExtension(fileName).toLowerCase())) {
return "在线表格";
}
return "在线文档";
}
function extractErrorMessage(payload: Record<string, unknown>) {
const detail = payload.detail;
if (typeof detail === "string") return detail;
if (detail && typeof detail === "object") {
const message = (detail as { message?: unknown }).message;
if (typeof message === "string") return message;
}
const error = payload.error;
return typeof error === "string" ? error : null;
}
function askAboutFile(file: SessionFile) {
window.dispatchEvent(
new CustomEvent("claw-composer-insert", {
detail: {
text: [
`请针对这个${file.kind === "input" ? "输入" : "输出"}文件回答我的问题:`,
`- 文件名: ${file.name}`,
`- 本地路径: ${file.path}`,
"请先使用 read_file 工具读取文件内容。",
].join("\n"),
},
}),
);
}
function fileExtension(fileName: string) {
const ext = fileName.split(".").at(-1);
return ext && ext !== fileName ? ext : "";
}
function formatBytes(bytes: number) {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
}
function activityMessageId(activityId: string) {
return activityId.split(":", 1)[0] ?? activityId;
}
function useLiveRunStatus(
sessionId: string | null,
mode: ActivityContextValue["mode"],
) {
const [status, setStatus] = useState<ClawRunStatus | null>(null);
useEffect(() => {
if (!sessionId || mode !== "activity") {
setStatus(null);
return;
}
const safeSessionId = sessionId;
let cancelled = false;
async function refresh() {
const nextStatus = await fetchLatestRunStatus(safeSessionId);
if (cancelled) return;
setStatus(hasActiveRun(nextStatus) ? nextStatus : null);
}
refresh();
const interval = window.setInterval(refresh, 1000);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [sessionId, mode]);
return status;
}
function hasActiveRun(
status?: ClawRunStatus | null,
): status is ClawRunStatus & { status: "queued" | "running" } {
return status?.status === "queued" || status?.status === "running";
}
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 collectLiveRunItems(runStatus: ClawRunStatus | null) {
if (!hasActiveRun(runStatus)) return [];
const items: ActivityItem[] = [];
const events = runStatus.events ?? [];
const summaryLines = summarizeLiveRunEvents(runStatus, events);
items.push({
id: `live:${runStatus.run_id ?? runStatus.session_id ?? "active"}:reasoning`,
kind: "reasoning",
title: runStatus.status === "queued" ? "排队中" : "思考中",
summary: summaryLines.join("\n"),
status: "running",
});
const resultById = new Map<string, LiveRunEvent>();
for (const event of events) {
if (event.type === "tool_result" && event.tool_call_id) {
resultById.set(event.tool_call_id, event);
}
}
const seenToolCalls = new Set<string>();
for (const event of events) {
if (
event.type !== "tool_start" ||
!event.tool_call_id ||
!event.tool_name ||
seenToolCalls.has(event.tool_call_id)
) {
continue;
}
seenToolCalls.add(event.tool_call_id);
const result = resultById.get(event.tool_call_id);
const stageNote =
typeof event.assistant_content === "string"
? event.assistant_content.trim()
: "";
if (stageNote) {
items.push({
id: `live:${runStatus.run_id ?? runStatus.session_id ?? "active"}:${event.tool_call_id}:stage`,
kind: "reasoning",
title: "阶段说明",
summary: stageNote,
status: result
? result.ok === false
? "incomplete"
: "complete"
: "running",
});
}
const resultSummary =
typeof result?.metadata?.output_preview === "string"
? result.metadata.output_preview
: result
? result.ok === false
? "工具执行失败,等待最终结果汇总。"
: "工具调用完成,等待最终结果汇总。"
: undefined;
const input = stripClawToolMetadata(
attachStageNote(event.arguments ?? {}, event.assistant_content),
);
const argsText = formatToolArgs(input);
items.push({
id: `live:${runStatus.run_id ?? runStatus.session_id ?? "active"}:${event.tool_call_id}`,
kind: "tool",
title: event.tool_name,
summary: result
? result.ok === false
? "工具调用失败"
: "工具调用完成"
: "工具调用中",
status: result
? result.ok === false
? "incomplete"
: "complete"
: "running",
argsText,
resultSummary,
rawResult: resultSummary,
fileLinks: extractLocalPaths(
[argsText, resultSummary].filter(Boolean) as string[],
),
});
}
return items;
}
function summarizeLiveRunEvents(
runStatus: ClawRunStatus,
events: readonly LiveRunEvent[],
) {
const lines: string[] = [];
for (const event of events) {
if (event.type === "run_queued") {
lines.push("当前会话已有任务在执行,本轮正在排队。");
}
if (event.type === "run_started") {
lines.push("后端已开始执行本轮任务。");
}
if (event.type === "tool_start") {
const stageNote =
typeof event.assistant_content === "string"
? event.assistant_content.trim()
: "";
lines.push(
stageNote ||
(event.tool_name ? `调用工具 ${event.tool_name}` : "正在调用工具"),
);
}
if (event.type === "tool_result") {
lines.push(
event.tool_name ? `工具完成 ${event.tool_name}` : "工具调用完成",
);
}
if (event.type === "tool_delta") {
lines.push(event.tool_name ? `${event.tool_name} 输出中` : "工具输出中");
}
if (event.type === "final_text_start") {
lines.push("正在整理回复");
}
}
if (runStatus.current_stage && lines.at(-1) !== runStatus.current_stage) {
lines.push(runStatus.current_stage);
}
if (!lines.length) {
lines.push(
runStatus.status === "queued"
? "当前会话已有任务在执行,本轮正在排队。"
: "后端正在执行这个会话。",
);
}
return lines.slice(-40);
}
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);
const summary =
part.text.trim() ||
(status === "running"
? "模型正在整理下一步行动。"
: "思考并执行完成。");
items.push({
id,
kind: "reasoning",
title: reasoningTitle(summary, status),
summary,
status,
});
}
if (part.type === "tool-call") {
const input = stripClawToolMetadata(part.args);
const stageNote = getStageNote(part.args);
const argsText = formatToolArgs(input);
if (stageNote) {
items.push({
id: `${id}:stage`,
kind: "reasoning",
title: "阶段说明",
summary: stageNote,
status: getPartStatus(message, part),
});
}
items.push({
id,
kind: "tool",
title: part.toolName,
summary: summarizeTool(message, part),
status: getPartStatus(message, part),
argsText,
result: decodeJsonString(part.result),
resultSummary: summarizeToolResult(decodeJsonString(part.result)),
rawResult:
part.result === undefined
? undefined
: formatValue(decodeJsonString(part.result)),
fileLinks: extractLocalPaths(
[
argsText,
part.result === undefined
? undefined
: formatValue(decodeJsonString(part.result)),
].filter(Boolean) as string[],
),
});
}
}
}
return items;
}
function getStageNote(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
const note = (value as Record<string, unknown>).__claw_stage_note;
return typeof note === "string" ? note.trim() : "";
}
function attachStageNote(input: unknown, stageNote?: unknown) {
const note = typeof stageNote === "string" ? stageNote.trim() : "";
if (!note) return input;
if (input && typeof input === "object" && !Array.isArray(input)) {
return { ...(input as Record<string, unknown>), __claw_stage_note: note };
}
return { value: input, __claw_stage_note: note };
}
function stripClawToolMetadata(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const {
__claw_stage_note: _stageNote,
__claw_elapsed_ms: _elapsedMs,
...rest
} = value as Record<string, unknown>;
return rest;
}
function formatToolArgs(value: unknown) {
if (value === undefined) return undefined;
if (typeof value === "string") return value;
return JSON.stringify(value ?? {}, null, 2);
}
function reasoningTitle(
text: string,
status: ToolCallMessagePartStatus["type"],
) {
if (status === "running") return "思考中";
const normalized = text.trim();
if (
normalized.startsWith("思考中") ||
normalized.startsWith("思考并执行完成")
) {
return "思考完成";
}
return "阶段说明";
}
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;
}