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

@@ -6,13 +6,22 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { getStoredAuth } from "@/core/auth";
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import {
loadKnowledgeBaseProductVersion,
loadOrdinaryKnowledgeLanding,
saveOrdinaryKnowledgeLanding,
type OrdinaryKnowledgeLanding,
type KnowledgeBaseProductVersion,
updateKnowledgeBaseProductVersion,
} from "@/core/system-settings/api";
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_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
@@ -25,6 +34,14 @@ export function KnowledgeProductNav() {
const navigate = useNavigate();
const client = useQueryClient();
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 save = useMutation({
mutationFn: updateKnowledgeBaseProductVersion,
@@ -36,15 +53,20 @@ export function KnowledgeProductNav() {
const assistant = location.pathname.includes("/knowledge/assistant");
return (
<div className="flex 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}`)}>
<div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
<BookOpen />普通知识库
</Button>
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
<Database />助手知识库
</Button>
{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)}>
<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" />
<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>
);
})}
{(runtime.isLoading || knowledgeBases.isLoading) && <Loader2 className="m-4 size-5 animate-spin" />}
{!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && (
<p className="p-3 text-xs text-destructive">
读取 WeKnora 普通知识库失败:
读取普通知识库失败:
{knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)}
</p>
)}
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
<p className="p-3 text-xs text-muted-foreground">
{runtime.data?.provider !== "weknora"
? "当前未配置 WeKnora 普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
? "当前未配置普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
</p>
)}
@@ -11,6 +11,22 @@ export interface PromptPrefixSettings {
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> {
const response = await apiFetch(
`${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 {
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 />;
}
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
const BASE = () => `${getBackendBaseURL()}/api/assistant-knowledge`;
@@ -113,7 +114,7 @@ async function readJson<T>(response: Response): Promise<T> {
? (data as { detail: unknown }).detail
: `请求失败 (${response.status})`;
throw new Error(
typeof detail === "string" ? detail : JSON.stringify(detail),
userFacingKnowledgeMessage(typeof detail === "string" ? detail : JSON.stringify(detail)),
);
}
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) {
const response = await apiFetch(
`${BASE()}/sources/${encodeURIComponent(sourceId)}`,
@@ -490,6 +502,8 @@ export function useAssistantKnowledgeActions() {
mutationFn: uploadAssistantWeKnoraPackage,
onSuccess: invalidate,
}),
uploadDocument: useMutation({ mutationFn: uploadAssistantDocument, onSuccess: invalidate }),
retryDocument: useMutation({ mutationFn: retryAssistantDocument, onSuccess: invalidate }),
reimportSource: useMutation({
mutationFn: reimportAssistantKnowledgeSource,
onSuccess: invalidate,
@@ -1,3 +1,4 @@
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
import { apiFetch } from "@/core/api/fetch-client";
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
? String((payload as { detail: unknown }).detail)
: `${context}失败(${response.status})`;
throw new Error(detail);
throw new Error(userFacingKnowledgeMessage(detail));
}
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
? String((payload as { detail: unknown }).detail)
: `删除研究任务失败(${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 { useLiveAccount } from "@/core/auth/use-live-account";
import { getBackendBaseURL } from "@/core/config";
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
const API = () => `${getBackendBaseURL()}/api/llmwiki`;
const PUBLIC_API = () => `${getBackendBaseURL()}/api/public/llmwiki`;
@@ -366,10 +367,6 @@ export interface LlmWikiWikiPageUpdate {
wiki_path?: string;
}
function userFacingKnowledgeMessage(value: string): string {
return value.replace(/weknora/gi, "知识服务");
}
async function readJson<T>(response: Response, context: string): Promise<T> {
if (!response.ok) {
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 { 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
? JSON.stringify((data as { detail: unknown }).detail)
: `请求失败 (${response.status})`;
throw new Error(detail.replace(/^"|"$/g, ""));
throw new Error(userFacingKnowledgeMessage(detail.replace(/^"|"$/g, "")));
}
return (await response.json()) as T;
}
@@ -17,6 +17,7 @@ import {
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { useOrdinaryKnowledgeLanding } from "@/components/workspace/knowledge-product-nav";
import {
Dialog,
DialogContent,
@@ -139,6 +140,7 @@ export function WeKnoraKnowledgeDetailPage() {
const [frameError, setFrameError] = useState<string | null>(null);
const isAdmin = getStoredAuth()?.system_role === "admin";
const runtime = useLlmWikiRuntime();
const landing = useOrdinaryKnowledgeLanding();
const details = useLlmWikiKnowledgeBase(
knowledgeBaseId || null,
!!knowledgeBaseId,
@@ -165,6 +167,8 @@ export function WeKnoraKnowledgeDetailPage() {
searchParams.get("sourceChunkId") || searchParams.get("chunkId") || "";
const documentId = searchParams.get("documentId") || "";
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 =
knowledgeBaseId && wikiSlug && preferredTab === "wiki"
? buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug)
@@ -175,7 +179,7 @@ export function WeKnoraKnowledgeDetailPage() {
setFrameSrc("");
setFrameError(null);
if (
nativeWikiPageUrl ||
nativeWikiPageUrl || landing.isLoading || showNativeIndex ||
!knowledgeBaseId ||
runtime.data?.provider !== "weknora" ||
!detailsReady
@@ -225,6 +229,8 @@ export function WeKnoraKnowledgeDetailPage() {
documentId,
knowledgeBaseId,
nativeWikiPageUrl,
landing.isLoading,
showNativeIndex,
preferredTab,
runtime.data?.provider,
sourceChunkId,
@@ -253,7 +259,7 @@ export function WeKnoraKnowledgeDetailPage() {
return <Navigate to={nativeWikiPageUrl} replace />;
}
if (runtime.isLoading || details.isLoading) {
if (runtime.isLoading || details.isLoading || landing.isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<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")
return <Navigate to={LIST_PATH} replace />;
@@ -313,7 +323,7 @@ export function WeKnoraKnowledgeDetailPage() {
return (
<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="min-w-0 flex-1">
{canEditCurrentWikiArticle && (
@@ -334,6 +344,7 @@ export function WeKnoraKnowledgeDetailPage() {
)}
</div>
<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 && (
<Button
size="sm"
@@ -348,18 +359,14 @@ export function WeKnoraKnowledgeDetailPage() {
数据包任务
</Button>
)}
<Button
{canManageWiki && <Button
size="sm"
variant="outline"
onClick={() =>
navigate(
`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki`,
)
}
onClick={() => navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki`)}
>
<FileText className="mr-2 size-4" />
Wiki管理
</Button>
</Button>}
</div>
</div>
)}
@@ -1,3 +1,4 @@
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Archive,
@@ -180,7 +181,7 @@ export default function WeKnoraPackageTransfersPage() {
</div>
{job.error && (
<p className="mt-2 text-sm text-destructive">
{job.error}
{userFacingKnowledgeMessage(job.error)}
</p>
)}
</div>
@@ -153,11 +153,11 @@ export function WeKnoraWikiPageView({
const pages = publicEmbed ? publicPages : privatePages;
const remotePagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]);
const explicitIndexPage = useMemo(
() => (publicEmbed ? remotePagesList.find(isWikiIndexPage) : undefined),
() => remotePagesList.find(isWikiIndexPage),
[publicEmbed, remotePagesList],
);
const generatedIndexPage = useMemo<LlmWikiWikiPage | undefined>(
() => publicEmbed && remotePagesList.length > 0 && !explicitIndexPage
() => remotePagesList.length > 0 && !explicitIndexPage
? {
id: "__generated_public_index__",
slug: GENERATED_PUBLIC_INDEX_SLUG,
@@ -181,14 +181,14 @@ export function WeKnoraWikiPageView({
[generatedIndexPage, remotePagesList],
);
const landingPage = useMemo(() => selectDefaultWikiPage(pagesList), [pagesList]);
const wikiSlug = requestedWikiSlug || (publicEmbed ? landingPage?.slug || "" : "");
const wikiSlug = requestedWikiSlug || landingPage?.slug || "";
const generatedIndexActive = generatedIndexPage?.slug === wikiSlug;
const generatedIndexRequested =
publicEmbed && wikiSlug === GENERATED_PUBLIC_INDEX_SLUG && !explicitIndexPage;
wikiSlug === GENERATED_PUBLIC_INDEX_SLUG && !explicitIndexPage;
const privatePage = useLlmWikiWikiPage(
noteId || null,
wikiSlug || null,
!!noteId && !!wikiSlug && !publicEmbed,
!!noteId && !!wikiSlug && !publicEmbed && !generatedIndexActive && !generatedIndexRequested,
);
const publicPage = usePublicLlmWikiWikiPage(
noteId || null,
@@ -377,15 +377,15 @@ export function WeKnoraWikiPageView({
};
if (!wikiSlug) {
if (publicEmbed && pages.isLoading) {
if (pages.isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-5 animate-spin" />
正在加载公开 Wiki
正在加载 Wiki 索引
</div>
);
}
if (publicEmbed && pages.error) {
if (pages.error) {
return (
<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">
@@ -405,9 +405,9 @@ export function WeKnoraWikiPageView({
}
return (
<div className="flex h-full flex-col items-center justify-center gap-4 text-sm text-muted-foreground">
{publicEmbed ? "该知识库暂无可展示的 Wiki 页面" : "当前链接缺少 Wiki 页面标识"}
该知识库暂无可展示的 Wiki 页面
{!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>
);
@@ -492,6 +492,7 @@ export function WeKnoraWikiPageView({
</div>
</div>
<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>}
<Button variant="outline" size="sm" onClick={() => void download("md")} disabled={downloading !== null}>
{downloading === "md" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
@@ -1,3 +1,4 @@
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Download, Loader2, Upload } from "lucide-react";
import { useRef } from "react";
@@ -110,7 +111,7 @@ export function WikiPackageTransfers({
{job.file_size_bytes ? (
<span>{(job.file_size_bytes / 1024 / 1024).toFixed(2)} MB</span>
) : null}
{job.error && <span className="text-destructive">{job.error}</span>}
{job.error && <span className="text-destructive">{userFacingKnowledgeMessage(job.error)}</span>}
{assistant &&
job.status === "completed" &&
job.file_exists &&