feat: improve WeKnora source preview and wiki management

This commit is contained in:
吉鑫升 committed 2026-08-17 15:53:19 +08:00
1 parent 94cf37a2ae
commit 3ab2c9b282
19 files changed
+2602 -303

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@
"name": "DeerFlow: Open Frontend in Chrome",
"type": "node-terminal",
"request": "launch",
"preLaunchTask": "DeerFlow: Start Full Stack and Wait",
"preLaunchTask": "DeerFlow: VS Code Start",
"command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"Start-Process 'http://127.0.0.1:5174/'\"",
"cwd": "${workspaceFolder}"
}
+23
View File
@@ -1,6 +1,29 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "DeerFlow: VS Code Start",
"type": "process",
"command": "powershell.exe",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"${workspaceFolder}/scripts/vscode-start.ps1",
"-OpenBrowser"
],
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
"reveal": "always",
"panel": "dedicated",
"clear": true
},
"problemMatcher": []
},
{
"label": "DeerFlow: Start Full Stack and Wait",
"type": "process",
@@ -1,4 +1,4 @@
import { ExternalLinkIcon, FileTextIcon, LoaderCircle } from "lucide-react";
import { ExternalLinkIcon, FileTextIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -9,12 +9,12 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import { useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils";
import { useEffect, useState } from "react";
import { useState } from "react";
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
export function SkillDisplaySources({
highlightedReferenceId,
@@ -52,12 +52,14 @@ export function SourceCard({
source: RuntimeDisplaySource;
referenceTarget?: boolean;
}) {
const [open, setOpen] = useState(false);
const [context, setContext] = useState<WeKnoraSourceContext | null>(null);
const [contextLoading, setContextLoading] = useState(false);
const [contextError, setContextError] = useState<string | null>(null);
const [relatedOpen, setRelatedOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [settings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const previewTarget = resolveWeKnoraSourceTarget({
...(source.raw || {}),
url: source.url,
});
const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
@@ -78,57 +80,27 @@ export function SourceCard({
].filter(Boolean);
const isCitation = source.mode === "citation";
const showReferenceNumber = isCitation;
const previewTarget = parseWeKnoraPreviewUrl(source.url);
useEffect(() => {
if (!open || !previewTarget) {
return;
}
let cancelled = false;
setContext(null);
setContextError(null);
setContextLoading(true);
const params = new URLSearchParams({
knowledge_base_id: previewTarget.knowledgeBaseId,
chunk_id: previewTarget.chunkId,
});
void apiFetch(`${getBackendBaseURL()}/api/llmwiki/sources/context?${params}`)
.then(async (response) => {
const payload = (await response.json().catch(() => null)) as unknown;
if (!response.ok || !isWeKnoraSourceContextResponse(payload)) {
throw new Error(readPreviewError(payload, response.status));
}
if (!cancelled) {
setContext(payload.data);
}
})
.catch((error: unknown) => {
if (!cancelled) {
setContextError(error instanceof Error ? error.message : "无法加载原文上下文");
}
})
.finally(() => {
if (!cancelled) {
setContextLoading(false);
}
});
return () => {
cancelled = true;
};
}, [open, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]);
return (
<>
<button
<div
id={referenceTarget ? `reference-${source.index}` : undefined}
type="button"
role="button"
tabIndex={0}
className={cn(
"group bg-card/60 hover:bg-card hover:border-primary/40 focus-visible:ring-primary/30 w-full scroll-mt-12 rounded-xl border px-3.5 py-3 text-left shadow-sm transition-all focus-visible:ring-2 focus-visible:outline-none",
"target:border-amber-300 target:bg-amber-50/80",
highlighted &&
"border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]",
"cursor-pointer",
)}
onClick={() => setOpen(true)}
onClick={() => setRelatedOpen(true)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setRelatedOpen(true);
}
}}
>
<div className="flex items-start gap-3">
{showReferenceNumber && (
@@ -158,18 +130,43 @@ export function SourceCard({
</div>
{source.url && (
previewTarget ? (
<span className="text-primary bg-primary/10 mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium">
<span className="text-muted-foreground bg-muted mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium">
<FileTextIcon className="size-3.5" />
查看原文
知识库文档
</span>
) : (
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
)
)}
</div>
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
{previewTarget && (
<div className="mt-3 flex justify-start border-t pt-2">
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-primary h-7 px-1.5 text-xs"
onClick={(event) => {
event.stopPropagation();
setDetailOpen(true);
}}
>
<ExternalLinkIcon className="mr-1.5 size-3.5" />
查看详情
</Button>
</div>
)}
</div>
{previewTarget && (
<WeKnoraSourceDrawer
open={detailOpen}
target={previewTarget}
title={source.title}
quote={content}
onOpenChange={setDetailOpen}
/>
)}
<Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
<DialogHeader>
<div className="flex items-start justify-between gap-3 pr-8">
<div className="min-w-0 flex-1">
@@ -205,13 +202,7 @@ export function SourceCard({
)}
</div>
</DialogHeader>
{previewTarget ? (
<WeKnoraSourceContextPanel
context={context}
loading={contextLoading}
error={contextError}
/>
) : paragraphs.length > 0 ? (
{paragraphs.length > 0 ? (
<div className="text-foreground max-h-[55vh] space-y-3 overflow-y-auto text-sm leading-relaxed">
{paragraphs.map((paragraph, index) => (
<p key={index} className="whitespace-pre-wrap">
@@ -231,156 +222,6 @@ export function SourceCard({
);
}
type WeKnoraPreviewTarget = {
knowledgeBaseId: string;
chunkId: string;
};
type WeKnoraSourceContext = {
knowledge_base_name?: string;
document_title?: string;
document_file_name?: string;
display_mode?: "wiki" | "chunks";
focus_chunk_id: string;
wiki_page?: {
id?: string;
slug?: string;
title?: string;
page_type?: string;
status?: string;
summary?: string;
content?: string;
source_refs?: string[];
} | null;
chunks: Array<{
id: string;
chunk_index?: number;
content: string;
is_focus: boolean;
}>;
};
function parseWeKnoraPreviewUrl(url?: string): WeKnoraPreviewTarget | null {
if (!url?.startsWith("/weknora-source-preview?")) {
return null;
}
const queryIndex = url.indexOf("?");
const params = new URLSearchParams(url.slice(queryIndex + 1));
const knowledgeBaseId = params.get("knowledge_base_id")?.trim();
const chunkId = params.get("chunk_id")?.trim();
return knowledgeBaseId && chunkId ? { knowledgeBaseId, chunkId } : null;
}
function isWeKnoraSourceContextResponse(
value: unknown,
): value is { success: true; data: WeKnoraSourceContext } {
if (!value || typeof value !== "object") {
return false;
}
const data = (value as { data?: unknown }).data;
return (
(value as { success?: unknown }).success === true &&
!!data &&
typeof data === "object" &&
Array.isArray((data as WeKnoraSourceContext).chunks)
);
}
function readPreviewError(payload: unknown, status: number): string {
if (payload && typeof payload === "object") {
const detail = (payload as { detail?: unknown }).detail;
if (typeof detail === "string" && detail) {
return detail;
}
}
return status === 404 ? "原文已不可用" : "无法加载原文上下文";
}
function WeKnoraSourceContextPanel({
context,
loading,
error,
}: {
context: WeKnoraSourceContext | null;
loading: boolean;
error: string | null;
}) {
if (loading) {
return (
<div className="text-muted-foreground flex min-h-36 items-center justify-center gap-2 text-sm">
<LoaderCircle className="size-4 animate-spin" />
正在加载原文上下文…
</div>
);
}
if (error) {
return (
<div className="border-destructive/30 bg-destructive/5 text-destructive rounded-lg border p-3 text-sm">
{error}
</div>
);
}
if (!context) {
return null;
}
const wikiPage = context.wiki_page;
const wikiContent = cleanReferenceText(
[wikiPage?.summary, wikiPage?.content].filter(Boolean).join("\n\n"),
[],
);
if (wikiPage && wikiContent) {
return (
<div className="max-h-[55vh] space-y-3 overflow-y-auto pr-1">
<div className="bg-muted/60 text-muted-foreground rounded-lg px-3 py-2 text-xs">
<span>{context.knowledge_base_name || "WeKnora 知识库"}</span>
<span> · LLMWiki</span>
{wikiPage.page_type && <span> · {wikiPage.page_type}</span>}
</div>
<section className="border-primary/40 bg-primary/5 rounded-lg border p-3 text-sm leading-relaxed shadow-sm">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="text-foreground text-sm font-medium">
{wikiPage.title || wikiPage.slug || "LLMWiki 页面"}
</span>
<span className="bg-primary text-primary-foreground rounded px-1.5 py-0.5 text-xs font-medium">
清洗页
</span>
</div>
<div className="space-y-3 whitespace-pre-wrap">{wikiContent}</div>
</section>
</div>
);
}
return (
<div className="max-h-[55vh] space-y-3 overflow-y-auto pr-1">
<div className="bg-muted/60 text-muted-foreground rounded-lg px-3 py-2 text-xs">
<span>{context.knowledge_base_name || "WeKnora 知识库"}</span>
{context.document_title && <span> · {context.document_title}</span>}
</div>
{context.chunks.map((chunk) => (
<section
key={chunk.id}
className={cn(
"rounded-lg border p-3 text-sm leading-relaxed",
chunk.is_focus
? "border-primary/50 bg-primary/5 shadow-sm"
: "bg-muted/20",
)}
>
<div className="text-muted-foreground mb-2 flex items-center gap-2 text-xs">
<span>分段 {typeof chunk.chunk_index === "number" ? chunk.chunk_index + 1 : "-"}</span>
{chunk.is_focus && (
<span className="bg-primary text-primary-foreground rounded px-1.5 py-0.5 font-medium">
检索命中
</span>
)}
</div>
<p className="whitespace-pre-wrap">{chunk.content}</p>
</section>
))}
</div>
);
}
function cleanReferenceText(
raw: string | undefined,
cleanupWords: string[],
@@ -0,0 +1,125 @@
import { readRuntimeEnv } from "@/env";
export type WeKnoraSourceTarget = {
knowledgeBaseId: string;
chunkId?: string;
documentId?: string;
wikiSlug?: string;
tab?: "documents" | "wiki" | "graph";
};
export type KnowledgeSourceLike = {
url?: string | null;
recUuid?: string | null;
rec_uuid?: string | null;
knowledge_base_id?: string | null;
knowledgeBaseId?: string | null;
mapping_id?: string | null;
mappingId?: string | null;
chunk_id?: string | null;
chunkId?: string | null;
knowledge_id?: string | null;
knowledgeId?: string | null;
document_id?: string | null;
documentId?: string | null;
wiki_slug?: string | null;
wikiSlug?: string | null;
slug?: string | null;
};
export function parseWeKnoraPreviewUrl(url?: string | null): WeKnoraSourceTarget | null {
if (!url?.startsWith("/weknora-source-preview?")) {
return null;
}
const queryIndex = url.indexOf("?");
const params = new URLSearchParams(url.slice(queryIndex + 1));
const knowledgeBaseId = params.get("knowledge_base_id")?.trim();
const chunkId = params.get("chunk_id")?.trim();
const documentId =
params.get("document_id")?.trim() ||
params.get("knowledge_id")?.trim() ||
"";
const wikiSlug =
params.get("wiki_slug")?.trim() ||
params.get("wikiSlug")?.trim() ||
params.get("slug")?.trim() ||
"";
return knowledgeBaseId && chunkId
? {
knowledgeBaseId,
chunkId,
documentId: documentId || undefined,
wikiSlug: wikiSlug || undefined,
tab: wikiSlug ? "wiki" : "documents",
}
: null;
}
export function resolveWeKnoraSourceTarget(source: KnowledgeSourceLike): WeKnoraSourceTarget | null {
const fromPreview = parseWeKnoraPreviewUrl(source.url);
const knowledgeBaseId =
source.knowledge_base_id ||
source.knowledgeBaseId ||
source.mapping_id ||
source.mappingId ||
fromPreview?.knowledgeBaseId ||
"";
if (!knowledgeBaseId) return null;
const wikiSlug = source.wiki_slug || source.wikiSlug || source.slug || fromPreview?.wikiSlug || "";
const documentId =
source.document_id ||
source.documentId ||
source.knowledge_id ||
source.knowledgeId ||
fromPreview?.documentId ||
"";
const chunkId = source.chunk_id || source.chunkId || fromPreview?.chunkId || "";
return {
knowledgeBaseId,
chunkId: chunkId || undefined,
documentId: documentId || undefined,
wikiSlug: wikiSlug || undefined,
tab: wikiSlug ? "wiki" : "documents",
};
}
export function buildWeKnoraSourceIframeUrl(target: WeKnoraSourceTarget): string {
const params = new URLSearchParams();
if (target.tab) params.set("tab", target.tab);
if (target.chunkId) params.set("sourceChunkId", target.chunkId);
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}` : ""}`;
}
export function buildLegacyKnowledgeSourceUrl(source: KnowledgeSourceLike): string {
const recUuid = source.recUuid || source.rec_uuid || source.url || "";
if (!recUuid) return "";
if (/^https?:\/\//i.test(recUuid)) return recUuid;
const base = readRuntimeEnv("VITE_AIPI_KNOWLEDGE_URL");
if (base) {
return `${base.replace(/\/+$/, "")}/#/knowledge/mid-detail?recUuid=${encodeURIComponent(recUuid)}`;
}
return "";
}
export function openKnowledgeSource(source: KnowledgeSourceLike): boolean {
const weknoraTarget = resolveWeKnoraSourceTarget(source);
const href = weknoraTarget
? buildWeKnoraSourceIframeUrl(weknoraTarget)
: buildLegacyKnowledgeSourceUrl(source);
if (!href) return false;
window.open(href, "_blank", "noopener,noreferrer");
return true;
}
export function normalizeMalformedWeKnoraHashRoute(): boolean {
if (typeof window === "undefined") return false;
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}`);
return true;
}
+15 -12
View File
@@ -10,15 +10,18 @@ import "katex/dist/katex.min.css";
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import { syncEmbedSessionFromCurrentUrl } from "@/core/embed/embed-session";
// 渲染前同步启用「iframe 嵌入会话」(按标签页 sessionStorage):必须早于任何鉴权写入,
// 使 iframe 嵌入态的登录走 sessionStorage、与同浏览器正常登录(localStorage)完全隔离。
syncEmbedSessionFromCurrentUrl();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
import { App } from "./App";
import { syncEmbedSessionFromCurrentUrl } from "@/core/embed/embed-session";
import { normalizeMalformedWeKnoraHashRoute } from "@/core/llmwiki/source-links";
if (!normalizeMalformedWeKnoraHashRoute()) {
// 渲染前同步启用「iframe 嵌入会话」(按标签页 sessionStorage):必须早于任何鉴权写入,
// 使 iframe 嵌入态的登录走 sessionStorage、与同浏览器正常登录(localStorage)完全隔离。
syncEmbedSessionFromCurrentUrl();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
}
@@ -36,6 +36,7 @@ import AdminSkillsPage from './AdminSkillsPage';
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
import ScheduledTasksPage from './ScheduledTasksPage';
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
import WeKnoraWikiManagementPage from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage';
import LlmMetricsPage from './LlmMetricsPage';
import CheckpointMigrationPage from './CheckpointMigrationPage';
import MemoryPage from './MemoryPage';
@@ -616,6 +617,14 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="knowledge/:noteId/wiki"
element={
<WorkspaceLayout {...layoutProps}>
<WeKnoraWikiManagementPage />
</WorkspaceLayout>
}
/>
<Route
path="knowledge/:noteId"
element={
@@ -124,6 +124,8 @@ export interface LlmWikiWikiPage {
aliases: string[];
parent_slug?: string;
category_path?: string[];
folder_id?: string;
wiki_path?: string;
depth?: number;
source_refs: string[];
in_links: string[];
@@ -141,6 +143,21 @@ export interface LlmWikiWikiPageList {
total_pages: number;
}
export interface LlmWikiSourceContext {
knowledge_base_name?: string;
document_title?: string;
document_file_name?: string;
display_mode?: "wiki" | "chunks";
focus_chunk_id: string;
wiki_page?: LlmWikiWikiPage | null;
chunks: Array<{
id: string;
chunk_index?: number;
content: string;
is_focus: boolean;
}>;
}
export interface LlmWikiSearchResult {
chunk_id: string;
content: string;
@@ -159,6 +176,33 @@ export interface LlmWikiWeKnoraEmbedSession {
expires_in: number;
}
export interface LlmWikiWikiPageCreate {
slug: string;
title: string;
content: string;
summary?: string;
page_type?: string;
status?: string;
aliases?: string[];
parent_slug?: string;
category_path?: string[];
folder_id?: string;
wiki_path?: string;
}
export interface LlmWikiWikiPageUpdate {
title?: string;
content?: string;
summary?: string;
page_type?: string;
status?: string;
aliases?: string[];
parent_slug?: string;
category_path?: string[];
folder_id?: string;
wiki_path?: string;
}
async function readJson<T>(response: Response, context: string): Promise<T> {
if (!response.ok) {
const payload = await response.json().catch(() => null as unknown);
@@ -226,6 +270,21 @@ export async function createLlmWikiWeKnoraEmbedSession(
);
}
export async function getLlmWikiSourceContext(
knowledgeBaseId: string,
chunkId: string,
): Promise<LlmWikiSourceContext> {
const params = new URLSearchParams({
knowledge_base_id: knowledgeBaseId,
chunk_id: chunkId,
});
const payload = await readJson<{ success: boolean; data: LlmWikiSourceContext }>(
await apiFetch(`${API()}/sources/context?${params}`),
"加载参考文献原文",
);
return payload.data;
}
export async function createLlmWikiKnowledgeBase(input: {
name: string;
description: string;
@@ -353,6 +412,56 @@ export async function getLlmWikiWikiPage(
);
}
export async function createLlmWikiWikiPage(
knowledgeBaseId: string,
input: LlmWikiWikiPageCreate,
): Promise<LlmWikiWikiPage> {
return readJson(
await apiFetch(
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/pages`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
},
),
"创建 Wiki 页面",
);
}
export async function updateLlmWikiWikiPage(
knowledgeBaseId: string,
slug: string,
input: LlmWikiWikiPageUpdate,
): Promise<LlmWikiWikiPage> {
const safeSlug = slug.split("/").map(encodeURIComponent).join("/");
return readJson(
await apiFetch(
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/pages/${safeSlug}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
},
),
"鏇存柊 Wiki 椤甸潰",
);
}
export async function deleteLlmWikiWikiPage(
knowledgeBaseId: string,
slug: string,
): Promise<void> {
const safeSlug = slug.split("/").map(encodeURIComponent).join("/");
await readJson<void>(
await apiFetch(
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/pages/${safeSlug}`,
{ method: "DELETE" },
),
"鍒犻櫎 Wiki 椤甸潰",
);
}
export async function uploadLlmWikiDocument(
knowledgeBaseId: string,
file: File,
@@ -698,6 +807,21 @@ export function useLlmWikiActions() {
reprocessLlmWikiDocument(id, documentId),
onSuccess: invalidateKnowledge,
}),
createWikiPage: useMutation({
mutationFn: ({ id, input }: { id: string; input: LlmWikiWikiPageCreate }) =>
createLlmWikiWikiPage(id, input),
onSuccess: invalidateKnowledge,
}),
updateWikiPage: useMutation({
mutationFn: ({ id, slug, input }: { id: string; slug: string; input: LlmWikiWikiPageUpdate }) =>
updateLlmWikiWikiPage(id, slug, input),
onSuccess: invalidateKnowledge,
}),
removeWikiPage: useMutation({
mutationFn: ({ id, slug }: { id: string; slug: string }) =>
deleteLlmWikiWikiPage(id, slug),
onSuccess: invalidateKnowledge,
}),
publication: useMutation({
mutationFn: ({ id, publish }: { id: string; publish: boolean }) =>
setLlmWikiPublication(id, publish),
@@ -310,11 +310,29 @@ export const createMockSingleRagMessages = (): Message[] => ([
/**
* 将多轮检索接口返回的 references 项映射为通用引用来源。
*/
export const mapReferenceToSource = (item: { title?: string; content?: string; snippet?: string }): SourceItem => ({
export const mapReferenceToSource = (item: {
title?: string;
content?: string;
snippet?: string;
url?: string;
knowledge_base_id?: string;
knowledge_id?: string;
document_id?: string;
chunk_id?: string;
wiki_slug?: string;
slug?: string;
}): SourceItem => ({
title: item.title || '未命名来源',
type: '参考资料',
snippet: item.snippet || item.content || '',
content: item.content || item.snippet || '',
url: item.url,
knowledge_base_id: item.knowledge_base_id,
knowledge_id: item.knowledge_id,
document_id: item.document_id,
chunk_id: item.chunk_id,
wiki_slug: item.wiki_slug,
slug: item.slug,
});
/**
@@ -343,6 +361,12 @@ export const mapDocsToSources = (docs: KnowledgeBaseChunk['docs']): SourceItem[]
source: item.source || item.title || '',
time: '',
url: item.url,
knowledge_base_id: (item as any).knowledge_base_id,
knowledge_id: (item as any).knowledge_id,
document_id: (item as any).document_id,
chunk_id: (item as any).chunk_id,
wiki_slug: (item as any).wiki_slug,
slug: (item as any).slug,
content: item.content || item.snippet || '',
};
});
@@ -447,4 +471,4 @@ export const platformTypeChange = (platform: string) => {
platformType = 'facebook';
}
return platformType;
}
}
@@ -30,7 +30,9 @@ import {
import { requestCrudService } from '@/service/request-axios-service';
import { authStorageGet } from '@/core/auth/scoped-storage';
import { copyTextToClipboard } from '@/core/clipboard';
import { openKnowledgeSource, resolveWeKnoraSourceTarget, type WeKnoraSourceTarget } from '@/core/llmwiki/source-links';
import { readRuntimeEnv } from '@/env';
import { WeKnoraSourceDrawer } from '@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer';
/**
* 通用问答组件
@@ -43,7 +45,6 @@ const zncmUrl = readRuntimeEnv('VITE_API_BASE_URL') || '';
const chatUrl = readRuntimeEnv('VITE_AIPI_CHAT_URL') || '';
const generateUrl = readRuntimeEnv('VITE_AIPI_GENERATE_CHAT_URL') || '';
const taskChatUrl = readRuntimeEnv('VITE_AIPI_TASK_CHAT_URL') || '';
const knowledgeUrl = readRuntimeEnv('VITE_AIPI_KNOWLEDGE_URL');
const service = requestCrudService;
const GeneralQA: React.FC<{
onNavigate?: (page: string) => void;
@@ -110,6 +111,10 @@ const GeneralQA: React.FC<{
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
// 当前选中的引用来源,用于显示引用详情弹窗
const [selectedCitation, setSelectedCitation] = useState<SourceItem | null>(null);
const [weknoraDrawer, setWeknoraDrawer] = useState<{
target: WeKnoraSourceTarget;
title: string;
} | null>(null);
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const [editingThinking, setEditingThinking] = useState('');
const [editingAnswer, setEditingAnswer] = useState('');
@@ -384,6 +389,13 @@ const GeneralQA: React.FC<{
source: item.source1,
time: item.m_publish,
url: item.recUuid,
recUuid: item.recUuid,
knowledge_base_id: item.knowledge_base_id,
knowledge_id: item.knowledge_id,
document_id: item.document_id,
chunk_id: item.chunk_id,
wiki_slug: item.wiki_slug,
slug: item.slug,
content: item.m_content || '',
}));
// 更新检索统计信息
@@ -2380,12 +2392,22 @@ const GeneralQA: React.FC<{
</div>
</div>
{selectedCitation.url && (
{(selectedCitation.url || resolveWeKnoraSourceTarget(selectedCitation)) && (
<div className="mt-4">
<a
href={`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${selectedCitation.url}`}
target="_blank"
rel="noreferrer"
<button
type="button"
onClick={() => {
const weknoraTarget = resolveWeKnoraSourceTarget(selectedCitation);
if (weknoraTarget) {
setWeknoraDrawer({
target: weknoraTarget,
title: selectedCitation.title || '参考文献原文',
});
setSelectedCitation(null);
} else {
void openKnowledgeSource(selectedCitation);
}
}}
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm transition-all ${isDark
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
@@ -2394,7 +2416,7 @@ const GeneralQA: React.FC<{
>
<ExternalLink className="w-4 h-4" />
查看原文链接
</a>
</button>
</div>
)}
@@ -2406,6 +2428,15 @@ const GeneralQA: React.FC<{
</div>
)}
<WeKnoraSourceDrawer
open={!!weknoraDrawer}
target={weknoraDrawer?.target ?? null}
title={weknoraDrawer?.title}
onOpenChange={(nextOpen) => {
if (!nextOpen) setWeknoraDrawer(null);
}}
/>
<Dialog
visible={isModelSettingsOpen}
header="模型参数设置"
@@ -14,6 +14,21 @@ export interface SourceItem {
source?: string;
time?: string;
url?: string;
recUuid?: string;
rec_uuid?: string;
knowledge_base_id?: string;
knowledgeBaseId?: string;
mapping_id?: string;
mappingId?: string;
knowledge_id?: string;
knowledgeId?: string;
document_id?: string;
documentId?: string;
chunk_id?: string;
chunkId?: string;
wiki_slug?: string;
wikiSlug?: string;
slug?: string;
keywords?: string;
taskDirection?: string;
content?: string;
@@ -97,4 +112,4 @@ export interface qaModeItem {
label: string;
icon: LucideIcon;
desc: string;
}
}
@@ -21,10 +21,12 @@ import {
ExternalLink,
} from 'lucide-react';
import { useTheme } from '@/contexts/ThemeContext';
import { openKnowledgeSource, resolveWeKnoraSourceTarget, type WeKnoraSourceTarget } from '@/core/llmwiki/source-links';
import MarkdownRenderer from '@/strategy-components/MarkdownRenderer';
import { SourceItem } from './GeneralQA.types';
import '../qa/qa-animations.css';
import { readRuntimeEnv } from '@/env';
import { WeKnoraSourceDrawer } from '@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer';
/**
* 单条检索结果。
@@ -242,7 +244,6 @@ export function ChatMessage<T extends QACommonMessage>({
const isDark = theme === 'dark';
const isUser = message.role === 'user';
const hasTrace = !isUser && !!(message.singleRagTrace || message.thinkingTrace);
const knowledgeUrl = readRuntimeEnv('VITE_AIPI_KNOWLEDGE_URL');
const [visible, setVisible] = useState(false); // 打开详情对话框
const [listData, setListData] = useState<SourceItem>({
title: '',
@@ -252,10 +253,17 @@ export function ChatMessage<T extends QACommonMessage>({
source: '',
content: '',
});
const [weknoraDrawer, setWeknoraDrawer] = useState<{
target: WeKnoraSourceTarget;
title: string;
} | null>(null);
const showDialog = (item: SourceItem) => {
console.log('item', item)
if (item.url) {
window.open(`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${item.url}`);
const weknoraTarget = resolveWeKnoraSourceTarget(item);
if (weknoraTarget) {
setWeknoraDrawer({ target: weknoraTarget, title: item.title || '参考文献原文' });
} else if (item.url) {
openKnowledgeSource(item);
} else {
setListData(item);
setVisible(true);
@@ -361,12 +369,19 @@ export function ChatMessage<T extends QACommonMessage>({
<div>时间:{listData.time || '未知'}</div>
</div>
</div>
{listData.url && (
{(listData.url || resolveWeKnoraSourceTarget(listData)) && (
<div className="mt-4">
<a
href={`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${listData.url}`}
target="_blank"
rel="noreferrer"
<button
type="button"
onClick={() => {
const weknoraTarget = resolveWeKnoraSourceTarget(listData);
if (weknoraTarget) {
setWeknoraDrawer({ target: weknoraTarget, title: listData.title || '参考文献原文' });
setVisible(false);
} else {
void openKnowledgeSource(listData);
}
}}
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm transition-all ${isDark
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
@@ -375,7 +390,7 @@ export function ChatMessage<T extends QACommonMessage>({
>
<ExternalLink className="w-4 h-4" />
查看原文链接
</a>
</button>
</div>
)}
<div className={`mt-4 rounded-xl p-3 text-sm leading-relaxed whitespace-pre-wrap ${isDark ? 'bg-slate-800/70 text-slate-200' : 'bg-gray-50 text-gray-700'
@@ -384,6 +399,14 @@ export function ChatMessage<T extends QACommonMessage>({
</div>
</div>
</Dialog>
<WeKnoraSourceDrawer
open={!!weknoraDrawer}
target={weknoraDrawer?.target ?? null}
title={weknoraDrawer?.title}
onOpenChange={(nextOpen) => {
if (!nextOpen) setWeknoraDrawer(null);
}}
/>
{!isUser && renderAssistantExtra?.(message)}
</div>
@@ -1,14 +1,24 @@
import { AlertCircle, Loader2, RefreshCw, Upload } from "lucide-react";
import { AlertCircle, Edit3, FileText, Loader2, RefreshCw, Trash2, Upload } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Navigate, useNavigate, useParams } from "react-router-dom";
import { Navigate, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { getBackendBaseURL } from "@/core/config";
import {
createLlmWikiWeKnoraEmbedSession,
getLlmWikiSourceContext,
useLlmWikiActions,
useLlmWikiKnowledgeBase,
useLlmWikiWikiPage,
useLlmWikiRuntime,
} from "@/strategy-components/api/llmwiki";
@@ -86,10 +96,12 @@ export function WeKnoraKnowledgeDetailPage() {
noteId: workspaceKnowledgeBaseId,
} = useParams();
const knowledgeBaseId = strategyKnowledgeBaseId ?? workspaceKnowledgeBaseId ?? "";
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const frameRef = useRef<HTMLIFrameElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [frameSrc, setFrameSrc] = useState("");
const [frameVersion, setFrameVersion] = useState(0);
const [frameError, setFrameError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const runtime = useLlmWikiRuntime();
@@ -97,7 +109,16 @@ export function WeKnoraKnowledgeDetailPage() {
const actions = useLlmWikiActions();
const detailsReady = !!details.data;
const preferredTab = details.data?.indexing_strategy?.wiki_enabled ? "wiki" : "documents";
const requestedTab = searchParams.get("tab");
const preferredTab =
requestedTab === "documents" || requestedTab === "wiki" || requestedTab === "graph"
? requestedTab
: details.data?.indexing_strategy?.wiki_enabled
? "wiki"
: "documents";
const sourceChunkId = searchParams.get("sourceChunkId") || searchParams.get("chunkId") || "";
const documentId = searchParams.get("documentId") || "";
const wikiSlug = searchParams.get("wikiSlug") || "";
useEffect(() => {
let cancelled = false;
@@ -109,14 +130,24 @@ export function WeKnoraKnowledgeDetailPage() {
};
}
void createLlmWikiWeKnoraEmbedSession(knowledgeBaseId, preferredTab)
.then((session) => {
void (async () => {
const resolvedWikiSlug =
wikiSlug ||
(sourceChunkId
? (await getLlmWikiSourceContext(knowledgeBaseId, sourceChunkId).catch(
() => null,
))?.wiki_page?.slug
: "");
const session = await createLlmWikiWeKnoraEmbedSession(knowledgeBaseId, preferredTab);
if (cancelled) return;
const framePath = session.frame_path || "";
const nextUrl = buildFrameUrl(framePath);
nextUrl.searchParams.set("theme", collectThemePayload().theme);
if (sourceChunkId) nextUrl.searchParams.set("sourceChunkId", sourceChunkId);
if (documentId) nextUrl.searchParams.set("documentId", documentId);
if (resolvedWikiSlug) nextUrl.searchParams.set("wikiSlug", resolvedWikiSlug);
setFrameSrc(nextUrl.toString());
})
})()
.catch((error: unknown) => {
if (cancelled) return;
const message = error instanceof Error ? error.message : "创建 WeKnora 详情会话失败";
@@ -126,7 +157,7 @@ export function WeKnoraKnowledgeDetailPage() {
return () => {
cancelled = true;
};
}, [detailsReady, knowledgeBaseId, preferredTab, runtime.data?.provider]);
}, [detailsReady, documentId, knowledgeBaseId, preferredTab, runtime.data?.provider, sourceChunkId, wikiSlug]);
useEffect(() => {
if (!frameSrc) return;
@@ -194,11 +225,14 @@ export function WeKnoraKnowledgeDetailPage() {
}
const canUpload = details.data.can_write && !details.data.is_conversation_deposit;
const canEditCurrentWikiArticle =
canUpload && !!details.data.indexing_strategy?.wiki_enabled && !!wikiSlug;
const canManageWiki = canUpload && !!details.data.indexing_strategy?.wiki_enabled;
return (
<div className="flex h-full min-h-0 flex-col bg-background">
{canUpload && (
<div className="flex h-12 shrink-0 items-center justify-end border-b px-4">
{(canUpload || canEditCurrentWikiArticle) && (
<div className="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-2">
<input
ref={fileInputRef}
type="file"
@@ -222,7 +256,7 @@ export function WeKnoraKnowledgeDetailPage() {
const succeeded = files.length - failed.length;
if (succeeded > 0) {
await details.refetch();
if (frameRef.current) frameRef.current.src = frameSrc;
setFrameVersion((value) => value + 1);
}
if (failed.length === 0) {
toast.success(`已上传 ${succeeded} 个文件,正在处理`);
@@ -237,23 +271,53 @@ export function WeKnoraKnowledgeDetailPage() {
})();
}}
/>
<Button
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Upload className="mr-2 size-4" />
<div className="min-w-0 flex-1">
{canEditCurrentWikiArticle && (
<CurrentWikiArticleActions
knowledgeBaseId={knowledgeBaseId}
slug={wikiSlug}
onChanged={() => setFrameVersion((value) => value + 1)}
onDeleted={() => {
navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}?tab=wiki`, {
replace: true,
});
setFrameVersion((value) => value + 1);
}}
/>
)}
上传文件
</Button>
</div>
<div className="flex shrink-0 items-center gap-2">
{canManageWiki && (
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki`)}
>
<FileText className="mr-2 size-4" />
Wiki管理
</Button>
)}
{canUpload && (
<Button
size="sm"
className="shrink-0"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Upload className="mr-2 size-4" />
)}
上传文件
</Button>
)}
</div>
</div>
)}
{frameSrc && (
<iframe
key={frameSrc}
key={`${frameSrc}:${frameVersion}`}
ref={frameRef}
title={`${details.data.name || "WeKnora"} 详情`}
src={frameSrc}
@@ -279,4 +343,147 @@ export function WeKnoraKnowledgeDetailPage() {
);
}
function CurrentWikiArticleActions({
knowledgeBaseId,
slug,
onChanged,
onDeleted,
}: {
knowledgeBaseId: string;
slug: string;
onChanged: () => void;
onDeleted: () => void;
}) {
const [open, setOpen] = useState(false);
const detail = useLlmWikiWikiPage(knowledgeBaseId, slug || null, !!slug);
const actions = useLlmWikiActions();
const [draft, setDraft] = useState({ title: "", summary: "", content: "" });
useEffect(() => {
const page = detail.data;
if (!page) return;
setDraft({
title: page.title || "",
summary: page.summary || "",
content: page.content || "",
});
}, [detail.data]);
const save = async () => {
if (!slug) return;
try {
await actions.updateWikiPage.mutateAsync({
id: knowledgeBaseId,
slug,
input: {
title: draft.title.trim(),
summary: draft.summary,
content: draft.content,
},
});
toast.success("文章已保存");
await detail.refetch();
setOpen(false);
onChanged();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存文章失败");
}
};
const remove = async () => {
if (!slug) return;
if (!window.confirm(`确定删除文章「${detail.data?.title || slug}」吗?删除后不可恢复。`)) return;
try {
await actions.removeWikiPage.mutateAsync({ id: knowledgeBaseId, slug });
toast.success("文章已删除");
onDeleted();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除文章失败");
}
};
return (
<div className="flex min-w-0 flex-wrap items-center gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
{detail.data?.title || slug}
</div>
<div className="truncate text-xs text-muted-foreground">{slug}</div>
</div>
<Button
size="sm"
variant="outline"
className="shrink-0"
onClick={() => setOpen(true)}
disabled={detail.isLoading || !!detail.error}
>
<Edit3 className="mr-2 size-4" />
编辑此文章
</Button>
<Button
size="sm"
variant="outline"
className="shrink-0"
onClick={remove}
disabled={actions.removeWikiPage.isPending}
>
{actions.removeWikiPage.isPending ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Trash2 className="mr-2 size-4" />
)}
删除
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="flex max-h-[90vh] w-[calc(100vw-2rem)] max-w-4xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>编辑此文章</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-3 overflow-auto">
<div className="space-y-1.5">
<label className="text-sm font-medium">标题</label>
<Input
value={draft.title}
onChange={(event) => setDraft((prev) => ({ ...prev, title: event.target.value }))}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">摘要</label>
<Textarea
value={draft.summary}
onChange={(event) => setDraft((prev) => ({ ...prev, summary: event.target.value }))}
rows={3}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">正文</label>
<Textarea
value={draft.content}
onChange={(event) => setDraft((prev) => ({ ...prev, content: event.target.value }))}
className="min-h-[48vh] resize-y font-mono text-sm"
/>
</div>
</div>
<div className="mt-4 flex shrink-0 justify-end gap-2 border-t pt-3">
<Button variant="outline" onClick={() => setOpen(false)} disabled={actions.updateWikiPage.isPending}>
取消
</Button>
<Button
onClick={save}
disabled={actions.updateWikiPage.isPending || !draft.title.trim() || !draft.content.trim()}
>
{actions.updateWikiPage.isPending ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<RefreshCw className="mr-2 size-4" />
)}
保存
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
export default WeKnoraKnowledgeDetailPage;
@@ -0,0 +1,710 @@
import { FileText, Loader2, Maximize2, Minimize2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetTitle,
} from "@/components/ui/sheet";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import { SpreadsheetPreview } from "@/components/workspace/artifacts/spreadsheet-preview";
import { cn } from "@/lib/utils";
import {
getLlmWikiDocument,
getLlmWikiDocumentPreview,
getLlmWikiWikiPage,
listLlmWikiDocumentChunks,
type LlmWikiChunk,
type LlmWikiDocument,
type LlmWikiWikiPage,
} from "@/strategy-components/api/llmwiki";
type SourceContextResponse = {
success?: boolean;
data?: {
document_id?: string;
wiki_page?: {
slug?: string;
} | null;
};
};
type DetailTarget = WeKnoraSourceTarget & {
documentId?: string;
wikiSlug?: string;
};
type PreviewState =
| { kind: "none" }
| { kind: "iframe"; url: string }
| { kind: "xlsx"; data: ArrayBuffer }
| { kind: "table"; rows: string[][]; highlightedRows: number[]; truncated: boolean; source: "csv" | "chunks" }
| { kind: "text"; text: string }
| { kind: "unsupported"; message: string };
type CachedSourceDetail = {
document: LlmWikiDocument | null;
wikiPage: LlmWikiWikiPage | null;
preview: PreviewState;
previewError: string;
};
const sourceDetailCache = new Map<string, CachedSourceDetail>();
async function resolveTargetDetail(target: WeKnoraSourceTarget): Promise<DetailTarget> {
if (target.documentId || target.wikiSlug || !target.chunkId) {
return target;
}
const params = new URLSearchParams({
knowledge_base_id: target.knowledgeBaseId,
chunk_id: target.chunkId,
});
const response = await apiFetch(
`${getBackendBaseURL()}/api/llmwiki/sources/context?${params}`,
);
const payload = (await response.json().catch(() => null)) as SourceContextResponse | null;
if (!response.ok || payload?.success !== true) {
throw new Error("无法定位原文文档详情");
}
const documentId = payload.data?.document_id?.trim();
const wikiSlug = payload.data?.wiki_page?.slug?.trim();
if (!documentId && !wikiSlug) {
throw new Error("当前引用缺少原文文档标识,无法打开详情");
}
return {
...target,
documentId: documentId || target.documentId,
wikiSlug: wikiSlug || target.wikiSlug,
};
}
function formatDate(value?: string | null) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", { hour12: false });
}
function displayType(document?: LlmWikiDocument | null, wiki?: LlmWikiWikiPage | null) {
return (
wiki?.page_type ||
document?.file_type ||
document?.type ||
document?.source ||
"知识库文档"
);
}
function cleanText(value?: string | null) {
return (value || "").replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
}
function fileExtension(document?: LlmWikiDocument | null) {
const name = [document?.file_name, document?.title, document?.file_type]
.filter(Boolean)
.join(" ")
.toLowerCase();
const match = name.match(/\.([a-z0-9]+)(?:\s|$)/i);
return match?.[1] || String(document?.file_type || "").toLowerCase().replace(/^\./, "");
}
function isXlsx(blob: Blob, document?: LlmWikiDocument | null) {
const ext = fileExtension(document);
return (
ext === "xlsx" ||
blob.type.includes("spreadsheetml") ||
blob.type.includes("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
);
}
function looksLikeZip(data: ArrayBuffer) {
const bytes = new Uint8Array(data.slice(0, 4));
return bytes[0] === 0x50 && bytes[1] === 0x4b;
}
function looksLikeOldXls(data: ArrayBuffer) {
const bytes = new Uint8Array(data.slice(0, 8));
return (
bytes[0] === 0xd0 &&
bytes[1] === 0xcf &&
bytes[2] === 0x11 &&
bytes[3] === 0xe0 &&
bytes[4] === 0xa1 &&
bytes[5] === 0xb1 &&
bytes[6] === 0x1a &&
bytes[7] === 0xe1
);
}
function looksLikeText(data: ArrayBuffer) {
const bytes = new Uint8Array(data.slice(0, 1024));
if (bytes.length === 0) return true;
let control = 0;
for (const byte of bytes) {
if (byte === 0) return false;
if (byte < 9 || (byte > 13 && byte < 32)) control += 1;
}
return control / bytes.length < 0.05;
}
function isCsv(blob: Blob, document?: LlmWikiDocument | null) {
const ext = fileExtension(document);
return ext === "csv" || blob.type.includes("csv") || blob.type.startsWith("text/csv");
}
function isTextPreview(blob: Blob, document?: LlmWikiDocument | null) {
const ext = fileExtension(document);
return (
blob.type.startsWith("text/") ||
["txt", "md", "markdown", "json", "log", "xml", "yaml", "yml"].includes(ext)
);
}
function extractTerms(value: string, limit = 8) {
return Array.from(
new Set(
value
.replace(/[A-Z]:/g, " ")
.split(/[,。;;、\n\r\t, ]+/)
.map((item) => item.trim())
.filter((item) => item.length >= 2 && item.length <= 40),
),
).slice(0, limit);
}
function parseLabeledChunkLine(line: string): string[] | null {
const matches = Array.from(line.matchAll(/(?:^|,)\s*([A-Z])\s*:\s*([^,]+?)(?=\s*,\s*[A-Z]\s*:|$)/g));
if (matches.length < 2) return null;
return matches.map((match) => match[2]?.trim() || "");
}
function tableFromChunks(
chunks: LlmWikiChunk[],
focusChunkId?: string,
quote = "",
): PreviewState | null {
const rows: string[][] = [];
const highlightedRows = new Set<number>();
const terms = extractTerms(quote);
for (const chunk of chunks) {
const lines = cleanText(chunk.content).split("\n").filter(Boolean);
for (const line of lines) {
const parsed = parseLabeledChunkLine(line);
if (!parsed) continue;
const rowIndex = rows.length;
rows.push(parsed);
const haystack = parsed.join(" ");
if (
chunk.id === focusChunkId ||
(terms.length > 0 && terms.some((term) => haystack.includes(term)))
) {
highlightedRows.add(rowIndex);
}
}
}
if (rows.length === 0) return null;
const header = rows[0] || [];
const normalizedHeader =
header.includes("方向") || header.includes("人物名称") || header.includes("简介")
? header
: ["方向", "人物名称", "简介"].slice(0, Math.max(...rows.map((row) => row.length), 3));
const bodyRows =
header.includes("方向") || header.includes("人物名称") || header.includes("简介")
? rows.slice(1)
: rows;
const shiftedHighlights = Array.from(highlightedRows)
.map((index) => (bodyRows === rows ? index : index - 1))
.filter((index) => index >= 0);
return {
kind: "table",
rows: [normalizedHeader, ...bodyRows],
highlightedRows: shiftedHighlights,
truncated: false,
source: "chunks",
};
}
async function buildPreviewState(
blob: Blob,
document: LlmWikiDocument,
chunks: LlmWikiChunk[],
focusChunkId?: string,
quote = "",
): Promise<PreviewState> {
const data = await blob.arrayBuffer();
const ext = fileExtension(document);
const chunkTable = tableFromChunks(chunks, focusChunkId, quote);
if (data.byteLength === 0 && chunkTable) {
return chunkTable;
}
if ((ext === "xlsx" || ext === "xls") && chunkTable) {
return chunkTable;
}
if (isXlsx(blob, document) && looksLikeZip(data)) {
return { kind: "xlsx", data };
}
if (isCsv(blob, document) || ext === "csv") {
const text = new TextDecoder("utf-8").decode(data);
return { kind: "table", ...parseCsv(text), highlightedRows: [], source: "csv" };
}
if (looksLikeOldXls(data) || ext === "xls") {
if (chunkTable) return chunkTable;
return {
kind: "unsupported",
message: "当前文件是旧版 .xls 二进制格式,暂未拿到可展示的解析表格。",
};
}
if (looksLikeText(data) || isTextPreview(blob, document)) {
const text = new TextDecoder("utf-8").decode(data);
const trimmed = text.trimStart();
if (trimmed.startsWith("<!doctype") || trimmed.startsWith("<html")) {
const htmlBlob = new Blob([text], { type: "text/html;charset=utf-8" });
return { kind: "iframe", url: URL.createObjectURL(htmlBlob) };
}
if (text.includes(",") || text.includes("\t")) {
const delimiterNormalized = text.includes("\t") && !text.includes(",")
? text.replace(/\t/g, ",")
: text;
return { kind: "table", ...parseCsv(delimiterNormalized), highlightedRows: [], source: "csv" };
}
return { kind: "text", text };
}
return { kind: "iframe", url: URL.createObjectURL(blob) };
}
function parseCsv(text: string, maxRows = 1000, maxCols = 200): { rows: string[][]; truncated: boolean } {
const rows: string[][] = [];
let row: string[] = [];
let cell = "";
let inQuotes = false;
let truncated = false;
const pushCell = () => {
if (row.length < maxCols) row.push(cell);
else truncated = true;
cell = "";
};
const pushRow = () => {
pushCell();
if (rows.length < maxRows) rows.push(row);
else truncated = true;
row = [];
};
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
const next = text[index + 1];
if (inQuotes) {
if (char === '"' && next === '"') {
cell += '"';
index += 1;
} else if (char === '"') {
inQuotes = false;
} else {
cell += char;
}
continue;
}
if (char === '"') {
inQuotes = true;
} else if (char === ",") {
pushCell();
} else if (char === "\n") {
pushRow();
} else if (char !== "\r") {
cell += char;
}
}
if (cell || row.length > 0) pushRow();
return { rows, truncated };
}
function DataTablePreview({
rows,
truncated,
highlightedRows,
}: {
rows: string[][];
truncated: boolean;
highlightedRows: number[];
}) {
const containerRef = useRef<HTMLDivElement>(null);
const [headerRow, ...bodyRows] = rows;
const columnCount = Math.max(headerRow?.length ?? 0, ...rows.map((row) => row.length), 1);
const highlighted = new Set(highlightedRows);
const highlightedCount = highlightedRows.length;
useEffect(() => {
const node = containerRef.current?.querySelector<HTMLElement>("[data-highlighted='true']");
node?.scrollIntoView({ block: "center", inline: "nearest" });
}, [highlightedRows]);
return (
<div className="flex size-full flex-col overflow-hidden bg-[#f8fafc]">
<div className="flex shrink-0 items-center justify-between gap-3 border-b bg-background px-4 py-2.5">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">
{highlightedCount > 0 ? "已定位到本次检索命中的表格行" : "表格预览"}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
共 {bodyRows.length} 行 · {columnCount} 列
{highlightedCount > 0 ? ` · ${highlightedCount} 行命中` : ""}
</div>
</div>
{truncated && (
<Badge variant="outline" className="shrink-0">
仅预览部分
</Badge>
)}
</div>
<div ref={containerRef} className="min-h-0 flex-1 overflow-auto p-4">
<table className="w-max min-w-full border-separate border-spacing-0 overflow-hidden rounded-lg border bg-background text-sm shadow-sm">
<thead>
<tr>
<th className="sticky left-0 top-0 z-20 w-12 border-b border-r bg-slate-100 px-2 py-2.5 text-center text-xs font-medium text-muted-foreground">
#
</th>
{Array.from({ length: columnCount }, (_, columnIndex) => (
<th
key={columnIndex}
className="sticky top-0 z-10 border-b border-r bg-slate-100 px-3 py-2.5 text-left font-semibold text-foreground whitespace-nowrap"
>
{headerRow?.[columnIndex] ?? ""}
</th>
))}
</tr>
</thead>
<tbody>
{bodyRows.map((row, rowIndex) => (
<tr
key={rowIndex}
data-highlighted={highlighted.has(rowIndex) ? "true" : undefined}
className={cn(
"transition-colors",
highlighted.has(rowIndex)
? "bg-amber-50 outline-2 outline-offset-[-2px] outline-amber-300"
: "even:bg-slate-50/70 hover:bg-emerald-50/50",
)}
>
<td className="sticky left-0 z-10 border-b border-r bg-inherit px-2 py-2.5 text-center text-xs text-muted-foreground">
{rowIndex + 1}
</td>
{Array.from({ length: columnCount }, (_, columnIndex) => (
<td
key={columnIndex}
className="max-w-[560px] border-b border-r px-3 py-2.5 align-top leading-6 whitespace-pre-wrap"
>
{row[columnIndex] ?? ""}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export function WeKnoraSourceDrawer({
open,
target,
title,
quote = "",
onOpenChange,
}: {
open: boolean;
target: WeKnoraSourceTarget | null;
title?: string;
quote?: string;
onOpenChange: (open: boolean) => void;
}) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [document, setDocument] = useState<LlmWikiDocument | null>(null);
const [wikiPage, setWikiPage] = useState<LlmWikiWikiPage | null>(null);
const [preview, setPreview] = useState<PreviewState>({ kind: "none" });
const [previewError, setPreviewError] = useState("");
const [previewLoading, setPreviewLoading] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const targetKey = useMemo(() => {
if (!target) return "";
return [
target.knowledgeBaseId,
target.chunkId || "",
target.documentId || "",
target.wikiSlug || "",
quote.slice(0, 200),
].join("|");
}, [quote, target]);
useEffect(() => {
if (!open || !target) {
setError(null);
setLoading(false);
setDocument(null);
setWikiPage(null);
setPreviewError("");
setPreviewLoading(false);
setPreview({ kind: "none" });
setFullscreen(false);
return;
}
let cancelled = false;
const cached = sourceDetailCache.get(targetKey);
if (cached) {
setDocument(cached.document);
setWikiPage(cached.wikiPage);
setPreview(cached.preview);
setPreviewError(cached.previewError);
setLoading(false);
setPreviewLoading(false);
return;
}
setLoading(true);
setError(null);
setDocument(null);
setWikiPage(null);
setPreviewError("");
setPreviewLoading(false);
setPreview({ kind: "none" });
void (async () => {
const detailTarget = await resolveTargetDetail(target);
const tasks: Promise<void>[] = [];
let loadedDocument: LlmWikiDocument | null = null;
let loadedWikiPage: LlmWikiWikiPage | null = null;
let loadedPreview: PreviewState = { kind: "none" };
let loadedPreviewError = "";
let loadedChunks: LlmWikiChunk[] = [];
if (detailTarget.wikiSlug) {
tasks.push(
getLlmWikiWikiPage(detailTarget.knowledgeBaseId, detailTarget.wikiSlug)
.then((page) => {
loadedWikiPage = page;
if (!cancelled) setWikiPage(page);
})
.catch(() => undefined),
);
}
if (detailTarget.documentId) {
loadedDocument = await getLlmWikiDocument(
detailTarget.knowledgeBaseId,
detailTarget.documentId,
);
if (!cancelled) setDocument(loadedDocument);
loadedChunks = await listLlmWikiDocumentChunks(
detailTarget.knowledgeBaseId,
detailTarget.documentId,
1,
200,
)
.then((page) => page.items || [])
.catch(() => []);
if (!cancelled) {
setLoading(false);
setPreviewLoading(true);
}
const previewTask = getLlmWikiDocumentPreview(detailTarget.knowledgeBaseId, detailTarget.documentId)
.then((blob) => {
return buildPreviewState(
blob,
loadedDocument!,
loadedChunks,
detailTarget.chunkId,
quote,
);
})
.then((nextPreview) => {
loadedPreview = nextPreview;
if (!cancelled) setPreview(nextPreview);
})
.catch((err: unknown) => {
loadedPreviewError = err instanceof Error ? err.message : "原件预览不可用";
if (!cancelled) {
setPreviewError(loadedPreviewError);
}
})
.finally(() => {
if (!cancelled) setPreviewLoading(false);
});
tasks.push(previewTask);
}
if (tasks.length === 0) {
throw new Error("当前引用缺少原文文档标识,无法打开详情");
}
await Promise.all(tasks);
sourceDetailCache.set(targetKey, {
document: loadedDocument,
wikiPage: loadedWikiPage,
preview: loadedPreview,
previewError: loadedPreviewError,
});
})()
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : "无法打开文档详情");
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, target, targetKey]);
const resolvedTitle =
wikiPage?.title ||
document?.title ||
document?.file_name ||
title ||
"文档详情";
const summary = cleanText(wikiPage?.summary || document?.description);
const wikiContent = cleanText(wikiPage?.content);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className={cn(
"gap-0 overflow-hidden p-0 sm:max-w-none",
fullscreen ? "w-screen" : "w-[min(92vw,900px)]",
)}
>
<SheetTitle className="sr-only">{resolvedTitle}</SheetTitle>
<SheetDescription className="sr-only">知识库文档详情</SheetDescription>
<div className="relative flex h-full min-h-0 flex-col bg-background">
<div className="shrink-0 border-b px-5 py-4 pr-24">
<div className="flex min-w-0 items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-700">
<FileText className="size-5" />
</div>
<div className="min-w-0">
<div className="truncate text-base font-semibold text-foreground">{resolvedTitle}</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline">{displayType(document, wikiPage)}</Badge>
{formatDate(document?.created_at || wikiPage?.created_at) && (
<span>{formatDate(document?.created_at || wikiPage?.created_at)}</span>
)}
{document?.parse_status && <span>{document.parse_status}</span>}
</div>
</div>
</div>
<Button
type="button"
size="icon-sm"
variant="ghost"
className="absolute top-4 right-12"
onClick={() => setFullscreen((value) => !value)}
aria-label={fullscreen ? "退出全屏" : "全屏打开参考文献源文件"}
title={fullscreen ? "退出全屏" : "全屏打开参考文献源文件"}
>
{fullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</Button>
</div>
{loading && (
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-5 animate-spin" />
正在加载完整文档预览
</div>
)}
{error && !loading && (
<div className="flex min-h-0 flex-1 items-center justify-center px-8 text-center text-sm text-destructive">
{error}
</div>
)}
{!loading && !error && (
<div className="min-h-0 flex-1 overflow-auto">
{summary && (
<section className="border-b px-5 py-4">
<div className="mb-2 text-sm font-medium text-foreground">摘要</div>
<div className="rounded-md bg-muted/70 px-3 py-2 text-sm leading-7 text-foreground whitespace-pre-wrap">
{summary}
</div>
</section>
)}
{previewLoading && preview.kind === "none" && (
<section className="flex h-[70vh] min-h-[520px] items-center justify-center border-b text-sm text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" />
正在加载完整文档预览
</section>
)}
{preview.kind === "xlsx" && (
<section className={cn("border-b", fullscreen ? "h-[calc(100vh-190px)]" : "h-[70vh] min-h-[520px]")}>
<SpreadsheetPreview data={preview.data} className="h-full" />
</section>
)}
{preview.kind === "table" && (
<section className={cn("border-b", fullscreen ? "h-[calc(100vh-190px)]" : "h-[70vh] min-h-[520px]")}>
<DataTablePreview
rows={preview.rows}
truncated={preview.truncated}
highlightedRows={preview.highlightedRows}
/>
</section>
)}
{preview.kind === "text" && (
<section className="border-b px-5 py-4">
<pre className="max-h-[70vh] overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-3 text-sm leading-7">
{preview.text}
</pre>
</section>
)}
{preview.kind === "unsupported" && (
<section className="border-b px-5 py-4 text-sm text-muted-foreground">
{preview.message}
</section>
)}
{preview.kind === "iframe" ? (
<section className={cn("border-b", fullscreen ? "h-[calc(100vh-190px)]" : "h-[70vh] min-h-[520px]")}>
<iframe
title="原件预览"
src={preview.url}
className="h-full w-full border-0 bg-white"
/>
</section>
) : previewError ? (
<section className="border-b px-5 py-4 text-sm text-muted-foreground">
{previewError}
</section>
) : null}
{wikiContent && (
<section className="border-b px-5 py-4">
<div className="mb-2 text-sm font-medium text-foreground">Wiki 原文</div>
<div className="prose prose-sm max-w-none whitespace-pre-wrap text-foreground">
{wikiContent}
</div>
</section>
)}
</div>
)}
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,765 @@
import { ArrowLeft, ChevronDown, ChevronRight, FilePlus2, FileText, Folder, Loader2, RefreshCw, Save, Search, Trash2 } from "lucide-react";
import { type Dispatch, type SetStateAction, useEffect, useMemo, useState } from "react";
import { Navigate, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
type LlmWikiWikiPage,
useLlmWikiActions,
useLlmWikiKnowledgeBase,
useLlmWikiRuntime,
useLlmWikiWikiPage,
useLlmWikiWikiPages,
} from "@/strategy-components/api/llmwiki";
const LIST_PATH = "/page/workspace/knowledge";
type Draft = {
slug: string;
title: string;
summary: string;
content: string;
pageType: string;
status: string;
parentSlug: string;
folderId: string;
wikiPath: string;
aliasesText: string;
categoryPathText: string;
};
const emptyDraft = (): Draft => ({
slug: "",
title: "",
summary: "",
content: "",
pageType: "article",
status: "published",
parentSlug: "",
folderId: "",
wikiPath: "",
aliasesText: "",
categoryPathText: "",
});
function splitTags(value: string): string[] {
return value
.split(/[\n,,]/)
.map((item) => item.trim())
.filter(Boolean);
}
function splitDirectory(value: string): string[] {
return value
.split(/[\/\\>,,,\n]/)
.map((item) => item.trim())
.filter(Boolean);
}
function joinTags(value?: string[] | null): string {
return (value || []).join(", ");
}
type WikiPageListItem = LlmWikiWikiPage;
type WikiTreeNode = {
key: string;
label: string;
page: WikiPageListItem | null;
children: WikiTreeNode[];
count: number;
};
type WikiTree = {
roots: WikiTreeNode[];
parentKeysBySlug: Map<string, string[]>;
};
type MoveTargetOption = {
value: string;
label: string;
};
function sameStringArray(left: string[], right: string[]): boolean {
if (left.length !== right.length) return false;
return left.every((item, index) => item === right[index]);
}
function pageDirectoryParts(page: WikiPageListItem): string[] {
const wikiPath = (page.wiki_path || "").split("/").map((item) => item.trim()).filter(Boolean);
if (wikiPath.length > 2) return wikiPath.slice(1, -1);
const category = page.category_path?.filter(Boolean) || [];
if (category.length > 0) return category;
const parts = (page.slug || "").split("/").filter(Boolean);
return parts.length > 1 ? parts.slice(0, -1) : ["未分类"];
}
function filterWikiPages(pages: WikiPageListItem[], query: string): WikiPageListItem[] {
const words = query
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
if (words.length === 0) return pages;
return pages.filter((page) => {
const haystack = [
page.title,
page.slug,
page.summary,
page.content,
page.page_type,
page.status,
page.parent_slug,
...(page.aliases || []),
...(page.category_path || []),
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return words.every((word) => haystack.includes(word));
});
}
function filterWikiPagesWithAncestors(pages: WikiPageListItem[], query: string): WikiPageListItem[] {
const filtered = filterWikiPages(pages, query);
if (!query.trim()) return filtered;
const bySlug = new Map(pages.map((page) => [page.slug, page]));
const result = new Map<string, WikiPageListItem>();
const addWithParents = (page: WikiPageListItem) => {
let current: WikiPageListItem | undefined = page;
const visited = new Set<string>();
while (current && current.slug && !visited.has(current.slug)) {
visited.add(current.slug);
result.set(current.slug, current);
current = current.parent_slug ? bySlug.get(current.parent_slug) : undefined;
}
};
filtered.forEach(addWithParents);
return Array.from(result.values());
}
function createDirectoryNode(path: string[], directoryNodes: Map<string, WikiTreeNode>): WikiTreeNode {
const key = `dir:${path.join("/")}`;
const existing = directoryNodes.get(key);
if (existing) return existing;
const node: WikiTreeNode = {
key,
label: path[path.length - 1] || "未分类",
page: null,
children: [],
count: 0,
};
directoryNodes.set(key, node);
return node;
}
function attachNode(parent: WikiTreeNode | null, roots: WikiTreeNode[], node: WikiTreeNode) {
const siblings = parent ? parent.children : roots;
if (!siblings.some((item) => item.key === node.key)) siblings.push(node);
}
function sortWikiNodes(nodes: WikiTreeNode[]) {
nodes.sort((a, b) => {
if (!!a.children.length !== !!b.children.length) return a.children.length ? -1 : 1;
return a.label.localeCompare(b.label, "zh-Hans-CN");
});
nodes.forEach((node) => sortWikiNodes(node.children));
}
function countPages(node: WikiTreeNode): number {
node.count = (node.page ? 1 : 0) + node.children.reduce((sum, child) => sum + countPages(child), 0);
return node.count;
}
function buildWikiTree(pages: WikiPageListItem[]): WikiTree {
const roots: WikiTreeNode[] = [];
const pageNodes = new Map<string, WikiTreeNode>();
const directoryNodes = new Map<string, WikiTreeNode>();
const parentKeysBySlug = new Map<string, string[]>();
for (const page of pages) {
if (!page.slug) continue;
pageNodes.set(page.slug, {
key: `page:${page.slug}`,
label: page.title || page.slug,
page,
children: [],
count: 1,
});
}
for (const page of pages) {
if (!page.slug) continue;
const node = pageNodes.get(page.slug);
if (!node) continue;
const ancestorKeys: string[] = [];
const parentSlug = page.parent_slug?.trim();
if (parentSlug && pageNodes.has(parentSlug)) {
const parent = pageNodes.get(parentSlug)!;
attachNode(parent, roots, node);
ancestorKeys.push(parent.key);
let current = parent.page?.parent_slug ? pageNodes.get(parent.page.parent_slug) : undefined;
const seen = new Set<string>([parent.key]);
while (current && !seen.has(current.key)) {
seen.add(current.key);
ancestorKeys.unshift(current.key);
current = current.page?.parent_slug ? pageNodes.get(current.page.parent_slug) : undefined;
}
parentKeysBySlug.set(page.slug, ancestorKeys);
continue;
}
const directory = pageDirectoryParts(page);
let parent: WikiTreeNode | null = null;
const path: string[] = [];
for (const part of directory) {
path.push(part);
const dirNode = createDirectoryNode(path, directoryNodes);
attachNode(parent, roots, dirNode);
parent = dirNode;
ancestorKeys.push(dirNode.key);
}
attachNode(parent, roots, node);
parentKeysBySlug.set(page.slug, ancestorKeys);
}
roots.forEach(countPages);
sortWikiNodes(roots);
return { roots, parentKeysBySlug };
}
function moveTargetValue(parentSlug: string, categoryPathText: string, folderId: string): string {
const folder = folderId.trim();
if (folder) return `folder:${folder}`;
const parent = parentSlug.trim();
if (parent) return `page:${parent}`;
const category = splitDirectory(categoryPathText).join("/");
if (category) return `dir:${category}`;
return "root:";
}
function moveTargetOptions(pages: WikiPageListItem[]): MoveTargetOption[] {
const directories = new Map<string, string>();
for (const page of pages) {
const path = pageDirectoryParts(page).join("/");
const folderId = page.folder_id?.trim();
if (path && path !== "未分类") directories.set(folderId ? `folder:${folderId}` : `dir:${path}`, path);
}
const options: MoveTargetOption[] = [
{ value: "root:", label: "根目录" },
...Array.from(directories.entries())
.sort((a, b) => a[1].localeCompare(b[1], "zh-Hans-CN"))
.map(([value, path]) => ({ value, label: `目录 / ${path.replace(/\//g, " / ")}` })),
];
return options;
}
function applyMoveTarget(value: string, setDraft: Dispatch<SetStateAction<Draft>>) {
if (value === "root:") {
setDraft((prev) => ({ ...prev, parentSlug: "", folderId: "", wikiPath: "", categoryPathText: "" }));
return;
}
if (value.startsWith("folder:")) {
setDraft((prev) => ({ ...prev, parentSlug: "", folderId: value.slice("folder:".length), wikiPath: "" }));
return;
}
if (value.startsWith("page:")) {
setDraft((prev) => ({ ...prev, parentSlug: value.slice("page:".length), folderId: "", wikiPath: "", categoryPathText: "" }));
return;
}
if (value.startsWith("dir:")) {
setDraft((prev) => ({ ...prev, parentSlug: "", folderId: "", wikiPath: "", categoryPathText: value.slice("dir:".length).replace(/\//g, ", ") }));
}
}
function nextWikiPath(originalPath: string | undefined, nextTitle: string): string | undefined {
const parts = (originalPath || "").split("/").map((item) => item.trim()).filter(Boolean);
if (parts.length === 0 || !nextTitle.trim()) return undefined;
return [...parts.slice(0, -1), nextTitle.trim()].join("/");
}
export function WeKnoraWikiManagementPage() {
const { noteId = "" } = useParams();
const navigate = useNavigate();
const runtime = useLlmWikiRuntime();
const details = useLlmWikiKnowledgeBase(noteId || null, !!noteId);
const actions = useLlmWikiActions();
const [query, setQuery] = useState("");
const [selectedSlug, setSelectedSlug] = useState("");
const [creating, setCreating] = useState(false);
const [draft, setDraft] = useState<Draft>(() => emptyDraft());
const [expandedDirectories, setExpandedDirectories] = useState<Set<string>>(() => new Set());
const canWrite = !!details.data?.can_write && !details.data?.is_conversation_deposit;
const pages = useLlmWikiWikiPages(noteId || null, 1, 500, "", !!noteId);
const detail = useLlmWikiWikiPage(
noteId || null,
selectedSlug || null,
!!noteId && !!selectedSlug && !creating,
);
useEffect(() => {
if (creating || !detail.data) return;
setDraft({
slug: detail.data.slug || "",
title: detail.data.title || "",
summary: detail.data.summary || "",
content: detail.data.content || "",
pageType: detail.data.page_type || "article",
status: detail.data.status || "published",
parentSlug: detail.data.parent_slug || "",
folderId: detail.data.folder_id || "",
wikiPath: detail.data.wiki_path || "",
aliasesText: joinTags(detail.data.aliases),
categoryPathText: joinTags(detail.data.category_path),
});
}, [creating, detail.data]);
const title = details.data?.name || "Wiki 文档管理";
const pagesList = useMemo(() => pages.data?.pages ?? [], [pages.data?.pages]);
const filteredPages = useMemo(() => filterWikiPagesWithAncestors(pagesList, query), [pagesList, query]);
const wikiTree = useMemo(() => buildWikiTree(filteredPages), [filteredPages]);
const moveOptions = useMemo(
() => moveTargetOptions(pagesList),
[pagesList],
);
const currentMoveTarget = moveTargetValue(draft.parentSlug, draft.categoryPathText, draft.folderId);
useEffect(() => {
if (!selectedSlug || creating) return;
const selectedPage = pagesList.find((page) => page.slug === selectedSlug);
if (!selectedPage) return;
const ancestorKeys = wikiTree.parentKeysBySlug.get(selectedPage.slug) || [];
if (ancestorKeys.length === 0) return;
setExpandedDirectories((prev) => {
if (ancestorKeys.every((key) => prev.has(key))) return prev;
const next = new Set(prev);
ancestorKeys.forEach((key) => next.add(key));
return next;
});
}, [creating, pagesList, selectedSlug, wikiTree.parentKeysBySlug]);
if (runtime.isLoading || details.isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-5 animate-spin" />
加载中
</div>
);
}
if (runtime.data?.provider !== "weknora") return <Navigate to={LIST_PATH} replace />;
if (!details.data) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
无法打开知识库
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button>
</div>
);
}
const startCreate = () => {
setCreating(true);
setSelectedSlug("");
setDraft(emptyDraft());
};
const selectPage = (slug: string) => {
setCreating(false);
setSelectedSlug(slug);
};
const toggleDirectory = (key: string) => {
setExpandedDirectories((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const renderWikiNode = (node: WikiTreeNode, depth = 0) => {
const hasChildren = node.children.length > 0;
const isExpanded = query.trim() ? true : expandedDirectories.has(node.key);
const page = node.page;
const isSelected = !!page && selectedSlug === page.slug && !creating;
const leftPadding = Math.min(depth * 14, 42);
if (hasChildren) {
return (
<div key={node.key} className="space-y-1">
<button
type="button"
className={`flex w-full items-center gap-1.5 rounded-md py-1.5 pr-2 text-left text-xs font-medium transition-colors ${
isSelected
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-background hover:text-foreground"
}`}
style={{ paddingLeft: `${8 + leftPadding}px` }}
onClick={() => toggleDirectory(node.key)}
aria-expanded={isExpanded}
>
{isExpanded ? (
<ChevronDown className="size-3.5 shrink-0" />
) : (
<ChevronRight className="size-3.5 shrink-0" />
)}
<Folder className="size-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">{node.label}</span>
<span className="shrink-0">({node.count})</span>
</button>
{isExpanded && (
<div className="space-y-1">
{page && (
<button
type="button"
className={`w-full rounded-md border px-3 py-2 text-left text-sm transition-colors ${
isSelected
? "border-primary/40 bg-primary/10 text-primary"
: "border-transparent hover:border-border hover:bg-background"
}`}
style={{ marginLeft: `${20 + leftPadding}px`, width: `calc(100% - ${20 + leftPadding}px)` }}
onClick={() => selectPage(page.slug)}
>
<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>
</div>
<div className="mt-1 truncate pl-5 text-xs text-muted-foreground">{page.slug}</div>
</button>
)}
{node.children.map((child) => renderWikiNode(child, depth + 1))}
</div>
)}
</div>
);
}
if (!page) return null;
return (
<button
key={node.key}
type="button"
className={`w-full rounded-md border px-3 py-2 text-left text-sm transition-colors ${
isSelected
? "border-primary/40 bg-primary/10 text-primary"
: "border-transparent hover:border-border hover:bg-background"
}`}
style={{ marginLeft: `${12 + leftPadding}px`, width: `calc(100% - ${12 + leftPadding}px)` }}
onClick={() => selectPage(page.slug)}
>
<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>
</div>
<div className="mt-1 truncate pl-5 text-xs text-muted-foreground">{page.slug}</div>
{page.aliases?.length ? (
<div className="mt-1 flex flex-wrap gap-1 pl-5">
{page.aliases.slice(0, 3).map((alias) => (
<Badge
key={`${page.slug}-${alias}`}
variant="outline"
className="max-w-[110px] truncate px-1.5 py-0 text-[10px]"
>
{alias}
</Badge>
))}
</div>
) : null}
</button>
);
};
const save = async () => {
if (!canWrite) return;
const slug = draft.slug.trim();
const titleValue = draft.title.trim();
const content = draft.content.trim();
if (!slug || !titleValue || !content) {
toast.error("请填写 slug、标题和正文");
return;
}
try {
if (creating) {
const created = await actions.createWikiPage.mutateAsync({
id: noteId,
input: {
slug,
title: titleValue,
summary: draft.summary,
content: draft.content,
page_type: draft.pageType || "article",
status: draft.status || "published",
aliases: splitTags(draft.aliasesText),
parent_slug: draft.parentSlug.trim() || undefined,
category_path: splitDirectory(draft.categoryPathText),
folder_id: draft.folderId.trim() || undefined,
wiki_path: draft.wikiPath.trim() || undefined,
},
});
toast.success("文章已创建");
setCreating(false);
setSelectedSlug(created.slug || slug);
} else {
const originalParentSlug = detail.data?.parent_slug?.trim() || "";
const nextParentSlug = draft.parentSlug.trim();
const originalFolderId = detail.data?.folder_id?.trim() || "";
const nextFolderId = draft.folderId.trim();
const originalWikiPath = detail.data?.wiki_path?.trim() || "";
const folderChanged = nextFolderId !== originalFolderId;
const computedWikiPath = folderChanged
? undefined
: draft.wikiPath.trim() || nextWikiPath(originalWikiPath, titleValue);
const originalCategoryPath = detail.data?.category_path?.filter(Boolean) || [];
const nextCategoryPath = splitDirectory(draft.categoryPathText);
const nextAliases = splitTags(draft.aliasesText);
await actions.updateWikiPage.mutateAsync({
id: noteId,
slug: selectedSlug,
input: {
title: titleValue,
summary: draft.summary,
content: draft.content,
page_type: draft.pageType || detail.data?.page_type || "article",
status: draft.status || detail.data?.status || "published",
aliases: nextAliases,
parent_slug: nextParentSlug !== originalParentSlug ? nextParentSlug : originalParentSlug,
folder_id: folderChanged ? nextFolderId : originalFolderId,
wiki_path: computedWikiPath,
category_path: sameStringArray(nextCategoryPath, originalCategoryPath)
? originalCategoryPath
: nextCategoryPath,
},
});
toast.success("文章已保存");
}
await pages.refetch();
await detail.refetch();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存文章失败");
}
};
const remove = async () => {
if (!canWrite || creating || !selectedSlug) return;
if (!window.confirm(`确定删除文章「${draft.title || selectedSlug}」吗?删除后不可恢复。`)) return;
try {
await actions.removeWikiPage.mutateAsync({ id: noteId, slug: selectedSlug });
toast.success("文章已删除");
setSelectedSlug("");
setDraft(emptyDraft());
await pages.refetch();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除文章失败");
}
};
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<header className="flex min-h-14 shrink-0 flex-wrap items-center justify-between gap-3 border-b px-4 py-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Button size="icon-sm" variant="ghost" onClick={() => navigate(`/page/workspace/knowledge/${encodeURIComponent(noteId)}?tab=wiki`)}>
<ArrowLeft className="size-4" />
</Button>
<h1 className="truncate text-base font-semibold text-foreground">{title}</h1>
</div>
<p className="ml-9 mt-0.5 text-xs text-muted-foreground">Wiki 文档新增、编辑、删除</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => void pages.refetch()}>
<RefreshCw className="mr-2 size-4" />
刷新
</Button>
<Button size="sm" onClick={startCreate} disabled={!canWrite}>
<FilePlus2 className="mr-2 size-4" />
新建文章
</Button>
</div>
</header>
<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">
<div className="relative">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索标题、目录、slug 或别名"
className="pl-9"
/>
</div>
<div className="mt-2 text-xs text-muted-foreground">
{query.trim() ? `匹配 ${filteredPages.length} / ${pagesList.length} 篇` : `共 ${pagesList.length} 篇`}
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto p-2">
{pages.isLoading ? (
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" />
加载文章
</div>
) : filteredPages.length === 0 ? (
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">
{query.trim() ? "没有匹配的 Wiki 文章" : "暂无 Wiki 文章"}
</div>
) : (
<div className="space-y-3">
{wikiTree.roots.map((node) => renderWikiNode(node))}
</div>
)}
</div>
</aside>
<section className="flex min-h-0 flex-col overflow-hidden">
{!creating && !selectedSlug ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
选择左侧文章,或点击新建文章
</div>
) : detail.isLoading && !creating ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" />
加载文章详情
</div>
) : (
<>
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{creating ? "新建文章" : draft.title || selectedSlug}</div>
<div className="truncate text-xs text-muted-foreground">{creating ? "创建后写入 WeKnora Wiki" : draft.slug}</div>
{(draft.pageType || draft.status || draft.parentSlug || draft.aliasesText || draft.categoryPathText) && (
<div className="mt-1 flex flex-wrap gap-1">
{[draft.pageType, draft.status, draft.parentSlug, ...splitDirectory(draft.categoryPathText), ...splitTags(draft.aliasesText)]
.filter(Boolean)
.slice(0, 8)
.map((tag) => (
<Badge key={tag} variant="outline" className="px-1.5 py-0 text-[10px]">
{tag}
</Badge>
))}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{!creating && (
<Button variant="outline" size="sm" onClick={remove} disabled={!canWrite || actions.removeWikiPage.isPending}>
{actions.removeWikiPage.isPending ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Trash2 className="mr-2 size-4" />
)}
删除
</Button>
)}
<Button size="sm" onClick={save} disabled={!canWrite || actions.createWikiPage.isPending || actions.updateWikiPage.isPending}>
{actions.createWikiPage.isPending || actions.updateWikiPage.isPending ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Save className="mr-2 size-4" />
)}
保存
</Button>
</div>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-auto p-4">
<div className="grid gap-4 md:grid-cols-2">
<label className="space-y-1.5">
<span className="text-sm font-medium">Slug</span>
<Input
value={draft.slug}
disabled={!creating}
onChange={(event) => setDraft((prev) => ({ ...prev, slug: event.target.value }))}
placeholder="例如 entity/flow-json"
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium">标题</span>
<Input
value={draft.title}
onChange={(event) => setDraft((prev) => ({ ...prev, title: event.target.value }))}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium">类型</span>
<Input
value={draft.pageType}
onChange={(event) => setDraft((prev) => ({ ...prev, pageType: event.target.value }))}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium">状态</span>
<Input
value={draft.status}
onChange={(event) => setDraft((prev) => ({ ...prev, status: event.target.value }))}
/>
</label>
<label className="space-y-1.5 md:col-span-2">
<span className="text-sm font-medium">移动到</span>
<select
value={moveOptions.some((option) => option.value === currentMoveTarget) ? currentMoveTarget : "root:"}
onChange={(event) => applyMoveTarget(event.target.value, setDraft)}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
>
{moveOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium">目录路径</span>
<Input
value={draft.categoryPathText}
onChange={(event) => setDraft((prev) => ({ ...prev, categoryPathText: event.target.value }))}
placeholder="例如 政要/日本/内阁"
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium">父级 slug</span>
<Input
value={draft.parentSlug}
onChange={(event) => setDraft((prev) => ({ ...prev, parentSlug: event.target.value }))}
placeholder="可选,例如 entity/japan"
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium">别名</span>
<Input
value={draft.aliasesText}
onChange={(event) => setDraft((prev) => ({ ...prev, aliasesText: event.target.value }))}
placeholder="多个别名用逗号分隔"
/>
</label>
</div>
<label className="block space-y-1.5">
<span className="text-sm font-medium">摘要</span>
<Textarea
value={draft.summary}
rows={3}
onChange={(event) => setDraft((prev) => ({ ...prev, summary: event.target.value }))}
/>
</label>
<label className="block space-y-1.5">
<span className="text-sm font-medium">正文</span>
<Textarea
value={draft.content}
onChange={(event) => setDraft((prev) => ({ ...prev, content: event.target.value }))}
className="min-h-[46vh] resize-y font-mono text-sm"
/>
</label>
</div>
</>
)}
</section>
</main>
</div>
);
}
export default WeKnoraWikiManagementPage;
@@ -179,6 +179,33 @@ class ManualKnowledgeCreate(BaseModel):
content: str = Field(..., min_length=1, max_length=2_000_000)
class WikiPageCreate(BaseModel):
slug: str = Field(..., min_length=1, max_length=512)
title: str = Field(..., min_length=1, max_length=255)
content: str = Field(..., min_length=1, max_length=2_000_000)
summary: str | None = Field(default=None, max_length=200_000)
page_type: str | None = Field(default="article", min_length=1, max_length=64)
status: str | None = Field(default="published", min_length=1, max_length=64)
aliases: list[str] | None = Field(default=None, max_length=100)
parent_slug: str | None = Field(default=None, max_length=512)
category_path: list[str] | None = Field(default=None, max_length=50)
folder_id: str | None = Field(default=None, max_length=128)
wiki_path: str | None = Field(default=None, max_length=1024)
class WikiPageUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=255)
content: str | None = Field(default=None, min_length=1, max_length=2_000_000)
summary: str | None = Field(default=None, max_length=200_000)
page_type: str | None = Field(default=None, min_length=1, max_length=64)
status: str | None = Field(default=None, min_length=1, max_length=64)
aliases: list[str] | None = Field(default=None, max_length=100)
parent_slug: str | None = Field(default=None, max_length=512)
category_path: list[str] | None = Field(default=None, max_length=50)
folder_id: str | None = Field(default=None, max_length=128)
wiki_path: str | None = Field(default=None, max_length=1024)
class ConversationDepositCreate(BaseModel):
thread_id: str = Field(..., min_length=1, max_length=128)
question: str = Field(default="", max_length=200_000)
@@ -291,15 +318,17 @@ async def _get_weknora_admin_session(runtime: Any, *, force_refresh: bool = Fals
return cached
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0), follow_redirects=False) as client:
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0), follow_redirects=False, trust_env=False) as client:
response = await client.post(
f"{runtime.api_base_url}/api/v1/auth/login",
json={"email": runtime.admin_email, "password": runtime.admin_password},
headers={"Accept": "application/json"},
)
except httpx.TimeoutException as exc:
logger.warning("WeKnora admin login timed out: base_url=%s", runtime.api_base_url)
raise HTTPException(status_code=504, detail="WeKnora admin login timed out") from exc
except httpx.HTTPError as exc:
logger.warning("WeKnora admin login unavailable: base_url=%s error=%r", runtime.api_base_url, exc)
raise HTTPException(status_code=502, detail="WeKnora admin login is unavailable") from exc
if response.status_code >= 400:
@@ -412,13 +441,14 @@ def _request_kb_id(request: Request, body: Any) -> str:
async def _admin_json(runtime: Any, path: str, *, token: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0), follow_redirects=False) as client:
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0), follow_redirects=False, trust_env=False) as client:
response = await client.get(
f"{runtime.api_base_url}{_encode_upstream_path(path)}",
params=params,
headers={"Accept": "application/json", "Authorization": f"Bearer {token}"},
)
except httpx.HTTPError as exc:
logger.warning("WeKnora verification request failed: base_url=%s path=%s error=%r", runtime.api_base_url, path, exc)
raise HTTPException(status_code=502, detail="WeKnora verification request failed") from exc
if response.status_code == 404:
raise HTTPException(status_code=404, detail="WeKnora resource was not found")
@@ -502,6 +532,18 @@ async def _authorize_weknora_api_proxy(
if len(tail) == 1:
raise HTTPException(status_code=403, detail="Global WeKnora knowledge collection is not allowed")
action = tail[1]
if read_method and action == "batch":
ids = [
item.strip()
for raw in request.query_params.getlist("ids")
for item in raw.split(",")
if item.strip()
]
if not ids:
raise HTTPException(status_code=403, detail="Batch operation must include document ids")
for document_id in ids:
await _verify_proxy_document_belongs_to_kb(runtime, ctx, document_id, token)
return
if action in {"batch", "batch-delete", "batch-reparse", "folder", "move", "tags"}:
if _request_kb_id(request, json_body) != ctx.weknora_id:
raise HTTPException(status_code=403, detail="Batch operation must be scoped to the current knowledge base")
@@ -783,6 +825,7 @@ def _weknora_embed_injection(
"tenantId": tenant_id,
"user": user,
"tenant": tenant,
"weknoraId": ctx.weknora_id,
"publicPrefix": public_prefix,
"apiPrefix": _with_public_prefix(public_prefix, _WEKNORA_EMBED_API_PREFIX),
}
@@ -878,6 +921,29 @@ html.deerflow-weknora-embed [class^="guide__"],
html.deerflow-weknora-embed [class*=" guide__"] {{
display: none !important;
}}
html.deerflow-source-detail-open [data-deerflow-source-detail-hidden="true"] {{
display: none !important;
}}
html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] {{
position: fixed !important;
inset: 0 !important;
width: 100vw !important;
height: 100vh !important;
max-width: none !important;
max-height: none !important;
margin: 0 !important;
transform: none !important;
z-index: 2147483000 !important;
background: var(--td-bg-color-page, #fff) !important;
box-shadow: none !important;
border-radius: 0 !important;
}}
html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [class*="drawer"],
html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [class*="Drawer"],
html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [class*="dialog"],
html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [class*="Dialog"] {{
max-width: none !important;
}}
</style>
<script id="deerflow-weknora-embed-guard">
(() => {{
@@ -1019,6 +1085,143 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
}});
}}
const launchParams = new URLSearchParams(location.search);
const requestedWikiSlug = launchParams.get("wikiSlug") || launchParams.get("slug") || "";
const requestedDocumentId =
launchParams.get("documentId") ||
launchParams.get("knowledgeId") ||
launchParams.get("knowledge_id") ||
"";
const sourceDetailMode = Boolean(requestedDocumentId || requestedWikiSlug);
if (sourceDetailMode) {{
root.classList.add("deerflow-source-detail-request");
}}
let requestedWikiOpened = false;
let requestedDocumentOpened = false;
let sourceDetailAttempts = 0;
const maxSourceDetailAttempts = 18;
function escapeAttr(value) {{
if (window.CSS && typeof window.CSS.escape === "function") return window.CSS.escape(value);
return String(value).replace(/["\\\\]/g, "\\\\$&");
}}
function openRequestedWikiSlug() {{
if (!requestedWikiSlug || requestedWikiOpened || root.classList.contains("deerflow-source-detail-open")) return;
const slug = escapeAttr(requestedWikiSlug);
const link =
document.querySelector(`[data-slug="${{slug}}"]`) ||
document.querySelector(`a[href*="${{encodeURIComponent(requestedWikiSlug)}}"]`) ||
document.querySelector(`a[href*="${{requestedWikiSlug.replace(/"/g, "%22")}}"]`);
if (!link || typeof link.click !== "function") return;
requestedWikiOpened = true;
link.click();
}}
function openRequestedDocumentId() {{
if (!requestedDocumentId || requestedDocumentOpened || root.classList.contains("deerflow-source-detail-open")) return;
const id = escapeAttr(requestedDocumentId);
const encoded = encodeURIComponent(requestedDocumentId);
const candidates = [
`[data-id="${{id}}"]`,
`[data-row-key="${{id}}"]`,
`[data-knowledge-id="${{id}}"]`,
`[data-document-id="${{id}}"]`,
`a[href*="${{encoded}}"]`,
`button[href*="${{encoded}}"]`
];
let node = null;
for (const selector of candidates) {{
node = document.querySelector(selector);
if (node) break;
}}
if (!node) {{
node = Array.from(document.querySelectorAll("[data-id],[data-row-key],[data-knowledge-id],[data-document-id]"))
.find((item) => {{
const values = [
item.getAttribute("data-id"),
item.getAttribute("data-row-key"),
item.getAttribute("data-knowledge-id"),
item.getAttribute("data-document-id")
];
return values.includes(requestedDocumentId);
}}) || null;
}}
if (!node) return;
const clickable = node.closest("button,a,[role='button'],.t-card,.document-card,.knowledge-card,.file-card,.t-list-item,.t-table__row") || node;
if (!clickable || typeof clickable.click !== "function") return;
requestedDocumentOpened = true;
clickable.click();
}}
function sourceDetailCandidateScore(node) {{
if (!node || node === document.body || node === document.documentElement) return 0;
const rect = node.getBoundingClientRect();
if (rect.width < 360 || rect.height < 260) return 0;
const text = (node.textContent || "").slice(0, 1200);
const className = String(node.className || "");
let score = 0;
if (/基本信息|上传时间|创建时间|摘要|文档内容|查看分块|预览|引用内容|Wiki 原文|全文/.test(text)) score += 4;
if (/drawer|Drawer|dialog|Dialog|modal|Modal|detail|Detail|preview|Preview/.test(className)) score += 3;
if (rect.right > window.innerWidth * 0.65 && rect.width >= window.innerWidth * 0.38) score += 2;
if (getComputedStyle(node).position === "fixed") score += 2;
return score;
}}
function findSourceDetailPanel() {{
const selectors = [
".t-drawer",
".t-drawer__content",
".t-dialog",
".t-dialog__ctx",
".document-detail",
".knowledge-detail",
".document-preview",
"[class*='Drawer']",
"[class*='drawer']",
"[class*='Dialog']",
"[class*='dialog']",
"[class*='Detail']",
"[class*='detail']",
"[class*='Preview']",
"[class*='preview']"
].join(",");
let best = null;
let bestScore = 0;
document.querySelectorAll(selectors).forEach((node) => {{
const score = sourceDetailCandidateScore(node);
if (score > bestScore) {{
best = node;
bestScore = score;
}}
}});
return bestScore >= 5 ? best : null;
}}
function isolateSourceDetailPanel() {{
if (!sourceDetailMode) return;
if (root.classList.contains("deerflow-source-detail-open")) return;
const panel = findSourceDetailPanel();
if (!panel) return;
panel.dataset.deerflowSourceDetailPanel = "true";
document.querySelectorAll("[data-deerflow-source-detail-hidden='true']").forEach((node) => {{
if (node.contains(panel) || panel.contains(node)) return;
delete node.dataset.deerflowSourceDetailHidden;
}});
Array.from(document.body.children).forEach((child) => {{
if (child === panel || child.contains(panel)) return;
child.dataset.deerflowSourceDetailHidden = "true";
}});
root.classList.add("deerflow-source-detail-open");
}}
function tickSourceDetail() {{
if (!sourceDetailMode || root.classList.contains("deerflow-source-detail-open")) return true;
sourceDetailAttempts += 1;
openRequestedDocumentId();
openRequestedWikiSlug();
isolateSourceDetailPanel();
if (root.classList.contains("deerflow-source-detail-open")) return true;
if (sourceDetailAttempts >= maxSourceDetailAttempts) {{
root.classList.add("deerflow-source-detail-open");
return true;
}}
return false;
}}
const allowedPath = cfg.allowedPath;
function allowedUrl(url) {{
try {{
@@ -1160,14 +1363,20 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
enforceLocation();
purgeGuides();
hideWeKnoraShell();
tickSourceDetail();
if (ready) return;
if (!force && (location.pathname !== allowedPath || !hasDetailShell())) return;
ready = true;
root.classList.add("deerflow-weknora-ready");
}}
let observerTimer = 0;
const readyObserver = new MutationObserver(() => {{
markReady(false);
refreshThemeSoon();
if (observerTimer) return;
observerTimer = window.setTimeout(() => {{
observerTimer = 0;
markReady(false);
refreshThemeSoon();
}}, 120);
}});
readyObserver.observe(document.documentElement, {{ childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style", "data-theme", "theme-mode"] }});
if (document.readyState === "complete") {{
@@ -1175,12 +1384,23 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
}} else {{
window.addEventListener("load", () => setTimeout(() => markReady(false), 180), {{ once: true }});
}}
setInterval(() => {{
const maintenanceTimer = setInterval(() => {{
enforceLocation();
purgeGuides();
hideWeKnoraShell();
if (tickSourceDetail() && sourceDetailMode) {{
clearInterval(maintenanceTimer);
try {{ readyObserver.disconnect(); }} catch (_) {{}}
}}
}}, 500);
setTimeout(() => markReady(true), 2200);
setTimeout(() => {{
markReady(true);
if (sourceDetailMode && !root.classList.contains("deerflow-source-detail-open")) {{
root.classList.add("deerflow-source-detail-open");
clearInterval(maintenanceTimer);
try {{ readyObserver.disconnect(); }} catch (_) {{}}
}}
}}, 9000);
}})();
</script>
"""
@@ -1236,7 +1456,7 @@ async def _proxy_weknora_request(
url = f"{target_base_url.rstrip('/')}{_encode_upstream_path(upstream_path)}"
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), follow_redirects=False) as client:
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), follow_redirects=False, trust_env=False) as client:
response = await client.request(
request.method,
url,
@@ -1244,10 +1464,13 @@ async def _proxy_weknora_request(
headers=headers,
content=body,
)
if is_api and response.status_code == 401:
if is_api and response.status_code in {401, 403}:
session = await _get_weknora_admin_session(runtime, force_refresh=True)
headers["Authorization"] = f"Bearer {session.get('token')}"
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), follow_redirects=False) as client:
tenant_id = _tenant_id_from_session(session)
if tenant_id:
headers["X-Tenant-ID"] = tenant_id
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), follow_redirects=False, trust_env=False) as client:
response = await client.request(
request.method,
url,
@@ -1258,6 +1481,7 @@ async def _proxy_weknora_request(
except httpx.TimeoutException as exc:
raise HTTPException(status_code=504, detail="WeKnora proxy request timed out") from exc
except httpx.HTTPError as exc:
logger.warning("WeKnora proxy request failed: url=%s error=%r", url, exc)
raise HTTPException(status_code=502, detail="WeKnora proxy request failed") from exc
content = response.content
@@ -1577,7 +1801,18 @@ def _wiki_page_view(page: dict[str, Any]) -> dict[str, Any] | None:
"status": str(page.get("status") or ""),
"summary": summary,
"content": content,
"aliases": list(page.get("aliases") or []) if isinstance(page.get("aliases"), list) else [],
"parent_slug": str(page.get("parent_slug") or page.get("parentSlug") or ""),
"category_path": list(page.get("category_path") or []) if isinstance(page.get("category_path"), list) else [],
"folder_id": str(page.get("folder_id") or page.get("folderId") or ""),
"wiki_path": str(page.get("wiki_path") or page.get("wikiPath") or ""),
"depth": int(page.get("depth") or 0),
"version": int(page.get("version") or 0),
"created_at": str(page.get("created_at") or ""),
"updated_at": str(page.get("updated_at") or ""),
"source_refs": list(page.get("source_refs") or []) if isinstance(page.get("source_refs"), list) else [],
"in_links": list(page.get("in_links") or []) if isinstance(page.get("in_links"), list) else [],
"out_links": list(page.get("out_links") or []) if isinstance(page.get("out_links"), list) else [],
}
@@ -1627,31 +1862,6 @@ async def _find_wiki_page_for_chunk(
if view and (view.get("content") or view.get("summary")):
return view
candidate_refs = _candidate_chunk_ref_tokens(focus, chunk_id)
query = str(focus.get("knowledge_title") or focus.get("title") or focus.get("file_name") or "").strip()
try:
page_list = await client.list_wiki_pages(knowledge_base_id, page=1, page_size=100, query=query)
except WeKnoraError as exc:
if exc.status_code != 404:
logger.debug("Failed to list WeKnora wiki pages for source preview", exc_info=True)
return None
for page in page_list.get("pages", []):
if not isinstance(page, dict):
continue
source_refs = _source_ref_tokens(page.get("source_refs"))
if not source_refs or not candidate_refs.intersection(source_refs):
continue
slug = str(page.get("slug") or "").strip()
try:
full_page = await client.get_wiki_page(knowledge_base_id, slug) if slug else page
except WeKnoraError as exc:
if exc.status_code != 404:
logger.debug("Failed to hydrate WeKnora wiki page slug=%s", slug, exc_info=True)
full_page = page
view = _wiki_page_view(full_page)
if view and (view.get("content") or view.get("summary")):
return view
return None
@@ -2187,17 +2397,37 @@ async def list_wiki_pages(
request: Request,
mapping_id: str,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=100),
page_size: int = Query(default=50, ge=1, le=500),
query: str = Query(default="", max_length=255),
) -> dict[str, Any]:
row, _, _ = await _authorized_mapping(request, mapping_id, write=False)
try:
return await _client_or_503().list_wiki_pages(
result = await _client_or_503().list_wiki_pages(
str(row["weknora_id"]),
page=page,
page_size=page_size,
query=query.strip(),
)
raw_pages = result.get("pages") if isinstance(result, dict) else []
pages = [
view
for page_item in (raw_pages if isinstance(raw_pages, list) else [])
if isinstance(page_item, dict)
for view in [_wiki_page_view(page_item)]
if view is not None
]
return {**result, "pages": pages}
except WeKnoraError as exc:
_raise_upstream(exc)
@router.post("/api/llmwiki/knowledge-bases/{mapping_id}/wiki/pages")
async def create_wiki_page(request: Request, mapping_id: str, body: WikiPageCreate) -> dict[str, Any]:
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
payload = body.model_dump(exclude_none=True)
try:
page = await _client_or_503().create_wiki_page(str(row["weknora_id"]), payload)
return _wiki_page_view(page) or page
except WeKnoraError as exc:
_raise_upstream(exc)
@@ -2206,7 +2436,34 @@ async def list_wiki_pages(
async def get_wiki_page(request: Request, mapping_id: str, slug: str) -> dict[str, Any]:
row, _, _ = await _authorized_mapping(request, mapping_id, write=False)
try:
return await _client_or_503().get_wiki_page(str(row["weknora_id"]), slug)
page = await _client_or_503().get_wiki_page(str(row["weknora_id"]), slug)
return _wiki_page_view(page) or page
except WeKnoraError as exc:
_raise_upstream(exc)
@router.patch("/api/llmwiki/knowledge-bases/{mapping_id}/wiki/pages/{slug:path}")
async def update_wiki_page(request: Request, mapping_id: str, slug: str, body: WikiPageUpdate) -> dict[str, Any]:
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
changes = body.model_dump(exclude_none=True)
if not changes:
try:
page = await _client_or_503().get_wiki_page(str(row["weknora_id"]), slug)
return _wiki_page_view(page) or page
except WeKnoraError as exc:
_raise_upstream(exc)
try:
page = await _client_or_503().update_wiki_page(str(row["weknora_id"]), slug, changes)
return _wiki_page_view(page) or page
except WeKnoraError as exc:
_raise_upstream(exc)
@router.delete("/api/llmwiki/knowledge-bases/{mapping_id}/wiki/pages/{slug:path}", status_code=204)
async def delete_wiki_page(request: Request, mapping_id: str, slug: str) -> None:
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
try:
await _client_or_503().delete_wiki_page(str(row["weknora_id"]), slug)
except WeKnoraError as exc:
_raise_upstream(exc)
@@ -2407,6 +2664,20 @@ async def get_source_context(
sanitize_deposit = _is_conversation_deposit_mapping(row)
if sanitize_deposit and wiki_page is not None:
wiki_page = _sanitize_conversation_deposit_value(wiki_page)
focus_chunk = next((chunk for chunk in chunks if str(chunk.get("id") or "") == chunk_id), {})
document_id = str(
focus_chunk.get("knowledge_id")
or focus_chunk.get("document_id")
or focus_chunk.get("knowledgeId")
or ""
)
document_title = str(
focus_chunk.get("knowledge_title")
or focus_chunk.get("title")
or focus_chunk.get("file_name")
or focus_chunk.get("filename")
or ""
)
normalized_chunks = [
{
"id": str(chunk.get("id") or ""),
@@ -2426,6 +2697,8 @@ async def get_source_context(
"data": {
"knowledge_base_id": row["id"],
"knowledge_base_name": row.get("name") or "",
"document_id": _sanitize_conversation_deposit_value(document_id) if sanitize_deposit else document_id,
"document_title": _sanitize_conversation_deposit_value(document_title) if sanitize_deposit else document_title,
"focus_chunk_id": chunk_id,
"display_mode": "wiki" if wiki_page is not None else "chunks",
"wiki_page": wiki_page,
@@ -110,6 +110,7 @@ class WeKnoraClient:
client = self._http_client or httpx.AsyncClient(
timeout=httpx.Timeout(self.timeout_seconds),
follow_redirects=False,
trust_env=False,
)
try:
response = await client.post(
@@ -164,7 +165,11 @@ class WeKnoraClient:
data: Mapping[str, Any] | None = None,
) -> Any:
owns_client = self._http_client is None
client = self._http_client or httpx.AsyncClient(timeout=httpx.Timeout(self.timeout_seconds), follow_redirects=False)
client = self._http_client or httpx.AsyncClient(
timeout=httpx.Timeout(self.timeout_seconds),
follow_redirects=False,
trust_env=False,
)
try:
for attempt in range(2):
response = await client.request(
@@ -392,6 +397,7 @@ class WeKnoraClient:
client = self._http_client or httpx.AsyncClient(
timeout=httpx.Timeout(self.timeout_seconds),
follow_redirects=True,
trust_env=False,
)
try:
for attempt in range(2):
@@ -486,6 +492,36 @@ class WeKnoraClient:
raise WeKnoraError("WeKnora returned an invalid Wiki page")
return dict(value)
async def create_wiki_page(self, knowledge_base_id: str, payload: Mapping[str, Any]) -> dict[str, Any]:
value = _data(
await self._request(
"POST",
f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/pages",
json=dict(payload),
)
)
if not isinstance(value, dict):
raise WeKnoraError("WeKnora returned an invalid Wiki page")
return dict(value)
async def update_wiki_page(self, knowledge_base_id: str, slug: str, changes: Mapping[str, Any]) -> dict[str, Any]:
safe_slug = "/".join(quote(part, safe="") for part in slug.split("/"))
value = _data(
await self._request(
"PUT",
f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/pages/{safe_slug}",
json=dict(changes),
)
)
return dict(value) if isinstance(value, dict) else {}
async def delete_wiki_page(self, knowledge_base_id: str, slug: str) -> None:
safe_slug = "/".join(quote(part, safe="") for part in slug.split("/"))
await self._request(
"DELETE",
f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/pages/{safe_slug}",
)
async def search(self, query: str, knowledge_base_ids: Sequence[str]) -> list[dict[str, Any]]:
ids = [str(value) for value in knowledge_base_ids if str(value)]
if not ids:
@@ -133,6 +133,9 @@ async def llmwiki_search_tool(
{
"knowledge_base_id": mapping["id"],
"chunk_id": visible_result.get("chunk_id") or "",
"knowledge_id": visible_result.get("knowledge_id") or "",
"document_id": visible_result.get("knowledge_id") or "",
"wiki_slug": visible_result.get("wiki_slug") or visible_result.get("slug") or "",
}
)
citations.append(
@@ -551,6 +551,39 @@ async def test_weknora_client_imports_url_manual_content_and_lists_wiki_pages()
assert pages["pages"][0]["slug"] == "guide"
@pytest.mark.asyncio
async def test_weknora_client_creates_updates_and_deletes_wiki_pages() -> None:
seen: list[tuple[str, str]] = []
async def handler(request: httpx.Request) -> httpx.Response:
seen.append((request.method, request.url.path))
if request.method == "POST":
assert request.url.path == "/api/v1/knowledgebase/kb-one/wiki/pages"
assert json.loads(request.content) == {"slug": "folder/guide", "title": "Guide", "content": "# Guide"}
return httpx.Response(200, json={"success": True, "data": {"slug": "folder/guide", "title": "Guide"}})
assert request.url.path == "/api/v1/knowledgebase/kb-one/wiki/pages/folder/guide"
if request.method == "PUT":
assert json.loads(request.content) == {"title": "Guide", "content": "# Guide"}
return httpx.Response(200, json={"success": True, "data": {"slug": "folder/guide", "title": "Guide"}})
if request.method == "DELETE":
return httpx.Response(204)
return httpx.Response(405)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
client = WeKnoraClient(base_url="http://weknora.local:8080", http_client=http_client)
created = await client.create_wiki_page("kb-one", {"slug": "folder/guide", "title": "Guide", "content": "# Guide"})
updated = await client.update_wiki_page("kb-one", "folder/guide", {"title": "Guide", "content": "# Guide"})
await client.delete_wiki_page("kb-one", "folder/guide")
assert created["slug"] == "folder/guide"
assert updated["slug"] == "folder/guide"
assert seen == [
("POST", "/api/v1/knowledgebase/kb-one/wiki/pages"),
("PUT", "/api/v1/knowledgebase/kb-one/wiki/pages/folder/guide"),
("DELETE", "/api/v1/knowledgebase/kb-one/wiki/pages/folder/guide"),
]
@pytest.mark.asyncio
async def test_weknora_client_reads_document_chunks_and_wiki_graph() -> None:
async def handler(request: httpx.Request) -> httpx.Response:
+54
View File
@@ -0,0 +1,54 @@
param(
[int]$TimeoutSeconds = 90,
[switch]$OpenBrowser
)
. "$PSScriptRoot\env.ps1"
function Wait-HttpReady {
param(
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $true)]
[string]$Url,
[Parameter(Mandatory = $true)]
[datetime]$Deadline
)
Write-Host "Waiting for ${Name}: $Url"
while ((Get-Date) -lt $Deadline) {
try {
$response = Invoke-WebRequest -UseBasicParsing -Uri $Url -TimeoutSec 2
if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 500) {
Write-Host "$Name is ready: $Url"
return
}
} catch {
Start-Sleep -Milliseconds 700
}
}
throw "Timed out waiting for ${Name}: $Url"
}
& "$PSScriptRoot\start-all.ps1"
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
Wait-HttpReady -Name "backend" -Url "$BackendPublicBaseUrl/api/health" -Deadline $deadline
Wait-HttpReady -Name "frontend" -Url "$FrontendPublicBaseUrl/" -Deadline $deadline
Write-Host ""
Write-Host "DeerFlow is ready."
Write-Host "Frontend: $FrontendPublicBaseUrl/"
Write-Host "Backend: $BackendPublicBaseUrl"
Write-Host ""
Write-Host "Logs:"
Write-Host " Backend: $(Join-Path $LogDir 'backend.log')"
Write-Host " Frontend: $(Join-Path $LogDir 'frontend.log')"
if ($OpenBrowser) {
Start-Process "$FrontendPublicBaseUrl/"
}