Improve workspace sidebar and file panel
This commit is contained in:
@@ -1,6 +1,11 @@
|
|||||||
import { readFile, stat } from "node:fs/promises";
|
import { readdir, readFile, stat } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { accountBaseRoot, getCurrentAccount } from "@/lib/claw-auth";
|
import {
|
||||||
|
accountBaseRoot,
|
||||||
|
accountSessionInputRoot,
|
||||||
|
accountSessionOutputRoot,
|
||||||
|
getCurrentAccount,
|
||||||
|
} from "@/lib/claw-auth";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@@ -11,8 +16,15 @@ export async function GET(req: Request) {
|
|||||||
|
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const requestedPath = url.searchParams.get("path");
|
const requestedPath = url.searchParams.get("path");
|
||||||
|
const sessionId = url.searchParams.get("session_id");
|
||||||
|
if (!requestedPath && sessionId) {
|
||||||
|
return Response.json({
|
||||||
|
input: await listSessionFiles(account.id, sessionId, "input"),
|
||||||
|
output: await listSessionFiles(account.id, sessionId, "output"),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!requestedPath) {
|
if (!requestedPath) {
|
||||||
return Response.json({ error: "缺少文件路径" }, { status: 400 });
|
return Response.json({ error: "缺少文件路径或会话 ID" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const accountRoot = path.resolve(accountBaseRoot(account.id));
|
const accountRoot = path.resolve(accountBaseRoot(account.id));
|
||||||
@@ -38,6 +50,43 @@ export async function GET(req: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listSessionFiles(
|
||||||
|
accountId: string,
|
||||||
|
sessionId: string,
|
||||||
|
kind: "input" | "output",
|
||||||
|
) {
|
||||||
|
const accountRoot = path.resolve(accountBaseRoot(accountId));
|
||||||
|
const dir =
|
||||||
|
kind === "input"
|
||||||
|
? accountSessionInputRoot(accountId, sessionId)
|
||||||
|
: accountSessionOutputRoot(accountId, sessionId);
|
||||||
|
const resolvedDir = path.resolve(dir);
|
||||||
|
if (!resolvedDir.startsWith(`${accountRoot}${path.sep}`)) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await readdir(resolvedDir, { withFileTypes: true });
|
||||||
|
const files = await Promise.all(
|
||||||
|
entries
|
||||||
|
.filter((entry) => entry.isFile())
|
||||||
|
.map(async (entry) => {
|
||||||
|
const filePath = path.join(resolvedDir, entry.name);
|
||||||
|
const fileStat = await stat(filePath);
|
||||||
|
return {
|
||||||
|
name: entry.name,
|
||||||
|
path: filePath,
|
||||||
|
kind,
|
||||||
|
size: fileStat.size,
|
||||||
|
modified_at: fileStat.mtime.toISOString(),
|
||||||
|
download_url: `/api/claw/files?path=${encodeURIComponent(filePath)}`,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return files.sort((a, b) => b.modified_at.localeCompare(a.modified_at));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function contentTypeFor(filePath: string) {
|
function contentTypeFor(filePath: string) {
|
||||||
const ext = path.extname(filePath).toLowerCase();
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
if (ext === ".json") return "application/json; charset=utf-8";
|
if (ext === ".json") return "application/json; charset=utf-8";
|
||||||
|
|||||||
@@ -14,16 +14,9 @@ import {
|
|||||||
} from "@/components/assistant-ui/activity-panel";
|
} from "@/components/assistant-ui/activity-panel";
|
||||||
import { Thread } from "@/components/assistant-ui/thread";
|
import { Thread } from "@/components/assistant-ui/thread";
|
||||||
import { ThreadListSidebar } from "@/components/assistant-ui/threadlist-sidebar";
|
import { ThreadListSidebar } from "@/components/assistant-ui/threadlist-sidebar";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||||
import {
|
|
||||||
SidebarInset,
|
|
||||||
SidebarProvider,
|
|
||||||
SidebarTrigger,
|
|
||||||
} from "@/components/ui/sidebar";
|
|
||||||
import { ClawSessionReplayProvider } from "@/lib/claw-session-replay";
|
import { ClawSessionReplayProvider } from "@/lib/claw-session-replay";
|
||||||
import { ClawAccountGate, useClawAccount } from "./claw-account-gate";
|
import { ClawAccountGate, useClawAccount } from "./claw-account-gate";
|
||||||
import { ClawLlmSettings } from "./claw-llm-settings";
|
|
||||||
import { ClawThemeSwitcher } from "./claw-theme-switcher";
|
|
||||||
|
|
||||||
export const Assistant = () => {
|
export const Assistant = () => {
|
||||||
const { account, isLoading, setAccount } = useClawAccount();
|
const { account, isLoading, setAccount } = useClawAccount();
|
||||||
@@ -83,24 +76,12 @@ export const Assistant = () => {
|
|||||||
<ClawSessionReplayProvider value={{ replaySession }}>
|
<ClawSessionReplayProvider value={{ replaySession }}>
|
||||||
<ActivityProvider>
|
<ActivityProvider>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<div className="flex h-dvh w-full pr-0.5">
|
<div className="flex h-dvh w-full">
|
||||||
<ThreadListSidebar
|
<ThreadListSidebar
|
||||||
account={account}
|
account={account}
|
||||||
onLogout={() => setAccount(null)}
|
onLogout={() => setAccount(null)}
|
||||||
/>
|
/>
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
|
||||||
<SidebarTrigger />
|
|
||||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate font-medium text-sm">新会话</div>
|
|
||||||
<div className="text-muted-foreground text-xs">
|
|
||||||
ZK Data Agent
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ClawThemeSwitcher />
|
|
||||||
<ClawLlmSettings />
|
|
||||||
</header>
|
|
||||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||||
<div className="min-w-0 flex-1 overflow-hidden">
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
<Thread />
|
<Thread />
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import {
|
|||||||
CheckCircle2Icon,
|
CheckCircle2Icon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
|
DownloadIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
|
MessageSquareTextIcon,
|
||||||
PanelRightCloseIcon,
|
PanelRightCloseIcon,
|
||||||
WrenchIcon,
|
WrenchIcon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
@@ -37,8 +39,11 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
type ActivityContextValue = {
|
type ActivityContextValue = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
mode: "activity" | "files";
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
|
sessionId: string | null;
|
||||||
openItem: (id: string) => void;
|
openItem: (id: string) => void;
|
||||||
|
openFiles: (sessionId?: string | null) => void;
|
||||||
close: () => void;
|
close: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,20 +51,36 @@ const ActivityContext = createContext<ActivityContextValue | null>(null);
|
|||||||
|
|
||||||
export function ActivityProvider({ children }: { children: ReactNode }) {
|
export function ActivityProvider({ children }: { children: ReactNode }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [mode, setMode] = useState<"activity" | "files">("activity");
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
const openItem = useCallback((id: string) => {
|
const openItem = useCallback((id: string) => {
|
||||||
|
setMode("activity");
|
||||||
setSelectedId(id);
|
setSelectedId(id);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
const openFiles = useCallback((nextSessionId?: string | null) => {
|
||||||
|
setMode("files");
|
||||||
|
setSessionId(
|
||||||
|
nextSessionId ??
|
||||||
|
(typeof window !== "undefined"
|
||||||
|
? window.localStorage.getItem("claw.activeSessionId")
|
||||||
|
: null),
|
||||||
|
);
|
||||||
|
setOpen(true);
|
||||||
|
}, []);
|
||||||
const close = useCallback(() => setOpen(false), []);
|
const close = useCallback(() => setOpen(false), []);
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
open,
|
open,
|
||||||
|
mode,
|
||||||
selectedId,
|
selectedId,
|
||||||
|
sessionId,
|
||||||
openItem,
|
openItem,
|
||||||
|
openFiles,
|
||||||
close,
|
close,
|
||||||
}),
|
}),
|
||||||
[open, selectedId, openItem, close],
|
[open, mode, selectedId, sessionId, openItem, openFiles, close],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -91,7 +112,8 @@ type ActivityItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ActivityPanel() {
|
export function ActivityPanel() {
|
||||||
const { open, selectedId, openItem, close } = useActivityPanel();
|
const { open, mode, selectedId, sessionId, openItem, close } =
|
||||||
|
useActivityPanel();
|
||||||
const messages = useAuiState((s) => s.thread.messages);
|
const messages = useAuiState((s) => s.thread.messages);
|
||||||
const items = useMemo(() => collectActivityItems(messages), [messages]);
|
const items = useMemo(() => collectActivityItems(messages), [messages]);
|
||||||
const selectedMessageId = selectedId ? activityMessageId(selectedId) : null;
|
const selectedMessageId = selectedId ? activityMessageId(selectedId) : null;
|
||||||
@@ -101,14 +123,18 @@ export function ActivityPanel() {
|
|||||||
const prevCountRef = useRef(0);
|
const prevCountRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (items.length > prevCountRef.current) {
|
if (mode === "activity" && items.length > prevCountRef.current) {
|
||||||
openItem(items.at(-1)?.id ?? items[0]?.id ?? "");
|
openItem(items.at(-1)?.id ?? items[0]?.id ?? "");
|
||||||
}
|
}
|
||||||
prevCountRef.current = items.length;
|
prevCountRef.current = items.length;
|
||||||
}, [items, openItem]);
|
}, [items, mode, openItem]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
|
if (mode === "files") {
|
||||||
|
return <SessionFilesPanel sessionId={sessionId} onClose={close} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<header className="flex h-16 shrink-0 items-center justify-between border-b px-4">
|
||||||
@@ -155,6 +181,195 @@ export function ActivityPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionFile = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
kind: "input" | "output";
|
||||||
|
size: number;
|
||||||
|
modified_at: string;
|
||||||
|
download_url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionFilesPayload = {
|
||||||
|
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 =
|
||||||
|
sessionId ?? window.localStorage.getItem("claw.activeSessionId");
|
||||||
|
if (!activeSessionId) {
|
||||||
|
setPayload({ input: [], output: [] });
|
||||||
|
setStatus("当前还没有可用会话。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("正在读取聊天中的文件...");
|
||||||
|
try {
|
||||||
|
const url = new URL("/api/claw/files", window.location.origin);
|
||||||
|
url.searchParams.set("session_id", activeSessionId);
|
||||||
|
const response = await fetch(url, { cache: "no-store" });
|
||||||
|
const nextPayload = (await response.json()) as SessionFilesPayload;
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!response.ok) {
|
||||||
|
setPayload({ input: [], output: [], error: nextPayload.error });
|
||||||
|
setStatus(nextPayload.error ?? "读取文件失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPayload(nextPayload);
|
||||||
|
setStatus("");
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPayload({ input: [], output: [] });
|
||||||
|
setStatus(err instanceof Error ? err.message : "读取文件失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadFiles();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }) {
|
||||||
|
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">
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function activityMessageId(activityId: string) {
|
||||||
return activityId.split(":", 1)[0] ?? activityId;
|
return activityId.split(":", 1)[0] ?? activityId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ function isClawSession(value: unknown): value is ClawSession {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toReplayRepository(
|
export function toReplayRepository(
|
||||||
session: ClawStoredSession,
|
session: ClawStoredSession,
|
||||||
fallbackSessionId: string,
|
fallbackSessionId: string,
|
||||||
): ExportedMessageRepository {
|
): ExportedMessageRepository {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
|
FileTextIcon,
|
||||||
ListIcon,
|
ListIcon,
|
||||||
MoreHorizontalIcon,
|
MoreHorizontalIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
@@ -115,8 +116,9 @@ export const Thread: FC = () => {
|
|||||||
<ThreadPrimitive.Viewport
|
<ThreadPrimitive.Viewport
|
||||||
turnAnchor="top"
|
turnAnchor="top"
|
||||||
data-slot="aui_thread-viewport"
|
data-slot="aui_thread-viewport"
|
||||||
className="relative flex flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth"
|
className="relative flex flex-1 flex-col overflow-x-auto overflow-y-auto scroll-smooth"
|
||||||
>
|
>
|
||||||
|
<ChatTopActions />
|
||||||
<div className="mx-auto flex w-full max-w-(--thread-max-width) flex-1 flex-col px-4 pt-4">
|
<div className="mx-auto flex w-full max-w-(--thread-max-width) flex-1 flex-col px-4 pt-4">
|
||||||
<AuiIf condition={(s) => s.thread.isEmpty}>
|
<AuiIf condition={(s) => s.thread.isEmpty}>
|
||||||
<ThreadWelcome />
|
<ThreadWelcome />
|
||||||
@@ -141,6 +143,52 @@ export const Thread: FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ChatTopActions: FC = () => {
|
||||||
|
const { openFiles } = useActivityPanel();
|
||||||
|
const latestSessionId = useAuiState((s) =>
|
||||||
|
findLatestMessageMetadataValue(s.thread.messages, "sessionId"),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none sticky top-3 z-20 mr-4 flex justify-end">
|
||||||
|
<PopoverPrimitive.Root>
|
||||||
|
<PopoverPrimitive.Trigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="pointer-events-auto size-8 rounded-full bg-background/90 text-muted-foreground shadow-sm backdrop-blur hover:bg-muted hover:text-foreground"
|
||||||
|
aria-label="聊天选项"
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</PopoverPrimitive.Trigger>
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
side="bottom"
|
||||||
|
align="end"
|
||||||
|
sideOffset={8}
|
||||||
|
className="z-50 min-w-40 rounded-xl border bg-popover p-1 text-popover-foreground shadow-lg outline-none"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-muted"
|
||||||
|
onClick={() => {
|
||||||
|
openFiles(
|
||||||
|
typeof latestSessionId === "string" ? latestSessionId : null,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileTextIcon className="size-4 text-muted-foreground" />
|
||||||
|
聊天中的文件
|
||||||
|
</button>
|
||||||
|
</PopoverPrimitive.Content>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
</PopoverPrimitive.Root>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const ThreadMessage: FC = () => {
|
const ThreadMessage: FC = () => {
|
||||||
const role = useAuiState((s) => s.message.role);
|
const role = useAuiState((s) => s.message.role);
|
||||||
const isEditing = useAuiState((s) => s.message.composer.isEditing);
|
const isEditing = useAuiState((s) => s.message.composer.isEditing);
|
||||||
@@ -168,7 +216,7 @@ const ThreadWelcome: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="aui-thread-welcome-root my-auto flex grow flex-col">
|
<div className="aui-thread-welcome-root my-auto flex grow flex-col">
|
||||||
<div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center">
|
<div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center">
|
||||||
<div className="aui-thread-welcome-message flex size-full flex-col justify-center px-4">
|
<div className="aui-thread-welcome-message flex size-full flex-col items-center justify-center px-4 text-center">
|
||||||
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both font-semibold text-2xl duration-200">
|
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both font-semibold text-2xl duration-200">
|
||||||
ZK Data Agent
|
ZK Data Agent
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -1,16 +1,35 @@
|
|||||||
|
import type { ExportedMessageRepository } from "@assistant-ui/core";
|
||||||
|
import { ThreadListPrimitive } from "@assistant-ui/react";
|
||||||
import {
|
import {
|
||||||
BarChart3Icon,
|
BarChart3Icon,
|
||||||
BotIcon,
|
BotIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
|
Clock3Icon,
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
|
PanelLeftOpenIcon,
|
||||||
|
SearchIcon,
|
||||||
|
SquarePenIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Popover as PopoverPrimitive } from "radix-ui";
|
import { Popover as PopoverPrimitive } from "radix-ui";
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { ClawAccount } from "@/app/claw-account-gate";
|
import type { ClawAccount } from "@/app/claw-account-gate";
|
||||||
import { ThreadList } from "@/components/assistant-ui/thread-list";
|
import { ClawLlmSettings } from "@/app/claw-llm-settings";
|
||||||
|
import { ClawThemeSwitcher } from "@/app/claw-theme-switcher";
|
||||||
|
import {
|
||||||
|
ThreadList,
|
||||||
|
toReplayRepository,
|
||||||
|
} from "@/components/assistant-ui/thread-list";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -20,7 +39,10 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarRail,
|
SidebarRail,
|
||||||
|
SidebarTrigger,
|
||||||
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
|
import { useClawSessionReplay } from "@/lib/claw-session-replay";
|
||||||
|
|
||||||
type ThreadListSidebarProps = React.ComponentProps<typeof Sidebar> & {
|
type ThreadListSidebarProps = React.ComponentProps<typeof Sidebar> & {
|
||||||
account: ClawAccount;
|
account: ClawAccount;
|
||||||
@@ -38,6 +60,7 @@ type ClawSessionSummary = {
|
|||||||
turns: number;
|
turns: number;
|
||||||
tool_calls: number;
|
tool_calls: number;
|
||||||
modified_at: number;
|
modified_at: number;
|
||||||
|
preview?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
usage?: UsageSummary;
|
usage?: UsageSummary;
|
||||||
};
|
};
|
||||||
@@ -50,57 +73,337 @@ type UsageSummary = {
|
|||||||
|
|
||||||
type ClawStoredSession = {
|
type ClawStoredSession = {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
|
messages?: unknown[];
|
||||||
usage?: UsageSummary;
|
usage?: UsageSummary;
|
||||||
tool_calls?: number;
|
tool_calls?: number;
|
||||||
turns?: number;
|
turns?: number;
|
||||||
model?: string;
|
model?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ReplaySessionPayload = Parameters<typeof toReplayRepository>[0];
|
||||||
|
|
||||||
|
type SidebarSession = ClawSessionSummary & {
|
||||||
|
preview: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function ThreadListSidebar({
|
export function ThreadListSidebar({
|
||||||
account,
|
account,
|
||||||
onLogout,
|
onLogout,
|
||||||
...props
|
...props
|
||||||
}: ThreadListSidebarProps) {
|
}: ThreadListSidebarProps) {
|
||||||
return (
|
return (
|
||||||
<Sidebar {...props}>
|
<Sidebar collapsible="icon" {...props}>
|
||||||
<SidebarHeader className="aui-sidebar-header mb-2 border-b">
|
<div className="flex h-full flex-col group-data-[collapsible=icon]:hidden">
|
||||||
<div className="aui-sidebar-header-content flex items-center justify-between">
|
<SidebarHeader className="aui-sidebar-header mb-2 border-b">
|
||||||
<SidebarMenu>
|
<div className="aui-sidebar-header-content flex items-center justify-between gap-2">
|
||||||
<SidebarMenuItem>
|
<SidebarMenu>
|
||||||
<SidebarMenuButton size="lg">
|
<SidebarMenuItem>
|
||||||
<div className="aui-sidebar-header-icon-wrapper flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
<SidebarMenuButton size="lg">
|
||||||
<BotIcon className="aui-sidebar-header-icon size-4" />
|
<div className="aui-sidebar-header-icon-wrapper flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||||
</div>
|
<BotIcon className="aui-sidebar-header-icon size-4" />
|
||||||
<div className="aui-sidebar-header-heading mr-6 flex flex-col gap-0.5 leading-none">
|
</div>
|
||||||
<span className="aui-sidebar-header-title font-semibold">
|
<div className="aui-sidebar-header-heading mr-6 flex flex-col gap-0.5 leading-none">
|
||||||
ZK Data Agent
|
<span className="aui-sidebar-header-title font-semibold">
|
||||||
</span>
|
ZK Data Agent
|
||||||
<span className="text-muted-foreground text-xs">
|
</span>
|
||||||
中控数据开发平台
|
<span className="text-muted-foreground text-xs">
|
||||||
</span>
|
中控数据开发平台
|
||||||
</div>
|
</span>
|
||||||
</SidebarMenuButton>
|
</div>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuButton>
|
||||||
</SidebarMenu>
|
</SidebarMenuItem>
|
||||||
</div>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
<SidebarTrigger className="size-8 shrink-0" />
|
||||||
<SidebarContent className="aui-sidebar-content px-2">
|
</div>
|
||||||
<ThreadList />
|
</SidebarHeader>
|
||||||
</SidebarContent>
|
<SidebarContent className="aui-sidebar-content px-2">
|
||||||
<SidebarRail />
|
<ThreadList />
|
||||||
<SidebarFooter className="aui-sidebar-footer border-t">
|
</SidebarContent>
|
||||||
<AccountMenu account={account} onLogout={onLogout} />
|
<SidebarRail />
|
||||||
</SidebarFooter>
|
<SidebarFooter className="aui-sidebar-footer border-t">
|
||||||
|
<AccountMenu account={account} onLogout={onLogout} />
|
||||||
|
</SidebarFooter>
|
||||||
|
</div>
|
||||||
|
<div className="hidden h-full group-data-[collapsible=icon]:flex">
|
||||||
|
<CollapsedSidebarRail account={account} onLogout={onLogout} />
|
||||||
|
</div>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CollapsedSidebarRail({
|
||||||
|
account,
|
||||||
|
onLogout,
|
||||||
|
}: {
|
||||||
|
account: ClawAccount;
|
||||||
|
onLogout: () => void;
|
||||||
|
}) {
|
||||||
|
const { setOpen } = useSidebar();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center border-r bg-sidebar py-3 text-sidebar-foreground">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group mb-5 flex size-9 items-center justify-center rounded-lg text-sidebar-foreground transition-colors hover:bg-sidebar-accent"
|
||||||
|
aria-label="展开侧栏"
|
||||||
|
title="展开侧栏"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<BotIcon className="size-5 group-hover:hidden" />
|
||||||
|
<PanelLeftOpenIcon className="hidden size-5 group-hover:block" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div
|
||||||
|
onClickCapture={() => {
|
||||||
|
window.localStorage.removeItem("claw.activeSessionId");
|
||||||
|
window.dispatchEvent(new Event("claw-active-session-cleared"));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThreadListPrimitive.New asChild>
|
||||||
|
<CollapsedIconButton label="新任务">
|
||||||
|
<SquarePenIcon className="size-4" />
|
||||||
|
</CollapsedIconButton>
|
||||||
|
</ThreadListPrimitive.New>
|
||||||
|
</div>
|
||||||
|
<CollapsedSessionSearch />
|
||||||
|
<CollapsedRecentSessions />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto">
|
||||||
|
<AccountMenu account={account} onLogout={onLogout} compact />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsedIconButton({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex size-8 items-center justify-center rounded-lg text-sidebar-foreground transition-colors hover:bg-sidebar-accent"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsedSessionSearch() {
|
||||||
|
const { replaySession } = useClawSessionReplay();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const { sessions, loadingSessionId, openSession } = useSidebarSessions(open);
|
||||||
|
const filteredSessions = sessions.filter((session) => {
|
||||||
|
const keyword = query.trim().toLowerCase();
|
||||||
|
if (!keyword) return true;
|
||||||
|
return (
|
||||||
|
session.preview.toLowerCase().includes(keyword) ||
|
||||||
|
session.session_id.toLowerCase().includes(keyword)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CollapsedIconButton label="搜索任务" onClick={() => setOpen(true)}>
|
||||||
|
<SearchIcon className="size-4" />
|
||||||
|
</CollapsedIconButton>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="top-[35%] max-w-lg translate-y-[-35%] gap-3 p-4">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>搜索任务</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
按任务标题或会话 ID 搜索,点击后恢复会话。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
placeholder="输入关键词..."
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="max-h-80 overflow-y-auto">
|
||||||
|
{filteredSessions.length ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{filteredSessions.map((session) => (
|
||||||
|
<SessionResultButton
|
||||||
|
key={session.session_id}
|
||||||
|
session={session}
|
||||||
|
loading={loadingSessionId === session.session_id}
|
||||||
|
onClick={async () => {
|
||||||
|
await openSession(session.session_id, replaySession);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="py-8 text-center text-muted-foreground text-sm">
|
||||||
|
没有匹配的任务。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsedRecentSessions() {
|
||||||
|
const { replaySession } = useClawSessionReplay();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { sessions, loadingSessionId, openSession } = useSidebarSessions(open);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverPrimitive.Trigger asChild>
|
||||||
|
<CollapsedIconButton label="最近任务">
|
||||||
|
<Clock3Icon className="size-4" />
|
||||||
|
</CollapsedIconButton>
|
||||||
|
</PopoverPrimitive.Trigger>
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
side="right"
|
||||||
|
align="start"
|
||||||
|
sideOffset={10}
|
||||||
|
className="z-50 w-72 rounded-xl border bg-popover p-2 text-popover-foreground shadow-lg outline-none"
|
||||||
|
>
|
||||||
|
<div className="px-2 py-1.5 font-medium text-sm">最近任务</div>
|
||||||
|
{sessions.length ? (
|
||||||
|
<div className="flex max-h-96 flex-col gap-1 overflow-y-auto">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<SessionResultButton
|
||||||
|
key={session.session_id}
|
||||||
|
session={session}
|
||||||
|
loading={loadingSessionId === session.session_id}
|
||||||
|
onClick={async () => {
|
||||||
|
await openSession(session.session_id, replaySession);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="px-2 py-6 text-center text-muted-foreground text-sm">
|
||||||
|
暂无最近任务。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</PopoverPrimitive.Content>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
</PopoverPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionResultButton({
|
||||||
|
session,
|
||||||
|
loading,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
session: SidebarSession;
|
||||||
|
loading: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full flex-col rounded-lg px-2 py-2 text-left transition-colors hover:bg-muted"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<span className="truncate font-medium text-sm">
|
||||||
|
{loading ? "回放中..." : session.preview || session.session_id}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 text-muted-foreground text-xs">
|
||||||
|
{session.turns ?? 0} 轮 · {session.tool_calls ?? 0} 次工具
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSidebarSessions(open: boolean) {
|
||||||
|
const [sessions, setSessions] = useState<SidebarSession[]>([]);
|
||||||
|
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
fetch("/api/claw/sessions", { cache: "no-store" })
|
||||||
|
.then((res) => (res.ok ? res.json() : []))
|
||||||
|
.then((payload) => {
|
||||||
|
if (cancelled || !Array.isArray(payload)) return;
|
||||||
|
setSessions(dedupeSidebarSessions(payload).slice(0, 12));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setSessions([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
async function openSession(
|
||||||
|
sessionId: string,
|
||||||
|
replaySession: (
|
||||||
|
sessionId: string,
|
||||||
|
repository: ExportedMessageRepository,
|
||||||
|
) => void,
|
||||||
|
) {
|
||||||
|
window.localStorage.setItem("claw.activeSessionId", sessionId);
|
||||||
|
setLoadingSessionId(sessionId);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
const payload = (await response.json()) as ReplaySessionPayload;
|
||||||
|
replaySession(sessionId, toReplayRepository(payload, sessionId));
|
||||||
|
} finally {
|
||||||
|
setLoadingSessionId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sessions, loadingSessionId, openSession };
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeSidebarSessions(payload: unknown[]) {
|
||||||
|
const byId = new Map<string, SidebarSession>();
|
||||||
|
for (const item of payload) {
|
||||||
|
if (!isSidebarSession(item)) continue;
|
||||||
|
const previous = byId.get(item.session_id);
|
||||||
|
if (!previous || item.modified_at >= previous.modified_at) {
|
||||||
|
byId.set(item.session_id, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byId.values()].sort((a, b) => b.modified_at - a.modified_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSidebarSession(value: unknown): value is SidebarSession {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
const item = value as Partial<SidebarSession>;
|
||||||
|
return (
|
||||||
|
typeof item.session_id === "string" &&
|
||||||
|
typeof item.modified_at === "number" &&
|
||||||
|
typeof item.preview === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AccountMenu({
|
function AccountMenu({
|
||||||
account,
|
account,
|
||||||
onLogout,
|
onLogout,
|
||||||
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
account: ClawAccount;
|
account: ClawAccount;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [state, setState] = useState<ClawState | null>(null);
|
const [state, setState] = useState<ClawState | null>(null);
|
||||||
@@ -165,20 +468,33 @@ function AccountMenu({
|
|||||||
return (
|
return (
|
||||||
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
||||||
<PopoverPrimitive.Trigger asChild>
|
<PopoverPrimitive.Trigger asChild>
|
||||||
<button
|
{compact ? (
|
||||||
type="button"
|
<button
|
||||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left transition-colors hover:bg-sidebar-accent"
|
type="button"
|
||||||
>
|
className="flex size-8 items-center justify-center rounded-full bg-sidebar-primary text-sidebar-primary-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
aria-label="账号与统计"
|
||||||
<UserIcon className="size-4" />
|
title={account.username}
|
||||||
</div>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<span className="font-medium text-xs">
|
||||||
<div className="truncate font-medium text-sm">
|
{account.username.slice(0, 2).toUpperCase()}
|
||||||
{account.username}
|
</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left transition-colors hover:bg-sidebar-accent"
|
||||||
|
>
|
||||||
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||||
|
<UserIcon className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground text-xs">账号与统计</div>
|
<div className="min-w-0 flex-1">
|
||||||
</div>
|
<div className="truncate font-medium text-sm">
|
||||||
</button>
|
{account.username}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">账号与统计</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</PopoverPrimitive.Trigger>
|
</PopoverPrimitive.Trigger>
|
||||||
<PopoverPrimitive.Portal>
|
<PopoverPrimitive.Portal>
|
||||||
<PopoverPrimitive.Content
|
<PopoverPrimitive.Content
|
||||||
@@ -191,12 +507,21 @@ function AccountMenu({
|
|||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
<div className="flex size-9 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||||
<UserIcon className="size-4" />
|
<UserIcon className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate font-medium">{account.username}</div>
|
<div className="truncate font-medium">{account.username}</div>
|
||||||
<div className="truncate text-muted-foreground text-xs">
|
<div className="truncate text-muted-foreground text-xs">
|
||||||
{state?.model ?? "模型配置读取中"}
|
{state?.model ?? "模型配置读取中"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 shrink-0 gap-1.5 px-2 text-muted-foreground"
|
||||||
|
onClick={logout}
|
||||||
|
>
|
||||||
|
<LogOutIcon className="size-3.5" />
|
||||||
|
退出
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<AccountStat label="会话" value={sessions.length} />
|
<AccountStat label="会话" value={sessions.length} />
|
||||||
@@ -270,14 +595,15 @@ function AccountMenu({
|
|||||||
<div className="text-muted-foreground">暂无可统计会话</div>
|
<div className="text-muted-foreground">暂无可统计会话</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="mt-3 flex items-center justify-between gap-2 border-t pt-3">
|
||||||
variant="ghost"
|
<span className="text-muted-foreground text-xs">
|
||||||
className="mt-3 w-full justify-start gap-2 text-muted-foreground"
|
界面与模型配置
|
||||||
onClick={logout}
|
</span>
|
||||||
>
|
<div className="flex items-center gap-1">
|
||||||
<LogOutIcon className="size-4" />
|
<ClawThemeSwitcher />
|
||||||
退出登录
|
<ClawLlmSettings />
|
||||||
</Button>
|
</div>
|
||||||
|
</div>
|
||||||
</PopoverPrimitive.Content>
|
</PopoverPrimitive.Content>
|
||||||
</PopoverPrimitive.Portal>
|
</PopoverPrimitive.Portal>
|
||||||
</PopoverPrimitive.Root>
|
</PopoverPrimitive.Root>
|
||||||
|
|||||||
Reference in New Issue
Block a user