Restore 0.1.5 version from stash
This commit is contained in:
@@ -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: "Mix(KG+向量)" },
|
||||
{ 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">可编辑并同步至 Supabase,AI 可直接引用。</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">按段编辑后保存即可同步 Supabase,LightRAG 会重新索引。</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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user