Files
zk-data-agent/frontend/app/app/api/claw/files/route.ts
T
2026-05-12 22:33:15 +08:00

333 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
import {
accountBaseRoot,
accountSessionInputRoot,
accountSessionOutputRoot,
getCurrentAccount,
} from "@/lib/claw-auth";
export const runtime = "nodejs";
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 url = new URL(req.url);
const requestedPath = url.searchParams.get("path");
const sessionId = normalizeSessionId(url.searchParams.get("session_id"));
const onlineDocs = await readOnlineDocMap(account.id);
if (!requestedPath && sessionId) {
const remoteFiles = await listRemoteSessionFiles(
account.id,
sessionId,
onlineDocs,
);
return Response.json({
session_id: sessionId,
input: [
...(await listSessionFiles(account.id, sessionId, "input", onlineDocs)),
...remoteFiles.input,
],
output: [
...(await listSessionFiles(
account.id,
sessionId,
"output",
onlineDocs,
)),
...remoteFiles.output,
],
});
}
if (!requestedPath) {
const latestSessionId = await findLatestSessionId(account.id);
if (!latestSessionId) {
return Response.json({ session_id: null, input: [], output: [] });
}
const remoteFiles = await listRemoteSessionFiles(
account.id,
latestSessionId,
onlineDocs,
);
return Response.json({
session_id: latestSessionId,
input: [
...(await listSessionFiles(
account.id,
latestSessionId,
"input",
onlineDocs,
)),
...remoteFiles.input,
],
output: [
...(await listSessionFiles(
account.id,
latestSessionId,
"output",
onlineDocs,
)),
...remoteFiles.output,
],
});
}
if (requestedPath.startsWith("jupyter://")) {
return downloadRemoteFile(account.id, requestedPath);
}
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 });
}
}
async function listRemoteSessionFiles(
accountId: string,
sessionId: string,
onlineDocs: Record<string, OnlineDocRecord>,
) {
try {
const target = new URL(`${CLAW_API_URL}/api/jupyter/files`);
target.searchParams.set("account_id", accountId);
target.searchParams.set("session_id", sessionId);
const response = await fetch(target, { cache: "no-store" });
if (!response.ok) return { input: [], output: [] };
const payload = (await response.json()) as {
input?: RemoteSessionFile[];
output?: RemoteSessionFile[];
};
return {
input: mapRemoteFiles(payload.input, sessionId, "input", onlineDocs),
output: mapRemoteFiles(payload.output, sessionId, "output", onlineDocs),
};
} catch {
return { input: [], output: [] };
}
}
type RemoteSessionFile = {
name?: unknown;
path?: unknown;
kind?: unknown;
size?: unknown;
modified_at?: unknown;
};
function mapRemoteFiles(
files: RemoteSessionFile[] | undefined,
sessionId: string,
kind: "input" | "output",
onlineDocs: Record<string, OnlineDocRecord>,
) {
if (!Array.isArray(files)) return [];
return files
.map((file) => {
const remotePath =
typeof file.path === "string" && file.path.startsWith("/")
? file.path
: null;
if (!remotePath) return null;
const name =
typeof file.name === "string" && file.name.trim()
? file.name.trim()
: path.basename(remotePath);
const uri = toJupyterFileUri(sessionId, remotePath);
const onlineDoc = onlineDocs[uri];
return {
name,
path: uri,
kind,
source: "jupyter",
size: typeof file.size === "number" ? file.size : 0,
modified_at:
typeof file.modified_at === "string"
? file.modified_at
: new Date().toISOString(),
download_url: `/api/claw/files?path=${encodeURIComponent(uri)}`,
online_doc_url: cleanOnlineDocUrl(onlineDoc?.url) ?? null,
online_doc_title: onlineDoc?.title ?? null,
online_doc_kind: onlineDoc?.kind ?? null,
online_doc_updated_at: onlineDoc?.updated_at ?? null,
};
})
.filter((file): file is NonNullable<typeof file> => Boolean(file));
}
async function downloadRemoteFile(accountId: string, uri: string) {
const parsed = parseJupyterFileUri(uri);
if (!parsed) {
return Response.json({ error: "无效的远端文件路径" }, { status: 400 });
}
const target = new URL(`${CLAW_API_URL}/api/jupyter/file`);
target.searchParams.set("account_id", accountId);
target.searchParams.set("session_id", parsed.sessionId);
target.searchParams.set("path", parsed.remotePath);
const response = await fetch(target, { cache: "no-store" });
if (!response.ok) {
const payload = await response.text();
return new Response(payload, {
status: response.status,
headers: {
"content-type":
response.headers.get("content-type") ?? "application/json",
},
});
}
return new Response(response.body, {
status: response.status,
headers: {
"content-type":
response.headers.get("content-type") ?? "application/octet-stream",
"content-disposition":
response.headers.get("content-disposition") ??
`attachment; filename="${encodeURIComponent(path.basename(parsed.remotePath))}"`,
},
});
}
function toJupyterFileUri(sessionId: string, remotePath: string) {
return `jupyter://${sessionId}${remotePath}`;
}
function parseJupyterFileUri(uri: string) {
const match = uri.match(/^jupyter:\/\/([^/]+)(\/.*)$/u);
if (!match) return null;
return {
sessionId: match[1],
remotePath: match[2],
};
}
async function listSessionFiles(
accountId: string,
sessionId: string,
kind: "input" | "output",
onlineDocs: Record<string, OnlineDocRecord>,
) {
const accountRoot = path.resolve(accountBaseRoot(accountId));
const dir =
kind === "input"
? accountSessionInputRoot(accountId, sessionId)
: accountSessionOutputRoot(accountId, sessionId);
const resolvedDir = path.resolve(dir);
if (!resolvedDir.startsWith(`${accountRoot}${path.sep}`)) return [];
try {
const entries = await readdir(resolvedDir, { withFileTypes: true });
const files = await Promise.all(
entries
.filter((entry) => entry.isFile())
.map(async (entry) => {
const filePath = path.join(resolvedDir, entry.name);
const fileStat = await stat(filePath);
const onlineDoc = onlineDocs[path.resolve(filePath)];
return {
name: entry.name,
path: filePath,
kind,
size: fileStat.size,
modified_at: fileStat.mtime.toISOString(),
download_url: `/api/claw/files?path=${encodeURIComponent(filePath)}`,
online_doc_url: cleanOnlineDocUrl(onlineDoc?.url) ?? null,
online_doc_title: onlineDoc?.title ?? null,
online_doc_kind: onlineDoc?.kind ?? null,
online_doc_updated_at: onlineDoc?.updated_at ?? null,
};
}),
);
return files.sort((a, b) => b.modified_at.localeCompare(a.modified_at));
} catch {
return [];
}
}
function cleanOnlineDocUrl(value: unknown) {
if (typeof value !== "string") return null;
return value.trim().replace(/[.,;"']+$/u, "") || null;
}
type OnlineDocRecord = {
url?: string;
title?: string;
kind?: string;
updated_at?: number;
};
async function readOnlineDocMap(accountId: string) {
const mapPath = path.join(
accountBaseRoot(accountId),
"integrations",
"feishu",
"online-docs.json",
);
try {
const payload = JSON.parse(await readFile(mapPath, "utf8")) as {
files?: Record<string, OnlineDocRecord>;
};
return payload.files ?? {};
} catch {
return {};
}
}
function normalizeSessionId(value: string | null) {
const trimmed = value?.trim();
return trimmed || null;
}
async function findLatestSessionId(accountId: string) {
const sessionsRoot = path.join(accountBaseRoot(accountId), "sessions");
try {
const entries = await readdir(sessionsRoot, { withFileTypes: true });
const candidates = await Promise.all(
entries
.filter((entry) => entry.isDirectory())
.map(async (entry) => {
const sessionDir = path.join(sessionsRoot, entry.name);
const sessionFile = path.join(sessionDir, "session.json");
try {
const sessionStat = await stat(sessionFile);
return { id: entry.name, modifiedAt: sessionStat.mtimeMs };
} catch {
const dirStat = await stat(sessionDir);
return { id: entry.name, modifiedAt: dirStat.mtimeMs };
}
}),
);
candidates.sort((a, b) => b.modifiedAt - a.modifiedAt);
return candidates.at(0)?.id ?? null;
} catch {
return null;
}
}
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";
}