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:
1 parent
f9b6d7e322
commit
3ca5528640
24 files changed
+549
-54
No files matched your search
@@ -6,13 +6,22 @@ import { toast } from "sonner";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
|
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||||||
import {
|
import {
|
||||||
loadKnowledgeBaseProductVersion,
|
loadKnowledgeBaseProductVersion,
|
||||||
|
loadOrdinaryKnowledgeLanding,
|
||||||
|
saveOrdinaryKnowledgeLanding,
|
||||||
|
type OrdinaryKnowledgeLanding,
|
||||||
type KnowledgeBaseProductVersion,
|
type KnowledgeBaseProductVersion,
|
||||||
updateKnowledgeBaseProductVersion,
|
updateKnowledgeBaseProductVersion,
|
||||||
} from "@/core/system-settings/api";
|
} from "@/core/system-settings/api";
|
||||||
|
|
||||||
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const;
|
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const;
|
||||||
|
const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const;
|
||||||
|
|
||||||
|
export function useOrdinaryKnowledgeLanding() {
|
||||||
|
return useQuery({ queryKey: LANDING_KEY, queryFn: loadOrdinaryKnowledgeLanding, staleTime: 0 });
|
||||||
|
}
|
||||||
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
|
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
|
||||||
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
|
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
|
||||||
|
|
||||||
@@ -25,6 +34,14 @@ export function KnowledgeProductNav() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
const version = useKnowledgeBaseProductVersion();
|
const version = useKnowledgeBaseProductVersion();
|
||||||
|
const landing = useOrdinaryKnowledgeLanding();
|
||||||
|
const saveLanding = useMutation({
|
||||||
|
mutationFn: saveOrdinaryKnowledgeLanding,
|
||||||
|
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
});
|
||||||
|
const runtime = useLlmWikiRuntime();
|
||||||
|
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
|
||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: updateKnowledgeBaseProductVersion,
|
mutationFn: updateKnowledgeBaseProductVersion,
|
||||||
@@ -36,15 +53,20 @@ export function KnowledgeProductNav() {
|
|||||||
const assistant = location.pathname.includes("/knowledge/assistant");
|
const assistant = location.pathname.includes("/knowledge/assistant");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 border-b bg-card px-6 py-2">
|
<div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
|
||||||
<Button size="sm" variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
||||||
<BookOpen />普通知识库
|
<BookOpen />普通知识库
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
||||||
<Database />助手知识库
|
<Database />助手知识库
|
||||||
</Button>
|
</Button>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
普通知识库默认页面
|
||||||
|
<Select disabled={landing.isLoading || saveLanding.isPending} value={landing.data ?? "wiki"} onValueChange={(value) => saveLanding.mutate(value as OrdinaryKnowledgeLanding)}>
|
||||||
|
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
|
||||||
|
</Select>
|
||||||
默认产品版本
|
默认产品版本
|
||||||
<Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
|
<Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
|
||||||
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
||||||
|
|||||||
@@ -205,21 +205,21 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
|
|||||||
/>
|
/>
|
||||||
<Database className="size-4 text-muted-foreground" />
|
<Database className="size-4 text-muted-foreground" />
|
||||||
<span className="text-sm">{target.target_name}</span>
|
<span className="text-sm">{target.target_name}</span>
|
||||||
<Badge variant="outline" className="ml-auto text-[10px]">WeKnora Wiki</Badge>
|
<Badge variant="outline" className="ml-auto text-[10px]">普通 Wiki 库</Badge>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{(runtime.isLoading || knowledgeBases.isLoading) && <Loader2 className="m-4 size-5 animate-spin" />}
|
{(runtime.isLoading || knowledgeBases.isLoading) && <Loader2 className="m-4 size-5 animate-spin" />}
|
||||||
{!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && (
|
{!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && (
|
||||||
<p className="p-3 text-xs text-destructive">
|
<p className="p-3 text-xs text-destructive">
|
||||||
读取 WeKnora 普通知识库失败:
|
读取普通知识库失败:
|
||||||
{knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)}
|
{knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
|
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
|
||||||
<p className="p-3 text-xs text-muted-foreground">
|
<p className="p-3 text-xs text-muted-foreground">
|
||||||
{runtime.data?.provider !== "weknora"
|
{runtime.data?.provider !== "weknora"
|
||||||
? "当前未配置 WeKnora 普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
|
? "当前未配置普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
|
||||||
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
|
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,6 +11,22 @@ export interface PromptPrefixSettings {
|
|||||||
|
|
||||||
export type KnowledgeBaseProductVersion = "v1" | "v2";
|
export type KnowledgeBaseProductVersion = "v1" | "v2";
|
||||||
|
|
||||||
|
export type OrdinaryKnowledgeLanding = "wiki" | "weknora";
|
||||||
|
|
||||||
|
export async function loadOrdinaryKnowledgeLanding(): Promise<OrdinaryKnowledgeLanding> {
|
||||||
|
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`);
|
||||||
|
if (!response.ok) throw new Error("无法读取普通知识库默认页面设置");
|
||||||
|
return ((await response.json()) as { mode: OrdinaryKnowledgeLanding }).mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveOrdinaryKnowledgeLanding(mode: OrdinaryKnowledgeLanding): Promise<OrdinaryKnowledgeLanding> {
|
||||||
|
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`, {
|
||||||
|
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("保存失败,仅管理员可配置普通知识库默认页面");
|
||||||
|
return ((await response.json()) as { mode: OrdinaryKnowledgeLanding }).mode;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadKnowledgeBaseProductVersion(): Promise<KnowledgeBaseProductVersion> {
|
export async function loadKnowledgeBaseProductVersion(): Promise<KnowledgeBaseProductVersion> {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`,
|
`${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`,
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** Neutral product terminology for system messages; never apply to knowledge content or API identifiers. */
|
||||||
|
export function userFacingKnowledgeMessage(value: string): string {
|
||||||
|
return value.replace(/weknora/gi, "知识服务");
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
BookOpenText,
|
BookOpenText,
|
||||||
@@ -67,6 +68,7 @@ export default function AssistantKnowledgePage() {
|
|||||||
ReturnType<typeof searchAssistantWiki>
|
ReturnType<typeof searchAssistantWiki>
|
||||||
> | null>(null);
|
> | null>(null);
|
||||||
const upload = useRef<HTMLInputElement>(null);
|
const upload = useRef<HTMLInputElement>(null);
|
||||||
|
const documentUpload = useRef<HTMLInputElement>(null);
|
||||||
const base = bases.data?.find((b) => b.id === baseId);
|
const base = bases.data?.find((b) => b.id === baseId);
|
||||||
const libraryPages = useMemo<LibraryWikiPage[]>(
|
const libraryPages = useMemo<LibraryWikiPage[]>(
|
||||||
() =>
|
() =>
|
||||||
@@ -159,8 +161,7 @@ export default function AssistantKnowledgePage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">助手知识库</h1>
|
<h1 className="text-2xl font-semibold">助手知识库</h1>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
浏览分类
|
选择或新建知识库,上传文档后自动生成分类 Wiki、关联知识和向量索引。可独立使用,也可导入 普通知识库数据包;各库知识汇入知识梳理总库。
|
||||||
Wiki、关联知识与来源。普通知识库向量化完成后自动沉淀到知识梳理。
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
@@ -306,6 +307,12 @@ export default function AssistantKnowledgePage() {
|
|||||||
删除
|
删除
|
||||||
</Button>
|
</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 && (
|
{isAdmin && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -314,10 +321,23 @@ export default function AssistantKnowledgePage() {
|
|||||||
onClick={() => upload.current?.click()}
|
onClick={() => upload.current?.click()}
|
||||||
>
|
>
|
||||||
<Upload className="mr-2 size-4" />
|
<Upload className="mr-2 size-4" />
|
||||||
导入数据包
|
导入 普通知识库数据包
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<input
|
||||||
ref={upload}
|
ref={upload}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -441,7 +461,8 @@ export default function AssistantKnowledgePage() {
|
|||||||
<p className="mt-2 text-xs text-muted-foreground">
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
上次同步:{new Date(s.updated_at).toLocaleString()}
|
上次同步:{new Date(s.updated_at).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
{isAdmin && (
|
{s.source_type === "file" && <p className="mt-2 text-xs text-muted-foreground">本地上传文档 · 处理进度及重试请查看导入记录</p>}
|
||||||
|
{isAdmin && s.source_type === "weknora" && (
|
||||||
<Button
|
<Button
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -469,8 +490,10 @@ export default function AssistantKnowledgePage() {
|
|||||||
>
|
>
|
||||||
<WikiPackageTransfers id={baseId} assistant />
|
<WikiPackageTransfers id={baseId} assistant />
|
||||||
<h2 className="text-base font-semibold">导入与同步记录</h2>
|
<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) => (
|
{jobs.data?.map((j) => (
|
||||||
<div key={j.id} className="rounded-xl border p-4">
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
{{
|
{{
|
||||||
@@ -484,6 +507,9 @@ export default function AssistantKnowledgePage() {
|
|||||||
{new Date(j.created_at).toLocaleString()}
|
{new Date(j.created_at).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<p className="mt-3 text-sm">
|
||||||
{j.counts?.wiki_pages || 0} Wiki ·{" "}
|
{j.counts?.wiki_pages || 0} Wiki ·{" "}
|
||||||
{j.counts?.vectors || 0} 向量 · {j.counts?.entities || 0}{" "}
|
{j.counts?.vectors || 0} 向量 · {j.counts?.entities || 0}{" "}
|
||||||
@@ -491,9 +517,15 @@ export default function AssistantKnowledgePage() {
|
|||||||
</p>
|
</p>
|
||||||
{j.last_error_message && (
|
{j.last_error_message && (
|
||||||
<p className="mt-2 text-sm text-destructive">
|
<p className="mt-2 text-sm text-destructive">
|
||||||
{j.last_error_message}
|
{userFacingKnowledgeMessage(j.last_error_message)}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||||
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -211,7 +212,7 @@ export default function EnterpriseResearchPage() {
|
|||||||
setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task));
|
setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task));
|
||||||
}
|
}
|
||||||
if (event.type === "completed") toast.success("企业研究报告已完成");
|
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) => {
|
}).catch((error: unknown) => {
|
||||||
@@ -290,7 +291,7 @@ export default function EnterpriseResearchPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isWeKnoraReady) {
|
if (!isWeKnoraReady) {
|
||||||
toast.error("知识服务尚未启用,请先在系统中配置 WeKnora");
|
toast.error("知识服务尚未启用,请先在系统中配置知识服务");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,7 +443,7 @@ export default function EnterpriseResearchPage() {
|
|||||||
<section>
|
<section>
|
||||||
<Badge variant="secondary" className="mb-3"><Sparkles className="size-3" />阶段一:证据编排</Badge>
|
<Badge variant="secondary" className="mb-3"><Sparkles className="size-3" />阶段一:证据编排</Badge>
|
||||||
<h2 className="text-xl font-semibold tracking-tight">建立一个可追溯的研究任务</h2>
|
<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>
|
</section>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -609,12 +610,12 @@ function TaskOverview({
|
|||||||
<CardContent><ol className="space-y-4">{template.stages.map((stage, index) => {
|
<CardContent><ol className="space-y-4">{template.stages.map((stage, index) => {
|
||||||
const complete = task.status !== "collecting" && (index < 2 || task.sources.length > 0);
|
const complete = task.status !== "collecting" && (index < 2 || task.sources.length > 0);
|
||||||
const current = task.status === "collecting" && index === 1;
|
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>
|
})}</ol></CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="gap-1"><CardTitle className="text-base">已执行检索词</CardTitle><CardDescription>检索词由模板和你的研究范围生成,结果仅来自当前账号可见的 WeKnora 知识库。</CardDescription></CardHeader>
|
<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" />{task.warning}</div> : null}</CardContent>
|
<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>
|
</Card>
|
||||||
{canPlan ? <Card>
|
{canPlan ? <Card>
|
||||||
<CardHeader className="gap-1"><CardTitle className="text-base">研究计划与选用资料</CardTitle><CardDescription>计划和资料选择会保存到当前任务;后续报告只能使用这里确认过的证据。</CardDescription></CardHeader>
|
<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 />;
|
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") {
|
if (mode === "list" || mode === "detail") {
|
||||||
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
|
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
|
|
||||||
import { apiFetch } from "@/core/api/fetch-client";
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||||
|
|
||||||
const BASE = () => `${getBackendBaseURL()}/api/assistant-knowledge`;
|
const BASE = () => `${getBackendBaseURL()}/api/assistant-knowledge`;
|
||||||
|
|
||||||
@@ -113,7 +114,7 @@ async function readJson<T>(response: Response): Promise<T> {
|
|||||||
? (data as { detail: unknown }).detail
|
? (data as { detail: unknown }).detail
|
||||||
: `请求失败 (${response.status})`;
|
: `请求失败 (${response.status})`;
|
||||||
throw new Error(
|
throw new Error(
|
||||||
typeof detail === "string" ? detail : JSON.stringify(detail),
|
userFacingKnowledgeMessage(typeof detail === "string" ? detail : JSON.stringify(detail)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (await response.json()) as T;
|
return (await response.json()) as T;
|
||||||
@@ -408,6 +409,17 @@ export async function uploadAssistantWeKnoraPackage(input: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function uploadAssistantDocument(input: { file: File; assistantBaseId?: string | null }) {
|
||||||
|
const body = new FormData();
|
||||||
|
body.set("file", input.file);
|
||||||
|
if (input.assistantBaseId) body.set("assistant_base_id", input.assistantBaseId);
|
||||||
|
return normalizeImportJob(await readJson<AssistantImportJob>(await apiFetch(`${BASE()}/imports/files`, { method: "POST", body })));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryAssistantDocument(jobId: string) {
|
||||||
|
return readJson<AssistantImportJob>(await apiFetch(`${BASE()}/import-jobs/${encodeURIComponent(jobId)}/retry-file`, { method: "POST" }));
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteAssistantKnowledgeSource(sourceId: string) {
|
export async function deleteAssistantKnowledgeSource(sourceId: string) {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${BASE()}/sources/${encodeURIComponent(sourceId)}`,
|
`${BASE()}/sources/${encodeURIComponent(sourceId)}`,
|
||||||
@@ -490,6 +502,8 @@ export function useAssistantKnowledgeActions() {
|
|||||||
mutationFn: uploadAssistantWeKnoraPackage,
|
mutationFn: uploadAssistantWeKnoraPackage,
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
}),
|
}),
|
||||||
|
uploadDocument: useMutation({ mutationFn: uploadAssistantDocument, onSuccess: invalidate }),
|
||||||
|
retryDocument: useMutation({ mutationFn: retryAssistantDocument, onSuccess: invalidate }),
|
||||||
reimportSource: useMutation({
|
reimportSource: useMutation({
|
||||||
mutationFn: reimportAssistantKnowledgeSource,
|
mutationFn: reimportAssistantKnowledgeSource,
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||||
import { apiFetch } from "@/core/api/fetch-client";
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ async function readJson<T>(response: Response, context: string): Promise<T> {
|
|||||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||||
? String((payload as { detail: unknown }).detail)
|
? String((payload as { detail: unknown }).detail)
|
||||||
: `${context}失败(${response.status})`;
|
: `${context}失败(${response.status})`;
|
||||||
throw new Error(detail);
|
throw new Error(userFacingKnowledgeMessage(detail));
|
||||||
}
|
}
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
@@ -249,6 +250,6 @@ export async function deleteEnterpriseResearchTask(taskId: string): Promise<void
|
|||||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||||
? String((payload as { detail: unknown }).detail)
|
? String((payload as { detail: unknown }).detail)
|
||||||
: `删除研究任务失败(${response.status})`;
|
: `删除研究任务失败(${response.status})`;
|
||||||
throw new Error(detail);
|
throw new Error(userFacingKnowledgeMessage(detail));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import { getCsrfToken } from "@/core/api/csrf";
|
|||||||
import { getAuthorizationHeaderValue } from "@/core/auth";
|
import { getAuthorizationHeaderValue } from "@/core/auth";
|
||||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||||
|
|
||||||
const API = () => `${getBackendBaseURL()}/api/llmwiki`;
|
const API = () => `${getBackendBaseURL()}/api/llmwiki`;
|
||||||
const PUBLIC_API = () => `${getBackendBaseURL()}/api/public/llmwiki`;
|
const PUBLIC_API = () => `${getBackendBaseURL()}/api/public/llmwiki`;
|
||||||
@@ -366,10 +367,6 @@ export interface LlmWikiWikiPageUpdate {
|
|||||||
wiki_path?: string;
|
wiki_path?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function userFacingKnowledgeMessage(value: string): string {
|
|
||||||
return value.replace(/weknora/gi, "知识服务");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readJson<T>(response: Response, context: string): Promise<T> {
|
async function readJson<T>(response: Response, context: string): Promise<T> {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const payload = await response.json().catch(() => null as unknown);
|
const payload = await response.json().catch(() => null as unknown);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { apiFetch } from "@/core/api/fetch-client";
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
@@ -102,7 +103,7 @@ async function readJson<T>(response: Response): Promise<T> {
|
|||||||
data && typeof data === "object" && "detail" in data
|
data && typeof data === "object" && "detail" in data
|
||||||
? JSON.stringify((data as { detail: unknown }).detail)
|
? JSON.stringify((data as { detail: unknown }).detail)
|
||||||
: `请求失败 (${response.status})`;
|
: `请求失败 (${response.status})`;
|
||||||
throw new Error(detail.replace(/^"|"$/g, ""));
|
throw new Error(userFacingKnowledgeMessage(detail.replace(/^"|"$/g, "")));
|
||||||
}
|
}
|
||||||
return (await response.json()) as T;
|
return (await response.json()) as T;
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-10
@@ -17,6 +17,7 @@ import {
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useOrdinaryKnowledgeLanding } from "@/components/workspace/knowledge-product-nav";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -139,6 +140,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
const [frameError, setFrameError] = useState<string | null>(null);
|
const [frameError, setFrameError] = useState<string | null>(null);
|
||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
const runtime = useLlmWikiRuntime();
|
const runtime = useLlmWikiRuntime();
|
||||||
|
const landing = useOrdinaryKnowledgeLanding();
|
||||||
const details = useLlmWikiKnowledgeBase(
|
const details = useLlmWikiKnowledgeBase(
|
||||||
knowledgeBaseId || null,
|
knowledgeBaseId || null,
|
||||||
!!knowledgeBaseId,
|
!!knowledgeBaseId,
|
||||||
@@ -165,6 +167,8 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
searchParams.get("sourceChunkId") || searchParams.get("chunkId") || "";
|
searchParams.get("sourceChunkId") || searchParams.get("chunkId") || "";
|
||||||
const documentId = searchParams.get("documentId") || "";
|
const documentId = searchParams.get("documentId") || "";
|
||||||
const wikiSlug = searchParams.get("wikiSlug") || "";
|
const wikiSlug = searchParams.get("wikiSlug") || "";
|
||||||
|
const defaultEntry = searchParams.get("view") !== "weknora" && !requestedTab && !wikiSlug && !sourceChunkId && !documentId;
|
||||||
|
const showNativeIndex = defaultEntry && (landing.data ?? "wiki") === "wiki" && !!detailsWikiEnabled;
|
||||||
const nativeWikiPageUrl =
|
const nativeWikiPageUrl =
|
||||||
knowledgeBaseId && wikiSlug && preferredTab === "wiki"
|
knowledgeBaseId && wikiSlug && preferredTab === "wiki"
|
||||||
? buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug)
|
? buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug)
|
||||||
@@ -175,7 +179,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
setFrameSrc("");
|
setFrameSrc("");
|
||||||
setFrameError(null);
|
setFrameError(null);
|
||||||
if (
|
if (
|
||||||
nativeWikiPageUrl ||
|
nativeWikiPageUrl || landing.isLoading || showNativeIndex ||
|
||||||
!knowledgeBaseId ||
|
!knowledgeBaseId ||
|
||||||
runtime.data?.provider !== "weknora" ||
|
runtime.data?.provider !== "weknora" ||
|
||||||
!detailsReady
|
!detailsReady
|
||||||
@@ -225,6 +229,8 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
documentId,
|
documentId,
|
||||||
knowledgeBaseId,
|
knowledgeBaseId,
|
||||||
nativeWikiPageUrl,
|
nativeWikiPageUrl,
|
||||||
|
landing.isLoading,
|
||||||
|
showNativeIndex,
|
||||||
preferredTab,
|
preferredTab,
|
||||||
runtime.data?.provider,
|
runtime.data?.provider,
|
||||||
sourceChunkId,
|
sourceChunkId,
|
||||||
@@ -253,7 +259,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
return <Navigate to={nativeWikiPageUrl} replace />;
|
return <Navigate to={nativeWikiPageUrl} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runtime.isLoading || details.isLoading) {
|
if (runtime.isLoading || details.isLoading || landing.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
<Loader2 className="mr-2 size-5 animate-spin" />
|
<Loader2 className="mr-2 size-5 animate-spin" />
|
||||||
@@ -262,6 +268,10 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showNativeIndex) {
|
||||||
|
return <Navigate to={`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki/view`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
if (runtime.data?.provider !== "weknora")
|
if (runtime.data?.provider !== "weknora")
|
||||||
return <Navigate to={LIST_PATH} replace />;
|
return <Navigate to={LIST_PATH} replace />;
|
||||||
|
|
||||||
@@ -313,7 +323,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||||
{canManageWiki && (
|
{detailsWikiEnabled && (
|
||||||
<div className="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-2">
|
<div className="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-2">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{canEditCurrentWikiArticle && (
|
{canEditCurrentWikiArticle && (
|
||||||
@@ -334,6 +344,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki/view`)}>查看 Wiki 索引</Button>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -348,18 +359,14 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
数据包任务
|
数据包任务
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
{canManageWiki && <Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() => navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki`)}
|
||||||
navigate(
|
|
||||||
`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<FileText className="mr-2 size-4" />
|
<FileText className="mr-2 size-4" />
|
||||||
Wiki管理
|
Wiki管理
|
||||||
</Button>
|
</Button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
@@ -180,7 +181,7 @@ export default function WeKnoraPackageTransfersPage() {
|
|||||||
</div>
|
</div>
|
||||||
{job.error && (
|
{job.error && (
|
||||||
<p className="mt-2 text-sm text-destructive">
|
<p className="mt-2 text-sm text-destructive">
|
||||||
{job.error}
|
{userFacingKnowledgeMessage(job.error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+11
-10
@@ -153,11 +153,11 @@ export function WeKnoraWikiPageView({
|
|||||||
const pages = publicEmbed ? publicPages : privatePages;
|
const pages = publicEmbed ? publicPages : privatePages;
|
||||||
const remotePagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]);
|
const remotePagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]);
|
||||||
const explicitIndexPage = useMemo(
|
const explicitIndexPage = useMemo(
|
||||||
() => (publicEmbed ? remotePagesList.find(isWikiIndexPage) : undefined),
|
() => remotePagesList.find(isWikiIndexPage),
|
||||||
[publicEmbed, remotePagesList],
|
[publicEmbed, remotePagesList],
|
||||||
);
|
);
|
||||||
const generatedIndexPage = useMemo<LlmWikiWikiPage | undefined>(
|
const generatedIndexPage = useMemo<LlmWikiWikiPage | undefined>(
|
||||||
() => publicEmbed && remotePagesList.length > 0 && !explicitIndexPage
|
() => remotePagesList.length > 0 && !explicitIndexPage
|
||||||
? {
|
? {
|
||||||
id: "__generated_public_index__",
|
id: "__generated_public_index__",
|
||||||
slug: GENERATED_PUBLIC_INDEX_SLUG,
|
slug: GENERATED_PUBLIC_INDEX_SLUG,
|
||||||
@@ -181,14 +181,14 @@ export function WeKnoraWikiPageView({
|
|||||||
[generatedIndexPage, remotePagesList],
|
[generatedIndexPage, remotePagesList],
|
||||||
);
|
);
|
||||||
const landingPage = useMemo(() => selectDefaultWikiPage(pagesList), [pagesList]);
|
const landingPage = useMemo(() => selectDefaultWikiPage(pagesList), [pagesList]);
|
||||||
const wikiSlug = requestedWikiSlug || (publicEmbed ? landingPage?.slug || "" : "");
|
const wikiSlug = requestedWikiSlug || landingPage?.slug || "";
|
||||||
const generatedIndexActive = generatedIndexPage?.slug === wikiSlug;
|
const generatedIndexActive = generatedIndexPage?.slug === wikiSlug;
|
||||||
const generatedIndexRequested =
|
const generatedIndexRequested =
|
||||||
publicEmbed && wikiSlug === GENERATED_PUBLIC_INDEX_SLUG && !explicitIndexPage;
|
wikiSlug === GENERATED_PUBLIC_INDEX_SLUG && !explicitIndexPage;
|
||||||
const privatePage = useLlmWikiWikiPage(
|
const privatePage = useLlmWikiWikiPage(
|
||||||
noteId || null,
|
noteId || null,
|
||||||
wikiSlug || null,
|
wikiSlug || null,
|
||||||
!!noteId && !!wikiSlug && !publicEmbed,
|
!!noteId && !!wikiSlug && !publicEmbed && !generatedIndexActive && !generatedIndexRequested,
|
||||||
);
|
);
|
||||||
const publicPage = usePublicLlmWikiWikiPage(
|
const publicPage = usePublicLlmWikiWikiPage(
|
||||||
noteId || null,
|
noteId || null,
|
||||||
@@ -377,15 +377,15 @@ export function WeKnoraWikiPageView({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!wikiSlug) {
|
if (!wikiSlug) {
|
||||||
if (publicEmbed && pages.isLoading) {
|
if (pages.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
<Loader2 className="mr-2 size-5 animate-spin" />
|
<Loader2 className="mr-2 size-5 animate-spin" />
|
||||||
正在加载公开 Wiki
|
正在加载 Wiki 索引
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (publicEmbed && pages.error) {
|
if (pages.error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
|
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
|
||||||
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||||
@@ -405,9 +405,9 @@ export function WeKnoraWikiPageView({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-4 text-sm text-muted-foreground">
|
<div className="flex h-full flex-col items-center justify-center gap-4 text-sm text-muted-foreground">
|
||||||
{publicEmbed ? "该知识库暂无可展示的 Wiki 页面" : "当前链接缺少 Wiki 页面标识"}
|
该知识库暂无可展示的 Wiki 页面
|
||||||
{!publicEmbed && (
|
{!publicEmbed && (
|
||||||
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button>
|
<><Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button><Button onClick={() => navigate(`${LIST_PATH}/${encodeURIComponent(noteId)}?view=weknora`)}>进入知识库管理,导入资料</Button></>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -492,6 +492,7 @@ export function WeKnoraWikiPageView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => navigate(`${LIST_PATH}/${encodeURIComponent(noteId)}?view=weknora`)}>知识库管理</Button>
|
||||||
{globalBase && isDeposited && <Button variant="outline" size="sm" onClick={() => navigate(`/page/workspace/knowledge/assistant?${new URLSearchParams({ base_id: globalBase.id, wikiSlug: pageData.slug })}`)}>在知识梳理中查看</Button>}
|
{globalBase && isDeposited && <Button variant="outline" size="sm" onClick={() => navigate(`/page/workspace/knowledge/assistant?${new URLSearchParams({ base_id: globalBase.id, wikiSlug: pageData.slug })}`)}>在知识梳理中查看</Button>}
|
||||||
<Button variant="outline" size="sm" onClick={() => void download("md")} disabled={downloading !== null}>
|
<Button variant="outline" size="sm" onClick={() => void download("md")} disabled={downloading !== null}>
|
||||||
{downloading === "md" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
{downloading === "md" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
||||||
|
|||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Download, Loader2, Upload } from "lucide-react";
|
import { Download, Loader2, Upload } from "lucide-react";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
@@ -110,7 +111,7 @@ export function WikiPackageTransfers({
|
|||||||
{job.file_size_bytes ? (
|
{job.file_size_bytes ? (
|
||||||
<span>{(job.file_size_bytes / 1024 / 1024).toFixed(2)} MB</span>
|
<span>{(job.file_size_bytes / 1024 / 1024).toFixed(2)} MB</span>
|
||||||
) : null}
|
) : null}
|
||||||
{job.error && <span className="text-destructive">{job.error}</span>}
|
{job.error && <span className="text-destructive">{userFacingKnowledgeMessage(job.error)}</span>}
|
||||||
{assistant &&
|
{assistant &&
|
||||||
job.status === "completed" &&
|
job.status === "completed" &&
|
||||||
job.file_exists &&
|
job.file_exists &&
|
||||||
|
|||||||
@@ -1762,9 +1762,12 @@ gone.
|
|||||||
|
|
||||||
Assistant knowledge has one global full-library base (`知识梳理总库`) and any
|
Assistant knowledge has one global full-library base (`知识梳理总库`) and any
|
||||||
number of admin-created custom bases. Ordinary users have read/search access and
|
number of admin-created custom bases. Ordinary users have read/search access and
|
||||||
administrators own all mutations. Assistant bases only accept WeKnora export
|
administrators own all mutations. Assistant bases accept local documents independently
|
||||||
packages or imports triggered from a WeKnora mapping. Do not add direct skill
|
of WeKnora via `/api/assistant-knowledge/imports/files`, as well as WeKnora export
|
||||||
imports or arbitrary local-file imports to assistant knowledge. Importing into a
|
packages or imports triggered from a WeKnora mapping. Local documents use the
|
||||||
|
Gateway `assistant_file_ingest` pipeline: persisted original files, chunked LLM Wiki
|
||||||
|
extraction, cached body embeddings, and import-job status/retry. Do not add direct skill
|
||||||
|
imports. Importing into a
|
||||||
custom assistant base must also mirror the package into the global base for
|
custom assistant base must also mirror the package into the global base for
|
||||||
cross-source entity alignment. Admins may rename any assistant base and delete
|
cross-source entity alignment. Admins may rename any assistant base and delete
|
||||||
custom bases. Deletion removes the custom base's Wiki pages, vectors, entities,
|
custom bases. Deletion removes the custom base's Wiki pages, vectors, entities,
|
||||||
@@ -1773,6 +1776,8 @@ deletable because it owns full-library aggregation.
|
|||||||
|
|
||||||
### Wiki package and local encoder invariants (2026-09)
|
### Wiki package and local encoder invariants (2026-09)
|
||||||
|
|
||||||
|
Ordinary knowledge detail landing is globally configured by `ordinary_knowledge_landing` (`wiki` default / `weknora`), with admin-only PUT `/api/system-settings/ordinary-knowledge-landing`. Explicit document/graph/article links bypass this default. Wiki reading and the embedded WeKnora view provide reciprocal navigation; `?view=weknora` explicitly selects the iframe and must not loop back to the default Wiki route.
|
||||||
|
|
||||||
- `knowledge_transfer.sync_to_global` is called after successful explicit local
|
- `knowledge_transfer.sync_to_global` is called after successful explicit local
|
||||||
Wiki vectorization; it skips conversation/system mappings. Do not enable a new
|
Wiki vectorization; it skips conversation/system mappings. Do not enable a new
|
||||||
watcher or automatic remote-write scheduler as a side effect.
|
watcher or automatic remote-write scheduler as a side effect.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# DeerFlow Backend
|
# DeerFlow Backend
|
||||||
|
|
||||||
|
普通知识库默认详情页面:管理员可在知识库顶部选择“本系统 Wiki 索引”或“嵌入 WeKnora”,对应 `ordinary_knowledge_landing`(默认 `wiki`)。Wiki 库首次进入默认展示本系统索引,右上角“打开 WeKnora”切换到内嵌管理页;内嵌页可返回索引。非 Wiki 库仍进入 WeKnora 文档页。设置通过 `/api/system-settings/ordinary-knowledge-landing` 保存,仅管理员可修改;指定文章、文档、图谱的直达链接优先于默认设置。
|
||||||
|
|
||||||
|
助手知识库可独立于 WeKnora 使用:管理员进入助手知识库,选择或创建库后点击“上传文件生成 Wiki”。支持 Markdown/TXT/CSV、PDF、Word、Excel、PowerPoint(单文件 100 MB,解析正文上限 400 万字符)。文件先写入运行目录 `assistant-knowledge/files/{job_id}`,后台分段调用已配置的语言模型生成分类 Wiki、实体关系,再通过 DeerFlow 编码器对正文分段向量化,保存到助手库并汇入知识梳理总库。无需配置 WeKnora URL;配置后仍可直接上传,也保留 WeKnora 数据包导入。文件处理沿用管理员写入权限,所有用户可以浏览和搜索。
|
||||||
|
|
||||||
|
导入记录显示解析、Wiki 生成和向量化阶段;失败或服务中断后可“重试 / 恢复处理”,复用已落盘的 Wiki 和相同模型指纹的向量。离线编码器须预先放置模型并在管理员设置中配置;模型或编码器不可用会明确失败,不把摘要冒充 Wiki 或虚构向量。扫描件是否可解析取决于部署的文档解析/OCR 能力。
|
||||||
|
|
||||||
DeerFlow is a LangGraph-based AI super agent with sandbox execution, persistent memory, and extensible tool integration. The backend enables AI agents to execute code, browse the web, manage files, delegate tasks to subagents, and retain context across conversations - all in isolated, per-thread environments.
|
DeerFlow is a LangGraph-based AI super agent with sandbox execution, persistent memory, and extensible tool integration. The backend enables AI agents to execute code, browse the web, manage files, delegate tasks to subagents, and retain context across conversations - all in isolated, per-thread environments.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""Local document → Wiki → vectors, independent of the WeKnora service."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from deerflow.config.runtime_paths import runtime_home
|
||||||
|
from deerflow.knowledge.llm_extractor import extract_document_llm_multi
|
||||||
|
from deerflow.utils.file_conversion import CONVERTIBLE_EXTENSIONS, convert_file_to_markdown
|
||||||
|
|
||||||
|
ALLOWED_EXTENSIONS = CONVERTIBLE_EXTENSIONS | {".md", ".markdown", ".txt", ".csv"}
|
||||||
|
MAX_FILE_BYTES = 100 * 1024 * 1024
|
||||||
|
MAX_TEXT_CHARS = 4_000_000
|
||||||
|
_tasks: dict[str, asyncio.Task] = {}
|
||||||
|
_slots = asyncio.Semaphore(2)
|
||||||
|
_queue_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def file_job_dir(job_id: str) -> Path:
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
return runtime_home() / "assistant-knowledge" / "files" / str(UUID(job_id))
|
||||||
|
|
||||||
|
|
||||||
|
def save_json(path: Path, value: Any) -> None:
|
||||||
|
temporary = path.with_suffix(".tmp")
|
||||||
|
temporary.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
|
||||||
|
temporary.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path: Path) -> Any:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def split_text(text: str, size: int):
|
||||||
|
"""Bound every extraction/embedding input without truncating the document."""
|
||||||
|
for offset in range(0, len(text), size):
|
||||||
|
yield text[offset : offset + size]
|
||||||
|
|
||||||
|
|
||||||
|
def notes_package(notes: list[dict], source: dict) -> dict:
|
||||||
|
from deerflow.persistence.assistant_knowledge.store import _entity_alignment_key
|
||||||
|
|
||||||
|
merged: dict[str, dict] = {}
|
||||||
|
for raw in notes:
|
||||||
|
key = _entity_alignment_key(raw["title"])
|
||||||
|
if key not in merged:
|
||||||
|
merged[key] = dict(raw)
|
||||||
|
continue
|
||||||
|
note = merged[key]
|
||||||
|
note["content_md"] = "\n\n".join(dict.fromkeys([note["content_md"], raw["content_md"]]))
|
||||||
|
for field in ("tags", "entities", "relations"):
|
||||||
|
note[field] = [*note.get(field, []), *raw.get(field, [])]
|
||||||
|
note["tags"] = list(dict.fromkeys(note["tags"]))
|
||||||
|
pages, entities, relations = [], [], []
|
||||||
|
for index, note in enumerate(merged.values()):
|
||||||
|
slug = f"files/{source['digest']}/{index}"
|
||||||
|
pages.append(
|
||||||
|
{
|
||||||
|
"slug": slug,
|
||||||
|
"title": note["title"],
|
||||||
|
"content": note["content_md"],
|
||||||
|
"page_type": "article",
|
||||||
|
"tags": note.get("tags", []),
|
||||||
|
"category_path": [note.get("category") or "知识文档"],
|
||||||
|
"source_refs": [{"type": "file", "title": source["filename"]}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for entity in note.get("entities", []):
|
||||||
|
entities.append({"id": entity["name"], "name": entity["name"], "entity_type": entity.get("type", "concept"), "aliases": entity.get("aliases", []), "source_wiki_slug": slug})
|
||||||
|
for relation in note.get("relations", []):
|
||||||
|
relations.append({"source": relation["from"], "target": relation["to"], "predicate": relation.get("type", "related_to"), "source_wiki_slug": slug})
|
||||||
|
# Persist real sibling links, understood by the shared Wiki reader.
|
||||||
|
for index, page in enumerate(pages):
|
||||||
|
others = [p for p in pages[max(0, index - 4) : index + 5] if p["slug"] != page["slug"]]
|
||||||
|
page["out_links"] = [p["slug"] for p in others]
|
||||||
|
page["in_links"] = list(page["out_links"])
|
||||||
|
if others:
|
||||||
|
page["content"] += "\n\n## 相关知识\n" + "\n".join(f"- [[{p['slug']}|{p['title']}]]" for p in others)
|
||||||
|
return {"source": source, "wiki_pages": pages, "entities": entities, "relations": relations, "vectors": []}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_file_job(app, job_id: str) -> None:
|
||||||
|
store = app.state.assistant_knowledge_store
|
||||||
|
root = file_job_dir(job_id)
|
||||||
|
try:
|
||||||
|
async with _slots:
|
||||||
|
manifest = await asyncio.to_thread(read_json, root / "manifest.json")
|
||||||
|
await store.update_import_job(job_id, status="running", phase="parsing")
|
||||||
|
package_file = root / "wiki.json"
|
||||||
|
if package_file.exists():
|
||||||
|
package = await asyncio.to_thread(read_json, package_file)
|
||||||
|
else:
|
||||||
|
text_file = root / "parsed.md"
|
||||||
|
if not text_file.exists():
|
||||||
|
original = root / manifest["stored_name"]
|
||||||
|
if original.suffix in CONVERTIBLE_EXTENSIONS:
|
||||||
|
converted = await asyncio.to_thread(lambda: asyncio.run(convert_file_to_markdown(original)))
|
||||||
|
if converted is None:
|
||||||
|
raise ValueError("无法解析文档,请检查文件是否损坏或是否需要 OCR")
|
||||||
|
text = await asyncio.to_thread(converted.read_text, encoding="utf-8")
|
||||||
|
else:
|
||||||
|
raw = await asyncio.to_thread(original.read_bytes)
|
||||||
|
try:
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
text = raw.decode("gb18030")
|
||||||
|
if not text.strip() or len(text) > MAX_TEXT_CHARS:
|
||||||
|
raise ValueError("文件没有可提取文本,或解析正文超过 400 万字符,请拆分文件")
|
||||||
|
await asyncio.to_thread(text_file.write_text, text, encoding="utf-8")
|
||||||
|
else:
|
||||||
|
text = await asyncio.to_thread(text_file.read_text, encoding="utf-8")
|
||||||
|
notes = []
|
||||||
|
parts = list(split_text(text, 10000))
|
||||||
|
for index, part in enumerate(parts):
|
||||||
|
await store.update_import_job(job_id, phase="generating_wiki", counts={"parts_total": len(parts), "parts_done": index})
|
||||||
|
cache = root / f"notes-{index}.json"
|
||||||
|
if cache.exists():
|
||||||
|
drafts = await asyncio.to_thread(read_json, cache)
|
||||||
|
else:
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
extracted = await asyncio.wait_for(
|
||||||
|
extract_document_llm_multi(
|
||||||
|
part,
|
||||||
|
doc_id=f"{job_id}:{index}",
|
||||||
|
doc_title=manifest["filename"],
|
||||||
|
max_input_chars=12000,
|
||||||
|
max_notes=6,
|
||||||
|
),
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
if not extracted:
|
||||||
|
raise ValueError("Wiki 生成失败,请在设置中检查可用的语言模型,然后重试")
|
||||||
|
drafts = [asdict(note) for note in extracted]
|
||||||
|
await asyncio.to_thread(save_json, cache, drafts)
|
||||||
|
notes.extend(drafts)
|
||||||
|
package = notes_package(notes, {"type": "file", "filename": manifest["filename"], "digest": manifest["digest"]})
|
||||||
|
await asyncio.to_thread(save_json, package_file, package)
|
||||||
|
embedding = getattr(app.state, "llmwiki_embedding", None)
|
||||||
|
if embedding is None:
|
||||||
|
raise ValueError("Wiki 已生成;请管理员在设置中配置本地编码器或编码服务,然后重试向量化")
|
||||||
|
vectors = []
|
||||||
|
sections = [(p, i, section) for p in package["wiki_pages"] for i, section in enumerate(split_text(p["content"], 1600))]
|
||||||
|
for index, (page, section_index, content) in enumerate(sections):
|
||||||
|
await store.update_import_job(job_id, phase="vectorizing", counts={"wiki_pages": len(package["wiki_pages"]), "vectors_total": len(sections), "vectors": index})
|
||||||
|
digest = hashlib.sha256(content.encode()).hexdigest()
|
||||||
|
cache = root / f"vector-{digest}.json"
|
||||||
|
record = await asyncio.to_thread(read_json, cache) if cache.exists() else None
|
||||||
|
if record is None or record["embedding_fingerprint"] != embedding.fingerprint:
|
||||||
|
values = await embedding.embed_texts([content])
|
||||||
|
record = {"vector": values[0], "embedding_model": embedding.config.model, "embedding_fingerprint": embedding.fingerprint, "content_hash": digest}
|
||||||
|
await asyncio.to_thread(save_json, cache, record)
|
||||||
|
vectors.append({**record, "wiki_slug": page["slug"], "section_index": section_index, "section_content": content})
|
||||||
|
package["vectors"] = vectors
|
||||||
|
await store.update_import_job(job_id, phase="importing")
|
||||||
|
await store.import_package_for_job(job_id, package=package, created_by=manifest["actor"])
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await store.update_import_job(job_id, status="failed", phase="interrupted", error="服务停止,已保留处理进度,可重试", completed=True)
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
await store.update_import_job(job_id, status="failed", phase="failed", error=str(exc) or type(exc).__name__, completed=True)
|
||||||
|
|
||||||
|
|
||||||
|
def start_file_job(app, job_id: str) -> bool:
|
||||||
|
if job_id in _tasks:
|
||||||
|
return False
|
||||||
|
task = asyncio.create_task(run_file_job(app, job_id))
|
||||||
|
_tasks[job_id] = task
|
||||||
|
task.add_done_callback(lambda _: _tasks.pop(job_id, None))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_file_jobs() -> None:
|
||||||
|
tasks = list(_tasks.values())
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
if tasks:
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def retry_file_job(app, job_id: str) -> bool:
|
||||||
|
async with _queue_lock:
|
||||||
|
if job_id in _tasks:
|
||||||
|
return False
|
||||||
|
await app.state.assistant_knowledge_store.update_import_job(job_id, status="queued", phase="queued")
|
||||||
|
return start_file_job(app, job_id)
|
||||||
@@ -701,6 +701,9 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
from app.gateway.assistant_file_ingest import stop_file_jobs
|
||||||
|
|
||||||
|
await stop_file_jobs()
|
||||||
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
|
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
|
||||||
|
|
||||||
register_local_wiki_index_runtime(store=None, search=None, sync=None, retrieval=None)
|
register_local_wiki_index_runtime(store=None, search=None, sync=None, retrieval=None)
|
||||||
|
|||||||
@@ -152,6 +152,18 @@ def _list_export_manifests(mapping_id: str, *, limit: int) -> list[dict[str, Any
|
|||||||
|
|
||||||
def _delete_base_package_files(base_id: str, import_job_ids: list[str]) -> None:
|
def _delete_base_package_files(base_id: str, import_job_ids: list[str]) -> None:
|
||||||
"""Delete package artifacts owned by one assistant base only."""
|
"""Delete package artifacts owned by one assistant base only."""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from app.gateway.assistant_file_ingest import file_job_dir
|
||||||
|
|
||||||
|
for job_id in import_job_ids:
|
||||||
|
# UUID validation confines cleanup to exactly one job directory.
|
||||||
|
try:
|
||||||
|
directory = file_job_dir(job_id)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if directory.is_dir() and not directory.is_symlink():
|
||||||
|
shutil.rmtree(directory)
|
||||||
root = _package_root()
|
root = _package_root()
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
return
|
return
|
||||||
@@ -448,10 +460,71 @@ async def import_skills(request: Request, _body: SkillImportRequest) -> dict[str
|
|||||||
raise HTTPException(status_code=410, detail="不支持技能直接归纳到助手知识库,请先归纳到 WeKnora 普通知识库后再导入助手知识库")
|
raise HTTPException(status_code=410, detail="不支持技能直接归纳到助手知识库,请先归纳到 WeKnora 普通知识库后再导入助手知识库")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/imports/files", status_code=410)
|
@router.post("/imports/files", status_code=202)
|
||||||
async def import_files(request: Request) -> dict[str, Any]:
|
async def import_files(
|
||||||
|
request: Request,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
assistant_base_id: str | None = Form(default=None),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from app.gateway.assistant_file_ingest import ALLOWED_EXTENSIONS, MAX_FILE_BYTES, file_job_dir, save_json, start_file_job
|
||||||
|
|
||||||
|
actor = await _actor(request, admin=True)
|
||||||
|
filename = (file.filename or "document").replace("\\", "/").rsplit("/", 1)[-1][:240]
|
||||||
|
extension = Path(filename).suffix.lower()
|
||||||
|
if extension not in ALLOWED_EXTENSIONS:
|
||||||
|
await file.close()
|
||||||
|
raise HTTPException(status_code=415, detail="支持 Markdown、TXT、CSV、PDF、Word、Excel、PowerPoint 文件")
|
||||||
|
job = await _store(request).create_queued_import_job(
|
||||||
|
source_type="file", source_key=f"file-upload:{uuid4()}", source_name=filename,
|
||||||
|
trigger="local_file_upload", created_by=actor, base_id=assistant_base_id,
|
||||||
|
metadata={"type": "file", "filename": filename}, phase="uploading",
|
||||||
|
)
|
||||||
|
root = file_job_dir(job["id"])
|
||||||
|
await asyncio.to_thread(root.mkdir, parents=True, exist_ok=True)
|
||||||
|
path = root / f"original{extension}"
|
||||||
|
size, digest = 0, hashlib.sha256()
|
||||||
|
try:
|
||||||
|
with path.open("wb") as handle:
|
||||||
|
while chunk := await file.read(1024 * 1024):
|
||||||
|
size += len(chunk)
|
||||||
|
if size > MAX_FILE_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="单个文件最大 100 MB,请拆分后上传")
|
||||||
|
digest.update(chunk)
|
||||||
|
await asyncio.to_thread(handle.write, chunk)
|
||||||
|
if not size:
|
||||||
|
raise HTTPException(status_code=422, detail="上传文件为空")
|
||||||
|
await asyncio.to_thread(save_json, root / "manifest.json", {
|
||||||
|
"filename": filename, "stored_name": path.name, "digest": digest.hexdigest(), "actor": actor,
|
||||||
|
})
|
||||||
|
job = await _store(request).update_import_job(job["id"], phase="queued", counts={"uploaded_bytes": size})
|
||||||
|
start_file_job(request.app, job["id"])
|
||||||
|
return job
|
||||||
|
except Exception as exc:
|
||||||
|
await _store(request).update_import_job(job["id"], status="failed", phase="upload_failed", error=str(getattr(exc, "detail", exc)), completed=True)
|
||||||
|
await asyncio.to_thread(path.unlink, missing_ok=True)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await file.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import-jobs/{job_id}/retry-file", status_code=202)
|
||||||
|
async def retry_file_import(request: Request, job_id: str) -> dict[str, Any]:
|
||||||
|
from app.gateway.assistant_file_ingest import file_job_dir, retry_file_job
|
||||||
|
|
||||||
await _actor(request, admin=True)
|
await _actor(request, admin=True)
|
||||||
raise HTTPException(status_code=410, detail="助手知识库不支持导入任意本地文件,请从 WeKnora 普通知识库导出后导入")
|
store = _store(request)
|
||||||
|
job = await store.get_job(job_id)
|
||||||
|
if not job or job.get("trigger") != "local_file_upload":
|
||||||
|
raise HTTPException(status_code=404, detail="文件处理任务不存在")
|
||||||
|
if job["status"] == "completed":
|
||||||
|
raise HTTPException(status_code=409, detail="文件已处理完成")
|
||||||
|
if not (file_job_dir(job_id) / "manifest.json").exists():
|
||||||
|
raise HTTPException(status_code=409, detail="文件上传未完成,请重新上传")
|
||||||
|
if not await retry_file_job(request.app, job_id):
|
||||||
|
raise HTTPException(status_code=409, detail="文件仍在处理中")
|
||||||
|
return await store.get_job(job_id)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/imports/weknora-package", status_code=202)
|
@router.post("/imports/weknora-package", status_code=202)
|
||||||
|
|||||||
@@ -56,6 +56,22 @@ class KnowledgeBaseProductVersionUpdateRequest(BaseModel):
|
|||||||
version: Literal["v1", "v2"]
|
version: Literal["v1", "v2"]
|
||||||
|
|
||||||
|
|
||||||
|
class OrdinaryKnowledgeLanding(BaseModel):
|
||||||
|
mode: Literal["wiki", "weknora"] = "wiki"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ordinary-knowledge-landing", response_model=OrdinaryKnowledgeLanding)
|
||||||
|
async def get_ordinary_knowledge_landing() -> OrdinaryKnowledgeLanding:
|
||||||
|
return OrdinaryKnowledgeLanding(mode=load_system_settings().ordinary_knowledge_landing)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/ordinary-knowledge-landing", response_model=OrdinaryKnowledgeLanding)
|
||||||
|
async def update_ordinary_knowledge_landing(body: OrdinaryKnowledgeLanding, request: Request) -> OrdinaryKnowledgeLanding:
|
||||||
|
await _require_admin(request)
|
||||||
|
saved = save_system_settings(load_system_settings().model_copy(update={"ordinary_knowledge_landing": body.mode}))
|
||||||
|
return OrdinaryKnowledgeLanding(mode=saved.ordinary_knowledge_landing)
|
||||||
|
|
||||||
|
|
||||||
class PromptPrefixUpdateRequest(BaseModel):
|
class PromptPrefixUpdateRequest(BaseModel):
|
||||||
enabled: bool | None = None
|
enabled: bool | None = None
|
||||||
switch_label: str | None = None
|
switch_label: str | None = None
|
||||||
|
|||||||
@@ -538,6 +538,7 @@ class SystemSettings(BaseModel):
|
|||||||
default="v1",
|
default="v1",
|
||||||
description="v1 opens the ordinary WeKnora experience; v2 opens the DeerFlow assistant knowledge base.",
|
description="v1 opens the ordinary WeKnora experience; v2 opens the DeerFlow assistant knowledge base.",
|
||||||
)
|
)
|
||||||
|
ordinary_knowledge_landing: Literal["wiki", "weknora"] = "wiki"
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
@@ -241,6 +241,8 @@ class AssistantKnowledgeRepository:
|
|||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
metadata = package.get("source") or {}
|
metadata = package.get("source") or {}
|
||||||
provider = str(metadata.get("provider") or metadata.get("type") or "").strip().casefold()
|
provider = str(metadata.get("provider") or metadata.get("type") or "").strip().casefold()
|
||||||
|
if provider == "file" and metadata.get("digest"):
|
||||||
|
return f"file:{metadata['digest']}", str(metadata.get("filename") or fallback_name)
|
||||||
if provider == "weknora":
|
if provider == "weknora":
|
||||||
source_key = str(
|
source_key = str(
|
||||||
metadata.get("knowledge_base_id")
|
metadata.get("knowledge_base_id")
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.gateway import assistant_file_ingest as ingest
|
||||||
|
from app.gateway.routers import assistant_knowledge as router
|
||||||
|
from deerflow.knowledge.schemas import NoteDraft
|
||||||
|
from deerflow.persistence.assistant_knowledge import AssistantKnowledgeRepository
|
||||||
|
from deerflow.persistence.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_topic_across_document_parts_preserves_all_knowledge():
|
||||||
|
package = ingest.notes_package([
|
||||||
|
{"title": "设备维护", "content_md": "每周清理过滤网", "tags": ["运维"]},
|
||||||
|
{"title": "设备 维护", "content_md": "每月检查电机温度", "tags": ["设备"]},
|
||||||
|
], {"digest": "document", "filename": "维护指南.md"})
|
||||||
|
assert len(package["wiki_pages"]) == 1
|
||||||
|
assert "每周清理过滤网" in package["wiki_pages"][0]["content"]
|
||||||
|
assert "每月检查电机温度" in package["wiki_pages"][0]["content"]
|
||||||
|
assert package["wiki_pages"][0]["tags"] == ["运维", "设备"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_standalone_upload_vectors_retry_and_dedup(tmp_path, monkeypatch):
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
|
async with engine.begin() as connection:
|
||||||
|
await connection.run_sync(Base.metadata.create_all)
|
||||||
|
store = AssistantKnowledgeRepository(async_sessionmaker(engine, expire_on_commit=False))
|
||||||
|
base = await store.create_base(name="独立文档库", created_by="admin")
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router.router)
|
||||||
|
app.state.assistant_knowledge_store = store
|
||||||
|
app.state.llmwiki_embedding = None
|
||||||
|
monkeypatch.setattr(ingest, "runtime_home", lambda: tmp_path)
|
||||||
|
extraction_calls = []
|
||||||
|
|
||||||
|
async def actor(*args, **kwargs):
|
||||||
|
return "admin"
|
||||||
|
|
||||||
|
async def extract(text, **kwargs):
|
||||||
|
extraction_calls.append(text)
|
||||||
|
return [NoteDraft(title="冷却系统", summary="摘要占位", content_md="# 冷却系统\n风扇的额定转速为每分钟 1200 转。", tags=["设备"], entities=[{"name": "风扇", "type": "product"}])]
|
||||||
|
|
||||||
|
class Encoder:
|
||||||
|
fingerprint = "local-test-3"
|
||||||
|
dimensions = 3
|
||||||
|
config = SimpleNamespace(model="offline-test")
|
||||||
|
|
||||||
|
async def embed_texts(self, texts):
|
||||||
|
return [[1.0, 0.0, 0.0] for _ in texts]
|
||||||
|
|
||||||
|
async def embed_query(self, text):
|
||||||
|
return [1.0, 0.0, 0.0]
|
||||||
|
|
||||||
|
monkeypatch.setattr(router, "_actor", actor)
|
||||||
|
monkeypatch.setattr(ingest, "extract_document_llm_multi", extract)
|
||||||
|
|
||||||
|
async def finish():
|
||||||
|
if ingest._tasks:
|
||||||
|
await asyncio.gather(*list(ingest._tasks.values()))
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
payload = {"assistant_base_id": base["id"]}
|
||||||
|
response = await client.post("/api/assistant-knowledge/imports/files", data=payload, files={"file": ("../../设备.md", "风扇额定转速为 1200 转。".encode())})
|
||||||
|
assert response.status_code == 202, response.text
|
||||||
|
job_id = response.json()["id"]
|
||||||
|
await finish()
|
||||||
|
assert (await store.get_job(job_id))["status"] == "failed"
|
||||||
|
assert (ingest.file_job_dir(job_id) / "wiki.json").exists()
|
||||||
|
app.state.llmwiki_embedding = Encoder()
|
||||||
|
retry = await client.post(f"/api/assistant-knowledge/import-jobs/{job_id}/retry-file")
|
||||||
|
assert retry.status_code == 202
|
||||||
|
await finish()
|
||||||
|
job = await store.get_job(job_id)
|
||||||
|
assert job["status"] == "completed", job
|
||||||
|
assert job["counts"]["vectors"] == 1
|
||||||
|
assert len(extraction_calls) == 1
|
||||||
|
results = await store.search_pages(base_ids=[base["id"]], query="额定转速", embedding_client=app.state.llmwiki_embedding)
|
||||||
|
assert results and results[0]["retrieval_mode"] == "vector"
|
||||||
|
assert "1200" in results[0]["matched_section"]["content"]
|
||||||
|
assert "摘要占位" not in results[0]["matched_section"]["content"]
|
||||||
|
global_base = await store.get_base()
|
||||||
|
assert len(await store.list_pages(base_id=global_base["id"])) == 1
|
||||||
|
again = await client.post("/api/assistant-knowledge/imports/files", data=payload, files={"file": ("设备.md", "风扇额定转速为 1200 转。".encode())})
|
||||||
|
assert again.status_code == 202
|
||||||
|
await finish()
|
||||||
|
assert len(await store.list_sources(base_id=base["id"])) == 1
|
||||||
|
assert len(await store.list_pages(base_id=global_base["id"])) == 1
|
||||||
|
bad = await client.post("/api/assistant-knowledge/imports/files", data=payload, files={"file": ("script.py", b"print('no')")})
|
||||||
|
assert bad.status_code == 415
|
||||||
|
empty = await client.post("/api/assistant-knowledge/imports/files", data=payload, files={"file": ("empty.txt", b"")})
|
||||||
|
assert empty.status_code == 422
|
||||||
|
finally:
|
||||||
|
await ingest.stop_file_jobs()
|
||||||
|
await engine.dispose()
|
||||||
Reference in new issue
Block a user