Restore 0.1.5 version from stash

This commit is contained in:
liaibo
2025-12-08 19:56:24 +08:00
parent de189e938d
commit 8db3f4e32d
8578 changed files with 2703426 additions and 217 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
<Breadcrumb documents={documents} />
</header>
<main className="flex-1 overflow-hidden bg-white">{children}</main>
<BottomToolbar />
<BottomToolbar workspaceId={sidebarInitialData?.activeWorkspaceId ?? null} />
<SearchPalette workspaceId={sidebarInitialData?.activeWorkspaceId ?? null} />
</div>
</div>
@@ -21,7 +21,9 @@ export default async function PdfAssetPage({ params, searchParams }: PdfPageProp
const { data: asset } = await supabase
.from("media_assets")
.select("id,workspace_id,document_id,file_url,file_name,mime_type,ocr_status,documents!media_assets_document_id_fkey(user_id,title)")
.select(
"id,workspace_id,document_id,file_url,file_name,mime_type,ocr_status,ocr_text,documents!media_assets_document_id_fkey(user_id,title,index_status,raw_text,rag_settings)",
)
.eq("id", assetId)
.maybeSingle();
@@ -54,6 +56,10 @@ export default async function PdfAssetPage({ params, searchParams }: PdfPageProp
initialPage={initialPage}
initialHighlightId={initialHighlightId}
ocrStatus={asset.ocr_status ?? null}
ocrText={asset.ocr_text ?? null}
documentIndexStatus={asset.documents?.index_status ?? null}
documentRawText={asset.documents?.raw_text ?? null}
ragSettings={asset.documents?.rag_settings ?? null}
enableOcrLayer={(asset.ocr_status ?? "").toLowerCase() === "success"}
/>
);
@@ -1,3 +1,4 @@
"use client";
import dynamic from "next/dynamic";
@@ -19,9 +20,97 @@ type PdfAssetViewerProps = {
initialHighlightId?: string | null;
ocrStatus?: string | null;
enableOcrLayer?: boolean;
ocrText?: string | null;
documentIndexStatus?: string | null;
documentRawText?: string | null;
ragSettings?: Record<string, unknown> | null;
};
type RagParams = {
mode?: string;
top_k?: number;
chunk_top_k?: number;
max_entity_tokens?: number;
max_relation_tokens?: number;
max_total_tokens?: number;
enable_rerank?: boolean;
embedding_model?: string;
rerank_model?: string;
user_prompt?: string;
};
const ragModeOptions = [
{ value: "naive", label: "Naive(基础检索)" },
{ value: "local", label: "Local(上下文优先)" },
{ value: "global", label: "Global(全局知识)" },
{ value: "hybrid", label: "Hybrid(混合)" },
{ value: "mix", label: "MixKG+向量)" },
{ value: "bypass", label: "Bypass(直连 LLM" },
];
const embeddingOptions = ["qwen3-embedding:8b", "text-embedding-3-large", "text-embedding-3-small", "custom"];
const rerankOptions = ["embedding-sim", "bge-reranker", "none"];
const numericKeys: (keyof RagParams)[] = [
"top_k",
"chunk_top_k",
"max_entity_tokens",
"max_relation_tokens",
"max_total_tokens",
];
function normalizeRagSettings(value: Record<string, unknown> | null | undefined): RagParams {
if (!value || typeof value !== "object") {
return {};
}
const normalized: RagParams = {};
for (const key of numericKeys) {
const raw = value[key];
if (typeof raw === "number" && Number.isFinite(raw)) {
normalized[key] = raw;
} else if (typeof raw === "string" && raw.trim()) {
const parsed = Number(raw);
if (Number.isFinite(parsed)) {
normalized[key] = parsed;
}
}
}
if ("enable_rerank" in value) {
normalized.enable_rerank = Boolean(value.enable_rerank);
}
if (typeof value.mode === "string") {
normalized.mode = value.mode;
}
if (typeof value.embedding_model === "string") {
normalized.embedding_model = value.embedding_model;
}
if (typeof value.rerank_model === "string") {
normalized.rerank_model = value.rerank_model;
}
if (typeof value.user_prompt === "string") {
normalized.user_prompt = value.user_prompt;
}
return normalized;
}
const MAX_QUOTES = 20;
let cachedPdfJs: typeof import("pdfjs-dist") | null = null;
async function ensurePdfJs() {
if (cachedPdfJs) return cachedPdfJs;
if (typeof window === "undefined") {
throw new Error("pdf.js 仅能在浏览器中加载");
}
const mod = await import("pdfjs-dist");
if (!mod.GlobalWorkerOptions.workerPort) {
mod.GlobalWorkerOptions.workerPort = new Worker(new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url), {
type: "module",
});
}
cachedPdfJs = mod;
return mod;
}
const PdfViewerWithQuote = dynamic(
() => import("@/components/pdf/pdf-viewer-with-quote").then((mod) => mod.PdfViewerWithQuote),
{
@@ -31,7 +120,6 @@ const PdfViewerWithQuote = dynamic(
),
},
);
export function PdfAssetViewer({
assetId,
workspaceId,
@@ -43,6 +131,10 @@ export function PdfAssetViewer({
initialHighlightId = null,
ocrStatus = null,
enableOcrLayer = false,
ocrText = null,
documentIndexStatus = null,
documentRawText = null,
ragSettings = null,
}: PdfAssetViewerProps) {
const [quotes, setQuotes] = useState<PdfQuote[]>([]);
const [activeQuote, setActiveQuote] = useState<PdfQuote | null>(null);
@@ -53,25 +145,161 @@ export function PdfAssetViewer({
const [ocrError, setOcrError] = useState<string | null>(null);
const [ocrLoading, setOcrLoading] = useState(false);
const [ocrReloadKey, setOcrReloadKey] = useState(0);
const [ocrStatusLocal, setOcrStatusLocal] = useState(ocrStatus ?? null);
const [ocrTextValue, setOcrTextValue] = useState(ocrText ?? "");
const [savingOcrText, setSavingOcrText] = useState(false);
const [pdfNature, setPdfNature] = useState<"unknown" | "text" | "image" | "error" | "checking">("checking");
const [autoOcrEnabled, setAutoOcrEnabled] = useState(true);
const [triggeringOcr, setTriggeringOcr] = useState(false);
const [activeTab, setActiveTab] = useState<"ocr" | "lightrag">("ocr");
const [ragSegments, setRagSegments] = useState<string[]>(
(documentRawText || "")
.split(/\n{2,}/)
.map((item) => item.trim())
.filter(Boolean),
);
const [ragParams, setRagParams] = useState<RagParams>(normalizeRagSettings(ragSettings));
const [indexStatusLocal, setIndexStatusLocal] = useState(documentIndexStatus ?? null);
const [savingRagParams, setSavingRagParams] = useState(false);
const [ragParamsExpanded, setRagParamsExpanded] = useState(false);
useEffect(() => {
setOcrStatusLocal(ocrStatus ?? null);
}, [ocrStatus]);
useEffect(() => {
setOcrTextValue(ocrText ?? "");
}, [ocrText]);
useEffect(() => {
setRagSegments(
(documentRawText || "")
.split(/\n{2,}/)
.map((item) => item.trim())
.filter(Boolean),
);
}, [documentRawText]);
useEffect(() => {
setIndexStatusLocal(documentIndexStatus ?? null);
}, [documentIndexStatus]);
useEffect(() => {
setRagParams(normalizeRagSettings(ragSettings));
}, [ragSettings]);
useEffect(() => {
if (typeof window === "undefined") return;
const saved = window.localStorage.getItem("pdf_auto_ocr");
if (saved === "off") {
setAutoOcrEnabled(false);
}
}, []);
useEffect(() => {
if (typeof window === "undefined") return;
window.localStorage.setItem("pdf_auto_ocr", autoOcrEnabled ? "on" : "off");
}, [autoOcrEnabled]);
const detectPdfNature = useCallback(async () => {
setPdfNature("checking");
if (typeof window === "undefined") {
setPdfNature("unknown");
return;
}
let loadingTask: import("pdfjs-dist/types/src/display/api").PDFDocumentLoadingTask | null = null;
try {
const pdfjsLib = await ensurePdfJs();
loadingTask = pdfjsLib.getDocument({ url: fileUrl, useSystemFonts: true });
const pdf = await loadingTask.promise;
const page = await pdf.getPage(1);
const text = await page.getTextContent({ normalizeWhitespace: true });
const aggregated = (text.items || [])
.map((item) => (typeof (item as { str?: string }).str === "string" ? (item as { str: string }).str : ""))
.join("")
.replace(/\s+/g, "");
const hasText = aggregated.length > 20;
setPdfNature(hasText ? "text" : "image");
} catch (error) {
console.error("检测 PDF 类型失败", error);
setPdfNature("error");
} finally {
loadingTask?.destroy();
}
}, [fileUrl]);
useEffect(() => {
void detectPdfNature();
}, [detectPdfNature]);
const ocrStatusNormalized = (ocrStatusLocal || "").toLowerCase();
const ocrStatusMessage = useMemo(() => {
if (!enableOcrLayer) {
if (!ocrStatus) {
return "MinerU OCR 未启用或仍在排队。";
if (pdfNature === "checking") {
return "正在判断 PDF 类型…";
}
if (pdfNature === "text") {
return "文字型 PDF,无需 OCR。";
}
if (pdfNature === "error") {
return "无法判定 PDF 类型,可尝试手动触发 OCR。";
}
if (!enableOcrLayer && ocrStatusNormalized !== "success") {
if (ocrStatusNormalized === "processing") {
return "图片型 PDF,正在进行 OCR 处理…";
}
return `等待 MinerU 任务完成(当前状态:${ocrStatus}`;
return "图片型 PDF,可以进行 OCR。";
}
if (ocrLoading) {
return "正在载入 OCR 文本层…";
}
if (ocrLayer) {
return "OCR 文本层已启用,图片型 PDF 亦可直接选中引用。";
return "OCR 文本层已启用,可直接引用图像中的文字";
}
if (ocrError) {
return ocrError;
}
if (ocrStatusNormalized === "success") {
return "OCR 已完成,如需再次附加文本层可重新加载";
}
return "OCR 数据可用,点击重新加载以附加文本层。";
}, [enableOcrLayer, ocrLayer, ocrLoading, ocrError, ocrStatus]);
}, [enableOcrLayer, ocrError, ocrLayer, ocrLoading, ocrStatusNormalized, pdfNature]);
const hasOcrLayer = Boolean(ocrLayer);
const shouldAutoTriggerOcr =
autoOcrEnabled &&
pdfNature === "image" &&
!triggeringOcr &&
ocrStatusNormalized !== "processing" &&
ocrStatusNormalized !== "success";
const triggerOcr = useCallback(async () => {
if (triggeringOcr) return;
setTriggeringOcr(true);
try {
const resp = await fetch("/api/media/ocr", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assetId }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => null);
throw new Error((payload as { error?: string } | null)?.error ?? `触发失败(${resp.status})`);
}
toast.success("已触发 MinerU OCR,稍后自动刷新。");
setOcrStatusLocal("processing");
} catch (error) {
console.error("触发 OCR 失败", error);
toast.error("触发 OCR 失败", { description: error instanceof Error ? error.message : "请稍后再试" });
} finally {
setTriggeringOcr(false);
}
}, [assetId, triggeringOcr]);
useEffect(() => {
if (shouldAutoTriggerOcr) {
void triggerOcr();
}
}, [shouldAutoTriggerOcr, triggerOcr]);
const copyToClipboard = useCallback(async (value: string) => {
try {
@@ -79,7 +307,7 @@ export function PdfAssetViewer({
await navigator.clipboard.writeText(value);
return true;
}
throw new Error("no clipboard");
throw new Error("clipboard unavailable");
} catch {
window.prompt("请复制以下引用链接", value);
return false;
@@ -132,9 +360,7 @@ export function PdfAssetViewer({
throw new Error("服务端未返回引用数据");
}
await copyToClipboard(savedQuote.targetUrl);
toast.success("已生成引用链接", {
description: truncateText(savedQuote.text, 60),
});
toast.success("已生成引用链接", { description: truncateText(savedQuote.text, 60) });
setQuotes((prev) => [savedQuote, ...prev.filter((item) => item.id !== savedQuote.id)].slice(0, MAX_QUOTES));
setActiveQuote(savedQuote);
setPendingQuote(null);
@@ -165,6 +391,80 @@ export function PdfAssetViewer({
setOcrReloadKey((key) => key + 1);
};
const updateRagParam = <K extends keyof RagParams>(key: K, value: RagParams[K]) => {
setRagParams((prev) => ({ ...prev, [key]: value }));
};
const handleSaveRagParams = async () => {
setSavingRagParams(true);
try {
const resp = await fetch("/api/documents/rag-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, ragSettings: ragParams }),
});
const payload = (await resp.json().catch(() => null)) as { ragSettings?: Record<string, unknown>; error?: string } | null;
if (!resp.ok) {
throw new Error(payload?.error ?? "保存失败");
}
setRagParams(normalizeRagSettings(payload?.ragSettings ?? ragParams));
toast.success("RAG 参数已保存");
} catch (error) {
console.error("保存 RAG 参数失败", error);
toast.error("保存 RAG 参数失败", {
description: error instanceof Error ? error.message : "请稍后再试",
});
} finally {
setSavingRagParams(false);
}
};
const handleSaveOcrText = async () => {
setSavingOcrText(true);
try {
const resp = await fetch("/api/media/ocr-text", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assetId, ocrText: ocrTextValue }),
});
const payload = (await resp.json().catch(() => null)) as { error?: string } | null;
if (!resp.ok) {
throw new Error(payload?.error ?? "保存失败");
}
toast.success("OCR 文本已保存并同步");
setOcrStatusLocal(ocrTextValue.trim() ? "success" : "pending");
} catch (error) {
console.error("保存 OCR 文本失败", error);
toast.error("保存 OCR 文本失败", {
description: error instanceof Error ? error.message : "请稍后再试",
});
}
setSavingOcrText(false);
};
const handleSaveRagContent = async () => {
const normalized = ragSegments.map((seg) => seg.trim());
setRagSegments(normalized);
try {
const resp = await fetch("/api/documents/rag-content", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, segments: normalized }),
});
const payload = (await resp.json().catch(() => null)) as { error?: string } | null;
if (!resp.ok) {
throw new Error(payload?.error ?? "保存失败");
}
toast.success("RAG 内容已保存并同步");
setIndexStatusLocal("pending");
} catch (error) {
console.error("保存 RAG 内容失败", error);
toast.error("保存 RAG 内容失败", {
description: error instanceof Error ? error.message : "请稍后再试",
});
}
};
const openOriginal = () => {
window.open(fileUrl, "_blank", "noopener,noreferrer");
};
@@ -177,16 +477,14 @@ export function PdfAssetViewer({
anchor.target = "_blank";
anchor.click();
};
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
const fetchQuotes = async () => {
setQuotesLoading(true);
try {
const response = await fetch(`/api/pdf/quotes?assetId=${assetId}`, {
signal: controller.signal,
});
const response = await fetch(`/api/pdf/quotes?assetId=${assetId}`, { signal: controller.signal });
const payload = (await response.json().catch(() => null)) as { quotes?: PdfQuote[]; error?: string } | null;
if (!response.ok) {
throw new Error(payload?.error ?? "加载引用失败");
@@ -292,6 +590,35 @@ export function PdfAssetViewer({
};
}, [assetId, documentId, fileUrl, quotes, sourceTitle, workspaceId]);
const ragProgress = useMemo(() => {
const status = (indexStatusLocal || "").toLowerCase();
if (status === "ready" || status === "success") return { label: "已完成", percent: 100 };
if (status === "processing" || status === "running") return { label: "处理中", percent: 60 };
if (status === "pending") return { label: "待处理", percent: 20 };
if (status === "failed") return { label: "失败", percent: 0 };
return { label: "未知", percent: 0 };
}, [indexStatusLocal]);
const ragModeLabel = useMemo(() => {
return ragModeOptions.find((opt) => opt.value === (ragParams.mode ?? "naive"))?.label ?? "Naive(基础检索)";
}, [ragParams.mode]);
const ragParamsSummary = useMemo(() => {
const summary: string[] = [ragModeLabel];
if (typeof ragParams.top_k === "number") {
summary.push(`TopK ${ragParams.top_k}`);
}
if (ragParams.enable_rerank) {
summary.push(`Rerank: ${ragParams.rerank_model || "auto"}`);
}
if (ragParams.embedding_model) {
summary.push(`Embedding: ${ragParams.embedding_model}`);
}
return summary.join(" · ");
}, [ragModeLabel, ragParams.embedding_model, ragParams.enable_rerank, ragParams.rerank_model, ragParams.top_k]);
const isTextPdf = pdfNature === "text";
const isImagePdf = pdfNature === "image";
return (
<div className="flex h-full flex-col bg-white">
<header className="flex items-center justify-between border-b border-border px-6 py-4">
@@ -308,11 +635,7 @@ export function PdfAssetViewer({
<Download className="h-4 w-4" />
</Button>
<Button
className="gap-2"
disabled={!pendingQuote || isSavingQuote}
onClick={() => void handleQuoteCreate()}
>
<Button className="gap-2" disabled={!pendingQuote || isSavingQuote} onClick={() => void handleQuoteCreate()}>
<Highlighter className="h-4 w-4" />
{isSavingQuote ? "生成中..." : "生成引用"}
</Button>
@@ -332,88 +655,309 @@ export function PdfAssetViewer({
ocrLayer={ocrLayer}
/>
</div>
<aside className="w-80 border-l border-border bg-white">
<div className="border-b border-border px-4 py-3">
<p className="text-sm font-semibold text-foreground"></p>
<p className="text-xs text-muted-foreground"></p>
<div className="mt-2 rounded-lg border border-dashed border-border/70 bg-muted/40 p-2">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 line-clamp-3 text-sm text-foreground">
{pendingQuote ? pendingQuote.text : "暂无选中的文本"}
</p>
<aside className="flex w-[34rem] border-l border-border bg-white">
<div className="flex w-64 flex-col border-r border-border bg-muted/30">
<div className="border-b border-border px-4 py-3">
<p className="text-sm font-semibold text-foreground"></p>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="flex-1 space-y-3 overflow-y-auto px-4 py-3">
<div className="rounded-lg border border-border/70 bg-white p-3 shadow-sm">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 line-clamp-4 text-sm text-foreground">
{pendingQuote ? pendingQuote.text : "暂无选中的文本"}
</p>
</div>
<div className="rounded-lg border border-border/70 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-foreground"></p>
<span className="text-xs text-muted-foreground">{quotes.length} </span>
</div>
<div className="mt-2 max-h-[420px] space-y-3 overflow-y-auto pr-1">
{quotesLoading ? (
<p className="text-sm text-muted-foreground">...</p>
) : quotes.length === 0 ? (
<p className="text-sm text-muted-foreground"> PDF </p>
) : (
<ul className="space-y-3">
{quotes.map((quote) => (
<li key={quote.id} className="rounded-lg border border-border/70 bg-muted/20 p-2">
<p className="text-[11px] font-medium text-indigo-600"> {quote.page} </p>
<p className="mt-1 line-clamp-3 text-xs text-foreground">{quote.text}</p>
<div className="mt-2 flex gap-1">
<Button
size="sm"
variant="outline"
className="h-7 flex-1 gap-1 text-[11px]"
onClick={() => {
setActiveQuote(quote);
void handleCopyQuote(quote);
}}
>
<LinkIcon className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 flex-1 text-[11px]"
onClick={() => {
setActiveQuote(quote);
window.open(quote.targetUrl, "_blank", "noopener,noreferrer");
}}
>
</Button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
</div>
</div>
<div className="h-full overflow-y-auto px-4 py-3">
{quotesLoading ? (
<p className="text-sm text-muted-foreground">...</p>
) : quotes.length === 0 ? (
<p className="text-sm text-muted-foreground"> PDF </p>
) : (
<ul className="space-y-3">
{quotes.map((quote) => (
<li key={quote.id} className="rounded-lg border border-border/70 bg-white p-3 shadow-sm">
<p className="text-xs font-medium text-indigo-600"> {quote.page} </p>
<p className="mt-1 line-clamp-3 text-sm text-foreground">{quote.text}</p>
<div className="mt-3 flex gap-2">
<Button
size="sm"
variant="outline"
className="h-8 gap-1 text-xs"
onClick={() => {
setActiveQuote(quote);
void handleCopyQuote(quote);
}}
>
<LinkIcon className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 text-xs"
onClick={() => {
setActiveQuote(quote);
window.open(quote.targetUrl, "_blank", "noopener,noreferrer");
}}
>
</Button>
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
<button
type="button"
className={`rounded-md px-3 py-1 text-sm font-medium ${
activeTab === "ocr" ? "bg-gray-900 text-white" : "text-gray-600 hover:bg-gray-100"
}`}
onClick={() => setActiveTab("ocr")}
>
OCR
</button>
<button
type="button"
className={`rounded-md px-3 py-1 text-sm font-medium ${
activeTab === "lightrag" ? "bg-gray-900 text-white" : "text-gray-600 hover:bg-gray-100"
}`}
onClick={() => setActiveTab("lightrag")}
>
LightRAG
</button>
</div>
<div className="flex min-h-0 flex-1">
{activeTab === "ocr" ? (
<div className="h-full w-full space-y-4 overflow-y-auto px-4 py-3">
<div className="space-y-3 rounded-lg border border-dashed border-border/70 bg-muted/30 p-3">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-foreground">MinerU OCR</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<label className="flex items-center gap-1">
<input
type="checkbox"
className="h-3 w-3"
checked={autoOcrEnabled}
onChange={(e) => setAutoOcrEnabled(e.target.checked)}
/>
OCR{autoOcrEnabled ? "开" : "关"}
</label>
{enableOcrLayer && <span>{hasOcrLayer ? "已启用" : ocrLoading ? "加载中" : "待加载"}</span>}
</div>
</div>
</li>
))}
</ul>
)}
<div className="mt-6 rounded-xl border border-dashed border-border/70 bg-muted/30 p-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-foreground">MinerU OCR</p>
{enableOcrLayer && (
<span className="text-xs text-muted-foreground">
{hasOcrLayer ? "已启用" : ocrLoading ? "加载中" : "待加载"}
</span>
)}
</div>
<p className="mt-2 text-xs text-muted-foreground leading-relaxed">{ocrStatusMessage}</p>
{enableOcrLayer && !ocrLoading && !hasOcrLayer && (
<Button
size="sm"
variant="outline"
className="mt-3 w-full"
onClick={handleReloadOcrLayer}
>
OCR
</Button>
<p className="text-xs text-muted-foreground leading-relaxed">{ocrStatusMessage}</p>
{isTextPdf ? (
<p className="rounded-md bg-white/80 px-2 py-1 text-[11px] text-emerald-700"> PDF OCR</p>
) : null}
{isImagePdf && ocrStatusNormalized !== "processing" && ocrStatusNormalized !== "success" ? (
<Button size="sm" variant="outline" className="w-full" disabled={triggeringOcr} onClick={() => void triggerOcr()}>
{triggeringOcr ? "触发中..." : "立即 OCR"}
</Button>
) : null}
{enableOcrLayer && !ocrLoading && !hasOcrLayer ? (
<Button size="sm" variant="outline" className="w-full" onClick={handleReloadOcrLayer}>
OCR
</Button>
) : null}
</div>
{isImagePdf ? (
<div className="rounded-lg border border-border/70 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-foreground">OCR </p>
<Button size="sm" disabled={savingOcrText} onClick={() => void handleSaveOcrText()}>
{savingOcrText ? "保存中..." : "保存"}
</Button>
</div>
<p className="mt-1 text-xs text-muted-foreground"> SupabaseAI </p>
<textarea
className="mt-2 h-48 w-full resize-none rounded-md border bg-gray-50 p-2 text-sm"
value={ocrTextValue}
onChange={(e) => setOcrTextValue(e.target.value)}
placeholder="OCR 结果将展示在此,可手动调整。"
/>
</div>
) : null}
</div>
) : (
<div className="h-full w-full space-y-4 overflow-y-auto px-4 py-3">
<div className="rounded-lg border border-border/70 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-foreground">RAG </p>
<span className="text-xs text-muted-foreground">{ragProgress.label}</span>
</div>
<div className="mt-2 h-2 w-full rounded-full bg-gray-100">
<div className="h-2 rounded-full bg-blue-500" style={{ width: `${ragProgress.percent}%` }} />
</div>
</div>
<div className="rounded-lg border border-border/70 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-foreground">RAG </p>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={() => setRagParamsExpanded((prev) => !prev)}>
{ragParamsExpanded ? "收起" : "展开"}
</Button>
<Button size="sm" onClick={() => void handleSaveRagParams()} disabled={savingRagParams}>
{savingRagParams ? "保存中..." : "保存参数"}
</Button>
</div>
</div>
<p className="mt-1 text-xs text-muted-foreground"> LightRAG </p>
{ragParamsExpanded ? (
<div className="mt-3 space-y-3">
<div className="space-y-1">
<label className="block text-[13px] font-medium text-gray-800"></label>
<select
className="w-full rounded-md border px-2 py-1 text-sm"
value={ragParams.mode ?? "naive"}
onChange={(e) => updateRagParam("mode", e.target.value)}
>
{ragModeOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
{numericKeys.map((key) => (
<div key={key}>
<label className="block text-[13px] font-medium text-gray-800">{key}</label>
<input
type="number"
className="mt-1 w-full rounded-md border px-2 py-1 text-sm"
value={ragParams[key] ?? ""}
onChange={(e) => {
const raw = e.target.value;
if (!raw) {
updateRagParam(key, undefined);
return;
}
const numericValue = Number(raw);
updateRagParam(key, Number.isFinite(numericValue) ? numericValue : undefined);
}}
/>
</div>
))}
</div>
<div className="flex items-center gap-2">
<label className="text-[13px] font-medium text-gray-800"> Rerank</label>
<input
type="checkbox"
checked={Boolean(ragParams.enable_rerank)}
onChange={(e) => updateRagParam("enable_rerank", e.target.checked)}
/>
</div>
<div className="space-y-1">
<label className="block text-[13px] font-medium text-gray-800">Embedding </label>
<select
className="w-full rounded-md border px-2 py-1 text-sm"
value={ragParams.embedding_model ?? ""}
onChange={(e) => updateRagParam("embedding_model", e.target.value)}
>
<option value="">{embeddingOptions[0]}</option>
{embeddingOptions.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="block text-[13px] font-medium text-gray-800">Rerank </label>
<select
className="w-full rounded-md border px-2 py-1 text-sm"
value={ragParams.rerank_model ?? ""}
onChange={(e) => updateRagParam("rerank_model", e.target.value)}
disabled={!ragParams.enable_rerank}
>
<option value="">embedding </option>
{rerankOptions.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="block text-[13px] font-medium text-gray-800"> Prompt</label>
<textarea
className="h-24 w-full rounded-md border bg-gray-50 p-2 text-sm"
value={ragParams.user_prompt ?? ""}
onChange={(e) => updateRagParam("user_prompt", e.target.value)}
placeholder="例如:生成列表时使用 Markdown 表格"
/>
</div>
</div>
) : (
<p className="mt-3 rounded-md bg-muted/40 px-2 py-1 text-xs text-muted-foreground">{ragParamsSummary}</p>
)}
</div>
<div className="rounded-lg border border-border/70 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-foreground">RAG </p>
<Button size="sm" onClick={() => void handleSaveRagContent()}></Button>
</div>
<p className="mt-1 text-xs text-muted-foreground"> SupabaseLightRAG </p>
<div className="mt-3 space-y-2">
{ragSegments.length === 0 ? (
<p className="text-xs text-muted-foreground"></p>
) : null}
{ragSegments.map((seg, idx) => (
<textarea
key={`segment-${idx}`}
className="w-full rounded-md border bg-gray-50 p-2 text-sm"
value={seg}
onChange={(e) => {
const next = [...ragSegments];
next[idx] = e.target.value;
setRagSegments(next);
}}
rows={3}
/>
))}
<div className="flex gap-2">
<Button size="sm" variant="outline" className="flex-1" onClick={() => setRagSegments((prev) => [...prev, ""])}>
</Button>
{ragSegments.length > 0 ? (
<Button
size="sm"
variant="ghost"
className="flex-1"
onClick={() => setRagSegments((prev) => prev.slice(0, -1))}
>
</Button>
) : null}
</div>
</div>
</div>
</div>
)}
</div>
</div>
</aside>
</div>
</div>
);
}
const truncateText = (value: string, length: number) => {
if (value.length <= length) {
return value;
}
return `${value.slice(0, length - 1)}`;
};
function truncateText(value: string, length: number) {
if (!value) return "";
if (value.length <= length) return value;
return `${value.slice(0, length)}...`;
}
@@ -0,0 +1 @@
test
@@ -0,0 +1,99 @@
import { NextRequest, NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { buildBackendUrl } from "@/lib/backend/env";
type ChatPayload = {
query: string;
document_id?: string;
workspace_id?: string;
model?: string;
use_web_search?: boolean;
};
async function forwardChat(payload: ChatPayload) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
let targetUrl: URL;
try {
targetUrl = buildBackendUrl("/api/v1/chat");
} catch {
return NextResponse.json({ error: "BACKEND_URL 未配置" }, { status: 500 });
}
let response: Response;
try {
response = await fetch(targetUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${session.access_token}`,
"x-supabase-access-token": session.access_token,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
} catch {
return NextResponse.json({ error: "后端 AI 服务不可用" }, { status: 502 });
}
if (!response.body) {
const detail = await response.text().catch(() => "");
return NextResponse.json(
{ error: detail || "后端未返回数据" },
{ status: response.status === 200 ? 502 : response.status },
);
}
const headers = new Headers(response.headers);
headers.set("Cache-Control", "no-cache");
headers.set("Connection", "keep-alive");
headers.set("Content-Type", headers.get("Content-Type") ?? "text/event-stream");
return new Response(response.body, {
status: response.status,
headers,
});
}
export async function GET(request: NextRequest) {
const query = request.nextUrl.searchParams.get("query")?.trim();
if (!query) {
return NextResponse.json({ error: "缺少 query 参数" }, { status: 400 });
}
const payload: ChatPayload = {
query,
document_id: request.nextUrl.searchParams.get("document_id") || undefined,
workspace_id: request.nextUrl.searchParams.get("workspace_id") || undefined,
model: request.nextUrl.searchParams.get("model") || undefined,
use_web_search:
request.nextUrl.searchParams.get("use_web_search") === "true" ||
request.nextUrl.searchParams.get("use_web_search") === "1",
};
return forwardChat(payload);
}
export async function POST(request: NextRequest) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "请求体格式错误" }, { status: 400 });
}
const payload = body as Partial<ChatPayload>;
if (!payload?.query || typeof payload.query !== "string" || !payload.query.trim()) {
return NextResponse.json({ error: "缺少 query 参数" }, { status: 400 });
}
return forwardChat({
query: payload.query.trim(),
document_id: payload.document_id,
workspace_id: payload.workspace_id,
model: payload.model,
use_web_search: payload.use_web_search,
});
}
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { buildBackendUrl } from "@/lib/backend/env";
export async function GET() {
let targetUrl: URL;
try {
targetUrl = buildBackendUrl("/health");
} catch {
return NextResponse.json({ status: "error", detail: "BACKEND_URL 未配置" }, { status: 500 });
}
try {
const response = await fetch(targetUrl);
const data = await response.json().catch(() => ({}));
return NextResponse.json(data, { status: response.ok ? 200 : response.status });
} catch {
return NextResponse.json({ status: "error" }, { status: 502 });
}
}
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { buildBackendUrl } from "@/lib/backend/env";
export async function POST(request: NextRequest) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const payload = await request.json();
let response: Response;
let targetUrl: URL;
try {
targetUrl = buildBackendUrl("/api/v1/tasks/ocr");
} catch {
return NextResponse.json({ error: "BACKEND_URL 未配置" }, { status: 500 });
}
try {
response = await fetch(targetUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.access_token}`,
"x-supabase-access-token": session.access_token,
},
body: JSON.stringify(payload),
});
} catch {
return NextResponse.json({ error: "后端任务服务不可用" }, { status: 502 });
}
const data = await response.json().catch(() => ({}));
return NextResponse.json(data, { status: response.status });
}
@@ -0,0 +1,72 @@
"use server";
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { tryGetBackendBaseUrl } from "@/lib/backend/env";
type Payload = {
documentId?: string;
segments?: unknown;
};
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
let body: Payload;
try {
body = (await request.json()) as Payload;
} catch {
return NextResponse.json({ error: "请求体格式错误" }, { status: 400 });
}
if (!body.documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
if (!Array.isArray(body.segments)) {
return NextResponse.json({ error: "segments 应为数组" }, { status: 400 });
}
const normalizedSegments = body.segments
.map((segment) => (typeof segment === "string" ? segment.trim() : ""))
.filter((segment) => segment.length > 0);
const rawText = normalizedSegments.join("\n\n");
const { error } = await supabase
.from("documents")
.update({
raw_text: rawText || null,
index_status: "pending",
})
.eq("id", body.documentId)
.eq("user_id", session.user.id)
.limit(1);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
const backendBase = tryGetBackendBaseUrl();
if (backendBase && rawText) {
const targetUrl = new URL("/api/v1/lightrag/index", backendBase);
void fetch(targetUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.access_token}`,
"x-supabase-access-token": session.access_token,
},
body: JSON.stringify({ document_id: body.documentId }),
}).catch((err) => {
console.warn("[rag-content] trigger backend reindex failed", err);
});
}
return NextResponse.json({ ok: true, rawText });
}
@@ -0,0 +1,77 @@
"use server";
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
type RagSettingsPayload = {
documentId?: string;
ragSettings?: Record<string, unknown>;
};
const numberKeys = ["top_k", "chunk_top_k", "max_entity_tokens", "max_relation_tokens", "max_total_tokens"];
const boolKeys = ["enable_rerank"];
const textKeys = ["mode", "embedding_model", "rerank_model", "user_prompt"];
function sanitizeRagSettings(value: Record<string, unknown> | undefined | null) {
const result: Record<string, unknown> = {};
if (!value || typeof value !== "object") {
return result;
}
for (const key of numberKeys) {
const raw = value[key];
if (raw === undefined || raw === null || raw === "") continue;
const num = Number(raw);
if (Number.isFinite(num)) {
result[key] = Math.round(num);
}
}
for (const key of boolKeys) {
if (key in value) {
result[key] = Boolean(value[key]);
}
}
for (const key of textKeys) {
const raw = value[key];
if (typeof raw === "string" && raw.trim()) {
result[key] = raw.trim();
}
}
return result;
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
let body: RagSettingsPayload;
try {
body = (await request.json()) as RagSettingsPayload;
} catch {
return NextResponse.json({ error: "请求体格式错误" }, { status: 400 });
}
if (!body.documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const sanitized = sanitizeRagSettings(body.ragSettings);
const { error } = await supabase
.from("documents")
.update({ rag_settings: Object.keys(sanitized).length > 0 ? sanitized : null })
.eq("id", body.documentId)
.eq("user_id", session.user.id)
.limit(1);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ ok: true, ragSettings: sanitized });
}
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { blocksToPlainText, normalizeBlocksFromContent } from "@/lib/documents/plain-text";
import { tryGetBackendBaseUrl } from "@/lib/backend/env";
interface SavePayload {
documentId: string;
@@ -36,13 +37,15 @@ 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`, {
const backendBase = tryGetBackendBaseUrl();
if (backendBase && rawText) {
const targetUrl = new URL("/api/v1/lightrag/index", backendBase);
void fetch(targetUrl.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.access_token}`,
"x-supabase-access-token": session.access_token,
},
body: JSON.stringify({ document_id: documentId }),
}).catch((err) => {
@@ -0,0 +1,49 @@
"use server";
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
type Payload = {
assetId?: string;
ocrText?: string | null;
};
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
let body: Payload;
try {
body = (await request.json()) as Payload;
} catch {
return NextResponse.json({ error: "请求体格式错误" }, { status: 400 });
}
if (!body.assetId) {
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
}
const normalizedText = typeof body.ocrText === "string" ? body.ocrText : null;
const nextStatus = normalizedText && normalizedText.trim() ? "success" : "pending";
const { error } = await supabase
.from("media_assets")
.update({
ocr_text: normalizedText,
ocr_status: nextStatus,
})
.eq("id", body.assetId)
.limit(1);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ ok: true });
}
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { tryGetBackendBaseUrl } from "@/lib/backend/env";
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
@@ -27,10 +28,11 @@ export async function POST(request: Request) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL;
if (backendUrl) {
const backendBase = tryGetBackendBaseUrl();
if (backendBase) {
console.log("media ocr session token prefix", session.access_token?.slice(0, 8));
void fetch(`${backendUrl}/api/v1/tasks/media-ocr`, {
const targetUrl = new URL("/api/v1/tasks/media-ocr", backendBase);
void fetch(targetUrl.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
+495 -24
View File
@@ -1,36 +1,507 @@
"use client";
import { MessageCircle, Sparkles, Wand2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Loader2, MessageCircle, PanelRightClose, Send, Sparkles, Wand2 } from "lucide-react";
import { useSessionContext } from "@supabase/auth-helpers-react";
import { useBackendHealth } from "@/hooks/use-backend-health";
import { cn } from "@/lib/utils";
import { useCurrentPageStore } from "@/store/current-page";
export function BottomToolbar() {
type Props = {
workspaceId?: string | null;
};
type ChatReference = {
file_path?: string;
title?: string;
url?: string;
snippet?: string;
[key: string]: unknown;
};
type ChatChunk =
| { type: "chunk"; content: string }
| { type: "references"; data: ChatReference[] }
| { type: "done" };
type ResolvedReference = {
key: string;
title: string;
href: string;
preview?: string;
source: "local" | "web";
external?: boolean;
};
type ChatHistoryItem = {
id: string;
query: string;
timestamp: number;
useWebSearch: boolean;
modelChoice: "auto" | "deepseek" | "ollama";
workspaceId?: string | null;
documentId?: string | null;
};
const HISTORY_KEY = "ai_chat_history";
const HISTORY_LIMIT = 30;
function normalizeReferences(refs: ChatReference[]): ResolvedReference[] {
return refs
.map((ref, idx) => {
// 本地文档引用:file_path 形如 doc://<id>?title=xxx
const fp = typeof ref.file_path === "string" ? ref.file_path : "";
if (fp.startsWith("doc://")) {
const raw = fp.replace(/^doc:\/\//, "");
const [docId, query = ""] = raw.split("?");
const searchParams = new URLSearchParams(query);
const title = searchParams.get("title") || `文档 ${docId.slice(0, 8)}`;
const preview =
(typeof ref.snippet === "string" && ref.snippet) ||
(typeof ref.content === "string" && ref.content) ||
undefined;
return {
key: `doc-${docId}-${idx}`,
title,
href: `/documents/${docId}`,
preview,
source: "local",
external: false,
};
}
// 联网搜索引用:包含 url/title/snippet
const url = typeof ref.url === "string" ? ref.url : "";
if (url) {
const title =
(typeof ref.title === "string" && ref.title) || `搜索结果 ${idx + 1}`;
const preview =
(typeof ref.snippet === "string" && ref.snippet) ||
(typeof ref.content === "string" && ref.content) ||
undefined;
return {
key: `web-${idx}`,
title,
href: url,
preview,
source: "web",
external: true,
};
}
return null;
})
.filter((item): item is ResolvedReference => Boolean(item));
}
export function BottomToolbar({ workspaceId }: Props) {
const status = useBackendHealth();
const indicatorColor =
status === "ok" ? "bg-green-500" : status === "error" ? "bg-red-500" : "bg-gray-300";
const { session } = useSessionContext();
const currentDocumentId = useCurrentPageStore((state) => state.documentId);
const currentWorkspaceId = useCurrentPageStore((state) => state.workspaceId);
const effectiveWorkspaceId = currentWorkspaceId ?? workspaceId ?? null;
const [panelOpen, setPanelOpen] = useState(false);
const [query, setQuery] = useState("");
const [answer, setAnswer] = useState("");
const [references, setReferences] = useState<ChatReference[]>([]);
const [history, setHistory] = useState<ChatHistoryItem[]>([]);
const [historyOpen, setHistoryOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [modelChoice, setModelChoice] = useState<"auto" | "deepseek" | "ollama">("auto");
const [useWebSearch, setUseWebSearch] = useState(false);
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => {
// 组件卸载时中断 SSE
return () => {
controllerRef.current?.abort();
};
}, []);
useEffect(() => {
// 启动时尝试加载历史
if (typeof window === "undefined") return;
try {
const raw = window.localStorage.getItem(HISTORY_KEY);
if (raw) {
const parsed = JSON.parse(raw) as ChatHistoryItem[];
setHistory(parsed);
}
} catch {
// 忽略解析失败
}
}, []);
function persistHistory(
next: ChatHistoryItem[] | ((prev: ChatHistoryItem[]) => ChatHistoryItem[]),
) {
setHistory((prev) => {
const computed = typeof next === "function" ? next(prev) : next;
const limited = computed.slice(-HISTORY_LIMIT);
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(HISTORY_KEY, JSON.stringify(limited));
} catch {
// 忽略存储异常
}
}
return limited;
});
}
function deleteHistoryItem(id: string) {
persistHistory((prev) => prev.filter((item) => item.id !== id));
}
async function runChat(payloadOverride?: Partial<{
query: string;
useWebSearch: boolean;
modelChoice: "auto" | "deepseek" | "ollama";
}>) {
const finalQuery = (payloadOverride?.query ?? query).trim();
if (!finalQuery) {
setError("请输入问题");
return;
}
if (payloadOverride?.query) {
setQuery(payloadOverride.query);
}
if (!session) {
setError("尚未登录,无法调用 AI");
return;
}
const finalUseWebSearch = payloadOverride?.useWebSearch ?? useWebSearch;
const finalModelChoice = payloadOverride?.modelChoice ?? modelChoice;
if (payloadOverride?.useWebSearch !== undefined) {
setUseWebSearch(payloadOverride.useWebSearch);
}
if (payloadOverride?.modelChoice) {
setModelChoice(payloadOverride.modelChoice);
}
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
setLoading(true);
setError(null);
setAnswer("");
setReferences([]);
// 调试日志:确认调用参数
console.log("[AI助手] runChat", {
workspaceId: effectiveWorkspaceId,
documentId: currentDocumentId,
modelChoice: finalModelChoice,
query: finalQuery,
useWebSearch: finalUseWebSearch,
});
const payload: Record<string, unknown> = {
query: finalQuery,
workspace_id: effectiveWorkspaceId ?? undefined,
document_id: currentDocumentId ?? undefined,
use_web_search: finalUseWebSearch,
};
if (finalModelChoice === "deepseek") {
payload.model = "deepseek";
} else if (finalModelChoice === "ollama") {
payload.model = "ollama";
}
let aborted = false;
let hasError = false;
try {
const resp = await fetch("/api/backend/chat", {
method: "POST",
signal: controller.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok || !resp.body) {
throw new Error(`请求失败:${resp.status}`);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder("utf-8");
let pending = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
pending += decoder.decode(value, { stream: true });
const parts = pending.split("\n\n");
pending = parts.pop() || "";
for (const part of parts) {
if (!part.startsWith("data:")) continue;
const payload = part.replace(/^data:\s*/, "");
if (payload === "[DONE]") continue;
let parsed: ChatChunk | null = null;
try {
parsed = JSON.parse(payload);
} catch {
continue;
}
if (parsed.type === "chunk") {
setAnswer((prev) => prev + (parsed?.content ?? ""));
} else if (parsed.type === "references") {
setReferences(parsed.data ?? []);
}
}
}
} catch (err) {
if (err instanceof Error) {
if (err.name !== "AbortError") {
setError(err.message ?? "请求异常");
hasError = true;
} else {
aborted = true;
}
} else {
setError("请求异常");
hasError = true;
}
} finally {
setLoading(false);
if (!aborted && !hasError) {
const item: ChatHistoryItem = {
id: `hist-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
query: finalQuery,
timestamp: Date.now(),
useWebSearch: finalUseWebSearch,
modelChoice: finalModelChoice,
workspaceId: effectiveWorkspaceId ?? undefined,
documentId: currentDocumentId ?? undefined,
};
persistHistory((prev) => [...prev, item]);
}
}
}
return (
<footer className="flex h-12 items-center justify-between border-t border-[#eeeeee] px-6 text-sm">
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className={cn("h-2 w-2 rounded-full", indicatorColor)} />
{status === "ok" ? "正常" : status === "error" ? "异常" : "检测中"}
</div>
<div className="flex items-center gap-3 text-gray-600">
<button type="button" className="wolai-hover rounded-full px-3 py-1">
<MessageCircle className="mr-1 inline h-4 w-4" />
</button>
<button type="button" className="wolai-hover rounded-full px-3 py-1">
<Wand2 className="mr-1 inline h-4 w-4" />
</button>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full bg-[#2563eb] text-white shadow-sm"
>
<Sparkles className="h-4 w-4" />
</button>
</div>
</footer>
<>
<footer className="flex h-12 items-center justify-between border-t border-[#eeeeee] px-6 text-sm">
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className={cn("h-2 w-2 rounded-full", indicatorColor)} />
{status === "ok" ? "正常" : status === "error" ? "异常" : "检测中"}
{workspaceId ? <span className="ml-2 text-gray-400">Workspace: {workspaceId.slice(0, 8)}</span> : null}
</div>
<div className="flex items-center gap-3 text-gray-600">
<button
type="button"
onClick={() => setPanelOpen(true)}
className="wolai-hover rounded-full px-3 py-1"
>
<MessageCircle className="mr-1 inline h-4 w-4" />
AI
</button>
<button type="button" className="wolai-hover rounded-full px-3 py-1">
<Wand2 className="mr-1 inline h-4 w-4" />
</button>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full bg-[#2563eb] text-white shadow-sm"
onClick={() => setPanelOpen(true)}
>
<Sparkles className="h-4 w-4" />
</button>
</div>
</footer>
{panelOpen ? (
<div className="fixed inset-0 z-50 flex flex-col bg-black/20">
<div className="ml-auto mt-auto h-[60vh] w-full max-w-3xl rounded-t-2xl bg-white shadow-xl ring-1 ring-black/5">
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="font-medium text-gray-800">AI </div>
<div className="flex items-center gap-2">
<select
className="rounded-md border px-2 py-1 text-sm text-gray-700"
value={modelChoice}
onChange={(e) => setModelChoice(e.target.value as "auto" | "deepseek" | "ollama")}
>
<option value="auto"></option>
<option value="deepseek">DeepSeek线</option>
<option value="ollama">Ollama </option>
</select>
<label className="flex items-center gap-1 text-xs text-gray-600">
<input
type="checkbox"
checked={useWebSearch}
onChange={(e) => setUseWebSearch(e.target.checked)}
/>
</label>
<button
type="button"
className={cn(
"rounded-md border px-2 py-1 text-xs",
historyOpen
? "border-blue-200 bg-blue-50 text-blue-700"
: "border-gray-200 bg-gray-50 text-gray-600",
)}
onClick={() => setHistoryOpen((prev) => !prev)}
>
</button>
<button
type="button"
className="wolai-hover rounded-full p-2 text-gray-500"
onClick={() => {
controllerRef.current?.abort();
setPanelOpen(false);
setHistoryOpen(false);
}}
>
<PanelRightClose className="h-4 w-4" />
</button>
</div>
</div>
<div
className={cn(
"relative flex h-[calc(60vh-56px)] flex-col gap-3 p-4",
historyOpen ? "pr-72" : "",
)}
>
<div className="flex-1 overflow-y-auto rounded-lg border bg-gray-50 p-3 text-sm text-gray-800">
{loading && (
<div className="mb-2 flex items-center gap-2 text-blue-600">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{error ? <div className="text-red-500">{error}</div> : null}
{answer ? <div className="whitespace-pre-wrap leading-6">{answer}</div> : null}
{!answer && !loading && !error ? (
<div className="text-gray-400"></div>
) : null}
{references.length > 0 ? (
<div className="mt-3 rounded border border-dashed border-gray-200 bg-white p-2 text-xs text-gray-600">
<div className="mb-1 font-medium text-gray-700"></div>
<div className="flex flex-col gap-2">
{normalizeReferences(references).map((ref) => (
<a
key={ref.key}
href={ref.href}
target={ref.external ? "_blank" : undefined}
rel={ref.external ? "noreferrer" : undefined}
className="group rounded-lg border border-gray-100 bg-gray-50/80 p-2 transition hover:border-blue-200 hover:bg-blue-50"
>
<div className="flex items-center gap-2 text-[13px] font-medium text-gray-800">
<span
className={cn(
"inline-flex h-5 items-center rounded-full px-2 text-xs font-semibold",
ref.source === "web"
? "bg-blue-100 text-blue-700"
: "bg-emerald-100 text-emerald-700",
)}
>
{ref.source === "web" ? "搜索" : "本地"}
</span>
<span className="truncate group-hover:text-blue-700">
{ref.title}
</span>
</div>
{ref.preview ? (
<div className="mt-1 line-clamp-2 text-[12px] text-gray-500">
{ref.preview}
</div>
) : null}
</a>
))}
</div>
</div>
) : null}
</div>
<div className="flex items-center gap-3">
<input
className="flex-1 rounded-lg border px-3 py-2 text-sm outline-none ring-0 focus:border-blue-500"
placeholder="输入问题,回车或点击发送"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void runChat();
}
}}
/>
<button
type="button"
onClick={() => void runChat()}
disabled={loading}
className="flex items-center gap-1 rounded-lg bg-[#2563eb] px-3 py-2 text-sm text-white shadow-sm disabled:cursor-not-allowed disabled:bg-blue-200"
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</button>
</div>
{historyOpen ? (
<div className="absolute right-6 top-4 bottom-4 w-64 rounded-lg border bg-white p-3 text-xs text-gray-700 shadow-lg">
<div className="flex items-center justify-between">
<div className="font-medium text-gray-800"></div>
{history.length > 0 ? (
<button
type="button"
className="text-[11px] text-gray-400 hover:text-gray-600"
onClick={() => persistHistory([])}
>
</button>
) : null}
</div>
<div className="mt-2 flex h-[calc(60vh-130px)] flex-col gap-2 overflow-y-auto pr-1">
{history.length === 0 ? (
<div className="rounded bg-gray-50 p-2 text-gray-400"></div>
) : null}
{history
.slice()
.reverse()
.map((item) => (
<div
key={item.id}
className="group rounded border border-gray-100 bg-gray-50 p-2 shadow-[0_1px_0_rgba(0,0,0,0.03)] transition hover:border-blue-200 hover:bg-blue-50"
>
<div className="flex items-start justify-between gap-2">
<button
type="button"
className="text-left text-[13px] font-medium text-gray-800 hover:text-blue-700"
onClick={() =>
void runChat({
query: item.query,
useWebSearch: item.useWebSearch,
modelChoice: item.modelChoice,
})
}
>
{item.query}
</button>
<button
type="button"
className="text-gray-400 hover:text-red-500"
onClick={() => deleteHistoryItem(item.id)}
aria-label="删除历史"
>
×
</button>
</div>
<div className="mt-1 flex items-center gap-1 text-[11px] text-gray-500">
<span
className={cn(
"rounded px-1",
item.useWebSearch ? "bg-blue-100 text-blue-700" : "bg-emerald-100 text-emerald-700",
)}
>
{item.useWebSearch ? "联网" : "本地"}
</span>
<span>{new Date(item.timestamp).toLocaleString()}</span>
</div>
</div>
))}
</div>
</div>
) : null}
</div>
</div>
</div>
) : null}
</>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useSessionContext, useSupabaseClient } from "@supabase/auth-helpers-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -30,7 +30,6 @@ export function DocumentTaskPanel({ documentId }: Props) {
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;
@@ -79,21 +78,24 @@ export function DocumentTaskPanel({ documentId }: Props) {
}, [documentId, supabase, toTaskResponse]);
const triggerTask = async () => {
if (!backendUrl || !session?.access_token || !fileUrl) return;
if (!session || !fileUrl) {
setErrorMessage("未登录或缺少文件地址");
return;
}
setPending(true);
setErrorMessage(null);
try {
const response = await fetch(`${backendUrl}/api/v1/tasks/ocr`, {
const response = await fetch("/api/backend/tasks/ocr", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.access_token}`,
},
body: JSON.stringify({
document_id: documentId,
file_url: fileUrl,
}),
});
const data = await response.json();
const data = (await response.json().catch(() => ({}))) as Record<string, unknown>;
if (response.ok) {
const nextTask: TaskResponse = {
task_id: data.task_id,
@@ -104,10 +106,11 @@ export function DocumentTaskPanel({ documentId }: Props) {
};
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 失败");
}
} catch {
setErrorMessage("触发 OCR 失败,请稍后再试");
} finally {
setPending(false);
}
@@ -7,6 +7,7 @@ import { PageOptionsSidebar } from "@/components/editor/page-options-sidebar";
import { PageBacklinksPanel } from "@/components/editor/page-backlinks-panel";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { usePageLayoutStore } from "@/store/page-layout";
import { useCurrentPageStore } from "@/store/current-page";
import type { Json } from "@/types/supabase";
import { DocumentHistoryDrawer } from "@/components/editor/document-history-drawer";
import type { DocumentSnapshot } from "@/types/document";
@@ -61,6 +62,8 @@ export function DocumentContent({
const editorBridge = useEditorBridgeStore((state) => state.bridge);
const showInspector = usePageLayoutStore((state) => state.showInspector);
const [pageTitle, setPageTitle] = useState(title ?? "无标题");
const setCurrentPage = useCurrentPageStore((state) => state.setCurrentPage);
const resetCurrentPage = useCurrentPageStore((state) => state.reset);
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
@@ -74,6 +77,12 @@ export function DocumentContent({
useEffect(() => {
setStats(initialStats ?? defaultStats);
}, [initialStats]);
useEffect(() => {
setCurrentPage({ documentId, workspaceId });
return () => {
resetCurrentPage();
};
}, [documentId, workspaceId, setCurrentPage, resetCurrentPage]);
/* eslint-enable react-hooks/set-state-in-effect */
const persistTitle = useCallback(
@@ -1,7 +1,6 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState, type ReactNode } from "react";
interface QueryProviderProps {
@@ -25,7 +24,6 @@ export function QueryProvider({ children }: QueryProviderProps) {
return (
<QueryClientProvider client={client}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
+2 -11
View File
@@ -3,23 +3,14 @@ import { useEffect, useState } from "react";
type Status = "idle" | "ok" | "error";
export function useBackendHealth() {
const [status, setStatus] = useState<Status>(() => {
if (!process.env.NEXT_PUBLIC_BACKEND_URL) {
return "error";
}
return "idle";
});
const [status, setStatus] = useState<Status>("idle");
useEffect(() => {
let destroyed = false;
const url = process.env.NEXT_PUBLIC_BACKEND_URL;
if (!url) {
return;
}
const controller = new AbortController();
const check = async () => {
try {
const response = await fetch(`${url}/health`, { signal: controller.signal });
const response = await fetch("/api/backend/health", { signal: controller.signal });
if (!destroyed) {
setStatus(response.ok ? "ok" : "error");
}
+48
View File
@@ -0,0 +1,48 @@
const TRAILING_SLASH = /\/$/;
export function getBackendBaseUrl(): string {
const raw = process.env.BACKEND_URL ?? process.env.NEXT_PUBLIC_BACKEND_URL;
if (!raw) {
throw new Error("BACKEND_URL 未配置");
}
return raw.replace(TRAILING_SLASH, "");
}
export function tryGetBackendBaseUrl(): string | null {
try {
return getBackendBaseUrl();
} catch {
return null;
}
}
type SearchParamsInit = Record<string, string | number | boolean | undefined> | URLSearchParams;
export function buildBackendUrl(path: string, params?: SearchParamsInit): URL {
const base = getBackendBaseUrl();
const url = new URL(path, base);
if (params) {
const search =
params instanceof URLSearchParams
? params
: new URLSearchParams(
Object.entries(params).reduce<Record<string, string>>((acc, [key, value]) => {
if (value !== undefined) {
acc[key] = String(value);
}
return acc;
}, {}),
);
search.forEach((value, key) => {
if (value !== undefined && value !== null) {
url.searchParams.set(key, value);
}
});
}
return url;
}
export async function fetchBackend(path: string, init?: RequestInit, params?: SearchParamsInit) {
const url = buildBackendUrl(path, params);
return fetch(url, init);
}
+18
View File
@@ -0,0 +1,18 @@
"use client";
import { create } from "zustand";
interface CurrentPageState {
documentId: string | null;
workspaceId: string | null;
setCurrentPage: (payload: { documentId?: string | null; workspaceId?: string | null }) => void;
reset: () => void;
}
export const useCurrentPageStore = create<CurrentPageState>((set) => ({
documentId: null,
workspaceId: null,
setCurrentPage: ({ documentId = null, workspaceId = null }) =>
set({ documentId, workspaceId }),
reset: () => set({ documentId: null, workspaceId: null }),
}));
+3
View File
@@ -67,6 +67,7 @@ export type Database = {
mindmap_data: Json | null;
parent_id: string | null;
raw_text: string | null;
rag_settings: Json | null;
sort_order: number;
title: string | null;
updated_at: string | null;
@@ -97,6 +98,7 @@ export type Database = {
mindmap_data?: Json | null;
parent_id?: string | null;
raw_text?: string | null;
rag_settings?: Json | null;
sort_order?: number;
title?: string | null;
updated_at?: string | null;
@@ -127,6 +129,7 @@ export type Database = {
mindmap_data?: Json | null;
parent_id?: string | null;
raw_text?: string | null;
rag_settings?: Json | null;
sort_order?: number;
title?: string | null;
updated_at?: string | null;