Add Feishu online document conversion
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { getCurrentAccount } from "@/lib/claw-auth";
|
||||
|
||||
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const account = await getCurrentAccount();
|
||||
if (!account)
|
||||
return Response.json({ error: "请先登录账号" }, { status: 401 });
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
path?: unknown;
|
||||
title?: unknown;
|
||||
folder_token?: unknown;
|
||||
};
|
||||
const filePath = typeof body.path === "string" ? body.path.trim() : "";
|
||||
if (!filePath) {
|
||||
return Response.json({ error: "Missing file path" }, { status: 400 });
|
||||
}
|
||||
|
||||
const response = await fetch(`${CLAW_API_URL}/api/files/online-doc`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
account_id: account.id,
|
||||
path: filePath,
|
||||
...(typeof body.title === "string" && body.title.trim()
|
||||
? { title: body.title.trim() }
|
||||
: {}),
|
||||
...(typeof body.folder_token === "string" && body.folder_token.trim()
|
||||
? { folder_token: body.folder_token.trim() }
|
||||
: {}),
|
||||
}),
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -17,11 +17,17 @@ export async function GET(req: Request) {
|
||||
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) {
|
||||
return Response.json({
|
||||
session_id: sessionId,
|
||||
input: await listSessionFiles(account.id, sessionId, "input"),
|
||||
output: await listSessionFiles(account.id, sessionId, "output"),
|
||||
input: await listSessionFiles(account.id, sessionId, "input", onlineDocs),
|
||||
output: await listSessionFiles(
|
||||
account.id,
|
||||
sessionId,
|
||||
"output",
|
||||
onlineDocs,
|
||||
),
|
||||
});
|
||||
}
|
||||
if (!requestedPath) {
|
||||
@@ -31,8 +37,18 @@ export async function GET(req: Request) {
|
||||
}
|
||||
return Response.json({
|
||||
session_id: latestSessionId,
|
||||
input: await listSessionFiles(account.id, latestSessionId, "input"),
|
||||
output: await listSessionFiles(account.id, latestSessionId, "output"),
|
||||
input: await listSessionFiles(
|
||||
account.id,
|
||||
latestSessionId,
|
||||
"input",
|
||||
onlineDocs,
|
||||
),
|
||||
output: await listSessionFiles(
|
||||
account.id,
|
||||
latestSessionId,
|
||||
"output",
|
||||
onlineDocs,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,6 +79,7 @@ async function listSessionFiles(
|
||||
accountId: string,
|
||||
sessionId: string,
|
||||
kind: "input" | "output",
|
||||
onlineDocs: Record<string, OnlineDocRecord>,
|
||||
) {
|
||||
const accountRoot = path.resolve(accountBaseRoot(accountId));
|
||||
const dir =
|
||||
@@ -80,6 +97,7 @@ async function listSessionFiles(
|
||||
.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,
|
||||
@@ -87,6 +105,9 @@ async function listSessionFiles(
|
||||
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_updated_at: onlineDoc?.updated_at ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -96,6 +117,34 @@ async function listSessionFiles(
|
||||
}
|
||||
}
|
||||
|
||||
function cleanOnlineDocUrl(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
return value.trim().replace(/[.,;,。;、"'“”‘’]+$/u, "") || null;
|
||||
}
|
||||
|
||||
type OnlineDocRecord = {
|
||||
url?: string;
|
||||
title?: 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;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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({ error: "请先登录账号" }, { status: 401 });
|
||||
|
||||
const url = new URL(`${CLAW_API_URL}/api/integrations/feishu/status`);
|
||||
url.searchParams.set("account_id", account.id);
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
return proxyResponse(response);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const account = await getCurrentAccount();
|
||||
if (!account)
|
||||
return Response.json({ error: "请先登录账号" }, { status: 401 });
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
action?: unknown;
|
||||
};
|
||||
const action = typeof body.action === "string" ? body.action : "login";
|
||||
const endpoint =
|
||||
action === "logout"
|
||||
? "/api/integrations/feishu/logout"
|
||||
: "/api/integrations/feishu/login";
|
||||
|
||||
const response = await fetch(`${CLAW_API_URL}${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ account_id: account.id }),
|
||||
cache: "no-store",
|
||||
});
|
||||
return proxyResponse(response);
|
||||
}
|
||||
|
||||
async function proxyResponse(response: Response) {
|
||||
const payload = await response.text();
|
||||
return new Response(payload, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
response.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
ClockIcon,
|
||||
CloudUploadIcon,
|
||||
DownloadIcon,
|
||||
FileTextIcon,
|
||||
Loader2Icon,
|
||||
MessageSquareTextIcon,
|
||||
PanelRightCloseIcon,
|
||||
WrenchIcon,
|
||||
@@ -219,6 +221,9 @@ type SessionFile = {
|
||||
size: number;
|
||||
modified_at: string;
|
||||
download_url: string;
|
||||
online_doc_url?: string | null;
|
||||
online_doc_title?: string | null;
|
||||
online_doc_updated_at?: number | null;
|
||||
};
|
||||
|
||||
type SessionFilesPayload = {
|
||||
@@ -383,19 +388,157 @@ function FileSection({
|
||||
}
|
||||
|
||||
function SessionFileRow({ file }: { file: SessionFile }) {
|
||||
const [onlineDoc, setOnlineDoc] = useState<{
|
||||
status: "idle" | "loading" | "auth" | "done" | "error";
|
||||
message?: string;
|
||||
url?: string | null;
|
||||
}>({
|
||||
status: file.online_doc_url ? "done" : "idle",
|
||||
message: file.online_doc_url ? "已转在线文档" : undefined,
|
||||
url: file.online_doc_url ?? null,
|
||||
});
|
||||
const canCreateOnlineDoc = isOnlineDocSupported(file.name);
|
||||
const onlineDocUrl = onlineDoc.url ?? file.online_doc_url ?? null;
|
||||
|
||||
async function createOnlineDoc() {
|
||||
if (!canCreateOnlineDoc || onlineDoc.status === "loading") return;
|
||||
let pendingWindow: Window | null = null;
|
||||
try {
|
||||
pendingWindow = window.open("about:blank", "_blank");
|
||||
if (pendingWindow) {
|
||||
pendingWindow.document.write("正在创建飞书在线文档...");
|
||||
}
|
||||
} catch {
|
||||
pendingWindow = null;
|
||||
}
|
||||
setOnlineDoc({ status: "loading", message: "正在转换..." });
|
||||
try {
|
||||
const response = await fetch("/api/claw/files/online-doc", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ path: file.path, title: file.name }),
|
||||
});
|
||||
const payload = (await response.json().catch(() => ({}))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (response.status === 409) {
|
||||
const loginResponse = await fetch("/api/claw/integrations/feishu", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ action: "login" }),
|
||||
});
|
||||
const loginPayload = (await loginResponse
|
||||
.json()
|
||||
.catch(() => ({}))) as FeishuLoginPayload;
|
||||
if (!loginResponse.ok) {
|
||||
pendingWindow?.close();
|
||||
setOnlineDoc({
|
||||
status: "error",
|
||||
message:
|
||||
extractErrorMessage(
|
||||
loginPayload as unknown as Record<string, unknown>,
|
||||
) ?? "飞书授权启动失败",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const loginUrl = loginPayload.login?.login_url ?? null;
|
||||
if (loginUrl) {
|
||||
if (pendingWindow) {
|
||||
try {
|
||||
pendingWindow.opener = null;
|
||||
} catch {
|
||||
// 有些浏览器不允许改 opener,不影响后续跳转。
|
||||
}
|
||||
pendingWindow.location.href = loginUrl;
|
||||
} else {
|
||||
window.open(loginUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
} else if (pendingWindow) {
|
||||
pendingWindow.close();
|
||||
}
|
||||
setOnlineDoc({
|
||||
status: "auth",
|
||||
message: loginUrl ? "完成授权后再点一次" : "需要先完成飞书授权",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
pendingWindow?.close();
|
||||
setOnlineDoc({
|
||||
status: "error",
|
||||
message: extractErrorMessage(payload) ?? "转换失败",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const url = typeof payload.url === "string" ? payload.url : null;
|
||||
if (url) {
|
||||
if (pendingWindow) {
|
||||
try {
|
||||
pendingWindow.opener = null;
|
||||
} catch {
|
||||
// 有些浏览器不允许改 opener,不影响后续跳转。
|
||||
}
|
||||
pendingWindow.location.href = url;
|
||||
} else {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
} else {
|
||||
pendingWindow?.close();
|
||||
}
|
||||
setOnlineDoc({
|
||||
status: "done",
|
||||
message: url ? "已生成在线文档" : "已生成",
|
||||
url,
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent("claw-session-files-changed"));
|
||||
} catch (error) {
|
||||
pendingWindow?.close();
|
||||
setOnlineDoc({
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "转换失败",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border px-3 py-2">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<FileTextIcon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-sm" title={file.name}>
|
||||
{file.name}
|
||||
</div>
|
||||
{onlineDocUrl ? (
|
||||
<a
|
||||
href={onlineDocUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block truncate font-medium text-primary text-sm underline-offset-4 hover:underline"
|
||||
title={`${file.name} · 打开在线文档`}
|
||||
>
|
||||
{file.name}
|
||||
</a>
|
||||
) : (
|
||||
<div className="truncate font-medium text-sm" title={file.name}>
|
||||
{file.name}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{fileExtension(file.name).toUpperCase() || "FILE"} ·{" "}
|
||||
{formatBytes(file.size)}
|
||||
</div>
|
||||
{onlineDoc.message ? (
|
||||
<div
|
||||
className={cn(
|
||||
"truncate text-xs",
|
||||
onlineDoc.status === "error"
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
title={onlineDoc.message}
|
||||
>
|
||||
{onlineDoc.message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<a
|
||||
href={file.download_url}
|
||||
@@ -414,10 +557,50 @@ function SessionFileRow({ file }: { file: SessionFile }) {
|
||||
>
|
||||
<MessageSquareTextIcon className="size-4" />
|
||||
</button>
|
||||
{canCreateOnlineDoc ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={`将 ${file.name} 转为在线文档`}
|
||||
title="转为在线文档"
|
||||
disabled={onlineDoc.status === "loading"}
|
||||
onClick={createOnlineDoc}
|
||||
>
|
||||
{onlineDoc.status === "loading" ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<CloudUploadIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FeishuLoginPayload = {
|
||||
login?: {
|
||||
login_url?: string | null;
|
||||
user_code?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
function isOnlineDocSupported(fileName: string) {
|
||||
return ["csv", "docx", "md", "txt", "xlsx"].includes(
|
||||
fileExtension(fileName).toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
function extractErrorMessage(payload: Record<string, unknown>) {
|
||||
const detail = payload.detail;
|
||||
if (typeof detail === "string") return detail;
|
||||
if (detail && typeof detail === "object") {
|
||||
const message = (detail as { message?: unknown }).message;
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
const error = payload.error;
|
||||
return typeof error === "string" ? error : null;
|
||||
}
|
||||
|
||||
function askAboutFile(file: SessionFile) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("claw-composer-insert", {
|
||||
|
||||
Reference in New Issue
Block a user