This commit is contained in:
董宏凯 committed 2026-08-17 19:30:37 +08:00
commit 819e537aa3
28 files changed
+2838 -355

No files matched your search

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