This commit is contained in:
董宏凯 committed 2026-08-25 17:33:40 +08:00
commit 5c0864bf0a
40 files changed
+1200 -179

No files matched your search

+15 -4
View File
@@ -27,6 +27,16 @@ export type KnowledgeSourceLike = {
slug?: string | null;
};
/** Build a HashRouter URL without dropping a reverse-proxy path such as /web/. */
export function buildAppHashUrl(path: string): string {
const route = path.startsWith("/") ? path : `/${path}`;
if (typeof window !== "undefined") {
return `${window.location.pathname || "/"}#${route}`;
}
const base = import.meta.env.BASE_URL || "/";
return `${base}#${route}`;
}
export function parseWeKnoraPreviewUrl(url?: string | null): WeKnoraSourceTarget | null {
if (!url?.startsWith("/weknora-source-preview?")) {
return null;
@@ -92,7 +102,7 @@ export function buildWeKnoraSourceIframeUrl(target: WeKnoraSourceTarget): string
if (target.documentId) params.set("documentId", target.documentId);
if (target.wikiSlug) params.set("wikiSlug", target.wikiSlug);
const query = params.toString();
return `/#/page/workspace/knowledge/${encodeURIComponent(target.knowledgeBaseId)}${query ? `?${query}` : ""}`;
return buildAppHashUrl(`/page/workspace/knowledge/${encodeURIComponent(target.knowledgeBaseId)}${query ? `?${query}` : ""}`);
}
/**
@@ -103,7 +113,7 @@ export function buildWeKnoraWikiPageUrl(
knowledgeBaseId: string,
wikiSlug: string,
): string {
return `/#${buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug)}`;
return buildAppHashUrl(buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug));
}
export function buildWeKnoraWikiPagePath(
@@ -129,7 +139,7 @@ export function buildPublicKnowledgeWikiUrl(
knowledgeBaseId: string,
wikiSlug?: string,
): string {
return `/#${buildPublicKnowledgeWikiPath(knowledgeBaseId, wikiSlug)}`;
return buildAppHashUrl(buildPublicKnowledgeWikiPath(knowledgeBaseId, wikiSlug));
}
export function buildLegacyKnowledgeSourceUrl(source: KnowledgeSourceLike): string {
@@ -160,6 +170,7 @@ export function normalizeMalformedWeKnoraHashRoute(): boolean {
const { pathname, search, hash } = window.location;
if (!pathname.startsWith("/page/workspace/knowledge/")) return false;
if (hash && !hash.startsWith("#/")) return false;
window.location.replace(`/#${pathname}${search}`);
const base = import.meta.env.BASE_URL || "/";
window.location.replace(`${base}#${pathname}${search}`);
return true;
}
@@ -37,6 +37,34 @@ const WIKI_INDEX_TYPE_LABELS: Record<string, string> = {
const WIKI_INDEX_TYPE_ORDER = ["summary", "entity", "concept"];
export const WIKI_PROTECTED_IMAGE_MARKER = "/__llmwiki-protected-image__";
const PROTECTED_WIKI_IMAGE_SOURCE =
/(?:storage:\/\/[0-9A-Za-z_-]+\/)?(?:resource|local|minio|cos|tos|s3|oss|ks3|obs):\/\/[^\s"'<>\])]+/gi;
/**
* Browsers cannot load WeKnora's protected resource:// and object-storage
* schemes directly. Replace them with a safe marker before Markdown
* sanitization; the image component resolves that marker through DeerFlow.
*/
export function rewriteProtectedWikiImageSources(markdown: string): string {
return markdown.replace(
PROTECTED_WIKI_IMAGE_SOURCE,
(filePath) => `${WIKI_PROTECTED_IMAGE_MARKER}?file_path=${encodeURIComponent(filePath)}`,
);
}
export function parseProtectedWikiImageSource(source: string | undefined): string | null {
if (!source) return null;
try {
const url = new URL(source, window.location.origin);
if (url.pathname !== WIKI_PROTECTED_IMAGE_MARKER) return null;
return url.searchParams.get("file_path")?.trim() || null;
} catch {
return null;
}
}
export function stripWikiFrontmatter(value: string) {
return value.replace(/^\s*---\s*\n[\s\S]*?\n---\s*(?:\n|$)/, "").trim();
}
@@ -179,6 +179,28 @@ export interface LlmWikiWikiIndexStatus {
last_error?: string | null;
last_started_at?: string | null;
last_completed_at?: string | null;
progress?: {
total_pages: number;
processed_pages: number;
vectorized_pages: number;
failed_pages: number;
vector_count: number;
completion_percent: number;
};
pages?: Array<{
id: string;
slug: string;
title: string;
page_type: string;
status: string;
index_status: string;
vector_count: number;
vectorized: boolean;
processed_in_current_sync: boolean;
indexed_at?: string | null;
updated_at?: string | null;
last_error?: string | null;
}>;
}
export interface LlmWikiSourceContext {
@@ -444,6 +466,24 @@ export async function getLlmWikiDocumentPreview(
return response.blob();
}
export async function getLlmWikiProtectedFileUrl(
knowledgeBaseId: string,
filePath: string,
publicAccess = false,
): Promise<string> {
const params = new URLSearchParams({ file_path: filePath });
if (publicAccess) {
return `${PUBLIC_API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/files?${params.toString()}`;
}
const access = await readJson<{ token: string; expires_in: number }>(
await apiFetch(
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/files/access-url?${params.toString()}`,
),
"读取 Wiki 图片",
);
return `${PUBLIC_API()}/files/${encodeURIComponent(access.token)}`;
}
export async function getLlmWikiGraph(knowledgeBaseId: string): Promise<LlmWikiGraph> {
return readJson(
await apiFetch(`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/graph?limit=200`),
@@ -33,6 +33,7 @@ import { reasoningPlugins } from "@/core/streamdown";
import {
exportableWikiMarkdown,
renderableWikiMarkdown,
rewriteProtectedWikiImageSources,
safeWikiFileName,
stripWikiFrontmatter,
wikiLinkMarkdown,
@@ -49,6 +50,7 @@ import {
type LlmWikiDocument,
type LlmWikiWikiPage,
} from "@/strategy-components/api/llmwiki";
import { WikiProtectedImage } from "./WikiProtectedImage";
type SourceContextResponse = {
success?: boolean;
@@ -926,10 +928,18 @@ export function WeKnoraSourceDrawer({
</div>
<MarkdownContent
className="max-w-none"
content={preview.text}
content={rewriteProtectedWikiImageSources(preview.text)}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
components={{
img: (props) => (
<WikiProtectedImage
{...props}
knowledgeBaseId={target?.knowledgeBaseId || ""}
/>
),
}}
/>
</article>
</section>
@@ -964,10 +974,18 @@ export function WeKnoraSourceDrawer({
<div className="rounded-lg border bg-background p-5">
<MarkdownContent
className="max-w-none"
content={wikiDisplayContent}
content={rewriteProtectedWikiImageSources(wikiDisplayContent)}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
components={{
img: (props) => (
<WikiProtectedImage
{...props}
knowledgeBaseId={target?.knowledgeBaseId || ""}
/>
),
}}
linkClassName={(href) =>
parseWeKnoraPreviewUrl(href)?.wikiSlug
? "wiki-content-link font-medium text-[#be2323] decoration-[#be2323]/55 decoration-dashed hover:decoration-solid"
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownCodeEditor } from "@/components/wiki/MarkdownCodeEditor";
import {
@@ -331,6 +332,10 @@ export function WeKnoraWikiManagementPage() {
() => moveTargetOptions(pagesList),
[pagesList],
);
const pageIndexBySlug = useMemo(
() => new Map((indexStatus.data?.pages ?? []).map((item) => [item.slug, item])),
[indexStatus.data?.pages],
);
const currentMoveTarget = moveTargetValue(draft.parentSlug, draft.categoryPathText, draft.folderId);
useEffect(() => {
@@ -387,6 +392,17 @@ export function WeKnoraWikiManagementPage() {
});
};
const renderVectorBadge = (page: WikiPageListItem) => {
const item = pageIndexBySlug.get(page.slug);
if (item?.last_error || item?.index_status === "failed" || item?.index_status === "stale") {
return <Badge variant="destructive" className="ml-auto shrink-0 text-[10px]" title={item?.last_error || "向量化失败"}>失败</Badge>;
}
if (item?.vectorized) {
return <Badge variant="secondary" className="ml-auto shrink-0 text-[10px]" title={`最近向量化:${item.indexed_at || "未知"}`}>已向量化 · {item.vector_count} 段</Badge>;
}
return <Badge variant="outline" className="ml-auto shrink-0 text-[10px]">未向量化</Badge>;
};
const renderWikiNode = (node: WikiTreeNode, depth = 0) => {
const hasChildren = node.children.length > 0;
const isExpanded = query.trim() ? true : expandedDirectories.has(node.key);
@@ -432,7 +448,8 @@ export function WeKnoraWikiManagementPage() {
>
<div className="flex min-w-0 items-center gap-2">
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate font-medium">{page.title || page.slug}</span>
<span className="truncate font-medium">{page.title || page.slug}</span>
{renderVectorBadge(page)}
</div>
<div className="mt-1 truncate pl-5 text-xs text-muted-foreground">{page.slug}</div>
</button>
@@ -460,6 +477,7 @@ export function WeKnoraWikiManagementPage() {
<div className="flex min-w-0 items-center gap-2">
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate font-medium">{page.title || page.slug}</span>
{renderVectorBadge(page)}
</div>
<div className="mt-1 truncate pl-5 text-xs text-muted-foreground">{page.slug}</div>
{page.aliases?.length ? (
@@ -634,6 +652,26 @@ export function WeKnoraWikiManagementPage() {
<main className="grid min-h-0 flex-1 grid-cols-[320px_1fr] overflow-hidden">
<aside className="flex min-h-0 flex-col border-r bg-muted/20">
<div className="border-b p-3">
{indexStatus.data && (
<div className="mb-3 rounded-lg border bg-background p-3">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="flex items-center gap-2 font-medium">
向量化进度
{!indexStatus.data.local_index_enabled && <Badge variant="outline" className="text-[10px]">当前未启用</Badge>}
</span>
<span className="tabular-nums text-muted-foreground">
{indexStatus.data.progress?.processed_pages ?? 0}/{indexStatus.data.progress?.total_pages ?? 0} 篇 · {indexStatus.data.progress?.completion_percent ?? 0}%
</span>
</div>
<Progress value={indexStatus.data.progress?.completion_percent ?? 0} className="mt-2 h-2" />
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
<span>已向量化 {indexStatus.data.progress?.vectorized_pages ?? 0} 篇</span>
<span>向量章节 {indexStatus.data.progress?.vector_count ?? 0}</span>
<span className={(indexStatus.data.progress?.failed_pages ?? 0) > 0 ? "text-destructive" : ""}>失败 {indexStatus.data.progress?.failed_pages ?? 0} 篇</span>
</div>
{indexStatus.data.last_error && <div className="mt-2 break-words text-[11px] text-destructive">{indexStatus.data.last_error}</div>}
</div>
)}
<div className="relative">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -19,6 +19,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import {
buildAppHashUrl,
buildPublicKnowledgeWikiPath,
buildWeKnoraWikiPagePath,
} from "@/core/llmwiki/source-links";
@@ -35,6 +36,7 @@ import { markdownToDocxBlob } from "@/core/scheduled-tasks/markdown-docx";
import {
exportableWikiMarkdown,
renderableWikiMarkdown,
rewriteProtectedWikiImageSources,
safeWikiFileName,
stripWikiFrontmatter,
wikiLinkMarkdown,
@@ -49,6 +51,7 @@ import {
usePublicLlmWikiWikiPage,
usePublicLlmWikiWikiPages,
} from "@/strategy-components/api/llmwiki";
import { WikiProtectedImage } from "./WikiProtectedImage";
const LIST_PATH = "/page/workspace/knowledge";
@@ -96,9 +99,11 @@ function downloadBlob(blob: Blob, fileName: string) {
}
function publicWikiTargetFromHref(href: string, knowledgeBaseId: string) {
const routePrefix = `/#${buildPublicKnowledgeWikiPath(knowledgeBaseId)}`;
if (!href.startsWith(`${routePrefix}?`)) return null;
const route = href.slice(2);
const hashIndex = href.indexOf("#");
if (hashIndex < 0) return null;
const route = href.slice(hashIndex + 1);
const routePrefix = buildPublicKnowledgeWikiPath(knowledgeBaseId);
if (!route.startsWith(`${routePrefix}?`)) return null;
const queryIndex = route.indexOf("?");
const slug = new URLSearchParams(route.slice(queryIndex + 1)).get("wikiSlug")?.trim();
return slug ? { route, slug } : null;
@@ -209,7 +214,7 @@ export function WeKnoraWikiPageView({
: pageData?.content,
));
const displayMarkdown = pageData
? renderableWikiMarkdown(
? rewriteProtectedWikiImageSources(renderableWikiMarkdown(
title,
content,
noteId,
@@ -217,12 +222,12 @@ export function WeKnoraWikiPageView({
? (knowledgeBaseId, slug) => {
const next = new URLSearchParams(searchParams);
next.set("wikiSlug", slug);
return `/#${buildPublicKnowledgeWikiPath(knowledgeBaseId)}?${next.toString()}`;
return buildAppHashUrl(`${buildPublicKnowledgeWikiPath(knowledgeBaseId)}?${next.toString()}`);
}
: undefined,
publicEmbed ? pagesList : [],
publicEmbed ? publicWikiIndex.data?.groups || [] : [],
)
))
: "";
const exportMarkdown = pageData
? wikiLinkMarkdown(exportableWikiMarkdown(title, summary, content), noteId)
@@ -497,6 +502,15 @@ export function WeKnoraWikiPageView({
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
components={{
img: (props) => (
<WikiProtectedImage
{...props}
knowledgeBaseId={noteId}
publicAccess={publicEmbed}
/>
),
}}
linkClassName={(href) =>
publicEmbed && publicWikiTargetFromHref(href, noteId)
? "wiki-content-link border-b border-dashed !border-[var(--primary)] font-medium !text-primary !no-underline hover:border-solid focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
@@ -0,0 +1,72 @@
import { ImageOff, Loader2 } from "lucide-react";
import { type ImgHTMLAttributes, useEffect, useState } from "react";
import { parseProtectedWikiImageSource } from "@/core/llmwiki/wiki-page-markdown";
import { getLlmWikiProtectedFileUrl } from "@/strategy-components/api/llmwiki";
type WikiProtectedImageProps = ImgHTMLAttributes<HTMLImageElement> & {
knowledgeBaseId: string;
publicAccess?: boolean;
};
export function WikiProtectedImage({
knowledgeBaseId,
publicAccess = false,
src,
alt,
className,
...props
}: WikiProtectedImageProps) {
const filePath = parseProtectedWikiImageSource(src);
const [httpUrl, setHttpUrl] = useState("");
const [error, setError] = useState("");
useEffect(() => {
if (!filePath || !knowledgeBaseId) {
setHttpUrl("");
setError("");
return;
}
let cancelled = false;
setHttpUrl("");
setError("");
void getLlmWikiProtectedFileUrl(knowledgeBaseId, filePath, publicAccess)
.then((url) => {
if (cancelled) return;
setHttpUrl(url);
})
.catch((reason: unknown) => {
if (!cancelled) {
setError(reason instanceof Error ? reason.message : "Wiki 图片加载失败");
}
});
return () => {
cancelled = true;
};
}, [filePath, knowledgeBaseId, publicAccess]);
if (!filePath) {
return <img {...props} src={src} alt={alt} className={className} />;
}
if (error) {
return (
<span
className="my-3 flex min-h-24 items-center justify-center gap-2 rounded-lg border border-dashed bg-muted/40 px-4 text-sm text-muted-foreground"
data-wiki-image-error={error}
role="img"
aria-label={alt || "Wiki 图片加载失败"}
>
<ImageOff className="size-5" />
{alt || "Wiki 图片加载失败"}
</span>
);
}
if (!httpUrl) {
return (
<span className="my-3 flex min-h-24 items-center justify-center rounded-lg bg-muted/30 text-muted-foreground">
<Loader2 className="size-5 animate-spin" aria-label="正在加载 Wiki 图片" />
</span>
);
}
return <img {...props} src={httpUrl} alt={alt} className={className} />;
}