Merge branch 'ji' into master
This commit is contained in:
commit
49d9ced04f
84 files changed
+13258
-662
No files matched your search
@@ -0,0 +1,7 @@
|
||||
# 技能知识归纳与“知识梳理”助手知识库
|
||||
|
||||
管理员在「技能管理 → 技能知识归纳」中选择一个或多个技能、一个或多个目标,系统按“技能 × 目标”创建后台任务。界面提供总体进度、单项失败重试、实体/关系审核、绑定重归纳、自动同步和两种解绑动作。技能列表通过一次 bindings 查询展示归纳目标,避免逐技能请求。
|
||||
|
||||
扫描仅处理知识资料。代码、脚本、锁文件、密钥文件以及 Markdown 代码块不会进入上传、摘要、提示词或知识指纹;ZIP 只读取逐项验证通过的知识成员。自动监听只比较该知识指纹,所以纯代码改动不会产生 stale 状态。
|
||||
|
||||
知识库页面新增「普通知识库 / 助手知识库」并行入口。管理员可将系统默认入口设为 v1(普通 WeKnora)或 v2(全局“知识梳理”)。助手库无需 WeKnora 即可初始化,所有用户可只读查看和搜索 Wiki;管理员可上传本地文件、从普通库导入、重新导入或删除来源、查看版本并回滚。来源删除只移除对应贡献,共享实体与关系继续保留。
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FilesIcon, XIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import {
|
||||
artifactPathname,
|
||||
listThreadArtifacts,
|
||||
mergeArtifactPaths,
|
||||
} from "@/core/artifacts/listing";
|
||||
@@ -94,7 +95,13 @@ const ChatBox: React.FC<{
|
||||
knowledgeReferencesEnabled = false,
|
||||
} = useThread();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const requestedArtifact = useMemo(() => {
|
||||
const value = searchParams.get("artifact")?.trim();
|
||||
return value ? artifactPathname(value) : null;
|
||||
}, [searchParams]);
|
||||
const threadIdRef = useRef(threadId);
|
||||
const openedRequestedArtifactRef = useRef<string | null>(null);
|
||||
const artifactsCountRef = useRef(0);
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const latestReferenceBatchIdRef = useRef<string | null>(null);
|
||||
@@ -135,7 +142,7 @@ const ChatBox: React.FC<{
|
||||
const { skills } = useSkills();
|
||||
|
||||
const canScanArtifactDirectory =
|
||||
artifactDirectoryModeEnabled &&
|
||||
(artifactDirectoryModeEnabled || !!requestedArtifact) &&
|
||||
!disableArtifacts &&
|
||||
!isMock &&
|
||||
!!threadId &&
|
||||
@@ -153,17 +160,21 @@ const ChatBox: React.FC<{
|
||||
);
|
||||
const checkpointArtifactSignature = checkpointArtifacts.join("\n");
|
||||
const availableArtifacts = useMemo(
|
||||
() =>
|
||||
artifactDirectoryModeEnabled
|
||||
? mergeArtifactPaths(
|
||||
checkpointArtifacts,
|
||||
(artifactListingQuery.data?.files ?? []).map((file) => file.path),
|
||||
)
|
||||
: checkpointArtifacts,
|
||||
() => {
|
||||
if (!artifactDirectoryModeEnabled && !requestedArtifact) {
|
||||
return checkpointArtifacts;
|
||||
}
|
||||
const discoveredArtifacts = (artifactListingQuery.data?.files ?? []).map(
|
||||
(file) => file.path,
|
||||
);
|
||||
if (requestedArtifact) discoveredArtifacts.push(requestedArtifact);
|
||||
return mergeArtifactPaths(checkpointArtifacts, discoveredArtifacts);
|
||||
},
|
||||
[
|
||||
artifactDirectoryModeEnabled,
|
||||
artifactListingQuery.data?.files,
|
||||
checkpointArtifacts,
|
||||
requestedArtifact,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -207,6 +218,29 @@ const ChatBox: React.FC<{
|
||||
thread.isLoading,
|
||||
);
|
||||
}, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
|
||||
useEffect(() => {
|
||||
if (
|
||||
disableArtifacts ||
|
||||
!requestedArtifact ||
|
||||
!threadId ||
|
||||
threadId === "new"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const requestKey = `${threadId}:${artifactPathname(requestedArtifact)}`;
|
||||
if (openedRequestedArtifactRef.current === requestKey) return;
|
||||
openedRequestedArtifactRef.current = requestKey;
|
||||
selectArtifact(requestedArtifact);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
}, [
|
||||
disableArtifacts,
|
||||
requestedArtifact,
|
||||
selectArtifact,
|
||||
setArtifactsOpen,
|
||||
threadId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldFetchBackendReferenceBatches) {
|
||||
setBackendReferenceBatches([]);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { BookOpen, Database } from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import {
|
||||
loadKnowledgeBaseProductVersion,
|
||||
type KnowledgeBaseProductVersion,
|
||||
updateKnowledgeBaseProductVersion,
|
||||
} from "@/core/system-settings/api";
|
||||
|
||||
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const;
|
||||
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
|
||||
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
|
||||
|
||||
export function useKnowledgeBaseProductVersion() {
|
||||
return useQuery({ queryKey: KNOWLEDGE_PRODUCT_KEY, queryFn: loadKnowledgeBaseProductVersion, staleTime: 60_000 });
|
||||
}
|
||||
|
||||
export function KnowledgeProductNav() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const client = useQueryClient();
|
||||
const version = useKnowledgeBaseProductVersion();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const save = useMutation({
|
||||
mutationFn: updateKnowledgeBaseProductVersion,
|
||||
onSuccess: (value) => {
|
||||
client.setQueryData(KNOWLEDGE_PRODUCT_KEY, value);
|
||||
toast.success(`默认知识库体验已切换为 ${value}`);
|
||||
},
|
||||
});
|
||||
const assistant = location.pathname.includes("/knowledge/assistant");
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-b bg-card px-6 py-2">
|
||||
<Button size="sm" variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
||||
<BookOpen />普通知识库
|
||||
</Button>
|
||||
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
||||
<Database />助手知识库
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||
默认产品版本
|
||||
<Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
|
||||
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="v1">v1 普通知识库</SelectItem><SelectItem value="v2">v2 助手知识库</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type AssistantKnowledgeBase,
|
||||
useAssistantKnowledgeBases,
|
||||
} from "@/strategy-components/api/assistant-knowledge";
|
||||
import {
|
||||
type LlmWikiKnowledgeBase,
|
||||
useAgentLlmWikiKnowledgeBases,
|
||||
@@ -66,10 +70,26 @@ function SpaceIcon({ item }: { item: LlmWikiKnowledgeBase }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantSpaceIcon({ item }: { item: AssistantKnowledgeBase }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-md",
|
||||
item.is_global ? "bg-amber-500/10 text-amber-600 dark:text-amber-400" : "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||
)}
|
||||
>
|
||||
<Database className="size-3.5" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectionContent({
|
||||
rows,
|
||||
assistantRows,
|
||||
value,
|
||||
assistantValue,
|
||||
onChange,
|
||||
onAssistantChange,
|
||||
search,
|
||||
onSearchChange,
|
||||
loading,
|
||||
@@ -79,8 +99,11 @@ function SelectionContent({
|
||||
readOnly,
|
||||
}: {
|
||||
rows: LlmWikiKnowledgeBase[];
|
||||
assistantRows: AssistantKnowledgeBase[];
|
||||
value: string[];
|
||||
assistantValue: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
onAssistantChange: (value: string[]) => void;
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
loading: boolean;
|
||||
@@ -90,6 +113,7 @@ function SelectionContent({
|
||||
readOnly: boolean;
|
||||
}) {
|
||||
const selected = useMemo(() => new Set(value), [value]);
|
||||
const selectedAssistant = useMemo(() => new Set(assistantValue), [assistantValue]);
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLocaleLowerCase();
|
||||
if (!query) return rows;
|
||||
@@ -97,11 +121,28 @@ function SelectionContent({
|
||||
`${item.name} ${item.description}`.toLocaleLowerCase().includes(query),
|
||||
);
|
||||
}, [rows, search]);
|
||||
const filteredAssistant = useMemo(() => {
|
||||
const query = search.trim().toLocaleLowerCase();
|
||||
if (!query) return assistantRows;
|
||||
return assistantRows.filter((item) =>
|
||||
`${item.name} ${item.description ?? ""}`.toLocaleLowerCase().includes(query),
|
||||
);
|
||||
}, [assistantRows, search]);
|
||||
const groups = useMemo(() => groupRows(filtered), [filtered]);
|
||||
const toggle = (id: string) => {
|
||||
if (readOnly) return;
|
||||
onChange(selected.has(id) ? value.filter((item) => item !== id) : [...value, id]);
|
||||
};
|
||||
const toggleAssistant = (id: string) => {
|
||||
if (readOnly) return;
|
||||
onAssistantChange(
|
||||
selectedAssistant.has(id)
|
||||
? assistantValue.filter((item) => item !== id)
|
||||
: [...assistantValue, id],
|
||||
);
|
||||
};
|
||||
const selectedCount = value.length + assistantValue.length;
|
||||
const hasRows = groups.length > 0 || filteredAssistant.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -127,7 +168,7 @@ function SelectionContent({
|
||||
<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 ? (
|
||||
) : !hasRows ? (
|
||||
<div className="flex h-28 items-center justify-center px-4 text-center text-xs text-muted-foreground">
|
||||
{search
|
||||
? "没有匹配的知识空间"
|
||||
@@ -136,91 +177,181 @@ function SelectionContent({
|
||||
: "当前账号暂无可用知识空间"}
|
||||
</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)}
|
||||
disabled={readOnly}
|
||||
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>
|
||||
<>
|
||||
{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>
|
||||
) : (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={selected.has(item.id)}
|
||||
disabled={readOnly}
|
||||
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 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)}
|
||||
disabled={readOnly}
|
||||
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)}
|
||||
disabled={readOnly}
|
||||
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>
|
||||
))}
|
||||
{filteredAssistant.length > 0 && (
|
||||
<div>
|
||||
{(groups.length > 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">
|
||||
<Database className="size-3" />助手知识库
|
||||
</DropdownMenuLabel>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
|
||||
<Database className="size-3" />助手知识库
|
||||
</div>
|
||||
)}
|
||||
{filteredAssistant.map((item) => {
|
||||
const rowContent = (
|
||||
<>
|
||||
<AssistantSpaceIcon 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">
|
||||
Wiki · {item.page_count ?? 0} 页 · {item.entity_count ?? 0} 实体
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="h-4 px-1 text-[9px]">
|
||||
{item.is_global ? "总库" : "助手"}
|
||||
</Badge>
|
||||
</>
|
||||
);
|
||||
return variant === "popover" ? (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={`assistant:${item.id}`}
|
||||
checked={selectedAssistant.has(item.id)}
|
||||
disabled={readOnly}
|
||||
onCheckedChange={() => toggleAssistant(item.id)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
className={cn(
|
||||
"my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7",
|
||||
selectedAssistant.has(item.id) && "bg-primary/8 focus:bg-primary/12",
|
||||
)}
|
||||
>
|
||||
{rowContent}
|
||||
</DropdownMenuCheckboxItem>
|
||||
) : (
|
||||
<button
|
||||
key={`assistant:${item.id}`}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={selectedAssistant.has(item.id)}
|
||||
disabled={readOnly}
|
||||
onClick={() => toggleAssistant(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",
|
||||
selectedAssistant.has(item.id) && "bg-primary/8",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"flex size-4 shrink-0 items-center justify-center rounded border",
|
||||
selectedAssistant.has(item.id) && "border-primary bg-primary text-primary-foreground",
|
||||
)}>
|
||||
{selectedAssistant.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">
|
||||
{readOnly ? `本对话已选 ${value.length} 个` : `已选择 ${value.length} 个`}
|
||||
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
|
||||
</span>
|
||||
{readOnly ? (
|
||||
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</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
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => {
|
||||
onChange(filtered.map((item) => item.id));
|
||||
onAssistantChange(filteredAssistant.map((item) => item.id));
|
||||
}}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange([])}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => {
|
||||
onChange([]);
|
||||
onAssistantChange([]);
|
||||
}}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
@@ -232,7 +363,9 @@ function SelectionContent({
|
||||
|
||||
export function LlmWikiKnowledgeSelector({
|
||||
value,
|
||||
assistantValue = [],
|
||||
onChange,
|
||||
onAssistantChange = () => undefined,
|
||||
source = "all",
|
||||
agentId,
|
||||
autoSelectAll = false,
|
||||
@@ -243,7 +376,9 @@ export function LlmWikiKnowledgeSelector({
|
||||
variant = "popover",
|
||||
}: {
|
||||
value: string[];
|
||||
assistantValue?: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
onAssistantChange?: (value: string[]) => void;
|
||||
source?: SelectorSource;
|
||||
agentId?: string | null;
|
||||
autoSelectAll?: boolean;
|
||||
@@ -257,28 +392,39 @@ export function LlmWikiKnowledgeSelector({
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const weknoraEnabled = runtime.data?.provider === "weknora";
|
||||
const all = useLlmWikiKnowledgeBases(
|
||||
"all",
|
||||
runtime.data?.provider === "weknora",
|
||||
weknoraEnabled,
|
||||
);
|
||||
const agent = useAgentLlmWikiKnowledgeBases(
|
||||
agentId,
|
||||
runtime.data?.provider === "weknora" && source === "agent" && !!agentId,
|
||||
weknoraEnabled && source === "agent" && !!agentId,
|
||||
);
|
||||
const rows = all.data ?? [];
|
||||
const assistantBases = useAssistantKnowledgeBases();
|
||||
const rows = weknoraEnabled ? (all.data ?? []) : [];
|
||||
const assistantRows = assistantBases.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 loading = runtime.isLoading || (weknoraEnabled && all.isLoading) || assistantBases.isLoading || (source === "agent" && !!agentId && agent.isLoading);
|
||||
const error = (weknoraEnabled ? all.error ?? (source === "agent" ? agent.error : null) : null) ?? assistantBases.error;
|
||||
const availableIds = useMemo(() => new Set(rows.map((item) => item.id)), [rows]);
|
||||
const assistantAvailableIds = useMemo(
|
||||
() => new Set(assistantRows.map((item) => item.id)),
|
||||
[assistantRows],
|
||||
);
|
||||
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
|
||||
const initialized = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtime.isLoading && !weknoraEnabled) {
|
||||
if (value.length > 0) onChange([]);
|
||||
return;
|
||||
}
|
||||
if (!all.data || (source === "agent" && agentId && !agent.data) || initialized.current === initializationKey) return;
|
||||
initialized.current = initializationKey;
|
||||
const valid = value.filter((id) => availableIds.has(id));
|
||||
@@ -288,19 +434,30 @@ export function LlmWikiKnowledgeSelector({
|
||||
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]);
|
||||
}, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, runtime.isLoading, source, value, weknoraEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!assistantBases.data) return;
|
||||
const valid = assistantValue.filter((id) => assistantAvailableIds.has(id));
|
||||
if (valid.length !== assistantValue.length || valid.some((id, index) => id !== assistantValue[index])) {
|
||||
onAssistantChange(valid);
|
||||
}
|
||||
}, [assistantAvailableIds, assistantBases.data, assistantValue, onAssistantChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setSearch("");
|
||||
}, [open]);
|
||||
|
||||
if (runtime.isError || runtime.data?.provider !== "weknora") return null;
|
||||
if ((runtime.isError || !weknoraEnabled) && !assistantBases.isLoading && assistantRows.length === 0) return null;
|
||||
|
||||
const content = (
|
||||
<SelectionContent
|
||||
rows={rows}
|
||||
assistantRows={assistantRows}
|
||||
value={value}
|
||||
assistantValue={assistantValue}
|
||||
onChange={onChange}
|
||||
onAssistantChange={onAssistantChange}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
loading={loading}
|
||||
@@ -323,7 +480,7 @@ export function LlmWikiKnowledgeSelector({
|
||||
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0">{value.length} 个</Badge>
|
||||
<Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge>
|
||||
</div>
|
||||
{content}
|
||||
</div>
|
||||
@@ -333,10 +490,15 @@ export function LlmWikiKnowledgeSelector({
|
||||
const selectedNames = value
|
||||
.map((id) => rows.find((item) => item.id === id)?.name)
|
||||
.filter((name): name is string => !!name);
|
||||
const selectedLabel = selectedNames.length > 0
|
||||
? selectedNames.join("、")
|
||||
: value.length > 0
|
||||
? `已选 ${value.length} 个知识空间`
|
||||
const selectedAssistantNames = assistantValue
|
||||
.map((id) => assistantRows.find((item) => item.id === id)?.name)
|
||||
.filter((name): name is string => !!name);
|
||||
const allSelectedNames = [...selectedNames, ...selectedAssistantNames];
|
||||
const selectedCount = value.length + assistantValue.length;
|
||||
const selectedLabel = allSelectedNames.length > 0
|
||||
? allSelectedNames.join("、")
|
||||
: selectedCount > 0
|
||||
? `已选 ${selectedCount} 个知识空间`
|
||||
: label;
|
||||
|
||||
return (
|
||||
@@ -348,20 +510,20 @@ export function LlmWikiKnowledgeSelector({
|
||||
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",
|
||||
selectedCount > 0 && "bg-primary/8 text-primary hover:bg-primary/12 hover:text-primary",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled || loading}
|
||||
title={value.length > 0 ? selectedLabel : undefined}
|
||||
title={selectedCount > 0 ? selectedLabel : undefined}
|
||||
>
|
||||
{loading ? <Loader2 className="size-3.5 animate-spin" /> : <Database className="size-3.5" />}
|
||||
<span className="truncate text-xs font-normal">{selectedLabel}</span>
|
||||
{value.length > 0 && (
|
||||
{selectedCount > 0 && (
|
||||
<span className="flex size-4 items-center justify-center rounded-full bg-primary text-[9px] font-semibold text-primary-foreground">
|
||||
{value.length}
|
||||
{selectedCount}
|
||||
</span>
|
||||
)}
|
||||
{value.length > 0 && value.length === rows.length && <Check className="size-3" />}
|
||||
{selectedCount > 0 && selectedCount === rows.length + assistantRows.length && <Check className="size-3" />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={8} className="w-80 overflow-hidden rounded-xl p-0 shadow-xl">
|
||||
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
useViewMode,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import type { SkillKnowledgeBinding } from "@/strategy-components/api/skill-knowledge";
|
||||
import { formatDateTime } from "@/core/utils/datetime";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
@@ -601,6 +602,7 @@ export function SkillRow({
|
||||
isAdmin,
|
||||
layout = "row",
|
||||
onPublisherClick,
|
||||
knowledgeBindings = [],
|
||||
}: {
|
||||
skill: Skill;
|
||||
currentUserId: string | null;
|
||||
@@ -610,6 +612,8 @@ export function SkillRow({
|
||||
/** Click the 发布者 line to filter the list to that publisher. Receives the
|
||||
* owner_user_id + its desensitized display label. */
|
||||
onPublisherClick?: (publisher: { id: string; label: string }) => void;
|
||||
/** Admin list can inject its single batched binding query here (never N+1). */
|
||||
knowledgeBindings?: SkillKnowledgeBinding[];
|
||||
}) {
|
||||
const isCard = layout === "card";
|
||||
const { t } = useI18n();
|
||||
@@ -858,6 +862,20 @@ export function SkillRow({
|
||||
常驻
|
||||
</Badge>
|
||||
)}
|
||||
{knowledgeBindings.slice(0, 3).map((binding) => (
|
||||
<Badge
|
||||
key={binding.id}
|
||||
variant="outline"
|
||||
className="border-indigo-500/30 text-indigo-700 dark:text-indigo-300 text-xs"
|
||||
title={`${binding.target_name} · ${binding.status}${binding.remote_business_key ? ` · ${binding.remote_business_key}` : ""}`}
|
||||
>
|
||||
知识库:{binding.target_name || binding.target_id}
|
||||
{binding.status === "stale" ? "(待更新)" : ""}
|
||||
</Badge>
|
||||
))}
|
||||
{knowledgeBindings.length > 3 && (
|
||||
<Badge variant="secondary" className="text-xs">+{knowledgeBindings.length - 3}</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { Database, Loader2, RefreshCw, Search, Sparkles } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useAllSkillsAdmin } from "@/core/skills/hooks";
|
||||
import { useLlmWikiKnowledgeBases, useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||||
import {
|
||||
type SkillKnowledgeTarget,
|
||||
useSkillKnowledgeActions,
|
||||
useSkillKnowledgeBindings,
|
||||
useSkillKnowledgeJobs,
|
||||
} from "@/strategy-components/api/skill-knowledge";
|
||||
|
||||
const PHASE_LABELS: Record<string, string> = {
|
||||
scanning: "扫描知识文件",
|
||||
parsing: "解析资料",
|
||||
extracting: "抽取实体关系",
|
||||
awaiting_review: "等待审核",
|
||||
merging: "合并实体关系",
|
||||
writing_wiki: "写入 Wiki",
|
||||
activating: "激活新版本",
|
||||
completed: "完成",
|
||||
};
|
||||
|
||||
export function SkillKnowledgePanel() {
|
||||
const { skills, isLoading } = useAllSkillsAdmin(true);
|
||||
const liveSkills = useMemo(() => skills.filter((skill) => (skill.state ?? "active") === "active"), [skills]);
|
||||
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
||||
const [skillQuery, setSkillQuery] = useState("");
|
||||
const [selectedTargetKey, setSelectedTargetKey] = useState<string | null>(null);
|
||||
const [reviewMode, setReviewMode] = useState<"auto_high_confidence" | "off">("auto_high_confidence");
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const knowledgeBases = useLlmWikiKnowledgeBases("all", runtime.data?.provider === "weknora");
|
||||
const bindings = useSkillKnowledgeBindings();
|
||||
const jobs = useSkillKnowledgeJobs();
|
||||
const actions = useSkillKnowledgeActions();
|
||||
|
||||
const targets = useMemo<SkillKnowledgeTarget[]>(() => {
|
||||
return (knowledgeBases.data ?? [])
|
||||
.filter((kb) => {
|
||||
const strategy = kb.indexing_strategy ?? {};
|
||||
const capabilities = kb.capabilities ?? {};
|
||||
const wikiEnabled =
|
||||
kb.type === "wiki" ||
|
||||
kb.wiki_index_enabled === true ||
|
||||
strategy.wiki_enabled === true ||
|
||||
capabilities.wiki_enabled === true ||
|
||||
(kb.wiki_config && Object.keys(kb.wiki_config).length > 0);
|
||||
return kb.can_write && kb.remote_status !== "missing" && !kb.is_conversation_deposit && kb.type !== "faq" && wikiEnabled;
|
||||
})
|
||||
.map((kb) => ({
|
||||
target_type: "weknora",
|
||||
target_mode: "wiki",
|
||||
target_id: kb.id,
|
||||
target_name: kb.name,
|
||||
remote_business_key: `weknora:kb:${kb.id}`,
|
||||
}));
|
||||
}, [knowledgeBases.data]);
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
const needle = skillQuery.trim().toLowerCase();
|
||||
if (!needle) return liveSkills;
|
||||
return liveSkills.filter((skill) => {
|
||||
const tagText = (skill.tags ?? []).map((tag) => tag.name).join(" ");
|
||||
const haystack = [
|
||||
skill.name,
|
||||
skill.name_zh,
|
||||
skill.description,
|
||||
skill.category,
|
||||
tagText,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.toLowerCase();
|
||||
return haystack.includes(needle);
|
||||
});
|
||||
}, [liveSkills, skillQuery]);
|
||||
|
||||
const filteredSkillNames = useMemo(() => filteredSkills.map((skill) => skill.name), [filteredSkills]);
|
||||
const allFilteredSelected = filteredSkillNames.length > 0 && filteredSkillNames.every((name) => selectedSkills.includes(name));
|
||||
|
||||
const toggleFilteredSkills = () => {
|
||||
if (!filteredSkillNames.length) return;
|
||||
setSelectedSkills((current) => {
|
||||
if (allFilteredSelected) {
|
||||
const filtered = new Set(filteredSkillNames);
|
||||
return current.filter((name) => !filtered.has(name));
|
||||
}
|
||||
return [...current, ...filteredSkillNames.filter((name) => !current.includes(name))];
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTargetKey && !targets.some((target) => `${target.target_type}:${target.target_id}` === selectedTargetKey)) {
|
||||
setSelectedTargetKey(null);
|
||||
}
|
||||
}, [selectedTargetKey, targets]);
|
||||
|
||||
const toggle = (values: string[], value: string, setter: (next: string[]) => void) =>
|
||||
setter(values.includes(value) ? values.filter((item) => item !== value) : [...values, value]);
|
||||
|
||||
const submit = async () => {
|
||||
const selectedTargets = targets.filter((target) =>
|
||||
selectedTargetKey === `${target.target_type}:${target.target_id}`,
|
||||
);
|
||||
if (!selectedSkills.length || !selectedTargets.length) {
|
||||
toast.error("请至少选择一个技能和一个目标知识库");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const job = await actions.createJob.mutateAsync({
|
||||
skill_names: selectedSkills,
|
||||
targets: selectedTargets,
|
||||
review_mode: reviewMode,
|
||||
});
|
||||
toast.success(`已创建 ${job.total_count} 个归纳项`);
|
||||
setSelectedSkills([]);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid size-full min-h-0 grid-cols-[minmax(320px,0.9fr)_minmax(420px,1.1fr)] gap-4 overflow-hidden p-6">
|
||||
<section className="flex min-h-0 flex-col rounded-lg border bg-card">
|
||||
<div className="border-b p-4">
|
||||
<h2 className="font-semibold">技能到知识库管理</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">一次选择多个技能归纳到一个 WeKnora Wiki 知识库;代码与脚本不会被读取。</p>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-5 p-4">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">1. 选择技能({selectedSkills.length})</span>
|
||||
<Button variant="ghost" size="sm" onClick={toggleFilteredSkills}>
|
||||
{allFilteredSelected ? "清空当前结果" : "选择当前结果"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative mb-2">
|
||||
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={skillQuery}
|
||||
onChange={(event) => setSkillQuery(event.target.value)}
|
||||
placeholder="按技能中文名、英文名、描述、分类或标签搜索"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-60 space-y-1 overflow-y-auto rounded-md border p-2">
|
||||
{isLoading ? <Loader2 className="m-4 size-5 animate-spin" /> : filteredSkills.map((skill) => (
|
||||
<label key={skill.name} className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-muted">
|
||||
<Checkbox checked={selectedSkills.includes(skill.name)} onCheckedChange={() => toggle(selectedSkills, skill.name, setSelectedSkills)} />
|
||||
<span className="truncate text-sm">{skill.name_zh || skill.name}</span>
|
||||
<span className="ml-auto truncate text-xs text-muted-foreground">{skill.name}</span>
|
||||
</label>
|
||||
))}
|
||||
{!isLoading && !filteredSkills.length && (
|
||||
<p className="p-3 text-xs text-muted-foreground">没有匹配的技能,请换个关键词。</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
当前显示 {filteredSkills.length}/{liveSkills.length} 个技能。
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">2. 选择目标知识库({selectedTargetKey ? 1 : 0})</span>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-md border p-2">
|
||||
{targets.map((target) => {
|
||||
const key = `${target.target_type}:${target.target_id}`;
|
||||
return (
|
||||
<label key={key} className="flex cursor-pointer items-center gap-2 rounded px-2 py-2 hover:bg-muted">
|
||||
<Checkbox
|
||||
checked={selectedTargetKey === key}
|
||||
onCheckedChange={() => setSelectedTargetKey((current) => (current === key ? null : key))}
|
||||
/>
|
||||
<Database className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm">{target.target_name}</span>
|
||||
<Badge variant="outline" className="ml-auto text-[10px]">WeKnora Wiki</Badge>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{(runtime.isLoading || knowledgeBases.isLoading) && <Loader2 className="m-4 size-5 animate-spin" />}
|
||||
{!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && (
|
||||
<p className="p-3 text-xs text-destructive">
|
||||
读取 WeKnora 普通知识库失败:
|
||||
{knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)}
|
||||
</p>
|
||||
)}
|
||||
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
|
||||
<p className="p-3 text-xs text-muted-foreground">
|
||||
{runtime.data?.provider !== "weknora"
|
||||
? "当前未配置 WeKnora 普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
|
||||
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium">审核策略</span>
|
||||
<Select value={reviewMode} onValueChange={(value) => setReviewMode(value as typeof reviewMode)}>
|
||||
<SelectTrigger className="w-52"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto_high_confidence">高置信自动通过</SelectItem>
|
||||
<SelectItem value="off">不阻塞写入</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button className="w-full" disabled={actions.createJob.isPending} onClick={() => void submit()}>
|
||||
{actions.createJob.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
|
||||
开始归纳({selectedSkills.length * (selectedTargetKey ? 1 : 0)} 项)
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-0 flex-col gap-4 overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col rounded-lg border bg-card">
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div><h2 className="font-semibold">归纳任务</h2><p className="text-xs text-muted-foreground">后台执行、失败可单项重试</p></div>
|
||||
<Button size="icon" variant="ghost" onClick={() => void jobs.refetch()}><RefreshCw className="size-4" /></Button>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-3 p-4">
|
||||
{(jobs.data ?? []).map((job) => {
|
||||
const done = job.success_count + job.partial_failed_count + job.failed_count;
|
||||
const progress = job.total_count ? Math.round((done / job.total_count) * 100) : 0;
|
||||
return (
|
||||
<div key={job.id} className="rounded-md border p-3">
|
||||
<div className="flex items-center gap-2 text-sm"><Badge variant={job.status === "completed" ? "default" : "secondary"}>{job.status}</Badge><span>{done}/{job.total_count} 项</span><span className="ml-auto text-xs text-muted-foreground">{new Date(job.created_at).toLocaleString()}</span></div>
|
||||
<Progress className="my-2" value={progress} />
|
||||
{job.items?.map((item) => (
|
||||
<div key={item.id} className="mt-2 flex items-center gap-2 border-t pt-2 text-xs">
|
||||
<span className="max-w-36 truncate font-medium">{item.skill_name}</span>
|
||||
<span className="text-muted-foreground">{PHASE_LABELS[item.phase] || item.phase}</span>
|
||||
<span className="ml-auto">{item.progress}%</span>
|
||||
{item.status === "failed" && <Button size="sm" variant="outline" onClick={() => actions.retry.mutate(item.id)}>重试</Button>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!jobs.data?.length && <p className="py-10 text-center text-sm text-muted-foreground">暂无归纳任务</p>}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-auto rounded-lg border bg-card p-3 text-xs">
|
||||
<div className="mb-2 text-muted-foreground">当前有效绑定:{bindings.data?.length ?? 0};同名同类型实体按来源贡献合并。</div>
|
||||
<div className="space-y-2">{bindings.data?.map((binding) => <div key={binding.id} className="flex flex-wrap items-center gap-2 rounded border p-2"><span className="max-w-32 truncate font-medium">{binding.skill_name}</span><span className="max-w-40 truncate text-muted-foreground">→ {binding.target_name}</span><Badge variant="outline">{binding.status}</Badge><code className="max-w-40 truncate text-[10px] text-muted-foreground">{binding.remote_business_key}</code><Button size="sm" variant="outline" className="ml-auto" disabled={actions.resync.isPending} onClick={() => actions.resync.mutate(binding.id)}>重归纳</Button><Button size="sm" variant="ghost" onClick={() => { if (window.confirm("解除关联并保留目标知识内容?")) actions.detach.mutate({ id: binding.id, deleteContributions: false }); }}>解绑并保留</Button><Button size="sm" variant="destructive" onClick={() => { if (window.confirm("解除关联并删除此技能贡献?共享知识仍会保留其他来源。")) actions.detach.mutate({ id: binding.id, deleteContributions: true }); }}>解绑并删除贡献</Button></div>)}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { BookOpen, BotIcon, BrainIcon, FileSearch, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, FileSearch, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
||||
|
||||
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
@@ -65,6 +65,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
path: "/page/workspace/chats",
|
||||
match: (pathname) => pathname === "/page/workspace/chats",
|
||||
},
|
||||
{
|
||||
id: "workspace-library",
|
||||
label: "资料库",
|
||||
icon: Library,
|
||||
path: "/page/workspace/library",
|
||||
match: (pathname) => pathname === "/page/workspace/library",
|
||||
},
|
||||
{
|
||||
id: "workspace-agents",
|
||||
label: "智能体",
|
||||
|
||||
@@ -254,6 +254,14 @@ export function WorkspaceNavMenu() {
|
||||
技能管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/skills?tab=knowledge")}
|
||||
>
|
||||
<DatabaseZapIcon />
|
||||
技能到知识库管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/models")}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { apiFetch } from "../api";
|
||||
import { getFileName } from "../utils/files";
|
||||
import { apiFetch } from "../api";
|
||||
import { downloadMarkdownAsDocx } from "../scheduled-tasks/markdown-docx";
|
||||
import { getFileName } from "../utils/files";
|
||||
|
||||
import { urlOfArtifact } from "./utils";
|
||||
|
||||
@@ -39,7 +40,7 @@ async function errorMessageFromResponse(res: Response): Promise<string> {
|
||||
return res.statusText || "Request failed";
|
||||
}
|
||||
|
||||
export async function downloadArtifactFile({
|
||||
export async function downloadArtifactFile({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
@@ -73,8 +74,26 @@ export async function downloadArtifactFile({
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
|
||||
}
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
|
||||
}
|
||||
|
||||
export async function downloadArtifactMarkdownAsWord({
|
||||
filepath,
|
||||
threadId,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
}): Promise<void> {
|
||||
const url = urlOfArtifact({ filepath, threadId, download: false });
|
||||
const res = await apiFetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(await errorMessageFromResponse(res));
|
||||
}
|
||||
|
||||
const fileName = getFileName(filepath);
|
||||
const title = fileName.replace(/\.(md|markdown)$/i, "");
|
||||
await downloadMarkdownAsDocx(await res.text(), fileName, title);
|
||||
}
|
||||
|
||||
export async function openArtifactInNewTab({
|
||||
filepath,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export type ArtifactLibraryKind =
|
||||
| "all"
|
||||
| "document"
|
||||
| "spreadsheet"
|
||||
| "text_data"
|
||||
| "archive"
|
||||
| "other";
|
||||
|
||||
export type ArtifactLibrarySource = "all" | "general_qa" | "agent";
|
||||
|
||||
export interface ArtifactLibraryItem {
|
||||
threadId: string;
|
||||
threadTitle: string;
|
||||
agentId: string | null;
|
||||
agentName: string | null;
|
||||
threadType: string | null;
|
||||
sourceType: Exclude<ArtifactLibrarySource, "all">;
|
||||
path: string;
|
||||
name: string;
|
||||
sizeBytes: number;
|
||||
mimeType: string | null;
|
||||
modifiedAt: string;
|
||||
fileKind: Exclude<ArtifactLibraryKind, "all">;
|
||||
}
|
||||
|
||||
export interface ArtifactLibraryResponse {
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
items: ArtifactLibraryItem[];
|
||||
}
|
||||
|
||||
export async function fetchArtifactLibrary({
|
||||
query,
|
||||
fileKind,
|
||||
sourceType,
|
||||
offset,
|
||||
limit,
|
||||
refresh = false,
|
||||
signal,
|
||||
}: {
|
||||
query?: string;
|
||||
fileKind: ArtifactLibraryKind;
|
||||
sourceType: ArtifactLibrarySource;
|
||||
offset: number;
|
||||
limit: number;
|
||||
refresh?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ArtifactLibraryResponse> {
|
||||
const search = new URLSearchParams({
|
||||
file_kind: fileKind,
|
||||
source_type: sourceType,
|
||||
offset: String(offset),
|
||||
limit: String(limit),
|
||||
});
|
||||
const normalizedQuery = query?.trim();
|
||||
if (normalizedQuery) search.set("query", normalizedQuery);
|
||||
if (refresh) search.set("refresh", "true");
|
||||
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/artifact-library?${search.toString()}`,
|
||||
{ method: "GET", signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Failed to load artifact library (${response.status})${detail ? `: ${detail}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
total?: number;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
items?: Array<{
|
||||
thread_id: string;
|
||||
thread_title: string;
|
||||
agent_id?: string | null;
|
||||
agent_name?: string | null;
|
||||
thread_type?: string | null;
|
||||
source_type?: Exclude<ArtifactLibrarySource, "all">;
|
||||
path: string;
|
||||
name: string;
|
||||
size_bytes: number;
|
||||
mime_type?: string | null;
|
||||
modified_at: string;
|
||||
file_kind: Exclude<ArtifactLibraryKind, "all">;
|
||||
}>;
|
||||
};
|
||||
|
||||
return {
|
||||
total: data.total ?? 0,
|
||||
offset: data.offset ?? offset,
|
||||
limit: data.limit ?? limit,
|
||||
items: (data.items ?? []).map((item) => ({
|
||||
threadId: item.thread_id,
|
||||
threadTitle: item.thread_title,
|
||||
agentId: item.agent_id ?? null,
|
||||
agentName: item.agent_name ?? item.agent_id ?? null,
|
||||
threadType: item.thread_type ?? null,
|
||||
sourceType: item.source_type ?? (item.agent_id ? "agent" : "general_qa"),
|
||||
path: item.path,
|
||||
name: item.name,
|
||||
sizeBytes: item.size_bytes,
|
||||
mimeType: item.mime_type ?? null,
|
||||
modifiedAt: item.modified_at,
|
||||
fileKind: item.file_kind,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -149,7 +149,7 @@ export function buildWikiIndexCatalogMarkdown(
|
||||
buildPageUrl: WikiPageUrlBuilder = buildWeKnoraWikiPageUrl,
|
||||
indexGroups: WikiIndexGroup[] = [],
|
||||
): string {
|
||||
if (indexGroups.length > 0) {
|
||||
if (indexGroups.some((group) => group.items.length > 0)) {
|
||||
const sections: string[] = [];
|
||||
for (const group of indexGroups) {
|
||||
if (group.items.length === 0) continue;
|
||||
|
||||
@@ -61,6 +61,7 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
icon: HelpCircle,
|
||||
children: [
|
||||
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
|
||||
{ id: "artifact-library", label: "资料库", icon: Library, path: "/page/workspace/library" },
|
||||
{
|
||||
id: "multi-agent-roundtable",
|
||||
label: "多智能体会商",
|
||||
|
||||
@@ -9,6 +9,31 @@ export interface PromptPrefixSettings {
|
||||
ordinary_qa_markdown_format_enabled: boolean;
|
||||
}
|
||||
|
||||
export type KnowledgeBaseProductVersion = "v1" | "v2";
|
||||
|
||||
export async function loadKnowledgeBaseProductVersion(): Promise<KnowledgeBaseProductVersion> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`,
|
||||
);
|
||||
if (!response.ok) throw new Error(`Failed to load knowledge base product version: ${response.status}`);
|
||||
return ((await response.json()) as { version: KnowledgeBaseProductVersion }).version;
|
||||
}
|
||||
|
||||
export async function updateKnowledgeBaseProductVersion(
|
||||
version: KnowledgeBaseProductVersion,
|
||||
): Promise<KnowledgeBaseProductVersion> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ version }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`Failed to update knowledge base product version: ${response.status}`);
|
||||
return ((await response.json()) as { version: KnowledgeBaseProductVersion }).version;
|
||||
}
|
||||
|
||||
export interface PluginDomainAgentMappingDto {
|
||||
id: string;
|
||||
domain: string;
|
||||
|
||||
@@ -1512,10 +1512,16 @@ export function useThreadStream({
|
||||
)
|
||||
? mergedContext.llmwiki_knowledge_base_ids
|
||||
: [];
|
||||
const selectedAssistantKnowledgeIds = Array.isArray(
|
||||
mergedContext.assistant_knowledge_base_ids,
|
||||
)
|
||||
? mergedContext.assistant_knowledge_base_ids
|
||||
: [];
|
||||
const ragModeEnabled =
|
||||
(referenceModeEntryEnabled && localSettings.display.ragModeEnabled) ||
|
||||
isThreadRagModeLocked(threadId) ||
|
||||
selectedLlmWikiIds.length > 0;
|
||||
selectedLlmWikiIds.length > 0 ||
|
||||
selectedAssistantKnowledgeIds.length > 0;
|
||||
if (ragModeEnabled) {
|
||||
lockThreadRagMode(threadId);
|
||||
}
|
||||
@@ -1693,7 +1699,7 @@ export function useThreadStream({
|
||||
? undefined
|
||||
: thread.error;
|
||||
|
||||
const mergedThread = {
|
||||
const mergedThread = {
|
||||
...thread,
|
||||
error: visibleThreadError,
|
||||
values: mergedValues,
|
||||
@@ -1703,7 +1709,7 @@ export function useThreadStream({
|
||||
: mergedMessages,
|
||||
isLoading: thread.isLoading || streamHoldOpen,
|
||||
stop: handleThreadStop,
|
||||
} as typeof thread;
|
||||
} as typeof thread;
|
||||
|
||||
// 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。
|
||||
// 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
||||
knowledge_rag_enabled?: boolean;
|
||||
/** DeerFlow LLMWiki mapping ids selected for this conversation. */
|
||||
llmwiki_knowledge_base_ids?: string[];
|
||||
/** Assistant knowledge-base ids selected for this conversation. */
|
||||
assistant_knowledge_base_ids?: string[];
|
||||
/** Direct RAG modes handled in the frontend; these requests never enter Lead Agent. */
|
||||
execution_profile?: "single_round_rag" | "multi_round_rag";
|
||||
agent_id?: string;
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DatabaseZap,
|
||||
Replace,
|
||||
SearchIcon,
|
||||
SparklesIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -18,17 +20,32 @@ import {
|
||||
WorkspaceHeader,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { SkillRow } from "@/components/workspace/settings/skill-settings-page";
|
||||
import { SkillKnowledgePanel } from "@/components/workspace/skills/SkillKnowledgePanel";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useAllSkillsAdmin } from "@/core/skills/hooks";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { useSkillKnowledgeBindings } from "@/strategy-components/api/skill-knowledge";
|
||||
import { SkillAddressesPanel } from "./SkillAddressesPage";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export default function AdminSkillsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const { skills, isLoading, error } = useAllSkillsAdmin(true);
|
||||
const knowledgeBindings = useSkillKnowledgeBindings();
|
||||
const activeTab = useMemo(() => {
|
||||
const tab = searchParams.get("tab");
|
||||
return tab === "addresses" || tab === "knowledge" ? tab : "skills";
|
||||
}, [searchParams]);
|
||||
|
||||
const changeTab = (value: string) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (value === "skills") next.delete("tab");
|
||||
else next.set("tab", value);
|
||||
setSearchParams(next, { replace: true });
|
||||
};
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
@@ -56,7 +73,8 @@ export default function AdminSkillsPage() {
|
||||
<WorkspaceHeader />
|
||||
<WorkspaceBody>
|
||||
<Tabs
|
||||
defaultValue="skills"
|
||||
value={activeTab}
|
||||
onValueChange={changeTab}
|
||||
className="flex size-full min-h-0 flex-col gap-0"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4 border-b px-6 pt-4">
|
||||
@@ -71,6 +89,10 @@ export default function AdminSkillsPage() {
|
||||
<Replace />
|
||||
技能地址管理
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="knowledge">
|
||||
<DatabaseZap />
|
||||
技能到知识库管理
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,6 +148,9 @@ export default function AdminSkillsPage() {
|
||||
skill={skill}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin
|
||||
knowledgeBindings={(knowledgeBindings.data ?? []).filter(
|
||||
(binding) => binding.skill_name === skill.name,
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -164,6 +189,12 @@ export default function AdminSkillsPage() {
|
||||
>
|
||||
<SkillAddressesPanel />
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="knowledge"
|
||||
className="mt-0 min-h-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<SkillKnowledgePanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</WorkspaceBody>
|
||||
</WorkspaceContainer>
|
||||
|
||||
@@ -118,6 +118,26 @@ function readPendingKnowledgeSelection(agentId: string | undefined): string[] |
|
||||
}
|
||||
}
|
||||
|
||||
function readPendingAssistantKnowledgeSelection(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;
|
||||
assistantKnowledgeBaseIds?: unknown;
|
||||
};
|
||||
if (parsed.agentId !== agentId || !Array.isArray(parsed.assistantKnowledgeBaseIds)) {
|
||||
return null;
|
||||
}
|
||||
return parsed.assistantKnowledgeBaseIds
|
||||
.map((item) => String(item).trim())
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyForHiddenContext(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
@@ -334,11 +354,17 @@ export default function AgentChatPage({
|
||||
const pendingKnowledgeSelection = useRef(
|
||||
readPendingKnowledgeSelection(agent_id),
|
||||
);
|
||||
const pendingAssistantKnowledgeSelection = useRef(
|
||||
readPendingAssistantKnowledgeSelection(agent_id),
|
||||
);
|
||||
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>(
|
||||
() => pendingKnowledgeSelection.current ?? [],
|
||||
);
|
||||
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>(
|
||||
() => pendingAssistantKnowledgeSelection.current ?? [],
|
||||
);
|
||||
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(
|
||||
() => pendingKnowledgeSelection.current !== null,
|
||||
() => pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null,
|
||||
);
|
||||
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
|
||||
const knowledgeSelectionThread = useRef<string | null>(null);
|
||||
@@ -349,6 +375,10 @@ export default function AgentChatPage({
|
||||
setSelectedKnowledgeBaseIds(ids);
|
||||
setKnowledgeSelectionReady(true);
|
||||
}, []);
|
||||
const changeAssistantKnowledgeSelection = useCallback((ids: string[]) => {
|
||||
setSelectedAssistantKnowledgeBaseIds(ids);
|
||||
setKnowledgeSelectionReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
activeThreadIdRef.current = threadId;
|
||||
@@ -358,17 +388,23 @@ export default function AgentChatPage({
|
||||
if (knowledgeSelectionAgent.current === agent_id) return;
|
||||
knowledgeSelectionAgent.current = agent_id;
|
||||
const pending = readPendingKnowledgeSelection(agent_id);
|
||||
const pendingAssistant = readPendingAssistantKnowledgeSelection(agent_id);
|
||||
pendingKnowledgeSelection.current = pending;
|
||||
pendingAssistantKnowledgeSelection.current = pendingAssistant;
|
||||
knowledgeSelectionThread.current = null;
|
||||
setSelectedKnowledgeBaseIds(pending ?? []);
|
||||
setKnowledgeSelectionReady(pending !== null);
|
||||
setSelectedAssistantKnowledgeBaseIds(pendingAssistant ?? []);
|
||||
setKnowledgeSelectionReady(pending !== null || pendingAssistant !== null);
|
||||
}, [agent_id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewThread) {
|
||||
if (knowledgeSelectionThread.current !== "__new__") {
|
||||
setSelectedKnowledgeBaseIds(pendingKnowledgeSelection.current ?? []);
|
||||
setKnowledgeSelectionReady(pendingKnowledgeSelection.current !== null);
|
||||
setSelectedAssistantKnowledgeBaseIds(pendingAssistantKnowledgeSelection.current ?? []);
|
||||
setKnowledgeSelectionReady(
|
||||
pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null,
|
||||
);
|
||||
}
|
||||
knowledgeSelectionThread.current = "__new__";
|
||||
setKnowledgeSelectionLoading(false);
|
||||
@@ -383,18 +419,29 @@ export default function AgentChatPage({
|
||||
if (knowledgeSelectionThread.current !== threadId) return;
|
||||
const metadata = record.metadata as Record<string, unknown> | undefined;
|
||||
const raw = metadata?.llmwiki_knowledge_base_ids;
|
||||
const assistantRaw = metadata?.assistant_knowledge_base_ids;
|
||||
const llmwikiIds = Array.isArray(raw)
|
||||
? raw.map((item) => String(item).trim()).filter(Boolean)
|
||||
: [];
|
||||
const assistantIds = Array.isArray(assistantRaw)
|
||||
? assistantRaw.map((item) => String(item).trim()).filter(Boolean)
|
||||
: [];
|
||||
if (Array.isArray(raw)) {
|
||||
setSelectedKnowledgeBaseIds(
|
||||
raw.map((item) => String(item).trim()).filter(Boolean),
|
||||
);
|
||||
setKnowledgeSelectionReady(true);
|
||||
setSelectedKnowledgeBaseIds(llmwikiIds);
|
||||
} else {
|
||||
setSelectedKnowledgeBaseIds([]);
|
||||
setKnowledgeSelectionReady(false);
|
||||
}
|
||||
if (Array.isArray(assistantRaw)) {
|
||||
setSelectedAssistantKnowledgeBaseIds(assistantIds);
|
||||
} else {
|
||||
setSelectedAssistantKnowledgeBaseIds([]);
|
||||
}
|
||||
setKnowledgeSelectionReady(Array.isArray(raw) || Array.isArray(assistantRaw));
|
||||
})
|
||||
.catch(() => {
|
||||
if (knowledgeSelectionThread.current === threadId) {
|
||||
setSelectedKnowledgeBaseIds([]);
|
||||
setSelectedAssistantKnowledgeBaseIds([]);
|
||||
setKnowledgeSelectionReady(false);
|
||||
}
|
||||
})
|
||||
@@ -417,10 +464,13 @@ export default function AgentChatPage({
|
||||
() => ({
|
||||
...(taskId ? taskThreadMetadata(taskContext) : {}),
|
||||
...(knowledgeSelectionReady
|
||||
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
|
||||
? {
|
||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
||||
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
[knowledgeSelectionReady, selectedKnowledgeBaseIds, taskContext, taskId],
|
||||
[knowledgeSelectionReady, selectedAssistantKnowledgeBaseIds, selectedKnowledgeBaseIds, taskContext, taskId],
|
||||
);
|
||||
// 任务工作区(taskBind):每次运行都带隐藏任务上下文(任务id/名称/内容),
|
||||
// 新建对话的消息历史里没有开场信息时模型也能拿到任务id(见 taskBindRunContext)。
|
||||
@@ -436,7 +486,10 @@ export default function AgentChatPage({
|
||||
...settings.context,
|
||||
agent_id,
|
||||
...(knowledgeSelectionReady
|
||||
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
|
||||
? {
|
||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
||||
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
|
||||
}
|
||||
: {}),
|
||||
...(taskBindRunContextValue ?? {}),
|
||||
},
|
||||
@@ -1021,9 +1074,13 @@ export default function AgentChatPage({
|
||||
source="agent"
|
||||
agentId={agent_id}
|
||||
value={selectedKnowledgeBaseIds}
|
||||
assistantValue={selectedAssistantKnowledgeBaseIds}
|
||||
onChange={changeKnowledgeSelection}
|
||||
onAssistantChange={changeAssistantKnowledgeSelection}
|
||||
autoSelectAll={
|
||||
isNewThread && pendingKnowledgeSelection.current === null
|
||||
isNewThread &&
|
||||
pendingKnowledgeSelection.current === null &&
|
||||
pendingAssistantKnowledgeSelection.current === null
|
||||
}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
||||
label="知识空间"
|
||||
@@ -1108,7 +1165,9 @@ export default function AgentChatPage({
|
||||
source="agent"
|
||||
agentId={agent_id}
|
||||
value={selectedKnowledgeBaseIds}
|
||||
assistantValue={selectedAssistantKnowledgeBaseIds}
|
||||
onChange={changeKnowledgeSelection}
|
||||
onAssistantChange={changeAssistantKnowledgeSelection}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
||||
readOnly
|
||||
label="知识空间"
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowDownWideNarrow,
|
||||
Bot,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
FileText,
|
||||
FileX2,
|
||||
Library,
|
||||
Loader2,
|
||||
MessagesSquare,
|
||||
MessageSquareShare,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
WorkspaceHeader,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import {
|
||||
downloadArtifactFile,
|
||||
downloadArtifactMarkdownAsWord,
|
||||
} from "@/core/artifacts/download";
|
||||
import {
|
||||
fetchArtifactLibrary,
|
||||
type ArtifactLibraryItem,
|
||||
type ArtifactLibraryKind,
|
||||
type ArtifactLibrarySource,
|
||||
} from "@/core/artifacts/library";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { bootstrapResumePath, pathOfThread } from "@/core/threads/utils";
|
||||
import { formatDateTime } from "@/core/utils/datetime";
|
||||
import {
|
||||
getFileExtensionDisplayName,
|
||||
getFileIcon,
|
||||
} from "@/core/utils/files";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const FILTERS: Array<{ value: ArtifactLibraryKind; label: string }> = [
|
||||
{ value: "all", label: "全部" },
|
||||
{ value: "document", label: "文档" },
|
||||
{ value: "spreadsheet", label: "表格" },
|
||||
{ value: "text_data", label: "文本与数据" },
|
||||
{ value: "archive", label: "压缩包" },
|
||||
{ value: "other", label: "其他" },
|
||||
];
|
||||
|
||||
const SOURCE_FILTERS: Array<{
|
||||
value: ArtifactLibrarySource;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "all", label: "全部来源" },
|
||||
{ value: "general_qa", label: "通用问答" },
|
||||
{ value: "agent", label: "智能体" },
|
||||
];
|
||||
|
||||
type DownloadFormat = "original" | "word";
|
||||
|
||||
function isMarkdownArtifact(name: string): boolean {
|
||||
return /\.(md|markdown)$/i.test(name);
|
||||
}
|
||||
|
||||
function artifactDownloadKey(
|
||||
item: ArtifactLibraryItem,
|
||||
format: DownloadFormat,
|
||||
): string {
|
||||
return `${item.threadId}:${item.path}:${format}`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
const index = Math.min(
|
||||
Math.floor(Math.log(bytes) / Math.log(1024)),
|
||||
units.length - 1,
|
||||
);
|
||||
const value = bytes / 1024 ** index;
|
||||
const digits = index === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 2;
|
||||
return `${value.toFixed(digits)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function sourceConversationPath(item: ArtifactLibraryItem): string {
|
||||
const basePath =
|
||||
item.threadType === "bootstrap"
|
||||
? bootstrapResumePath(item.threadId)
|
||||
: pathOfThread({
|
||||
thread_id: item.threadId,
|
||||
metadata: item.agentId ? { agent_id: item.agentId } : undefined,
|
||||
});
|
||||
const search = new URLSearchParams({ artifact: item.path });
|
||||
return `${basePath}?${search.toString()}`;
|
||||
}
|
||||
|
||||
function FileRow({
|
||||
item,
|
||||
downloading,
|
||||
onDownload,
|
||||
}: {
|
||||
item: ArtifactLibraryItem;
|
||||
downloading: DownloadFormat | null;
|
||||
onDownload: (item: ArtifactLibraryItem, format: DownloadFormat) => void;
|
||||
}) {
|
||||
const sourcePath = sourceConversationPath(item);
|
||||
const displayName = desensitize(item.name);
|
||||
const sourceTitle = desensitize(item.threadTitle || "新对话");
|
||||
const sourceLabel = item.sourceType === "agent" ? "智能体" : "通用问答";
|
||||
const sourceName =
|
||||
item.sourceType === "agent"
|
||||
? desensitize(item.agentName || item.agentId || "未知智能体")
|
||||
: sourceLabel;
|
||||
const SourceIcon = item.sourceType === "agent" ? Bot : MessagesSquare;
|
||||
const busy = downloading !== null;
|
||||
|
||||
return (
|
||||
<tr className="group border-b transition-colors hover:bg-muted/35">
|
||||
<td className="min-w-0 py-3 pr-4 pl-1">
|
||||
<Link
|
||||
href={sourcePath}
|
||||
className="flex min-w-0 items-center gap-3 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
title={`打开原对话并查看 ${displayName}`}
|
||||
>
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground/75">
|
||||
{getFileIcon(item.name, "size-5")}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-foreground group-hover:text-primary">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-xs text-muted-foreground lg:hidden">
|
||||
{getFileExtensionDisplayName(item.name)} · {sourceName} · 来自「
|
||||
{sourceTitle}」
|
||||
</span>
|
||||
<span className="mt-1 hidden text-xs text-muted-foreground lg:block">
|
||||
{getFileExtensionDisplayName(item.name)} 文件
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="hidden min-w-0 px-3 py-3 lg:table-cell">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<SourceIcon className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-foreground">
|
||||
{sourceName}
|
||||
</span>
|
||||
<Link
|
||||
href={sourcePath}
|
||||
className="block truncate text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||
title={`打开原对话:${sourceTitle}`}
|
||||
>
|
||||
{item.sourceType === "agent" ? "智能体 · " : ""}
|
||||
{sourceTitle}
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3 text-sm text-muted-foreground">
|
||||
{formatDateTime(item.modifiedAt)}
|
||||
</td>
|
||||
<td className="hidden whitespace-nowrap px-3 py-3 text-sm text-muted-foreground md:table-cell">
|
||||
{formatBytes(item.sizeBytes)}
|
||||
</td>
|
||||
<td className="py-3 pr-1 pl-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-8"
|
||||
disabled={busy}
|
||||
onClick={() => onDownload(item, "original")}
|
||||
title={`下载原文件:${displayName}`}
|
||||
aria-label={`下载原文件:${displayName}`}
|
||||
>
|
||||
{downloading === "original" ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
{isMarkdownArtifact(item.name) && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 gap-1 px-2 text-xs"
|
||||
disabled={busy}
|
||||
onClick={() => onDownload(item, "word")}
|
||||
title={`下载为 Word:${displayName}`}
|
||||
aria-label={`下载为 Word:${displayName}`}
|
||||
>
|
||||
{downloading === "word" ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<FileText className="size-4" />
|
||||
)}
|
||||
Word
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
asChild
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-8"
|
||||
title={`打开原对话:${sourceTitle}`}
|
||||
>
|
||||
<Link href={sourcePath} aria-label={`打开原对话:${sourceTitle}`}>
|
||||
<MessageSquareShare className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingRows() {
|
||||
return Array.from({ length: 5 }, (_, index) => (
|
||||
<tr className="border-b" key={index}>
|
||||
<td className="py-3 pr-4 pl-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 shrink-0" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-3 w-1/3" />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden px-3 lg:table-cell"><Skeleton className="h-4 w-3/4" /></td>
|
||||
<td className="px-3"><Skeleton className="h-4 w-24" /></td>
|
||||
<td className="hidden px-3 md:table-cell"><Skeleton className="h-4 w-16" /></td>
|
||||
<td className="pr-1 pl-3"><Skeleton className="ml-auto size-8" /></td>
|
||||
</tr>
|
||||
));
|
||||
}
|
||||
|
||||
export default function ArtifactLibraryPage() {
|
||||
const { t } = useI18n();
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [fileKind, setFileKind] = useState<ArtifactLibraryKind>("all");
|
||||
const [sourceType, setSourceType] = useState<ArtifactLibrarySource>("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
|
||||
const forceRefreshRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `资料库 - ${t.pages.appName}`;
|
||||
}, [t.pages.appName]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch, fileKind, sourceType]);
|
||||
|
||||
const offset = (page - 1) * PAGE_SIZE;
|
||||
const libraryQuery = useQuery({
|
||||
queryKey: ["artifact-library", debouncedSearch, fileKind, sourceType, page],
|
||||
queryFn: async ({ signal }) => {
|
||||
const refresh = forceRefreshRef.current;
|
||||
try {
|
||||
return await fetchArtifactLibrary({
|
||||
query: debouncedSearch,
|
||||
fileKind,
|
||||
sourceType,
|
||||
offset,
|
||||
limit: PAGE_SIZE,
|
||||
refresh,
|
||||
signal,
|
||||
});
|
||||
} finally {
|
||||
forceRefreshRef.current = false;
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const total = libraryQuery.data?.total ?? 0;
|
||||
const items = libraryQuery.data?.items ?? [];
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const hasPrevious = page > 1;
|
||||
const hasNext = page < totalPages;
|
||||
|
||||
useEffect(() => {
|
||||
if (page > totalPages) setPage(totalPages);
|
||||
}, [page, totalPages]);
|
||||
|
||||
const itemKeys = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
items.flatMap((item) => [
|
||||
artifactDownloadKey(item, "original"),
|
||||
artifactDownloadKey(item, "word"),
|
||||
]),
|
||||
),
|
||||
[items],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (downloadingKey && !itemKeys.has(downloadingKey)) setDownloadingKey(null);
|
||||
}, [downloadingKey, itemKeys]);
|
||||
|
||||
const handleDownload = async (
|
||||
item: ArtifactLibraryItem,
|
||||
format: DownloadFormat,
|
||||
) => {
|
||||
const key = artifactDownloadKey(item, format);
|
||||
if (downloadingKey) return;
|
||||
setDownloadingKey(key);
|
||||
try {
|
||||
if (format === "word") {
|
||||
await downloadArtifactMarkdownAsWord({
|
||||
filepath: item.path,
|
||||
threadId: item.threadId,
|
||||
});
|
||||
toast.success("Word 文件已生成");
|
||||
} else {
|
||||
await downloadArtifactFile({
|
||||
filepath: item.path,
|
||||
threadId: item.threadId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(error instanceof Error ? error.message : "下载失败");
|
||||
} finally {
|
||||
setDownloadingKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
forceRefreshRef.current = true;
|
||||
void libraryQuery.refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader />
|
||||
<WorkspaceBody className="items-stretch">
|
||||
<div className="flex min-h-0 w-full flex-1 flex-col">
|
||||
<header className="shrink-0 border-b bg-card/40 px-5 py-6 sm:px-8">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-5">
|
||||
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-start">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Library className="size-6 text-primary" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">资料库</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
汇总当前账号在对话中生成的文件,不包含上传文件、图片、音频和视频。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={libraryQuery.isFetching}
|
||||
>
|
||||
<RefreshCw className={cn("size-4", libraryQuery.isFetching && "animate-spin")} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-xl">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
className="h-11 pl-9"
|
||||
placeholder="搜索文件名、来源对话或智能体名称"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex flex-wrap gap-1" aria-label="文件类型筛选">
|
||||
{FILTERS.map((filter) => (
|
||||
<Button
|
||||
key={filter.value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={fileKind === filter.value ? "secondary" : "ghost"}
|
||||
aria-pressed={fileKind === filter.value}
|
||||
onClick={() => setFileKind(filter.value)}
|
||||
>
|
||||
{filter.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Select
|
||||
value={sourceType}
|
||||
onValueChange={(value) =>
|
||||
setSourceType(value as ArtifactLibrarySource)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-32"
|
||||
aria-label="筛选文件来源"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SOURCE_FILTERS.map((filter) => (
|
||||
<SelectItem key={filter.value} value={filter.value}>
|
||||
{filter.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<ArrowDownWideNarrow className="size-4" />
|
||||
最近生成
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<main className="mx-auto w-full max-w-6xl px-5 py-5 sm:px-8">
|
||||
<div className="mb-2 text-xs text-muted-foreground tabular-nums">
|
||||
{libraryQuery.isLoading ? "正在统计文件…" : `共 ${total} 个文件`}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full table-fixed border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs font-normal text-muted-foreground">
|
||||
<th className="w-[48%] py-3 pr-4 pl-1 font-normal lg:w-[35%]">文件名</th>
|
||||
<th className="hidden w-[25%] px-3 py-3 font-normal lg:table-cell">来源</th>
|
||||
<th className="w-[34%] px-3 py-3 font-normal md:w-[22%] lg:w-[16%]">生成时间</th>
|
||||
<th className="hidden w-[13%] px-3 py-3 font-normal lg:w-[10%] md:table-cell">大小</th>
|
||||
<th className="w-[18%] py-3 pr-1 pl-3 text-right font-normal md:w-[17%] lg:w-[14%]">
|
||||
<span className="sr-only">操作</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{libraryQuery.isLoading ? (
|
||||
<LoadingRows />
|
||||
) : (
|
||||
items.map((item) => {
|
||||
const key = `${item.threadId}:${item.path}`;
|
||||
const activeDownload = downloadingKey?.startsWith(`${key}:`)
|
||||
? downloadingKey.endsWith(":word")
|
||||
? "word"
|
||||
: "original"
|
||||
: null;
|
||||
return (
|
||||
<FileRow
|
||||
key={key}
|
||||
item={item}
|
||||
downloading={activeDownload}
|
||||
onDownload={(target, format) =>
|
||||
void handleDownload(target, format)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!libraryQuery.isLoading && libraryQuery.isError && (
|
||||
<div className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<FileX2 className="size-9 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">资料库加载失败</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{libraryQuery.error instanceof Error
|
||||
? libraryQuery.error.message
|
||||
: "请稍后重试"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!libraryQuery.isLoading && !libraryQuery.isError && items.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 py-20 text-center">
|
||||
<FileX2 className="size-9 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{debouncedSearch || fileKind !== "all" || sourceType !== "all"
|
||||
? "没有找到匹配的文件"
|
||||
: "还没有生成文件"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
对话中生成的文档、表格和数据文件会自动出现在这里。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!libraryQuery.isLoading && !libraryQuery.isError && totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-3 py-7">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasPrevious}
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasNext}
|
||||
onClick={() => setPage((current) => current + 1)}
|
||||
>
|
||||
下一页
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</WorkspaceBody>
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
import { BookOpen, Database, Download, Loader2, Plus, RefreshCw, Search, ShieldCheck, Trash2, Upload } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { KnowledgeProductNav } from "@/components/workspace/knowledge-product-nav";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import {
|
||||
type AssistantKnowledgeBase,
|
||||
type AssistantWikiPage,
|
||||
useAssistantImportJobs,
|
||||
useAssistantKnowledgeActions,
|
||||
useAssistantKnowledgeBases,
|
||||
useAssistantKnowledgeSources,
|
||||
useAssistantWikiPages,
|
||||
} from "@/strategy-components/api/assistant-knowledge";
|
||||
|
||||
function baseSubtitle(base: AssistantKnowledgeBase) {
|
||||
if (base.is_global) return "全库沉淀总库 · 所有 WeKnora 导入数据都会同步进入这里并做实体对齐";
|
||||
return base.description || "自建助手知识库 · 可上传普通知识库导出的 Wiki 向量包";
|
||||
}
|
||||
|
||||
function EmptyInitializeCard({
|
||||
isAdmin,
|
||||
onInitialize,
|
||||
pending,
|
||||
}: {
|
||||
isAdmin: boolean;
|
||||
onInitialize: () => void;
|
||||
pending: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-8">
|
||||
<div className="max-w-lg rounded-xl border bg-card p-8 text-center">
|
||||
<Database className="mx-auto mb-4 size-10 text-primary" />
|
||||
<h1 className="text-xl font-semibold">初始化助手知识库</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
未配置 WeKnora 时会默认展示助手知识库。首次使用需要创建“知识梳理总库”,后续从 WeKnora 导入的数据都会沉淀到这里。
|
||||
</p>
|
||||
{isAdmin ? (
|
||||
<Button className="mt-5" disabled={pending} onClick={onInitialize}>
|
||||
{pending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Database className="mr-2 size-4" />}
|
||||
创建知识梳理总库
|
||||
</Button>
|
||||
) : (
|
||||
<p className="mt-5 text-sm">请联系管理员初始化。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AssistantKnowledgePage() {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedBaseId, setSelectedBaseId] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<AssistantWikiPage | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createDraft, setCreateDraft] = useState({ name: "", description: "" });
|
||||
const packageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const bases = useAssistantKnowledgeBases();
|
||||
const actions = useAssistantKnowledgeActions();
|
||||
|
||||
useEffect(() => {
|
||||
const values = bases.data ?? [];
|
||||
if (!values.length) {
|
||||
setSelectedBaseId(null);
|
||||
return;
|
||||
}
|
||||
if (selectedBaseId && values.some((base) => base.id === selectedBaseId)) return;
|
||||
setSelectedBaseId((values.find((base) => base.is_global) ?? values[0]).id);
|
||||
}, [bases.data, selectedBaseId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(null);
|
||||
}, [selectedBaseId]);
|
||||
|
||||
const selectedBase = useMemo(
|
||||
() => (bases.data ?? []).find((base) => base.id === selectedBaseId) ?? null,
|
||||
[bases.data, selectedBaseId],
|
||||
);
|
||||
const pages = useAssistantWikiPages(query, selectedBaseId);
|
||||
const sources = useAssistantKnowledgeSources(selectedBaseId);
|
||||
const jobs = useAssistantImportJobs(selectedBaseId);
|
||||
const activePage = selected ?? pages.data?.[0] ?? null;
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
pages: pages.data?.length ?? selectedBase?.page_count ?? 0,
|
||||
sources: sources.data?.length ?? selectedBase?.source_count ?? 0,
|
||||
jobs: jobs.data?.length ?? 0,
|
||||
}),
|
||||
[jobs.data, pages.data, selectedBase?.page_count, selectedBase?.source_count, sources.data],
|
||||
);
|
||||
|
||||
const initializeGlobal = () => {
|
||||
actions.initialize.mutate("知识梳理总库", {
|
||||
onSuccess: (base) => {
|
||||
setSelectedBaseId(base.id);
|
||||
toast.success("知识梳理总库已创建");
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
};
|
||||
|
||||
const createBase = async () => {
|
||||
const name = createDraft.name.trim();
|
||||
if (!name) {
|
||||
toast.error("请输入助手知识库名称");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = await actions.createBase.mutateAsync({
|
||||
name,
|
||||
description: createDraft.description.trim(),
|
||||
});
|
||||
setSelectedBaseId(base.id);
|
||||
setCreateDraft({ name: "", description: "" });
|
||||
setCreateOpen(false);
|
||||
toast.success("助手知识库已创建");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const uploadPackage = async (file: File | null | undefined) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
await actions.uploadWeKnoraPackage.mutateAsync({
|
||||
file,
|
||||
assistantBaseId: selectedBaseId,
|
||||
});
|
||||
toast.success("导出包已上传,后台正在异步导入");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
if (packageInputRef.current) packageInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
if (bases.isLoading) {
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
<KnowledgeProductNav />
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!bases.data?.length) {
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
<KnowledgeProductNav />
|
||||
<EmptyInitializeCard
|
||||
isAdmin={isAdmin}
|
||||
pending={actions.initialize.isPending}
|
||||
onInitialize={initializeGlobal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full min-h-0 flex-col bg-background">
|
||||
<KnowledgeProductNav />
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[280px_1fr]">
|
||||
<aside className="flex min-h-0 flex-col border-r bg-muted/20">
|
||||
<div className="border-b p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">助手知识库</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">总库置顶,自建库可上传 WeKnora 导出包</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Button size="icon" variant="outline" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-2 p-3">
|
||||
{(bases.data ?? []).map((base) => (
|
||||
<button
|
||||
key={base.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedBaseId(base.id)}
|
||||
className={`w-full rounded-lg border px-3 py-3 text-left transition ${
|
||||
selectedBaseId === base.id ? "border-primary bg-primary/10 text-primary" : "bg-card hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="size-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{base.name}</span>
|
||||
{base.is_global && <Badge variant="outline" className="text-[10px]">总库</Badge>}
|
||||
</div>
|
||||
<div className="mt-2 flex gap-3 text-[11px] text-muted-foreground">
|
||||
<span>{base.page_count ?? 0} 页</span>
|
||||
<span>{base.source_count ?? 0} 来源</span>
|
||||
<span>{base.entity_count ?? 0} 实体</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-h-0 flex-col">
|
||||
<header className="flex items-center gap-4 border-b px-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-xl font-semibold">{selectedBase?.name}</h1>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{selectedBase ? baseSubtitle(selectedBase) : "请选择助手知识库"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
<ShieldCheck className="mr-1 size-3" />
|
||||
实体对齐
|
||||
</Badge>
|
||||
{isAdmin && (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<input
|
||||
ref={packageInputRef}
|
||||
type="file"
|
||||
accept=".jsonl,.ndjson,application/x-ndjson,application/json"
|
||||
className="hidden"
|
||||
onChange={(event) => void uploadPackage(event.target.files?.[0])}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={actions.uploadWeKnoraPackage.isPending || !selectedBaseId}
|
||||
onClick={() => packageInputRef.current?.click()}
|
||||
>
|
||||
{actions.uploadWeKnoraPackage.isPending ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 size-4" />
|
||||
)}
|
||||
上传 WeKnora 导出包
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<Tabs defaultValue="wiki" className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-4 border-b px-6">
|
||||
<TabsList className="my-2">
|
||||
<TabsTrigger value="wiki">Wiki({counts.pages})</TabsTrigger>
|
||||
<TabsTrigger value="sources">来源({counts.sources})</TabsTrigger>
|
||||
<TabsTrigger value="jobs">导入记录({counts.jobs})</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="relative ml-auto w-72">
|
||||
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索标题、实体别名和正文"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="wiki" className="mt-0 grid min-h-0 flex-1 grid-cols-[300px_1fr]">
|
||||
<ScrollArea className="border-r">
|
||||
<div className="space-y-1 p-3">
|
||||
{pages.isLoading && <Loader2 className="m-4 size-5 animate-spin" />}
|
||||
{pages.data?.map((page) => (
|
||||
<button
|
||||
key={page.id}
|
||||
type="button"
|
||||
onClick={() => setSelected(page)}
|
||||
className={`w-full rounded-md px-3 py-2 text-left ${
|
||||
activePage?.id === page.id ? "bg-primary/10 text-primary" : "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<div className="truncate text-sm font-medium">{page.title}</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>{page.page_type}</span>
|
||||
<span>{new Date(page.updated_at).toLocaleString()}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{!pages.isLoading && !pages.data?.length && (
|
||||
<div className="p-4 text-xs text-muted-foreground">
|
||||
暂无 Wiki 页面。请先到普通知识库详情页下载向量化数据包,再在当前助手知识库上传。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<ScrollArea>
|
||||
<article className="mx-auto max-w-5xl p-8">
|
||||
{activePage ? (
|
||||
<>
|
||||
<h1 className="mb-1 flex items-center gap-2 text-2xl font-semibold">
|
||||
<BookOpen className="size-5 text-primary" />
|
||||
{activePage.title}
|
||||
</h1>
|
||||
<p className="mb-6 text-xs text-muted-foreground">{activePage.slug}</p>
|
||||
<MarkdownContent content={activePage.content_markdown} isLoading={false} rehypePlugins={[]} />
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无 Wiki 页面。</p>
|
||||
)}
|
||||
</article>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sources" className="mt-0 min-h-0 flex-1 overflow-auto p-6">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{sources.data?.map((source) => (
|
||||
<div key={source.id} className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="size-4" />
|
||||
<span className="truncate font-medium">{source.source_name}</span>
|
||||
<Badge variant="outline" className="ml-auto">{source.source_type}</Badge>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">业务键:{source.source_key}</p>
|
||||
{isAdmin && (
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={source.source_type !== "weknora" || actions.reimportSource.isPending}
|
||||
onClick={() =>
|
||||
actions.reimportSource.mutate(source.id, {
|
||||
onSuccess: () => toast.success("已发起重新导入"),
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
}
|
||||
>
|
||||
<RefreshCw className="mr-2 size-4" />
|
||||
重新导入
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={actions.deleteSource.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`确认删除来源“${source.source_name}”的贡献?仍被其他来源引用的知识会保留。`)) {
|
||||
actions.deleteSource.mutate(source.id, {
|
||||
onSuccess: () => toast.success("来源贡献已删除"),
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
删除来源
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!sources.data?.length && <p className="text-sm text-muted-foreground">暂无来源。</p>}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="jobs" className="mt-0 min-h-0 flex-1 overflow-auto p-6">
|
||||
<div className="space-y-3">
|
||||
{jobs.data?.map((job) => (
|
||||
<div key={job.id} className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge>{job.status}</Badge>
|
||||
<span className="text-sm">{job.trigger}</span>
|
||||
<span className="text-xs text-muted-foreground">阶段:{job.phase}</span>
|
||||
<span className="ml-auto text-xs">{new Date(job.created_at).toLocaleString()}</span>
|
||||
{isAdmin && job.package_download_url && (
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={job.package_download_url} target="_blank" rel="noreferrer">
|
||||
<Download className="mr-2 size-4" />
|
||||
下载导出包
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
{Object.entries(job.counts || {}).map(([key, value]) => `${key} ${value}`).join(" · ") || "暂无统计"}
|
||||
</div>
|
||||
{job.last_error_message && (
|
||||
<div className="mt-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{job.last_error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!jobs.data?.length && <p className="text-sm text-muted-foreground">暂无导入记录。</p>}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建助手知识库</DialogTitle>
|
||||
<DialogDescription>
|
||||
自建助手知识库用于承接从 WeKnora 普通知识库下载的 Wiki 向量包;导入时同一份数据也会沉淀到“知识梳理总库”。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">名称</label>
|
||||
<Input
|
||||
value={createDraft.name}
|
||||
onChange={(event) => setCreateDraft((prev) => ({ ...prev, name: event.target.value }))}
|
||||
placeholder="例如:政策资料库"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">描述</label>
|
||||
<Textarea
|
||||
value={createDraft.description}
|
||||
onChange={(event) => setCreateDraft((prev) => ({ ...prev, description: event.target.value }))}
|
||||
placeholder="可选,用于说明这个助手知识库的范围"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={actions.createBase.isPending} onClick={() => void createBase()}>
|
||||
{actions.createBase.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Plus className="mr-2 size-4" />}
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -142,6 +142,7 @@ export default function ChatPage() {
|
||||
// per-thread header toggle so stale localStorage can't re-enable it.
|
||||
const [knowledgeRagEnabled] = useKnowledgeRagEnabled();
|
||||
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
|
||||
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>([]);
|
||||
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(false);
|
||||
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
|
||||
const knowledgeSelectionThread = useRef<string | null>(null);
|
||||
@@ -150,18 +151,26 @@ export default function ChatPage() {
|
||||
setSelectedKnowledgeBaseIds(ids);
|
||||
setKnowledgeSelectionReady(true);
|
||||
}, []);
|
||||
const changeAssistantKnowledgeSelection = useCallback((ids: string[]) => {
|
||||
setSelectedAssistantKnowledgeBaseIds(ids);
|
||||
setKnowledgeSelectionReady(true);
|
||||
}, []);
|
||||
const streamContext = useMemo(
|
||||
() => ({
|
||||
...settings.context,
|
||||
knowledge_rag_enabled: knowledgeRagEnabled,
|
||||
...(knowledgeSelectionReady
|
||||
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
|
||||
? {
|
||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
||||
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
[
|
||||
settings.context,
|
||||
knowledgeRagEnabled,
|
||||
knowledgeSelectionReady,
|
||||
selectedAssistantKnowledgeBaseIds,
|
||||
selectedKnowledgeBaseIds,
|
||||
],
|
||||
);
|
||||
@@ -200,6 +209,7 @@ export default function ChatPage() {
|
||||
setHasStartedConversation(false);
|
||||
setPromptPrefixOn(false);
|
||||
setSelectedKnowledgeBaseIds([]);
|
||||
setSelectedAssistantKnowledgeBaseIds([]);
|
||||
setKnowledgeSelectionReady(false);
|
||||
setKnowledgeSelectionLoading(false);
|
||||
knowledgeSelectionThread.current = "__new__";
|
||||
@@ -225,18 +235,29 @@ export default function ChatPage() {
|
||||
if (knowledgeSelectionThread.current !== threadId) return;
|
||||
const metadata = record.metadata as Record<string, unknown> | undefined;
|
||||
const raw = metadata?.llmwiki_knowledge_base_ids;
|
||||
const assistantRaw = metadata?.assistant_knowledge_base_ids;
|
||||
const llmwikiIds = Array.isArray(raw)
|
||||
? raw.map((item) => String(item).trim()).filter(Boolean)
|
||||
: [];
|
||||
const assistantIds = Array.isArray(assistantRaw)
|
||||
? assistantRaw.map((item) => String(item).trim()).filter(Boolean)
|
||||
: [];
|
||||
if (Array.isArray(raw)) {
|
||||
setSelectedKnowledgeBaseIds(
|
||||
raw.map((item) => String(item).trim()).filter(Boolean),
|
||||
);
|
||||
setKnowledgeSelectionReady(true);
|
||||
setSelectedKnowledgeBaseIds(llmwikiIds);
|
||||
} else {
|
||||
setSelectedKnowledgeBaseIds([]);
|
||||
setKnowledgeSelectionReady(false);
|
||||
}
|
||||
if (Array.isArray(assistantRaw)) {
|
||||
setSelectedAssistantKnowledgeBaseIds(assistantIds);
|
||||
} else {
|
||||
setSelectedAssistantKnowledgeBaseIds([]);
|
||||
}
|
||||
setKnowledgeSelectionReady(Array.isArray(raw) || Array.isArray(assistantRaw));
|
||||
})
|
||||
.catch(() => {
|
||||
if (knowledgeSelectionThread.current === threadId) {
|
||||
setSelectedKnowledgeBaseIds([]);
|
||||
setSelectedAssistantKnowledgeBaseIds([]);
|
||||
setKnowledgeSelectionReady(false);
|
||||
}
|
||||
})
|
||||
@@ -259,6 +280,7 @@ export default function ChatPage() {
|
||||
if (!known.some((a) => a.id === selectedAgentId)) {
|
||||
setSelectedAgentId(null);
|
||||
setSelectedKnowledgeBaseIds([]);
|
||||
setSelectedAssistantKnowledgeBaseIds([]);
|
||||
setKnowledgeSelectionReady(false);
|
||||
}
|
||||
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
|
||||
@@ -269,7 +291,10 @@ export default function ChatPage() {
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
context: streamContext,
|
||||
threadMetadata: knowledgeSelectionReady
|
||||
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
|
||||
? {
|
||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
||||
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
|
||||
}
|
||||
: undefined,
|
||||
isMock,
|
||||
onStart: (createdThreadId) => {
|
||||
@@ -443,7 +468,10 @@ export default function ChatPage() {
|
||||
metadata: {
|
||||
agent_id: selectedAgentId,
|
||||
...(knowledgeSelectionReady
|
||||
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
|
||||
? {
|
||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
||||
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
@@ -454,7 +482,10 @@ export default function ChatPage() {
|
||||
threadId: created.thread_id,
|
||||
text,
|
||||
...(knowledgeSelectionReady
|
||||
? { knowledgeBaseIds: selectedKnowledgeBaseIds }
|
||||
? {
|
||||
knowledgeBaseIds: selectedKnowledgeBaseIds,
|
||||
assistantKnowledgeBaseIds: selectedAssistantKnowledgeBaseIds,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
@@ -516,6 +547,7 @@ export default function ChatPage() {
|
||||
hideAdminPrompt,
|
||||
preferencesLoading,
|
||||
selectedAgentId,
|
||||
selectedAssistantKnowledgeBaseIds,
|
||||
selectedKnowledgeBaseIds,
|
||||
knowledgeSelectionReady,
|
||||
agentHandoffPending,
|
||||
@@ -619,6 +651,7 @@ export default function ChatPage() {
|
||||
const handleAgentChange = useCallback((agentId: string | null) => {
|
||||
setSelectedAgentId(agentId);
|
||||
setSelectedKnowledgeBaseIds([]);
|
||||
setSelectedAssistantKnowledgeBaseIds([]);
|
||||
setKnowledgeSelectionReady(false);
|
||||
saveLastAgentId(agentId);
|
||||
}, []);
|
||||
@@ -907,7 +940,9 @@ export default function ChatPage() {
|
||||
source={selectedAgentId ? "agent" : "all"}
|
||||
agentId={selectedAgentId}
|
||||
value={selectedKnowledgeBaseIds}
|
||||
assistantValue={selectedAssistantKnowledgeBaseIds}
|
||||
onChange={changeKnowledgeSelection}
|
||||
onAssistantChange={changeAssistantKnowledgeSelection}
|
||||
autoSelectAll={isLandingState && !!selectedAgentId}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
|
||||
@@ -3,6 +3,11 @@ import { useState } from "react";
|
||||
import { Navigate, useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
KNOWLEDGE_NORMAL_VIEW_VALUE,
|
||||
KnowledgeProductNav,
|
||||
useKnowledgeBaseProductVersion,
|
||||
} from "@/components/workspace/knowledge-product-nav";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -70,7 +75,9 @@ export default function LlmWikiKnowledgePage({
|
||||
const { noteId = "" } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const productVersion = useKnowledgeBaseProductVersion();
|
||||
const wikiSlug = searchParams.get("wikiSlug")?.trim() || "";
|
||||
const explicitNormalView = searchParams.get("view") === KNOWLEDGE_NORMAL_VIEW_VALUE;
|
||||
|
||||
if (mode === "detail" && noteId && wikiSlug) {
|
||||
return <Navigate to={buildWeKnoraWikiPagePath(noteId, wikiSlug)} replace />;
|
||||
@@ -80,7 +87,14 @@ export default function LlmWikiKnowledgePage({
|
||||
return <RuntimeLoading />;
|
||||
}
|
||||
|
||||
if (mode === "list" && productVersion.data === "v2" && !explicitNormalView) {
|
||||
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
|
||||
}
|
||||
|
||||
if (runtime.isError || runtime.data?.provider !== "weknora") {
|
||||
if (mode === "list" || mode === "detail") {
|
||||
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
|
||||
}
|
||||
if (mode === "graph") {
|
||||
return <KnowledgeGraphPage />;
|
||||
}
|
||||
@@ -95,5 +109,5 @@ export default function LlmWikiKnowledgePage({
|
||||
return <WeKnoraKnowledgeDetailPage />;
|
||||
}
|
||||
|
||||
return <WeKnoraKnowledgeWorkspace />;
|
||||
return <div className="flex size-full min-h-0 flex-col"><KnowledgeProductNav /><div className="min-h-0 flex-1"><WeKnoraKnowledgeWorkspace /></div></div>;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { agentRefOfThread } from '@/core/threads/utils';
|
||||
|
||||
import ChatsPage from './ChatsPage';
|
||||
import ChatPage from './ChatPage';
|
||||
import ArtifactLibraryPage from './ArtifactLibraryPage';
|
||||
import IframeChatPage from './IframeChatPage';
|
||||
import AgentsPage from './AgentsPage';
|
||||
import AgentChatPage from './AgentChatPage';
|
||||
@@ -41,6 +42,7 @@ import AdminSkillsPage from './AdminSkillsPage';
|
||||
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
|
||||
import ScheduledTasksPage from './ScheduledTasksPage';
|
||||
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
|
||||
import AssistantKnowledgePage from './AssistantKnowledgePage';
|
||||
import WeKnoraWikiManagementPage from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage';
|
||||
import WeKnoraWikiPageView from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView';
|
||||
import MarkdownDocxEditorPage from './MarkdownDocxEditorPage';
|
||||
@@ -95,6 +97,7 @@ function shouldShowCompactTopActions(pathname: string): boolean {
|
||||
pathname === '/page/workspace/agents' ||
|
||||
pathname === '/page/workspace/skills' ||
|
||||
pathname === '/page/workspace/memory' ||
|
||||
pathname === '/page/workspace/library' ||
|
||||
pathname === '/page/workspace/knowledge' ||
|
||||
pathname.startsWith('/page/workspace/knowledge/')
|
||||
);
|
||||
@@ -518,6 +521,20 @@ export default function WorkspaceRoutes({
|
||||
path="chats/:thread_id"
|
||||
element={<ChatThreadEntryRoute layoutProps={layoutProps} />}
|
||||
/>
|
||||
<Route
|
||||
path="library"
|
||||
element={
|
||||
<WorkspaceLayout
|
||||
{...layoutProps}
|
||||
workspaceSidebarHidden={
|
||||
layoutProps.workspaceSidebarHidden ||
|
||||
!layoutProps.pageSidebarHidden
|
||||
}
|
||||
>
|
||||
<ArtifactLibraryPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="chats_iframe/new"
|
||||
element={<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam={false} />}
|
||||
@@ -753,6 +770,14 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="knowledge/assistant"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AssistantKnowledgePage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="knowledge/:noteId/wiki/view"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/assistant-knowledge`;
|
||||
|
||||
export interface AssistantKnowledgeBase {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
base_kind?: "global" | "custom";
|
||||
is_global?: boolean;
|
||||
status: string;
|
||||
source_count?: number;
|
||||
page_count?: number;
|
||||
entity_count?: number;
|
||||
relation_count?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AssistantWikiPage {
|
||||
id: string;
|
||||
base_id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
page_type: string;
|
||||
content_markdown: string;
|
||||
summary: string;
|
||||
current_revision_id?: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AssistantWikiRevision {
|
||||
id: string;
|
||||
page_id: string;
|
||||
revision_no: number;
|
||||
summary: string;
|
||||
content_markdown: string;
|
||||
change_type: string;
|
||||
created_by?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AssistantKnowledgeSource {
|
||||
id: string;
|
||||
base_id: string;
|
||||
source_type: "weknora" | "skill" | "local_file";
|
||||
source_name: string;
|
||||
source_key: string;
|
||||
status: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AssistantImportJob {
|
||||
id: string;
|
||||
base_id: string;
|
||||
source_id: string;
|
||||
mirrored_global_base_id?: string;
|
||||
mirrored_global_job_id?: string;
|
||||
package_download_url?: string;
|
||||
trigger: string;
|
||||
status: string;
|
||||
phase: string;
|
||||
counts: Record<string, number>;
|
||||
last_error_message?: string | null;
|
||||
created_at: string;
|
||||
completed_at?: string | null;
|
||||
}
|
||||
|
||||
export interface LlmWikiExportPackageJob {
|
||||
id: string;
|
||||
mapping_id: string;
|
||||
status: string;
|
||||
phase: string;
|
||||
counts: Record<string, number>;
|
||||
package_download_url?: string;
|
||||
status_url?: string;
|
||||
error?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
completed_at?: string | null;
|
||||
file_exists?: boolean;
|
||||
file_size_bytes?: number;
|
||||
}
|
||||
|
||||
async function readJson<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
const detail = data && typeof data === "object" && "detail" in data
|
||||
? (data as { detail: unknown }).detail
|
||||
: `请求失败 (${response.status})`;
|
||||
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
function normalizeImportJob(job: AssistantImportJob): AssistantImportJob {
|
||||
const url = normalizeBackendUrl(job.package_download_url);
|
||||
return url ? { ...job, package_download_url: url } : job;
|
||||
}
|
||||
|
||||
function normalizeBackendUrl(url: string | undefined | null): string | undefined {
|
||||
if (!url || /^https?:\/\//i.test(url)) return url || undefined;
|
||||
return `${getBackendBaseURL()}${url.startsWith("/") ? url : `/${url}`}`;
|
||||
}
|
||||
|
||||
function normalizeExportPackageJob(job: LlmWikiExportPackageJob): LlmWikiExportPackageJob {
|
||||
return {
|
||||
...job,
|
||||
package_download_url: normalizeBackendUrl(job.package_download_url),
|
||||
status_url: normalizeBackendUrl(job.status_url),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAssistantKnowledgeBase() {
|
||||
const data = await readJson<{ base: AssistantKnowledgeBase | null }>(await apiFetch(`${BASE()}/base`));
|
||||
return data.base;
|
||||
}
|
||||
|
||||
export async function getAssistantKnowledgeBaseById(baseId: string) {
|
||||
const data = await readJson<{ base: AssistantKnowledgeBase }>(
|
||||
await apiFetch(`${BASE()}/bases/${encodeURIComponent(baseId)}`),
|
||||
);
|
||||
return data.base;
|
||||
}
|
||||
|
||||
export async function listAssistantKnowledgeBases() {
|
||||
return (await readJson<{ bases: AssistantKnowledgeBase[] }>(await apiFetch(`${BASE()}/bases`))).bases;
|
||||
}
|
||||
|
||||
export async function initializeAssistantKnowledgeBase(name = "知识梳理总库") {
|
||||
const data = await readJson<{ base: AssistantKnowledgeBase }>(
|
||||
await apiFetch(`${BASE()}/base/initialize`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
);
|
||||
return data.base;
|
||||
}
|
||||
|
||||
export async function createAssistantKnowledgeBase(input: { name: string; description?: string }) {
|
||||
const data = await readJson<{ base: AssistantKnowledgeBase }>(
|
||||
await apiFetch(`${BASE()}/bases`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
);
|
||||
return data.base;
|
||||
}
|
||||
|
||||
function withBaseParam(baseId?: string | null) {
|
||||
const params = new URLSearchParams();
|
||||
if (baseId) params.set("base_id", baseId);
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function listAssistantWikiPages(q = "", baseId?: string | null) {
|
||||
const params = withBaseParam(baseId);
|
||||
if (q.trim()) params.set("q", q.trim());
|
||||
return (await readJson<{ pages: AssistantWikiPage[] }>(await apiFetch(`${BASE()}/wiki-pages?${params}`))).pages;
|
||||
}
|
||||
|
||||
export async function listAssistantKnowledgeSources(baseId?: string | null) {
|
||||
const params = withBaseParam(baseId);
|
||||
return (await readJson<{ sources: AssistantKnowledgeSource[] }>(await apiFetch(`${BASE()}/sources?${params}`))).sources;
|
||||
}
|
||||
|
||||
export async function listAssistantImportJobs(baseId?: string | null) {
|
||||
const params = withBaseParam(baseId);
|
||||
return (
|
||||
await readJson<{ jobs: AssistantImportJob[] }>(await apiFetch(`${BASE()}/import-jobs?${params}`))
|
||||
).jobs.map(normalizeImportJob);
|
||||
}
|
||||
|
||||
export async function listAssistantWikiRevisions(slug: string, baseId?: string | null) {
|
||||
const params = withBaseParam(baseId);
|
||||
return (await readJson<{ revisions: AssistantWikiRevision[] }>(
|
||||
await apiFetch(`${BASE()}/wiki-pages/${encodeURIComponent(slug)}/revisions?${params}`),
|
||||
)).revisions;
|
||||
}
|
||||
|
||||
export async function rollbackAssistantWikiRevision(slug: string, revisionId: string, baseId?: string | null) {
|
||||
const params = withBaseParam(baseId);
|
||||
return readJson<AssistantWikiRevision>(
|
||||
await apiFetch(
|
||||
`${BASE()}/wiki-pages/${encodeURIComponent(slug)}/revisions/${encodeURIComponent(revisionId)}/rollback?${params}`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportLlmWikiToAssistant(mappingId: string, assistantBaseId?: string | null) {
|
||||
return normalizeImportJob(await readJson<AssistantImportJob>(
|
||||
await apiFetch(
|
||||
`${getBackendBaseURL()}/api/llmwiki/knowledge-bases/${encodeURIComponent(mappingId)}/export-to-assistant`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ assistant_base_id: assistantBaseId || null }),
|
||||
},
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
export async function createLlmWikiExportPackage(mappingId: string) {
|
||||
return normalizeExportPackageJob(
|
||||
await readJson<LlmWikiExportPackageJob>(
|
||||
await apiFetch(
|
||||
`${getBackendBaseURL()}/api/llmwiki/knowledge-bases/${encodeURIComponent(mappingId)}/export-package`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function listLlmWikiExportPackages(mappingId: string) {
|
||||
return (
|
||||
await readJson<{ jobs: LlmWikiExportPackageJob[] }>(
|
||||
await apiFetch(
|
||||
`${getBackendBaseURL()}/api/llmwiki/knowledge-bases/${encodeURIComponent(mappingId)}/export-package`,
|
||||
),
|
||||
)
|
||||
).jobs.map(normalizeExportPackageJob);
|
||||
}
|
||||
|
||||
export async function getLlmWikiExportPackageStatus(mappingId: string, jobId: string) {
|
||||
return normalizeExportPackageJob(
|
||||
await readJson<LlmWikiExportPackageJob>(
|
||||
await apiFetch(
|
||||
`${getBackendBaseURL()}/api/llmwiki/knowledge-bases/${encodeURIComponent(mappingId)}/export-package/${encodeURIComponent(jobId)}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function reimportAssistantKnowledgeSource(sourceId: string) {
|
||||
return normalizeImportJob(await readJson<AssistantImportJob>(
|
||||
await apiFetch(`${BASE()}/sources/${encodeURIComponent(sourceId)}/reimport`, {
|
||||
method: "POST",
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
export function getAssistantImportPackageUrl(jobId: string) {
|
||||
return `${BASE()}/import-jobs/${encodeURIComponent(jobId)}/package`;
|
||||
}
|
||||
|
||||
export async function uploadAssistantWeKnoraPackage(input: { file: File; assistantBaseId?: string | null }) {
|
||||
const body = new FormData();
|
||||
body.set("file", input.file);
|
||||
if (input.assistantBaseId) body.set("assistant_base_id", input.assistantBaseId);
|
||||
return normalizeImportJob(await readJson<AssistantImportJob>(
|
||||
await apiFetch(`${BASE()}/imports/weknora-package`, {
|
||||
method: "POST",
|
||||
body,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
export async function deleteAssistantKnowledgeSource(sourceId: string) {
|
||||
const response = await apiFetch(`${BASE()}/sources/${encodeURIComponent(sourceId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) await readJson(response);
|
||||
}
|
||||
|
||||
const KEY = "assistant-knowledge";
|
||||
|
||||
export function useAssistantKnowledgeBase(baseId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "base", baseId ?? "global"],
|
||||
queryFn: () => (baseId ? getAssistantKnowledgeBaseById(baseId) : getAssistantKnowledgeBase()),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssistantKnowledgeBases() {
|
||||
return useQuery({ queryKey: [KEY, "bases"], queryFn: listAssistantKnowledgeBases });
|
||||
}
|
||||
|
||||
export function useAssistantWikiPages(query = "", baseId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "pages", baseId ?? "global", query],
|
||||
queryFn: () => listAssistantWikiPages(query, baseId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssistantKnowledgeSources(baseId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "sources", baseId ?? "global"],
|
||||
queryFn: () => listAssistantKnowledgeSources(baseId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssistantImportJobs(baseId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "jobs", baseId ?? "global"],
|
||||
queryFn: () => listAssistantImportJobs(baseId),
|
||||
refetchInterval: (query) =>
|
||||
(query.state.data ?? []).some((job) => ["queued", "running"].includes(job.status))
|
||||
? 3000
|
||||
: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssistantKnowledgeActions() {
|
||||
const client = useQueryClient();
|
||||
const invalidate = () => client.invalidateQueries({ queryKey: [KEY] });
|
||||
return {
|
||||
initialize: useMutation({ mutationFn: initializeAssistantKnowledgeBase, onSuccess: invalidate }),
|
||||
createBase: useMutation({ mutationFn: createAssistantKnowledgeBase, onSuccess: invalidate }),
|
||||
uploadWeKnoraPackage: useMutation({ mutationFn: uploadAssistantWeKnoraPackage, onSuccess: invalidate }),
|
||||
reimportSource: useMutation({ mutationFn: reimportAssistantKnowledgeSource, onSuccess: invalidate }),
|
||||
deleteSource: useMutation({ mutationFn: deleteAssistantKnowledgeSource, onSuccess: invalidate }),
|
||||
rollback: useMutation({
|
||||
mutationFn: ({ slug, revisionId, baseId }: { slug: string; revisionId: string; baseId?: string | null }) =>
|
||||
rollbackAssistantWikiRevision(slug, revisionId, baseId),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export interface LlmWikiKnowledgeBase {
|
||||
remote_status: "available" | "missing";
|
||||
name: string;
|
||||
description: string;
|
||||
type: "document" | "faq";
|
||||
type: "document" | "faq" | "wiki";
|
||||
publication_status: "private" | "pending" | "published" | "rejected";
|
||||
owner_user_id?: string | null;
|
||||
is_owner: boolean;
|
||||
@@ -37,6 +37,12 @@ export interface LlmWikiKnowledgeBase {
|
||||
knowledge_count: number;
|
||||
chunk_count: number;
|
||||
processing_count: number;
|
||||
share_count?: number;
|
||||
is_processing?: boolean;
|
||||
is_temporary?: boolean;
|
||||
wiki_index_enabled?: boolean;
|
||||
external_search_enabled?: boolean;
|
||||
external_search_updated_at?: string | null;
|
||||
embedding_model_id?: string | null;
|
||||
vector_store_id?: string | null;
|
||||
vector_store_name?: string | null;
|
||||
@@ -44,19 +50,65 @@ export interface LlmWikiKnowledgeBase {
|
||||
vector_store_engine_type?: string | null;
|
||||
vector_store_status?: string | null;
|
||||
chunking_config?: Record<string, unknown>;
|
||||
image_processing_config?: Record<string, unknown>;
|
||||
indexing_strategy?: Record<string, boolean>;
|
||||
capabilities?: Record<string, boolean>;
|
||||
extract_config?: Record<string, unknown>;
|
||||
faq_config?: Record<string, unknown>;
|
||||
wiki_config?: Record<string, unknown>;
|
||||
question_generation_config?: Record<string, unknown>;
|
||||
auto_tag_config?: Record<string, unknown>;
|
||||
vlm_config?: Record<string, unknown>;
|
||||
asr_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;
|
||||
remote_created_at?: string | null;
|
||||
remote_updated_at?: string | null;
|
||||
is_conversation_deposit?: boolean;
|
||||
}
|
||||
|
||||
export interface PublicLlmWikiKnowledgeBase extends LlmWikiKnowledgeBase {
|
||||
wiki: {
|
||||
status: "ready" | "empty" | "unavailable";
|
||||
page_count: number;
|
||||
loaded_page_count: number;
|
||||
stats_complete: boolean;
|
||||
page_type_counts: Record<string, number>;
|
||||
page_status_counts: Record<string, number>;
|
||||
index_mode: "explicit" | "generated" | "none" | "unknown";
|
||||
has_explicit_index: boolean;
|
||||
index_slug: string | null;
|
||||
default_slug: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
embed_route: string;
|
||||
};
|
||||
local_index: {
|
||||
enabled: boolean;
|
||||
wiki_index_enabled: boolean;
|
||||
state: LlmWikiWikiIndexStatus["state"];
|
||||
fully_vectorized: boolean;
|
||||
retrieval_mode: "vector" | "wiki_api";
|
||||
remote_page_count: number;
|
||||
local_page_count: number;
|
||||
ready_page_count: number;
|
||||
failed_page_count: number;
|
||||
vector_count: number;
|
||||
last_started_at?: string | null;
|
||||
last_completed_at?: string | null;
|
||||
last_success_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PublicLlmWikiKnowledgeBaseList {
|
||||
knowledge_bases: PublicLlmWikiKnowledgeBase[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface LlmWikiDocument {
|
||||
id: string;
|
||||
type?: string;
|
||||
@@ -742,6 +794,13 @@ export async function getPublicLlmWikiKnowledgeBase(
|
||||
);
|
||||
}
|
||||
|
||||
export async function listPublicLlmWikiKnowledgeBases(): Promise<PublicLlmWikiKnowledgeBaseList> {
|
||||
return readJson(
|
||||
await apiFetch(`${PUBLIC_API()}/knowledge-bases`),
|
||||
"读取公开知识库列表",
|
||||
);
|
||||
}
|
||||
|
||||
export async function listPublicLlmWikiWikiPages(
|
||||
knowledgeBaseId: string,
|
||||
page: number,
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/skill-knowledge`;
|
||||
|
||||
export type SkillKnowledgeTargetType = "weknora" | "assistant";
|
||||
export type SkillKnowledgeTargetMode =
|
||||
| "wiki"
|
||||
| "document"
|
||||
| "faq"
|
||||
| "conversation_archive"
|
||||
| "readonly_sidecar"
|
||||
| "assistant";
|
||||
|
||||
export interface SkillKnowledgeTarget {
|
||||
target_type: SkillKnowledgeTargetType;
|
||||
target_mode: SkillKnowledgeTargetMode;
|
||||
target_id: string;
|
||||
target_name: string;
|
||||
remote_business_key?: string | null;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
export interface SkillKnowledgeBinding {
|
||||
id: string;
|
||||
skill_name: string;
|
||||
target_type: SkillKnowledgeTargetType;
|
||||
target_mode: SkillKnowledgeTargetMode;
|
||||
target_id: string;
|
||||
target_name: string;
|
||||
remote_business_key?: string | null;
|
||||
remote_refs?: Array<{
|
||||
provider: string;
|
||||
remote_kind: string;
|
||||
remote_id: string;
|
||||
slug?: string;
|
||||
}>;
|
||||
status: string;
|
||||
active: boolean;
|
||||
auto_sync_enabled: boolean;
|
||||
synced_digest?: string | null;
|
||||
last_synced_at?: string | null;
|
||||
last_error_message?: string | null;
|
||||
}
|
||||
|
||||
export interface SkillKnowledgeSyncItem {
|
||||
id: string;
|
||||
job_id: string;
|
||||
binding_id: string;
|
||||
skill_name: string;
|
||||
target_type: SkillKnowledgeTargetType;
|
||||
target_mode: SkillKnowledgeTargetMode;
|
||||
target_id: string;
|
||||
status: string;
|
||||
phase: string;
|
||||
progress: number;
|
||||
attempts: number;
|
||||
last_error_message?: string | null;
|
||||
}
|
||||
|
||||
export interface SkillKnowledgeJob {
|
||||
id: string;
|
||||
status: string;
|
||||
total_count: number;
|
||||
success_count: number;
|
||||
partial_failed_count: number;
|
||||
failed_count: number;
|
||||
review_mode: "required" | "auto_high_confidence" | "off";
|
||||
created_at: string;
|
||||
completed_at?: string | null;
|
||||
items?: SkillKnowledgeSyncItem[];
|
||||
}
|
||||
|
||||
export interface SkillKnowledgeReview {
|
||||
id: string;
|
||||
job_id: string;
|
||||
item_id: string;
|
||||
binding_id: string;
|
||||
object_type: "entity" | "relation" | "wiki" | "alignment";
|
||||
object_key: string;
|
||||
original: Record<string, unknown>;
|
||||
edited?: Record<string, unknown> | null;
|
||||
status: string;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
export interface SkillKnowledgeAutoSyncSettings {
|
||||
watch_enabled: boolean;
|
||||
schedule_enabled: boolean;
|
||||
schedule_cron: string;
|
||||
debounce_seconds: number;
|
||||
write_remote_enabled: boolean;
|
||||
review_mode: "required" | "auto_high_confidence" | "off";
|
||||
}
|
||||
|
||||
async function readJson<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
const detail =
|
||||
data && typeof data === "object" && "detail" in data
|
||||
? JSON.stringify((data as { detail: unknown }).detail)
|
||||
: `请求失败 (${response.status})`;
|
||||
throw new Error(detail.replace(/^"|"$/g, ""));
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function listSkillKnowledgeBindings(
|
||||
skillNames?: string[],
|
||||
): Promise<SkillKnowledgeBinding[]> {
|
||||
const params = new URLSearchParams();
|
||||
for (const name of skillNames ?? []) params.append("skill_names", name);
|
||||
const response = await apiFetch(`${BASE()}/bindings?${params.toString()}`);
|
||||
return (await readJson<{ bindings: SkillKnowledgeBinding[] }>(response)).bindings;
|
||||
}
|
||||
|
||||
export async function createSkillKnowledgeJob(input: {
|
||||
skill_names: string[];
|
||||
targets: SkillKnowledgeTarget[];
|
||||
force?: boolean;
|
||||
review_mode?: "required" | "auto_high_confidence" | "off";
|
||||
}): Promise<SkillKnowledgeJob> {
|
||||
return readJson(
|
||||
await apiFetch(`${BASE()}/sync-jobs`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function listSkillKnowledgeJobs(): Promise<SkillKnowledgeJob[]> {
|
||||
const response = await apiFetch(`${BASE()}/sync-jobs`);
|
||||
return (await readJson<{ jobs: SkillKnowledgeJob[] }>(response)).jobs;
|
||||
}
|
||||
|
||||
export async function getSkillKnowledgeJob(id: string): Promise<SkillKnowledgeJob> {
|
||||
return readJson(await apiFetch(`${BASE()}/sync-jobs/${encodeURIComponent(id)}`));
|
||||
}
|
||||
|
||||
export async function listSkillKnowledgeReviews(): Promise<SkillKnowledgeReview[]> {
|
||||
const response = await apiFetch(`${BASE()}/reviews?status=pending`);
|
||||
return (await readJson<{ reviews: SkillKnowledgeReview[] }>(response)).reviews;
|
||||
}
|
||||
|
||||
export async function reviewSkillKnowledge(input: {
|
||||
review_ids: string[];
|
||||
action: "approve" | "reject";
|
||||
edits?: Record<string, Record<string, unknown>>;
|
||||
reason?: string;
|
||||
}) {
|
||||
return readJson<{ updated: number; resumed_item_ids: string[] }>(
|
||||
await apiFetch(`${BASE()}/reviews/actions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function retrySkillKnowledgeItem(itemId: string) {
|
||||
return readJson<SkillKnowledgeSyncItem>(
|
||||
await apiFetch(`${BASE()}/sync-items/${encodeURIComponent(itemId)}/retry`, {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function detachSkillKnowledgeBinding(
|
||||
bindingId: string,
|
||||
deleteContributions: boolean,
|
||||
) {
|
||||
return readJson<SkillKnowledgeBinding>(
|
||||
await apiFetch(`${BASE()}/bindings/${encodeURIComponent(bindingId)}/detach`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ delete_contributions: deleteContributions }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function resyncSkillKnowledgeBinding(bindingId: string) {
|
||||
return readJson<SkillKnowledgeJob>(
|
||||
await apiFetch(`${BASE()}/bindings/${encodeURIComponent(bindingId)}/resync`, {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function setSkillKnowledgeBindingAutoSync(
|
||||
bindingId: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return readJson<SkillKnowledgeBinding>(
|
||||
await apiFetch(
|
||||
`${BASE()}/bindings/${encodeURIComponent(bindingId)}/${enabled ? "enable" : "disable"}-auto-sync`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSkillKnowledgeAutoSyncSettings() {
|
||||
return readJson<SkillKnowledgeAutoSyncSettings>(
|
||||
await apiFetch(`${BASE()}/auto-sync-settings`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateSkillKnowledgeAutoSyncSettings(
|
||||
input: Partial<SkillKnowledgeAutoSyncSettings>,
|
||||
) {
|
||||
return readJson<SkillKnowledgeAutoSyncSettings>(
|
||||
await apiFetch(`${BASE()}/auto-sync-settings`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const KEY = "skill-knowledge";
|
||||
|
||||
export function useSkillKnowledgeBindings(skillNames?: string[]) {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "bindings", ...(skillNames ?? [])],
|
||||
queryFn: () => listSkillKnowledgeBindings(skillNames),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSkillKnowledgeJobs() {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "jobs"],
|
||||
queryFn: listSkillKnowledgeJobs,
|
||||
refetchInterval: (query) =>
|
||||
(query.state.data ?? []).some((job) => ["queued", "running"].includes(job.status))
|
||||
? 1500
|
||||
: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSkillKnowledgeReviews() {
|
||||
return useQuery({ queryKey: [KEY, "reviews"], queryFn: listSkillKnowledgeReviews });
|
||||
}
|
||||
|
||||
export function useSkillKnowledgeAutoSyncSettings() {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "auto-sync-settings"],
|
||||
queryFn: getSkillKnowledgeAutoSyncSettings,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSkillKnowledgeActions() {
|
||||
const client = useQueryClient();
|
||||
const invalidate = () => client.invalidateQueries({ queryKey: [KEY] });
|
||||
return {
|
||||
createJob: useMutation({ mutationFn: createSkillKnowledgeJob, onSuccess: invalidate }),
|
||||
review: useMutation({ mutationFn: reviewSkillKnowledge, onSuccess: invalidate }),
|
||||
retry: useMutation({ mutationFn: retrySkillKnowledgeItem, onSuccess: invalidate }),
|
||||
resync: useMutation({ mutationFn: resyncSkillKnowledgeBinding, onSuccess: invalidate }),
|
||||
setAutoSync: useMutation({
|
||||
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
|
||||
setSkillKnowledgeBindingAutoSync(id, enabled),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
updateAutoSyncSettings: useMutation({
|
||||
mutationFn: updateSkillKnowledgeAutoSyncSettings,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
detach: useMutation({
|
||||
mutationFn: ({ id, deleteContributions }: { id: string; deleteContributions: boolean }) =>
|
||||
detachSkillKnowledgeBinding(id, deleteContributions),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
};
|
||||
}
|
||||
+193
-11
@@ -1,19 +1,16 @@
|
||||
import { AlertCircle, Edit3, FileText, Loader2, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AlertCircle, Download, Edit3, FileText, Loader2, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Navigate, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MarkdownCodeEditor } from "@/components/wiki/MarkdownCodeEditor";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { buildWeKnoraWikiPagePath } from "@/core/llmwiki/source-links";
|
||||
import {
|
||||
createLlmWikiWeKnoraEmbedSession,
|
||||
@@ -23,8 +20,20 @@ import {
|
||||
useLlmWikiWikiPage,
|
||||
useLlmWikiRuntime,
|
||||
} from "@/strategy-components/api/llmwiki";
|
||||
import {
|
||||
createLlmWikiExportPackage,
|
||||
listLlmWikiExportPackages,
|
||||
type LlmWikiExportPackageJob,
|
||||
} from "@/strategy-components/api/assistant-knowledge";
|
||||
|
||||
const LIST_PATH = "/page/workspace/knowledge";
|
||||
const RUNNING_EXPORT_STATUSES = new Set(["queued", "running"]);
|
||||
const EXPORT_STATUS_LABELS: Record<string, string> = {
|
||||
queued: "排队中",
|
||||
running: "整理中",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
};
|
||||
|
||||
type DeerFlowThemePayload = {
|
||||
type: "deerflow-weknora-theme";
|
||||
@@ -92,6 +101,25 @@ function buildFrameUrl(framePath: string) {
|
||||
return new URL(`${backendBase || backendOrigin}/${framePath.replace(/^\/+/, "")}`);
|
||||
}
|
||||
|
||||
function formatBytes(value: number | undefined | null) {
|
||||
const bytes = Number(value || 0);
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "—";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let current = bytes;
|
||||
let unitIndex = 0;
|
||||
while (current >= 1024 && unitIndex < units.length - 1) {
|
||||
current /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${current >= 10 || unitIndex === 0 ? current.toFixed(0) : current.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatTime(value: string | undefined | null) {
|
||||
if (!value) return "—";
|
||||
const time = new Date(value);
|
||||
return Number.isNaN(time.getTime()) ? "—" : time.toLocaleString();
|
||||
}
|
||||
|
||||
export function WeKnoraKnowledgeDetailPage() {
|
||||
const {
|
||||
knowledge_base_id: strategyKnowledgeBaseId,
|
||||
@@ -104,15 +132,40 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
const [frameSrc, setFrameSrc] = useState("");
|
||||
const [frameVersion, setFrameVersion] = useState(0);
|
||||
const [frameError, setFrameError] = useState<string | null>(null);
|
||||
const [exportPackageLoading, setExportPackageLoading] = useState(false);
|
||||
const [exportJobsLoading, setExportJobsLoading] = useState(false);
|
||||
const [exportJobs, setExportJobs] = useState<LlmWikiExportPackageJob[]>([]);
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const details = useLlmWikiKnowledgeBase(knowledgeBaseId || null, !!knowledgeBaseId);
|
||||
|
||||
const refreshExportJobs = useCallback(
|
||||
async (silent = false) => {
|
||||
if (!knowledgeBaseId || !isAdmin) return;
|
||||
if (!silent) setExportJobsLoading(true);
|
||||
try {
|
||||
setExportJobs(await listLlmWikiExportPackages(knowledgeBaseId));
|
||||
} catch (error) {
|
||||
if (!silent) toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
if (!silent) setExportJobsLoading(false);
|
||||
}
|
||||
},
|
||||
[isAdmin, knowledgeBaseId],
|
||||
);
|
||||
|
||||
const detailsReady = !!details.data;
|
||||
const requestedTab = searchParams.get("tab");
|
||||
const detailsWikiEnabled =
|
||||
details.data?.type === "wiki" ||
|
||||
details.data?.wiki_index_enabled === true ||
|
||||
details.data?.indexing_strategy?.wiki_enabled === true ||
|
||||
details.data?.capabilities?.wiki_enabled === true ||
|
||||
(details.data?.wiki_config && Object.keys(details.data.wiki_config).length > 0);
|
||||
const preferredTab =
|
||||
requestedTab === "documents" || requestedTab === "wiki" || requestedTab === "graph"
|
||||
? requestedTab
|
||||
: details.data?.indexing_strategy?.wiki_enabled
|
||||
: detailsWikiEnabled
|
||||
? "wiki"
|
||||
: "documents";
|
||||
const sourceChunkId = searchParams.get("sourceChunkId") || searchParams.get("chunkId") || "";
|
||||
@@ -179,6 +232,22 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
};
|
||||
}, [frameSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin || !knowledgeBaseId || runtime.data?.provider !== "weknora") {
|
||||
setExportJobs([]);
|
||||
return;
|
||||
}
|
||||
void refreshExportJobs();
|
||||
}, [isAdmin, knowledgeBaseId, refreshExportJobs, runtime.data?.provider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin || !knowledgeBaseId || !exportJobs.some((job) => RUNNING_EXPORT_STATUSES.has(job.status))) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setInterval(() => void refreshExportJobs(true), 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [exportJobs, isAdmin, knowledgeBaseId, refreshExportJobs]);
|
||||
|
||||
if (nativeWikiPageUrl) {
|
||||
return <Navigate to={nativeWikiPageUrl} replace />;
|
||||
}
|
||||
@@ -232,8 +301,25 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
|
||||
const canWrite = details.data.can_write && !details.data.is_conversation_deposit;
|
||||
const canEditCurrentWikiArticle =
|
||||
canWrite && !!details.data.indexing_strategy?.wiki_enabled && !!wikiSlug;
|
||||
const canManageWiki = canWrite && !!details.data.indexing_strategy?.wiki_enabled;
|
||||
canWrite && !!detailsWikiEnabled && !!wikiSlug;
|
||||
const canManageWiki = canWrite && !!detailsWikiEnabled;
|
||||
|
||||
const createVectorizedPackage = () => {
|
||||
if (exportPackageLoading) return;
|
||||
setExportPackageLoading(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const job = await createLlmWikiExportPackage(knowledgeBaseId);
|
||||
setExportJobs((current) => [job, ...current.filter((item) => item.id !== job.id)]);
|
||||
toast.success("已创建向量化数据包整理记录,完成后可在下方列表下载");
|
||||
void refreshExportJobs(true);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setExportPackageLoading(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
@@ -255,6 +341,17 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={exportPackageLoading}
|
||||
onClick={createVectorizedPackage}
|
||||
>
|
||||
{exportPackageLoading ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Download className="mr-2 size-4" />}
|
||||
生成向量化数据包
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -266,6 +363,13 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{canManageWiki && isAdmin && (
|
||||
<ExportPackageJobsPanel
|
||||
jobs={exportJobs}
|
||||
loading={exportJobsLoading}
|
||||
onRefresh={() => void refreshExportJobs()}
|
||||
/>
|
||||
)}
|
||||
{frameSrc && (
|
||||
<iframe
|
||||
key={`${frameSrc}:${frameVersion}`}
|
||||
@@ -293,6 +397,84 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ExportPackageJobsPanel({
|
||||
jobs,
|
||||
loading,
|
||||
onRefresh,
|
||||
}: {
|
||||
jobs: LlmWikiExportPackageJob[];
|
||||
loading: boolean;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="shrink-0 border-b bg-muted/20 px-4 py-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">向量化数据包记录</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
每次点击都会生成一条后台整理记录;文件较大时,请等待状态变为“已完成”后再下载。
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={onRefresh} disabled={loading}>
|
||||
{loading ? <Loader2 className="mr-2 size-4 animate-spin" /> : <RefreshCw className="mr-2 size-4" />}
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-44 space-y-2 overflow-y-auto pr-1">
|
||||
{jobs.map((job) => {
|
||||
const running = RUNNING_EXPORT_STATUSES.has(job.status);
|
||||
const completed = job.status === "completed";
|
||||
const canDownload = completed && !!job.package_download_url && job.file_exists !== false;
|
||||
const counts = job.counts ?? {};
|
||||
return (
|
||||
<div key={job.id} className="flex items-center gap-3 rounded-md border bg-card px-3 py-2 text-xs">
|
||||
<Badge
|
||||
variant={job.status === "failed" ? "destructive" : completed ? "default" : "secondary"}
|
||||
className="shrink-0"
|
||||
>
|
||||
{running && <Loader2 className="mr-1 inline size-3 animate-spin" />}
|
||||
{EXPORT_STATUS_LABELS[job.status] || job.status}
|
||||
</Badge>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="font-medium">任务 {job.id.slice(0, 8)}</span>
|
||||
<span className="text-muted-foreground">创建:{formatTime(job.created_at)}</span>
|
||||
<span className="text-muted-foreground">大小:{formatBytes(job.file_size_bytes)}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-muted-foreground">
|
||||
<span>Wiki {counts.wiki_page ?? counts.wiki_pages ?? 0}</span>
|
||||
<span>文档 {counts.document ?? counts.documents ?? 0}</span>
|
||||
<span>片段 {counts.chunk ?? counts.chunks ?? 0}</span>
|
||||
<span>向量 {counts.vector ?? counts.vectors ?? 0}</span>
|
||||
<span>阶段:{job.phase}</span>
|
||||
{job.error && <span className="text-destructive">{job.error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{canDownload ? (
|
||||
<Button asChild size="sm" variant="outline" className="shrink-0">
|
||||
<a href={job.package_download_url} target="_blank" rel="noreferrer">
|
||||
<Download className="mr-2 size-4" />
|
||||
下载
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" className="shrink-0" disabled>
|
||||
{completed && job.file_exists === false ? "文件不存在" : "等待完成"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!jobs.length && (
|
||||
<div className="rounded-md border border-dashed bg-card px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
暂无导出记录。点击上方“生成向量化数据包”后,任务会显示在这里。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CurrentWikiArticleActions({
|
||||
knowledgeBaseId,
|
||||
slug,
|
||||
|
||||
+58
-6
@@ -54,6 +54,7 @@ import {
|
||||
import { WikiProtectedImage } from "./WikiProtectedImage";
|
||||
|
||||
const LIST_PATH = "/page/workspace/knowledge";
|
||||
const GENERATED_PUBLIC_INDEX_SLUG = "__index__";
|
||||
|
||||
function cleanText(value?: string | null) {
|
||||
return (value || "").replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
||||
@@ -144,13 +145,40 @@ export function WeKnoraWikiPageView({
|
||||
!!noteId && publicEmbed,
|
||||
);
|
||||
const pages = publicEmbed ? publicPages : privatePages;
|
||||
const pagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]);
|
||||
const indexPage = useMemo(
|
||||
() => (publicEmbed ? pagesList.find(isWikiIndexPage) : undefined),
|
||||
[pagesList, publicEmbed],
|
||||
const remotePagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]);
|
||||
const explicitIndexPage = useMemo(
|
||||
() => (publicEmbed ? remotePagesList.find(isWikiIndexPage) : undefined),
|
||||
[publicEmbed, remotePagesList],
|
||||
);
|
||||
const generatedIndexPage = useMemo<LlmWikiWikiPage | undefined>(
|
||||
() => publicEmbed && remotePagesList.length > 0 && !explicitIndexPage
|
||||
? {
|
||||
id: "__generated_public_index__",
|
||||
slug: GENERATED_PUBLIC_INDEX_SLUG,
|
||||
title: "索引",
|
||||
page_type: "index",
|
||||
status: "generated",
|
||||
content: "# 索引\n\n知识库页面目录",
|
||||
summary: "",
|
||||
aliases: [],
|
||||
source_refs: [],
|
||||
in_links: [],
|
||||
out_links: [],
|
||||
version: 0,
|
||||
}
|
||||
: undefined,
|
||||
[explicitIndexPage, publicEmbed, remotePagesList.length],
|
||||
);
|
||||
const indexPage = explicitIndexPage || generatedIndexPage;
|
||||
const pagesList = useMemo(
|
||||
() => generatedIndexPage ? [generatedIndexPage, ...remotePagesList] : remotePagesList,
|
||||
[generatedIndexPage, remotePagesList],
|
||||
);
|
||||
const landingPage = useMemo(() => selectDefaultWikiPage(pagesList), [pagesList]);
|
||||
const wikiSlug = requestedWikiSlug || (publicEmbed ? landingPage?.slug || "" : "");
|
||||
const generatedIndexActive = generatedIndexPage?.slug === wikiSlug;
|
||||
const generatedIndexRequested =
|
||||
publicEmbed && wikiSlug === GENERATED_PUBLIC_INDEX_SLUG && !explicitIndexPage;
|
||||
const privatePage = useLlmWikiWikiPage(
|
||||
noteId || null,
|
||||
wikiSlug || null,
|
||||
@@ -159,7 +187,11 @@ export function WeKnoraWikiPageView({
|
||||
const publicPage = usePublicLlmWikiWikiPage(
|
||||
noteId || null,
|
||||
wikiSlug || null,
|
||||
!!noteId && !!wikiSlug && publicEmbed,
|
||||
!!noteId &&
|
||||
!!wikiSlug &&
|
||||
publicEmbed &&
|
||||
!generatedIndexActive &&
|
||||
!generatedIndexRequested,
|
||||
);
|
||||
const page = publicEmbed ? publicPage : privatePage;
|
||||
const publicWikiIndex = usePublicLlmWikiWikiIndex(
|
||||
@@ -170,7 +202,9 @@ export function WeKnoraWikiPageView({
|
||||
const [directoryQuery, setDirectoryQuery] = useState("");
|
||||
const [collapsedDirectories, setCollapsedDirectories] = useState<Set<string>>(() => new Set());
|
||||
const routedPage = (location.state as { wikiPage?: LlmWikiWikiPage } | null)?.wikiPage;
|
||||
const pageData = page.data || (routedPage?.slug === wikiSlug ? routedPage : undefined);
|
||||
const pageData = generatedIndexActive
|
||||
? generatedIndexPage
|
||||
: page.data || (routedPage?.slug === wikiSlug ? routedPage : undefined);
|
||||
const filteredPages = useMemo(
|
||||
() => filterWikiPagesWithAncestors(pagesList, directoryQuery),
|
||||
[directoryQuery, pagesList],
|
||||
@@ -345,6 +379,24 @@ export function WeKnoraWikiPageView({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (publicEmbed && pages.error) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||
<BookOpenText className="size-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">无法读取 Wiki 目录</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{pages.error instanceof Error ? pages.error.message : "公开 Wiki 服务暂时不可用"}
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => void pages.refetch()}>
|
||||
<RefreshCw className="mr-2 size-4" />重试
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 text-sm text-muted-foreground">
|
||||
{publicEmbed ? "该知识库暂无可展示的 Wiki 页面" : "当前链接缺少 Wiki 页面标识"}
|
||||
|
||||
Reference in new issue
Block a user