Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-nj
This commit is contained in:
commit
819e537aa3
28 files changed
+2838
-355
No files matched your search
Vendored
+1
-1
@@ -47,7 +47,7 @@
|
||||
"name": "DeerFlow: Open Frontend in Chrome",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "DeerFlow: Start Full Stack and Wait",
|
||||
"preLaunchTask": "DeerFlow: VS Code Start",
|
||||
"command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"Start-Process 'http://127.0.0.1:5174/'\"",
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
|
||||
Vendored
+23
@@ -1,6 +1,29 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "DeerFlow: VS Code Start",
|
||||
"type": "process",
|
||||
"command": "powershell.exe",
|
||||
"args": [
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
"${workspaceFolder}/scripts/vscode-start.ps1",
|
||||
"-OpenBrowser"
|
||||
],
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"clear": true
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "DeerFlow: Start Full Stack and Wait",
|
||||
"type": "process",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ExternalLinkIcon, FileTextIcon, LoaderCircle } from "lucide-react";
|
||||
import { ExternalLinkIcon, FileTextIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
||||
|
||||
export function SkillDisplaySources({
|
||||
highlightedReferenceId,
|
||||
@@ -52,12 +52,14 @@ export function SourceCard({
|
||||
source: RuntimeDisplaySource;
|
||||
referenceTarget?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [context, setContext] = useState<WeKnoraSourceContext | null>(null);
|
||||
const [contextLoading, setContextLoading] = useState(false);
|
||||
const [contextError, setContextError] = useState<string | null>(null);
|
||||
const [relatedOpen, setRelatedOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [settings] = useLocalSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const previewTarget = resolveWeKnoraSourceTarget({
|
||||
...(source.raw || {}),
|
||||
url: source.url,
|
||||
});
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
||||
@@ -78,57 +80,27 @@ export function SourceCard({
|
||||
].filter(Boolean);
|
||||
const isCitation = source.mode === "citation";
|
||||
const showReferenceNumber = isCitation;
|
||||
const previewTarget = parseWeKnoraPreviewUrl(source.url);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !previewTarget) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setContext(null);
|
||||
setContextError(null);
|
||||
setContextLoading(true);
|
||||
const params = new URLSearchParams({
|
||||
knowledge_base_id: previewTarget.knowledgeBaseId,
|
||||
chunk_id: previewTarget.chunkId,
|
||||
});
|
||||
void apiFetch(`${getBackendBaseURL()}/api/llmwiki/sources/context?${params}`)
|
||||
.then(async (response) => {
|
||||
const payload = (await response.json().catch(() => null)) as unknown;
|
||||
if (!response.ok || !isWeKnoraSourceContextResponse(payload)) {
|
||||
throw new Error(readPreviewError(payload, response.status));
|
||||
}
|
||||
if (!cancelled) {
|
||||
setContext(payload.data);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!cancelled) {
|
||||
setContextError(error instanceof Error ? error.message : "无法加载原文上下文");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setContextLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
<div
|
||||
id={referenceTarget ? `reference-${source.index}` : undefined}
|
||||
type="button"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"group bg-card/60 hover:bg-card hover:border-primary/40 focus-visible:ring-primary/30 w-full scroll-mt-12 rounded-xl border px-3.5 py-3 text-left shadow-sm transition-all focus-visible:ring-2 focus-visible:outline-none",
|
||||
"target:border-amber-300 target:bg-amber-50/80",
|
||||
highlighted &&
|
||||
"border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]",
|
||||
"cursor-pointer",
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
onClick={() => setRelatedOpen(true)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setRelatedOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{showReferenceNumber && (
|
||||
@@ -158,18 +130,43 @@ export function SourceCard({
|
||||
</div>
|
||||
{source.url && (
|
||||
previewTarget ? (
|
||||
<span className="text-primary bg-primary/10 mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium">
|
||||
<span className="text-muted-foreground bg-muted mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium">
|
||||
<FileTextIcon className="size-3.5" />
|
||||
查看原文
|
||||
知识库文档
|
||||
</span>
|
||||
) : (
|
||||
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
{previewTarget && (
|
||||
<div className="mt-3 flex justify-start border-t pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-primary h-7 px-1.5 text-xs"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setDetailOpen(true);
|
||||
}}
|
||||
>
|
||||
<ExternalLinkIcon className="mr-1.5 size-3.5" />
|
||||
查看详情
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{previewTarget && (
|
||||
<WeKnoraSourceDrawer
|
||||
open={detailOpen}
|
||||
target={previewTarget}
|
||||
title={source.title}
|
||||
quote={content}
|
||||
onOpenChange={setDetailOpen}
|
||||
/>
|
||||
)}
|
||||
<Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
|
||||
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between gap-3 pr-8">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -205,13 +202,7 @@ export function SourceCard({
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{previewTarget ? (
|
||||
<WeKnoraSourceContextPanel
|
||||
context={context}
|
||||
loading={contextLoading}
|
||||
error={contextError}
|
||||
/>
|
||||
) : paragraphs.length > 0 ? (
|
||||
{paragraphs.length > 0 ? (
|
||||
<div className="text-foreground max-h-[55vh] space-y-3 overflow-y-auto text-sm leading-relaxed">
|
||||
{paragraphs.map((paragraph, index) => (
|
||||
<p key={index} className="whitespace-pre-wrap">
|
||||
@@ -231,156 +222,6 @@ export function SourceCard({
|
||||
);
|
||||
}
|
||||
|
||||
type WeKnoraPreviewTarget = {
|
||||
knowledgeBaseId: string;
|
||||
chunkId: string;
|
||||
};
|
||||
|
||||
type WeKnoraSourceContext = {
|
||||
knowledge_base_name?: string;
|
||||
document_title?: string;
|
||||
document_file_name?: string;
|
||||
display_mode?: "wiki" | "chunks";
|
||||
focus_chunk_id: string;
|
||||
wiki_page?: {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
title?: string;
|
||||
page_type?: string;
|
||||
status?: string;
|
||||
summary?: string;
|
||||
content?: string;
|
||||
source_refs?: string[];
|
||||
} | null;
|
||||
chunks: Array<{
|
||||
id: string;
|
||||
chunk_index?: number;
|
||||
content: string;
|
||||
is_focus: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
function parseWeKnoraPreviewUrl(url?: string): WeKnoraPreviewTarget | null {
|
||||
if (!url?.startsWith("/weknora-source-preview?")) {
|
||||
return null;
|
||||
}
|
||||
const queryIndex = url.indexOf("?");
|
||||
const params = new URLSearchParams(url.slice(queryIndex + 1));
|
||||
const knowledgeBaseId = params.get("knowledge_base_id")?.trim();
|
||||
const chunkId = params.get("chunk_id")?.trim();
|
||||
return knowledgeBaseId && chunkId ? { knowledgeBaseId, chunkId } : null;
|
||||
}
|
||||
|
||||
function isWeKnoraSourceContextResponse(
|
||||
value: unknown,
|
||||
): value is { success: true; data: WeKnoraSourceContext } {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const data = (value as { data?: unknown }).data;
|
||||
return (
|
||||
(value as { success?: unknown }).success === true &&
|
||||
!!data &&
|
||||
typeof data === "object" &&
|
||||
Array.isArray((data as WeKnoraSourceContext).chunks)
|
||||
);
|
||||
}
|
||||
|
||||
function readPreviewError(payload: unknown, status: number): string {
|
||||
if (payload && typeof payload === "object") {
|
||||
const detail = (payload as { detail?: unknown }).detail;
|
||||
if (typeof detail === "string" && detail) {
|
||||
return detail;
|
||||
}
|
||||
}
|
||||
return status === 404 ? "原文已不可用" : "无法加载原文上下文";
|
||||
}
|
||||
|
||||
function WeKnoraSourceContextPanel({
|
||||
context,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
context: WeKnoraSourceContext | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex min-h-36 items-center justify-center gap-2 text-sm">
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
正在加载原文上下文…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="border-destructive/30 bg-destructive/5 text-destructive rounded-lg border p-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
const wikiPage = context.wiki_page;
|
||||
const wikiContent = cleanReferenceText(
|
||||
[wikiPage?.summary, wikiPage?.content].filter(Boolean).join("\n\n"),
|
||||
[],
|
||||
);
|
||||
if (wikiPage && wikiContent) {
|
||||
return (
|
||||
<div className="max-h-[55vh] space-y-3 overflow-y-auto pr-1">
|
||||
<div className="bg-muted/60 text-muted-foreground rounded-lg px-3 py-2 text-xs">
|
||||
<span>{context.knowledge_base_name || "WeKnora 知识库"}</span>
|
||||
<span> · LLMWiki</span>
|
||||
{wikiPage.page_type && <span> · {wikiPage.page_type}</span>}
|
||||
</div>
|
||||
<section className="border-primary/40 bg-primary/5 rounded-lg border p-3 text-sm leading-relaxed shadow-sm">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{wikiPage.title || wikiPage.slug || "LLMWiki 页面"}
|
||||
</span>
|
||||
<span className="bg-primary text-primary-foreground rounded px-1.5 py-0.5 text-xs font-medium">
|
||||
清洗页
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3 whitespace-pre-wrap">{wikiContent}</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="max-h-[55vh] space-y-3 overflow-y-auto pr-1">
|
||||
<div className="bg-muted/60 text-muted-foreground rounded-lg px-3 py-2 text-xs">
|
||||
<span>{context.knowledge_base_name || "WeKnora 知识库"}</span>
|
||||
{context.document_title && <span> · {context.document_title}</span>}
|
||||
</div>
|
||||
{context.chunks.map((chunk) => (
|
||||
<section
|
||||
key={chunk.id}
|
||||
className={cn(
|
||||
"rounded-lg border p-3 text-sm leading-relaxed",
|
||||
chunk.is_focus
|
||||
? "border-primary/50 bg-primary/5 shadow-sm"
|
||||
: "bg-muted/20",
|
||||
)}
|
||||
>
|
||||
<div className="text-muted-foreground mb-2 flex items-center gap-2 text-xs">
|
||||
<span>分段 {typeof chunk.chunk_index === "number" ? chunk.chunk_index + 1 : "-"}</span>
|
||||
{chunk.is_focus && (
|
||||
<span className="bg-primary text-primary-foreground rounded px-1.5 py-0.5 font-medium">
|
||||
检索命中
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap">{chunk.content}</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function cleanReferenceText(
|
||||
raw: string | undefined,
|
||||
cleanupWords: string[],
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { readRuntimeEnv } from "@/env";
|
||||
|
||||
export type WeKnoraSourceTarget = {
|
||||
knowledgeBaseId: string;
|
||||
chunkId?: string;
|
||||
documentId?: string;
|
||||
wikiSlug?: string;
|
||||
tab?: "documents" | "wiki" | "graph";
|
||||
};
|
||||
|
||||
export type KnowledgeSourceLike = {
|
||||
url?: string | null;
|
||||
recUuid?: string | null;
|
||||
rec_uuid?: string | null;
|
||||
knowledge_base_id?: string | null;
|
||||
knowledgeBaseId?: string | null;
|
||||
mapping_id?: string | null;
|
||||
mappingId?: string | null;
|
||||
chunk_id?: string | null;
|
||||
chunkId?: string | null;
|
||||
knowledge_id?: string | null;
|
||||
knowledgeId?: string | null;
|
||||
document_id?: string | null;
|
||||
documentId?: string | null;
|
||||
wiki_slug?: string | null;
|
||||
wikiSlug?: string | null;
|
||||
slug?: string | null;
|
||||
};
|
||||
|
||||
export function parseWeKnoraPreviewUrl(url?: string | null): WeKnoraSourceTarget | null {
|
||||
if (!url?.startsWith("/weknora-source-preview?")) {
|
||||
return null;
|
||||
}
|
||||
const queryIndex = url.indexOf("?");
|
||||
const params = new URLSearchParams(url.slice(queryIndex + 1));
|
||||
const knowledgeBaseId = params.get("knowledge_base_id")?.trim();
|
||||
const chunkId = params.get("chunk_id")?.trim();
|
||||
const documentId =
|
||||
params.get("document_id")?.trim() ||
|
||||
params.get("knowledge_id")?.trim() ||
|
||||
"";
|
||||
const wikiSlug =
|
||||
params.get("wiki_slug")?.trim() ||
|
||||
params.get("wikiSlug")?.trim() ||
|
||||
params.get("slug")?.trim() ||
|
||||
"";
|
||||
return knowledgeBaseId && chunkId
|
||||
? {
|
||||
knowledgeBaseId,
|
||||
chunkId,
|
||||
documentId: documentId || undefined,
|
||||
wikiSlug: wikiSlug || undefined,
|
||||
tab: wikiSlug ? "wiki" : "documents",
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
export function resolveWeKnoraSourceTarget(source: KnowledgeSourceLike): WeKnoraSourceTarget | null {
|
||||
const fromPreview = parseWeKnoraPreviewUrl(source.url);
|
||||
const knowledgeBaseId =
|
||||
source.knowledge_base_id ||
|
||||
source.knowledgeBaseId ||
|
||||
source.mapping_id ||
|
||||
source.mappingId ||
|
||||
fromPreview?.knowledgeBaseId ||
|
||||
"";
|
||||
if (!knowledgeBaseId) return null;
|
||||
|
||||
const wikiSlug = source.wiki_slug || source.wikiSlug || source.slug || fromPreview?.wikiSlug || "";
|
||||
const documentId =
|
||||
source.document_id ||
|
||||
source.documentId ||
|
||||
source.knowledge_id ||
|
||||
source.knowledgeId ||
|
||||
fromPreview?.documentId ||
|
||||
"";
|
||||
const chunkId = source.chunk_id || source.chunkId || fromPreview?.chunkId || "";
|
||||
return {
|
||||
knowledgeBaseId,
|
||||
chunkId: chunkId || undefined,
|
||||
documentId: documentId || undefined,
|
||||
wikiSlug: wikiSlug || undefined,
|
||||
tab: wikiSlug ? "wiki" : "documents",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWeKnoraSourceIframeUrl(target: WeKnoraSourceTarget): string {
|
||||
const params = new URLSearchParams();
|
||||
if (target.tab) params.set("tab", target.tab);
|
||||
if (target.chunkId) params.set("sourceChunkId", target.chunkId);
|
||||
if (target.documentId) params.set("documentId", target.documentId);
|
||||
if (target.wikiSlug) params.set("wikiSlug", target.wikiSlug);
|
||||
const query = params.toString();
|
||||
return `/#/page/workspace/knowledge/${encodeURIComponent(target.knowledgeBaseId)}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
export function buildLegacyKnowledgeSourceUrl(source: KnowledgeSourceLike): string {
|
||||
const recUuid = source.recUuid || source.rec_uuid || source.url || "";
|
||||
if (!recUuid) return "";
|
||||
if (/^https?:\/\//i.test(recUuid)) return recUuid;
|
||||
const base = readRuntimeEnv("VITE_AIPI_KNOWLEDGE_URL");
|
||||
if (base) {
|
||||
return `${base.replace(/\/+$/, "")}/#/knowledge/mid-detail?recUuid=${encodeURIComponent(recUuid)}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function openKnowledgeSource(source: KnowledgeSourceLike): boolean {
|
||||
const weknoraTarget = resolveWeKnoraSourceTarget(source);
|
||||
const href = weknoraTarget
|
||||
? buildWeKnoraSourceIframeUrl(weknoraTarget)
|
||||
: buildLegacyKnowledgeSourceUrl(source);
|
||||
if (!href) return false;
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
return true;
|
||||
}
|
||||
|
||||
export function normalizeMalformedWeKnoraHashRoute(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
const { pathname, search, hash } = window.location;
|
||||
if (!pathname.startsWith("/page/workspace/knowledge/")) return false;
|
||||
if (hash && !hash.startsWith("#/")) return false;
|
||||
window.location.replace(`/#${pathname}${search}`);
|
||||
return true;
|
||||
}
|
||||
+15
-12
@@ -10,15 +10,18 @@ import "katex/dist/katex.min.css";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import { App } from "./App";
|
||||
import { syncEmbedSessionFromCurrentUrl } from "@/core/embed/embed-session";
|
||||
|
||||
// 渲染前同步启用「iframe 嵌入会话」(按标签页 sessionStorage):必须早于任何鉴权写入,
|
||||
// 使 iframe 嵌入态的登录走 sessionStorage、与同浏览器正常登录(localStorage)完全隔离。
|
||||
syncEmbedSessionFromCurrentUrl();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
import { App } from "./App";
|
||||
import { syncEmbedSessionFromCurrentUrl } from "@/core/embed/embed-session";
|
||||
import { normalizeMalformedWeKnoraHashRoute } from "@/core/llmwiki/source-links";
|
||||
|
||||
if (!normalizeMalformedWeKnoraHashRoute()) {
|
||||
// 渲染前同步启用「iframe 嵌入会话」(按标签页 sessionStorage):必须早于任何鉴权写入,
|
||||
// 使 iframe 嵌入态的登录走 sessionStorage、与同浏览器正常登录(localStorage)完全隔离。
|
||||
syncEmbedSessionFromCurrentUrl();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import AdminSkillsPage from './AdminSkillsPage';
|
||||
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
|
||||
import ScheduledTasksPage from './ScheduledTasksPage';
|
||||
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
|
||||
import WeKnoraWikiManagementPage from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage';
|
||||
import LlmMetricsPage from './LlmMetricsPage';
|
||||
import CheckpointMigrationPage from './CheckpointMigrationPage';
|
||||
import MemoryPage from './MemoryPage';
|
||||
@@ -656,6 +657,14 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="knowledge/:noteId/wiki"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<WeKnoraWikiManagementPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="knowledge/:noteId"
|
||||
element={
|
||||
|
||||
@@ -124,6 +124,8 @@ export interface LlmWikiWikiPage {
|
||||
aliases: string[];
|
||||
parent_slug?: string;
|
||||
category_path?: string[];
|
||||
folder_id?: string;
|
||||
wiki_path?: string;
|
||||
depth?: number;
|
||||
source_refs: string[];
|
||||
in_links: string[];
|
||||
@@ -141,6 +143,21 @@ export interface LlmWikiWikiPageList {
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface LlmWikiSourceContext {
|
||||
knowledge_base_name?: string;
|
||||
document_title?: string;
|
||||
document_file_name?: string;
|
||||
display_mode?: "wiki" | "chunks";
|
||||
focus_chunk_id: string;
|
||||
wiki_page?: LlmWikiWikiPage | null;
|
||||
chunks: Array<{
|
||||
id: string;
|
||||
chunk_index?: number;
|
||||
content: string;
|
||||
is_focus: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LlmWikiSearchResult {
|
||||
chunk_id: string;
|
||||
content: string;
|
||||
@@ -159,6 +176,33 @@ export interface LlmWikiWeKnoraEmbedSession {
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
export interface LlmWikiWikiPageCreate {
|
||||
slug: string;
|
||||
title: string;
|
||||
content: string;
|
||||
summary?: string;
|
||||
page_type?: string;
|
||||
status?: string;
|
||||
aliases?: string[];
|
||||
parent_slug?: string;
|
||||
category_path?: string[];
|
||||
folder_id?: string;
|
||||
wiki_path?: string;
|
||||
}
|
||||
|
||||
export interface LlmWikiWikiPageUpdate {
|
||||
title?: string;
|
||||
content?: string;
|
||||
summary?: string;
|
||||
page_type?: string;
|
||||
status?: string;
|
||||
aliases?: string[];
|
||||
parent_slug?: string;
|
||||
category_path?: string[];
|
||||
folder_id?: string;
|
||||
wiki_path?: string;
|
||||
}
|
||||
|
||||
async function readJson<T>(response: Response, context: string): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null as unknown);
|
||||
@@ -226,6 +270,21 @@ export async function createLlmWikiWeKnoraEmbedSession(
|
||||
);
|
||||
}
|
||||
|
||||
export async function getLlmWikiSourceContext(
|
||||
knowledgeBaseId: string,
|
||||
chunkId: string,
|
||||
): Promise<LlmWikiSourceContext> {
|
||||
const params = new URLSearchParams({
|
||||
knowledge_base_id: knowledgeBaseId,
|
||||
chunk_id: chunkId,
|
||||
});
|
||||
const payload = await readJson<{ success: boolean; data: LlmWikiSourceContext }>(
|
||||
await apiFetch(`${API()}/sources/context?${params}`),
|
||||
"加载参考文献原文",
|
||||
);
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
export async function createLlmWikiKnowledgeBase(input: {
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -353,6 +412,56 @@ export async function getLlmWikiWikiPage(
|
||||
);
|
||||
}
|
||||
|
||||
export async function createLlmWikiWikiPage(
|
||||
knowledgeBaseId: string,
|
||||
input: LlmWikiWikiPageCreate,
|
||||
): Promise<LlmWikiWikiPage> {
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/pages`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
),
|
||||
"创建 Wiki 页面",
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateLlmWikiWikiPage(
|
||||
knowledgeBaseId: string,
|
||||
slug: string,
|
||||
input: LlmWikiWikiPageUpdate,
|
||||
): Promise<LlmWikiWikiPage> {
|
||||
const safeSlug = slug.split("/").map(encodeURIComponent).join("/");
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/pages/${safeSlug}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
),
|
||||
"鏇存柊 Wiki 椤甸潰",
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteLlmWikiWikiPage(
|
||||
knowledgeBaseId: string,
|
||||
slug: string,
|
||||
): Promise<void> {
|
||||
const safeSlug = slug.split("/").map(encodeURIComponent).join("/");
|
||||
await readJson<void>(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/pages/${safeSlug}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
"鍒犻櫎 Wiki 椤甸潰",
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadLlmWikiDocument(
|
||||
knowledgeBaseId: string,
|
||||
file: File,
|
||||
@@ -698,6 +807,21 @@ export function useLlmWikiActions() {
|
||||
reprocessLlmWikiDocument(id, documentId),
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
createWikiPage: useMutation({
|
||||
mutationFn: ({ id, input }: { id: string; input: LlmWikiWikiPageCreate }) =>
|
||||
createLlmWikiWikiPage(id, input),
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
updateWikiPage: useMutation({
|
||||
mutationFn: ({ id, slug, input }: { id: string; slug: string; input: LlmWikiWikiPageUpdate }) =>
|
||||
updateLlmWikiWikiPage(id, slug, input),
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
removeWikiPage: useMutation({
|
||||
mutationFn: ({ id, slug }: { id: string; slug: string }) =>
|
||||
deleteLlmWikiWikiPage(id, slug),
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
publication: useMutation({
|
||||
mutationFn: ({ id, publish }: { id: string; publish: boolean }) =>
|
||||
setLlmWikiPublication(id, publish),
|
||||
|
||||
@@ -310,11 +310,29 @@ export const createMockSingleRagMessages = (): Message[] => ([
|
||||
/**
|
||||
* 将多轮检索接口返回的 references 项映射为通用引用来源。
|
||||
*/
|
||||
export const mapReferenceToSource = (item: { title?: string; content?: string; snippet?: string }): SourceItem => ({
|
||||
export const mapReferenceToSource = (item: {
|
||||
title?: string;
|
||||
content?: string;
|
||||
snippet?: string;
|
||||
url?: string;
|
||||
knowledge_base_id?: string;
|
||||
knowledge_id?: string;
|
||||
document_id?: string;
|
||||
chunk_id?: string;
|
||||
wiki_slug?: string;
|
||||
slug?: string;
|
||||
}): SourceItem => ({
|
||||
title: item.title || '未命名来源',
|
||||
type: '参考资料',
|
||||
snippet: item.snippet || item.content || '',
|
||||
content: item.content || item.snippet || '',
|
||||
url: item.url,
|
||||
knowledge_base_id: item.knowledge_base_id,
|
||||
knowledge_id: item.knowledge_id,
|
||||
document_id: item.document_id,
|
||||
chunk_id: item.chunk_id,
|
||||
wiki_slug: item.wiki_slug,
|
||||
slug: item.slug,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -343,6 +361,12 @@ export const mapDocsToSources = (docs: KnowledgeBaseChunk['docs']): SourceItem[]
|
||||
source: item.source || item.title || '',
|
||||
time: '',
|
||||
url: item.url,
|
||||
knowledge_base_id: (item as any).knowledge_base_id,
|
||||
knowledge_id: (item as any).knowledge_id,
|
||||
document_id: (item as any).document_id,
|
||||
chunk_id: (item as any).chunk_id,
|
||||
wiki_slug: (item as any).wiki_slug,
|
||||
slug: (item as any).slug,
|
||||
content: item.content || item.snippet || '',
|
||||
};
|
||||
});
|
||||
@@ -447,4 +471,4 @@ export const platformTypeChange = (platform: string) => {
|
||||
platformType = 'facebook';
|
||||
}
|
||||
return platformType;
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,9 @@ import {
|
||||
import { requestCrudService } from '@/service/request-axios-service';
|
||||
import { authStorageGet } from '@/core/auth/scoped-storage';
|
||||
import { copyTextToClipboard } from '@/core/clipboard';
|
||||
import { openKnowledgeSource, resolveWeKnoraSourceTarget, type WeKnoraSourceTarget } from '@/core/llmwiki/source-links';
|
||||
import { readRuntimeEnv } from '@/env';
|
||||
import { WeKnoraSourceDrawer } from '@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer';
|
||||
|
||||
/**
|
||||
* 通用问答组件
|
||||
@@ -43,7 +45,6 @@ const zncmUrl = readRuntimeEnv('VITE_API_BASE_URL') || '';
|
||||
const chatUrl = readRuntimeEnv('VITE_AIPI_CHAT_URL') || '';
|
||||
const generateUrl = readRuntimeEnv('VITE_AIPI_GENERATE_CHAT_URL') || '';
|
||||
const taskChatUrl = readRuntimeEnv('VITE_AIPI_TASK_CHAT_URL') || '';
|
||||
const knowledgeUrl = readRuntimeEnv('VITE_AIPI_KNOWLEDGE_URL');
|
||||
const service = requestCrudService;
|
||||
const GeneralQA: React.FC<{
|
||||
onNavigate?: (page: string) => void;
|
||||
@@ -110,6 +111,10 @@ const GeneralQA: React.FC<{
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||
// 当前选中的引用来源,用于显示引用详情弹窗
|
||||
const [selectedCitation, setSelectedCitation] = useState<SourceItem | null>(null);
|
||||
const [weknoraDrawer, setWeknoraDrawer] = useState<{
|
||||
target: WeKnoraSourceTarget;
|
||||
title: string;
|
||||
} | null>(null);
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
||||
const [editingThinking, setEditingThinking] = useState('');
|
||||
const [editingAnswer, setEditingAnswer] = useState('');
|
||||
@@ -384,6 +389,13 @@ const GeneralQA: React.FC<{
|
||||
source: item.source1,
|
||||
time: item.m_publish,
|
||||
url: item.recUuid,
|
||||
recUuid: item.recUuid,
|
||||
knowledge_base_id: item.knowledge_base_id,
|
||||
knowledge_id: item.knowledge_id,
|
||||
document_id: item.document_id,
|
||||
chunk_id: item.chunk_id,
|
||||
wiki_slug: item.wiki_slug,
|
||||
slug: item.slug,
|
||||
content: item.m_content || '',
|
||||
}));
|
||||
// 更新检索统计信息
|
||||
@@ -2380,12 +2392,22 @@ const GeneralQA: React.FC<{
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedCitation.url && (
|
||||
{(selectedCitation.url || resolveWeKnoraSourceTarget(selectedCitation)) && (
|
||||
<div className="mt-4">
|
||||
<a
|
||||
href={`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${selectedCitation.url}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const weknoraTarget = resolveWeKnoraSourceTarget(selectedCitation);
|
||||
if (weknoraTarget) {
|
||||
setWeknoraDrawer({
|
||||
target: weknoraTarget,
|
||||
title: selectedCitation.title || '参考文献原文',
|
||||
});
|
||||
setSelectedCitation(null);
|
||||
} else {
|
||||
void openKnowledgeSource(selectedCitation);
|
||||
}
|
||||
}}
|
||||
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm transition-all ${isDark
|
||||
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
|
||||
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
@@ -2394,7 +2416,7 @@ const GeneralQA: React.FC<{
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
查看原文链接
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2406,6 +2428,15 @@ const GeneralQA: React.FC<{
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WeKnoraSourceDrawer
|
||||
open={!!weknoraDrawer}
|
||||
target={weknoraDrawer?.target ?? null}
|
||||
title={weknoraDrawer?.title}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) setWeknoraDrawer(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
visible={isModelSettingsOpen}
|
||||
header="模型参数设置"
|
||||
|
||||
@@ -14,6 +14,21 @@ export interface SourceItem {
|
||||
source?: string;
|
||||
time?: string;
|
||||
url?: string;
|
||||
recUuid?: string;
|
||||
rec_uuid?: string;
|
||||
knowledge_base_id?: string;
|
||||
knowledgeBaseId?: string;
|
||||
mapping_id?: string;
|
||||
mappingId?: string;
|
||||
knowledge_id?: string;
|
||||
knowledgeId?: string;
|
||||
document_id?: string;
|
||||
documentId?: string;
|
||||
chunk_id?: string;
|
||||
chunkId?: string;
|
||||
wiki_slug?: string;
|
||||
wikiSlug?: string;
|
||||
slug?: string;
|
||||
keywords?: string;
|
||||
taskDirection?: string;
|
||||
content?: string;
|
||||
@@ -97,4 +112,4 @@ export interface qaModeItem {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
desc: string;
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,12 @@ import {
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { openKnowledgeSource, resolveWeKnoraSourceTarget, type WeKnoraSourceTarget } from '@/core/llmwiki/source-links';
|
||||
import MarkdownRenderer from '@/strategy-components/MarkdownRenderer';
|
||||
import { SourceItem } from './GeneralQA.types';
|
||||
import '../qa/qa-animations.css';
|
||||
import { readRuntimeEnv } from '@/env';
|
||||
import { WeKnoraSourceDrawer } from '@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer';
|
||||
|
||||
/**
|
||||
* 单条检索结果。
|
||||
@@ -242,7 +244,6 @@ export function ChatMessage<T extends QACommonMessage>({
|
||||
const isDark = theme === 'dark';
|
||||
const isUser = message.role === 'user';
|
||||
const hasTrace = !isUser && !!(message.singleRagTrace || message.thinkingTrace);
|
||||
const knowledgeUrl = readRuntimeEnv('VITE_AIPI_KNOWLEDGE_URL');
|
||||
const [visible, setVisible] = useState(false); // 打开详情对话框
|
||||
const [listData, setListData] = useState<SourceItem>({
|
||||
title: '',
|
||||
@@ -252,10 +253,17 @@ export function ChatMessage<T extends QACommonMessage>({
|
||||
source: '',
|
||||
content: '',
|
||||
});
|
||||
const [weknoraDrawer, setWeknoraDrawer] = useState<{
|
||||
target: WeKnoraSourceTarget;
|
||||
title: string;
|
||||
} | null>(null);
|
||||
const showDialog = (item: SourceItem) => {
|
||||
console.log('item', item)
|
||||
if (item.url) {
|
||||
window.open(`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${item.url}`);
|
||||
const weknoraTarget = resolveWeKnoraSourceTarget(item);
|
||||
if (weknoraTarget) {
|
||||
setWeknoraDrawer({ target: weknoraTarget, title: item.title || '参考文献原文' });
|
||||
} else if (item.url) {
|
||||
openKnowledgeSource(item);
|
||||
} else {
|
||||
setListData(item);
|
||||
setVisible(true);
|
||||
@@ -361,12 +369,19 @@ export function ChatMessage<T extends QACommonMessage>({
|
||||
<div>时间:{listData.time || '未知'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{listData.url && (
|
||||
{(listData.url || resolveWeKnoraSourceTarget(listData)) && (
|
||||
<div className="mt-4">
|
||||
<a
|
||||
href={`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${listData.url}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const weknoraTarget = resolveWeKnoraSourceTarget(listData);
|
||||
if (weknoraTarget) {
|
||||
setWeknoraDrawer({ target: weknoraTarget, title: listData.title || '参考文献原文' });
|
||||
setVisible(false);
|
||||
} else {
|
||||
void openKnowledgeSource(listData);
|
||||
}
|
||||
}}
|
||||
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm transition-all ${isDark
|
||||
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
|
||||
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
@@ -375,7 +390,7 @@ export function ChatMessage<T extends QACommonMessage>({
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
查看原文链接
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className={`mt-4 rounded-xl p-3 text-sm leading-relaxed whitespace-pre-wrap ${isDark ? 'bg-slate-800/70 text-slate-200' : 'bg-gray-50 text-gray-700'
|
||||
@@ -384,6 +399,14 @@ export function ChatMessage<T extends QACommonMessage>({
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
<WeKnoraSourceDrawer
|
||||
open={!!weknoraDrawer}
|
||||
target={weknoraDrawer?.target ?? null}
|
||||
title={weknoraDrawer?.title}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) setWeknoraDrawer(null);
|
||||
}}
|
||||
/>
|
||||
{!isUser && renderAssistantExtra?.(message)}
|
||||
|
||||
</div>
|
||||
|
||||
+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 { Navigate, useNavigate, useParams } from "react-router-dom";
|
||||
import { Navigate, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import {
|
||||
createLlmWikiWeKnoraEmbedSession,
|
||||
getLlmWikiSourceContext,
|
||||
useLlmWikiActions,
|
||||
useLlmWikiKnowledgeBase,
|
||||
useLlmWikiWikiPage,
|
||||
useLlmWikiRuntime,
|
||||
} from "@/strategy-components/api/llmwiki";
|
||||
|
||||
@@ -86,10 +96,12 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
noteId: workspaceKnowledgeBaseId,
|
||||
} = useParams();
|
||||
const knowledgeBaseId = strategyKnowledgeBaseId ?? workspaceKnowledgeBaseId ?? "";
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [frameSrc, setFrameSrc] = useState("");
|
||||
const [frameVersion, setFrameVersion] = useState(0);
|
||||
const [frameError, setFrameError] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const runtime = useLlmWikiRuntime();
|
||||
@@ -97,7 +109,16 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
const actions = useLlmWikiActions();
|
||||
|
||||
const detailsReady = !!details.data;
|
||||
const preferredTab = details.data?.indexing_strategy?.wiki_enabled ? "wiki" : "documents";
|
||||
const requestedTab = searchParams.get("tab");
|
||||
const preferredTab =
|
||||
requestedTab === "documents" || requestedTab === "wiki" || requestedTab === "graph"
|
||||
? requestedTab
|
||||
: details.data?.indexing_strategy?.wiki_enabled
|
||||
? "wiki"
|
||||
: "documents";
|
||||
const sourceChunkId = searchParams.get("sourceChunkId") || searchParams.get("chunkId") || "";
|
||||
const documentId = searchParams.get("documentId") || "";
|
||||
const wikiSlug = searchParams.get("wikiSlug") || "";
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -109,14 +130,24 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
};
|
||||
}
|
||||
|
||||
void createLlmWikiWeKnoraEmbedSession(knowledgeBaseId, preferredTab)
|
||||
.then((session) => {
|
||||
void (async () => {
|
||||
const resolvedWikiSlug =
|
||||
wikiSlug ||
|
||||
(sourceChunkId
|
||||
? (await getLlmWikiSourceContext(knowledgeBaseId, sourceChunkId).catch(
|
||||
() => null,
|
||||
))?.wiki_page?.slug
|
||||
: "");
|
||||
const session = await createLlmWikiWeKnoraEmbedSession(knowledgeBaseId, preferredTab);
|
||||
if (cancelled) return;
|
||||
const framePath = session.frame_path || "";
|
||||
const nextUrl = buildFrameUrl(framePath);
|
||||
nextUrl.searchParams.set("theme", collectThemePayload().theme);
|
||||
if (sourceChunkId) nextUrl.searchParams.set("sourceChunkId", sourceChunkId);
|
||||
if (documentId) nextUrl.searchParams.set("documentId", documentId);
|
||||
if (resolvedWikiSlug) nextUrl.searchParams.set("wikiSlug", resolvedWikiSlug);
|
||||
setFrameSrc(nextUrl.toString());
|
||||
})
|
||||
})()
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
const message = error instanceof Error ? error.message : "创建 WeKnora 详情会话失败";
|
||||
@@ -126,7 +157,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [detailsReady, knowledgeBaseId, preferredTab, runtime.data?.provider]);
|
||||
}, [detailsReady, documentId, knowledgeBaseId, preferredTab, runtime.data?.provider, sourceChunkId, wikiSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!frameSrc) return;
|
||||
@@ -194,11 +225,14 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
}
|
||||
|
||||
const canUpload = details.data.can_write && !details.data.is_conversation_deposit;
|
||||
const canEditCurrentWikiArticle =
|
||||
canUpload && !!details.data.indexing_strategy?.wiki_enabled && !!wikiSlug;
|
||||
const canManageWiki = canUpload && !!details.data.indexing_strategy?.wiki_enabled;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
{canUpload && (
|
||||
<div className="flex h-12 shrink-0 items-center justify-end border-b px-4">
|
||||
{(canUpload || canEditCurrentWikiArticle) && (
|
||||
<div className="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -222,7 +256,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
const succeeded = files.length - failed.length;
|
||||
if (succeeded > 0) {
|
||||
await details.refetch();
|
||||
if (frameRef.current) frameRef.current.src = frameSrc;
|
||||
setFrameVersion((value) => value + 1);
|
||||
}
|
||||
if (failed.length === 0) {
|
||||
toast.success(`已上传 ${succeeded} 个文件,正在处理`);
|
||||
@@ -237,23 +271,53 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
})();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 size-4" />
|
||||
<div className="min-w-0 flex-1">
|
||||
{canEditCurrentWikiArticle && (
|
||||
<CurrentWikiArticleActions
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
slug={wikiSlug}
|
||||
onChanged={() => setFrameVersion((value) => value + 1)}
|
||||
onDeleted={() => {
|
||||
navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}?tab=wiki`, {
|
||||
replace: true,
|
||||
});
|
||||
setFrameVersion((value) => value + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
上传文件
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{canManageWiki && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki`)}
|
||||
>
|
||||
<FileText className="mr-2 size-4" />
|
||||
Wiki管理
|
||||
</Button>
|
||||
)}
|
||||
{canUpload && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 size-4" />
|
||||
)}
|
||||
上传文件
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{frameSrc && (
|
||||
<iframe
|
||||
key={frameSrc}
|
||||
key={`${frameSrc}:${frameVersion}`}
|
||||
ref={frameRef}
|
||||
title={`${details.data.name || "WeKnora"} 详情`}
|
||||
src={frameSrc}
|
||||
@@ -279,4 +343,147 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function CurrentWikiArticleActions({
|
||||
knowledgeBaseId,
|
||||
slug,
|
||||
onChanged,
|
||||
onDeleted,
|
||||
}: {
|
||||
knowledgeBaseId: string;
|
||||
slug: string;
|
||||
onChanged: () => void;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const detail = useLlmWikiWikiPage(knowledgeBaseId, slug || null, !!slug);
|
||||
const actions = useLlmWikiActions();
|
||||
const [draft, setDraft] = useState({ title: "", summary: "", content: "" });
|
||||
|
||||
useEffect(() => {
|
||||
const page = detail.data;
|
||||
if (!page) return;
|
||||
setDraft({
|
||||
title: page.title || "",
|
||||
summary: page.summary || "",
|
||||
content: page.content || "",
|
||||
});
|
||||
}, [detail.data]);
|
||||
|
||||
const save = async () => {
|
||||
if (!slug) return;
|
||||
try {
|
||||
await actions.updateWikiPage.mutateAsync({
|
||||
id: knowledgeBaseId,
|
||||
slug,
|
||||
input: {
|
||||
title: draft.title.trim(),
|
||||
summary: draft.summary,
|
||||
content: draft.content,
|
||||
},
|
||||
});
|
||||
toast.success("文章已保存");
|
||||
await detail.refetch();
|
||||
setOpen(false);
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存文章失败");
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!slug) return;
|
||||
if (!window.confirm(`确定删除文章「${detail.data?.title || slug}」吗?删除后不可恢复。`)) return;
|
||||
try {
|
||||
await actions.removeWikiPage.mutateAsync({ id: knowledgeBaseId, slug });
|
||||
toast.success("文章已删除");
|
||||
onDeleted();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "删除文章失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">
|
||||
{detail.data?.title || slug}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{slug}</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={detail.isLoading || !!detail.error}
|
||||
>
|
||||
<Edit3 className="mr-2 size-4" />
|
||||
编辑此文章
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
onClick={remove}
|
||||
disabled={actions.removeWikiPage.isPending}
|
||||
>
|
||||
{actions.removeWikiPage.isPending ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
)}
|
||||
删除
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="flex max-h-[90vh] w-[calc(100vw-2rem)] max-w-4xl flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑此文章</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-auto">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">标题</label>
|
||||
<Input
|
||||
value={draft.title}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, title: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">摘要</label>
|
||||
<Textarea
|
||||
value={draft.summary}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, summary: event.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">正文</label>
|
||||
<Textarea
|
||||
value={draft.content}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, content: event.target.value }))}
|
||||
className="min-h-[48vh] resize-y font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex shrink-0 justify-end gap-2 border-t pt-3">
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={actions.updateWikiPage.isPending}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={save}
|
||||
disabled={actions.updateWikiPage.isPending || !draft.title.trim() || !draft.content.trim()}
|
||||
>
|
||||
{actions.updateWikiPage.isPending ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 size-4" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WeKnoraKnowledgeDetailPage;
|
||||
+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)
|
||||
|
||||
|
||||
class WikiPageCreate(BaseModel):
|
||||
slug: str = Field(..., min_length=1, max_length=512)
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
content: str = Field(..., min_length=1, max_length=2_000_000)
|
||||
summary: str | None = Field(default=None, max_length=200_000)
|
||||
page_type: str | None = Field(default="article", min_length=1, max_length=64)
|
||||
status: str | None = Field(default="published", min_length=1, max_length=64)
|
||||
aliases: list[str] | None = Field(default=None, max_length=100)
|
||||
parent_slug: str | None = Field(default=None, max_length=512)
|
||||
category_path: list[str] | None = Field(default=None, max_length=50)
|
||||
folder_id: str | None = Field(default=None, max_length=128)
|
||||
wiki_path: str | None = Field(default=None, max_length=1024)
|
||||
|
||||
|
||||
class WikiPageUpdate(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
content: str | None = Field(default=None, min_length=1, max_length=2_000_000)
|
||||
summary: str | None = Field(default=None, max_length=200_000)
|
||||
page_type: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
status: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
aliases: list[str] | None = Field(default=None, max_length=100)
|
||||
parent_slug: str | None = Field(default=None, max_length=512)
|
||||
category_path: list[str] | None = Field(default=None, max_length=50)
|
||||
folder_id: str | None = Field(default=None, max_length=128)
|
||||
wiki_path: str | None = Field(default=None, max_length=1024)
|
||||
|
||||
|
||||
class ConversationDepositCreate(BaseModel):
|
||||
thread_id: str = Field(..., min_length=1, max_length=128)
|
||||
question: str = Field(default="", max_length=200_000)
|
||||
@@ -291,15 +318,17 @@ async def _get_weknora_admin_session(runtime: Any, *, force_refresh: bool = Fals
|
||||
return cached
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0), follow_redirects=False) as client:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0), follow_redirects=False, trust_env=False) as client:
|
||||
response = await client.post(
|
||||
f"{runtime.api_base_url}/api/v1/auth/login",
|
||||
json={"email": runtime.admin_email, "password": runtime.admin_password},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
logger.warning("WeKnora admin login timed out: base_url=%s", runtime.api_base_url)
|
||||
raise HTTPException(status_code=504, detail="WeKnora admin login timed out") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("WeKnora admin login unavailable: base_url=%s error=%r", runtime.api_base_url, exc)
|
||||
raise HTTPException(status_code=502, detail="WeKnora admin login is unavailable") from exc
|
||||
|
||||
if response.status_code >= 400:
|
||||
@@ -412,13 +441,14 @@ def _request_kb_id(request: Request, body: Any) -> str:
|
||||
|
||||
async def _admin_json(runtime: Any, path: str, *, token: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0), follow_redirects=False) as client:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0), follow_redirects=False, trust_env=False) as client:
|
||||
response = await client.get(
|
||||
f"{runtime.api_base_url}{_encode_upstream_path(path)}",
|
||||
params=params,
|
||||
headers={"Accept": "application/json", "Authorization": f"Bearer {token}"},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("WeKnora verification request failed: base_url=%s path=%s error=%r", runtime.api_base_url, path, exc)
|
||||
raise HTTPException(status_code=502, detail="WeKnora verification request failed") from exc
|
||||
if response.status_code == 404:
|
||||
raise HTTPException(status_code=404, detail="WeKnora resource was not found")
|
||||
@@ -502,6 +532,18 @@ async def _authorize_weknora_api_proxy(
|
||||
if len(tail) == 1:
|
||||
raise HTTPException(status_code=403, detail="Global WeKnora knowledge collection is not allowed")
|
||||
action = tail[1]
|
||||
if read_method and action == "batch":
|
||||
ids = [
|
||||
item.strip()
|
||||
for raw in request.query_params.getlist("ids")
|
||||
for item in raw.split(",")
|
||||
if item.strip()
|
||||
]
|
||||
if not ids:
|
||||
raise HTTPException(status_code=403, detail="Batch operation must include document ids")
|
||||
for document_id in ids:
|
||||
await _verify_proxy_document_belongs_to_kb(runtime, ctx, document_id, token)
|
||||
return
|
||||
if action in {"batch", "batch-delete", "batch-reparse", "folder", "move", "tags"}:
|
||||
if _request_kb_id(request, json_body) != ctx.weknora_id:
|
||||
raise HTTPException(status_code=403, detail="Batch operation must be scoped to the current knowledge base")
|
||||
@@ -783,6 +825,7 @@ def _weknora_embed_injection(
|
||||
"tenantId": tenant_id,
|
||||
"user": user,
|
||||
"tenant": tenant,
|
||||
"weknoraId": ctx.weknora_id,
|
||||
"publicPrefix": public_prefix,
|
||||
"apiPrefix": _with_public_prefix(public_prefix, _WEKNORA_EMBED_API_PREFIX),
|
||||
}
|
||||
@@ -878,6 +921,29 @@ html.deerflow-weknora-embed [class^="guide__"],
|
||||
html.deerflow-weknora-embed [class*=" guide__"] {{
|
||||
display: none !important;
|
||||
}}
|
||||
html.deerflow-source-detail-open [data-deerflow-source-detail-hidden="true"] {{
|
||||
display: none !important;
|
||||
}}
|
||||
html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] {{
|
||||
position: fixed !important;
|
||||
inset: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
margin: 0 !important;
|
||||
transform: none !important;
|
||||
z-index: 2147483000 !important;
|
||||
background: var(--td-bg-color-page, #fff) !important;
|
||||
box-shadow: none !important;
|
||||
border-radius: 0 !important;
|
||||
}}
|
||||
html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [class*="drawer"],
|
||||
html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [class*="Drawer"],
|
||||
html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [class*="dialog"],
|
||||
html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [class*="Dialog"] {{
|
||||
max-width: none !important;
|
||||
}}
|
||||
</style>
|
||||
<script id="deerflow-weknora-embed-guard">
|
||||
(() => {{
|
||||
@@ -1019,6 +1085,143 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
|
||||
}});
|
||||
}}
|
||||
|
||||
const launchParams = new URLSearchParams(location.search);
|
||||
const requestedWikiSlug = launchParams.get("wikiSlug") || launchParams.get("slug") || "";
|
||||
const requestedDocumentId =
|
||||
launchParams.get("documentId") ||
|
||||
launchParams.get("knowledgeId") ||
|
||||
launchParams.get("knowledge_id") ||
|
||||
"";
|
||||
const sourceDetailMode = Boolean(requestedDocumentId || requestedWikiSlug);
|
||||
if (sourceDetailMode) {{
|
||||
root.classList.add("deerflow-source-detail-request");
|
||||
}}
|
||||
let requestedWikiOpened = false;
|
||||
let requestedDocumentOpened = false;
|
||||
let sourceDetailAttempts = 0;
|
||||
const maxSourceDetailAttempts = 18;
|
||||
function escapeAttr(value) {{
|
||||
if (window.CSS && typeof window.CSS.escape === "function") return window.CSS.escape(value);
|
||||
return String(value).replace(/["\\\\]/g, "\\\\$&");
|
||||
}}
|
||||
function openRequestedWikiSlug() {{
|
||||
if (!requestedWikiSlug || requestedWikiOpened || root.classList.contains("deerflow-source-detail-open")) return;
|
||||
const slug = escapeAttr(requestedWikiSlug);
|
||||
const link =
|
||||
document.querySelector(`[data-slug="${{slug}}"]`) ||
|
||||
document.querySelector(`a[href*="${{encodeURIComponent(requestedWikiSlug)}}"]`) ||
|
||||
document.querySelector(`a[href*="${{requestedWikiSlug.replace(/"/g, "%22")}}"]`);
|
||||
if (!link || typeof link.click !== "function") return;
|
||||
requestedWikiOpened = true;
|
||||
link.click();
|
||||
}}
|
||||
function openRequestedDocumentId() {{
|
||||
if (!requestedDocumentId || requestedDocumentOpened || root.classList.contains("deerflow-source-detail-open")) return;
|
||||
const id = escapeAttr(requestedDocumentId);
|
||||
const encoded = encodeURIComponent(requestedDocumentId);
|
||||
const candidates = [
|
||||
`[data-id="${{id}}"]`,
|
||||
`[data-row-key="${{id}}"]`,
|
||||
`[data-knowledge-id="${{id}}"]`,
|
||||
`[data-document-id="${{id}}"]`,
|
||||
`a[href*="${{encoded}}"]`,
|
||||
`button[href*="${{encoded}}"]`
|
||||
];
|
||||
let node = null;
|
||||
for (const selector of candidates) {{
|
||||
node = document.querySelector(selector);
|
||||
if (node) break;
|
||||
}}
|
||||
if (!node) {{
|
||||
node = Array.from(document.querySelectorAll("[data-id],[data-row-key],[data-knowledge-id],[data-document-id]"))
|
||||
.find((item) => {{
|
||||
const values = [
|
||||
item.getAttribute("data-id"),
|
||||
item.getAttribute("data-row-key"),
|
||||
item.getAttribute("data-knowledge-id"),
|
||||
item.getAttribute("data-document-id")
|
||||
];
|
||||
return values.includes(requestedDocumentId);
|
||||
}}) || null;
|
||||
}}
|
||||
if (!node) return;
|
||||
const clickable = node.closest("button,a,[role='button'],.t-card,.document-card,.knowledge-card,.file-card,.t-list-item,.t-table__row") || node;
|
||||
if (!clickable || typeof clickable.click !== "function") return;
|
||||
requestedDocumentOpened = true;
|
||||
clickable.click();
|
||||
}}
|
||||
function sourceDetailCandidateScore(node) {{
|
||||
if (!node || node === document.body || node === document.documentElement) return 0;
|
||||
const rect = node.getBoundingClientRect();
|
||||
if (rect.width < 360 || rect.height < 260) return 0;
|
||||
const text = (node.textContent || "").slice(0, 1200);
|
||||
const className = String(node.className || "");
|
||||
let score = 0;
|
||||
if (/基本信息|上传时间|创建时间|摘要|文档内容|查看分块|预览|引用内容|Wiki 原文|全文/.test(text)) score += 4;
|
||||
if (/drawer|Drawer|dialog|Dialog|modal|Modal|detail|Detail|preview|Preview/.test(className)) score += 3;
|
||||
if (rect.right > window.innerWidth * 0.65 && rect.width >= window.innerWidth * 0.38) score += 2;
|
||||
if (getComputedStyle(node).position === "fixed") score += 2;
|
||||
return score;
|
||||
}}
|
||||
function findSourceDetailPanel() {{
|
||||
const selectors = [
|
||||
".t-drawer",
|
||||
".t-drawer__content",
|
||||
".t-dialog",
|
||||
".t-dialog__ctx",
|
||||
".document-detail",
|
||||
".knowledge-detail",
|
||||
".document-preview",
|
||||
"[class*='Drawer']",
|
||||
"[class*='drawer']",
|
||||
"[class*='Dialog']",
|
||||
"[class*='dialog']",
|
||||
"[class*='Detail']",
|
||||
"[class*='detail']",
|
||||
"[class*='Preview']",
|
||||
"[class*='preview']"
|
||||
].join(",");
|
||||
let best = null;
|
||||
let bestScore = 0;
|
||||
document.querySelectorAll(selectors).forEach((node) => {{
|
||||
const score = sourceDetailCandidateScore(node);
|
||||
if (score > bestScore) {{
|
||||
best = node;
|
||||
bestScore = score;
|
||||
}}
|
||||
}});
|
||||
return bestScore >= 5 ? best : null;
|
||||
}}
|
||||
function isolateSourceDetailPanel() {{
|
||||
if (!sourceDetailMode) return;
|
||||
if (root.classList.contains("deerflow-source-detail-open")) return;
|
||||
const panel = findSourceDetailPanel();
|
||||
if (!panel) return;
|
||||
panel.dataset.deerflowSourceDetailPanel = "true";
|
||||
document.querySelectorAll("[data-deerflow-source-detail-hidden='true']").forEach((node) => {{
|
||||
if (node.contains(panel) || panel.contains(node)) return;
|
||||
delete node.dataset.deerflowSourceDetailHidden;
|
||||
}});
|
||||
Array.from(document.body.children).forEach((child) => {{
|
||||
if (child === panel || child.contains(panel)) return;
|
||||
child.dataset.deerflowSourceDetailHidden = "true";
|
||||
}});
|
||||
root.classList.add("deerflow-source-detail-open");
|
||||
}}
|
||||
function tickSourceDetail() {{
|
||||
if (!sourceDetailMode || root.classList.contains("deerflow-source-detail-open")) return true;
|
||||
sourceDetailAttempts += 1;
|
||||
openRequestedDocumentId();
|
||||
openRequestedWikiSlug();
|
||||
isolateSourceDetailPanel();
|
||||
if (root.classList.contains("deerflow-source-detail-open")) return true;
|
||||
if (sourceDetailAttempts >= maxSourceDetailAttempts) {{
|
||||
root.classList.add("deerflow-source-detail-open");
|
||||
return true;
|
||||
}}
|
||||
return false;
|
||||
}}
|
||||
|
||||
const allowedPath = cfg.allowedPath;
|
||||
function allowedUrl(url) {{
|
||||
try {{
|
||||
@@ -1160,14 +1363,20 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
|
||||
enforceLocation();
|
||||
purgeGuides();
|
||||
hideWeKnoraShell();
|
||||
tickSourceDetail();
|
||||
if (ready) return;
|
||||
if (!force && (location.pathname !== allowedPath || !hasDetailShell())) return;
|
||||
ready = true;
|
||||
root.classList.add("deerflow-weknora-ready");
|
||||
}}
|
||||
let observerTimer = 0;
|
||||
const readyObserver = new MutationObserver(() => {{
|
||||
markReady(false);
|
||||
refreshThemeSoon();
|
||||
if (observerTimer) return;
|
||||
observerTimer = window.setTimeout(() => {{
|
||||
observerTimer = 0;
|
||||
markReady(false);
|
||||
refreshThemeSoon();
|
||||
}}, 120);
|
||||
}});
|
||||
readyObserver.observe(document.documentElement, {{ childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style", "data-theme", "theme-mode"] }});
|
||||
if (document.readyState === "complete") {{
|
||||
@@ -1175,12 +1384,23 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
|
||||
}} else {{
|
||||
window.addEventListener("load", () => setTimeout(() => markReady(false), 180), {{ once: true }});
|
||||
}}
|
||||
setInterval(() => {{
|
||||
const maintenanceTimer = setInterval(() => {{
|
||||
enforceLocation();
|
||||
purgeGuides();
|
||||
hideWeKnoraShell();
|
||||
if (tickSourceDetail() && sourceDetailMode) {{
|
||||
clearInterval(maintenanceTimer);
|
||||
try {{ readyObserver.disconnect(); }} catch (_) {{}}
|
||||
}}
|
||||
}}, 500);
|
||||
setTimeout(() => markReady(true), 2200);
|
||||
setTimeout(() => {{
|
||||
markReady(true);
|
||||
if (sourceDetailMode && !root.classList.contains("deerflow-source-detail-open")) {{
|
||||
root.classList.add("deerflow-source-detail-open");
|
||||
clearInterval(maintenanceTimer);
|
||||
try {{ readyObserver.disconnect(); }} catch (_) {{}}
|
||||
}}
|
||||
}}, 9000);
|
||||
}})();
|
||||
</script>
|
||||
"""
|
||||
@@ -1236,7 +1456,7 @@ async def _proxy_weknora_request(
|
||||
|
||||
url = f"{target_base_url.rstrip('/')}{_encode_upstream_path(upstream_path)}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), follow_redirects=False) as client:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), follow_redirects=False, trust_env=False) as client:
|
||||
response = await client.request(
|
||||
request.method,
|
||||
url,
|
||||
@@ -1244,10 +1464,13 @@ async def _proxy_weknora_request(
|
||||
headers=headers,
|
||||
content=body,
|
||||
)
|
||||
if is_api and response.status_code == 401:
|
||||
if is_api and response.status_code in {401, 403}:
|
||||
session = await _get_weknora_admin_session(runtime, force_refresh=True)
|
||||
headers["Authorization"] = f"Bearer {session.get('token')}"
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), follow_redirects=False) as client:
|
||||
tenant_id = _tenant_id_from_session(session)
|
||||
if tenant_id:
|
||||
headers["X-Tenant-ID"] = tenant_id
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), follow_redirects=False, trust_env=False) as client:
|
||||
response = await client.request(
|
||||
request.method,
|
||||
url,
|
||||
@@ -1258,6 +1481,7 @@ async def _proxy_weknora_request(
|
||||
except httpx.TimeoutException as exc:
|
||||
raise HTTPException(status_code=504, detail="WeKnora proxy request timed out") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("WeKnora proxy request failed: url=%s error=%r", url, exc)
|
||||
raise HTTPException(status_code=502, detail="WeKnora proxy request failed") from exc
|
||||
|
||||
content = response.content
|
||||
@@ -1577,7 +1801,18 @@ def _wiki_page_view(page: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"status": str(page.get("status") or ""),
|
||||
"summary": summary,
|
||||
"content": content,
|
||||
"aliases": list(page.get("aliases") or []) if isinstance(page.get("aliases"), list) else [],
|
||||
"parent_slug": str(page.get("parent_slug") or page.get("parentSlug") or ""),
|
||||
"category_path": list(page.get("category_path") or []) if isinstance(page.get("category_path"), list) else [],
|
||||
"folder_id": str(page.get("folder_id") or page.get("folderId") or ""),
|
||||
"wiki_path": str(page.get("wiki_path") or page.get("wikiPath") or ""),
|
||||
"depth": int(page.get("depth") or 0),
|
||||
"version": int(page.get("version") or 0),
|
||||
"created_at": str(page.get("created_at") or ""),
|
||||
"updated_at": str(page.get("updated_at") or ""),
|
||||
"source_refs": list(page.get("source_refs") or []) if isinstance(page.get("source_refs"), list) else [],
|
||||
"in_links": list(page.get("in_links") or []) if isinstance(page.get("in_links"), list) else [],
|
||||
"out_links": list(page.get("out_links") or []) if isinstance(page.get("out_links"), list) else [],
|
||||
}
|
||||
|
||||
|
||||
@@ -1627,31 +1862,6 @@ async def _find_wiki_page_for_chunk(
|
||||
if view and (view.get("content") or view.get("summary")):
|
||||
return view
|
||||
|
||||
candidate_refs = _candidate_chunk_ref_tokens(focus, chunk_id)
|
||||
query = str(focus.get("knowledge_title") or focus.get("title") or focus.get("file_name") or "").strip()
|
||||
try:
|
||||
page_list = await client.list_wiki_pages(knowledge_base_id, page=1, page_size=100, query=query)
|
||||
except WeKnoraError as exc:
|
||||
if exc.status_code != 404:
|
||||
logger.debug("Failed to list WeKnora wiki pages for source preview", exc_info=True)
|
||||
return None
|
||||
|
||||
for page in page_list.get("pages", []):
|
||||
if not isinstance(page, dict):
|
||||
continue
|
||||
source_refs = _source_ref_tokens(page.get("source_refs"))
|
||||
if not source_refs or not candidate_refs.intersection(source_refs):
|
||||
continue
|
||||
slug = str(page.get("slug") or "").strip()
|
||||
try:
|
||||
full_page = await client.get_wiki_page(knowledge_base_id, slug) if slug else page
|
||||
except WeKnoraError as exc:
|
||||
if exc.status_code != 404:
|
||||
logger.debug("Failed to hydrate WeKnora wiki page slug=%s", slug, exc_info=True)
|
||||
full_page = page
|
||||
view = _wiki_page_view(full_page)
|
||||
if view and (view.get("content") or view.get("summary")):
|
||||
return view
|
||||
return None
|
||||
|
||||
|
||||
@@ -2187,17 +2397,37 @@ async def list_wiki_pages(
|
||||
request: Request,
|
||||
mapping_id: str,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=100),
|
||||
page_size: int = Query(default=50, ge=1, le=500),
|
||||
query: str = Query(default="", max_length=255),
|
||||
) -> dict[str, Any]:
|
||||
row, _, _ = await _authorized_mapping(request, mapping_id, write=False)
|
||||
try:
|
||||
return await _client_or_503().list_wiki_pages(
|
||||
result = await _client_or_503().list_wiki_pages(
|
||||
str(row["weknora_id"]),
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
query=query.strip(),
|
||||
)
|
||||
raw_pages = result.get("pages") if isinstance(result, dict) else []
|
||||
pages = [
|
||||
view
|
||||
for page_item in (raw_pages if isinstance(raw_pages, list) else [])
|
||||
if isinstance(page_item, dict)
|
||||
for view in [_wiki_page_view(page_item)]
|
||||
if view is not None
|
||||
]
|
||||
return {**result, "pages": pages}
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
|
||||
|
||||
@router.post("/api/llmwiki/knowledge-bases/{mapping_id}/wiki/pages")
|
||||
async def create_wiki_page(request: Request, mapping_id: str, body: WikiPageCreate) -> dict[str, Any]:
|
||||
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
||||
payload = body.model_dump(exclude_none=True)
|
||||
try:
|
||||
page = await _client_or_503().create_wiki_page(str(row["weknora_id"]), payload)
|
||||
return _wiki_page_view(page) or page
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
|
||||
@@ -2206,7 +2436,34 @@ async def list_wiki_pages(
|
||||
async def get_wiki_page(request: Request, mapping_id: str, slug: str) -> dict[str, Any]:
|
||||
row, _, _ = await _authorized_mapping(request, mapping_id, write=False)
|
||||
try:
|
||||
return await _client_or_503().get_wiki_page(str(row["weknora_id"]), slug)
|
||||
page = await _client_or_503().get_wiki_page(str(row["weknora_id"]), slug)
|
||||
return _wiki_page_view(page) or page
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
|
||||
|
||||
@router.patch("/api/llmwiki/knowledge-bases/{mapping_id}/wiki/pages/{slug:path}")
|
||||
async def update_wiki_page(request: Request, mapping_id: str, slug: str, body: WikiPageUpdate) -> dict[str, Any]:
|
||||
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
||||
changes = body.model_dump(exclude_none=True)
|
||||
if not changes:
|
||||
try:
|
||||
page = await _client_or_503().get_wiki_page(str(row["weknora_id"]), slug)
|
||||
return _wiki_page_view(page) or page
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
try:
|
||||
page = await _client_or_503().update_wiki_page(str(row["weknora_id"]), slug, changes)
|
||||
return _wiki_page_view(page) or page
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
|
||||
|
||||
@router.delete("/api/llmwiki/knowledge-bases/{mapping_id}/wiki/pages/{slug:path}", status_code=204)
|
||||
async def delete_wiki_page(request: Request, mapping_id: str, slug: str) -> None:
|
||||
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
||||
try:
|
||||
await _client_or_503().delete_wiki_page(str(row["weknora_id"]), slug)
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
|
||||
@@ -2407,6 +2664,20 @@ async def get_source_context(
|
||||
sanitize_deposit = _is_conversation_deposit_mapping(row)
|
||||
if sanitize_deposit and wiki_page is not None:
|
||||
wiki_page = _sanitize_conversation_deposit_value(wiki_page)
|
||||
focus_chunk = next((chunk for chunk in chunks if str(chunk.get("id") or "") == chunk_id), {})
|
||||
document_id = str(
|
||||
focus_chunk.get("knowledge_id")
|
||||
or focus_chunk.get("document_id")
|
||||
or focus_chunk.get("knowledgeId")
|
||||
or ""
|
||||
)
|
||||
document_title = str(
|
||||
focus_chunk.get("knowledge_title")
|
||||
or focus_chunk.get("title")
|
||||
or focus_chunk.get("file_name")
|
||||
or focus_chunk.get("filename")
|
||||
or ""
|
||||
)
|
||||
normalized_chunks = [
|
||||
{
|
||||
"id": str(chunk.get("id") or ""),
|
||||
@@ -2426,6 +2697,8 @@ async def get_source_context(
|
||||
"data": {
|
||||
"knowledge_base_id": row["id"],
|
||||
"knowledge_base_name": row.get("name") or "",
|
||||
"document_id": _sanitize_conversation_deposit_value(document_id) if sanitize_deposit else document_id,
|
||||
"document_title": _sanitize_conversation_deposit_value(document_title) if sanitize_deposit else document_title,
|
||||
"focus_chunk_id": chunk_id,
|
||||
"display_mode": "wiki" if wiki_page is not None else "chunks",
|
||||
"wiki_page": wiki_page,
|
||||
|
||||
+5
-2
@@ -12,7 +12,7 @@ from typing import Any, override
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
|
||||
from langchain_core.messages import SystemMessage
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from deerflow.runtime.references import (
|
||||
build_latest_reference_prompt,
|
||||
@@ -105,7 +105,10 @@ class RagCitationMiddleware(AgentMiddleware[AgentState]):
|
||||
)
|
||||
patched_messages = [
|
||||
*messages,
|
||||
SystemMessage(content=prompt),
|
||||
HumanMessage(
|
||||
content=prompt,
|
||||
additional_kwargs={"hide_from_ui": True, "lc_source": "rag_citation"},
|
||||
),
|
||||
]
|
||||
return request.override(messages=patched_messages)
|
||||
|
||||
|
||||
+6
-5
@@ -14,13 +14,13 @@ from typing import override
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
|
||||
class WritingSetupRoundCapMiddleware(AgentMiddleware[AgentState]):
|
||||
"""达到澄清轮数上限时,强制模型调用 ``setup_writing`` 提交。
|
||||
|
||||
软约束(注入 SystemMessage)+ 硬约束(``tool_choice`` 锁定工具)双保险:即使个别模型
|
||||
软约束(注入隐藏 HumanMessage)+ 硬约束(``tool_choice`` 锁定工具)双保险:即使个别模型
|
||||
无视提示,``tool_choice`` 也会逼它产出一次 ``setup_writing`` 调用。
|
||||
"""
|
||||
|
||||
@@ -33,8 +33,8 @@ class WritingSetupRoundCapMiddleware(AgentMiddleware[AgentState]):
|
||||
human_count = sum(1 for m in messages if isinstance(m, HumanMessage))
|
||||
return human_count >= self._max_rounds
|
||||
|
||||
def _reminder(self) -> SystemMessage:
|
||||
return SystemMessage(
|
||||
def _reminder(self) -> HumanMessage:
|
||||
return HumanMessage(
|
||||
content=(
|
||||
f"【系统强制】意图澄清已达 {self._max_rounds} 轮上限。"
|
||||
"现在必须立即调用 setup_writing 提交写作配置,禁止再向用户提任何问题。"
|
||||
@@ -42,7 +42,8 @@ class WritingSetupRoundCapMiddleware(AgentMiddleware[AgentState]):
|
||||
"word_count_target 默认 800、target_audience 默认「普通读者」、"
|
||||
"writing_mode 默认 loose、material_source 默认 knowledge_base、"
|
||||
"article_type 不确定就留空。"
|
||||
)
|
||||
),
|
||||
additional_kwargs={"hide_from_ui": True, "lc_source": "writing_setup_round_cap"},
|
||||
)
|
||||
|
||||
def _patch(self, request: ModelRequest) -> ModelRequest:
|
||||
|
||||
+37
-1
@@ -110,6 +110,7 @@ class WeKnoraClient:
|
||||
client = self._http_client or httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self.timeout_seconds),
|
||||
follow_redirects=False,
|
||||
trust_env=False,
|
||||
)
|
||||
try:
|
||||
response = await client.post(
|
||||
@@ -164,7 +165,11 @@ class WeKnoraClient:
|
||||
data: Mapping[str, Any] | None = None,
|
||||
) -> Any:
|
||||
owns_client = self._http_client is None
|
||||
client = self._http_client or httpx.AsyncClient(timeout=httpx.Timeout(self.timeout_seconds), follow_redirects=False)
|
||||
client = self._http_client or httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self.timeout_seconds),
|
||||
follow_redirects=False,
|
||||
trust_env=False,
|
||||
)
|
||||
try:
|
||||
for attempt in range(2):
|
||||
response = await client.request(
|
||||
@@ -392,6 +397,7 @@ class WeKnoraClient:
|
||||
client = self._http_client or httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self.timeout_seconds),
|
||||
follow_redirects=True,
|
||||
trust_env=False,
|
||||
)
|
||||
try:
|
||||
for attempt in range(2):
|
||||
@@ -486,6 +492,36 @@ class WeKnoraClient:
|
||||
raise WeKnoraError("WeKnora returned an invalid Wiki page")
|
||||
return dict(value)
|
||||
|
||||
async def create_wiki_page(self, knowledge_base_id: str, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
value = _data(
|
||||
await self._request(
|
||||
"POST",
|
||||
f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/pages",
|
||||
json=dict(payload),
|
||||
)
|
||||
)
|
||||
if not isinstance(value, dict):
|
||||
raise WeKnoraError("WeKnora returned an invalid Wiki page")
|
||||
return dict(value)
|
||||
|
||||
async def update_wiki_page(self, knowledge_base_id: str, slug: str, changes: Mapping[str, Any]) -> dict[str, Any]:
|
||||
safe_slug = "/".join(quote(part, safe="") for part in slug.split("/"))
|
||||
value = _data(
|
||||
await self._request(
|
||||
"PUT",
|
||||
f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/pages/{safe_slug}",
|
||||
json=dict(changes),
|
||||
)
|
||||
)
|
||||
return dict(value) if isinstance(value, dict) else {}
|
||||
|
||||
async def delete_wiki_page(self, knowledge_base_id: str, slug: str) -> None:
|
||||
safe_slug = "/".join(quote(part, safe="") for part in slug.split("/"))
|
||||
await self._request(
|
||||
"DELETE",
|
||||
f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/pages/{safe_slug}",
|
||||
)
|
||||
|
||||
async def search(self, query: str, knowledge_base_ids: Sequence[str]) -> list[dict[str, Any]]:
|
||||
ids = [str(value) for value in knowledge_base_ids if str(value)]
|
||||
if not ids:
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Utilities for provider-specific chat message ordering constraints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import BaseMessage, SystemMessage
|
||||
|
||||
|
||||
_SYSTEM_SEPARATOR = "\n\n---\n\n"
|
||||
|
||||
|
||||
def normalize_chat_payload_messages(payload: dict[str, Any]) -> None:
|
||||
"""Ensure payload ``system`` messages are represented once at the beginning.
|
||||
|
||||
Several OpenAI-compatible gateways reject requests when a transient
|
||||
``system`` message appears after user/assistant/tool history. LangChain and
|
||||
DeerFlow middleware can legitimately create those transient messages, so the
|
||||
provider boundary folds every system payload into a single leading message
|
||||
while preserving all non-system messages in their original order.
|
||||
"""
|
||||
messages = payload.get("messages")
|
||||
if not isinstance(messages, list):
|
||||
return
|
||||
|
||||
normalized = _normalize_payload_message_list(messages)
|
||||
if normalized is not messages:
|
||||
payload["messages"] = normalized
|
||||
|
||||
|
||||
def normalize_langchain_messages(messages: list[BaseMessage]) -> list[BaseMessage]:
|
||||
"""Return LangChain messages with all system context merged at the front."""
|
||||
system_messages = [message for message in messages if isinstance(message, SystemMessage)]
|
||||
if not system_messages:
|
||||
return messages
|
||||
|
||||
first_non_system_index = next((idx for idx, message in enumerate(messages) if not isinstance(message, SystemMessage)), len(messages))
|
||||
if len(system_messages) == first_non_system_index:
|
||||
return messages
|
||||
|
||||
merged = system_messages[0].model_copy(
|
||||
update={
|
||||
"content": _merge_contents([message.content for message in system_messages]),
|
||||
}
|
||||
)
|
||||
return [merged, *(message for message in messages if not isinstance(message, SystemMessage))]
|
||||
|
||||
|
||||
def _normalize_payload_message_list(messages: list[Any]) -> list[Any]:
|
||||
system_messages = [message for message in messages if isinstance(message, dict) and message.get("role") == "system"]
|
||||
if not system_messages:
|
||||
return messages
|
||||
|
||||
first_non_system_index = next(
|
||||
(
|
||||
idx
|
||||
for idx, message in enumerate(messages)
|
||||
if not (isinstance(message, dict) and message.get("role") == "system")
|
||||
),
|
||||
len(messages),
|
||||
)
|
||||
if len(system_messages) == first_non_system_index:
|
||||
return messages
|
||||
|
||||
merged_system = dict(system_messages[0])
|
||||
merged_system["content"] = _merge_contents([message.get("content") for message in system_messages])
|
||||
return [merged_system, *(message for message in messages if not (isinstance(message, dict) and message.get("role") == "system"))]
|
||||
|
||||
|
||||
def _merge_contents(contents: list[Any]) -> Any:
|
||||
if all(isinstance(content, str) for content in contents):
|
||||
return _SYSTEM_SEPARATOR.join(content for content in contents if content)
|
||||
|
||||
blocks: list[Any] = []
|
||||
for idx, content in enumerate(contents):
|
||||
if idx > 0:
|
||||
blocks.append({"type": "text", "text": _SYSTEM_SEPARATOR})
|
||||
if isinstance(content, list):
|
||||
blocks.extend(content)
|
||||
elif content is not None:
|
||||
blocks.append({"type": "text", "text": str(content)})
|
||||
return blocks
|
||||
+7
-5
@@ -6,10 +6,12 @@ import uuid
|
||||
from collections.abc import Iterator
|
||||
|
||||
import httpx
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, ToolMessage
|
||||
from langchain_core.outputs import ChatGenerationChunk, ChatResult
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, ToolMessage
|
||||
from langchain_core.outputs import ChatGenerationChunk, ChatResult
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from deerflow.models.message_ordering import normalize_langchain_messages
|
||||
|
||||
|
||||
def _fix_messages(messages: list) -> list:
|
||||
"""Sanitize incoming messages for MindIE compatibility.
|
||||
@@ -55,7 +57,7 @@ def _fix_messages(messages: list) -> list:
|
||||
|
||||
fixed.append(msg.model_copy(update={"content": text}))
|
||||
|
||||
return fixed
|
||||
return normalize_langchain_messages(fixed)
|
||||
|
||||
|
||||
def _parse_xml_tool_call_to_dict(content: str) -> tuple[str, list[dict]]:
|
||||
|
||||
+13
-10
@@ -9,10 +9,12 @@ on all assistant messages when thinking mode is enabled.
|
||||
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.language_models import LanguageModelInput
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_deepseek import ChatDeepSeek
|
||||
|
||||
from langchain_core.language_models import LanguageModelInput
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_deepseek import ChatDeepSeek
|
||||
|
||||
from deerflow.models.message_ordering import normalize_chat_payload_messages
|
||||
|
||||
|
||||
class PatchedChatDeepSeek(ChatDeepSeek):
|
||||
"""ChatDeepSeek with proper reasoning_content preservation.
|
||||
@@ -65,9 +67,10 @@ class PatchedChatDeepSeek(ChatDeepSeek):
|
||||
ai_messages = [m for m in original_messages if isinstance(m, AIMessage)]
|
||||
assistant_payloads = [(i, m) for i, m in enumerate(payload_messages) if m.get("role") == "assistant"]
|
||||
|
||||
for (idx, payload_msg), ai_msg in zip(assistant_payloads, ai_messages):
|
||||
reasoning_content = ai_msg.additional_kwargs.get("reasoning_content")
|
||||
if reasoning_content is not None:
|
||||
payload_messages[idx]["reasoning_content"] = reasoning_content
|
||||
|
||||
return payload
|
||||
for (idx, payload_msg), ai_msg in zip(assistant_payloads, ai_messages):
|
||||
reasoning_content = ai_msg.additional_kwargs.get("reasoning_content")
|
||||
if reasoning_content is not None:
|
||||
payload_messages[idx]["reasoning_content"] = reasoning_content
|
||||
|
||||
normalize_chat_payload_messages(payload)
|
||||
return payload
|
||||
+11
-8
@@ -20,11 +20,13 @@ from langchain_core.language_models import LanguageModelInput
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk
|
||||
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_openai.chat_models.base import (
|
||||
_convert_delta_to_message_chunk,
|
||||
_create_usage_metadata,
|
||||
)
|
||||
|
||||
from langchain_openai.chat_models.base import (
|
||||
_convert_delta_to_message_chunk,
|
||||
_create_usage_metadata,
|
||||
)
|
||||
|
||||
from deerflow.models.message_ordering import normalize_chat_payload_messages
|
||||
|
||||
_THINK_TAG_RE = re.compile(r"<think>\s*(.*?)\s*</think>", re.DOTALL)
|
||||
|
||||
|
||||
@@ -112,9 +114,10 @@ class PatchedChatMiniMax(ChatOpenAI):
|
||||
**extra_body,
|
||||
"reasoning_split": True,
|
||||
}
|
||||
else:
|
||||
payload["extra_body"] = {"reasoning_split": True}
|
||||
return payload
|
||||
else:
|
||||
payload["extra_body"] = {"reasoning_split": True}
|
||||
normalize_chat_payload_messages(payload)
|
||||
return payload
|
||||
|
||||
def _convert_chunk_to_generation_chunk(
|
||||
self,
|
||||
|
||||
+15
-12
@@ -29,9 +29,11 @@ from typing import Any
|
||||
|
||||
from langchain_core.language_models import LanguageModelInput
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk
|
||||
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from deerflow.models.message_ordering import normalize_chat_payload_messages
|
||||
|
||||
|
||||
class PatchedChatOpenAI(ChatOpenAI):
|
||||
"""ChatOpenAI with ``thought_signature`` preservation for Gemini thinking via OpenAI gateway.
|
||||
@@ -87,15 +89,16 @@ class PatchedChatOpenAI(ChatOpenAI):
|
||||
if payload_msg.get("role") == "assistant" and isinstance(orig_msg, AIMessage):
|
||||
_restore_tool_call_signatures(payload_msg, orig_msg)
|
||||
_restore_reasoning_content(payload_msg, orig_msg)
|
||||
else:
|
||||
# Fallback: match assistant-role entries positionally against AIMessages.
|
||||
ai_messages = [m for m in original_messages if isinstance(m, AIMessage)]
|
||||
assistant_payloads = [(i, m) for i, m in enumerate(payload_messages) if m.get("role") == "assistant"]
|
||||
for (_, payload_msg), ai_msg in zip(assistant_payloads, ai_messages):
|
||||
_restore_tool_call_signatures(payload_msg, ai_msg)
|
||||
_restore_reasoning_content(payload_msg, ai_msg)
|
||||
|
||||
return payload
|
||||
else:
|
||||
# Fallback: match assistant-role entries positionally against AIMessages.
|
||||
ai_messages = [m for m in original_messages if isinstance(m, AIMessage)]
|
||||
assistant_payloads = [(i, m) for i, m in enumerate(payload_messages) if m.get("role") == "assistant"]
|
||||
for (_, payload_msg), ai_msg in zip(assistant_payloads, ai_messages):
|
||||
_restore_tool_call_signatures(payload_msg, ai_msg)
|
||||
_restore_reasoning_content(payload_msg, ai_msg)
|
||||
|
||||
normalize_chat_payload_messages(payload)
|
||||
return payload
|
||||
|
||||
def _convert_chunk_to_generation_chunk(
|
||||
self,
|
||||
|
||||
+13
-10
@@ -32,9 +32,11 @@ from langchain_core.messages import (
|
||||
)
|
||||
from langchain_core.messages.tool import tool_call_chunk
|
||||
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_openai.chat_models.base import _create_usage_metadata
|
||||
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_openai.chat_models.base import _create_usage_metadata
|
||||
|
||||
from deerflow.models.message_ordering import normalize_chat_payload_messages
|
||||
|
||||
|
||||
def _normalize_vllm_chat_template_kwargs(payload: dict[str, Any]) -> None:
|
||||
"""Map DeerFlow's legacy ``thinking`` toggle to vLLM/Qwen's ``enable_thinking``.
|
||||
@@ -182,13 +184,14 @@ class VllmChatModel(ChatOpenAI):
|
||||
for payload_msg, orig_msg in zip(payload_messages, original_messages):
|
||||
if payload_msg.get("role") == "assistant" and isinstance(orig_msg, AIMessage):
|
||||
_restore_reasoning_field(payload_msg, orig_msg)
|
||||
else:
|
||||
ai_messages = [message for message in original_messages if isinstance(message, AIMessage)]
|
||||
assistant_payloads = [message for message in payload_messages if message.get("role") == "assistant"]
|
||||
for payload_msg, ai_msg in zip(assistant_payloads, ai_messages):
|
||||
_restore_reasoning_field(payload_msg, ai_msg)
|
||||
|
||||
return payload
|
||||
else:
|
||||
ai_messages = [message for message in original_messages if isinstance(message, AIMessage)]
|
||||
assistant_payloads = [message for message in payload_messages if message.get("role") == "assistant"]
|
||||
for payload_msg, ai_msg in zip(assistant_payloads, ai_messages):
|
||||
_restore_reasoning_field(payload_msg, ai_msg)
|
||||
|
||||
normalize_chat_payload_messages(payload)
|
||||
return payload
|
||||
|
||||
def _create_chat_result(self, response: dict | openai.BaseModel, generation_info: dict | None = None) -> ChatResult:
|
||||
"""Preserve vLLM reasoning on non-streaming responses."""
|
||||
|
||||
+3
@@ -133,6 +133,9 @@ async def llmwiki_search_tool(
|
||||
{
|
||||
"knowledge_base_id": mapping["id"],
|
||||
"chunk_id": visible_result.get("chunk_id") or "",
|
||||
"knowledge_id": visible_result.get("knowledge_id") or "",
|
||||
"document_id": visible_result.get("knowledge_id") or "",
|
||||
"wiki_slug": visible_result.get("wiki_slug") or visible_result.get("slug") or "",
|
||||
}
|
||||
)
|
||||
citations.append(
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
||||
|
||||
from deerflow.agents.middlewares.writing_setup_round_cap_middleware import WritingSetupRoundCapMiddleware
|
||||
from deerflow.models.message_ordering import normalize_chat_payload_messages, normalize_langchain_messages
|
||||
from deerflow.models.patched_openai import PatchedChatOpenAI
|
||||
|
||||
|
||||
def _multi_turn_payload_with_late_systems(turns: int):
|
||||
messages = [{"role": "system", "content": "base system"}]
|
||||
for idx in range(turns):
|
||||
messages.append({"role": "user", "content": f"user {idx}"})
|
||||
messages.append({"role": "assistant", "content": f"assistant {idx}"})
|
||||
if idx >= 9 and (idx + 1) % 10 == 0:
|
||||
messages.append({"role": "system", "content": f"late summary after {idx + 1} turns"})
|
||||
messages.append({"role": "system", "content": "tail forced instruction"})
|
||||
return {"messages": messages}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("turns", [12, 25, 50])
|
||||
def test_normalize_chat_payload_messages_merges_late_systems_after_many_turns(turns):
|
||||
payload = _multi_turn_payload_with_late_systems(turns)
|
||||
|
||||
normalize_chat_payload_messages(payload)
|
||||
|
||||
roles = [message["role"] for message in payload["messages"]]
|
||||
assert roles[0] == "system"
|
||||
assert roles.count("system") == 1
|
||||
assert roles[1:5] == ["user", "assistant", "user", "assistant"]
|
||||
assert "base system" in payload["messages"][0]["content"]
|
||||
assert "late summary after 10 turns" in payload["messages"][0]["content"]
|
||||
assert "tail forced instruction" in payload["messages"][0]["content"]
|
||||
|
||||
|
||||
def test_normalize_langchain_messages_moves_late_systems_before_history():
|
||||
messages = [SystemMessage(content="base system")]
|
||||
for idx in range(12):
|
||||
messages.append(HumanMessage(content=f"user {idx}"))
|
||||
messages.append(AIMessage(content=f"assistant {idx}"))
|
||||
if idx == 10:
|
||||
messages.append(SystemMessage(content="late compaction summary"))
|
||||
|
||||
normalized = normalize_langchain_messages(messages)
|
||||
|
||||
assert isinstance(normalized[0], SystemMessage)
|
||||
assert sum(isinstance(message, SystemMessage) for message in normalized) == 1
|
||||
assert normalized[0].content == "base system\n\n---\n\nlate compaction summary"
|
||||
assert isinstance(normalized[1], HumanMessage)
|
||||
assert normalized[1].content == "user 0"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("turns", [12, 25, 50])
|
||||
def test_patched_openai_payload_has_only_leading_system_after_many_turns(turns):
|
||||
model = PatchedChatOpenAI(
|
||||
model="test-model",
|
||||
api_key="test-key",
|
||||
base_url="http://127.0.0.1:9/v1",
|
||||
)
|
||||
messages = [SystemMessage(content="base system")]
|
||||
for idx in range(turns):
|
||||
messages.append(HumanMessage(content=f"user {idx}"))
|
||||
messages.append(AIMessage(content=f"assistant {idx}"))
|
||||
if idx >= 9 and (idx + 1) % 10 == 0:
|
||||
messages.append(SystemMessage(content=f"late system after {idx + 1} turns"))
|
||||
messages.append(SystemMessage(content="tail system"))
|
||||
|
||||
payload = model._get_request_payload(messages)
|
||||
|
||||
roles = [message["role"] for message in payload["messages"]]
|
||||
assert roles[0] == "system"
|
||||
assert roles.count("system") == 1
|
||||
assert "base system" in payload["messages"][0]["content"]
|
||||
assert "late system after 10 turns" in payload["messages"][0]["content"]
|
||||
assert "tail system" in payload["messages"][0]["content"]
|
||||
|
||||
|
||||
def test_writing_round_cap_reminder_is_not_late_system_message():
|
||||
middleware = WritingSetupRoundCapMiddleware(max_rounds=3)
|
||||
|
||||
reminder = middleware._reminder()
|
||||
|
||||
assert isinstance(reminder, HumanMessage)
|
||||
assert not isinstance(reminder, SystemMessage)
|
||||
assert reminder.additional_kwargs["hide_from_ui"] is True
|
||||
@@ -551,6 +551,39 @@ async def test_weknora_client_imports_url_manual_content_and_lists_wiki_pages()
|
||||
assert pages["pages"][0]["slug"] == "guide"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weknora_client_creates_updates_and_deletes_wiki_pages() -> None:
|
||||
seen: list[tuple[str, str]] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append((request.method, request.url.path))
|
||||
if request.method == "POST":
|
||||
assert request.url.path == "/api/v1/knowledgebase/kb-one/wiki/pages"
|
||||
assert json.loads(request.content) == {"slug": "folder/guide", "title": "Guide", "content": "# Guide"}
|
||||
return httpx.Response(200, json={"success": True, "data": {"slug": "folder/guide", "title": "Guide"}})
|
||||
assert request.url.path == "/api/v1/knowledgebase/kb-one/wiki/pages/folder/guide"
|
||||
if request.method == "PUT":
|
||||
assert json.loads(request.content) == {"title": "Guide", "content": "# Guide"}
|
||||
return httpx.Response(200, json={"success": True, "data": {"slug": "folder/guide", "title": "Guide"}})
|
||||
if request.method == "DELETE":
|
||||
return httpx.Response(204)
|
||||
return httpx.Response(405)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
|
||||
client = WeKnoraClient(base_url="http://weknora.local:8080", http_client=http_client)
|
||||
created = await client.create_wiki_page("kb-one", {"slug": "folder/guide", "title": "Guide", "content": "# Guide"})
|
||||
updated = await client.update_wiki_page("kb-one", "folder/guide", {"title": "Guide", "content": "# Guide"})
|
||||
await client.delete_wiki_page("kb-one", "folder/guide")
|
||||
|
||||
assert created["slug"] == "folder/guide"
|
||||
assert updated["slug"] == "folder/guide"
|
||||
assert seen == [
|
||||
("POST", "/api/v1/knowledgebase/kb-one/wiki/pages"),
|
||||
("PUT", "/api/v1/knowledgebase/kb-one/wiki/pages/folder/guide"),
|
||||
("DELETE", "/api/v1/knowledgebase/kb-one/wiki/pages/folder/guide"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weknora_client_reads_document_chunks_and_wiki_graph() -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
|
||||
@@ -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