feat: integrate WeKnora llmwiki knowledge flow
This commit is contained in:
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)) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
+152
@@ -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;
|
||||
+318
@@ -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;
|
||||
Reference in new issue
Block a user