Add assistant-ui data agent frontend
This commit is contained in:
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Assistant } from "./assistant";
|
||||
|
||||
export default function Home() {
|
||||
return <Assistant />;
|
||||
}
|
||||
Reference in New Issue
Block a user