969 lines
26 KiB
TypeScript
969 lines
26 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,
|
||
CloudUploadIcon,
|
||
DownloadIcon,
|
||
FileTextIcon,
|
||
Loader2Icon,
|
||
MessageSquareTextIcon,
|
||
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;
|
||
mode: "activity" | "files";
|
||
selectedId: string | null;
|
||
sessionId: string | null;
|
||
filesRefreshToken: number;
|
||
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 openItem = useCallback((id: string) => {
|
||
setMode("activity");
|
||
setSelectedId(id);
|
||
setOpen(true);
|
||
}, []);
|
||
const openFiles = useCallback((nextSessionId?: string | null) => {
|
||
setMode("files");
|
||
setSessionId(
|
||
nextSessionId ??
|
||
(typeof window !== "undefined"
|
||
? window.localStorage.getItem("claw.activeSessionId")
|
||
: null),
|
||
);
|
||
setFilesRefreshToken((value) => value + 1);
|
||
setOpen(true);
|
||
}, []);
|
||
const close = useCallback(() => setOpen(false), []);
|
||
const value = useMemo(
|
||
() => ({
|
||
open,
|
||
mode,
|
||
selectedId,
|
||
sessionId,
|
||
filesRefreshToken,
|
||
openItem,
|
||
openFiles,
|
||
close,
|
||
}),
|
||
[
|
||
open,
|
||
mode,
|
||
selectedId,
|
||
sessionId,
|
||
filesRefreshToken,
|
||
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[];
|
||
};
|
||
|
||
export function ActivityPanel() {
|
||
const {
|
||
open,
|
||
mode,
|
||
selectedId,
|
||
sessionId,
|
||
filesRefreshToken,
|
||
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 prevLatestIdRef = useRef<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
const latestId = items.at(-1)?.id ?? null;
|
||
if (
|
||
mode === "activity" &&
|
||
latestId &&
|
||
latestId !== prevLatestIdRef.current
|
||
) {
|
||
openItem(latestId);
|
||
}
|
||
prevLatestIdRef.current = latestId;
|
||
}, [items, mode, openItem]);
|
||
|
||
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">
|
||
{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";
|
||
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 ?? window.localStorage.getItem("claw.activeSessionId"),
|
||
);
|
||
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) {
|
||
window.localStorage.setItem(
|
||
"claw.activeSessionId",
|
||
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">
|
||
{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 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);
|
||
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 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;
|
||
}
|