Improve data agent workspace UI
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
This frontend is based on the official [assistant-ui](https://github.com/assistant-ui/assistant-ui) starter project.
|
||||
# ZK Data Agent Frontend
|
||||
|
||||
The current goal is to keep the upstream assistant-ui visual structure intact, then add Claw Code Agent backend adapters in small, reviewable steps.
|
||||
中控数据开发平台前端,基于 [assistant-ui](https://github.com/assistant-ui/assistant-ui) starter project 改造。
|
||||
|
||||
## Getting Started
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { getCurrentAccount } from "@/lib/claw-auth";
|
||||
|
||||
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const account = await getCurrentAccount();
|
||||
if (!account)
|
||||
return Response.json({ error: "请先登录账号" }, { status: 401 });
|
||||
|
||||
const incoming = new URL(req.url);
|
||||
const url = new URL(`${CLAW_API_URL}/api/context-budget`);
|
||||
url.searchParams.set("account_id", account.id);
|
||||
const sessionId = incoming.searchParams.get("session_id");
|
||||
if (sessionId) url.searchParams.set("session_id", sessionId);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
const payload = await response.text();
|
||||
return new Response(payload, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
response.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
err instanceof Error
|
||||
? `Unable to reach ZK Data Agent backend: ${err.message}`
|
||||
: "Unable to reach ZK Data Agent backend",
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,3 +23,52 @@ export async function GET(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string }> },
|
||||
) {
|
||||
const account = await getCurrentAccount();
|
||||
if (!account)
|
||||
return Response.json({ error: "请先登录账号" }, { status: 401 });
|
||||
|
||||
const { sessionId } = await params;
|
||||
const url = new URL(`${CLAW_API_URL}/api/sessions/${sessionId}`);
|
||||
url.searchParams.set("account_id", account.id);
|
||||
const response = await fetch(url, { method: "DELETE", cache: "no-store" });
|
||||
const payload = await response.text();
|
||||
return new Response(payload, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
response.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string }> },
|
||||
) {
|
||||
const account = await getCurrentAccount();
|
||||
if (!account)
|
||||
return Response.json({ error: "请先登录账号" }, { status: 401 });
|
||||
|
||||
const { sessionId } = await params;
|
||||
const url = new URL(`${CLAW_API_URL}/api/sessions/${sessionId}`);
|
||||
url.searchParams.set("account_id", account.id);
|
||||
const response = await fetch(url, {
|
||||
method: "PATCH",
|
||||
cache: "no-store",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: await req.text(),
|
||||
});
|
||||
const payload = await response.text();
|
||||
return new Response(payload, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
response.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { ClawSessionReplayProvider } from "@/lib/claw-session-replay";
|
||||
import { ClawAccountGate, useClawAccount } from "./claw-account-gate";
|
||||
import { ClawLlmSettings } from "./claw-llm-settings";
|
||||
import { ClawThemeSwitcher } from "./claw-theme-switcher";
|
||||
|
||||
export const Assistant = () => {
|
||||
const { account, isLoading, setAccount } = useClawAccount();
|
||||
@@ -94,9 +95,10 @@ export const Assistant = () => {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-sm">新会话</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Claw Data Agent
|
||||
ZK Data Agent
|
||||
</div>
|
||||
</div>
|
||||
<ClawThemeSwitcher />
|
||||
<ClawLlmSettings />
|
||||
</header>
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
|
||||
@@ -74,9 +74,9 @@ export function ClawAccountGate({
|
||||
<div className="flex min-h-dvh items-center justify-center bg-background px-4">
|
||||
<div className="w-full max-w-sm rounded-xl border bg-card p-6 shadow-sm">
|
||||
<div className="mb-6">
|
||||
<h1 className="font-semibold text-xl">Claw Data Agent</h1>
|
||||
<h1 className="font-semibold text-xl">ZK Data Agent</h1>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
登录后会按账号隔离会话、上传文件和生成产物。
|
||||
中控数据开发平台会按账号隔离会话、上传文件和生成产物。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
|
||||
@@ -96,7 +96,7 @@ export function ClawLlmSettings() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>后端 LLM API</DialogTitle>
|
||||
<DialogDescription>
|
||||
调试时更新 Claw 后端 Agent 使用的 Base URL 和 API Key。
|
||||
调试时更新 ZK Data Agent 后端使用的 Base URL 和 API Key。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
|
||||
import { Popover as PopoverPrimitive } from "radix-ui";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type ThemeMode = "light" | "dark" | "system";
|
||||
|
||||
const THEME_STORAGE_KEY = "claw.theme";
|
||||
|
||||
const THEME_OPTIONS: Array<{
|
||||
mode: ThemeMode;
|
||||
label: string;
|
||||
icon: typeof SunIcon;
|
||||
}> = [
|
||||
{ mode: "light", label: "亮色", icon: SunIcon },
|
||||
{ mode: "dark", label: "暗色", icon: MoonIcon },
|
||||
{ mode: "system", label: "跟随系统", icon: MonitorIcon },
|
||||
];
|
||||
|
||||
export function ClawThemeSwitcher() {
|
||||
const [mode, setMode] = useState<ThemeMode>("system");
|
||||
const activeOption =
|
||||
THEME_OPTIONS.find((option) => option.mode === mode) ?? THEME_OPTIONS[2];
|
||||
const ActiveIcon = activeOption.icon;
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
const initialMode = isThemeMode(stored) ? stored : "system";
|
||||
setMode(initialMode);
|
||||
applyTheme(initialMode);
|
||||
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const onChange = () => {
|
||||
if (window.localStorage.getItem(THEME_STORAGE_KEY) === "system") {
|
||||
applyTheme("system");
|
||||
}
|
||||
};
|
||||
media.addEventListener("change", onChange);
|
||||
return () => media.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
const updateMode = (nextMode: ThemeMode) => {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, nextMode);
|
||||
setMode(nextMode);
|
||||
applyTheme(nextMode);
|
||||
};
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Root>
|
||||
<PopoverPrimitive.Trigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-full"
|
||||
aria-label="切换主题"
|
||||
title={`主题:${activeOption.label}`}
|
||||
>
|
||||
<ActiveIcon className="size-4" />
|
||||
</Button>
|
||||
</PopoverPrimitive.Trigger>
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="z-50 min-w-36 rounded-xl border bg-popover p-1 text-popover-foreground shadow-lg"
|
||||
>
|
||||
{THEME_OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<button
|
||||
key={option.mode}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition hover:bg-accent"
|
||||
onClick={() => updateMode(option.mode)}
|
||||
>
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">{option.label}</span>
|
||||
{mode === option.mode ? <CheckIcon className="size-4" /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Portal>
|
||||
</PopoverPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function applyTheme(mode: ThemeMode) {
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
const shouldUseDark = mode === "dark" || (mode === "system" && prefersDark);
|
||||
document.documentElement.classList.toggle("dark", shouldUseDark);
|
||||
}
|
||||
|
||||
function isThemeMode(value: string | null): value is ThemeMode {
|
||||
return value === "light" || value === "dark" || value === "system";
|
||||
}
|
||||
@@ -14,8 +14,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Claw Data Agent",
|
||||
description: "assistant-ui based data agent workbench",
|
||||
title: "ZK Data Agent",
|
||||
description: "中控数据开发平台",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -24,7 +24,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
|
||||
@@ -94,6 +94,10 @@ export function ActivityPanel() {
|
||||
const { open, selectedId, openItem, close } = useActivityPanel();
|
||||
const messages = useAuiState((s) => s.thread.messages);
|
||||
const items = useMemo(() => collectActivityItems(messages), [messages]);
|
||||
const selectedMessageId = selectedId ? activityMessageId(selectedId) : null;
|
||||
const visibleItems = selectedMessageId
|
||||
? items.filter((item) => activityMessageId(item.id) === selectedMessageId)
|
||||
: items;
|
||||
const prevCountRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -111,7 +115,9 @@ export function ActivityPanel() {
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-semibold text-base">活动</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{items.length > 0 ? `${items.length} 个步骤` : "暂无链路"}
|
||||
{visibleItems.length > 0
|
||||
? `${visibleItems.length} 个步骤`
|
||||
: "暂无链路"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -126,13 +132,13 @@ export function ActivityPanel() {
|
||||
</Button>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||
{items.length === 0 ? (
|
||||
{visibleItems.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
当前对话还没有工具链路。
|
||||
当前这轮对话还没有工具链路。
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{items.map((item) => (
|
||||
{visibleItems.map((item) => (
|
||||
<ActivityPanelItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
@@ -149,6 +155,10 @@ export function ActivityPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
function activityMessageId(activityId: string) {
|
||||
return activityId.split(":", 1)[0] ?? activityId;
|
||||
}
|
||||
|
||||
function ActivityPanelItem({
|
||||
item,
|
||||
open,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import "@assistant-ui/react-markdown/styles/dot.css";
|
||||
|
||||
import { TextMessagePartProvider } from "@assistant-ui/core/react";
|
||||
import {
|
||||
type CodeHeaderProps,
|
||||
MarkdownTextPrimitive,
|
||||
@@ -15,14 +16,25 @@ import remarkGfm from "remark-gfm";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MarkdownTextImpl = () => {
|
||||
return (
|
||||
type MarkdownTextProps = {
|
||||
text?: string;
|
||||
isRunning?: boolean;
|
||||
};
|
||||
|
||||
const MarkdownTextImpl: FC<MarkdownTextProps> = ({ text, isRunning = false }) => {
|
||||
const content = (
|
||||
<MarkdownTextPrimitive
|
||||
remarkPlugins={[remarkGfm]}
|
||||
className="aui-md"
|
||||
components={defaultComponents}
|
||||
/>
|
||||
);
|
||||
if (text === undefined) return content;
|
||||
return (
|
||||
<TextMessagePartProvider text={text} isRunning={isRunning}>
|
||||
{content}
|
||||
</TextMessagePartProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const MarkdownText = memo(MarkdownTextImpl);
|
||||
|
||||
@@ -217,7 +217,9 @@ function ReasoningText({ className, ...props }: React.ComponentProps<"div">) {
|
||||
);
|
||||
}
|
||||
|
||||
const ReasoningImpl: ReasoningMessagePartComponent = () => <MarkdownText />;
|
||||
const ReasoningImpl: ReasoningMessagePartComponent = ({ text, status }) => (
|
||||
<MarkdownText text={text} isRunning={status?.type === "running"} />
|
||||
);
|
||||
|
||||
const ReasoningGroupImpl: ReasoningGroupComponent = ({
|
||||
children,
|
||||
|
||||
@@ -3,22 +3,22 @@ import {
|
||||
type ExportedMessageRepository,
|
||||
type ThreadMessage,
|
||||
} from "@assistant-ui/core";
|
||||
import {
|
||||
AuiIf,
|
||||
ThreadListItemMorePrimitive,
|
||||
ThreadListItemPrimitive,
|
||||
ThreadListPrimitive,
|
||||
} from "@assistant-ui/react";
|
||||
import { ThreadListPrimitive } from "@assistant-ui/react";
|
||||
import type { UIMessage } from "ai";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
HistoryIcon,
|
||||
MoreHorizontalIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import {
|
||||
type FC,
|
||||
type KeyboardEvent,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useClawSessionReplay } from "@/lib/claw-session-replay";
|
||||
|
||||
type ClawSession = {
|
||||
@@ -59,14 +59,6 @@ export const ThreadList: FC = () => {
|
||||
return (
|
||||
<ThreadListPrimitive.Root className="aui-root aui-thread-list-root flex flex-col gap-1">
|
||||
<ThreadListNew />
|
||||
<AuiIf condition={({ threads }) => threads.isLoading}>
|
||||
<ThreadListSkeleton />
|
||||
</AuiIf>
|
||||
<AuiIf condition={({ threads }) => !threads.isLoading}>
|
||||
<ThreadListPrimitive.Items>
|
||||
{() => <ThreadListItem />}
|
||||
</ThreadListPrimitive.Items>
|
||||
</AuiIf>
|
||||
<ClawSessionList />
|
||||
</ThreadListPrimitive.Root>
|
||||
);
|
||||
@@ -86,162 +78,235 @@ const ThreadListNew: FC = () => {
|
||||
className="aui-thread-list-new h-9 w-full justify-start gap-2 rounded-lg px-3 text-sm hover:bg-muted data-active:bg-muted"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
New Thread
|
||||
New Task
|
||||
</Button>
|
||||
</ThreadListPrimitive.New>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadListSkeleton: FC = () => {
|
||||
const skeletonKeys = ["one", "two", "three", "four", "five"];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{skeletonKeys.map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
role="status"
|
||||
aria-label="Loading threads"
|
||||
className="aui-thread-list-skeleton-wrapper flex h-9 items-center px-3"
|
||||
>
|
||||
<Skeleton className="aui-thread-list-skeleton h-4 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadListItem: FC = () => {
|
||||
return (
|
||||
<ThreadListItemPrimitive.Root className="aui-thread-list-item group flex h-9 items-center gap-2 rounded-lg transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none data-active:bg-muted">
|
||||
<ThreadListItemPrimitive.Trigger className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center truncate px-3 text-start text-sm">
|
||||
<ThreadListItemPrimitive.Title fallback="New Chat" />
|
||||
</ThreadListItemPrimitive.Trigger>
|
||||
<ThreadListItemMore />
|
||||
</ThreadListItemPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadListItemMore: FC = () => {
|
||||
return (
|
||||
<ThreadListItemMorePrimitive.Root>
|
||||
<ThreadListItemMorePrimitive.Trigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="aui-thread-list-item-more mr-2 size-7 p-0 opacity-0 transition-opacity group-hover:opacity-100 data-[state=open]:bg-accent data-[state=open]:opacity-100 group-data-active:opacity-100"
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More options</span>
|
||||
</Button>
|
||||
</ThreadListItemMorePrimitive.Trigger>
|
||||
<ThreadListItemMorePrimitive.Content
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="aui-thread-list-item-more-content z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
|
||||
>
|
||||
<ThreadListItemPrimitive.Archive asChild>
|
||||
<ThreadListItemMorePrimitive.Item className="aui-thread-list-item-more-item flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground">
|
||||
<ArchiveIcon className="size-4" />
|
||||
Archive
|
||||
</ThreadListItemMorePrimitive.Item>
|
||||
</ThreadListItemPrimitive.Archive>
|
||||
</ThreadListItemMorePrimitive.Content>
|
||||
</ThreadListItemMorePrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const ClawSessionList: FC = () => {
|
||||
const { replaySession } = useClawSessionReplay();
|
||||
const [sessions, setSessions] = useState<ClawSession[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [renamingSessionId, setRenamingSessionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [draftTitle, setDraftTitle] = useState("");
|
||||
const [openMenuSessionId, setOpenMenuSessionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const renameInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSessionId(window.localStorage.getItem("claw.activeSessionId"));
|
||||
const clearActiveSession = () => setActiveSessionId(null);
|
||||
const refreshSessions = () => {
|
||||
fetch("/api/claw/sessions")
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
.then((payload) => {
|
||||
if (Array.isArray(payload))
|
||||
setSessions(dedupeSessions(payload).slice(0, 8));
|
||||
})
|
||||
.catch(() => setSessions([]));
|
||||
};
|
||||
const clearActiveSession = () => {
|
||||
setActiveSessionId(null);
|
||||
refreshSessions();
|
||||
};
|
||||
window.addEventListener("claw-active-session-cleared", clearActiveSession);
|
||||
fetch("/api/claw/sessions")
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
.then((payload) => {
|
||||
if (Array.isArray(payload))
|
||||
setSessions(dedupeSessions(payload).slice(0, 8));
|
||||
})
|
||||
.catch(() => setSessions([]));
|
||||
window.addEventListener("claw-sessions-changed", refreshSessions);
|
||||
window.addEventListener("focus", refreshSessions);
|
||||
refreshSessions();
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"claw-active-session-cleared",
|
||||
clearActiveSession,
|
||||
);
|
||||
window.removeEventListener("claw-sessions-changed", refreshSessions);
|
||||
window.removeEventListener("focus", refreshSessions);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!renamingSessionId) return;
|
||||
window.requestAnimationFrame(() => {
|
||||
renameInputRef.current?.focus();
|
||||
renameInputRef.current?.select();
|
||||
});
|
||||
}, [renamingSessionId]);
|
||||
|
||||
if (!sessions.length) return null;
|
||||
|
||||
const saveTitle = async (sessionId: string, fallbackTitle: string) => {
|
||||
const normalized = draftTitle.trim();
|
||||
if (!normalized || normalized === fallbackTitle) {
|
||||
setRenamingSessionId(null);
|
||||
setDraftTitle("");
|
||||
return;
|
||||
}
|
||||
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ title: normalized }),
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const payload = (await response.json()) as { title?: string };
|
||||
const nextTitle = payload.title || normalized;
|
||||
setSessions((current) =>
|
||||
current.map((item) =>
|
||||
item.session_id === sessionId ? { ...item, preview: nextTitle } : item,
|
||||
),
|
||||
);
|
||||
setRenamingSessionId(null);
|
||||
setDraftTitle("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2 px-3">
|
||||
<div className="flex items-center gap-2 font-medium text-muted-foreground text-xs">
|
||||
<HistoryIcon className="size-3.5" />
|
||||
Claw 历史会话
|
||||
</div>
|
||||
{activeSessionId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground text-xs hover:text-foreground"
|
||||
onClick={() => {
|
||||
window.localStorage.removeItem("claw.activeSessionId");
|
||||
setActiveSessionId(null);
|
||||
}}
|
||||
<div className="flex flex-col gap-1">
|
||||
{sessions.map((session) => {
|
||||
const active = activeSessionId === session.session_id;
|
||||
const loading = loadingSessionId === session.session_id;
|
||||
return (
|
||||
<div
|
||||
key={`claw-session-${session.session_id}-${session.modified_at}`}
|
||||
className="aui-thread-list-item group relative flex h-9 items-center gap-2 rounded-lg transition-colors hover:bg-muted data-[active=true]:bg-muted"
|
||||
data-active={active}
|
||||
>
|
||||
取消续接
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{sessions.map((session) => {
|
||||
const active = activeSessionId === session.session_id;
|
||||
return (
|
||||
<button
|
||||
key={`claw-session-${session.session_id}-${session.modified_at}`}
|
||||
type="button"
|
||||
className="rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-muted data-[active=true]:bg-muted"
|
||||
data-active={active}
|
||||
onClick={async () => {
|
||||
window.localStorage.setItem(
|
||||
"claw.activeSessionId",
|
||||
session.session_id,
|
||||
);
|
||||
setActiveSessionId(session.session_id);
|
||||
setLoadingSessionId(session.session_id);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/claw/sessions/${session.session_id}`,
|
||||
);
|
||||
if (!response.ok) return;
|
||||
const payload = (await response.json()) as ClawStoredSession;
|
||||
replaySession(
|
||||
{renamingSessionId === session.session_id ? (
|
||||
<div className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center px-3">
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
type="text"
|
||||
className="h-7 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={draftTitle}
|
||||
onChange={(event) => setDraftTitle(event.target.value)}
|
||||
onBlur={() => {
|
||||
saveTitle(
|
||||
session.session_id,
|
||||
session.preview || session.session_id,
|
||||
);
|
||||
}}
|
||||
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setRenamingSessionId(null);
|
||||
setDraftTitle("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center truncate px-3 text-start text-sm"
|
||||
onClick={async () => {
|
||||
setOpenMenuSessionId(null);
|
||||
window.localStorage.setItem(
|
||||
"claw.activeSessionId",
|
||||
session.session_id,
|
||||
toReplayRepository(payload, session.session_id),
|
||||
);
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
setActiveSessionId(session.session_id);
|
||||
setLoadingSessionId(session.session_id);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/claw/sessions/${session.session_id}`,
|
||||
);
|
||||
if (!response.ok) return;
|
||||
const payload =
|
||||
(await response.json()) as ClawStoredSession;
|
||||
replaySession(
|
||||
session.session_id,
|
||||
toReplayRepository(payload, session.session_id),
|
||||
);
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="truncate">
|
||||
{loading
|
||||
? "回放中..."
|
||||
: session.preview || session.session_id}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="aui-thread-list-item-more mr-2 flex size-7 shrink-0 items-center justify-center rounded-md p-0 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100 data-[busy=true]:opacity-100 data-[open=true]:bg-accent data-[open=true]:opacity-100"
|
||||
data-busy={deletingSessionId === session.session_id}
|
||||
data-open={openMenuSessionId === session.session_id}
|
||||
aria-expanded={openMenuSessionId === session.session_id}
|
||||
aria-label="打开历史会话菜单"
|
||||
title="更多选项"
|
||||
onClick={() => {
|
||||
setOpenMenuSessionId((current) =>
|
||||
current === session.session_id ? null : session.session_id,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span className="block truncate">
|
||||
{session.preview || session.session_id}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-muted-foreground text-xs">
|
||||
{active ? "下一条消息将续接此会话 · " : ""}
|
||||
{loadingSessionId === session.session_id ? "回放中 · " : ""}
|
||||
{session.turns} 轮 · {session.tool_calls} 次工具
|
||||
</span>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{openMenuSessionId === session.session_id ? (
|
||||
<div className="aui-thread-list-item-more-content absolute top-8 right-1 z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md">
|
||||
<button
|
||||
type="button"
|
||||
className="aui-thread-list-item-more-item flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none hover:bg-accent focus:bg-accent"
|
||||
disabled={renamingSessionId === session.session_id}
|
||||
onClick={() => {
|
||||
setRenamingSessionId(session.session_id);
|
||||
setDraftTitle(session.preview || session.session_id);
|
||||
setOpenMenuSessionId(null);
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="size-4" />
|
||||
重命名
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="aui-thread-list-item-more-item flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm text-destructive outline-none hover:bg-accent focus:bg-accent"
|
||||
disabled={deletingSessionId === session.session_id}
|
||||
onClick={async () => {
|
||||
setDeletingSessionId(session.session_id);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/claw/sessions/${session.session_id}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!response.ok) return;
|
||||
setSessions((current) =>
|
||||
current.filter(
|
||||
(item) => item.session_id !== session.session_id,
|
||||
),
|
||||
);
|
||||
setOpenMenuSessionId(null);
|
||||
if (activeSessionId === session.session_id) {
|
||||
window.localStorage.removeItem("claw.activeSessionId");
|
||||
setActiveSessionId(null);
|
||||
window.dispatchEvent(
|
||||
new Event("claw-active-session-cleared"),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setDeletingSessionId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -68,13 +68,6 @@ type SkillSummary = {
|
||||
when_to_use?: string;
|
||||
};
|
||||
|
||||
type UsageSummary = {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
total_tokens?: number;
|
||||
reasoning_tokens?: number;
|
||||
};
|
||||
|
||||
type ClawState = {
|
||||
model?: string;
|
||||
active_session_id?: string | null;
|
||||
@@ -83,13 +76,32 @@ type ClawState = {
|
||||
type ClawStoredSession = {
|
||||
session_id?: string;
|
||||
model?: string;
|
||||
usage?: UsageSummary;
|
||||
};
|
||||
|
||||
type ModelOption = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
type ContextBudget = {
|
||||
model?: string;
|
||||
context_window_tokens?: number;
|
||||
projected_input_tokens?: number;
|
||||
message_tokens?: number;
|
||||
chat_overhead_tokens?: number;
|
||||
reserved_output_tokens?: number;
|
||||
reserved_compaction_buffer_tokens?: number;
|
||||
reserved_schema_tokens?: number;
|
||||
hard_input_limit_tokens?: number;
|
||||
soft_input_limit_tokens?: number;
|
||||
overflow_tokens?: number;
|
||||
soft_overflow_tokens?: number;
|
||||
exceeds_hard_limit?: boolean;
|
||||
exceeds_soft_limit?: boolean;
|
||||
token_counter_backend?: string;
|
||||
token_counter_source?: string;
|
||||
token_counter_accurate?: boolean;
|
||||
};
|
||||
|
||||
export const Thread: FC = () => {
|
||||
return (
|
||||
<ThreadPrimitive.Root
|
||||
@@ -158,10 +170,10 @@ const ThreadWelcome: FC = () => {
|
||||
<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">
|
||||
<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">
|
||||
Hello there!
|
||||
ZK Data Agent
|
||||
</h1>
|
||||
<p className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both text-muted-foreground text-xl delay-75 duration-200">
|
||||
How can I help you today?
|
||||
中控数据开发平台
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -261,9 +273,11 @@ const ComposerAction: FC = () => {
|
||||
|
||||
const ComposerContextStatus: FC = () => {
|
||||
const status = useComposerContextStatus();
|
||||
const totalTokens = status.usage?.total_tokens ?? 0;
|
||||
const limit = inferContextLimit(status.model);
|
||||
const percent = Math.min(100, Math.round((totalTokens / limit) * 100));
|
||||
const totalTokens = status.contextBudget?.projected_input_tokens ?? 0;
|
||||
const limit = status.contextBudget?.context_window_tokens ?? 0;
|
||||
const percent = limit
|
||||
? Math.min(100, Math.round((totalTokens / limit) * 100))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="mx-2 hidden min-w-0 flex-1 items-center justify-end gap-1.5 sm:flex">
|
||||
@@ -271,6 +285,7 @@ const ComposerContextStatus: FC = () => {
|
||||
percent={percent}
|
||||
totalTokens={totalTokens}
|
||||
limit={limit}
|
||||
contextBudget={status.contextBudget}
|
||||
/>
|
||||
<ModelPickerButton status={status} />
|
||||
</div>
|
||||
@@ -281,10 +296,12 @@ function ContextWindowButton({
|
||||
percent,
|
||||
totalTokens,
|
||||
limit,
|
||||
contextBudget,
|
||||
}: {
|
||||
percent: number;
|
||||
totalTokens: number;
|
||||
limit: number;
|
||||
contextBudget?: ContextBudget;
|
||||
}) {
|
||||
const ringStyle = {
|
||||
background: `conic-gradient(var(--primary) ${percent * 3.6}deg, var(--muted) 0deg)`,
|
||||
@@ -335,8 +352,33 @@ function ContextWindowButton({
|
||||
已用 {formatCompactTokenCount(totalTokens)} 标记,共{" "}
|
||||
{formatCompactTokenCount(limit)}
|
||||
</div>
|
||||
<div className="mt-3 font-semibold text-sm">
|
||||
Codex 自动压缩其背景信息
|
||||
<div className="mt-3 grid gap-1 text-muted-foreground text-xs">
|
||||
<div>
|
||||
软阈值:
|
||||
{formatCompactTokenCount(
|
||||
contextBudget?.soft_input_limit_tokens ?? 0,
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
硬阈值:
|
||||
{formatCompactTokenCount(
|
||||
contextBudget?.hard_input_limit_tokens ?? 0,
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
保留输出:
|
||||
{formatCompactTokenCount(
|
||||
contextBudget?.reserved_output_tokens ?? 0,
|
||||
)}
|
||||
;压缩缓冲:
|
||||
{formatCompactTokenCount(
|
||||
contextBudget?.reserved_compaction_buffer_tokens ?? 0,
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
计数器:{contextBudget?.token_counter_backend ?? "unknown"}
|
||||
{contextBudget?.token_counter_accurate ? "(精确)" : "(估算)"}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Portal>
|
||||
@@ -410,12 +452,10 @@ function useComposerContextStatus() {
|
||||
const latestSessionId = useAuiState((s) =>
|
||||
findLatestMessageMetadataValue(s.thread.messages, "sessionId"),
|
||||
);
|
||||
const latestUsage = useAuiState((s) =>
|
||||
findLatestMessageMetadataValue(s.thread.messages, "usage"),
|
||||
) as UsageSummary | undefined;
|
||||
const wasRunningRef = useRef(false);
|
||||
const [status, setStatus] = useState<{
|
||||
model?: string;
|
||||
usage?: UsageSummary;
|
||||
contextBudget?: ContextBudget;
|
||||
sessionId?: string | null;
|
||||
models: ModelOption[];
|
||||
setModel: (model: string) => Promise<void>;
|
||||
@@ -427,9 +467,22 @@ function useComposerContextStatus() {
|
||||
useEffect(() => {
|
||||
if (typeof latestSessionId === "string" && latestSessionId) {
|
||||
window.localStorage.setItem("claw.activeSessionId", latestSessionId);
|
||||
window.setTimeout(() => {
|
||||
window.dispatchEvent(new Event("claw-sessions-changed"));
|
||||
}, 0);
|
||||
}
|
||||
}, [latestSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasRunningRef.current && !isRunning) {
|
||||
window.dispatchEvent(new Event("claw-sessions-changed"));
|
||||
window.setTimeout(() => {
|
||||
window.dispatchEvent(new Event("claw-sessions-changed"));
|
||||
}, 1500);
|
||||
}
|
||||
wasRunningRef.current = isRunning;
|
||||
}, [isRunning]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -447,6 +500,7 @@ function useComposerContextStatus() {
|
||||
statePayload.active_session_id ||
|
||||
null;
|
||||
let sessionPayload: ClawStoredSession | null = null;
|
||||
let contextBudget: ContextBudget | undefined;
|
||||
if (sessionId) {
|
||||
const sessionResponse = await fetch(
|
||||
`/api/claw/sessions/${sessionId}`,
|
||||
@@ -458,10 +512,20 @@ function useComposerContextStatus() {
|
||||
? ((await sessionResponse.json()) as ClawStoredSession)
|
||||
: null;
|
||||
}
|
||||
const budgetUrl = new URL(
|
||||
"/api/claw/context-budget",
|
||||
window.location.origin,
|
||||
);
|
||||
if (sessionId) budgetUrl.searchParams.set("session_id", sessionId);
|
||||
const budgetResponse = await fetch(budgetUrl, { cache: "no-store" });
|
||||
contextBudget = budgetResponse.ok
|
||||
? ((await budgetResponse.json()) as ContextBudget)
|
||||
: undefined;
|
||||
if (cancelled) return;
|
||||
setStatus((current) => ({
|
||||
model: sessionPayload?.model ?? statePayload.model,
|
||||
usage: latestUsage ?? sessionPayload?.usage,
|
||||
model:
|
||||
contextBudget?.model ?? sessionPayload?.model ?? statePayload.model,
|
||||
contextBudget,
|
||||
sessionId,
|
||||
models: current.models,
|
||||
setModel: current.setModel,
|
||||
@@ -470,7 +534,6 @@ function useComposerContextStatus() {
|
||||
if (!cancelled) {
|
||||
setStatus((current) => ({
|
||||
...current,
|
||||
usage: latestUsage ?? current.usage,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -487,7 +550,7 @@ function useComposerContextStatus() {
|
||||
window.removeEventListener("claw-active-session-cleared", onRefresh);
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [isRunning, latestSessionId, latestUsage]);
|
||||
}, [isRunning, latestSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -528,6 +591,7 @@ function useComposerContextStatus() {
|
||||
setStatus((current) => ({
|
||||
...current,
|
||||
model: payload.model ?? model,
|
||||
contextBudget: undefined,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
@@ -562,14 +626,6 @@ function readMetadataValue(metadata: unknown, key: string): unknown {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function inferContextLimit(model?: string) {
|
||||
const lower = (model ?? "").toLowerCase();
|
||||
if (lower.includes("qwen3-32b")) return 128_000;
|
||||
if (lower.includes("deepseek")) return 128_000;
|
||||
if (lower.includes("mimo")) return 128_000;
|
||||
return 128_000;
|
||||
}
|
||||
|
||||
function formatCompactTokenCount(value: number) {
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
|
||||
@@ -849,7 +905,12 @@ const AssistantMessage: FC = () => {
|
||||
/>
|
||||
);
|
||||
case "text":
|
||||
return <MarkdownText />;
|
||||
return (
|
||||
<MarkdownText
|
||||
text={part.text}
|
||||
isRunning={part.status?.type === "running"}
|
||||
/>
|
||||
);
|
||||
case "reasoning":
|
||||
return <Reasoning {...part} />;
|
||||
case "tool-call":
|
||||
|
||||
@@ -73,10 +73,10 @@ export function ThreadListSidebar({
|
||||
</div>
|
||||
<div className="aui-sidebar-header-heading mr-6 flex flex-col gap-0.5 leading-none">
|
||||
<span className="aui-sidebar-header-title font-semibold">
|
||||
Claw Data Agent
|
||||
ZK Data Agent
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
数据开发工作台
|
||||
中控数据开发平台
|
||||
</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "biome check app/assistant.tsx app/claw-account-gate.tsx app/claw-llm-settings.tsx app/api components/assistant-ui/thread.tsx components/assistant-ui/thread-list.tsx components/assistant-ui/threadlist-sidebar.tsx components/assistant-ui/activity-panel.tsx hooks lib next.config.ts package.json tsconfig.json postcss.config.mjs components.json",
|
||||
"lint:fix": "biome check --write app/assistant.tsx app/claw-account-gate.tsx app/claw-llm-settings.tsx app/api components/assistant-ui/thread.tsx components/assistant-ui/thread-list.tsx components/assistant-ui/threadlist-sidebar.tsx components/assistant-ui/activity-panel.tsx hooks lib next.config.ts package.json tsconfig.json postcss.config.mjs components.json",
|
||||
"format": "biome format app/assistant.tsx app/claw-account-gate.tsx app/claw-llm-settings.tsx app/api components/assistant-ui/thread.tsx components/assistant-ui/thread-list.tsx components/assistant-ui/threadlist-sidebar.tsx components/assistant-ui/activity-panel.tsx hooks lib next.config.ts package.json tsconfig.json postcss.config.mjs components.json",
|
||||
"format:fix": "biome format --write app/assistant.tsx app/claw-account-gate.tsx app/claw-llm-settings.tsx app/api components/assistant-ui/thread.tsx components/assistant-ui/thread-list.tsx components/assistant-ui/threadlist-sidebar.tsx components/assistant-ui/activity-panel.tsx hooks lib next.config.ts package.json tsconfig.json postcss.config.mjs components.json"
|
||||
"lint": "biome check app/assistant.tsx app/claw-account-gate.tsx app/claw-llm-settings.tsx app/claw-theme-switcher.tsx app/api components/assistant-ui/thread.tsx components/assistant-ui/thread-list.tsx components/assistant-ui/threadlist-sidebar.tsx components/assistant-ui/activity-panel.tsx hooks lib next.config.ts package.json tsconfig.json postcss.config.mjs components.json",
|
||||
"lint:fix": "biome check --write app/assistant.tsx app/claw-account-gate.tsx app/claw-llm-settings.tsx app/claw-theme-switcher.tsx app/api components/assistant-ui/thread.tsx components/assistant-ui/thread-list.tsx components/assistant-ui/threadlist-sidebar.tsx components/assistant-ui/activity-panel.tsx hooks lib next.config.ts package.json tsconfig.json postcss.config.mjs components.json",
|
||||
"format": "biome format app/assistant.tsx app/claw-account-gate.tsx app/claw-llm-settings.tsx app/claw-theme-switcher.tsx app/api components/assistant-ui/thread.tsx components/assistant-ui/thread-list.tsx components/assistant-ui/threadlist-sidebar.tsx components/assistant-ui/activity-panel.tsx hooks lib next.config.ts package.json tsconfig.json postcss.config.mjs components.json",
|
||||
"format:fix": "biome format --write app/assistant.tsx app/claw-account-gate.tsx app/claw-llm-settings.tsx app/claw-theme-switcher.tsx app/api components/assistant-ui/thread.tsx components/assistant-ui/thread-list.tsx components/assistant-ui/threadlist-sidebar.tsx components/assistant-ui/activity-panel.tsx hooks lib next.config.ts package.json tsconfig.json postcss.config.mjs components.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^3.0.53",
|
||||
|
||||
Reference in New Issue
Block a user