Add assistant-ui data agent frontend

This commit is contained in:
武阳
2026-05-06 16:18:32 +08:00
parent d6a2359dc1
commit 7d4ae3e7ba
119 changed files with 14330 additions and 14420 deletions
+314
View File
@@ -0,0 +1,314 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import {
createUIMessageStream,
createUIMessageStreamResponse,
type UIMessage,
type UIMessageStreamWriter,
} from "ai";
import {
accountSessionInputRoot,
accountSessionOutputRoot,
getCurrentAccount,
} from "@/lib/claw-auth";
export const runtime = "nodejs";
type ClawChatResponse = {
final_output?: string;
session_id?: string;
tool_calls?: number;
stop_reason?: string;
elapsed_ms?: number;
usage?: UsageSummary;
transcript?: ClawTranscriptEntry[];
error?: string;
detail?: string;
};
type ChatRequestBody = {
id?: string;
messages: UIMessage[];
resumeSessionId?: string;
};
type ClawTranscriptEntry = {
role?: string;
content?: string;
tool_call_id?: string;
tool_calls?: ClawToolCall[];
};
type ClawToolCall = {
id?: string;
name?: string;
arguments?: unknown;
function?: {
name?: string;
arguments?: unknown;
};
};
type UsageSummary = {
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
reasoning_tokens?: number;
};
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
export async function POST(req: Request) {
const account = await getCurrentAccount();
if (!account) return new Response("请先登录账号", { status: 401 });
const { id, messages, resumeSessionId }: ChatRequestBody = await req.json();
const resumeId = resumeSessionId ?? getLastSessionId(messages);
const sessionId = safeSessionId(resumeId ?? id ?? crypto.randomUUID());
await ensureSessionDirectories(account.id, sessionId);
const userPrompt = await getLastUserText(messages, account.id, sessionId);
if (!userPrompt) {
return new Response("Prompt is empty", { status: 400 });
}
const runtimeContext = renderSessionRuntimeContext(account.id, sessionId);
const stream = createUIMessageStream({
execute: async ({ writer }) => {
writer.write({ type: "start" });
writer.write({ type: "start-step" });
const startedAt = Date.now();
writer.write({ type: "reasoning-start", id: "reasoning-1" });
writer.write({
type: "reasoning-delta",
id: "reasoning-1",
delta: "思考中,正在判断是否需要调用工具。",
});
const payload = await callClawBackend(
userPrompt,
runtimeContext,
account.id,
sessionId,
resumeId,
);
const elapsedMs = payload.elapsed_ms ?? Date.now() - startedAt;
const text = formatClawResponse(payload);
writer.write({
type: "reasoning-delta",
id: "reasoning-1",
delta: `\n思考并执行完成,用时 ${formatDuration(elapsedMs)}`,
});
writer.write({ type: "reasoning-end", id: "reasoning-1" });
writeToolTrace(writer, payload.transcript);
writer.write({ type: "text-start", id: "text-1" });
writer.write({ type: "text-delta", id: "text-1", delta: text });
writer.write({ type: "text-end", id: "text-1" });
writer.write({ type: "finish-step" });
writer.write({
type: "finish",
finishReason: payload.error ? "error" : "stop",
messageMetadata: {
sessionId: payload.session_id,
toolCalls: payload.tool_calls,
stopReason: payload.stop_reason,
elapsedMs,
usage: payload.usage,
},
});
},
});
return createUIMessageStreamResponse({ stream });
}
function formatDuration(ms: number) {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function writeToolTrace(
writer: UIMessageStreamWriter<UIMessage>,
transcript?: ClawTranscriptEntry[],
) {
if (!transcript?.length) return;
const toolResults = new Map<string, string>();
for (const entry of transcript) {
if (entry.role === "tool" && entry.tool_call_id) {
toolResults.set(entry.tool_call_id, entry.content ?? "");
}
}
for (const entry of transcript) {
if (!entry.tool_calls?.length) continue;
for (const call of entry.tool_calls) {
const toolCallId = call.id;
const toolName = call.function?.name ?? call.name;
if (!toolCallId || !toolName) continue;
writer.write({
type: "tool-input-available",
toolCallId,
toolName,
input: parseToolInput(call.function?.arguments ?? call.arguments),
});
if (toolResults.has(toolCallId)) {
writer.write({
type: "tool-output-available",
toolCallId,
output: toolResults.get(toolCallId),
});
}
}
}
}
function parseToolInput(value: unknown) {
if (typeof value !== "string") return value ?? {};
try {
return JSON.parse(value);
} catch {
return value;
}
}
async function getLastUserText(
messages: UIMessage[],
accountId: string,
threadId?: string,
) {
const lastUserMessage = [...messages]
.reverse()
.find((msg) => msg.role === "user");
if (!lastUserMessage) return "";
const chunks: string[] = [];
for (const part of lastUserMessage.parts) {
if (part.type === "text") chunks.push(part.text);
if (part.type === "file") {
chunks.push(
await saveFilePart(part, accountId, threadId ?? lastUserMessage.id),
);
}
}
return chunks.filter(Boolean).join("\n\n").trim();
}
async function ensureSessionDirectories(accountId: string, sessionId: string) {
await Promise.all([
mkdir(accountSessionInputRoot(accountId, sessionId), { recursive: true }),
mkdir(accountSessionOutputRoot(accountId, sessionId), { recursive: true }),
]);
}
function renderSessionRuntimeContext(accountId: string, sessionId: string) {
return [
"[当前会话目录]",
`- 输入目录: ${accountSessionInputRoot(accountId, sessionId)}`,
`- 输出目录: ${accountSessionOutputRoot(accountId, sessionId)}`,
"如需保存本轮任务产物,请优先写入输出目录。",
].join("\n\n");
}
function getLastSessionId(messages: UIMessage[]) {
for (const message of [...messages].reverse()) {
if (message.role !== "assistant") continue;
const metadata = message.metadata;
if (metadata && typeof metadata === "object" && "sessionId" in metadata) {
const sessionId = (metadata as { sessionId?: unknown }).sessionId;
if (typeof sessionId === "string" && sessionId) return sessionId;
}
}
return undefined;
}
async function saveFilePart(
part: Extract<UIMessage["parts"][number], { type: "file" }>,
accountId: string,
threadId: string,
) {
const filename = safeFilename(part.filename ?? "attachment");
if (!part.url.startsWith("data:")) {
return `[文件] ${filename}\n- 类型: ${part.mediaType}\n- URL: ${part.url}`;
}
const match = part.url.match(/^data:([^;,]+)?(;base64)?,(.*)$/);
if (!match) {
return `[文件] ${filename}\n- 类型: ${part.mediaType}\n- 状态: 无法解析 data URL`;
}
const isBase64 = Boolean(match[2]);
const body = match[3] ?? "";
const bytes = isBase64
? Buffer.from(body, "base64")
: Buffer.from(decodeURIComponent(body), "utf8");
const uploadDir = accountSessionInputRoot(accountId, safeFilename(threadId));
await mkdir(uploadDir, { recursive: true });
const filePath = path.join(uploadDir, `${Date.now()}-${filename}`);
await writeFile(filePath, bytes);
return [
`[文件已上传] ${filename}`,
`- 类型: ${part.mediaType}`,
`- 本地路径: ${filePath}`,
"请在需要读取文件内容时使用 read_file 工具读取该本地路径。",
].join("\n");
}
function safeFilename(value: string) {
return value.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 120) || "attachment";
}
async function callClawBackend(
prompt: string,
runtimeContext: string,
accountId: string,
sessionId: string,
resumeSessionId?: string,
): Promise<ClawChatResponse> {
try {
const response = await fetch(`${CLAW_API_URL}/api/chat`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
prompt,
runtime_context: runtimeContext,
account_id: accountId,
session_id: sessionId,
...(resumeSessionId ? { resume_session_id: resumeSessionId } : {}),
}),
});
const payload = (await response.json()) as ClawChatResponse;
if (!response.ok) {
return {
error:
payload.detail ??
payload.error ??
`Claw backend returned ${response.status}`,
};
}
return payload;
} catch (err) {
return {
error:
err instanceof Error
? `Unable to reach Claw backend: ${err.message}`
: "Unable to reach Claw backend",
};
}
}
function formatClawResponse(payload: ClawChatResponse) {
if (payload.error) return payload.error;
return payload.final_output ?? "";
}
function safeSessionId(value: string) {
return (
value.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 120) || crypto.randomUUID()
);
}
@@ -0,0 +1,20 @@
import { loginAccount } from "@/lib/claw-auth";
export async function POST(req: Request) {
try {
const payload = (await req.json()) as {
username?: string;
password?: string;
};
const account = await loginAccount(
payload.username ?? "",
payload.password ?? "",
);
return Response.json({ account });
} catch (err) {
return Response.json(
{ error: err instanceof Error ? err.message : "登录失败" },
{ status: 400 },
);
}
}
@@ -0,0 +1,6 @@
import { logoutAccount } from "@/lib/claw-auth";
export async function POST() {
await logoutAccount();
return Response.json({ ok: true });
}
@@ -0,0 +1,5 @@
import { getCurrentAccount } from "@/lib/claw-auth";
export async function GET() {
return Response.json({ account: await getCurrentAccount() });
}
@@ -0,0 +1,20 @@
import { registerAccount } from "@/lib/claw-auth";
export async function POST(req: Request) {
try {
const payload = (await req.json()) as {
username?: string;
password?: string;
};
const account = await registerAccount(
payload.username ?? "",
payload.password ?? "",
);
return Response.json({ account });
} catch (err) {
return Response.json(
{ error: err instanceof Error ? err.message : "注册失败" },
{ status: 400 },
);
}
}
+48
View File
@@ -0,0 +1,48 @@
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import { accountBaseRoot, getCurrentAccount } from "@/lib/claw-auth";
export const runtime = "nodejs";
export async function GET(req: Request) {
const account = await getCurrentAccount();
if (!account)
return Response.json({ error: "请先登录账号" }, { status: 401 });
const url = new URL(req.url);
const requestedPath = url.searchParams.get("path");
if (!requestedPath) {
return Response.json({ error: "缺少文件路径" }, { status: 400 });
}
const accountRoot = path.resolve(accountBaseRoot(account.id));
const filePath = path.resolve(requestedPath);
if (!filePath.startsWith(`${accountRoot}${path.sep}`)) {
return Response.json({ error: "文件不在当前账号目录内" }, { status: 403 });
}
try {
const fileStat = await stat(filePath);
if (!fileStat.isFile()) {
return Response.json({ error: "目标不是文件" }, { status: 400 });
}
const bytes = await readFile(filePath);
return new Response(bytes, {
headers: {
"content-type": contentTypeFor(filePath),
"content-disposition": `attachment; filename="${encodeURIComponent(path.basename(filePath))}"`,
},
});
} catch {
return Response.json({ error: "文件不存在" }, { status: 404 });
}
}
function contentTypeFor(filePath: string) {
const ext = path.extname(filePath).toLowerCase();
if (ext === ".json") return "application/json; charset=utf-8";
if (ext === ".jsonl" || ext === ".txt" || ext === ".md" || ext === ".csv") {
return "text/plain; charset=utf-8";
}
return "application/octet-stream";
}
+53
View File
@@ -0,0 +1,53 @@
import { getCurrentAccount } from "@/lib/claw-auth";
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
type ModelListRequest = {
base_url?: string;
api_key?: string;
};
export async function GET() {
return proxyModelsRequest("GET");
}
export async function POST(req: Request) {
const payload = (await req.json()) as ModelListRequest;
return proxyModelsRequest("POST", payload);
}
async function proxyModelsRequest(
method: "GET" | "POST",
payload?: ModelListRequest,
) {
const account = await getCurrentAccount();
if (!account) return Response.json({ models: [] }, { status: 401 });
try {
const response = await fetch(`${CLAW_API_URL}/api/models`, {
method,
headers: payload ? { "content-type": "application/json" } : undefined,
body: payload ? JSON.stringify(payload) : undefined,
cache: "no-store",
});
const body = await response.text();
return new Response(body, {
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 Claw backend: ${err.message}`
: "Unable to reach Claw backend",
models: [],
},
{ status: 502 },
);
}
}
@@ -0,0 +1,25 @@
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,
{ 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, { 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",
},
});
}
@@ -0,0 +1,22 @@
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() {
const account = await getCurrentAccount();
if (!account) return Response.json([], { status: 401 });
const url = new URL(`${CLAW_API_URL}/api/sessions`);
url.searchParams.set("account_id", account.id);
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",
},
});
}
+20
View File
@@ -0,0 +1,20 @@
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() {
const account = await getCurrentAccount();
if (!account) return Response.json([], { status: 401 });
const response = await fetch(`${CLAW_API_URL}/api/skills`, {
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",
},
});
}
+58
View File
@@ -0,0 +1,58 @@
import { getCurrentAccount } from "@/lib/claw-auth";
type ClawState = {
model?: string;
base_url?: string;
api_key?: string;
cwd?: string;
allow_shell?: boolean;
allow_write?: boolean;
};
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
export async function GET() {
return proxyStateRequest("GET");
}
export async function POST(req: Request) {
const payload = (await req.json()) as ClawState;
return proxyStateRequest("POST", payload);
}
async function proxyStateRequest(method: "GET" | "POST", payload?: ClawState) {
try {
const account = await getCurrentAccount();
const url = new URL(`${CLAW_API_URL}/api/state`);
if (method === "GET" && account)
url.searchParams.set("account_id", account.id);
const response = await fetch(url, {
method,
headers: payload ? { "content-type": "application/json" } : undefined,
body: payload
? JSON.stringify({
...payload,
...(account ? { account_id: account.id } : {}),
})
: undefined,
});
const body = await response.text();
return new Response(body, {
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 Claw backend: ${err.message}`
: "Unable to reach Claw backend",
},
{ status: 502 },
);
}
}
+127
View File
@@ -0,0 +1,127 @@
"use client";
import type { ExportedMessageRepository } from "@assistant-ui/core";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import {
AssistantChatTransport,
useChatRuntime,
} from "@assistant-ui/react-ai-sdk";
import type { UIMessage } from "ai";
import { useCallback, useMemo } from "react";
import {
ActivityPanel,
ActivityProvider,
} from "@/components/assistant-ui/activity-panel";
import { Thread } from "@/components/assistant-ui/thread";
import { ThreadListSidebar } from "@/components/assistant-ui/threadlist-sidebar";
import { Separator } from "@/components/ui/separator";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { ClawSessionReplayProvider } from "@/lib/claw-session-replay";
import { ClawAccountGate, useClawAccount } from "./claw-account-gate";
import { ClawLlmSettings } from "./claw-llm-settings";
export const Assistant = () => {
const { account, isLoading, setAccount } = useClawAccount();
const transport = useMemo(
() =>
new AssistantChatTransport({
api: "/api/chat",
prepareSendMessagesRequest: async (options) => {
const body = options.body as Record<string, unknown>;
const messages = options.messages as UIMessage[];
const selectedSessionId =
typeof window !== "undefined"
? window.localStorage.getItem("claw.activeSessionId")
: null;
const lastSessionId = getLastSessionId(messages);
const selectedResumeSessionId =
messages.length > 1 ? selectedSessionId : null;
return {
body: {
...body,
id: options.id,
messages,
resumeSessionId:
lastSessionId ?? selectedResumeSessionId ?? undefined,
},
};
},
}),
[],
);
const runtime = useChatRuntime({
transport,
});
const replaySession = useCallback(
(sessionId: string, repository: ExportedMessageRepository) => {
window.localStorage.setItem("claw.activeSessionId", sessionId);
runtime.thread.import(repository);
},
[runtime],
);
if (isLoading) {
return (
<div className="flex min-h-dvh items-center justify-center bg-background text-muted-foreground text-sm">
...
</div>
);
}
if (!account) {
return <ClawAccountGate onAccount={setAccount} />;
}
return (
<AssistantRuntimeProvider runtime={runtime}>
<ClawSessionReplayProvider value={{ replaySession }}>
<ActivityProvider>
<SidebarProvider>
<div className="flex h-dvh w-full pr-0.5">
<ThreadListSidebar
account={account}
onLogout={() => setAccount(null)}
/>
<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">
Claw Data Agent
</div>
</div>
<ClawLlmSettings />
</header>
<div className="flex min-h-0 flex-1 overflow-hidden">
<div className="min-w-0 flex-1 overflow-hidden">
<Thread />
</div>
<ActivityPanel />
</div>
</SidebarInset>
</div>
</SidebarProvider>
</ActivityProvider>
</ClawSessionReplayProvider>
</AssistantRuntimeProvider>
);
};
function getLastSessionId(messages: UIMessage[]) {
for (const message of [...messages].reverse()) {
if (message.role !== "assistant") continue;
const metadata = message.metadata;
if (metadata && typeof metadata === "object" && "sessionId" in metadata) {
const sessionId = (metadata as { sessionId?: unknown }).sessionId;
if (typeof sessionId === "string" && sessionId) return sessionId;
}
}
return undefined;
}
+150
View File
@@ -0,0 +1,150 @@
"use client";
import { LogOutIcon } from "lucide-react";
import type { ReactNode } from "react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export type ClawAccount = {
id: string;
username: string;
};
export function useClawAccount() {
const [account, setAccount] = useState<ClawAccount | null>(null);
const [isLoading, setIsLoading] = useState(true);
const refresh = useCallback(async () => {
setIsLoading(true);
try {
const response = await fetch("/api/claw/auth/me", { cache: "no-store" });
const payload = (await response.json()) as {
account?: ClawAccount | null;
};
setAccount(payload.account ?? null);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
return { account, isLoading, refresh, setAccount };
}
export function ClawAccountGate({
onAccount,
}: {
onAccount: (account: ClawAccount) => void;
}) {
const [mode, setMode] = useState<"login" | "register">("login");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
async function submit() {
setError("");
setIsSubmitting(true);
try {
const response = await fetch(`/api/claw/auth/${mode}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ username, password }),
});
const payload = (await response.json()) as {
account?: ClawAccount;
error?: string;
};
if (!response.ok || !payload.account) {
throw new Error(payload.error ?? "账号操作失败");
}
onAccount(payload.account);
} catch (err) {
setError(err instanceof Error ? err.message : "账号操作失败");
} finally {
setIsSubmitting(false);
}
}
return (
<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>
<p className="mt-1 text-muted-foreground text-sm">
</p>
</div>
<div className="grid gap-3">
<Input
autoFocus
placeholder="账号"
value={username}
onChange={(event) => setUsername(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") submit();
}}
/>
<Input
type="password"
placeholder="密码"
value={password}
onChange={(event) => setPassword(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") submit();
}}
/>
{error ? <p className="text-destructive text-sm">{error}</p> : null}
<Button onClick={submit} disabled={isSubmitting}>
{isSubmitting ? "处理中..." : mode === "login" ? "登录" : "注册"}
</Button>
<button
type="button"
className="text-muted-foreground text-sm hover:text-foreground"
onClick={() => {
setError("");
setMode((current) =>
current === "login" ? "register" : "login",
);
}}
>
{mode === "login" ? "没有账号,去注册" : "已有账号,去登录"}
</button>
</div>
</div>
</div>
);
}
export function ClawAccountBar({
account,
onLogout,
children,
}: {
account: ClawAccount;
onLogout: () => void;
children: ReactNode;
}) {
async function logout() {
await fetch("/api/claw/auth/logout", { method: "POST" });
window.localStorage.removeItem("claw.activeSessionId");
onLogout();
}
return (
<div className="ml-auto flex items-center gap-2">
{children}
<span className="max-w-32 truncate text-muted-foreground text-sm">
{account.username}
</span>
<Button variant="ghost" size="icon" onClick={logout} title="退出登录">
<LogOutIcon className="size-4" />
<span className="sr-only">退</span>
</Button>
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
"use client";
import { Settings2Icon } from "lucide-react";
import type { ReactNode } from "react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
type ClawState = {
base_url: string;
api_key?: string;
cwd?: string;
allow_shell?: boolean;
allow_write?: boolean;
};
const DEFAULT_STATE: ClawState = {
base_url: "http://model.mify.ai.srv/v1",
api_key: "sk-Jxoh5lf1jWg6HQZYYyfyagXudGxUXNAgkZklxMnnXHn7pFmU",
};
export function ClawLlmSettings() {
const [open, setOpen] = useState(false);
const [state, setState] = useState<ClawState>(DEFAULT_STATE);
const [status, setStatus] = useState<string>("");
const [isSaving, setIsSaving] = useState(false);
const loadState = useCallback(async () => {
setStatus("");
const response = await fetch("/api/claw/state");
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error ?? "读取后端配置失败");
}
setState((current) => ({
...DEFAULT_STATE,
...payload,
api_key: payload.api_key ?? current.api_key ?? DEFAULT_STATE.api_key,
}));
}, []);
useEffect(() => {
if (!open) return;
loadState().catch((err: unknown) => {
setStatus(err instanceof Error ? err.message : "读取后端配置失败");
});
}, [loadState, open]);
async function saveState() {
setIsSaving(true);
setStatus("");
try {
const response = await fetch("/api/claw/state", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
base_url: state.base_url,
api_key: state.api_key,
}),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error ?? payload.detail ?? "保存失败");
}
setState({
...DEFAULT_STATE,
...payload,
api_key: state.api_key,
});
setStatus("已更新后端 API 配置");
} catch (err) {
setStatus(err instanceof Error ? err.message : "保存失败");
} finally {
setIsSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Settings2Icon />
LLM API
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle> LLM API</DialogTitle>
<DialogDescription>
Claw Agent 使 Base URL API Key
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<Field label="Base URL">
<Input
value={state.base_url}
onChange={(event) =>
setState((current) => ({
...current,
base_url: event.target.value,
}))
}
/>
</Field>
<Field label="API Key">
<Input
type="password"
value={state.api_key ?? ""}
onChange={(event) =>
setState((current) => ({
...current,
api_key: event.target.value,
}))
}
/>
</Field>
</div>
{status ? (
<p className="text-muted-foreground text-sm">{status}</p>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => setState(DEFAULT_STATE)}>
</Button>
<Button onClick={saveState} disabled={isSaving}>
{isSaving ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="grid gap-2 text-sm">
<span className="font-medium">{label}</span>
{children}
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+141
View File
@@ -0,0 +1,141 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--animate-shimmer: shimmer-sweep var(--shimmer-duration, 1000ms) linear
infinite both;
@keyframes shimmer-sweep {
from {
background-position: 150% 0;
}
to {
background-position: -100% 0;
}
}
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
:root {
color-scheme: light;
}
:root.dark {
color-scheme: dark;
}
body {
@apply bg-background text-foreground;
}
}
+35
View File
@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { TooltipProvider } from "@/components/ui/tooltip";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Claw Data Agent",
description: "assistant-ui based data agent workbench",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<TooltipProvider>{children}</TooltipProvider>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { Assistant } from "./assistant";
export default function Home() {
return <Assistant />;
}