feat: integrate WeKnora llmwiki knowledge flow

This commit is contained in:
吉鑫升 committed 2026-08-11 18:13:30 +08:00
1 parent a0a75a9964
commit e0c09f6704
63 files changed
+7045 -197

No files matched your search

@@ -8,6 +8,7 @@ import {
SlidersHorizontal,
Upload,
} from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
@@ -18,7 +19,7 @@ function HeaderButton({
variant = "outline",
className,
}: {
icon: React.ReactNode;
icon: ReactNode;
label: string;
onClick: () => void;
variant?: "outline" | "primary";
@@ -52,6 +53,7 @@ export function WikiHeader({
onCreate,
onManageFolders,
onManageTemplates,
extraActions,
}: {
onToggleSidebar: () => void;
onImportFile: () => void;
@@ -61,6 +63,7 @@ export function WikiHeader({
onCreate: () => void;
onManageFolders: () => void;
onManageTemplates: () => void;
extraActions?: ReactNode;
}) {
return (
<header className="flex h-14 shrink-0 items-center justify-between border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900">
@@ -79,6 +82,7 @@ export function WikiHeader({
</div>
<div className="flex items-center gap-2">
{extraActions}
<HeaderButton
icon={<Upload className="size-4" />}
label="导入文件"
@@ -3,6 +3,7 @@
import {
BotIcon,
CheckIcon,
DatabaseIcon,
GlobeIcon,
InfoIcon,
Loader2Icon,
@@ -43,6 +44,7 @@ import {
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
import { AgentRecentChats } from "@/components/workspace/agents/agent-recent-chats";
import { useArtifactsOptional } from "@/components/workspace/artifacts";
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
@@ -80,6 +82,7 @@ interface AgentDraft {
templateName: string;
/** 会商席位「技能识别强化」开关。 */
seatSkillDirective: boolean;
knowledgeBaseIds: string[];
}
function draftFromAgent(agent: Agent): AgentDraft {
@@ -93,6 +96,7 @@ function draftFromAgent(agent: Agent): AgentDraft {
featured: agent.featured_order != null,
templateName: agent.template_name ?? "",
seatSkillDirective: agent.seat_skill_directive ?? false,
knowledgeBaseIds: agent.llmwiki_knowledge_base_ids ?? [],
};
}
@@ -310,6 +314,11 @@ export function AgentDetailPanel({
request.template_name = draft.templateName.trim();
if (draft.seatSkillDirective !== (agent.seat_skill_directive ?? false))
request.seat_skill_directive = draft.seatSkillDirective;
if (
JSON.stringify(draft.knowledgeBaseIds) !==
JSON.stringify(agent.llmwiki_knowledge_base_ids ?? [])
)
request.llmwiki_knowledge_base_ids = draft.knowledgeBaseIds;
if (nextPublished !== agent.published) request.published = nextPublished;
if (JSON.stringify(draft.skills) !== JSON.stringify(agent.skills ?? []))
request.skills = draft.skills;
@@ -579,6 +588,13 @@ export function AgentDetailPanel({
</div>
)}
{!editing && (agent.llmwiki_knowledge_base_ids?.length ?? 0) > 0 && (
<div className="text-muted-foreground mt-2 flex items-center gap-1 text-xs">
<DatabaseIcon className="size-3 shrink-0" />
LLMWiki 知识库:{agent.llmwiki_knowledge_base_ids?.length ?? 0} 个
</div>
)}
{/* Publish / featured toggles — only meaningful in edit mode. */}
{editing && draft && (
<div className="mt-3 space-y-2">
@@ -596,6 +612,14 @@ export function AgentDetailPanel({
配置后,对话生成的 Markdown 文件可一键发布到知识入库接口。
</p>
</div>
<LlmWikiKnowledgeSelector
source="all"
value={draft.knowledgeBaseIds}
onChange={(knowledgeBaseIds) =>
setDraft({ ...draft, knowledgeBaseIds })
}
variant="panel"
/>
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
<div className="pr-3">
<p className="text-sm font-medium">会商技能识别强化</p>
@@ -22,9 +22,10 @@ import {
useEffect,
useMemo,
useRef,
useState,
type ComponentProps,
} from "react";
useState,
type ComponentProps,
type ReactNode,
} from "react";
import {
PromptInput,
@@ -132,9 +133,10 @@ export function InputBox({
onFollowupsVisibilityChange,
onSubmit,
onStop,
promptPrefix,
agentSelector,
assistantId,
promptPrefix,
agentSelector,
knowledgeSelector,
assistantId,
hideAdminRecommendedQuestions,
compact,
...props
@@ -190,11 +192,13 @@ export function InputBox({
* homepage (``ChatPage``) passes this; ``AgentChatPage`` omits it because the
* thread is already bound to one agent.
*/
agentSelector?: {
selectedAgentId: string | null;
onAgentChange: (agentId: string | null) => void;
};
}) {
agentSelector?: {
selectedAgentId: string | null;
onAgentChange: (agentId: string | null) => void;
};
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
knowledgeSelector?: ReactNode;
}) {
const { t } = useI18n();
const [localSettings, setLocalSettings] = useLocalSettings();
const searchParams = useSearchParams();
@@ -1081,9 +1085,10 @@ export function InputBox({
</PromptInputActionMenuContent>
</PromptInputActionMenu>
)}
</PromptInputTools>
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
{agentSelector && (
</PromptInputTools>
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
{knowledgeSelector}
{agentSelector && (
<AgentSelector
selectedAgentId={agentSelector.selectedAgentId}
onAgentChange={agentSelector.onAgentChange}
@@ -0,0 +1,353 @@
"use client";
import {
Check,
Database,
Folder,
Globe2,
Loader2,
Search,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import {
type LlmWikiKnowledgeBase,
useAgentLlmWikiKnowledgeBases,
useLlmWikiKnowledgeBases,
useLlmWikiRuntime,
} from "@/strategy-components/api/llmwiki";
type SelectorSource = "all" | "agent";
type SelectorVariant = "popover" | "panel";
function groupRows(rows: LlmWikiKnowledgeBase[]) {
return [
{
key: "personal",
label: "我的知识空间",
rows: rows.filter((item) => item.publication_status !== "published" || item.is_owner),
},
{
key: "public",
label: "公共知识空间",
rows: rows.filter((item) => item.publication_status === "published" && !item.is_owner),
},
].filter((group) => group.rows.length > 0);
}
function SpaceIcon({ item }: { item: LlmWikiKnowledgeBase }) {
return (
<span
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md",
item.publication_status === "published"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-primary/10 text-primary",
)}
>
{item.publication_status === "published" ? (
<Globe2 className="size-3.5" />
) : (
<Folder className="size-3.5" />
)}
</span>
);
}
function SelectionContent({
rows,
value,
onChange,
search,
onSearchChange,
loading,
error,
source,
variant,
}: {
rows: LlmWikiKnowledgeBase[];
value: string[];
onChange: (value: string[]) => void;
search: string;
onSearchChange: (value: string) => void;
loading: boolean;
error: unknown;
source: SelectorSource;
variant: SelectorVariant;
}) {
const selected = useMemo(() => new Set(value), [value]);
const filtered = useMemo(() => {
const query = search.trim().toLocaleLowerCase();
if (!query) return rows;
return rows.filter((item) =>
`${item.name} ${item.description}`.toLocaleLowerCase().includes(query),
);
}, [rows, search]);
const groups = useMemo(() => groupRows(filtered), [filtered]);
const toggle = (id: string) => {
onChange(selected.has(id) ? value.filter((item) => item !== id) : [...value, id]);
};
return (
<>
<div className="border-b p-2.5">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(event) => onSearchChange(event.target.value)}
onKeyDown={(event) => event.stopPropagation()}
placeholder="搜索知识空间"
className="h-8 bg-muted/40 pl-8 text-xs"
/>
</div>
</div>
<div className={cn("overflow-y-auto p-1.5", variant === "popover" ? "max-h-64" : "max-h-80")}>
{loading ? (
<div className="flex h-28 items-center justify-center text-xs text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" />正在读取知识空间
</div>
) : error ? (
<div className="m-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
{error instanceof Error ? error.message : "知识空间读取失败"}
</div>
) : groups.length === 0 ? (
<div className="flex h-28 items-center justify-center px-4 text-center text-xs text-muted-foreground">
{search
? "没有匹配的知识空间"
: source === "agent"
? "该智能体尚未绑定可用知识空间"
: "当前账号暂无可用知识空间"}
</div>
) : (
groups.map((group, groupIndex) => (
<div key={group.key}>
{groupIndex > 0 && (
variant === "popover"
? <DropdownMenuSeparator className="my-1.5" />
: <div className="my-1.5 border-t" />
)}
{variant === "popover" ? (
<DropdownMenuLabel className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
</DropdownMenuLabel>
) : (
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
</div>
)}
{group.rows.map((item) => {
const rowContent = (
<>
<SpaceIcon item={item} />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium">{item.name}</div>
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
{item.type === "faq" ? "FAQ" : "文档库"} · {item.knowledge_count ?? 0} 项知识
</div>
</div>
{item.publication_status === "published" && (
<Badge variant="outline" className="h-4 px-1 text-[9px]">公共</Badge>
)}
</>
);
return variant === "popover" ? (
<DropdownMenuCheckboxItem
key={item.id}
checked={selected.has(item.id)}
onCheckedChange={() => toggle(item.id)}
onSelect={(event) => event.preventDefault()}
className={cn(
"my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7",
selected.has(item.id) && "bg-primary/8 focus:bg-primary/12",
)}
>
{rowContent}
</DropdownMenuCheckboxItem>
) : (
<button
key={item.id}
type="button"
role="checkbox"
aria-checked={selected.has(item.id)}
onClick={() => toggle(item.id)}
className={cn(
"my-0.5 flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary",
selected.has(item.id) && "bg-primary/8",
)}
>
<span className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border",
selected.has(item.id) && "border-primary bg-primary text-primary-foreground",
)}>
{selected.has(item.id) && <Check className="size-3" />}
</span>
{rowContent}
</button>
);
})}
</div>
))
)}
</div>
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2">
<span className="text-[11px] text-muted-foreground">已选择 {value.length} 个</span>
<div className="flex items-center gap-1">
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange(filtered.map((item) => item.id))}>
全选
</Button>
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange([])}>
清空
</Button>
</div>
</div>
</>
);
}
export function LlmWikiKnowledgeSelector({
value,
onChange,
source = "all",
agentId,
autoSelectAll = false,
disabled = false,
className,
label = "知识空间",
variant = "popover",
}: {
value: string[];
onChange: (value: string[]) => void;
source?: SelectorSource;
agentId?: string | null;
autoSelectAll?: boolean;
disabled?: boolean;
className?: string;
label?: string;
variant?: SelectorVariant;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const runtime = useLlmWikiRuntime();
const all = useLlmWikiKnowledgeBases(
"all",
runtime.data?.provider === "weknora",
);
const agent = useAgentLlmWikiKnowledgeBases(
agentId,
runtime.data?.provider === "weknora" && source === "agent" && !!agentId,
);
const rows = all.data ?? [];
const defaultIds = useMemo(() => {
if (source !== "agent") {
return rows.map((item) => item.id);
}
return (agent.data ?? []).map((item) => item.id);
}, [agent.data, rows, source]);
const loading = all.isLoading || (source === "agent" && !!agentId && agent.isLoading);
const error = all.error ?? (source === "agent" ? agent.error : null);
const availableIds = useMemo(() => new Set(rows.map((item) => item.id)), [rows]);
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
const initialized = useRef<string | null>(null);
useEffect(() => {
if (!all.data || (source === "agent" && agentId && !agent.data) || initialized.current === initializationKey) return;
initialized.current = initializationKey;
const valid = value.filter((id) => availableIds.has(id));
const next = autoSelectAll && valid.length === 0 && defaultIds.length > 0
? defaultIds.filter((id) => availableIds.has(id))
: valid;
if (next.length !== value.length || next.some((id, index) => id !== value[index])) {
onChange(next);
}
}, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, source, value]);
useEffect(() => {
if (!open) setSearch("");
}, [open]);
if (runtime.isError || runtime.data?.provider !== "weknora") return null;
const content = (
<SelectionContent
rows={rows}
value={value}
onChange={onChange}
search={search}
onSearchChange={setSearch}
loading={loading}
error={error}
source={source}
variant={variant}
/>
);
if (variant === "panel") {
return (
<div className={cn("overflow-hidden rounded-xl border bg-background shadow-sm", className)}>
<div className="flex items-start justify-between gap-4 border-b px-4 py-3">
<div>
<div className="flex items-center gap-2 text-sm font-semibold">
<Database className="size-4 text-primary" />绑定知识空间
</div>
<p className="mt-1 text-xs text-muted-foreground">
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
</p>
</div>
<Badge variant="secondary" className="shrink-0">{value.length} 个</Badge>
</div>
{content}
</div>
);
}
const selectedName = value.length === 1
? rows.find((item) => item.id === value[0])?.name
: null;
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
"h-8 max-w-52 gap-1.5 rounded-full border-0 bg-transparent px-2.5 text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground",
value.length > 0 && "bg-primary/8 text-primary hover:bg-primary/12 hover:text-primary",
className,
)}
disabled={disabled || loading}
>
{loading ? <Loader2 className="size-3.5 animate-spin" /> : <Database className="size-3.5" />}
<span className="truncate text-xs font-normal">{selectedName || label}</span>
{value.length > 0 && (
<span className="flex size-4 items-center justify-center rounded-full bg-primary text-[9px] font-semibold text-primary-foreground">
{value.length}
</span>
)}
{value.length > 0 && value.length === rows.length && <Check className="size-3" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={8} className="w-80 overflow-hidden rounded-xl p-0 shadow-xl">
{content}
</DropdownMenuContent>
</DropdownMenu>
);
}
export default LlmWikiKnowledgeSelector;
@@ -92,7 +92,7 @@ export function SourceCard({
knowledge_base_id: previewTarget.knowledgeBaseId,
chunk_id: previewTarget.chunkId,
});
void apiFetch(`${getBackendBaseURL()}/api/weknora/source-context?${params}`)
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)) {
+8 -4
View File
@@ -61,7 +61,9 @@ export interface Agent {
/** 会商席位「技能识别强化」开关。作为会商席位运行时,是否把本智能体配置的技能
* (名称 + SKILL.md 路径 + 必须先 read_file 的硬指令) 拼进本轮任务以补偿弱模型。
* 与业务链条同名开关取 OR。null/false = 关 (默认)。 */
seat_skill_directive?: boolean | null;
seat_skill_directive?: boolean | null;
/** DeerFlow LLMWiki mapping ids this agent allows conversations to select. */
llmwiki_knowledge_base_ids?: string[] | null;
tags?: Tag[];
featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */
@@ -96,7 +98,8 @@ export interface CreateAgentRequest {
common_questions?: AgentQuestion[] | null;
template_name?: string | null;
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
seat_skill_directive?: boolean | null;
seat_skill_directive?: boolean | null;
llmwiki_knowledge_base_ids?: string[] | null;
}
export interface UpdateAgentRequest {
@@ -113,5 +116,6 @@ export interface UpdateAgentRequest {
/** 入库模板名;传空字符串表示清除。 */
template_name?: string | null;
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
seat_skill_directive?: boolean | null;
}
seat_skill_directive?: boolean | null;
llmwiki_knowledge_base_ids?: string[] | null;
}
@@ -0,0 +1,66 @@
import type { Message } from "@langchain/langgraph-sdk";
import { textOfMessage } from "@/core/threads/utils";
import { createLlmWikiConversationDeposit } from "@/strategy-components/api/llmwiki";
function isHidden(message: Message): boolean {
const extra = (message as { additional_kwargs?: Record<string, unknown> })
.additional_kwargs;
return extra?.hide_from_ui === true;
}
function messageText(message: Message | undefined): string {
return (message ? textOfMessage(message) : "")?.trim() ?? "";
}
function hasText(message: Message | undefined): message is Message {
return !!message && messageText(message).length > 0;
}
export function queueLlmWikiConversationDeposit({
threadId,
messages,
assistantId,
knowledgeBaseIds,
}: {
threadId: string | null | undefined;
messages: Message[] | undefined;
assistantId?: string | null;
knowledgeBaseIds?: string[];
}) {
if (!threadId || !messages?.length) {
return;
}
const visible = messages.filter((message) => !isHidden(message));
const assistantIndex = [...visible]
.map((message, index) => ({ message, index }))
.reverse()
.find(({ message }) => message.type === "ai" && hasText(message))?.index;
if (assistantIndex === undefined) {
return;
}
const assistantMessage = visible[assistantIndex];
const humanMessage = [...visible.slice(0, assistantIndex)]
.reverse()
.find((message) => message.type === "human" && hasText(message));
if (!assistantMessage?.id || !humanMessage) {
return;
}
const question = messageText(humanMessage);
const answer = messageText(assistantMessage);
if (!question || !answer) {
return;
}
void createLlmWikiConversationDeposit({
thread_id: threadId,
question,
answer,
human_message_id: humanMessage.id ?? null,
assistant_message_id: assistantMessage.id,
assistant_id: assistantId ?? null,
knowledge_base_ids: knowledgeBaseIds ?? [],
}).catch((error) => {
console.warn("Failed to queue LLMWiki conversation deposit", error);
});
}
+16 -10
View File
@@ -1122,20 +1122,26 @@ export function useThreadStream({
}),
);
const ragModeEnabled =
localSettings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
const mergedContext = {
...context,
...extraContext,
};
const selectedLlmWikiIds = Array.isArray(
mergedContext.llmwiki_knowledge_base_ids,
)
? mergedContext.llmwiki_knowledge_base_ids
: [];
const ragModeEnabled =
localSettings.display.ragModeEnabled ||
isThreadRagModeLocked(threadId) ||
selectedLlmWikiIds.length > 0;
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
const contextCompressionEnabled =
localSettings.display.contextCompressionEnabled ?? false;
const mergedContext = {
...context,
...extraContext,
};
const hiddenContextMessages = normalizeHiddenContextMessages(options);
const submittedMessages: Message[] = [
...hiddenContextMessages.map((item) => ({
+3 -1
View File
@@ -24,7 +24,9 @@ export interface AgentThreadContext extends Record<string, unknown> {
/** Per-request switch for context compression (上下文压缩). Default off. */
summarization_enabled?: boolean;
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
knowledge_rag_enabled?: boolean;
knowledge_rag_enabled?: boolean;
/** DeerFlow LLMWiki mapping ids selected for this conversation. */
llmwiki_knowledge_base_ids?: string[];
agent_id?: string;
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
agent_name?: string;
+139 -4
View File
@@ -16,6 +16,7 @@ import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box";
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { QuestionNavigator } from "@/components/workspace/question-navigator";
@@ -24,7 +25,9 @@ import { TodoList } from "@/components/workspace/todo-list";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
import { Tooltip } from "@/components/workspace/tooltip";
import { normalizeAgentQuestions, useAgent } from "@/core/agents";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
@@ -69,6 +72,26 @@ const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder";
const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000;
const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000;
function readPendingKnowledgeSelection(agentId: string | undefined): string[] | null {
if (typeof window === "undefined" || !agentId) return null;
try {
const raw = sessionStorage.getItem("pendingAgentMessage");
if (!raw) return null;
const parsed = JSON.parse(raw) as {
agentId?: string;
knowledgeBaseIds?: unknown;
};
if (parsed.agentId !== agentId || !Array.isArray(parsed.knowledgeBaseIds)) {
return null;
}
return parsed.knowledgeBaseIds
.map((item) => String(item).trim())
.filter(Boolean);
} catch {
return null;
}
}
function stringifyForHiddenContext(value: unknown): string {
if (typeof value === "string") return value;
try {
@@ -244,6 +267,79 @@ export default function AgentChatPage({
useThreadChat();
const [settings, setSettings] = useThreadSettings(threadId);
const { tokenUsageEnabled } = useModels();
const pendingKnowledgeSelection = useRef(
readPendingKnowledgeSelection(agent_id),
);
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>(
() => pendingKnowledgeSelection.current ?? [],
);
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(
() => pendingKnowledgeSelection.current !== null,
);
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
const knowledgeSelectionThread = useRef<string | null>(null);
const knowledgeSelectionAgent = useRef(agent_id);
const activeThreadIdRef = useRef<string | null | undefined>(threadId);
const changeKnowledgeSelection = useCallback((ids: string[]) => {
setSelectedKnowledgeBaseIds(ids);
setKnowledgeSelectionReady(true);
}, []);
useEffect(() => {
activeThreadIdRef.current = threadId;
}, [threadId]);
useEffect(() => {
if (knowledgeSelectionAgent.current === agent_id) return;
knowledgeSelectionAgent.current = agent_id;
const pending = readPendingKnowledgeSelection(agent_id);
pendingKnowledgeSelection.current = pending;
knowledgeSelectionThread.current = null;
setSelectedKnowledgeBaseIds(pending ?? []);
setKnowledgeSelectionReady(pending !== null);
}, [agent_id]);
useEffect(() => {
if (isNewThread) {
if (knowledgeSelectionThread.current !== "__new__") {
setSelectedKnowledgeBaseIds(pendingKnowledgeSelection.current ?? []);
setKnowledgeSelectionReady(pendingKnowledgeSelection.current !== null);
}
knowledgeSelectionThread.current = "__new__";
setKnowledgeSelectionLoading(false);
return;
}
if (!threadId || knowledgeSelectionThread.current === threadId) return;
knowledgeSelectionThread.current = threadId;
setKnowledgeSelectionLoading(true);
void getAPIClient()
.threads.get(threadId)
.then((record) => {
if (knowledgeSelectionThread.current !== threadId) return;
const metadata = record.metadata as Record<string, unknown> | undefined;
const raw = metadata?.llmwiki_knowledge_base_ids;
if (Array.isArray(raw)) {
setSelectedKnowledgeBaseIds(
raw.map((item) => String(item).trim()).filter(Boolean),
);
setKnowledgeSelectionReady(true);
} else {
setSelectedKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
})
.catch(() => {
if (knowledgeSelectionThread.current === threadId) {
setKnowledgeSelectionReady(false);
}
})
.finally(() => {
if (knowledgeSelectionThread.current === threadId) {
setKnowledgeSelectionLoading(false);
}
});
}, [isNewThread, threadId]);
useEffect(() => {
if (isNewThread) {
@@ -253,14 +349,34 @@ export default function AgentChatPage({
}, [isNewThread, threadId, agent_id]);
const { showNotification } = useNotification();
const conversationMetadata = useMemo(
() => ({
...(taskId ? taskThreadMetadata(taskContext) : {}),
...(knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
: {}),
}),
[knowledgeSelectionReady, selectedKnowledgeBaseIds, taskContext, taskId],
);
const [thread, sendMessage] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
assistantId: agent_id,
context: { ...settings.context, agent_id },
context: {
...settings.context,
agent_id,
...(knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
: {}),
},
// 任务闸门:新建 thread 打 metadata.taskId 标签,供任务对话列表(1:N)按任务过滤;
// xdfx 额外打 metadata.actionId 标签,按「行动id」划分聊天记录(见 taskThreadMetadata)。
threadMetadata: taskId ? taskThreadMetadata(taskContext) : undefined,
threadMetadata:
Object.keys(conversationMetadata).length > 0
? conversationMetadata
: undefined,
onStart: (createdThreadId) => {
knowledgeSelectionThread.current = createdThreadId;
activeThreadIdRef.current = createdThreadId;
setThreadId(createdThreadId);
setIsNewThread(false);
// Keep the current component mounted while updating the hash route.
@@ -286,6 +402,12 @@ export default function AgentChatPage({
} catch {
/* 上报绝不影响问答 */
}
queueLlmWikiConversationDeposit({
threadId: activeThreadIdRef.current,
messages: state.messages,
assistantId: agent_id,
knowledgeBaseIds: selectedKnowledgeBaseIds,
});
if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished";
const lastMessage = state.messages[state.messages.length - 1];
@@ -581,10 +703,23 @@ export default function AgentChatPage({
autoFocus
status={status}
context={settings.context}
disabled={isDemoMode}
disabled={isDemoMode || knowledgeSelectionLoading}
onContextChange={(context) =>
setSettings("context", context)
}
knowledgeSelector={
<LlmWikiKnowledgeSelector
source="agent"
agentId={agent_id}
value={selectedKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
autoSelectAll={
isNewThread && pendingKnowledgeSelection.current === null
}
disabled={isDemoMode || knowledgeSelectionLoading}
label="知识空间"
/>
}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
@@ -636,7 +771,7 @@ export default function AgentChatPage({
assistantId={agent_id}
status={status}
context={settings.context}
disabled={isDemoMode}
disabled={isDemoMode || knowledgeSelectionLoading}
onContextChange={(context) =>
setSettings("context", context)
}
+110 -3
View File
@@ -16,6 +16,7 @@ import {
} from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { KnowledgeSaveTrigger } from "@/components/workspace/knowledge-save-trigger";
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
import { InputBox } from "@/components/workspace/input-box";
import {
MessageList,
@@ -52,6 +53,7 @@ import {
useThreadSettings,
} from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useLayoutMode } from "@/core/system-settings/hooks";
import { useRouteBase } from "@/core/route-base";
import { textOfMessage } from "@/core/threads/utils";
@@ -121,12 +123,29 @@ export default function ChatPage() {
// In-memory only — explicitly overrides any value persisted by the old
// per-thread header toggle so stale localStorage can't re-enable it.
const [knowledgeRagEnabled] = useKnowledgeRagEnabled();
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(false);
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
const knowledgeSelectionThread = useRef<string | null>(null);
const activeThreadIdRef = useRef<string | null | undefined>(threadId);
const changeKnowledgeSelection = useCallback((ids: string[]) => {
setSelectedKnowledgeBaseIds(ids);
setKnowledgeSelectionReady(true);
}, []);
const streamContext = useMemo(
() => ({
...settings.context,
knowledge_rag_enabled: knowledgeRagEnabled,
...(knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
: {}),
}),
[settings.context, knowledgeRagEnabled],
[
settings.context,
knowledgeRagEnabled,
knowledgeSelectionReady,
selectedKnowledgeBaseIds,
],
);
const [mounted, setMounted] = useState(false);
const { tokenUsageEnabled } = useModels();
@@ -150,6 +169,10 @@ export default function ChatPage() {
const { selectedArtifact } = useArtifacts();
useSpecificChatMode();
useEffect(() => {
activeThreadIdRef.current = threadId;
}, [threadId]);
useEffect(() => {
setMounted(true);
}, []);
@@ -158,12 +181,54 @@ export default function ChatPage() {
if (isNewThread) {
setHasStartedConversation(false);
setPromptPrefixOn(false);
setSelectedKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
setKnowledgeSelectionLoading(false);
knowledgeSelectionThread.current = "__new__";
// Pre-select the agent the user last started a conversation with so a
// fresh chat defaults to it. Validated against the known agent list below.
setSelectedAgentId(getLastAgentId());
}
}, [isNewThread, threadId]);
// The knowledge-base choice belongs to the conversation, not to a browser
// component instance. Restore it from LangGraph thread metadata whenever an
// existing conversation is opened. An explicit empty array means the user
// deliberately disabled LLMWiki retrieval for that conversation.
useEffect(() => {
if (isNewThread || !threadId || knowledgeSelectionThread.current === threadId) {
return;
}
knowledgeSelectionThread.current = threadId;
setKnowledgeSelectionLoading(true);
void getAPIClient(isMock)
.threads.get(threadId)
.then((record) => {
if (knowledgeSelectionThread.current !== threadId) return;
const metadata = record.metadata as Record<string, unknown> | undefined;
const raw = metadata?.llmwiki_knowledge_base_ids;
if (Array.isArray(raw)) {
setSelectedKnowledgeBaseIds(
raw.map((item) => String(item).trim()).filter(Boolean),
);
setKnowledgeSelectionReady(true);
} else {
setSelectedKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
})
.catch(() => {
if (knowledgeSelectionThread.current === threadId) {
setKnowledgeSelectionReady(false);
}
})
.finally(() => {
if (knowledgeSelectionThread.current === threadId) {
setKnowledgeSelectionLoading(false);
}
});
}, [isMock, isNewThread, threadId]);
// Drop a restored last-used agent that is no longer in the selector list
// (deleted / unpublished / no longer curated), so we never hand off to a
// dead agent. Wait for the list to load before deciding.
@@ -175,6 +240,8 @@ export default function ChatPage() {
if (known.length === 0) return;
if (!known.some((a) => a.id === selectedAgentId)) {
setSelectedAgentId(null);
setSelectedKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
@@ -183,8 +250,13 @@ export default function ChatPage() {
const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
context: streamContext,
threadMetadata: knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
: undefined,
isMock,
onStart: (createdThreadId) => {
knowledgeSelectionThread.current = createdThreadId;
activeThreadIdRef.current = createdThreadId;
setThreadId(createdThreadId);
setIsNewThread(false);
// Keep the current component mounted while updating the hash route.
@@ -210,6 +282,12 @@ export default function ChatPage() {
} catch {
/* 上报绝不影响问答 */
}
queueLlmWikiConversationDeposit({
threadId: activeThreadIdRef.current,
messages: state.messages,
assistantId: selectedAgentId,
knowledgeBaseIds: selectedKnowledgeBaseIds,
});
if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished";
const lastMessage = state.messages.at(-1);
@@ -262,7 +340,12 @@ export default function ChatPage() {
void (async () => {
try {
const created = await getAPIClient(isMock).threads.create({
metadata: { agent_id: selectedAgentId },
metadata: {
agent_id: selectedAgentId,
...(knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
: {}),
},
});
sessionStorage.setItem(
"pendingAgentMessage",
@@ -270,6 +353,9 @@ export default function ChatPage() {
agentId: selectedAgentId,
threadId: created.thread_id,
text,
...(knowledgeSelectionReady
? { knowledgeBaseIds: selectedKnowledgeBaseIds }
: {}),
}),
);
saveLastAgentId(selectedAgentId);
@@ -328,6 +414,8 @@ export default function ChatPage() {
hideAdminPrompt,
preferencesLoading,
selectedAgentId,
selectedKnowledgeBaseIds,
knowledgeSelectionReady,
agentHandoffPending,
isMock,
router,
@@ -418,6 +506,8 @@ export default function ChatPage() {
// Passing null (the ✕ clear) wipes the stored preference.
const handleAgentChange = useCallback((agentId: string | null) => {
setSelectedAgentId(agentId);
setSelectedKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
saveLastAgentId(agentId);
}, []);
const handleStop = useCallback(async () => {
@@ -620,7 +710,8 @@ export default function ChatPage() {
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading ||
agentHandoffPending
agentHandoffPending ||
knowledgeSelectionLoading
}
onContextChange={(context) =>
setSettings("context", context)
@@ -634,6 +725,22 @@ export default function ChatPage() {
}
: undefined
}
knowledgeSelector={
isLandingState ? (
<LlmWikiKnowledgeSelector
source={selectedAgentId ? "agent" : "all"}
agentId={selectedAgentId}
value={selectedKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
autoSelectAll={!!selectedAgentId}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
knowledgeSelectionLoading
}
label="知识空间"
/>
) : undefined
}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
+7 -2
View File
@@ -1,5 +1,5 @@
import { Loader2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
@@ -530,7 +530,11 @@ function VaultPageDialog({
);
}
export default function KnowledgeBasePage() {
export default function KnowledgeBasePage({
headerExtraActions,
}: {
headerExtraActions?: ReactNode;
} = {}) {
const navigate = useNavigate();
const { noteId } = useParams<{ noteId: string }>();
@@ -653,6 +657,7 @@ export default function KnowledgeBasePage() {
onCreate={() => setCreateOpen(true)}
onManageFolders={() => setFoldersOpen(true)}
onManageTemplates={() => setTemplatesOpen(true)}
extraActions={headerExtraActions}
/>
<div
@@ -0,0 +1,91 @@
import { Loader2, Settings2 } from "lucide-react";
import { useState } from "react";
import { Navigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { getStoredAuth } from "@/core/auth";
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import WeKnoraKnowledgeWorkspace, {
ConnectionSettings,
} from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
import WeKnoraKnowledgeDetailPage from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage";
import KnowledgeBasePage from "./KnowledgeBasePage";
import KnowledgeGraphPage from "./KnowledgeGraphPage";
type LlmWikiKnowledgePageMode = "list" | "detail" | "graph";
function RuntimeLoading() {
return (
<div className="flex h-full min-h-64 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-5 animate-spin" />
正在读取 LLMWiki 运行模式
</div>
);
}
function AdminWeKnoraSettingsButton() {
const [open, setOpen] = useState(false);
return (
<>
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
<Settings2 className="mr-2 size-4" />
配置 WeKnora
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>LLMWiki 运行模式</DialogTitle>
</DialogHeader>
<ConnectionSettings />
</DialogContent>
</Dialog>
</>
);
}
function LegacyKnowledgePage() {
const isAdmin = getStoredAuth()?.system_role === "admin";
return (
<KnowledgeBasePage
headerExtraActions={isAdmin ? <AdminWeKnoraSettingsButton /> : undefined}
/>
);
}
export default function LlmWikiKnowledgePage({
mode = "list",
}: {
mode?: LlmWikiKnowledgePageMode;
}) {
const runtime = useLlmWikiRuntime();
if (runtime.isLoading) {
return <RuntimeLoading />;
}
if (runtime.isError || runtime.data?.provider !== "weknora") {
if (mode === "graph") {
return <KnowledgeGraphPage />;
}
return <LegacyKnowledgePage />;
}
if (mode === "graph") {
return <Navigate to="/page/workspace/knowledge" replace />;
}
if (mode === "detail") {
return <WeKnoraKnowledgeDetailPage />;
}
return <WeKnoraKnowledgeWorkspace />;
}
+12
View File
@@ -31,6 +31,7 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
import {
MessageList,
type SetupAgentApprovalPayload,
@@ -95,6 +96,7 @@ export default function NewAgentPage() {
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
const [isSavingDraft, setIsSavingDraft] = useState(false);
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
// 恢复已有 bootstrap 会话时该 thread 已存在,无需再 create(置为 true,跳过创建)。
@@ -230,6 +232,7 @@ export default function NewAgentPage() {
model: modelOverride,
template_name: templateNameInput.trim(),
seat_skill_directive: seatSkillDirectiveInput,
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
});
setAgent(updated);
return updated;
@@ -252,6 +255,7 @@ export default function NewAgentPage() {
model: modelOverride,
template_name: templateNameInput.trim(),
seat_skill_directive: seatSkillDirectiveInput,
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
});
setAgentId(created.id);
setAgent(created);
@@ -280,6 +284,7 @@ export default function NewAgentPage() {
descriptionInput,
modelInput,
selectedSkills,
selectedKnowledgeBaseIds,
soulInput,
templateNameInput,
seatSkillDirectiveInput,
@@ -590,6 +595,13 @@ export default function NewAgentPage() {
</p>
</div>
<LlmWikiKnowledgeSelector
source="all"
value={selectedKnowledgeBaseIds}
onChange={setSelectedKnowledgeBaseIds}
variant="panel"
/>
<div className="space-y-2">
<label className="text-sm font-medium">SOUL.md</label>
<Textarea
+4 -5
View File
@@ -35,8 +35,7 @@ import AdminScheduledTasksPage from './AdminScheduledTasksPage';
import AdminSkillsPage from './AdminSkillsPage';
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
import ScheduledTasksPage from './ScheduledTasksPage';
import KnowledgeBasePage from './KnowledgeBasePage';
import KnowledgeGraphPage from './KnowledgeGraphPage';
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
import LlmMetricsPage from './LlmMetricsPage';
import CheckpointMigrationPage from './CheckpointMigrationPage';
import MemoryPage from './MemoryPage';
@@ -604,7 +603,7 @@ export default function WorkspaceRoutes({
path="knowledge"
element={
<WorkspaceLayout {...layoutProps}>
<KnowledgeBasePage />
<LlmWikiKnowledgePage />
</WorkspaceLayout>
}
/>
@@ -612,7 +611,7 @@ export default function WorkspaceRoutes({
path="knowledge/graph"
element={
<WorkspaceLayout {...layoutProps}>
<KnowledgeGraphPage />
<LlmWikiKnowledgePage mode="graph" />
</WorkspaceLayout>
}
/>
@@ -620,7 +619,7 @@ export default function WorkspaceRoutes({
path="knowledge/:noteId"
element={
<WorkspaceLayout {...layoutProps}>
<KnowledgeBasePage />
<LlmWikiKnowledgePage mode="detail" />
</WorkspaceLayout>
}
/>
@@ -0,0 +1,689 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
const API = () => `${getBackendBaseURL()}/api/llmwiki`;
const SETTINGS_API = () => `${getBackendBaseURL()}/api/system-settings/llmwiki`;
export type LlmWikiProvider = "legacy" | "weknora";
export type LlmWikiScope = "personal" | "public" | "all";
export interface LlmWikiRuntime {
provider: LlmWikiProvider;
api_base_url: string;
web_base_url: string;
override_enabled: boolean;
}
export interface LlmWikiSettings extends LlmWikiRuntime {
file_api_base_url: string;
file_web_base_url: string;
effective_provider: LlmWikiProvider;
}
export interface LlmWikiKnowledgeBase {
id: string;
remote_status: "available" | "missing";
name: string;
description: string;
type: "document" | "faq";
publication_status: "private" | "pending" | "published" | "rejected";
owner_user_id?: string | null;
is_owner: boolean;
can_write: boolean;
knowledge_count: number;
chunk_count: number;
processing_count: number;
embedding_model_id?: string | null;
vector_store_id?: string | null;
vector_store_name?: string | null;
vector_store_source?: string | null;
vector_store_engine_type?: string | null;
vector_store_status?: string | null;
chunking_config?: Record<string, unknown>;
indexing_strategy?: Record<string, boolean>;
capabilities?: Record<string, boolean>;
extract_config?: Record<string, unknown>;
wiki_config?: Record<string, unknown>;
question_generation_config?: Record<string, unknown>;
summary_model_id?: string | null;
storage_provider?: string | null;
created_at?: string | null;
updated_at?: string | null;
published_at?: string | null;
}
export interface LlmWikiDocument {
id: string;
type?: string;
title?: string;
description?: string;
source?: string;
channel?: string;
file_name?: string;
file_type?: string;
file_size?: number;
storage_size?: number;
parse_status?: string;
pending_subtasks_count?: number;
summary_status?: string;
enable_status?: string;
embedding_model_id?: string;
created_at?: string;
updated_at?: string;
processed_at?: string;
error_message?: string;
}
export interface LlmWikiChunk {
id: string;
content: string;
chunk_index: number;
is_enabled: boolean;
status?: number | string | null;
start_at: number;
end_at: number;
chunk_type: string;
parent_chunk_id?: string;
relation_chunk_ids: string[];
indirect_relation_chunk_ids: string[];
metadata: Record<string, unknown>;
created_at?: string;
updated_at?: string;
}
export interface LlmWikiChunkPage {
items: LlmWikiChunk[];
total: number;
page: number;
page_size: number;
}
export interface LlmWikiGraph {
nodes: Array<{ id: string; title: string; type: string; link_count: number }>;
edges: Array<{ source: string; target: string }>;
meta: {
mode?: string;
total?: number;
returned?: number;
truncated?: boolean;
};
}
export interface LlmWikiWikiPage {
id: string;
slug: string;
title: string;
page_type: string;
status: string;
content: string;
summary: string;
aliases: string[];
parent_slug?: string;
category_path?: string[];
depth?: number;
source_refs: string[];
in_links: string[];
out_links: string[];
version: number;
created_at?: string;
updated_at?: string;
}
export interface LlmWikiWikiPageList {
pages: LlmWikiWikiPage[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
export interface LlmWikiSearchResult {
chunk_id: string;
content: string;
knowledge_id: string;
knowledge_base_id: string;
knowledge_base_name: string;
title: string;
filename: string;
score: number;
chunk_index: number;
url: string;
}
async function readJson<T>(response: Response, context: string): Promise<T> {
if (!response.ok) {
const payload = await response.json().catch(() => null as unknown);
const detail =
payload && typeof payload === "object" && "detail" in payload
? String((payload as { detail: unknown }).detail)
: `${context}失败(${response.status})`;
throw new Error(detail);
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
export async function getLlmWikiRuntime(): Promise<LlmWikiRuntime> {
return readJson(
await apiFetch(`${API()}/runtime`),
"读取 LLMWiki 运行模式",
);
}
export async function listLlmWikiKnowledgeBases(
scope: LlmWikiScope,
): Promise<LlmWikiKnowledgeBase[]> {
const response = await apiFetch(
`${API()}/knowledge-bases?scope=${encodeURIComponent(scope)}`,
);
const payload = await readJson<{ knowledge_bases: LlmWikiKnowledgeBase[] }>(
response,
"读取知识库",
);
return payload.knowledge_bases ?? [];
}
export async function listAgentLlmWikiKnowledgeBases(
agentId: string,
): Promise<LlmWikiKnowledgeBase[]> {
const payload = await readJson<{ knowledge_bases: LlmWikiKnowledgeBase[] }>(
await apiFetch(
`${API()}/agents/${encodeURIComponent(agentId)}/knowledge-bases`,
),
"读取智能体知识库",
);
return payload.knowledge_bases ?? [];
}
export async function getLlmWikiKnowledgeBase(
id: string,
): Promise<LlmWikiKnowledgeBase> {
return readJson(
await apiFetch(`${API()}/knowledge-bases/${encodeURIComponent(id)}`),
"读取知识库详情",
);
}
export async function createLlmWikiKnowledgeBase(input: {
name: string;
description: string;
type: "document" | "faq";
wiki_enabled?: boolean;
}): Promise<LlmWikiKnowledgeBase> {
return readJson(
await apiFetch(`${API()}/knowledge-bases`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
}),
"创建知识库",
);
}
export async function updateLlmWikiKnowledgeBase(
id: string,
input: { name?: string; description?: string },
): Promise<LlmWikiKnowledgeBase> {
return readJson(
await apiFetch(`${API()}/knowledge-bases/${encodeURIComponent(id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
}),
"更新知识库",
);
}
export async function deleteLlmWikiKnowledgeBase(id: string): Promise<void> {
await readJson<void>(
await apiFetch(`${API()}/knowledge-bases/${encodeURIComponent(id)}`, {
method: "DELETE",
}),
"删除知识库",
);
}
export async function listLlmWikiDocuments(
knowledgeBaseId: string,
): Promise<LlmWikiDocument[]> {
const payload = await readJson<{ items: LlmWikiDocument[] }>(
await apiFetch(
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents`,
),
"读取文档",
);
return payload.items ?? [];
}
export async function getLlmWikiDocument(
knowledgeBaseId: string,
documentId: string,
): Promise<LlmWikiDocument> {
return readJson(
await apiFetch(
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents/${encodeURIComponent(documentId)}`,
),
"读取文档详情",
);
}
export async function listLlmWikiDocumentChunks(
knowledgeBaseId: string,
documentId: string,
page: number,
pageSize: number,
): Promise<LlmWikiChunkPage> {
return readJson(
await apiFetch(
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents/${encodeURIComponent(documentId)}/chunks?page=${page}&page_size=${pageSize}`,
),
"读取文档索引",
);
}
export async function getLlmWikiDocumentPreview(
knowledgeBaseId: string,
documentId: string,
): Promise<Blob> {
const response = await apiFetch(
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents/${encodeURIComponent(documentId)}/preview`,
);
if (!response.ok) {
await readJson<void>(response, "读取文档原件");
throw new Error("读取文档原件失败");
}
return response.blob();
}
export async function getLlmWikiGraph(knowledgeBaseId: string): Promise<LlmWikiGraph> {
return readJson(
await apiFetch(`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/graph?limit=200`),
"读取知识图谱",
);
}
export async function listLlmWikiWikiPages(
knowledgeBaseId: string,
page: number,
pageSize: number,
query = "",
): Promise<LlmWikiWikiPageList> {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (query.trim()) params.set("query", query.trim());
return readJson(
await apiFetch(
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/pages?${params.toString()}`,
),
"读取 Wiki 页面",
);
}
export async function getLlmWikiWikiPage(
knowledgeBaseId: string,
slug: string,
): Promise<LlmWikiWikiPage> {
const safeSlug = slug.split("/").map(encodeURIComponent).join("/");
return readJson(
await apiFetch(
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/pages/${safeSlug}`,
),
"读取 Wiki 页面详情",
);
}
export async function uploadLlmWikiDocument(
knowledgeBaseId: string,
file: File,
): Promise<LlmWikiDocument> {
const form = new FormData();
form.append("file", file);
return readJson(
await apiFetch(
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents`,
{ method: "POST", body: form },
),
"上传文档",
);
}
export async function importLlmWikiDocumentUrl(
knowledgeBaseId: string,
url: string,
): Promise<LlmWikiDocument> {
return readJson(
await apiFetch(
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents/url`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
},
),
"导入网页",
);
}
export async function createManualLlmWikiDocument(
knowledgeBaseId: string,
input: { title: string; content: string },
): Promise<LlmWikiDocument> {
return readJson(
await apiFetch(
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents/manual`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
},
),
"创建 Wiki 内容",
);
}
export async function deleteLlmWikiDocument(
knowledgeBaseId: string,
documentId: string,
): Promise<void> {
await readJson<void>(
await apiFetch(
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents/${encodeURIComponent(documentId)}`,
{ method: "DELETE" },
),
"删除文档",
);
}
export async function reprocessLlmWikiDocument(
knowledgeBaseId: string,
documentId: string,
): Promise<void> {
await readJson(
await apiFetch(
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents/${encodeURIComponent(documentId)}/reprocess`,
{ method: "POST" },
),
"重新解析文档",
);
}
export async function setLlmWikiPublication(
knowledgeBaseId: string,
publish: boolean,
): Promise<void> {
await readJson(
await apiFetch(
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/${publish ? "publish" : "unpublish"}`,
{ method: "POST" },
),
publish ? "发布知识库" : "取消发布",
);
}
export async function searchLlmWiki(input: {
query: string;
scope: LlmWikiScope;
knowledge_base_ids?: string[];
}): Promise<LlmWikiSearchResult[]> {
const payload = await readJson<{ results: LlmWikiSearchResult[] }>(
await apiFetch(`${API()}/search`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
}),
"检索知识库",
);
return payload.results ?? [];
}
export async function createLlmWikiConversationDeposit(input: {
thread_id: string;
question: string;
answer: string;
human_message_id?: string | null;
assistant_message_id: string;
assistant_id?: string | null;
knowledge_base_ids?: string[];
}): Promise<{ queued: boolean; provider: LlmWikiProvider }> {
return readJson(
await apiFetch(`${API()}/conversation-deposits/turn`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
}),
"写入对话沉淀",
);
}
export async function getLlmWikiSettings(): Promise<LlmWikiSettings> {
return readJson(await apiFetch(SETTINGS_API()), "读取 WeKnora 连接配置");
}
export async function saveLlmWikiSettings(input: {
override_enabled: boolean;
api_base_url: string;
web_base_url: string;
}): Promise<LlmWikiRuntime> {
return readJson(
await apiFetch(SETTINGS_API(), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
}),
"保存 WeKnora 连接配置",
);
}
export async function resetLlmWikiSettings(): Promise<LlmWikiRuntime> {
return readJson(
await apiFetch(`${SETTINGS_API()}/override`, { method: "DELETE" }),
"恢复配置文件设置",
);
}
export async function testLlmWikiConnection(
apiBaseUrl: string,
): Promise<void> {
await readJson(
await apiFetch(`${SETTINGS_API()}/test`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_base_url: apiBaseUrl }),
}),
"测试 WeKnora 连接",
);
}
const runtimeKey = ["llmwiki", "runtime"] as const;
const settingsKey = ["llmwiki", "settings"] as const;
const knowledgeBasesKey = (scope: LlmWikiScope) =>
["llmwiki", "knowledge-bases", scope] as const;
export function useLlmWikiRuntime() {
return useQuery({
queryKey: runtimeKey,
queryFn: getLlmWikiRuntime,
staleTime: 30_000,
retry: 1,
});
}
export function useLlmWikiKnowledgeBases(
scope: LlmWikiScope,
enabled = true,
) {
return useQuery({
queryKey: knowledgeBasesKey(scope),
queryFn: () => listLlmWikiKnowledgeBases(scope),
enabled,
refetchInterval: 15_000,
});
}
export function useAgentLlmWikiKnowledgeBases(
agentId: string | null | undefined,
enabled = true,
) {
return useQuery({
queryKey: ["llmwiki", "agents", agentId, "knowledge-bases"],
queryFn: () => listAgentLlmWikiKnowledgeBases(agentId!),
enabled: enabled && !!agentId,
});
}
export function useLlmWikiKnowledgeBase(
knowledgeBaseId: string | null,
enabled = true,
) {
return useQuery({
queryKey: ["llmwiki", "knowledge-base", knowledgeBaseId],
queryFn: () => getLlmWikiKnowledgeBase(knowledgeBaseId!),
enabled: enabled && !!knowledgeBaseId,
refetchInterval: 8_000,
});
}
export function useLlmWikiDocuments(
knowledgeBaseId: string | null,
enabled = true,
) {
return useQuery({
queryKey: ["llmwiki", "documents", knowledgeBaseId],
queryFn: () => listLlmWikiDocuments(knowledgeBaseId!),
enabled: enabled && !!knowledgeBaseId,
refetchInterval: 8_000,
});
}
export function useLlmWikiDocument(
knowledgeBaseId: string | null,
documentId: string | null,
enabled = true,
) {
return useQuery({
queryKey: ["llmwiki", "document", knowledgeBaseId, documentId],
queryFn: () => getLlmWikiDocument(knowledgeBaseId!, documentId!),
enabled: enabled && !!knowledgeBaseId && !!documentId,
});
}
export function useLlmWikiDocumentChunks(
knowledgeBaseId: string | null,
documentId: string | null,
page: number,
pageSize = 20,
enabled = true,
) {
return useQuery({
queryKey: ["llmwiki", "document-chunks", knowledgeBaseId, documentId, page, pageSize],
queryFn: () => listLlmWikiDocumentChunks(knowledgeBaseId!, documentId!, page, pageSize),
enabled: enabled && !!knowledgeBaseId && !!documentId,
});
}
export function useLlmWikiGraph(knowledgeBaseId: string | null, enabled = true) {
return useQuery({
queryKey: ["llmwiki", "graph", knowledgeBaseId],
queryFn: () => getLlmWikiGraph(knowledgeBaseId!),
enabled: enabled && !!knowledgeBaseId,
});
}
export function useLlmWikiWikiPages(
knowledgeBaseId: string | null,
page: number,
pageSize = 50,
query = "",
enabled = true,
) {
return useQuery({
queryKey: ["llmwiki", "wiki-pages", knowledgeBaseId, page, pageSize, query],
queryFn: () => listLlmWikiWikiPages(knowledgeBaseId!, page, pageSize, query),
enabled: enabled && !!knowledgeBaseId,
refetchInterval: 10_000,
});
}
export function useLlmWikiWikiPage(
knowledgeBaseId: string | null,
slug: string | null,
enabled = true,
) {
return useQuery({
queryKey: ["llmwiki", "wiki-page", knowledgeBaseId, slug],
queryFn: () => getLlmWikiWikiPage(knowledgeBaseId!, slug!),
enabled: enabled && !!knowledgeBaseId && !!slug,
});
}
export function useLlmWikiSettings(enabled = true) {
return useQuery({
queryKey: settingsKey,
queryFn: getLlmWikiSettings,
enabled,
});
}
export function useLlmWikiActions() {
const queryClient = useQueryClient();
const invalidateKnowledge = () =>
queryClient.invalidateQueries({ queryKey: ["llmwiki"] });
return {
create: useMutation({
mutationFn: createLlmWikiKnowledgeBase,
onSuccess: invalidateKnowledge,
}),
update: useMutation({
mutationFn: ({ id, ...input }: { id: string; name?: string; description?: string }) =>
updateLlmWikiKnowledgeBase(id, input),
onSuccess: invalidateKnowledge,
}),
remove: useMutation({
mutationFn: deleteLlmWikiKnowledgeBase,
onSuccess: invalidateKnowledge,
}),
upload: useMutation({
mutationFn: ({ id, file }: { id: string; file: File }) =>
uploadLlmWikiDocument(id, file),
onSuccess: invalidateKnowledge,
}),
importUrl: useMutation({
mutationFn: ({ id, url }: { id: string; url: string }) =>
importLlmWikiDocumentUrl(id, url),
onSuccess: invalidateKnowledge,
}),
createManual: useMutation({
mutationFn: ({ id, title, content }: { id: string; title: string; content: string }) =>
createManualLlmWikiDocument(id, { title, content }),
onSuccess: invalidateKnowledge,
}),
removeDocument: useMutation({
mutationFn: ({ id, documentId }: { id: string; documentId: string }) =>
deleteLlmWikiDocument(id, documentId),
onSuccess: invalidateKnowledge,
}),
reprocessDocument: useMutation({
mutationFn: ({ id, documentId }: { id: string; documentId: string }) =>
reprocessLlmWikiDocument(id, documentId),
onSuccess: invalidateKnowledge,
}),
publication: useMutation({
mutationFn: ({ id, publish }: { id: string; publish: boolean }) =>
setLlmWikiPublication(id, publish),
onSuccess: invalidateKnowledge,
}),
};
}
export function useLlmWikiSettingsActions() {
const queryClient = useQueryClient();
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: ["llmwiki"] });
};
return {
save: useMutation({ mutationFn: saveLlmWikiSettings, onSuccess: invalidate }),
reset: useMutation({ mutationFn: resetLlmWikiSettings, onSuccess: invalidate }),
test: useMutation({ mutationFn: testLlmWikiConnection }),
};
}
@@ -0,0 +1,152 @@
import { AlertCircle, Loader2, RefreshCw } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import { Navigate, useNavigate, useParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { getBackendBaseURL } from "@/core/config";
import {
useLlmWikiKnowledgeBase,
useLlmWikiRuntime,
} from "@/strategy-components/api/llmwiki";
const LIST_PATH = "/page/workspace/knowledge";
type DeerFlowThemePayload = {
type: "deerflow-weknora-theme";
theme: "dark" | "light";
vars: Record<string, string>;
};
function cssVar(name: string, fallback = ""): string {
if (typeof window === "undefined") return fallback;
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
}
function collectThemePayload(): DeerFlowThemePayload {
const isDark =
document.documentElement.classList.contains("dark") ||
document.documentElement.dataset.theme === "dark";
return {
type: "deerflow-weknora-theme",
theme: isDark ? "dark" : "light",
vars: {
primary: cssVar("--primary", isDark ? "#2b81ff" : "#0062d9"),
primaryForeground: cssVar("--button-foreground", cssVar("--primary-foreground", "#ffffff")),
background: cssVar("--background", isDark ? "#0e172b" : "#ffffff"),
foreground: cssVar("--foreground", isDark ? "#eff2f5" : "#202a3a"),
card: cssVar("--card", isDark ? "#1a2539" : "#ffffff"),
cardForeground: cssVar("--card-foreground", cssVar("--foreground", isDark ? "#eff2f5" : "#202a3a")),
muted: cssVar("--muted", isDark ? "#232e40" : "#f3f5f8"),
mutedForeground: cssVar("--muted-foreground", isDark ? "#8e9aa4" : "#7f7f7f"),
border: cssVar("--border", isDark ? "rgba(255,255,255,0.12)" : "#dbdbdb"),
popover: cssVar("--popover", cssVar("--card", isDark ? "#1a2539" : "#ffffff")),
destructive: cssVar("--destructive", "#d54941"),
},
};
}
function postThemeToFrame(frame: HTMLIFrameElement | null) {
if (!frame?.contentWindow) return;
const src = frame.getAttribute("src") || "";
const targetOrigin = new URL(src, window.location.href).origin;
frame.contentWindow.postMessage(collectThemePayload(), targetOrigin);
}
export function WeKnoraKnowledgeDetailPage() {
const {
knowledge_base_id: strategyKnowledgeBaseId,
noteId: workspaceKnowledgeBaseId,
} = useParams();
const knowledgeBaseId = strategyKnowledgeBaseId ?? workspaceKnowledgeBaseId ?? "";
const navigate = useNavigate();
const frameRef = useRef<HTMLIFrameElement>(null);
const runtime = useLlmWikiRuntime();
const details = useLlmWikiKnowledgeBase(knowledgeBaseId || null, !!knowledgeBaseId);
const preferredTab = details.data?.indexing_strategy?.wiki_enabled ? "wiki" : "documents";
const frameSrc = useMemo(() => {
if (!knowledgeBaseId || runtime.data?.provider !== "weknora") return "";
const base = getBackendBaseURL().replace(/\/+$/, "");
const params = new URLSearchParams({ tab: preferredTab });
return `${base}/api/llmwiki/knowledge-bases/${encodeURIComponent(
knowledgeBaseId,
)}/weknora/frame?${params.toString()}`;
}, [knowledgeBaseId, preferredTab, runtime.data?.provider]);
useEffect(() => {
if (!frameSrc) return;
const frame = frameRef.current;
const send = () => postThemeToFrame(frame);
const timer = window.setTimeout(send, 300);
const observer = new MutationObserver(send);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style", "data-theme"],
});
window.addEventListener("storage", send);
return () => {
window.clearTimeout(timer);
observer.disconnect();
window.removeEventListener("storage", send);
};
}, [frameSrc]);
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" />
正在打开 WeKnora 详情页
</div>
);
}
if (runtime.data?.provider !== "weknora") return <Navigate to={LIST_PATH} replace />;
if (details.error || !details.data) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 bg-background px-6 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<AlertCircle className="size-6" />
</div>
<div>
<div className="text-base font-semibold text-foreground">无法打开知识库详情</div>
<p className="mt-2 text-sm text-muted-foreground">
当前账号没有访问权限,或 WeKnora 资源已不存在。
</p>
</div>
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>
返回知识库
</Button>
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<iframe
key={frameSrc}
ref={frameRef}
title={`${details.data.name || "WeKnora"} 详情`}
src={frameSrc}
className="h-full min-h-0 w-full flex-1 border-0 bg-background"
sandbox="allow-same-origin allow-scripts allow-forms allow-downloads allow-popups"
referrerPolicy="no-referrer"
onLoad={() => postThemeToFrame(frameRef.current)}
/>
{!frameSrc && (
<div className="absolute inset-0 flex items-center justify-center bg-background text-sm text-muted-foreground">
<Loader2 className="mr-2 size-5 animate-spin" />
正在准备 iframe
</div>
)}
{details.isFetching && (
<div className="pointer-events-none fixed bottom-4 right-4 rounded-full border bg-card px-3 py-2 text-xs text-muted-foreground shadow-sm">
<RefreshCw className="mr-2 inline size-3 animate-spin" />
同步知识库状态
</div>
)}
</div>
);
}
export default WeKnoraKnowledgeDetailPage;
@@ -0,0 +1,318 @@
import {
BookOpen,
Database,
FileText,
Globe2,
Loader2,
Plus,
RefreshCw,
Search,
Settings2,
Trash2,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { getStoredAuth } from "@/core/auth";
import {
type LlmWikiKnowledgeBase,
type LlmWikiScope,
searchLlmWiki,
useLlmWikiActions,
useLlmWikiKnowledgeBases,
useLlmWikiSettings,
useLlmWikiSettingsActions,
} from "@/strategy-components/api/llmwiki";
type WorkspaceTab = "personal" | "public" | "settings";
const LIST_PATH = "/page/workspace/knowledge";
const publicationLabels: Record<string, string> = {
private: "仅自己",
pending: "仅自己",
published: "已公开",
rejected: "仅自己",
};
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : "操作失败,请稍后重试";
}
export function WeKnoraKnowledgeWorkspace() {
const navigate = useNavigate();
const isAdmin = getStoredAuth()?.system_role === "admin";
const [tab, setTab] = useState<WorkspaceTab>("personal");
const scope: LlmWikiScope = tab === "public" ? "public" : "personal";
const knowledge = useLlmWikiKnowledgeBases(scope, tab !== "settings");
const actions = useLlmWikiActions();
const [createOpen, setCreateOpen] = useState(false);
const [searchText, setSearchText] = useState("");
const [searching, setSearching] = useState(false);
const [searchResults, setSearchResults] = useState<Array<{
chunk_id: string;
title: string;
content: string;
score: number;
knowledge_base_name: string;
}>>([]);
useEffect(() => setSearchResults([]), [tab]);
const busy = actions.create.isPending || actions.remove.isPending || actions.publication.isPending;
const openDetails = (item: LlmWikiKnowledgeBase) => {
if (item.remote_status === "missing") {
toast.error("该知识库已从 WeKnora 删除,只能清理当前记录");
return;
}
navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}`);
};
const runSearch = async () => {
const query = searchText.trim();
if (!query) return;
setSearching(true);
try {
setSearchResults(await searchLlmWiki({ query, scope }));
} catch (error) {
toast.error(messageOf(error));
} finally {
setSearching(false);
}
};
const removeKnowledgeBase = async (item: LlmWikiKnowledgeBase) => {
const warning = item.remote_status === "missing"
? `知识库“${item.name}”已不在 WeKnora 中。确定清理 DeerFlow 中的这条失效记录吗?`
: `确定删除知识库“${item.name}”吗?WeKnora 中的文档和索引也会删除。`;
if (!window.confirm(warning)) return;
try {
await actions.remove.mutateAsync(item.id);
toast.success("知识库已删除");
} catch (error) {
toast.error(messageOf(error));
}
};
const togglePublication = async (item: LlmWikiKnowledgeBase) => {
const publish = item.publication_status !== "published";
try {
await actions.publication.mutateAsync({ id: item.id, publish });
toast.success(publish ? "知识库已发布到公共 LLMWiki" : "知识库已取消公开");
} catch (error) {
toast.error(messageOf(error));
}
};
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<div className="border-b bg-gradient-to-r from-primary/8 via-card to-card px-6 py-5">
<div className="mx-auto flex max-w-[1500px] flex-wrap items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<Database className="size-5 text-primary" />
<h1 className="text-xl font-semibold">问答梳理</h1>
<Badge variant="secondary">WeKnora</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">由 WeKnora 负责解析与检索,用户、权限和发布范围由 DeerFlow 管理。</p>
</div>
<div className="flex items-center gap-2 rounded-full border bg-background/80 p-1 shadow-sm backdrop-blur">
{tab !== "settings" && (
<Button variant="ghost" size="sm" className="rounded-full" onClick={() => knowledge.refetch()} disabled={knowledge.isFetching}>
<RefreshCw className={`mr-2 size-4 ${knowledge.isFetching ? "animate-spin" : ""}`} />刷新
</Button>
)}
{tab === "personal" && <Button size="sm" className="rounded-full px-4 shadow-sm" onClick={() => setCreateOpen(true)}><Plus className="mr-2 size-4" />新建知识库</Button>}
</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto px-6 py-5">
<div className="mx-auto max-w-[1500px]">
<Tabs value={tab} onValueChange={(value) => setTab(value as WorkspaceTab)}>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border bg-card/80 p-3 shadow-sm">
<TabsList className="rounded-xl bg-muted/70">
<TabsTrigger value="personal"><BookOpen className="mr-2 size-4" />我的知识库</TabsTrigger>
<TabsTrigger value="public"><Globe2 className="mr-2 size-4" />公共 LLMWiki</TabsTrigger>
{isAdmin && <TabsTrigger value="settings"><Settings2 className="mr-2 size-4" />连接配置</TabsTrigger>}
</TabsList>
{tab !== "settings" && (
<div className="flex w-full max-w-xl items-center gap-2 rounded-xl border bg-background/80 p-1 shadow-sm sm:w-auto">
<div className="relative min-w-0 flex-1 sm:w-80">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input value={searchText} onChange={(event) => setSearchText(event.target.value)} onKeyDown={(event) => event.key === "Enter" && void runSearch()} placeholder={tab === "public" ? "检索公共知识" : "检索我的知识"} className="border-0 bg-transparent pl-9 shadow-none focus-visible:ring-0" />
</div>
<Button variant="secondary" className="rounded-lg px-4" onClick={() => void runSearch()} disabled={searching || !searchText.trim()}>{searching ? <Loader2 className="size-4 animate-spin" /> : "检索"}</Button>
</div>
)}
</div>
<TabsContent value="personal" className="mt-5"><KnowledgeGrid rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="还没有个人知识库,点击右上角创建。" busy={busy} onOpen={openDetails} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
<TabsContent value="public" className="mt-5"><KnowledgeGrid rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="公共 LLMWiki 暂无已发布知识库。" busy={busy} onOpen={openDetails} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
{isAdmin && <TabsContent value="settings" className="mt-5"><ConnectionSettings /></TabsContent>}
</Tabs>
{searchResults.length > 0 && tab !== "settings" && (
<Card className="mt-5">
<CardHeader className="pb-3"><CardTitle className="text-base">检索结果</CardTitle><CardDescription>结果由 WeKnora 检索返回,问答中使用同一授权链路。</CardDescription></CardHeader>
<CardContent className="space-y-3">{searchResults.map((result) => <div key={result.chunk_id} className="rounded-lg border p-3"><div className="flex items-center justify-between gap-3 text-sm font-medium"><span>{result.title || "未命名来源"}</span><Badge variant="outline">{result.knowledge_base_name}</Badge></div><p className="mt-2 line-clamp-3 whitespace-pre-wrap text-sm text-muted-foreground">{result.content}</p></div>)}</CardContent>
</Card>
)}
</div>
</div>
<CreateKnowledgeDialog open={createOpen} pending={actions.create.isPending} onOpenChange={setCreateOpen} onCreate={async (input) => {
try {
const created = await actions.create.mutateAsync(input);
setCreateOpen(false);
navigate(`${LIST_PATH}/${encodeURIComponent(created.id)}`);
toast.success("知识库已创建");
} catch (error) {
toast.error(messageOf(error));
}
}} />
</div>
);
}
function KnowledgeGrid({
rows,
loading,
error,
emptyText,
busy,
onOpen,
onPublish,
onDelete,
}: {
rows: LlmWikiKnowledgeBase[];
loading: boolean;
error: unknown;
emptyText: string;
busy: boolean;
onOpen: (item: LlmWikiKnowledgeBase) => void;
onPublish: (item: LlmWikiKnowledgeBase) => void;
onDelete: (item: LlmWikiKnowledgeBase) => void;
}) {
if (loading) return <div className="flex h-64 items-center justify-center text-muted-foreground"><Loader2 className="mr-2 size-5 animate-spin" />正在读取知识库</div>;
if (error) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-6 text-sm text-destructive">{messageOf(error)}</div>;
if (!rows.length) return <div className="flex h-64 items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">{emptyText}</div>;
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{rows.map((item) => (
<Card
key={item.id}
className={`group overflow-hidden rounded-2xl transition-all hover:-translate-y-0.5 hover:shadow-lg ${
item.remote_status === "missing"
? "border-amber-500/40 bg-amber-500/[0.03]"
: "border-border/70 bg-card/95 hover:border-primary/30"
}`}
>
<div className={`h-1 ${item.publication_status === "published" ? "bg-primary" : "bg-muted"}`} />
<button type="button" disabled={item.remote_status === "missing"} className="w-full text-left outline-none transition-colors enabled:hover:bg-muted/25 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-default" onClick={() => onOpen(item)}>
<CardHeader className="pb-3 pt-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0"><CardTitle className="truncate text-base">{item.name}</CardTitle><CardDescription className="mt-1 line-clamp-2 min-h-10">{item.description || "暂无描述"}</CardDescription></div>
<div className="flex shrink-0 flex-col items-end gap-1.5">{item.remote_status === "missing" ? <Badge variant="outline" className="border-amber-500/50 text-amber-700 dark:text-amber-300">远端已删除</Badge> : <Badge variant={item.publication_status === "published" ? "default" : "secondary"}>{publicationLabels[item.publication_status] ?? "仅自己"}</Badge>}{item.remote_status !== "missing" && item.indexing_strategy?.wiki_enabled && <Badge className="bg-violet-100 text-violet-700 hover:bg-violet-100 dark:bg-violet-950 dark:text-violet-300">Wiki</Badge>}</div>
</div>
</CardHeader>
<div className="px-6 pb-4"><div className="grid grid-cols-3 gap-2 rounded-xl border bg-muted/35 p-3 text-center text-xs"><div><div className="text-base font-semibold text-foreground">{item.knowledge_count}</div><span className="text-muted-foreground">文档</span></div><div><div className="text-base font-semibold text-foreground">{item.chunk_count}</div><span className="text-muted-foreground">分块</span></div><div><div className="text-base font-semibold text-foreground">{item.processing_count}</div><span className="text-muted-foreground">处理中</span></div></div></div>
</button>
<CardContent className="border-t bg-muted/20 px-6 py-4">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{item.remote_status === "missing" ? <span className="text-xs text-muted-foreground">WeKnora 资源不存在,可清理本地记录</span> : <Button size="sm" variant="outline" className="rounded-full" onClick={() => onOpen(item)}><FileText className="mr-2 size-4" />查看详情</Button>}
{item.remote_status !== "missing" && item.is_owner && <Button size="sm" variant="secondary" className="rounded-full" disabled={busy} onClick={() => void onPublish(item)}>{item.publication_status === "published" ? "取消公开" : "发布公共库"}</Button>}
</div>
{item.can_write && <Button size="icon" variant="ghost" className="size-8 shrink-0 rounded-full text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label={item.remote_status === "missing" ? `清理失效记录 ${item.name}` : `删除知识库 ${item.name}`} title={item.remote_status === "missing" ? "清理失效记录" : "删除知识库"} disabled={busy} onClick={() => void onDelete(item)}><Trash2 className="size-4" /></Button>}
</div>
</CardContent>
</Card>
))}
</div>
);
}
function CreateKnowledgeDialog({
open,
pending,
onOpenChange,
onCreate,
}: {
open: boolean;
pending: boolean;
onOpenChange: (value: boolean) => void;
onCreate: (input: { name: string; description: string; type: "document" | "faq"; wiki_enabled: boolean }) => Promise<void>;
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [wikiEnabled, setWikiEnabled] = useState(true);
useEffect(() => {
if (!open) { setName(""); setDescription(""); setWikiEnabled(true); }
}, [open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader><DialogTitle>新建个人知识库</DialogTitle><DialogDescription>选择知识库模式;模型、切块和存储策略仍由 WeKnora 管理。</DialogDescription></DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-name">名称</label><Input id="llmwiki-name" value={name} onChange={(event) => setName(event.target.value)} maxLength={255} /></div>
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-description">描述</label><Textarea id="llmwiki-description" value={description} onChange={(event) => setDescription(event.target.value)} rows={4} maxLength={2048} /></div>
<div className="space-y-2"><div className="text-sm font-medium">知识库模式</div><div className="grid gap-3 sm:grid-cols-2">
<button type="button" className={`rounded-xl border p-4 text-left transition-colors ${wikiEnabled ? "border-primary bg-primary/5 ring-1 ring-primary" : "hover:bg-muted/40"}`} onClick={() => setWikiEnabled(true)}><div className="flex items-center gap-2 text-sm font-semibold"><BookOpen className="size-4 text-violet-500" />Wiki 知识库</div><p className="mt-2 text-xs leading-5 text-muted-foreground">自动综合资料,生成 Wiki 页面和页面关系图谱。</p></button>
<button type="button" className={`rounded-xl border p-4 text-left transition-colors ${!wikiEnabled ? "border-primary bg-primary/5 ring-1 ring-primary" : "hover:bg-muted/40"}`} onClick={() => setWikiEnabled(false)}><div className="flex items-center gap-2 text-sm font-semibold"><FileText className="size-4 text-blue-500" />标准知识库</div><p className="mt-2 text-xs leading-5 text-muted-foreground">仅进行文档解析、切块与向量/关键词索引。</p></button>
</div></div>
</div>
<DialogFooter><Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button><Button disabled={pending || !name.trim()} onClick={() => void onCreate({ name: name.trim(), description: description.trim(), type: "document", wiki_enabled: wikiEnabled })}>{pending && <Loader2 className="mr-2 size-4 animate-spin" />}创建</Button></DialogFooter>
</DialogContent>
</Dialog>
);
}
export function ConnectionSettings() {
const settings = useLlmWikiSettings(true);
const actions = useLlmWikiSettingsActions();
const [apiUrl, setApiUrl] = useState("");
const [webUrl, setWebUrl] = useState("");
useEffect(() => {
if (!settings.data) return;
setApiUrl(settings.data.api_base_url || settings.data.file_api_base_url || "");
setWebUrl(settings.data.web_base_url || settings.data.file_web_base_url || "");
}, [settings.data]);
const status = useMemo(() => settings.data?.effective_provider === "weknora" ? "WeKnora 模式" : "原 LLMWiki 模式", [settings.data]);
if (settings.isLoading) return <div className="flex h-52 items-center justify-center"><Loader2 className="size-5 animate-spin" /></div>;
if (settings.error) return <div className="rounded-lg border border-destructive/30 p-5 text-sm text-destructive">{messageOf(settings.error)}</div>;
return (
<Card className="max-w-3xl">
<CardHeader><CardTitle className="flex items-center gap-2 text-base"><Settings2 className="size-5" />WeKnora 连接</CardTitle><CardDescription>这里只管理 DeerFlow 到 WeKnora 的连接地址。数据库、Redis、模型、存储和解析器仍在 WeKnora 自己的配置中维护。</CardDescription></CardHeader>
<CardContent className="space-y-5">
<div className="flex flex-wrap gap-2 text-sm"><Badge>{status}</Badge><Badge variant="secondary">服务端代理鉴权</Badge>{settings.data?.override_enabled && <Badge variant="outline">页面配置覆盖中</Badge>}</div>
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="weknora-api-url">WeKnora API 地址</label><Input id="weknora-api-url" value={apiUrl} onChange={(event) => setApiUrl(event.target.value)} placeholder="http://weknora-app:8080" /><p className="text-xs text-muted-foreground">非空启用 WeKnora;保存为空会切回原 LLMWiki。</p></div>
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="weknora-web-url">WeKnora 管理地址(可选)</label><Input id="weknora-web-url" value={webUrl} onChange={(event) => setWebUrl(event.target.value)} placeholder="https://weknora-admin.example.com" /></div>
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">配置文件地址:{settings.data?.file_api_base_url || "未配置"}。页面只配置地址;WeKnora iframe 免登录所需的管理员账号/密码只从后端 config.yaml 或环境变量读取,不在前端保存。</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" disabled={!apiUrl.trim() || actions.test.isPending} onClick={async () => { try { await actions.test.mutateAsync(apiUrl.trim()); toast.success("WeKnora 连接正常"); } catch (error) { toast.error(messageOf(error)); } }}>{actions.test.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}测试连接</Button>
<Button disabled={actions.save.isPending} onClick={async () => { try { await actions.save.mutateAsync({ override_enabled: true, api_base_url: apiUrl.trim(), web_base_url: webUrl.trim() }); toast.success(apiUrl.trim() ? "已启用 WeKnora 模式" : "已切换到原 LLMWiki 模式"); } catch (error) { toast.error(messageOf(error)); } }}>{actions.save.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}保存页面配置</Button>
<Button variant="secondary" disabled={actions.reset.isPending} onClick={async () => { try { await actions.reset.mutateAsync(); toast.success("已恢复 config.yaml 配置"); } catch (error) { toast.error(messageOf(error)); } }}>恢复配置文件</Button>
</div>
</CardContent>
</Card>
);
}
export default WeKnoraKnowledgeWorkspace;