feat(knowledge): support standalone file ingestion and configurable wiki entry

Use neutral knowledge-service labels and sanitize user-facing task errors.
This commit is contained in:
吉鑫升 committed 2026-09-07 17:07:27 +08:00
1 parent f9b6d7e322
commit 3ca5528640
24 files changed
+549 -54

No files matched your search

@@ -1,3 +1,4 @@
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
import {
ArrowLeft,
BookOpenText,
@@ -67,6 +68,7 @@ export default function AssistantKnowledgePage() {
ReturnType<typeof searchAssistantWiki>
> | null>(null);
const upload = useRef<HTMLInputElement>(null);
const documentUpload = useRef<HTMLInputElement>(null);
const base = bases.data?.find((b) => b.id === baseId);
const libraryPages = useMemo<LibraryWikiPage[]>(
() =>
@@ -159,8 +161,7 @@ export default function AssistantKnowledgePage() {
<div>
<h1 className="text-2xl font-semibold">助手知识库</h1>
<p className="mt-2 text-sm text-muted-foreground">
浏览分类
Wiki、关联知识与来源。普通知识库向量化完成后自动沉淀到知识梳理。
选择或新建知识库,上传文档后自动生成分类 Wiki、关联知识和向量索引。可独立使用,也可导入 普通知识库数据包;各库知识汇入知识梳理总库。
</p>
</div>
{isAdmin && (
@@ -306,6 +307,12 @@ export default function AssistantKnowledgePage() {
删除
</Button>
)}
{isAdmin && (
<Button size="sm" disabled={actions.uploadDocument.isPending} onClick={() => documentUpload.current?.click()}>
{actions.uploadDocument.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Upload className="mr-2 size-4" />}
上传文件生成 Wiki
</Button>
)}
{isAdmin && (
<Button
variant="outline"
@@ -314,10 +321,23 @@ export default function AssistantKnowledgePage() {
onClick={() => upload.current?.click()}
>
<Upload className="mr-2 size-4" />
导入数据包
导入 普通知识库数据包
</Button>
)}
</div>
<input ref={documentUpload} type="file" multiple accept=".md,.markdown,.txt,.csv,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.odt,.ods,.odp" className="hidden"
onChange={async (event) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
setTab("transfer");
for (const file of files) {
if (file.size > 100 * 1024 * 1024) { toast.error(`${file.name} 超过 100 MB,请拆分后上传`); continue; }
try {
await actions.uploadDocument.mutateAsync({ file, assistantBaseId: baseId });
toast.success(`${file.name} 已上传,后台正在解析、生成 Wiki 并向量化`);
} catch (error) { toast.error(`${file.name}:${(error as Error).message}`); }
}
}} />
<input
ref={upload}
type="file"
@@ -441,7 +461,8 @@ export default function AssistantKnowledgePage() {
<p className="mt-2 text-xs text-muted-foreground">
上次同步:{new Date(s.updated_at).toLocaleString()}
</p>
{isAdmin && (
{s.source_type === "file" && <p className="mt-2 text-xs text-muted-foreground">本地上传文档 · 处理进度及重试请查看导入记录</p>}
{isAdmin && s.source_type === "weknora" && (
<Button
className="mt-4"
size="sm"
@@ -469,8 +490,10 @@ export default function AssistantKnowledgePage() {
>
<WikiPackageTransfers id={baseId} assistant />
<h2 className="text-base font-semibold">导入与同步记录</h2>
<p className="text-sm text-muted-foreground">可直接上传文档,独立生成 Wiki 并进行向量检索,无需配置普通知识库服务。支持 PDF、Word、Excel、PowerPoint、Markdown、TXT、CSV,单文件最大 100 MB。生成失败时可配置语言模型或编码器后重试。</p>
{jobs.data?.map((j) => (
<div key={j.id} className="rounded-xl border p-4">
<p className="mb-2 font-medium">{sources.data?.find((source) => source.id === j.source_id)?.source_name || (j.trigger === "local_file_upload" ? "上传文档" : "知识数据同步")}</p>
<div className="flex items-center gap-3">
<Badge variant="outline">
{{
@@ -484,6 +507,9 @@ export default function AssistantKnowledgePage() {
{new Date(j.created_at).toLocaleString()}
</span>
</div>
<p className="mt-2 text-sm text-muted-foreground">{({ uploading: "接收文件", parsing: "解析文档", generating_wiki: "生成 Wiki", vectorizing: "正文向量化", importing: "保存知识并同步总库", queued: "等待处理", interrupted: "处理已中断" } as Record<string, string>)[j.phase] || j.phase}</p>
{j.counts?.parts_total > 0 && <p className="text-xs text-muted-foreground">文档分段:{j.counts.parts_done || 0} / {j.counts.parts_total}</p>}
{j.counts?.vectors_total > 0 && <p className="text-xs text-muted-foreground">向量化:{j.counts.vectors || 0} / {j.counts.vectors_total}</p>}
<p className="mt-3 text-sm">
{j.counts?.wiki_pages || 0} Wiki ·{" "}
{j.counts?.vectors || 0} 向量 · {j.counts?.entities || 0}{" "}
@@ -491,9 +517,15 @@ export default function AssistantKnowledgePage() {
</p>
{j.last_error_message && (
<p className="mt-2 text-sm text-destructive">
{j.last_error_message}
{userFacingKnowledgeMessage(j.last_error_message)}
</p>
)}
{j.trigger === "local_file_upload" && j.status !== "completed" && (
<Button className="mt-2" size="sm" variant="outline" disabled={actions.retryDocument.isPending} onClick={async () => {
try { await actions.retryDocument.mutateAsync(j.id); toast.success("已继续处理,复用已有解析和向量结果"); }
catch (error) { toast.error((error as Error).message); }
}}>重试 / 恢复处理</Button>
)}
</div>
))}
</TabsContent>
@@ -1,3 +1,4 @@
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
import {
AlertCircle,
@@ -211,7 +212,7 @@ export default function EnterpriseResearchPage() {
setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task));
}
if (event.type === "completed") toast.success("企业研究报告已完成");
if (event.type === "failed") toast.error(event.job.error_message || "报告生成失败");
if (event.type === "failed") toast.error(userFacingKnowledgeMessage(event.job.error_message || "报告生成失败"));
}
},
}).catch((error: unknown) => {
@@ -290,7 +291,7 @@ export default function EnterpriseResearchPage() {
return;
}
if (!isWeKnoraReady) {
toast.error("知识服务尚未启用,请先在系统中配置 WeKnora");
toast.error("知识服务尚未启用,请先在系统中配置知识服务");
return;
}
@@ -442,7 +443,7 @@ export default function EnterpriseResearchPage() {
<section>
<Badge variant="secondary" className="mb-3"><Sparkles className="size-3" />阶段一:证据编排</Badge>
<h2 className="text-xl font-semibold tracking-tight">建立一个可追溯的研究任务</h2>
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">页面独立于现有“深度研究”功能。任务、检索词与证据快照保存在 DeerFlow 后端;当前版本只使用已授权的 WeKnora 知识库,不会修改任何知识库或既有报告。</p>
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">页面独立于现有“深度研究”功能。任务、检索词与证据快照保存在 DeerFlow 后端;当前版本只使用已授权的 普通知识库,不会修改任何知识库或既有报告。</p>
</section>
<Card>
@@ -609,12 +610,12 @@ function TaskOverview({
<CardContent><ol className="space-y-4">{template.stages.map((stage, index) => {
const complete = task.status !== "collecting" && (index < 2 || task.sources.length > 0);
const current = task.status === "collecting" && index === 1;
return <li key={stage} className="flex gap-3"><span className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold", complete ? "border-emerald-300 bg-emerald-50 text-emerald-700" : current ? "border-primary bg-primary/10 text-primary" : "text-muted-foreground")}>{complete ? <CheckCircle2 className="size-4" /> : index + 1}</span><div className="min-w-0 pt-0.5"><p className="text-sm font-medium">{stage}</p><p className="mt-1 text-xs leading-5 text-muted-foreground">{index === 0 ? "已固定研究主题、范围和知识库权限。" : index === 1 ? task.status === "collecting" ? "正在调用 WeKnora 检索并去重证据。" : `已执行 ${task.queries.length} 个检索方向。` : index === 2 ? `${task.sources.length} 条去重后的资料可在右侧逐条核验。` : "研究计划会固定选用资料、章节结构和写作约束。"}</p></div></li>;
return <li key={stage} className="flex gap-3"><span className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold", complete ? "border-emerald-300 bg-emerald-50 text-emerald-700" : current ? "border-primary bg-primary/10 text-primary" : "text-muted-foreground")}>{complete ? <CheckCircle2 className="size-4" /> : index + 1}</span><div className="min-w-0 pt-0.5"><p className="text-sm font-medium">{stage}</p><p className="mt-1 text-xs leading-5 text-muted-foreground">{index === 0 ? "已固定研究主题、范围和知识库权限。" : index === 1 ? task.status === "collecting" ? "正在调用知识服务检索并去重证据。" : `已执行 ${task.queries.length} 个检索方向。` : index === 2 ? `${task.sources.length} 条去重后的资料可在右侧逐条核验。` : "研究计划会固定选用资料、章节结构和写作约束。"}</p></div></li>;
})}</ol></CardContent>
</Card>
<Card>
<CardHeader className="gap-1"><CardTitle className="text-base">已执行检索词</CardTitle><CardDescription>检索词由模板和你的研究范围生成,结果仅来自当前账号可见的 WeKnora 知识库。</CardDescription></CardHeader>
<CardContent><div className="flex flex-wrap gap-2">{task.queries.map((query) => <Badge key={query} variant="outline" className="max-w-full whitespace-normal py-1 text-left"><Search className="size-3" />{query}</Badge>)}</div>{task.warning ? <div className="mt-4 flex gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"><AlertCircle className="mt-0.5 size-4 shrink-0" />{task.warning}</div> : null}</CardContent>
<CardHeader className="gap-1"><CardTitle className="text-base">已执行检索词</CardTitle><CardDescription>检索词由模板和你的研究范围生成,结果仅来自当前账号可见的 普通知识库。</CardDescription></CardHeader>
<CardContent><div className="flex flex-wrap gap-2">{task.queries.map((query) => <Badge key={query} variant="outline" className="max-w-full whitespace-normal py-1 text-left"><Search className="size-3" />{query}</Badge>)}</div>{task.warning ? <div className="mt-4 flex gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"><AlertCircle className="mt-0.5 size-4 shrink-0" />{userFacingKnowledgeMessage(task.warning)}</div> : null}</CardContent>
</Card>
{canPlan ? <Card>
<CardHeader className="gap-1"><CardTitle className="text-base">研究计划与选用资料</CardTitle><CardDescription>计划和资料选择会保存到当前任务;后续报告只能使用这里确认过的证据。</CardDescription></CardHeader>
@@ -91,7 +91,7 @@ export default function LlmWikiKnowledgePage({
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
}
if (runtime.isError || runtime.data?.provider !== "weknora") {
if (runtime.isError || runtime.data?.provider !== "weknora" || !runtime.data?.api_base_url?.trim()) {
if (mode === "list" || mode === "detail") {
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
}