feat: improve WeKnora source preview and wiki management
This commit is contained in:
1 parent
94cf37a2ae
commit
3ab2c9b282
19 files changed
+2596
-297
No files matched your search
Vendored
+1
-1
@@ -47,7 +47,7 @@
|
|||||||
"name": "DeerFlow: Open Frontend in Chrome",
|
"name": "DeerFlow: Open Frontend in Chrome",
|
||||||
"type": "node-terminal",
|
"type": "node-terminal",
|
||||||
"request": "launch",
|
"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/'\"",
|
"command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"Start-Process 'http://127.0.0.1:5174/'\"",
|
||||||
"cwd": "${workspaceFolder}"
|
"cwd": "${workspaceFolder}"
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+23
@@ -1,6 +1,29 @@
|
|||||||
{
|
{
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"tasks": [
|
"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",
|
"label": "DeerFlow: Start Full Stack and Wait",
|
||||||
"type": "process",
|
"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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -9,12 +9,12 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||||
import { apiFetch } from "@/core/api/fetch-client";
|
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
|
||||||
import { useLocalSettings } from "@/core/settings";
|
import { useLocalSettings } from "@/core/settings";
|
||||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
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({
|
export function SkillDisplaySources({
|
||||||
highlightedReferenceId,
|
highlightedReferenceId,
|
||||||
@@ -52,12 +52,14 @@ export function SourceCard({
|
|||||||
source: RuntimeDisplaySource;
|
source: RuntimeDisplaySource;
|
||||||
referenceTarget?: boolean;
|
referenceTarget?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [relatedOpen, setRelatedOpen] = useState(false);
|
||||||
const [context, setContext] = useState<WeKnoraSourceContext | null>(null);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
const [contextLoading, setContextLoading] = useState(false);
|
|
||||||
const [contextError, setContextError] = useState<string | null>(null);
|
|
||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||||
|
const previewTarget = resolveWeKnoraSourceTarget({
|
||||||
|
...(source.raw || {}),
|
||||||
|
url: source.url,
|
||||||
|
});
|
||||||
const cleanupWords =
|
const cleanupWords =
|
||||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||||
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
||||||
@@ -78,57 +80,27 @@ export function SourceCard({
|
|||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
const isCitation = source.mode === "citation";
|
const isCitation = source.mode === "citation";
|
||||||
const showReferenceNumber = isCitation;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<div
|
||||||
id={referenceTarget ? `reference-${source.index}` : undefined}
|
id={referenceTarget ? `reference-${source.index}` : undefined}
|
||||||
type="button"
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
className={cn(
|
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",
|
"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",
|
"target:border-amber-300 target:bg-amber-50/80",
|
||||||
highlighted &&
|
highlighted &&
|
||||||
"border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]",
|
"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">
|
<div className="flex items-start gap-3">
|
||||||
{showReferenceNumber && (
|
{showReferenceNumber && (
|
||||||
@@ -158,18 +130,43 @@ export function SourceCard({
|
|||||||
</div>
|
</div>
|
||||||
{source.url && (
|
{source.url && (
|
||||||
previewTarget ? (
|
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" />
|
<FileTextIcon className="size-3.5" />
|
||||||
查看原文
|
知识库文档
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
{previewTarget && (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<div className="mt-3 flex justify-start border-t pt-2">
|
||||||
<DialogContent className="max-w-2xl">
|
<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>
|
<DialogHeader>
|
||||||
<div className="flex items-start justify-between gap-3 pr-8">
|
<div className="flex items-start justify-between gap-3 pr-8">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -205,13 +202,7 @@ export function SourceCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{previewTarget ? (
|
{paragraphs.length > 0 ? (
|
||||||
<WeKnoraSourceContextPanel
|
|
||||||
context={context}
|
|
||||||
loading={contextLoading}
|
|
||||||
error={contextError}
|
|
||||||
/>
|
|
||||||
) : paragraphs.length > 0 ? (
|
|
||||||
<div className="text-foreground max-h-[55vh] space-y-3 overflow-y-auto text-sm leading-relaxed">
|
<div className="text-foreground max-h-[55vh] space-y-3 overflow-y-auto text-sm leading-relaxed">
|
||||||
{paragraphs.map((paragraph, index) => (
|
{paragraphs.map((paragraph, index) => (
|
||||||
<p key={index} className="whitespace-pre-wrap">
|
<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(
|
function cleanReferenceText(
|
||||||
raw: string | undefined,
|
raw: string | undefined,
|
||||||
cleanupWords: string[],
|
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;
|
||||||
|
}
|
||||||
@@ -12,13 +12,16 @@ import ReactDOM from "react-dom/client";
|
|||||||
|
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import { syncEmbedSessionFromCurrentUrl } from "@/core/embed/embed-session";
|
import { syncEmbedSessionFromCurrentUrl } from "@/core/embed/embed-session";
|
||||||
|
import { normalizeMalformedWeKnoraHashRoute } from "@/core/llmwiki/source-links";
|
||||||
|
|
||||||
// 渲染前同步启用「iframe 嵌入会话」(按标签页 sessionStorage):必须早于任何鉴权写入,
|
if (!normalizeMalformedWeKnoraHashRoute()) {
|
||||||
// 使 iframe 嵌入态的登录走 sessionStorage、与同浏览器正常登录(localStorage)完全隔离。
|
// 渲染前同步启用「iframe 嵌入会话」(按标签页 sessionStorage):必须早于任何鉴权写入,
|
||||||
syncEmbedSessionFromCurrentUrl();
|
// 使 iframe 嵌入态的登录走 sessionStorage、与同浏览器正常登录(localStorage)完全隔离。
|
||||||
|
syncEmbedSessionFromCurrentUrl();
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ import AdminSkillsPage from './AdminSkillsPage';
|
|||||||
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
|
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
|
||||||
import ScheduledTasksPage from './ScheduledTasksPage';
|
import ScheduledTasksPage from './ScheduledTasksPage';
|
||||||
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
|
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
|
||||||
|
import WeKnoraWikiManagementPage from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage';
|
||||||
import LlmMetricsPage from './LlmMetricsPage';
|
import LlmMetricsPage from './LlmMetricsPage';
|
||||||
import CheckpointMigrationPage from './CheckpointMigrationPage';
|
import CheckpointMigrationPage from './CheckpointMigrationPage';
|
||||||
import MemoryPage from './MemoryPage';
|
import MemoryPage from './MemoryPage';
|
||||||
@@ -616,6 +617,14 @@ export default function WorkspaceRoutes({
|
|||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="knowledge/:noteId/wiki"
|
||||||
|
element={
|
||||||
|
<WorkspaceLayout {...layoutProps}>
|
||||||
|
<WeKnoraWikiManagementPage />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="knowledge/:noteId"
|
path="knowledge/:noteId"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ export interface LlmWikiWikiPage {
|
|||||||
aliases: string[];
|
aliases: string[];
|
||||||
parent_slug?: string;
|
parent_slug?: string;
|
||||||
category_path?: string[];
|
category_path?: string[];
|
||||||
|
folder_id?: string;
|
||||||
|
wiki_path?: string;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
source_refs: string[];
|
source_refs: string[];
|
||||||
in_links: string[];
|
in_links: string[];
|
||||||
@@ -141,6 +143,21 @@ export interface LlmWikiWikiPageList {
|
|||||||
total_pages: number;
|
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 {
|
export interface LlmWikiSearchResult {
|
||||||
chunk_id: string;
|
chunk_id: string;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -159,6 +176,33 @@ export interface LlmWikiWeKnoraEmbedSession {
|
|||||||
expires_in: number;
|
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> {
|
async function readJson<T>(response: Response, context: string): Promise<T> {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const payload = await response.json().catch(() => null as unknown);
|
const payload = await response.json().catch(() => null as unknown);
|
||||||
@@ -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: {
|
export async function createLlmWikiKnowledgeBase(input: {
|
||||||
name: string;
|
name: string;
|
||||||
description: 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(
|
export async function uploadLlmWikiDocument(
|
||||||
knowledgeBaseId: string,
|
knowledgeBaseId: string,
|
||||||
file: File,
|
file: File,
|
||||||
@@ -698,6 +807,21 @@ export function useLlmWikiActions() {
|
|||||||
reprocessLlmWikiDocument(id, documentId),
|
reprocessLlmWikiDocument(id, documentId),
|
||||||
onSuccess: invalidateKnowledge,
|
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({
|
publication: useMutation({
|
||||||
mutationFn: ({ id, publish }: { id: string; publish: boolean }) =>
|
mutationFn: ({ id, publish }: { id: string; publish: boolean }) =>
|
||||||
setLlmWikiPublication(id, publish),
|
setLlmWikiPublication(id, publish),
|
||||||
|
|||||||
@@ -310,11 +310,29 @@ export const createMockSingleRagMessages = (): Message[] => ([
|
|||||||
/**
|
/**
|
||||||
* 将多轮检索接口返回的 references 项映射为通用引用来源。
|
* 将多轮检索接口返回的 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 || '未命名来源',
|
title: item.title || '未命名来源',
|
||||||
type: '参考资料',
|
type: '参考资料',
|
||||||
snippet: item.snippet || item.content || '',
|
snippet: item.snippet || item.content || '',
|
||||||
content: item.content || item.snippet || '',
|
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 || '',
|
source: item.source || item.title || '',
|
||||||
time: '',
|
time: '',
|
||||||
url: item.url,
|
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 || '',
|
content: item.content || item.snippet || '',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ import {
|
|||||||
import { requestCrudService } from '@/service/request-axios-service';
|
import { requestCrudService } from '@/service/request-axios-service';
|
||||||
import { authStorageGet } from '@/core/auth/scoped-storage';
|
import { authStorageGet } from '@/core/auth/scoped-storage';
|
||||||
import { copyTextToClipboard } from '@/core/clipboard';
|
import { copyTextToClipboard } from '@/core/clipboard';
|
||||||
|
import { openKnowledgeSource, resolveWeKnoraSourceTarget, type WeKnoraSourceTarget } from '@/core/llmwiki/source-links';
|
||||||
import { readRuntimeEnv } from '@/env';
|
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 chatUrl = readRuntimeEnv('VITE_AIPI_CHAT_URL') || '';
|
||||||
const generateUrl = readRuntimeEnv('VITE_AIPI_GENERATE_CHAT_URL') || '';
|
const generateUrl = readRuntimeEnv('VITE_AIPI_GENERATE_CHAT_URL') || '';
|
||||||
const taskChatUrl = readRuntimeEnv('VITE_AIPI_TASK_CHAT_URL') || '';
|
const taskChatUrl = readRuntimeEnv('VITE_AIPI_TASK_CHAT_URL') || '';
|
||||||
const knowledgeUrl = readRuntimeEnv('VITE_AIPI_KNOWLEDGE_URL');
|
|
||||||
const service = requestCrudService;
|
const service = requestCrudService;
|
||||||
const GeneralQA: React.FC<{
|
const GeneralQA: React.FC<{
|
||||||
onNavigate?: (page: string) => void;
|
onNavigate?: (page: string) => void;
|
||||||
@@ -110,6 +111,10 @@ const GeneralQA: React.FC<{
|
|||||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||||
// 当前选中的引用来源,用于显示引用详情弹窗
|
// 当前选中的引用来源,用于显示引用详情弹窗
|
||||||
const [selectedCitation, setSelectedCitation] = useState<SourceItem | null>(null);
|
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 [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
||||||
const [editingThinking, setEditingThinking] = useState('');
|
const [editingThinking, setEditingThinking] = useState('');
|
||||||
const [editingAnswer, setEditingAnswer] = useState('');
|
const [editingAnswer, setEditingAnswer] = useState('');
|
||||||
@@ -384,6 +389,13 @@ const GeneralQA: React.FC<{
|
|||||||
source: item.source1,
|
source: item.source1,
|
||||||
time: item.m_publish,
|
time: item.m_publish,
|
||||||
url: item.recUuid,
|
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 || '',
|
content: item.m_content || '',
|
||||||
}));
|
}));
|
||||||
// 更新检索统计信息
|
// 更新检索统计信息
|
||||||
@@ -2380,12 +2392,22 @@ const GeneralQA: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedCitation.url && (
|
{(selectedCitation.url || resolveWeKnoraSourceTarget(selectedCitation)) && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<a
|
<button
|
||||||
href={`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${selectedCitation.url}`}
|
type="button"
|
||||||
target="_blank"
|
onClick={() => {
|
||||||
rel="noreferrer"
|
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
|
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-slate-600 text-slate-200 hover:bg-slate-800'
|
||||||
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
|
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||||
@@ -2394,7 +2416,7 @@ const GeneralQA: React.FC<{
|
|||||||
>
|
>
|
||||||
<ExternalLink className="w-4 h-4" />
|
<ExternalLink className="w-4 h-4" />
|
||||||
查看原文链接
|
查看原文链接
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2406,6 +2428,15 @@ const GeneralQA: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<WeKnoraSourceDrawer
|
||||||
|
open={!!weknoraDrawer}
|
||||||
|
target={weknoraDrawer?.target ?? null}
|
||||||
|
title={weknoraDrawer?.title}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (!nextOpen) setWeknoraDrawer(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
visible={isModelSettingsOpen}
|
visible={isModelSettingsOpen}
|
||||||
header="模型参数设置"
|
header="模型参数设置"
|
||||||
|
|||||||
@@ -14,6 +14,21 @@ export interface SourceItem {
|
|||||||
source?: string;
|
source?: string;
|
||||||
time?: string;
|
time?: string;
|
||||||
url?: 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;
|
keywords?: string;
|
||||||
taskDirection?: string;
|
taskDirection?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTheme } from '@/contexts/ThemeContext';
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
|
import { openKnowledgeSource, resolveWeKnoraSourceTarget, type WeKnoraSourceTarget } from '@/core/llmwiki/source-links';
|
||||||
import MarkdownRenderer from '@/strategy-components/MarkdownRenderer';
|
import MarkdownRenderer from '@/strategy-components/MarkdownRenderer';
|
||||||
import { SourceItem } from './GeneralQA.types';
|
import { SourceItem } from './GeneralQA.types';
|
||||||
import '../qa/qa-animations.css';
|
import '../qa/qa-animations.css';
|
||||||
import { readRuntimeEnv } from '@/env';
|
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 isDark = theme === 'dark';
|
||||||
const isUser = message.role === 'user';
|
const isUser = message.role === 'user';
|
||||||
const hasTrace = !isUser && !!(message.singleRagTrace || message.thinkingTrace);
|
const hasTrace = !isUser && !!(message.singleRagTrace || message.thinkingTrace);
|
||||||
const knowledgeUrl = readRuntimeEnv('VITE_AIPI_KNOWLEDGE_URL');
|
|
||||||
const [visible, setVisible] = useState(false); // 打开详情对话框
|
const [visible, setVisible] = useState(false); // 打开详情对话框
|
||||||
const [listData, setListData] = useState<SourceItem>({
|
const [listData, setListData] = useState<SourceItem>({
|
||||||
title: '',
|
title: '',
|
||||||
@@ -252,10 +253,17 @@ export function ChatMessage<T extends QACommonMessage>({
|
|||||||
source: '',
|
source: '',
|
||||||
content: '',
|
content: '',
|
||||||
});
|
});
|
||||||
|
const [weknoraDrawer, setWeknoraDrawer] = useState<{
|
||||||
|
target: WeKnoraSourceTarget;
|
||||||
|
title: string;
|
||||||
|
} | null>(null);
|
||||||
const showDialog = (item: SourceItem) => {
|
const showDialog = (item: SourceItem) => {
|
||||||
console.log('item', item)
|
console.log('item', item)
|
||||||
if (item.url) {
|
const weknoraTarget = resolveWeKnoraSourceTarget(item);
|
||||||
window.open(`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${item.url}`);
|
if (weknoraTarget) {
|
||||||
|
setWeknoraDrawer({ target: weknoraTarget, title: item.title || '参考文献原文' });
|
||||||
|
} else if (item.url) {
|
||||||
|
openKnowledgeSource(item);
|
||||||
} else {
|
} else {
|
||||||
setListData(item);
|
setListData(item);
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
@@ -361,12 +369,19 @@ export function ChatMessage<T extends QACommonMessage>({
|
|||||||
<div>时间:{listData.time || '未知'}</div>
|
<div>时间:{listData.time || '未知'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{listData.url && (
|
{(listData.url || resolveWeKnoraSourceTarget(listData)) && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<a
|
<button
|
||||||
href={`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${listData.url}`}
|
type="button"
|
||||||
target="_blank"
|
onClick={() => {
|
||||||
rel="noreferrer"
|
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
|
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-slate-600 text-slate-200 hover:bg-slate-800'
|
||||||
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
|
: '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" />
|
<ExternalLink className="w-4 h-4" />
|
||||||
查看原文链接
|
查看原文链接
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</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'
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
<WeKnoraSourceDrawer
|
||||||
|
open={!!weknoraDrawer}
|
||||||
|
target={weknoraDrawer?.target ?? null}
|
||||||
|
title={weknoraDrawer?.title}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (!nextOpen) setWeknoraDrawer(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{!isUser && renderAssistantExtra?.(message)}
|
{!isUser && renderAssistantExtra?.(message)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+229
-22
@@ -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 { 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 { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
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 { getBackendBaseURL } from "@/core/config";
|
||||||
import {
|
import {
|
||||||
createLlmWikiWeKnoraEmbedSession,
|
createLlmWikiWeKnoraEmbedSession,
|
||||||
|
getLlmWikiSourceContext,
|
||||||
useLlmWikiActions,
|
useLlmWikiActions,
|
||||||
useLlmWikiKnowledgeBase,
|
useLlmWikiKnowledgeBase,
|
||||||
|
useLlmWikiWikiPage,
|
||||||
useLlmWikiRuntime,
|
useLlmWikiRuntime,
|
||||||
} from "@/strategy-components/api/llmwiki";
|
} from "@/strategy-components/api/llmwiki";
|
||||||
|
|
||||||
@@ -86,10 +96,12 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
noteId: workspaceKnowledgeBaseId,
|
noteId: workspaceKnowledgeBaseId,
|
||||||
} = useParams();
|
} = useParams();
|
||||||
const knowledgeBaseId = strategyKnowledgeBaseId ?? workspaceKnowledgeBaseId ?? "";
|
const knowledgeBaseId = strategyKnowledgeBaseId ?? workspaceKnowledgeBaseId ?? "";
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [frameSrc, setFrameSrc] = useState("");
|
const [frameSrc, setFrameSrc] = useState("");
|
||||||
|
const [frameVersion, setFrameVersion] = useState(0);
|
||||||
const [frameError, setFrameError] = useState<string | null>(null);
|
const [frameError, setFrameError] = useState<string | null>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const runtime = useLlmWikiRuntime();
|
const runtime = useLlmWikiRuntime();
|
||||||
@@ -97,7 +109,16 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
const actions = useLlmWikiActions();
|
const actions = useLlmWikiActions();
|
||||||
|
|
||||||
const detailsReady = !!details.data;
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -109,14 +130,24 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
void createLlmWikiWeKnoraEmbedSession(knowledgeBaseId, preferredTab)
|
void (async () => {
|
||||||
.then((session) => {
|
const resolvedWikiSlug =
|
||||||
|
wikiSlug ||
|
||||||
|
(sourceChunkId
|
||||||
|
? (await getLlmWikiSourceContext(knowledgeBaseId, sourceChunkId).catch(
|
||||||
|
() => null,
|
||||||
|
))?.wiki_page?.slug
|
||||||
|
: "");
|
||||||
|
const session = await createLlmWikiWeKnoraEmbedSession(knowledgeBaseId, preferredTab);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const framePath = session.frame_path || "";
|
const framePath = session.frame_path || "";
|
||||||
const nextUrl = buildFrameUrl(framePath);
|
const nextUrl = buildFrameUrl(framePath);
|
||||||
nextUrl.searchParams.set("theme", collectThemePayload().theme);
|
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());
|
setFrameSrc(nextUrl.toString());
|
||||||
})
|
})()
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const message = error instanceof Error ? error.message : "创建 WeKnora 详情会话失败";
|
const message = error instanceof Error ? error.message : "创建 WeKnora 详情会话失败";
|
||||||
@@ -126,7 +157,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [detailsReady, knowledgeBaseId, preferredTab, runtime.data?.provider]);
|
}, [detailsReady, documentId, knowledgeBaseId, preferredTab, runtime.data?.provider, sourceChunkId, wikiSlug]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!frameSrc) return;
|
if (!frameSrc) return;
|
||||||
@@ -194,11 +225,14 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canUpload = details.data.can_write && !details.data.is_conversation_deposit;
|
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 (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||||
{canUpload && (
|
{(canUpload || canEditCurrentWikiArticle) && (
|
||||||
<div className="flex h-12 shrink-0 items-center justify-end border-b px-4">
|
<div className="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-2">
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -222,7 +256,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
const succeeded = files.length - failed.length;
|
const succeeded = files.length - failed.length;
|
||||||
if (succeeded > 0) {
|
if (succeeded > 0) {
|
||||||
await details.refetch();
|
await details.refetch();
|
||||||
if (frameRef.current) frameRef.current.src = frameSrc;
|
setFrameVersion((value) => value + 1);
|
||||||
}
|
}
|
||||||
if (failed.length === 0) {
|
if (failed.length === 0) {
|
||||||
toast.success(`已上传 ${succeeded} 个文件,正在处理`);
|
toast.success(`已上传 ${succeeded} 个文件,正在处理`);
|
||||||
@@ -237,23 +271,53 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
})();
|
})();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<div className="min-w-0 flex-1">
|
||||||
size="sm"
|
{canEditCurrentWikiArticle && (
|
||||||
onClick={() => fileInputRef.current?.click()}
|
<CurrentWikiArticleActions
|
||||||
disabled={uploading}
|
knowledgeBaseId={knowledgeBaseId}
|
||||||
>
|
slug={wikiSlug}
|
||||||
{uploading ? (
|
onChanged={() => setFrameVersion((value) => value + 1)}
|
||||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
onDeleted={() => {
|
||||||
) : (
|
navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}?tab=wiki`, {
|
||||||
<Upload className="mr-2 size-4" />
|
replace: true,
|
||||||
|
});
|
||||||
|
setFrameVersion((value) => value + 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
上传文件
|
</div>
|
||||||
</Button>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{frameSrc && (
|
{frameSrc && (
|
||||||
<iframe
|
<iframe
|
||||||
key={frameSrc}
|
key={`${frameSrc}:${frameVersion}`}
|
||||||
ref={frameRef}
|
ref={frameRef}
|
||||||
title={`${details.data.name || "WeKnora"} 详情`}
|
title={`${details.data.name || "WeKnora"} 详情`}
|
||||||
src={frameSrc}
|
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;
|
export default WeKnoraKnowledgeDetailPage;
|
||||||
+710
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+765
@@ -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)
|
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):
|
class ConversationDepositCreate(BaseModel):
|
||||||
thread_id: str = Field(..., min_length=1, max_length=128)
|
thread_id: str = Field(..., min_length=1, max_length=128)
|
||||||
question: str = Field(default="", max_length=200_000)
|
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
|
return cached
|
||||||
|
|
||||||
try:
|
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(
|
response = await client.post(
|
||||||
f"{runtime.api_base_url}/api/v1/auth/login",
|
f"{runtime.api_base_url}/api/v1/auth/login",
|
||||||
json={"email": runtime.admin_email, "password": runtime.admin_password},
|
json={"email": runtime.admin_email, "password": runtime.admin_password},
|
||||||
headers={"Accept": "application/json"},
|
headers={"Accept": "application/json"},
|
||||||
)
|
)
|
||||||
except httpx.TimeoutException as exc:
|
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
|
raise HTTPException(status_code=504, detail="WeKnora admin login timed out") from exc
|
||||||
except httpx.HTTPError as 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
|
raise HTTPException(status_code=502, detail="WeKnora admin login is unavailable") from exc
|
||||||
|
|
||||||
if response.status_code >= 400:
|
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]:
|
async def _admin_json(runtime: Any, path: str, *, token: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
try:
|
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(
|
response = await client.get(
|
||||||
f"{runtime.api_base_url}{_encode_upstream_path(path)}",
|
f"{runtime.api_base_url}{_encode_upstream_path(path)}",
|
||||||
params=params,
|
params=params,
|
||||||
headers={"Accept": "application/json", "Authorization": f"Bearer {token}"},
|
headers={"Accept": "application/json", "Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
except httpx.HTTPError as exc:
|
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
|
raise HTTPException(status_code=502, detail="WeKnora verification request failed") from exc
|
||||||
if response.status_code == 404:
|
if response.status_code == 404:
|
||||||
raise HTTPException(status_code=404, detail="WeKnora resource was not found")
|
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:
|
if len(tail) == 1:
|
||||||
raise HTTPException(status_code=403, detail="Global WeKnora knowledge collection is not allowed")
|
raise HTTPException(status_code=403, detail="Global WeKnora knowledge collection is not allowed")
|
||||||
action = tail[1]
|
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 action in {"batch", "batch-delete", "batch-reparse", "folder", "move", "tags"}:
|
||||||
if _request_kb_id(request, json_body) != ctx.weknora_id:
|
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")
|
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,
|
"tenantId": tenant_id,
|
||||||
"user": user,
|
"user": user,
|
||||||
"tenant": tenant,
|
"tenant": tenant,
|
||||||
|
"weknoraId": ctx.weknora_id,
|
||||||
"publicPrefix": public_prefix,
|
"publicPrefix": public_prefix,
|
||||||
"apiPrefix": _with_public_prefix(public_prefix, _WEKNORA_EMBED_API_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__"] {{
|
html.deerflow-weknora-embed [class*=" guide__"] {{
|
||||||
display: none !important;
|
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>
|
</style>
|
||||||
<script id="deerflow-weknora-embed-guard">
|
<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;
|
const allowedPath = cfg.allowedPath;
|
||||||
function allowedUrl(url) {{
|
function allowedUrl(url) {{
|
||||||
try {{
|
try {{
|
||||||
@@ -1160,14 +1363,20 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
|
|||||||
enforceLocation();
|
enforceLocation();
|
||||||
purgeGuides();
|
purgeGuides();
|
||||||
hideWeKnoraShell();
|
hideWeKnoraShell();
|
||||||
|
tickSourceDetail();
|
||||||
if (ready) return;
|
if (ready) return;
|
||||||
if (!force && (location.pathname !== allowedPath || !hasDetailShell())) return;
|
if (!force && (location.pathname !== allowedPath || !hasDetailShell())) return;
|
||||||
ready = true;
|
ready = true;
|
||||||
root.classList.add("deerflow-weknora-ready");
|
root.classList.add("deerflow-weknora-ready");
|
||||||
}}
|
}}
|
||||||
|
let observerTimer = 0;
|
||||||
const readyObserver = new MutationObserver(() => {{
|
const readyObserver = new MutationObserver(() => {{
|
||||||
markReady(false);
|
if (observerTimer) return;
|
||||||
refreshThemeSoon();
|
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"] }});
|
readyObserver.observe(document.documentElement, {{ childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style", "data-theme", "theme-mode"] }});
|
||||||
if (document.readyState === "complete") {{
|
if (document.readyState === "complete") {{
|
||||||
@@ -1175,12 +1384,23 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
|
|||||||
}} else {{
|
}} else {{
|
||||||
window.addEventListener("load", () => setTimeout(() => markReady(false), 180), {{ once: true }});
|
window.addEventListener("load", () => setTimeout(() => markReady(false), 180), {{ once: true }});
|
||||||
}}
|
}}
|
||||||
setInterval(() => {{
|
const maintenanceTimer = setInterval(() => {{
|
||||||
enforceLocation();
|
enforceLocation();
|
||||||
purgeGuides();
|
purgeGuides();
|
||||||
hideWeKnoraShell();
|
hideWeKnoraShell();
|
||||||
|
if (tickSourceDetail() && sourceDetailMode) {{
|
||||||
|
clearInterval(maintenanceTimer);
|
||||||
|
try {{ readyObserver.disconnect(); }} catch (_) {{}}
|
||||||
|
}}
|
||||||
}}, 500);
|
}}, 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>
|
</script>
|
||||||
"""
|
"""
|
||||||
@@ -1236,7 +1456,7 @@ async def _proxy_weknora_request(
|
|||||||
|
|
||||||
url = f"{target_base_url.rstrip('/')}{_encode_upstream_path(upstream_path)}"
|
url = f"{target_base_url.rstrip('/')}{_encode_upstream_path(upstream_path)}"
|
||||||
try:
|
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(
|
response = await client.request(
|
||||||
request.method,
|
request.method,
|
||||||
url,
|
url,
|
||||||
@@ -1244,10 +1464,13 @@ async def _proxy_weknora_request(
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
content=body,
|
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)
|
session = await _get_weknora_admin_session(runtime, force_refresh=True)
|
||||||
headers["Authorization"] = f"Bearer {session.get('token')}"
|
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(
|
response = await client.request(
|
||||||
request.method,
|
request.method,
|
||||||
url,
|
url,
|
||||||
@@ -1258,6 +1481,7 @@ async def _proxy_weknora_request(
|
|||||||
except httpx.TimeoutException as exc:
|
except httpx.TimeoutException as exc:
|
||||||
raise HTTPException(status_code=504, detail="WeKnora proxy request timed out") from exc
|
raise HTTPException(status_code=504, detail="WeKnora proxy request timed out") from exc
|
||||||
except httpx.HTTPError as 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
|
raise HTTPException(status_code=502, detail="WeKnora proxy request failed") from exc
|
||||||
|
|
||||||
content = response.content
|
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 ""),
|
"status": str(page.get("status") or ""),
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"content": content,
|
"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 [],
|
"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")):
|
if view and (view.get("content") or view.get("summary")):
|
||||||
return view
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -2187,17 +2397,37 @@ async def list_wiki_pages(
|
|||||||
request: Request,
|
request: Request,
|
||||||
mapping_id: str,
|
mapping_id: str,
|
||||||
page: int = Query(default=1, ge=1),
|
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),
|
query: str = Query(default="", max_length=255),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
row, _, _ = await _authorized_mapping(request, mapping_id, write=False)
|
row, _, _ = await _authorized_mapping(request, mapping_id, write=False)
|
||||||
try:
|
try:
|
||||||
return await _client_or_503().list_wiki_pages(
|
result = await _client_or_503().list_wiki_pages(
|
||||||
str(row["weknora_id"]),
|
str(row["weknora_id"]),
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
query=query.strip(),
|
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:
|
except WeKnoraError as exc:
|
||||||
_raise_upstream(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]:
|
async def get_wiki_page(request: Request, mapping_id: str, slug: str) -> dict[str, Any]:
|
||||||
row, _, _ = await _authorized_mapping(request, mapping_id, write=False)
|
row, _, _ = await _authorized_mapping(request, mapping_id, write=False)
|
||||||
try:
|
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:
|
except WeKnoraError as exc:
|
||||||
_raise_upstream(exc)
|
_raise_upstream(exc)
|
||||||
|
|
||||||
@@ -2407,6 +2664,20 @@ async def get_source_context(
|
|||||||
sanitize_deposit = _is_conversation_deposit_mapping(row)
|
sanitize_deposit = _is_conversation_deposit_mapping(row)
|
||||||
if sanitize_deposit and wiki_page is not None:
|
if sanitize_deposit and wiki_page is not None:
|
||||||
wiki_page = _sanitize_conversation_deposit_value(wiki_page)
|
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 = [
|
normalized_chunks = [
|
||||||
{
|
{
|
||||||
"id": str(chunk.get("id") or ""),
|
"id": str(chunk.get("id") or ""),
|
||||||
@@ -2426,6 +2697,8 @@ async def get_source_context(
|
|||||||
"data": {
|
"data": {
|
||||||
"knowledge_base_id": row["id"],
|
"knowledge_base_id": row["id"],
|
||||||
"knowledge_base_name": row.get("name") or "",
|
"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,
|
"focus_chunk_id": chunk_id,
|
||||||
"display_mode": "wiki" if wiki_page is not None else "chunks",
|
"display_mode": "wiki" if wiki_page is not None else "chunks",
|
||||||
"wiki_page": wiki_page,
|
"wiki_page": wiki_page,
|
||||||
|
|||||||
+37
-1
@@ -110,6 +110,7 @@ class WeKnoraClient:
|
|||||||
client = self._http_client or httpx.AsyncClient(
|
client = self._http_client or httpx.AsyncClient(
|
||||||
timeout=httpx.Timeout(self.timeout_seconds),
|
timeout=httpx.Timeout(self.timeout_seconds),
|
||||||
follow_redirects=False,
|
follow_redirects=False,
|
||||||
|
trust_env=False,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
@@ -164,7 +165,11 @@ class WeKnoraClient:
|
|||||||
data: Mapping[str, Any] | None = None,
|
data: Mapping[str, Any] | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
owns_client = self._http_client is None
|
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:
|
try:
|
||||||
for attempt in range(2):
|
for attempt in range(2):
|
||||||
response = await client.request(
|
response = await client.request(
|
||||||
@@ -392,6 +397,7 @@ class WeKnoraClient:
|
|||||||
client = self._http_client or httpx.AsyncClient(
|
client = self._http_client or httpx.AsyncClient(
|
||||||
timeout=httpx.Timeout(self.timeout_seconds),
|
timeout=httpx.Timeout(self.timeout_seconds),
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
|
trust_env=False,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
for attempt in range(2):
|
for attempt in range(2):
|
||||||
@@ -486,6 +492,36 @@ class WeKnoraClient:
|
|||||||
raise WeKnoraError("WeKnora returned an invalid Wiki page")
|
raise WeKnoraError("WeKnora returned an invalid Wiki page")
|
||||||
return dict(value)
|
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]]:
|
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)]
|
ids = [str(value) for value in knowledge_base_ids if str(value)]
|
||||||
if not ids:
|
if not ids:
|
||||||
|
|||||||
+3
@@ -133,6 +133,9 @@ async def llmwiki_search_tool(
|
|||||||
{
|
{
|
||||||
"knowledge_base_id": mapping["id"],
|
"knowledge_base_id": mapping["id"],
|
||||||
"chunk_id": visible_result.get("chunk_id") or "",
|
"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(
|
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"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_weknora_client_reads_document_chunks_and_wiki_graph() -> None:
|
async def test_weknora_client_reads_document_chunks_and_wiki_graph() -> None:
|
||||||
async def handler(request: httpx.Request) -> httpx.Response:
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
|||||||
@@ -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/"
|
||||||
|
}
|
||||||
Reference in new issue
Block a user