This commit is contained in:
liaibo
2025-12-06 16:47:17 +08:00
parent 15921bfeb7
commit 9ef8e06d67
75 changed files with 559237 additions and 93 deletions
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { blocksToPlainText, normalizeBlocksFromContent } from "@/lib/documents/plain-text";
interface SavePayload {
documentId: string;
@@ -17,10 +18,17 @@ export async function POST(request: Request) {
}
const { documentId, content }: SavePayload = await request.json();
const blocks = normalizeBlocksFromContent(content);
const rawText = blocksToPlainText(blocks);
const updatePayload: Record<string, unknown> = { content, index_status: "pending" };
if (rawText) {
updatePayload.raw_text = rawText;
}
const { error } = await supabase
.from("documents")
.update({ content })
.update(updatePayload)
.eq("id", documentId)
.eq("user_id", session.user.id);
@@ -28,5 +36,19 @@ export async function POST(request: Request) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL;
if (backendUrl && rawText) {
void fetch(`${backendUrl}/api/v1/lightrag/index`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.access_token}`,
},
body: JSON.stringify({ document_id: documentId }),
}).catch((err) => {
console.warn("[lightrag-index] trigger failed", err);
});
}
return NextResponse.json({ ok: true });
}
@@ -90,3 +90,26 @@ export async function POST(request: Request) {
return NextResponse.json({ asset: data as MediaAsset });
}
export async function DELETE(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { assetId } = (await request.json().catch(() => ({}))) as { assetId?: string };
if (!assetId) {
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
}
const { error } = await supabase.from("media_assets").delete().eq("id", assetId).limit(1);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ ok: true });
}
@@ -15,6 +15,7 @@ export async function POST(request: Request) {
if (!assetId) {
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
}
console.log("media ocr api called", assetId, Boolean(session?.access_token));
const { error } = await supabase
.from("media_assets")
@@ -28,10 +29,13 @@ export async function POST(request: Request) {
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL;
if (backendUrl) {
console.log("media ocr session token prefix", session.access_token?.slice(0, 8));
void fetch(`${backendUrl}/api/v1/tasks/media-ocr`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.access_token}`,
"X-Supabase-Access-Token": session.access_token,
},
body: JSON.stringify({ asset_id: assetId }),
}).catch((err) => {
@@ -1,15 +1,21 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useSessionContext } from "@supabase/auth-helpers-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSessionContext, useSupabaseClient } from "@supabase/auth-helpers-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import type { Database } from "@/types/supabase";
type BackgroundTaskRow = Database["public"]["Tables"]["background_tasks"]["Row"];
interface TaskResponse {
task_id: string;
status: string;
progress: number;
message?: string | null;
task_type?: string | null;
created_at?: string | null;
}
interface Props {
@@ -18,12 +24,62 @@ interface Props {
export function DocumentTaskPanel({ documentId }: Props) {
const { session } = useSessionContext();
const [task, setTask] = useState<TaskResponse | null>(null);
const supabase = useSupabaseClient<Database>();
const [activeTask, setActiveTask] = useState<TaskResponse | null>(null);
const [recentTasks, setRecentTasks] = useState<TaskResponse[]>([]);
const [pending, setPending] = useState(false);
const [fileUrl, setFileUrl] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const backendUrl = useMemo(() => process.env.NEXT_PUBLIC_BACKEND_URL, []);
const toTaskResponse = useCallback((record: BackgroundTaskRow | null): TaskResponse | null => {
if (!record) return null;
return {
task_id: String(record.id ?? ""),
status: record.status ?? "pending",
progress: record.progress ?? 0,
message: record.message,
task_type: record.task_type ?? "ocr",
created_at: record.created_at,
};
}, []);
const upsertTask = useCallback(
(record: BackgroundTaskRow | null) => {
const parsed = toTaskResponse(record);
if (!parsed) return;
setRecentTasks((prev) => {
const next = prev.filter((item) => item.task_id !== parsed.task_id);
next.unshift(parsed);
return next.slice(0, 5);
});
setActiveTask((current) => {
if (current?.task_id === parsed.task_id) {
return parsed;
}
return current;
});
},
[toTaskResponse],
);
const fetchRecentTasks = useCallback(async () => {
const { data, error } = await supabase
.from("background_tasks")
.select("*")
.eq("document_id", documentId)
.order("created_at", { ascending: false })
.limit(5);
if (error) {
console.warn("加载任务失败", error);
return;
}
const next = (data ?? []).map((row) => toTaskResponse(row)).filter((row): row is TaskResponse => Boolean(row));
setRecentTasks(next);
}, [documentId, supabase, toTaskResponse]);
const triggerTask = async () => {
if (!backendUrl || !session?.access_token) return;
if (!backendUrl || !session?.access_token || !fileUrl) return;
setPending(true);
try {
const response = await fetch(`${backendUrl}/api/v1/tasks/ocr`, {
@@ -34,12 +90,23 @@ export function DocumentTaskPanel({ documentId }: Props) {
},
body: JSON.stringify({
document_id: documentId,
file_url: "https://example.com/sample.pdf",
file_url: fileUrl,
}),
});
const data = await response.json();
if (response.ok) {
setTask(data);
const nextTask: TaskResponse = {
task_id: data.task_id,
status: data.status,
progress: data.progress,
message: data.message,
task_type: "ocr",
};
setActiveTask(nextTask);
setRecentTasks((prev) => [nextTask, ...prev.filter((item) => item.task_id !== nextTask.task_id)].slice(0, 5));
setErrorMessage(null);
} else {
setErrorMessage(data?.detail ?? data?.error ?? "触发 OCR 失败");
}
} finally {
setPending(false);
@@ -47,40 +114,76 @@ export function DocumentTaskPanel({ documentId }: Props) {
};
useEffect(() => {
if (!backendUrl || !session?.access_token || !task?.task_id) {
return;
}
const timer = setInterval(async () => {
const response = await fetch(`${backendUrl}/api/v1/tasks/${task.task_id}`, {
headers: {
Authorization: `Bearer ${session.access_token}`,
void fetchRecentTasks();
}, [fetchRecentTasks]);
useEffect(() => {
const channel = supabase
.channel(`bg-task-doc-${documentId}`)
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "background_tasks", filter: `document_id=eq.${documentId}` },
(payload) => {
upsertTask((payload.new as BackgroundTaskRow) ?? null);
},
});
if (!response.ok) {
return;
}
const data = (await response.json()) as TaskResponse;
setTask(data);
if (data.status === "completed") {
clearInterval(timer);
}
}, 2000);
return () => clearInterval(timer);
}, [backendUrl, session?.access_token, task?.task_id]);
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [documentId, supabase, upsertTask]);
const taskTypeLabel = (taskType?: string | null) => {
if (taskType === "index") return "LightRAG 索引";
if (taskType === "ocr") return "OCR 解析";
return taskType ?? "任务";
};
return (
<Card className="mt-4 bg-white shadow-sm">
<CardContent className="flex items-center justify-between py-3 text-sm text-gray-600">
<CardContent className="flex flex-col gap-4 py-3 text-sm text-gray-600">
<div>
<div className="font-medium text-gray-900"> OCR </div>
<div className="text-xs text-gray-500">
{task ? task.status : "未开始"} · {task ? `${task.progress}%` : "0%"}
<div className="font-medium text-gray-900"></div>
<div className="mt-2 space-y-2 text-xs text-gray-500">
{recentTasks.length === 0 ? (
<div></div>
) : (
recentTasks.map((task) => (
<div
key={task.task_id}
className="rounded border border-gray-100 bg-gray-50 px-3 py-2 text-gray-700 dark:bg-gray-900/30"
>
<div className="flex items-center justify-between">
<span className="font-semibold text-gray-900">{taskTypeLabel(task.task_type)}</span>
<span>{task.progress}%</span>
</div>
<div className="text-xs text-gray-500">{task.status}</div>
{task.message && <div className="text-xs text-gray-500">{task.message}</div>}
</div>
))
)}
</div>
{task?.message && <div className="text-xs text-gray-500">{task.message}</div>}
</div>
<Button onClick={triggerTask} disabled={pending} variant="outline">
{pending ? "触发中..." : "触发 OCR"}
</Button>
<div className="flex items-center justify-between gap-6 text-sm text-gray-600">
<div className="flex flex-col gap-2">
<div className="font-medium text-gray-900"> OCR</div>
<div className="text-xs text-gray-500">
{activeTask ? activeTask.status : "未开始"} · {activeTask ? `${activeTask.progress}%` : "0%"}
</div>
{activeTask?.message && <div className="text-xs text-gray-500">{activeTask.message}</div>}
<Input
placeholder="输入 Supabase Storage 签名 URL"
value={fileUrl}
onChange={(event) => setFileUrl(event.target.value)}
className="h-8 text-xs"
/>
{errorMessage && <div className="text-xs text-red-500">{errorMessage}</div>}
</div>
<Button onClick={triggerTask} disabled={pending || !fileUrl} variant="outline">
{pending ? "触发中..." : "触发 OCR"}
</Button>
</div>
</CardContent>
</Card>
);
@@ -4,7 +4,7 @@ import "@blocknote/core/style.css";
import "@blocknote/react/style.css";
import "@blocknote/mantine/style.css";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BlockNoteView } from "@blocknote/mantine";
import {
SideMenuController,
@@ -212,7 +212,9 @@ export function BlockNoteEditor({
const [tocEntries, setTocEntries] = useState<TocEntry[]>([]);
const [fullScreenTableId, setFullScreenTableId] = useState<string | null>(null);
const isFullScreenTableOpen = fullScreenTableId !== null;
const mediaAssetIdsRef = useRef<Set<string>>(new Set());
const mediaAssetIdsInitializedRef = useRef(false);
const openReferencePalette = useSearchPaletteStore((state) => state.openReference);
const registerEditorBridge = useEditorBridgeStore((state) => state.registerBridge);
@@ -290,6 +292,28 @@ export function BlockNoteEditor({
const blocks = editor.topLevelBlocks;
debouncedSave(blocks as Json);
const typedBlocks = blocks as Block<CustomBlockSchema>[];
const currentAssetIds = collectMediaAssetIds(typedBlocks);
if (mediaAssetIdsInitializedRef.current) {
const removedAssetIds: string[] = [];
mediaAssetIdsRef.current.forEach((id) => {
if (!currentAssetIds.has(id)) {
removedAssetIds.push(id);
}
});
if (removedAssetIds.length > 0) {
void Promise.allSettled(
removedAssetIds.map((assetId) =>
fetch("/api/media/assets", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assetId }),
}),
),
);
}
}
mediaAssetIdsRef.current = currentAssetIds;
mediaAssetIdsInitializedRef.current = true;
setTocEntries(buildHeadingToc(typedBlocks));
syncProgressMeters(editor);
const stats = computeDocumentStats(typedBlocks);
@@ -399,6 +423,25 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
};
};
const collectMediaAssetIds = (blocks: Block<CustomBlockSchema>[]): Set<string> => {
const ids = new Set<string>();
const traverse = (targetBlocks: Block<CustomBlockSchema>[]) => {
targetBlocks.forEach((block) => {
if (block.type === "media") {
const assetId = block.props.assetId as string | undefined;
if (assetId) {
ids.add(assetId);
}
}
if (block.children && block.children.length > 0) {
traverse(block.children as Block<CustomBlockSchema>[]);
}
});
};
traverse(blocks);
return ids;
};
const insertMediaAssetBlock = useCallback(
(asset: MediaAsset) => {
if (!editor) {
@@ -33,6 +33,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { CustomBlockSchema } from "../schema";
import { supabaseBrowser } from "@/lib/supabase/client";
type MediaAlign = "left" | "center" | "right";
@@ -260,6 +261,47 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => {
}
};
useEffect(() => {
// 监听 media_assets 变化,实时同步 OCR 状态
const assetId = block.props.assetId as string | undefined;
if (!assetId) return undefined;
let active = true;
const syncStatus = async () => {
const { data } = await supabaseBrowser
.from("media_assets")
.select("ocr_status")
.eq("id", assetId)
.maybeSingle();
if (!active || !data?.ocr_status) return;
if (data.ocr_status !== block.props.ocrStatus) {
editor.updateBlock(block, { props: { ocrStatus: data.ocr_status } });
}
};
void syncStatus();
const channel = supabaseBrowser
.channel(`media-asset-${assetId}`)
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "media_assets", filter: `id=eq.${assetId}` },
(payload) => {
const nextStatus = (payload.new as { ocr_status?: string } | null)?.ocr_status;
if (nextStatus && nextStatus !== block.props.ocrStatus) {
editor.updateBlock(block, { props: { ocrStatus: nextStatus } });
}
},
)
.subscribe();
return () => {
active = false;
void supabaseBrowser.removeChannel(channel);
};
}, [block, editor]);
if (!fileUrl) {
return (
<div className="wolai-media wolai-media--empty">
@@ -460,7 +502,9 @@ const figure = (
</>
)}
</div>
<div className="wolai-media__hint">{block.props.ocrStatus === "processing" ? "OCR 处理中..." : ""}</div>
<div className="wolai-media__hint">
{block.props.ocrStatus === "processing" ? "OCR 处理中..." : block.props.ocrStatus === "completed" ? "OCR 已完成" : ""}
</div>
</div>
);
};
@@ -73,6 +73,16 @@ const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) =>
void removePageReference();
return;
}
if (block.type === "media") {
const assetId = block.props.assetId as string | undefined;
if (assetId) {
void fetch("/api/media/assets", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assetId }),
}).catch((error) => console.warn("删除媒体资源失败", error));
}
}
if (block.type === "onlineTable") {
const tableId = block.props.tableId as string | undefined;
if (tableId) {
@@ -0,0 +1,74 @@
import type { Json } from "@/types/supabase";
type BlockNode = {
id?: string;
type?: string;
content?: unknown;
children?: unknown;
props?: Record<string, unknown>;
};
const toBlockArray = (value: unknown): BlockNode[] => {
if (Array.isArray(value)) {
return value.filter((item): item is BlockNode => typeof item === "object" && item !== null);
}
if (value && typeof value === "object" && Array.isArray((value as Record<string, unknown>).blocks)) {
return (value as { blocks: Json[] }).blocks.filter(
(item): item is BlockNode => typeof item === "object" && item !== null,
);
}
return [];
};
const extractInlineText = (value: unknown): string => {
if (!Array.isArray(value)) {
return "";
}
return value
.map((node) => {
if (!node || typeof node !== "object") {
return "";
}
const candidate = node as { text?: unknown; content?: unknown; children?: unknown };
if (typeof candidate.text === "string") {
return candidate.text;
}
if (Array.isArray(candidate.children)) {
return extractInlineText(candidate.children);
}
if (Array.isArray(candidate.content)) {
return extractInlineText(candidate.content);
}
return "";
})
.join(" ")
.replace(/\s+/g, " ")
.trim();
};
const flattenBlocks = (blocks: BlockNode[], lines: string[]) => {
blocks.forEach((block) => {
const inlineText = extractInlineText(block.content);
if (inlineText) {
lines.push(inlineText);
}
if (Array.isArray(block.children)) {
const nextChildren = block.children.filter(
(item): item is BlockNode => typeof item === "object" && item !== null,
);
if (nextChildren.length > 0) {
flattenBlocks(nextChildren, lines);
}
}
});
};
export const normalizeBlocksFromContent = (content: unknown): BlockNode[] => {
return toBlockArray(content);
};
export const blocksToPlainText = (blocks: BlockNode[]): string => {
const lines: string[] = [];
flattenBlocks(blocks, lines);
return lines.join("\n").trim();
};
+43
View File
@@ -9,6 +9,49 @@ export type Json =
export type Database = {
public: {
Tables: {
background_tasks: {
Row: {
id: string;
user_id: string;
document_id: string | null;
task_type: string;
status: string;
progress: number;
message: string | null;
created_at: string;
updated_at: string;
};
Insert: {
id?: string;
user_id: string;
document_id?: string | null;
task_type?: string;
status?: string;
progress?: number;
message?: string | null;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
user_id?: string;
document_id?: string | null;
task_type?: string;
status?: string;
progress?: number;
message?: string | null;
created_at?: string;
updated_at?: string;
};
Relationships: [
{
foreignKeyName: "background_tasks_document_id_fkey";
columns: ["document_id"];
referencedRelation: "documents";
referencedColumns: ["id"];
},
];
};
documents: {
Row: {
access_scope: "private" | "shared" | "public";