Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	frontend-web/src/components/workspace/workspace-nav-chat-list.tsx
#	frontend-web/src/pages/WorkspaceRoutes.tsx
#	offline-backend-20260512/backend/app/gateway/app.py
#	offline-backend-20260512/backend/app/gateway/deps.py
#	offline-backend-20260512/backend/pyproject.toml
This commit is contained in:
董宏凯 committed 2026-09-07 14:46:36 +08:00
commit cf9fe58905
109 files changed
+18863 -982

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
+13 -1
View File
@@ -56,7 +56,7 @@ type PageSidebarProps = {
headerHidden?: boolean;
};
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "encoder" | "about";
const hasActiveChild = (item: MenuItem, pathname: string): boolean =>
menuItemContainsPath(item, pathname);
@@ -270,6 +270,12 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
模型配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => openSettings("encoder")}>
<Cpu className="size-4" />
本地编码器
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/settings/model-concurrency")}
@@ -585,6 +591,12 @@ function LegacyAccordionSidebar({
模型配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => openSettings("encoder")}>
<Cpu className="size-4" />
本地编码器
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
<ShieldCheck className="size-4" />
@@ -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";
@@ -93,7 +94,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);
@@ -133,7 +140,7 @@ const ChatBox: React.FC<{
const { skills } = useSkills();
const canScanArtifactDirectory =
artifactDirectoryModeEnabled &&
(artifactDirectoryModeEnabled || !!requestedArtifact) &&
!disableArtifacts &&
!isMock &&
!!threadId &&
@@ -151,17 +158,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,
],
);
@@ -205,6 +216,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">
@@ -1,6 +1,6 @@
"use client";
import { BellIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
import { BellIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
@@ -14,16 +14,19 @@ import { AboutSettingsPage } from "@/components/workspace/settings/about-setting
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
type SettingsSection =
| "account"
| "appearance"
| "memory"
| "tools"
| "about";
| "memory"
| "tools"
| "encoder"
| "about";
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
defaultSection?: SettingsSection;
@@ -31,7 +34,8 @@ type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
export function SettingsDialog(props: SettingsDialogProps) {
const { defaultSection = "appearance", ...dialogProps } = props;
const { t } = useI18n();
const { t } = useI18n();
const isAdmin = getStoredAuth()?.system_role === "admin";
const [activeSection, setActiveSection] =
useState<SettingsSection>(defaultSection);
@@ -63,7 +67,10 @@ export function SettingsDialog(props: SettingsDialogProps) {
// label: t.settings.sections.memory,
// icon: BrainIcon,
// },
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin
? [{ id: "encoder", label: "本地编码器", icon: CpuIcon }]
: []),
{
id: "account",
label: "个人信息",
@@ -75,7 +82,8 @@ export function SettingsDialog(props: SettingsDialogProps) {
t.settings.sections.appearance,
t.settings.sections.memory,
t.settings.sections.tools,
t.settings.sections.about,
t.settings.sections.about,
isAdmin,
],
);
return (
@@ -125,7 +133,8 @@ export function SettingsDialog(props: SettingsDialogProps) {
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
)}
{activeSection === "memory" && <MemorySettingsPage />}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
{activeSection === "about" && <AboutSettingsPage />}
</div>
</ScrollArea>
@@ -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";
@@ -101,9 +102,16 @@ import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section";
import { SkillStateSheet } from "./skill-state-sheet";
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
const { t } = useI18n();
return (
export function SkillSettingsPage({
onClose,
embedded = false,
}: {
onClose?: () => void;
embedded?: boolean;
} = {}) {
const { t } = useI18n();
if (embedded) return <SkillSettingsList onClose={onClose} />;
return (
<SettingsSection
title={t.settings.skills.title}
description={t.settings.skills.description}
@@ -601,6 +609,7 @@ export function SkillRow({
isAdmin,
layout = "row",
onPublisherClick,
knowledgeBindings = [],
}: {
skill: Skill;
currentUserId: string | null;
@@ -610,6 +619,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 +869,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,280 @@
import { CheckCircle2, 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 { useAllSkillsAdmin, useSkills } 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",
writing_sources: "上传知识资料",
generating_wiki: "分析并生成分类 Wiki",
vectorizing_wiki: "Wiki 向量化",
syncing_assistant: "同步知识梳理总库",
activating: "激活新版本",
completed: "完成",
};
const STATUS_LABELS: Record<string, string> = {
queued: "等待处理",
running: "处理中",
awaiting_review: "等待管理员审核",
completed: "已完成",
partial_failed: "部分失败",
failed: "失败",
canceled: "已取消",
};
export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean }) {
const visibleSkills = useSkills();
const allSkills = useAllSkillsAdmin(adminMode);
const skills = adminMode ? allSkills.skills : visibleSkills.skills;
const isLoading = adminMode ? allSkills.isLoading : visibleSkills.isLoading;
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 runtime = useLlmWikiRuntime();
const knowledgeBases = useLlmWikiKnowledgeBases("all", runtime.data?.provider === "weknora");
const bindings = useSkillKnowledgeBindings(undefined, !adminMode);
const jobs = useSkillKnowledgeJobs(!adminMode);
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: "off",
});
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 gap-4 overflow-auto p-4 lg:grid-cols-[minmax(320px,0.9fr)_minmax(420px,1.1fr)] lg:overflow-hidden lg: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 leading-5 text-muted-foreground">选择技能和一个目标知识库后,系统会在后台提取 Markdown 等知识资料,忽略 Python 等代码文件,自动生成 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">第一步:选择技能(已选 {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>
{(bindings.data ?? []).some((binding) => binding.skill_name === skill.name) && (
<Badge variant="secondary" className="shrink-0 text-[10px]">
{(bindings.data ?? []).some((binding) => binding.skill_name === skill.name && `${binding.target_type}:${binding.target_id}` === selectedTargetKey)
? "已在目标库"
: `已归纳 ${(bindings.data ?? []).filter((binding) => binding.skill_name === skill.name).length} 库`}
</Badge>
)}
</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">第二步:选择一个目标知识库</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="rounded-lg border bg-muted/30 p-3 text-xs leading-5 text-muted-foreground">
第三步:提交后可离开本页。归纳、Wiki 生成、向量化及同步到“知识梳理”总库均在后台完成。
</div>
<Button className="w-full" disabled={actions.createJob.isPending || !selectedSkills.length || !selectedTargetKey} onClick={() => void submit()}>
{actions.createJob.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
开始归纳 {selectedSkills.length ? `${selectedSkills.length} 个技能` : ""}
</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 countedDone = job.success_count + job.partial_failed_count + job.failed_count;
const itemDone = (job.items ?? []).filter((item) =>
["completed", "partial_failed", "failed", "canceled"].includes(item.status),
).length;
const done = Math.max(countedDone, itemDone);
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"}>{STATUS_LABELS[job.status] || 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] || STATUS_LABELS[item.status] || 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 flex items-center gap-2 text-muted-foreground"><CheckCircle2 className="size-4 text-emerald-600" />已归纳关联:{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-40 truncate font-medium">{binding.skill_name}</span><span className="max-w-48 truncate text-muted-foreground">已归纳到 {binding.target_name}</span><Badge variant="outline">{STATUS_LABELS[binding.status] || binding.status}</Badge><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>{adminMode && <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, FileText, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, 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: "智能体",
@@ -83,7 +83,7 @@ export function WorkspaceNavMenu() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
"appearance" | "memory" | "tools" | "about"
"appearance" | "memory" | "tools" | "encoder" | "about"
>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
@@ -189,6 +189,17 @@ export function WorkspaceNavMenu() {
<Settings2Icon />
{t.common.settings}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() => {
setSettingsDefaultSection("encoder");
setSettingsOpen(true);
}}
>
<CpuIcon />
本地编码器
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
+24 -5
View File
@@ -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,
+113
View File
@@ -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;
+9 -3
View File
@@ -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)。
// 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话
+2
View File
@@ -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;
+20 -1
View File
@@ -8,6 +8,7 @@ import {
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";
@@ -21,14 +22,28 @@ import { SkillRow } from "@/components/workspace/settings/skill-settings-page";
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 : "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 +71,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">
@@ -126,6 +142,9 @@ export default function AdminSkillsPage() {
skill={skill}
currentUserId={currentUserId}
isAdmin
knowledgeBindings={(knowledgeBindings.data ?? []).filter(
(binding) => binding.skill_name === skill.name,
)}
/>
))}
</>
+71 -12
View File
@@ -128,6 +128,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 {
@@ -344,11 +364,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);
@@ -359,6 +385,10 @@ export default function AgentChatPage({
setSelectedKnowledgeBaseIds(ids);
setKnowledgeSelectionReady(true);
}, []);
const changeAssistantKnowledgeSelection = useCallback((ids: string[]) => {
setSelectedAssistantKnowledgeBaseIds(ids);
setKnowledgeSelectionReady(true);
}, []);
useEffect(() => {
activeThreadIdRef.current = threadId;
@@ -368,17 +398,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);
@@ -393,18 +429,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);
}
})
@@ -427,10 +474,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)。
@@ -446,7 +496,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 ?? {}),
},
@@ -1043,9 +1096,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="知识空间"
@@ -1130,7 +1187,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,631 @@
import {
ArrowLeft,
BookOpenText,
Database,
Edit3,
Loader2,
Plus,
RefreshCw,
Search,
Trash2,
Upload,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { KnowledgeProductNav } from "@/components/workspace/knowledge-product-nav";
import { getStoredAuth } from "@/core/auth";
import { buildAppHashUrl } from "@/core/llmwiki/source-links";
import {
searchAssistantWiki,
useAssistantImportJobs,
useAssistantKnowledgeActions,
useAssistantKnowledgeBases,
useAssistantKnowledgeSources,
useAssistantWikiPages,
type AssistantKnowledgeBase,
} from "@/strategy-components/api/assistant-knowledge";
import {
WikiLibraryReader,
type LibraryWikiPage,
} from "@/strategy-components/components/resource-management/llmwiki/WikiLibraryReader";
import { WikiPackageTransfers } from "@/strategy-components/components/resource-management/llmwiki/WikiPackageTransfers";
export default function AssistantKnowledgePage() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const [params, setParams] = useSearchParams();
const baseId = params.get("base_id");
const slug = params.get("wikiSlug");
const bases = useAssistantKnowledgeBases();
const actions = useAssistantKnowledgeActions();
const pages = useAssistantWikiPages("", baseId);
const sources = useAssistantKnowledgeSources(baseId);
const jobs = useAssistantImportJobs(baseId);
const client = useQueryClient();
const [tab, setTab] = useState("wiki");
const [createOpen, setCreateOpen] = useState(false);
const [editingBase, setEditingBase] =
useState<AssistantKnowledgeBase | null>(null);
const [name, setName] = useState("");
const [query, setQuery] = useState("");
const [searching, setSearching] = useState(false);
const [searchResult, setSearchResult] = useState<Awaited<
ReturnType<typeof searchAssistantWiki>
> | null>(null);
const upload = useRef<HTMLInputElement>(null);
const base = bases.data?.find((b) => b.id === baseId);
const libraryPages = useMemo<LibraryWikiPage[]>(
() =>
(pages.data || []).map((p) => ({
...p,
content: p.content_markdown,
status: "published",
aliases: p.aliases || [],
source_refs: p.source_refs || [],
in_links: p.in_links || [],
out_links: p.out_links || [],
version: 1,
})),
[pages.data],
);
const active =
libraryPages.find((p) => p.slug === slug) ||
libraryPages.find((p) => p.page_type === "index") ||
libraryPages[0] ||
null;
const navigatePage = (nextSlug: string) => {
setParams({ base_id: baseId!, wikiSlug: nextSlug });
setTab("wiki");
};
const finishedJobs = (jobs.data || [])
.filter((j) => j.status === "completed")
.map((j) => j.id)
.join(",");
useEffect(() => {
void client.invalidateQueries({
queryKey: ["assistant-knowledge", "pages"],
});
void client.invalidateQueries({
queryKey: ["assistant-knowledge", "bases"],
});
}, [finishedJobs, client]);
useEffect(() => {
setSearchResult(null);
setQuery("");
}, [baseId]);
const create = async () => {
if (!name.trim()) return;
try {
const b = await actions.createBase.mutateAsync({ name: name.trim() });
setCreateOpen(false);
setName("");
setParams({ base_id: b.id });
} catch (e) {
toast.error((e as Error).message);
}
};
const search = async () => {
if (!query.trim() || !baseId) return;
setSearching(true);
try {
setSearchResult(await searchAssistantWiki(query, baseId));
} catch (e) {
toast.error((e as Error).message);
} finally {
setSearching(false);
}
};
const refresh = () =>
void client.invalidateQueries({ queryKey: ["assistant-knowledge"] });
const removeBase = async (target: AssistantKnowledgeBase) => {
if (target.is_global) {
toast.error("知识梳理总库承担全库沉淀,不能删除");
return;
}
if (
!window.confirm(
`确定删除助手知识库“${target.name}”吗?库内 Wiki、向量、实体、关系和导入记录都会删除。`,
)
)
return;
try {
await actions.deleteBase.mutateAsync(target.id);
if (baseId === target.id) setParams({});
toast.success("助手知识库已删除");
} catch (error) {
toast.error((error as Error).message);
}
};
return (
<div className="flex size-full min-h-0 flex-col bg-background">
<KnowledgeProductNav />
{!baseId ? (
<main className="flex-1 overflow-auto p-6 lg:p-8">
<header className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">助手知识库</h1>
<p className="mt-2 text-sm text-muted-foreground">
浏览分类
Wiki、关联知识与来源。普通知识库向量化完成后自动沉淀到知识梳理。
</p>
</div>
{isAdmin && (
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 size-4" />
新建知识库
</Button>
)}
</header>
{bases.isLoading && <Loader2 className="animate-spin" />}
{bases.error && (
<p className="text-destructive">{bases.error.message}</p>
)}
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{bases.data?.map((b) => (
<div
key={b.id}
className="group overflow-hidden rounded-2xl border bg-card text-left shadow-sm transition hover:border-primary/40 hover:shadow-md"
>
<button
type="button"
onClick={() => setParams({ base_id: b.id })}
className="w-full p-6 text-left"
>
<div className="mb-5 flex items-center justify-between">
<div className="rounded-xl bg-emerald-100 p-3 text-emerald-700 dark:bg-emerald-950">
<Database className="size-6" />
</div>
{b.is_global && (
<Badge variant="secondary">全库知识梳理</Badge>
)}
</div>
<h2 className="text-lg font-semibold group-hover:text-primary">
{b.name}
</h2>
<p className="mt-2 line-clamp-2 min-h-10 text-sm text-muted-foreground">
{b.description ||
(b.is_global
? "汇聚普通知识库的分析成果,保留分类、标签和知识关联。"
: "独立的 Wiki 知识空间,支持导入、导出与向量检索。")}
</p>
<div className="mt-6 flex gap-5 text-xs text-muted-foreground">
<span>{b.page_count || 0} Wiki</span>
<span>{b.source_count || 0} 来源</span>
<span>{b.relation_count || 0} 关系</span>
</div>
</button>
{isAdmin && (
<div className="flex items-center gap-2 border-t bg-muted/20 px-6 py-3">
<Button
size="sm"
variant="outline"
disabled={actions.updateBase.isPending}
onClick={() => setEditingBase(b)}
>
<Edit3 className="mr-2 size-4" />
编辑
</Button>
{!b.is_global && (
<Button
size="sm"
variant="ghost"
className="ml-auto text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={actions.deleteBase.isPending}
onClick={() => void removeBase(b)}
>
<Trash2 className="mr-2 size-4" />
删除
</Button>
)}
{b.is_global && (
<span className="ml-auto text-xs text-muted-foreground">
系统大库不可删除
</span>
)}
</div>
)}
</div>
))}
</div>
{!bases.isLoading && !bases.data?.length && isAdmin && (
<Button
className="mt-5"
onClick={() =>
actions.initialize.mutate("知识梳理总库", {
onError: (e) => toast.error(e.message),
})
}
>
初始化知识梳理总库
</Button>
)}
</main>
) : (
<>
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b px-5 py-4">
<Button
variant="ghost"
size="icon"
aria-label="返回助手知识库"
onClick={() => setParams({})}
>
<ArrowLeft className="size-4" />
</Button>
<div className="rounded-xl bg-emerald-100 p-2.5 text-emerald-700 dark:bg-emerald-950">
<BookOpenText className="size-5" />
</div>
<div className="min-w-0">
<h1 className="text-lg font-semibold">
{base?.name || "助手知识库"}
</h1>
<p className="mt-1 text-xs text-muted-foreground">
{base?.is_global
? "全库知识梳理 · 自动同步分析成果"
: "Wiki 知识空间"}
</p>
</div>
<div className="ml-auto flex gap-2">
<Button variant="outline" size="sm" onClick={refresh}>
<RefreshCw className="mr-2 size-4" />
刷新
</Button>
{isAdmin && (
<Button
variant="outline"
size="sm"
disabled={!base || actions.updateBase.isPending}
onClick={() => base && setEditingBase(base)}
>
<Edit3 className="mr-2 size-4" />
编辑
</Button>
)}
{isAdmin && base && !base.is_global && (
<Button
variant="outline"
size="sm"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={actions.deleteBase.isPending}
onClick={() => void removeBase(base)}
>
<Trash2 className="mr-2 size-4" />
删除
</Button>
)}
{isAdmin && (
<Button
variant="outline"
size="sm"
disabled={actions.uploadWeKnoraPackage.isPending}
onClick={() => upload.current?.click()}
>
<Upload className="mr-2 size-4" />
导入数据包
</Button>
)}
</div>
<input
ref={upload}
type="file"
accept=".jsonl,.ndjson"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
try {
await actions.uploadWeKnoraPackage.mutateAsync({
file,
assistantBaseId: baseId,
});
toast.success("文件已上传,后台正在导入");
} catch (err) {
toast.error((err as Error).message);
}
}}
/>
</header>
<Tabs
value={tab}
onValueChange={setTab}
className="flex min-h-0 flex-1 flex-col gap-0"
>
<div className="shrink-0 border-b px-5 py-2">
<TabsList>
<TabsTrigger value="wiki">
Wiki({base?.page_count || 0})
</TabsTrigger>
<TabsTrigger value="search">知识检索</TabsTrigger>
<TabsTrigger value="sources">来源与同步</TabsTrigger>
{isAdmin && (
<TabsTrigger value="transfer">导入 / 导出记录</TabsTrigger>
)}
</TabsList>
</div>
<TabsContent value="wiki" className="mt-0 flex min-h-0 flex-1">
<WikiLibraryReader
key={baseId}
pages={libraryPages}
activePage={active}
onNavigate={navigatePage}
pageHref={(s) =>
buildAppHashUrl(
`/page/workspace/knowledge/assistant?${new URLSearchParams({ base_id: baseId, wikiSlug: s })}`,
)
}
mappingId={
active?.origins?.find((o) => o.mapping_id)?.mapping_id
}
initiallyCollapseTree
/>
</TabsContent>
<TabsContent value="search" className="overflow-auto p-6">
<div className="mb-5 flex max-w-3xl gap-2">
<Input
placeholder="用自然语言检索知识,例如:如何处理设备过热?"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void search();
}}
/>
<Button disabled={searching} onClick={() => void search()}>
{searching ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Search className="size-4" />
)}
检索
</Button>
</div>
{searchResult && (
<>
<Badge variant="outline">
{searchResult.retrieval_mode === "vector"
? "知识正文 / 片段 · 向量检索"
: searchResult.retrieval_mode === "empty"
? "没有匹配结果"
: "知识正文 / 片段 · 关键词检索(暂无匹配模型的向量)"}
</Badge>
<div className="mt-4 space-y-3">
{searchResult.results.map((p) => (
<button
key={p.id}
type="button"
onClick={() => {
if (p.page_type !== "chunk") navigatePage(p.slug);
}}
className={`block w-full max-w-4xl rounded-xl border p-5 text-left ${p.page_type === "chunk" ? "cursor-default" : "hover:bg-muted"}`}
>
<div className="font-medium">
{p.title}
{p.page_type === "chunk" && (
<Badge variant="secondary" className="ml-2">
知识片段
</Badge>
)}
<span className="float-right text-xs text-muted-foreground">
{p.score?.toFixed(4)}
</span>
</div>
<p className="mt-2 line-clamp-3 text-sm text-muted-foreground">
{p.matched_section?.content ||
p.matched_content ||
p.content_markdown}
</p>
</button>
))}
</div>
</>
)}
</TabsContent>
<TabsContent value="sources" className="overflow-auto p-6">
<div className="grid gap-4 lg:grid-cols-2">
{sources.data?.map((s) => (
<div className="rounded-xl border p-5" key={s.id}>
<h3 className="font-semibold">{s.source_name}</h3>
<p className="mt-2 text-xs text-muted-foreground">
上次同步:{new Date(s.updated_at).toLocaleString()}
</p>
{isAdmin && (
<Button
className="mt-4"
size="sm"
variant="outline"
disabled={actions.reimportSource.isPending}
onClick={() =>
actions.reimportSource.mutate(s.id, {
onSuccess: () => toast.success("已提交同步任务"),
onError: (e) => toast.error(e.message),
})
}
>
<RefreshCw className="mr-2 size-4" />
同步最新数据
</Button>
)}
</div>
))}
</div>
</TabsContent>
{isAdmin && (
<TabsContent
value="transfer"
className="space-y-5 overflow-auto p-6"
>
<WikiPackageTransfers id={baseId} assistant />
<h2 className="text-base font-semibold">导入与同步记录</h2>
{jobs.data?.map((j) => (
<div key={j.id} className="rounded-xl border p-4">
<div className="flex items-center gap-3">
<Badge variant="outline">
{{
completed: "已完成",
failed: "失败",
running: "处理中",
queued: "排队中",
}[j.status] || j.status}
</Badge>
<span className="text-xs text-muted-foreground">
{new Date(j.created_at).toLocaleString()}
</span>
</div>
<p className="mt-3 text-sm">
{j.counts?.wiki_pages || 0} Wiki ·{" "}
{j.counts?.vectors || 0} 向量 · {j.counts?.entities || 0}{" "}
实体 · {j.counts?.relations || 0} 关系
</p>
{j.last_error_message && (
<p className="mt-2 text-sm text-destructive">
{j.last_error_message}
</p>
)}
</div>
))}
</TabsContent>
)}
</Tabs>
</>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>新建助手知识库</DialogTitle>
</DialogHeader>
<Input
placeholder="知识库名称"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<DialogFooter>
<Button
disabled={!name.trim() || actions.createBase.isPending}
onClick={() => void create()}
>
创建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{editingBase && (
<EditAssistantBaseDialog
key={editingBase.id}
base={editingBase}
pending={actions.updateBase.isPending}
onClose={() => setEditingBase(null)}
onSave={async (input) => {
try {
await actions.updateBase.mutateAsync({
id: editingBase.id,
...input,
});
setEditingBase(null);
toast.success("助手知识库信息已保存");
} catch (error) {
toast.error((error as Error).message);
}
}}
/>
)}
</div>
);
}
function EditAssistantBaseDialog({
base,
pending,
onClose,
onSave,
}: {
base: AssistantKnowledgeBase;
pending: boolean;
onClose: () => void;
onSave: (input: { name: string; description: string }) => Promise<void>;
}) {
const [name, setName] = useState(base.name);
const [description, setDescription] = useState(base.description || "");
return (
<Dialog
open
onOpenChange={(open) => {
if (!open && !pending) onClose();
}}
>
<DialogContent>
<form
onSubmit={(event) => {
event.preventDefault();
if (!pending && name.trim()) {
void onSave({
name: name.trim(),
description: description.trim(),
});
}
}}
>
<DialogHeader>
<DialogTitle>编辑助手知识库</DialogTitle>
<DialogDescription>
修改知识库名称和描述。知识内容、向量和关联关系不会受到影响。
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="assistant-base-name">
名称
</label>
<Input
id="assistant-base-name"
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={255}
disabled={pending}
required
/>
</div>
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor="assistant-base-description"
>
描述
</label>
<Textarea
id="assistant-base-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
maxLength={2048}
rows={4}
disabled={pending}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" disabled={pending} onClick={onClose}>
取消
</Button>
<Button type="submit" disabled={pending || !name.trim()}>
{pending && <Loader2 className="mr-2 size-4 animate-spin" />}
保存
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+44 -9
View File
@@ -141,6 +141,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);
@@ -149,18 +150,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,
],
);
@@ -199,6 +208,7 @@ export default function ChatPage() {
setHasStartedConversation(false);
setPromptPrefixOn(false);
setSelectedKnowledgeBaseIds([]);
setSelectedAssistantKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
setKnowledgeSelectionLoading(false);
knowledgeSelectionThread.current = "__new__";
@@ -224,18 +234,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);
}
})
@@ -258,6 +279,7 @@ export default function ChatPage() {
if (!known.some((a) => a.id === selectedAgentId)) {
setSelectedAgentId(null);
setSelectedKnowledgeBaseIds([]);
setSelectedAssistantKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
@@ -268,7 +290,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) => {
@@ -442,7 +467,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,
}
: {}),
},
});
@@ -453,7 +481,10 @@ export default function ChatPage() {
threadId: created.thread_id,
text,
...(knowledgeSelectionReady
? { knowledgeBaseIds: selectedKnowledgeBaseIds }
? {
knowledgeBaseIds: selectedKnowledgeBaseIds,
assistantKnowledgeBaseIds: selectedAssistantKnowledgeBaseIds,
}
: {}),
}),
);
@@ -515,6 +546,7 @@ export default function ChatPage() {
hideAdminPrompt,
preferencesLoading,
selectedAgentId,
selectedAssistantKnowledgeBaseIds,
selectedKnowledgeBaseIds,
knowledgeSelectionReady,
agentHandoffPending,
@@ -618,6 +650,7 @@ export default function ChatPage() {
const handleAgentChange = useCallback((agentId: string | null) => {
setSelectedAgentId(agentId);
setSelectedKnowledgeBaseIds([]);
setSelectedAssistantKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
saveLastAgentId(agentId);
}, []);
@@ -900,7 +933,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>;
}
+49 -3
View File
@@ -1,9 +1,55 @@
import { BookOpenCheck, Sparkles } from "lucide-react";
import { useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
import { SkillKnowledgePanel } from "@/components/workspace/skills/SkillKnowledgePanel";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export default function SkillsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const tab = useMemo(
() => (searchParams.get("tab") === "knowledge" ? "knowledge" : "skills"),
[searchParams],
);
const changeTab = (value: string) => {
const next = new URLSearchParams(searchParams);
if (value === "knowledge") next.set("tab", "knowledge");
else next.delete("tab");
setSearchParams(next, { replace: true });
};
return (
<div className="flex size-full min-h-0 flex-col overflow-auto p-6">
<SkillSettingsPage />
</div>
<Tabs
value={tab}
onValueChange={changeTab}
className="flex size-full min-h-0 flex-col gap-0 overflow-hidden"
>
<header className="flex flex-wrap items-center justify-between gap-4 border-b bg-background px-6 py-4">
<div>
<h1 className="text-xl font-semibold">技能</h1>
<p className="mt-1 text-sm text-muted-foreground">
浏览和维护技能,也可以把多个技能的知识一次归纳到一个 Wiki 知识库。
</p>
</div>
<TabsList>
<TabsTrigger value="skills">
<Sparkles className="mr-2 size-4" />
技能列表
</TabsTrigger>
<TabsTrigger value="knowledge">
<BookOpenCheck className="mr-2 size-4" />
归纳到知识库
</TabsTrigger>
</TabsList>
</header>
<TabsContent value="skills" className="mt-0 min-h-0 flex-1 overflow-auto p-6">
<SkillSettingsPage embedded />
</TabsContent>
<TabsContent value="knowledge" className="mt-0 min-h-0 flex-1 overflow-hidden">
<SkillKnowledgePanel />
</TabsContent>
</Tabs>
);
}
+192 -122
View File
@@ -1,87 +1,96 @@
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
import {
Navigate,
Route,
Routes,
useLocation,
useParams,
} from "react-router-dom";
import { getStoredAuth } from '@/core/auth';
import { getAPIClient } from '@/core/api';
import { isSentimentAgentVisible } from '@/core/sentiment-agent/config';
import { getStoredAuth } from "@/core/auth";
import { getAPIClient } from "@/core/api";
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
import {
resolveChatsIframeEntryRedirect,
resolveStandardChatFallback,
} from '@/core/page-layout/chats-iframe-route';
import { isEmbeddedHost } from '@/core/embed/frame-context';
} from "@/core/page-layout/chats-iframe-route";
import { isEmbeddedHost } from "@/core/embed/frame-context";
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import {
CompactShortcutButtons,
LayoutModeSwitcher,
ThemeSwitcher,
} from '@/components/workspace/appearance-controls';
import { WorkspaceSidebar } from '@/components/workspace/workspace-sidebar';
import { UserIdentityBadge } from '@/components/workspace/user-identity-badge';
import { CommandPalette } from '@/components/workspace/command-palette';
import { Toaster } from 'sonner';
import { SubtasksProvider } from '@/core/tasks/context';
import { ArtifactsProvider } from '@/components/workspace/artifacts';
import { DocumentRewritePresentationProvider } from '@/core/document-rewrite/context';
} from "@/components/workspace/appearance-controls";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
import { CommandPalette } from "@/components/workspace/command-palette";
import { Toaster } from "sonner";
import { SubtasksProvider } from "@/core/tasks/context";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { DocumentRewritePresentationProvider } from "@/core/document-rewrite/context";
import { RouteBaseProvider } from '@/core/route-base';
import { agentRefOfThread } from '@/core/threads/utils';
import { RouteBaseProvider } from "@/core/route-base";
import { agentRefOfThread } from "@/core/threads/utils";
import ChatsPage from './ChatsPage';
import ChatPage from './ChatPage';
import IframeChatPage from './IframeChatPage';
import AgentsPage from './AgentsPage';
import AgentChatPage from './AgentChatPage';
import SentimentAgentChatPage from './SentimentAgentChatPage';
import NewAgentPage from './NewAgentPage';
import AdminAgentsPage from './AdminAgentsPage';
import AdminScheduledTasksPage from './AdminScheduledTasksPage';
import AdminSkillsPage from './AdminSkillsPage';
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
import ScheduledTasksPage from './ScheduledTasksPage';
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
import WeKnoraWikiManagementPage from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage';
import WeKnoraWikiPageView from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView';
import MarkdownDocxEditorPage from './MarkdownDocxEditorPage';
import LlmMetricsPage from './LlmMetricsPage';
import CheckpointMigrationPage from './CheckpointMigrationPage';
import MemoryPage from './MemoryPage';
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
import ModelConcurrencySettingsPage from './ModelConcurrencySettingsPage';
import ModelManagementPage from './ModelManagementPage';
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
import SkillCompressionSettingsPage from './SkillCompressionSettingsPage';
import SkillEvolutionPage from './SkillEvolutionPage';
import SkillsPage from './SkillsPage';
import UsersPage from './UsersPage';
import AdminUserActivityPage from './AdminUserActivityPage';
import AdminMemoryPage from './AdminMemoryPage';
import AdminUserHubPage from './AdminUserHubPage';
import TagsPage from './TagsPage';
import PositionsPage from './PositionsPage';
import PositionRolesPage from './PositionRolesPage';
import PluginManagementPage from './PluginManagementPage';
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import DeepResearchPage from './DeepResearchPage';
import ReportStructureCreatePage from './deep-research/ReportStructureCreatePage';
import EnterpriseResearchPage from './EnterpriseResearchPage';
import StudioNotebooksPage from './StudioNotebooksPage';
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
import PositionRoundtablePage from '@/position-roundtable/pages/PositionRoundtablePage';
import EightBfRoundtablePage from '@/eightbf-roundtable/pages/EightBfRoundtablePage';
import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage';
import BusinessChainListPage from '@/roundtable-planning/pages/BusinessChainListPage';
import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage';
import '@/roundtable-planning/styles/roundtable-planning.css';
import ChatsPage from "./ChatsPage";
import ChatPage from "./ChatPage";
import ArtifactLibraryPage from "./ArtifactLibraryPage";
import IframeChatPage from "./IframeChatPage";
import AgentsPage from "./AgentsPage";
import AgentChatPage from "./AgentChatPage";
import SentimentAgentChatPage from "./SentimentAgentChatPage";
import NewAgentPage from "./NewAgentPage";
import AdminAgentsPage from "./AdminAgentsPage";
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
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 WeKnoraPackageTransfersPage from "@/strategy-components/components/resource-management/llmwiki/WeKnoraPackageTransfersPage";
import MarkdownDocxEditorPage from "./MarkdownDocxEditorPage";
import LlmMetricsPage from "./LlmMetricsPage";
import CheckpointMigrationPage from "./CheckpointMigrationPage";
import MemoryPage from "./MemoryPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
import ModelConcurrencySettingsPage from "./ModelConcurrencySettingsPage";
import ModelManagementPage from "./ModelManagementPage";
import PublishSquaresSettingsPage from "./PublishSquaresSettingsPage";
import SkillCompressionSettingsPage from "./SkillCompressionSettingsPage";
import SkillEvolutionPage from "./SkillEvolutionPage";
import SkillsPage from "./SkillsPage";
import UsersPage from "./UsersPage";
import AdminUserActivityPage from "./AdminUserActivityPage";
import AdminMemoryPage from "./AdminMemoryPage";
import AdminUserHubPage from "./AdminUserHubPage";
import TagsPage from "./TagsPage";
import PositionsPage from "./PositionsPage";
import PositionRolesPage from "./PositionRolesPage";
import PluginManagementPage from "./PluginManagementPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
import DeepResearchPage from "./DeepResearchPage";
import ReportStructureCreatePage from "./deep-research/ReportStructureCreatePage";
import EnterpriseResearchPage from "./EnterpriseResearchPage";
import StudioNotebooksPage from "./StudioNotebooksPage";
import StudioNotebookDetailPage from "./StudioNotebookDetailPage";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
import EightBfRoundtablePage from "@/eightbf-roundtable/pages/EightBfRoundtablePage";
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage";
import DispatchChainPreviewPage from "@/roundtable-planning/pages/DispatchChainPreviewPage";
import "@/roundtable-planning/styles/roundtable-planning.css";
import ReportCollaborationPage, {
ReportCollaborationSessionPage,
} from '@/report-collaboration/pages/ReportCollaborationPage';
import '@/report-collaboration/styles/report-collaboration.css';
import { cn } from '@/lib/utils';
} from "@/report-collaboration/pages/ReportCollaborationPage";
import "@/report-collaboration/styles/report-collaboration.css";
import { cn } from "@/lib/utils";
function NotebookDetailPageWithKey() {
const { notebookId } = useParams<{ notebookId: string }>();
@@ -119,11 +128,12 @@ function ReportCollaborationSessionRoute({
function shouldShowCompactTopActions(pathname: string): boolean {
return (
pathname === '/page/workspace/agents' ||
pathname === '/page/workspace/skills' ||
pathname === '/page/workspace/memory' ||
pathname === '/page/workspace/knowledge' ||
pathname.startsWith('/page/workspace/knowledge/')
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/")
);
}
@@ -181,47 +191,52 @@ export function WorkspaceLayout({
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
const pathname = useLocation().pathname;
const isGeneralQa = pathname.startsWith('/page/workspace/chats');
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
const showCompactTopActions =
headerHidden && !compactActionsHidden && shouldShowCompactTopActions(pathname);
headerHidden &&
!compactActionsHidden &&
shouldShowCompactTopActions(pathname);
const pageSidebarWidth = pageSidebarHidden
? '0rem'
? "0rem"
: pageSidebarDark
? pageSidebarCollapsed
? '0rem'
: '118px'
? "0rem"
: "118px"
: pageSidebarCollapsed
? '4rem'
: '16rem';
? "4rem"
: "16rem";
return (
<SidebarProvider
className={[
'relative h-full w-full overflow-x-hidden',
'[&_[data-slot=sidebar-container]]:absolute',
'[&_[data-slot=sidebar-container]]:left-0',
'[&_[data-slot=sidebar-container]]:top-0',
].join(' ')}
"relative h-full w-full overflow-x-hidden",
"[&_[data-slot=sidebar-container]]:absolute",
"[&_[data-slot=sidebar-container]]:left-0",
"[&_[data-slot=sidebar-container]]:top-0",
].join(" ")}
style={
{
'--page-sidebar-width': pageSidebarWidth,
'--header-height': headerHidden ? '0rem' : '4rem',
"--page-sidebar-width": pageSidebarWidth,
"--header-height": headerHidden ? "0rem" : "4rem",
} as CSSProperties
}
// Agent and general-chat routes share this layout component. Keep their
// sidebar state separate: otherwise React preserves the collapsed agent
// state when a history item switches to `/chats/:thread_id`, while the
// new page's header button is meant to control GeneralQaSidebar.
key={`${String(compactWorkspaceSidebar)}:${isGeneralQa ? 'general' : 'workspace'}`}
key={`${String(compactWorkspaceSidebar)}:${isGeneralQa ? "general" : "workspace"}`}
data-page-sidebar-collapsed={pageSidebarCollapsed}
defaultOpen={
forceWorkspaceSidebarCollapsed
? false
: (defaultHistorySidebarOpen ?? (isGeneralQa || !compactWorkspaceSidebar))
: (defaultHistorySidebarOpen ??
(isGeneralQa || !compactWorkspaceSidebar))
}
open={forceWorkspaceSidebarCollapsed ? false : undefined}
onOpenChange={forceWorkspaceSidebarCollapsed ? () => undefined : undefined}
onOpenChange={
forceWorkspaceSidebarCollapsed ? () => undefined : undefined
}
disableToggle={forceWorkspaceSidebarCollapsed}
>
{!workspaceSidebarHidden && (
@@ -231,7 +246,7 @@ export function WorkspaceLayout({
/>
)}
<SidebarInset
className={cn('min-w-0', insetOverflowHidden && 'overflow-hidden')}
className={cn("min-w-0", insetOverflowHidden && "overflow-hidden")}
>
{showCompactTopActions ? (
<>
@@ -268,9 +283,14 @@ export function ChatRuntime({ children }: { children: ReactNode }) {
*/
function NewChatContent() {
const location = useLocation();
const agentId = new URLSearchParams(location.search).get('agent');
const agentId = new URLSearchParams(location.search).get("agent");
if (agentId) {
return <AgentChatPage key={`${agentId}:${location.key}`} embeddedAgentId={agentId} />;
return (
<AgentChatPage
key={`${agentId}:${location.key}`}
embeddedAgentId={agentId}
/>
);
}
return <ChatPage />;
}
@@ -417,8 +437,8 @@ function AgentAwareChatThreadContent() {
};
// Task-bound threads need their task host to preserve the purpose gate,
// action partitioning, and other task-specific controls.
const isTaskThread = typeof record.metadata?.taskId === 'string';
setAgentId(isTaskThread ? null : agentRefOfThread(record) ?? null);
const isTaskThread = typeof record.metadata?.taskId === "string";
setAgentId(isTaskThread ? null : (agentRefOfThread(record) ?? null));
})
.catch(() => {
if (!cancelled) setAgentId(null);
@@ -545,13 +565,34 @@ 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} />}
element={
<IframeChatEntryRoute
layoutProps={layoutProps}
hasThreadParam={false}
/>
}
/>
<Route
path="chats_iframe/:thread_id"
element={<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam />}
element={
<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam />
}
/>
<Route
path="agents"
@@ -585,7 +626,10 @@ export default function WorkspaceRoutes({
path="agents/sentiment-analysis/chats"
element={
isSentimentAgentVisible() ? (
<Navigate to="/page/workspace/agents/sentiment-analysis/chats/new" replace />
<Navigate
to="/page/workspace/agents/sentiment-analysis/chats/new"
replace
/>
) : (
<Navigate to="/page/workspace/agents" replace />
)
@@ -615,7 +659,9 @@ export default function WorkspaceRoutes({
<AgentChatPage
showAgentList
compactAppearanceActions={
headerHidden && !compactActionsHidden ? <CompactAppearanceActions /> : undefined
headerHidden && !compactActionsHidden ? (
<CompactAppearanceActions />
) : undefined
}
/>
</ChatRuntime>
@@ -714,7 +760,9 @@ export default function WorkspaceRoutes({
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<RoundtablePlanningPage compactChrome={headerHidden && !compactActionsHidden} />
<RoundtablePlanningPage
compactChrome={headerHidden && !compactActionsHidden}
/>
</ChatRuntime>
</WorkspaceLayout>
}
@@ -724,7 +772,9 @@ export default function WorkspaceRoutes({
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<EightBfRoundtablePage compactChrome={headerHidden && !compactActionsHidden} />
<EightBfRoundtablePage
compactChrome={headerHidden && !compactActionsHidden}
/>
</ChatRuntime>
</WorkspaceLayout>
}
@@ -734,7 +784,9 @@ export default function WorkspaceRoutes({
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<PositionRoundtablePage compactChrome={headerHidden && !compactActionsHidden} />
<PositionRoundtablePage
compactChrome={headerHidden && !compactActionsHidden}
/>
</ChatRuntime>
</WorkspaceLayout>
}
@@ -794,6 +846,14 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="knowledge/assistant"
element={
<WorkspaceLayout {...layoutProps}>
<AssistantKnowledgePage />
</WorkspaceLayout>
}
/>
<Route
path="knowledge/:noteId/wiki/view"
element={
@@ -810,6 +870,14 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="knowledge/:noteId/packages"
element={
<WorkspaceLayout {...layoutProps}>
<WeKnoraPackageTransfersPage />
</WorkspaceLayout>
}
/>
<Route
path="knowledge/markdown-editor"
element={
@@ -834,7 +902,9 @@ export default function WorkspaceRoutes({
path="scheduled/tasks"
element={
<WorkspaceLayout {...layoutProps}>
<ScheduledTasksPage compactChrome={headerHidden && !compactActionsHidden} />
<ScheduledTasksPage
compactChrome={headerHidden && !compactActionsHidden}
/>
</WorkspaceLayout>
}
/>
@@ -850,7 +920,7 @@ export default function WorkspaceRoutes({
<Route
path="admin"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps} pageSidebarHidden>
<AdminUserHubPage />
</WorkspaceLayout>
@@ -875,7 +945,7 @@ export default function WorkspaceRoutes({
<Route
path="admin/scheduled-tasks"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<AdminScheduledTasksPage />
</WorkspaceLayout>
@@ -887,7 +957,7 @@ export default function WorkspaceRoutes({
<Route
path="admin/agents"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<AdminAgentsPage />
</WorkspaceLayout>
@@ -899,7 +969,7 @@ export default function WorkspaceRoutes({
<Route
path="admin/skills"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<AdminSkillsPage />
</WorkspaceLayout>
@@ -919,7 +989,7 @@ export default function WorkspaceRoutes({
<Route
path="admin/plugins"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PluginManagementPage />
</WorkspaceLayout>
@@ -931,7 +1001,7 @@ export default function WorkspaceRoutes({
<Route
path="admin/positions"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PositionsPage />
</WorkspaceLayout>
@@ -943,7 +1013,7 @@ export default function WorkspaceRoutes({
<Route
path="admin/position-roles"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PositionRolesPage />
</WorkspaceLayout>
@@ -955,7 +1025,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/recommended-questions"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<RecommendedQuestionsPage />
</WorkspaceLayout>
@@ -967,7 +1037,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/prompt-prefix"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PromptPrefixSettingsPage />
</WorkspaceLayout>
@@ -979,7 +1049,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/private-square"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PrivateSquareSettingsPage />
</WorkspaceLayout>
@@ -991,7 +1061,7 @@ export default function WorkspaceRoutes({
<Route
path="admin/models"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<ModelManagementPage />
</WorkspaceLayout>
@@ -1003,7 +1073,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/model-concurrency"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<ModelConcurrencySettingsPage />
</WorkspaceLayout>
@@ -1015,7 +1085,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/publish-squares"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PublishSquaresSettingsPage />
</WorkspaceLayout>
@@ -1027,7 +1097,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/skill-compression"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<SkillCompressionSettingsPage />
</WorkspaceLayout>
@@ -1039,7 +1109,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/llm-metrics"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<LlmMetricsPage />
</WorkspaceLayout>
@@ -1051,7 +1121,7 @@ export default function WorkspaceRoutes({
<Route
path="settings/checkpoint-migration"
element={
getStoredAuth()?.system_role === 'admin' ? (
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<CheckpointMigrationPage />
</WorkspaceLayout>
@@ -0,0 +1,514 @@
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;
aliases?: string[];
tags?: string[];
category_path?: string[];
parent_slug?: string;
wiki_path?: string;
in_links?: string[];
out_links?: string[];
source_refs?: string[];
origins?: { mapping_id?: string; source_name: string; slug: string }[];
matched_content?: string;
matched_section?: {
id?: string;
section_index: number;
heading?: string | null;
content: string;
score: number;
source_chunk_id?: string | null;
embedding_model?: string | null;
embedding_fingerprint?: string | null;
};
}
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;
}
export async function updateAssistantKnowledgeBase(input: {
id: string;
name: string;
description?: string;
}) {
const data = await readJson<{ base: AssistantKnowledgeBase }>(
await apiFetch(`${BASE()}/bases/${encodeURIComponent(input.id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: input.name,
description: input.description || "",
}),
}),
);
return data.base;
}
export async function deleteAssistantKnowledgeBase(id: string) {
const response = await apiFetch(
`${BASE()}/bases/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
if (!response.ok) await readJson(response);
}
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);
params.set("limit", "500");
if (q.trim()) params.set("q", q.trim());
const pages: AssistantWikiPage[] = [];
let offset: number | null = 0;
do {
params.set("offset", String(offset));
const batch: { pages: AssistantWikiPage[]; next_offset?: number | null } =
await readJson(await apiFetch(`${BASE()}/wiki-pages?${params}`));
pages.push(...batch.pages);
offset = batch.next_offset ?? null;
} while (offset !== null);
return pages;
}
export async function searchAssistantWiki(q: string, baseId: string) {
return readJson<{
results: (AssistantWikiPage & { score: number; retrieval_mode: string })[];
retrieval_mode: string;
}>(
await apiFetch(
`${BASE()}/search?${new URLSearchParams({ q, base_id: baseId })}`,
),
);
}
export async function listWikiTransfers(id: string, assistant: boolean) {
const url = assistant
? `${BASE()}/bases/${encodeURIComponent(id)}/export-package`
: `${getBackendBaseURL()}/api/llmwiki/knowledge-bases/${encodeURIComponent(id)}/import-package`;
return (
await readJson<{ jobs: LlmWikiExportPackageJob[] }>(await apiFetch(url))
).jobs.map(normalizeExportPackageJob);
}
export async function createAssistantExportPackage(id: string) {
return readJson<LlmWikiExportPackageJob>(
await apiFetch(`${BASE()}/bases/${encodeURIComponent(id)}/export-package`, {
method: "POST",
}),
);
}
export async function importOrdinaryWikiPackage(id: string, file: File) {
const body = new FormData();
body.set("file", file);
return readJson<LlmWikiExportPackageJob>(
await apiFetch(
`${getBackendBaseURL()}/api/llmwiki/knowledge-bases/${encodeURIComponent(id)}/import-package`,
{ method: "POST", body },
),
);
}
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(enabled = true) {
return useQuery({
queryKey: [KEY, "bases"],
queryFn: listAssistantKnowledgeBases,
enabled,
});
}
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, enabled = true) {
return useQuery({
queryKey: [KEY, "sources", baseId ?? "global"],
queryFn: () => listAssistantKnowledgeSources(baseId),
enabled,
});
}
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,
}),
updateBase: useMutation({
mutationFn: updateAssistantKnowledgeBase,
onSuccess: invalidate,
}),
deleteBase: useMutation({
mutationFn: deleteAssistantKnowledgeBase,
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,
}),
};
}
@@ -1,6 +1,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getCsrfToken } from "@/core/api/csrf";
import { getAuthorizationHeaderValue } from "@/core/auth";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { getBackendBaseURL } from "@/core/config";
@@ -29,7 +31,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 +39,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 +52,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;
@@ -115,6 +169,7 @@ export interface LlmWikiGraph {
}
export interface LlmWikiWikiPage {
page_metadata?: { tags?: string[]; [key: string]: unknown };
id: string;
slug: string;
title: string;
@@ -167,7 +222,14 @@ export interface LlmWikiWikiIndex {
export interface LlmWikiWikiIndexStatus {
knowledge_base_id: string;
state: "not_synced" | "idle" | "running" | "partial" | "failed" | "rebuilding" | "rebuild_required";
state:
| "not_synced"
| "idle"
| "running"
| "partial"
| "failed"
| "rebuilding"
| "rebuild_required";
local_index_enabled: boolean;
wiki_index_enabled: boolean;
fully_vectorized: boolean;
@@ -203,6 +265,25 @@ export interface LlmWikiWikiIndexStatus {
}>;
}
export interface LocalWikiEncoderStatus {
provider: string;
model: string;
dimensions?: number | null;
state: "idle" | "starting" | "ready" | "failed";
running: boolean;
last_error?: string | null;
started_at?: string | null;
ready_at?: string | null;
offline_only: boolean;
model_path_configured: boolean;
model_path_exists: boolean;
model_files_ready: boolean;
process_isolation: boolean;
model_path: string;
uploaded_files?: number;
uploaded_bytes?: number;
}
export interface LlmWikiSourceContext {
knowledge_base_name?: string;
document_title?: string;
@@ -292,14 +373,16 @@ function userFacingKnowledgeMessage(value: string): string {
async function readJson<T>(response: Response, context: string): Promise<T> {
if (!response.ok) {
const payload = await response.json().catch(() => null as unknown);
const rawDetail = payload && typeof payload === "object" && "detail" in payload
? (payload as { detail: unknown }).detail
: null;
const detail = rawDetail && typeof rawDetail === "object" && "message" in rawDetail
? String((rawDetail as { message: unknown }).message)
: rawDetail
? String(rawDetail)
: `${context}失败(${response.status})`;
const rawDetail =
payload && typeof payload === "object" && "detail" in payload
? (payload as { detail: unknown }).detail
: null;
const detail =
rawDetail && typeof rawDetail === "object" && "message" in rawDetail
? String((rawDetail as { message: unknown }).message)
: rawDetail
? String(rawDetail)
: `${context}失败(${response.status})`;
throw new Error(userFacingKnowledgeMessage(detail));
}
if (response.status === 204) return undefined as T;
@@ -307,10 +390,7 @@ async function readJson<T>(response: Response, context: string): Promise<T> {
}
export async function getLlmWikiRuntime(): Promise<LlmWikiRuntime> {
return readJson(
await apiFetch(`${API()}/runtime`),
"读取 LLMWiki 运行模式",
);
return readJson(await apiFetch(`${API()}/runtime`), "读取 LLMWiki 运行模式");
}
export async function listLlmWikiKnowledgeBases(
@@ -368,10 +448,10 @@ export async function getLlmWikiSourceContext(
knowledge_base_id: knowledgeBaseId,
chunk_id: chunkId,
});
const payload = await readJson<{ success: boolean; data: LlmWikiSourceContext }>(
await apiFetch(`${API()}/sources/context?${params}`),
"加载参考文献原文",
);
const payload = await readJson<{
success: boolean;
data: LlmWikiSourceContext;
}>(await apiFetch(`${API()}/sources/context?${params}`), "加载参考文献原文");
return payload.data;
}
@@ -484,9 +564,13 @@ export async function getLlmWikiProtectedFileUrl(
return `${PUBLIC_API()}/files/${encodeURIComponent(access.token)}`;
}
export async function getLlmWikiGraph(knowledgeBaseId: string): Promise<LlmWikiGraph> {
export async function getLlmWikiGraph(
knowledgeBaseId: string,
): Promise<LlmWikiGraph> {
return readJson(
await apiFetch(`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/graph?limit=200`),
await apiFetch(
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/graph?limit=200`,
),
"读取知识图谱",
);
}
@@ -497,7 +581,10 @@ export async function listLlmWikiWikiPages(
pageSize: number,
query = "",
): Promise<LlmWikiWikiPageList> {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
const params = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
});
if (query.trim()) params.set("query", query.trim());
return readJson(
await apiFetch(
@@ -543,6 +630,80 @@ export async function vectorizeLlmWikiKnowledgeBase(
);
}
export async function getLocalWikiEncoderStatus(): Promise<LocalWikiEncoderStatus> {
return readJson(
await apiFetch(`${API()}/local-encoder/status`),
"读取本地编码器状态",
);
}
export async function startLocalWikiEncoder(): Promise<LocalWikiEncoderStatus> {
return readJson(
await apiFetch(`${API()}/local-encoder/start`, { method: "POST" }),
"启动本地编码器",
);
}
export async function configureLocalWikiEncoder(
modelPath: string,
): Promise<LocalWikiEncoderStatus> {
return readJson(
await apiFetch(`${API()}/local-encoder/config`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_path: modelPath }),
}),
"保存本地编码器目录",
);
}
export async function uploadLocalWikiEncoder(
files: File[],
onProgress?: (percent: number) => void,
): Promise<LocalWikiEncoderStatus> {
const body = new FormData();
for (const file of files) {
const webkitPath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
const parts = webkitPath.replace(/\\/g, "/").split("/").filter(Boolean);
const relativePath = parts.length > 1 ? parts.slice(1).join("/") : parts[0];
body.append("files", file, file.name);
body.append("paths", relativePath);
}
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("POST", `${API()}/local-encoder/upload`);
request.withCredentials = true;
const authorization = getAuthorizationHeaderValue();
const csrf = getCsrfToken();
if (authorization) request.setRequestHeader("Authorization", authorization);
if (csrf) request.setRequestHeader("X-CSRF-Token", csrf);
request.upload.onprogress = (event) => {
if (event.lengthComputable && event.total > 0) {
onProgress?.(Math.min(99, Math.round((event.loaded / event.total) * 100)));
}
};
request.onerror = () => reject(new Error("上传本地编码器失败:网络连接中断"));
request.onload = () => {
let payload: unknown = null;
try {
payload = JSON.parse(request.responseText || "null");
} catch {
payload = null;
}
if (request.status < 200 || request.status >= 300) {
const detail = payload && typeof payload === "object" && "detail" in payload
? (payload as { detail: unknown }).detail
: `请求失败 (${request.status})`;
reject(new Error(typeof detail === "string" ? detail : JSON.stringify(detail)));
return;
}
onProgress?.(100);
resolve(payload as LocalWikiEncoderStatus);
};
request.send(body);
});
}
export async function exportLlmWikiWikiExcel(
knowledgeBaseId: string,
knowledgeBaseName = "LLMWiki",
@@ -742,13 +903,23 @@ 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,
pageSize: number,
query = "",
): Promise<LlmWikiWikiPageList> {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
const params = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
});
if (query.trim()) params.set("query", query.trim());
return readJson(
await apiFetch(
@@ -827,9 +998,7 @@ export async function resetLlmWikiSettings(): Promise<LlmWikiRuntime> {
);
}
export async function testLlmWikiConnection(
apiBaseUrl: string,
): Promise<void> {
export async function testLlmWikiConnection(apiBaseUrl: string): Promise<void> {
await readJson(
await apiFetch(`${SETTINGS_API()}/test`, {
method: "POST",
@@ -854,10 +1023,7 @@ export function useLlmWikiRuntime() {
});
}
export function useLlmWikiKnowledgeBases(
scope: LlmWikiScope,
enabled = true,
) {
export function useLlmWikiKnowledgeBases(scope: LlmWikiScope, enabled = true) {
const { userId } = useLiveAccount();
return useQuery({
queryKey: knowledgeBasesKey(userId, scope),
@@ -873,7 +1039,13 @@ export function useAgentLlmWikiKnowledgeBases(
) {
const { userId } = useLiveAccount();
return useQuery({
queryKey: ["llmwiki", "agents", agentId, "knowledge-bases", userId ?? "anonymous"],
queryKey: [
"llmwiki",
"agents",
agentId,
"knowledge-bases",
userId ?? "anonymous",
],
queryFn: () => listAgentLlmWikiKnowledgeBases(agentId!),
enabled: enabled && !!agentId,
});
@@ -885,7 +1057,12 @@ export function useLlmWikiKnowledgeBase(
) {
const { userId } = useLiveAccount();
return useQuery({
queryKey: ["llmwiki", "knowledge-base", knowledgeBaseId, userId ?? "anonymous"],
queryKey: [
"llmwiki",
"knowledge-base",
knowledgeBaseId,
userId ?? "anonymous",
],
queryFn: () => getLlmWikiKnowledgeBase(knowledgeBaseId!),
enabled: enabled && !!knowledgeBaseId,
refetchInterval: 8_000,
@@ -912,7 +1089,13 @@ export function useLlmWikiDocument(
) {
const { userId } = useLiveAccount();
return useQuery({
queryKey: ["llmwiki", "document", knowledgeBaseId, documentId, userId ?? "anonymous"],
queryKey: [
"llmwiki",
"document",
knowledgeBaseId,
documentId,
userId ?? "anonymous",
],
queryFn: () => getLlmWikiDocument(knowledgeBaseId!, documentId!),
enabled: enabled && !!knowledgeBaseId && !!documentId,
});
@@ -927,13 +1110,25 @@ export function useLlmWikiDocumentChunks(
) {
const { userId } = useLiveAccount();
return useQuery({
queryKey: ["llmwiki", "document-chunks", knowledgeBaseId, documentId, page, pageSize, userId ?? "anonymous"],
queryFn: () => listLlmWikiDocumentChunks(knowledgeBaseId!, documentId!, page, pageSize),
queryKey: [
"llmwiki",
"document-chunks",
knowledgeBaseId,
documentId,
page,
pageSize,
userId ?? "anonymous",
],
queryFn: () =>
listLlmWikiDocumentChunks(knowledgeBaseId!, documentId!, page, pageSize),
enabled: enabled && !!knowledgeBaseId && !!documentId,
});
}
export function useLlmWikiGraph(knowledgeBaseId: string | null, enabled = true) {
export function useLlmWikiGraph(
knowledgeBaseId: string | null,
enabled = true,
) {
const { userId } = useLiveAccount();
return useQuery({
queryKey: ["llmwiki", "graph", knowledgeBaseId, userId ?? "anonymous"],
@@ -951,8 +1146,17 @@ export function useLlmWikiWikiPages(
) {
const { userId } = useLiveAccount();
return useQuery({
queryKey: ["llmwiki", "wiki-pages", knowledgeBaseId, page, pageSize, query, userId ?? "anonymous"],
queryFn: () => listLlmWikiWikiPages(knowledgeBaseId!, page, pageSize, query),
queryKey: [
"llmwiki",
"wiki-pages",
knowledgeBaseId,
page,
pageSize,
query,
userId ?? "anonymous",
],
queryFn: () =>
listLlmWikiWikiPages(knowledgeBaseId!, page, pageSize, query),
enabled: enabled && !!knowledgeBaseId,
refetchInterval: 10_000,
});
@@ -965,7 +1169,13 @@ export function useLlmWikiWikiPage(
) {
const { userId } = useLiveAccount();
return useQuery({
queryKey: ["llmwiki", "wiki-page", knowledgeBaseId, slug, userId ?? "anonymous"],
queryKey: [
"llmwiki",
"wiki-page",
knowledgeBaseId,
slug,
userId ?? "anonymous",
],
queryFn: () => getLlmWikiWikiPage(knowledgeBaseId!, slug!),
enabled: enabled && !!knowledgeBaseId && !!slug,
});
@@ -991,8 +1201,16 @@ export function usePublicLlmWikiWikiPages(
enabled = true,
) {
return useQuery({
queryKey: ["public-llmwiki", "wiki-pages", knowledgeBaseId, page, pageSize, query],
queryFn: () => listPublicLlmWikiWikiPages(knowledgeBaseId!, page, pageSize, query),
queryKey: [
"public-llmwiki",
"wiki-pages",
knowledgeBaseId,
page,
pageSize,
query,
],
queryFn: () =>
listPublicLlmWikiWikiPages(knowledgeBaseId!, page, pageSize, query),
enabled: enabled && !!knowledgeBaseId,
staleTime: 60_000,
});
@@ -1029,7 +1247,12 @@ export function useLlmWikiWikiIndexStatus(
) {
const { userId } = useLiveAccount();
return useQuery({
queryKey: ["llmwiki", "wiki-index-status", knowledgeBaseId, userId ?? "anonymous"],
queryKey: [
"llmwiki",
"wiki-index-status",
knowledgeBaseId,
userId ?? "anonymous",
],
queryFn: () => getLlmWikiWikiIndexStatus(knowledgeBaseId!),
enabled: enabled && !!knowledgeBaseId,
refetchInterval: (query) => {
@@ -1058,8 +1281,14 @@ export function useLlmWikiActions() {
onSuccess: invalidateKnowledge,
}),
update: useMutation({
mutationFn: ({ id, ...input }: { id: string; name?: string; description?: string }) =>
updateLlmWikiKnowledgeBase(id, input),
mutationFn: ({
id,
...input
}: {
id: string;
name?: string;
description?: string;
}) => updateLlmWikiKnowledgeBase(id, input),
onSuccess: invalidateKnowledge,
}),
remove: useMutation({
@@ -1077,8 +1306,15 @@ export function useLlmWikiActions() {
onSuccess: invalidateKnowledge,
}),
createManual: useMutation({
mutationFn: ({ id, title, content }: { id: string; title: string; content: string }) =>
createManualLlmWikiDocument(id, { title, content }),
mutationFn: ({
id,
title,
content,
}: {
id: string;
title: string;
content: string;
}) => createManualLlmWikiDocument(id, { title, content }),
onSuccess: invalidateKnowledge,
}),
removeDocument: useMutation({
@@ -1092,13 +1328,25 @@ export function useLlmWikiActions() {
onSuccess: invalidateKnowledge,
}),
createWikiPage: useMutation({
mutationFn: ({ id, input }: { id: string; input: LlmWikiWikiPageCreate }) =>
createLlmWikiWikiPage(id, input),
mutationFn: ({
id,
input,
}: {
id: string;
input: LlmWikiWikiPageCreate;
}) => createLlmWikiWikiPage(id, input),
onSuccess: invalidateKnowledge,
}),
updateWikiPage: useMutation({
mutationFn: ({ id, slug, input }: { id: string; slug: string; input: LlmWikiWikiPageUpdate }) =>
updateLlmWikiWikiPage(id, slug, input),
mutationFn: ({
id,
slug,
input,
}: {
id: string;
slug: string;
input: LlmWikiWikiPageUpdate;
}) => updateLlmWikiWikiPage(id, slug, input),
onSuccess: invalidateKnowledge,
}),
removeWikiPage: useMutation({
@@ -1128,8 +1376,14 @@ export function useLlmWikiSettingsActions() {
await queryClient.invalidateQueries({ queryKey: ["llmwiki"] });
};
return {
save: useMutation({ mutationFn: saveLlmWikiSettings, onSuccess: invalidate }),
reset: useMutation({ mutationFn: resetLlmWikiSettings, onSuccess: invalidate }),
save: useMutation({
mutationFn: saveLlmWikiSettings,
onSuccess: invalidate,
}),
reset: useMutation({
mutationFn: resetLlmWikiSettings,
onSuccess: invalidate,
}),
test: useMutation({ mutationFn: testLlmWikiConnection }),
};
}
@@ -0,0 +1,280 @@
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[],
mineOnly = false,
): Promise<SkillKnowledgeBinding[]> {
const params = new URLSearchParams();
for (const name of skillNames ?? []) params.append("skill_names", name);
if (mineOnly) params.set("mine_only", "true");
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(mineOnly = false): Promise<SkillKnowledgeJob[]> {
const params = mineOnly ? "?mine_only=true" : "";
const response = await apiFetch(`${BASE()}/sync-jobs${params}`);
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[], mineOnly = false) {
return useQuery({
queryKey: [KEY, "bindings", mineOnly ? "mine" : "all", ...(skillNames ?? [])],
queryFn: () => listSkillKnowledgeBindings(skillNames, mineOnly),
refetchOnWindowFocus: false,
});
}
export function useSkillKnowledgeJobs(mineOnly = false) {
return useQuery({
queryKey: [KEY, "jobs", mineOnly ? "mine" : "all"],
queryFn: () => listSkillKnowledgeJobs(mineOnly),
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,
}),
};
}
@@ -1,6 +1,19 @@
import { AlertCircle, Edit3, FileText, Loader2, RefreshCw, Trash2 } from "lucide-react";
import {
AlertCircle,
Archive,
Edit3,
FileText,
Loader2,
RefreshCw,
Trash2,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Navigate, useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
Navigate,
useNavigate,
useParams,
useSearchParams,
} from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -14,6 +27,7 @@ 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,
@@ -34,7 +48,10 @@ type DeerFlowThemePayload = {
function cssVar(name: string, fallback = ""): string {
if (typeof window === "undefined") return fallback;
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
return (
getComputedStyle(document.documentElement).getPropertyValue(name).trim() ||
fallback
);
}
function collectThemePayload(): DeerFlowThemePayload {
@@ -46,15 +63,27 @@ function collectThemePayload(): DeerFlowThemePayload {
theme: isDark ? "dark" : "light",
vars: {
primary: cssVar("--primary", isDark ? "#2b81ff" : "#0062d9"),
primaryForeground: cssVar("--button-foreground", cssVar("--primary-foreground", "#ffffff")),
primaryForeground: cssVar(
"--button-foreground",
cssVar("--primary-foreground", "#ffffff"),
),
background: cssVar("--background", isDark ? "#0e172b" : "#ffffff"),
foreground: cssVar("--foreground", isDark ? "#eff2f5" : "#202a3a"),
card: cssVar("--card", isDark ? "#1a2539" : "#ffffff"),
cardForeground: cssVar("--card-foreground", cssVar("--foreground", isDark ? "#eff2f5" : "#202a3a")),
cardForeground: cssVar(
"--card-foreground",
cssVar("--foreground", isDark ? "#eff2f5" : "#202a3a"),
),
muted: cssVar("--muted", isDark ? "#232e40" : "#f3f5f8"),
mutedForeground: cssVar("--muted-foreground", isDark ? "#8e9aa4" : "#7f7f7f"),
mutedForeground: cssVar(
"--muted-foreground",
isDark ? "#8e9aa4" : "#7f7f7f",
),
border: cssVar("--border", isDark ? "rgba(255,255,255,0.12)" : "#dbdbdb"),
popover: cssVar("--popover", cssVar("--card", isDark ? "#1a2539" : "#ffffff")),
popover: cssVar(
"--popover",
cssVar("--card", isDark ? "#1a2539" : "#ffffff"),
),
destructive: cssVar("--destructive", "#d54941"),
},
};
@@ -68,7 +97,8 @@ function postThemeToFrame(frame: HTMLIFrameElement | null) {
try {
const currentUrl = frame.contentWindow.location.href;
if (!currentUrl || currentUrl === "about:blank") return;
if (new URL(currentUrl, window.location.href).origin !== targetOrigin) return;
if (new URL(currentUrl, window.location.href).origin !== targetOrigin)
return;
} catch {
// Reading location throws once the iframe has reached the cross-origin backend.
}
@@ -89,7 +119,9 @@ function buildFrameUrl(framePath: string) {
: framePath;
return new URL(path, backendOrigin);
}
return new URL(`${backendBase || backendOrigin}/${framePath.replace(/^\/+/, "")}`);
return new URL(
`${backendBase || backendOrigin}/${framePath.replace(/^\/+/, "")}`,
);
}
export function WeKnoraKnowledgeDetailPage() {
@@ -97,36 +129,57 @@ export function WeKnoraKnowledgeDetailPage() {
knowledge_base_id: strategyKnowledgeBaseId,
noteId: workspaceKnowledgeBaseId,
} = useParams();
const knowledgeBaseId = strategyKnowledgeBaseId ?? workspaceKnowledgeBaseId ?? "";
const knowledgeBaseId =
strategyKnowledgeBaseId ?? workspaceKnowledgeBaseId ?? "";
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const frameRef = useRef<HTMLIFrameElement>(null);
const [frameSrc, setFrameSrc] = useState("");
const [frameVersion, setFrameVersion] = useState(0);
const [frameError, setFrameError] = useState<string | null>(null);
const isAdmin = getStoredAuth()?.system_role === "admin";
const runtime = useLlmWikiRuntime();
const details = useLlmWikiKnowledgeBase(knowledgeBaseId || null, !!knowledgeBaseId);
const details = useLlmWikiKnowledgeBase(
knowledgeBaseId || null,
!!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 === "documents" ||
requestedTab === "wiki" ||
requestedTab === "graph"
? requestedTab
: details.data?.indexing_strategy?.wiki_enabled
: detailsWikiEnabled
? "wiki"
: "documents";
const sourceChunkId = searchParams.get("sourceChunkId") || searchParams.get("chunkId") || "";
const sourceChunkId =
searchParams.get("sourceChunkId") || searchParams.get("chunkId") || "";
const documentId = searchParams.get("documentId") || "";
const wikiSlug = searchParams.get("wikiSlug") || "";
const nativeWikiPageUrl = knowledgeBaseId && wikiSlug && preferredTab === "wiki"
? buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug)
: "";
const nativeWikiPageUrl =
knowledgeBaseId && wikiSlug && preferredTab === "wiki"
? buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug)
: "";
useEffect(() => {
let cancelled = false;
setFrameSrc("");
setFrameError(null);
if (nativeWikiPageUrl || !knowledgeBaseId || runtime.data?.provider !== "weknora" || !detailsReady) {
if (
nativeWikiPageUrl ||
!knowledgeBaseId ||
runtime.data?.provider !== "weknora" ||
!detailsReady
) {
return () => {
cancelled = true;
};
@@ -136,30 +189,47 @@ export function WeKnoraKnowledgeDetailPage() {
const resolvedWikiSlug =
wikiSlug ||
(sourceChunkId
? (await getLlmWikiSourceContext(knowledgeBaseId, sourceChunkId).catch(
() => null,
))?.wiki_page?.slug
? (
await getLlmWikiSourceContext(
knowledgeBaseId,
sourceChunkId,
).catch(() => null)
)?.wiki_page?.slug
: "");
const session = await createLlmWikiWeKnoraEmbedSession(knowledgeBaseId, preferredTab);
if (cancelled) return;
const framePath = session.frame_path || "";
const nextUrl = buildFrameUrl(framePath);
nextUrl.searchParams.set("theme", collectThemePayload().theme);
if (sourceChunkId) nextUrl.searchParams.set("sourceChunkId", sourceChunkId);
if (documentId) nextUrl.searchParams.set("documentId", documentId);
if (resolvedWikiSlug) nextUrl.searchParams.set("wikiSlug", resolvedWikiSlug);
setFrameSrc(nextUrl.toString());
})()
.catch((error: unknown) => {
if (cancelled) return;
const message = error instanceof Error ? error.message : "创建知识库详情会话失败";
setFrameError(message);
});
const session = await createLlmWikiWeKnoraEmbedSession(
knowledgeBaseId,
preferredTab,
);
if (cancelled) return;
const framePath = session.frame_path || "";
const nextUrl = buildFrameUrl(framePath);
nextUrl.searchParams.set("theme", collectThemePayload().theme);
if (sourceChunkId)
nextUrl.searchParams.set("sourceChunkId", sourceChunkId);
if (documentId) nextUrl.searchParams.set("documentId", documentId);
if (resolvedWikiSlug)
nextUrl.searchParams.set("wikiSlug", resolvedWikiSlug);
setFrameSrc(nextUrl.toString());
})().catch((error: unknown) => {
if (cancelled) return;
const message =
error instanceof Error ? error.message : "创建知识库详情会话失败";
setFrameError(message);
});
return () => {
cancelled = true;
};
}, [detailsReady, documentId, knowledgeBaseId, nativeWikiPageUrl, preferredTab, runtime.data?.provider, sourceChunkId, wikiSlug]);
}, [
detailsReady,
documentId,
knowledgeBaseId,
nativeWikiPageUrl,
preferredTab,
runtime.data?.provider,
sourceChunkId,
wikiSlug,
]);
useEffect(() => {
if (!frameSrc) return;
@@ -192,7 +262,8 @@ export function WeKnoraKnowledgeDetailPage() {
);
}
if (runtime.data?.provider !== "weknora") return <Navigate to={LIST_PATH} replace />;
if (runtime.data?.provider !== "weknora")
return <Navigate to={LIST_PATH} replace />;
if (frameError) {
return (
@@ -201,7 +272,9 @@ export function WeKnoraKnowledgeDetailPage() {
<AlertCircle className="size-6" />
</div>
<div>
<div className="text-base font-semibold text-foreground">无法打开知识库详情</div>
<div className="text-base font-semibold text-foreground">
无法打开知识库详情
</div>
<p className="mt-2 text-sm text-muted-foreground">{frameError}</p>
</div>
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>
@@ -218,7 +291,9 @@ export function WeKnoraKnowledgeDetailPage() {
<AlertCircle className="size-6" />
</div>
<div>
<div className="text-base font-semibold text-foreground">无法打开知识库详情</div>
<div className="text-base font-semibold text-foreground">
无法打开知识库详情
</div>
<p className="mt-2 text-sm text-muted-foreground">
当前账号没有访问权限,或知识服务资源已不存在。
</p>
@@ -230,10 +305,11 @@ export function WeKnoraKnowledgeDetailPage() {
);
}
const canWrite = details.data.can_write && !details.data.is_conversation_deposit;
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;
return (
<div className="flex h-full min-h-0 flex-col bg-background">
@@ -246,19 +322,40 @@ export function WeKnoraKnowledgeDetailPage() {
slug={wikiSlug}
onChanged={() => setFrameVersion((value) => value + 1)}
onDeleted={() => {
navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}?tab=wiki`, {
replace: true,
});
navigate(
`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}?tab=wiki`,
{
replace: true,
},
);
setFrameVersion((value) => value + 1);
}}
/>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{isAdmin && (
<Button
size="sm"
variant="outline"
onClick={() =>
navigate(
`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/packages`,
)
}
>
<Archive className="mr-2 size-4" />
数据包任务
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki`)}
onClick={() =>
navigate(
`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki`,
)
}
>
<FileText className="mr-2 size-4" />
Wiki管理
@@ -342,7 +439,12 @@ function CurrentWikiArticleActions({
const remove = async () => {
if (!slug) return;
if (!window.confirm(`确定删除文章「${detail.data?.title || slug}」吗?删除后不可恢复。`)) return;
if (
!window.confirm(
`确定删除文章「${detail.data?.title || slug}」吗?删除后不可恢复。`,
)
)
return;
try {
await actions.removeWikiPage.mutateAsync({ id: knowledgeBaseId, slug });
toast.success("文章已删除");
@@ -394,14 +496,18 @@ function CurrentWikiArticleActions({
<label className="text-sm font-medium">标题</label>
<Input
value={draft.title}
onChange={(event) => setDraft((prev) => ({ ...prev, title: event.target.value }))}
onChange={(event) =>
setDraft((prev) => ({ ...prev, title: event.target.value }))
}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">摘要</label>
<Textarea
value={draft.summary}
onChange={(event) => setDraft((prev) => ({ ...prev, summary: event.target.value }))}
onChange={(event) =>
setDraft((prev) => ({ ...prev, summary: event.target.value }))
}
rows={3}
/>
</div>
@@ -409,18 +515,28 @@ function CurrentWikiArticleActions({
<label className="text-sm font-medium">正文</label>
<MarkdownCodeEditor
value={draft.content}
onChange={(content) => setDraft((prev) => ({ ...prev, content }))}
onChange={(content) =>
setDraft((prev) => ({ ...prev, content }))
}
className="[&_.cm-editor]:min-h-[48vh]"
/>
</div>
</div>
<div className="mt-4 flex shrink-0 justify-end gap-2 border-t pt-3">
<Button variant="outline" onClick={() => setOpen(false)} disabled={actions.updateWikiPage.isPending}>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={actions.updateWikiPage.isPending}
>
取消
</Button>
<Button
onClick={save}
disabled={actions.updateWikiPage.isPending || !draft.title.trim() || !draft.content.trim()}
disabled={
actions.updateWikiPage.isPending ||
!draft.title.trim() ||
!draft.content.trim()
}
>
{actions.updateWikiPage.isPending ? (
<Loader2 className="mr-2 size-4 animate-spin" />
@@ -1,8 +1,11 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
BookOpen,
BookOpenText,
ChevronRight,
Database,
Cpu,
Edit3,
FileText,
Globe2,
Loader2,
@@ -10,7 +13,9 @@ import {
RefreshCw,
Search,
Settings2,
Sparkles,
Trash2,
Upload,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
@@ -28,6 +33,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { getStoredAuth } from "@/core/auth";
@@ -37,7 +43,11 @@ import {
type LlmWikiSearchResponse,
type LlmWikiSearchResult,
type LlmWikiScope,
configureLocalWikiEncoder,
getLocalWikiEncoderStatus,
searchLlmWiki,
startLocalWikiEncoder,
uploadLocalWikiEncoder,
useLlmWikiActions,
useLlmWikiKnowledgeBases,
useLlmWikiSettings,
@@ -83,7 +93,6 @@ function formatSearchTime(value?: string | null) {
function resultPreview(result: LlmWikiSearchResult) {
const text = (
result.matched_sections?.map((section) => section.content).filter(Boolean).join("\n\n") ||
result.summary ||
result.content ||
""
);
@@ -109,6 +118,7 @@ export function WeKnoraKnowledgeWorkspace() {
const knowledge = useLlmWikiKnowledgeBases(scope, tab !== "settings");
const actions = useLlmWikiActions();
const [createOpen, setCreateOpen] = useState(false);
const [editingKnowledge, setEditingKnowledge] = useState<LlmWikiKnowledgeBase | null>(null);
const [searchText, setSearchText] = useState("");
const [searching, setSearching] = useState(false);
const [searchResponse, setSearchResponse] = useState<LlmWikiSearchResponse | null>(null);
@@ -119,12 +129,8 @@ export function WeKnoraKnowledgeWorkspace() {
setSelectedSearchResult(null);
}, [tab]);
const busy = actions.create.isPending || actions.remove.isPending || actions.publication.isPending;
const busy = actions.create.isPending || actions.update.isPending || actions.remove.isPending || actions.publication.isPending;
const openDetails = (item: LlmWikiKnowledgeBase) => {
if (item.remote_status === "missing") {
toast.error("该知识库已从知识服务删除,只能清理当前记录");
return;
}
navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}`);
};
@@ -142,10 +148,7 @@ export function WeKnoraKnowledgeWorkspace() {
};
const removeKnowledgeBase = async (item: LlmWikiKnowledgeBase) => {
const warning = item.remote_status === "missing"
? `知识库“${item.name}”已不在知识服务中。确定清理 cmzs 中的这条失效记录吗?`
: `确定删除知识库“${item.name}”吗?知识服务中的文档和索引也会删除。`;
if (!window.confirm(warning)) return;
if (!window.confirm(`确定删除知识库“${item.name}”吗?知识服务中的文档和索引也会删除。`)) return;
try {
await actions.remove.mutateAsync(item.id);
toast.success("知识库已删除");
@@ -177,6 +180,11 @@ export function WeKnoraKnowledgeWorkspace() {
<p className="mt-1 text-sm text-muted-foreground">由知识服务负责解析与检索,用户、权限和发布范围由 cmzs 管理。</p>
</div>
<div className="flex items-center gap-2 rounded-full border bg-background/80 p-1 shadow-sm backdrop-blur">
{tab !== "settings" && (
<Button variant="ghost" size="sm" className="rounded-full" onClick={() => navigate("/page/workspace/skills?tab=knowledge")}>
<Sparkles className="mr-2 size-4" />从技能归纳
</Button>
)}
{tab !== "settings" && (
<Button variant="ghost" size="sm" className="rounded-full" onClick={() => knowledge.refetch()} disabled={knowledge.isFetching}>
<RefreshCw className={`mr-2 size-4 ${knowledge.isFetching ? "animate-spin" : ""}`} />刷新
@@ -207,8 +215,8 @@ export function WeKnoraKnowledgeWorkspace() {
)}
</div>
<TabsContent value="personal" className="mt-5"><KnowledgeGrid rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="还没有个人知识库,点击右上角创建。" busy={busy} onOpen={openDetails} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
<TabsContent value="public" className="mt-5"><KnowledgeGrid rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="公共 LLMWiki 暂无已发布知识库。" busy={busy} onOpen={openDetails} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
<TabsContent value="personal" className="mt-5"><KnowledgeGrid rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="还没有个人知识库,点击右上角创建。" busy={busy} onOpen={openDetails} onEdit={setEditingKnowledge} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
<TabsContent value="public" className="mt-5"><KnowledgeGrid rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="公共 LLMWiki 暂无已发布知识库。" busy={busy} onOpen={openDetails} onEdit={setEditingKnowledge} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
{isAdmin && <TabsContent value="settings" className="mt-5"><ConnectionSettings /></TabsContent>}
</Tabs>
@@ -301,6 +309,24 @@ export function WeKnoraKnowledgeWorkspace() {
}}
/>
{editingKnowledge && (
<EditKnowledgeDialog
key={editingKnowledge.id}
knowledgeBase={editingKnowledge}
pending={actions.update.isPending}
onClose={() => setEditingKnowledge(null)}
onSave={async (input) => {
try {
await actions.update.mutateAsync({ id: editingKnowledge.id, ...input });
setEditingKnowledge(null);
toast.success("知识库信息已保存");
} catch (error) {
toast.error(messageOf(error));
}
}}
/>
)}
<CreateKnowledgeDialog open={createOpen} pending={actions.create.isPending} onOpenChange={setCreateOpen} onCreate={async (input) => {
try {
const created = await actions.create.mutateAsync(input);
@@ -322,6 +348,7 @@ function KnowledgeGrid({
emptyText,
busy,
onOpen,
onEdit,
onPublish,
onDelete,
}: {
@@ -331,29 +358,27 @@ function KnowledgeGrid({
emptyText: string;
busy: boolean;
onOpen: (item: LlmWikiKnowledgeBase) => void;
onEdit: (item: LlmWikiKnowledgeBase) => void;
onPublish: (item: LlmWikiKnowledgeBase) => void;
onDelete: (item: LlmWikiKnowledgeBase) => void;
}) {
if (loading) return <div className="flex h-64 items-center justify-center text-muted-foreground"><Loader2 className="mr-2 size-5 animate-spin" />正在读取知识库</div>;
if (error) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-6 text-sm text-destructive">{messageOf(error)}</div>;
if (!rows.length) return <div className="flex h-64 items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">{emptyText}</div>;
const visibleRows = rows.filter((item) => item.remote_status !== "missing");
if (!visibleRows.length) return <div className="flex h-64 items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">{emptyText}</div>;
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{rows.map((item) => (
{visibleRows.map((item) => (
<Card
key={item.id}
className={`group overflow-hidden rounded-2xl transition-all hover:-translate-y-0.5 hover:shadow-lg ${
item.remote_status === "missing"
? "border-amber-500/40 bg-amber-500/[0.03]"
: "border-border/70 bg-card/95 hover:border-primary/30"
}`}
className="group overflow-hidden rounded-2xl border-border/70 bg-card/95 transition-all hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-lg"
>
<div className={`h-1 ${item.publication_status === "published" ? "bg-primary" : "bg-muted"}`} />
<button type="button" disabled={item.remote_status === "missing"} className="w-full text-left outline-none transition-colors enabled:hover:bg-muted/25 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-default" onClick={() => onOpen(item)}>
<button type="button" className="w-full text-left outline-none transition-colors hover:bg-muted/25 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2" onClick={() => onOpen(item)}>
<CardHeader className="pb-3 pt-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0"><CardTitle className="truncate text-base">{item.name}</CardTitle><CardDescription className="mt-1 line-clamp-2 min-h-10">{item.description || "暂无描述"}</CardDescription></div>
<div className="flex shrink-0 flex-col items-end gap-1.5">{item.remote_status === "missing" ? <Badge variant="outline" className="border-amber-500/50 text-amber-700 dark:text-amber-300">远端已删除</Badge> : <Badge variant={item.publication_status === "published" ? "default" : "secondary"}>{publicationLabels[item.publication_status] ?? "仅自己"}</Badge>}{item.remote_status !== "missing" && item.indexing_strategy?.wiki_enabled && <Badge className="bg-violet-100 text-violet-700 hover:bg-violet-100 dark:bg-violet-950 dark:text-violet-300">Wiki</Badge>}</div>
<div className="flex shrink-0 flex-col items-end gap-1.5"><Badge variant={item.publication_status === "published" ? "default" : "secondary"}>{publicationLabels[item.publication_status] ?? "仅自己"}</Badge>{item.indexing_strategy?.wiki_enabled && <Badge className="bg-violet-100 text-violet-700 hover:bg-violet-100 dark:bg-violet-950 dark:text-violet-300">Wiki</Badge>}</div>
</div>
</CardHeader>
<div className="px-6 pb-4"><div className="grid grid-cols-3 gap-2 rounded-xl border bg-muted/35 p-3 text-center text-xs"><div><div className="text-base font-semibold text-foreground">{item.knowledge_count}</div><span className="text-muted-foreground">文档</span></div><div><div className="text-base font-semibold text-foreground">{item.chunk_count}</div><span className="text-muted-foreground">分块</span></div><div><div className="text-base font-semibold text-foreground">{item.processing_count}</div><span className="text-muted-foreground">处理中</span></div></div></div>
@@ -361,10 +386,11 @@ function KnowledgeGrid({
<CardContent className="border-t bg-muted/20 px-6 py-4">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{item.remote_status === "missing" ? <span className="text-xs text-muted-foreground">知识服务资源不存在,可清理本地记录</span> : <Button size="sm" variant="outline" className="rounded-full" onClick={() => onOpen(item)}><FileText className="mr-2 size-4" />查看详情</Button>}
{item.remote_status !== "missing" && item.is_owner && !item.is_conversation_deposit && <Button size="sm" variant="secondary" className="rounded-full" disabled={busy} onClick={() => void onPublish(item)}>{item.publication_status === "published" ? "取消公开" : "发布公共库"}</Button>}
<Button size="sm" variant="outline" className="rounded-full" onClick={() => onOpen(item)}><FileText className="mr-2 size-4" />查看详情</Button>
{item.can_write && !item.is_conversation_deposit && <Button size="sm" variant="outline" className="rounded-full" disabled={busy} onClick={() => onEdit(item)} aria-label={`编辑知识库 ${item.name}`}><Edit3 className="mr-2 size-4" />编辑</Button>}
{item.is_owner && !item.is_conversation_deposit && <Button size="sm" variant="secondary" className="rounded-full" disabled={busy} onClick={() => void onPublish(item)}>{item.publication_status === "published" ? "取消公开" : "发布公共库"}</Button>}
</div>
{item.can_write && !item.is_conversation_deposit && <Button size="icon" variant="ghost" className="size-8 shrink-0 rounded-full text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label={item.remote_status === "missing" ? `清理失效记录 ${item.name}` : `删除知识库 ${item.name}`} title={item.remote_status === "missing" ? "清理失效记录" : "删除知识库"} disabled={busy} onClick={() => void onDelete(item)}><Trash2 className="size-4" /></Button>}
{item.can_write && !item.is_conversation_deposit && <Button size="icon" variant="ghost" className="size-8 shrink-0 rounded-full text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label={`删除知识库 ${item.name}`} title="删除知识库" disabled={busy} onClick={() => void onDelete(item)}><Trash2 className="size-4" /></Button>}
</div>
</CardContent>
</Card>
@@ -373,6 +399,55 @@ function KnowledgeGrid({
);
}
function EditKnowledgeDialog({
knowledgeBase,
pending,
onClose,
onSave,
}: {
knowledgeBase: LlmWikiKnowledgeBase;
pending: boolean;
onClose: () => void;
onSave: (input: { name: string; description: string }) => Promise<void>;
}) {
const [name, setName] = useState(knowledgeBase.name);
const [description, setDescription] = useState(knowledgeBase.description);
return (
<Dialog open onOpenChange={(open) => { if (!open && !pending) onClose(); }}>
<DialogContent>
<form onSubmit={(event) => {
event.preventDefault();
if (!pending && name.trim()) {
void onSave({ name: name.trim(), description: description.trim() });
}
}}>
<DialogHeader>
<DialogTitle>编辑知识库</DialogTitle>
<DialogDescription>修改知识库名称和描述,保存后同步到知识服务。</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="llmwiki-edit-name">名称</label>
<Input id="llmwiki-edit-name" value={name} onChange={(event) => setName(event.target.value)} maxLength={255} required disabled={pending} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="llmwiki-edit-description">描述</label>
<Textarea id="llmwiki-edit-description" value={description} onChange={(event) => setDescription(event.target.value)} rows={4} maxLength={2048} disabled={pending} />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose} disabled={pending}>取消</Button>
<Button type="submit" disabled={pending || !name.trim()}>
{pending && <Loader2 className="mr-2 size-4 animate-spin" />}保存
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function CreateKnowledgeDialog({
open,
pending,
@@ -408,6 +483,122 @@ function CreateKnowledgeDialog({
);
}
export function LocalEncoderSettingsCard() {
const queryClient = useQueryClient();
const encoder = useQuery({
queryKey: ["llmwiki-local-encoder"],
queryFn: getLocalWikiEncoderStatus,
retry: false,
refetchInterval: (query) => query.state.data?.state === "starting" ? 1000 : false,
});
const [modelPath, setModelPath] = useState("");
const [files, setFiles] = useState<File[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
useEffect(() => {
if (encoder.data?.model_path) setModelPath(encoder.data.model_path);
}, [encoder.data?.model_path]);
const applyStatus = (status: Awaited<ReturnType<typeof getLocalWikiEncoderStatus>>) => {
queryClient.setQueryData(["llmwiki-local-encoder"], status);
setModelPath(status.model_path || "");
};
const configure = useMutation({
mutationFn: () => configureLocalWikiEncoder(modelPath.trim()),
onSuccess: (status) => { applyStatus(status); toast.success("编码器目录已保存"); },
onError: (error) => toast.error(messageOf(error)),
});
const upload = useMutation({
mutationFn: () => uploadLocalWikiEncoder(files, setUploadProgress),
onMutate: () => setUploadProgress(0),
onSuccess: (status) => {
applyStatus(status);
setFiles([]);
toast.success(`已保存 ${status.uploaded_files ?? 0} 个模型文件`);
},
onError: (error) => toast.error(messageOf(error)),
});
const start = useMutation({
mutationFn: startLocalWikiEncoder,
onSuccess: (status) => {
applyStatus(status);
toast.success(status.state === "ready" ? "本地编码器已就绪" : "本地编码器正在独立启动");
},
onError: (error) => toast.error(messageOf(error)),
});
const status = encoder.data;
const selectedBytes = files.reduce((sum, file) => sum + file.size, 0);
const formatSize = (value: number) => value >= 1024 ** 3
? `${(value / 1024 ** 3).toFixed(2)} GB`
: `${(value / 1024 ** 2).toFixed(1)} MB`;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base"><Cpu className="size-5" />DeerFlow 本地编码器</CardTitle>
<CardDescription>仅管理员可见。配置服务器上已有的 BGE-M3 目录,或直接上传完整离线模型目录;编码器作为独立进程启动,不影响 DeerFlow 其他服务。</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex flex-wrap items-center gap-2 text-sm">
<Badge variant={status?.state === "failed" ? "destructive" : status?.state === "ready" ? "default" : "secondary"}>
{status?.state === "ready" ? "已就绪" : status?.state === "starting" ? "启动中" : status?.state === "failed" ? "启动失败" : "未启动"}
</Badge>
<span className="text-muted-foreground">{status ? `${status.model} · ${status.dimensions || "自动探测"} 维 · ${status.process_isolation ? "独立进程" : "主进程"}` : "正在读取状态"}</span>
{status?.model_files_ready && <Badge variant="outline">模型文件已就绪</Badge>}
</div>
{encoder.error && <p className="text-sm text-destructive">{messageOf(encoder.error)}</p>}
{status?.last_error && <p className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">{status.last_error}</p>}
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="local-encoder-path">使用服务器已有目录</label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input id="local-encoder-path" value={modelPath} onChange={(event) => setModelPath(event.target.value)} placeholder="例如 /opt/deerflow/models/bge-m3" />
<Button variant="outline" disabled={!modelPath.trim() || configure.isPending} onClick={() => configure.mutate()}>
{configure.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}保存目录
</Button>
</div>
<p className="text-xs text-muted-foreground">目录内需包含 <code>onnx/model.onnx</code>(推荐)或根目录 <code>model.onnx</code>。</p>
</div>
<div className="space-y-2 rounded-xl border border-dashed p-4">
<div className="flex items-center gap-2 text-sm font-medium"><Upload className="size-4" />从当前电脑上传模型目录</div>
<Input
type="file"
multiple
{...{ webkitdirectory: "" }}
onChange={(event) => setFiles(Array.from(event.currentTarget.files ?? []))}
/>
{upload.isPending && (
<div className="space-y-1">
<Progress value={uploadProgress} />
<p className="text-right text-xs text-muted-foreground">上传进度 {uploadProgress}%</p>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
{files.length ? `已选择 ${files.length} 个文件,共 ${formatSize(selectedBytes)}` : "请选择完整 BGE-M3 目录。大文件会落到运行目录,不写入源码仓库。"}
</p>
<Button disabled={!files.length || upload.isPending} onClick={() => upload.mutate()}>
{upload.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Upload className="mr-2 size-4" />}
{upload.isPending ? "正在上传,请勿关闭页面" : "上传并设为当前模型"}
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button disabled={!status?.model_files_ready || start.isPending || status?.state === "starting" || status?.state === "ready"} onClick={() => start.mutate()}>
{(start.isPending || status?.state === "starting") && <Loader2 className="mr-2 size-4 animate-spin" />}
{status?.state === "ready" ? "编码器已启动" : status?.state === "failed" ? "重新启动" : "一键启动编码器"}
</Button>
<Button variant="ghost" disabled={encoder.isFetching} onClick={() => void encoder.refetch()}>
<RefreshCw className={`mr-2 size-4 ${encoder.isFetching ? "animate-spin" : ""}`} />刷新状态
</Button>
</div>
</CardContent>
</Card>
);
}
export function ConnectionSettings() {
const settings = useLlmWikiSettings(true);
const actions = useLlmWikiSettingsActions();
@@ -0,0 +1,221 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Archive,
ArrowLeft,
Download,
HardDrive,
Loader2,
PackagePlus,
RefreshCw,
} from "lucide-react";
import { Navigate, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { KnowledgeProductNav } from "@/components/workspace/knowledge-product-nav";
import { getStoredAuth } from "@/core/auth";
import {
createLlmWikiExportPackage,
listLlmWikiExportPackages,
} from "@/strategy-components/api/assistant-knowledge";
import { useLlmWikiKnowledgeBase, useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import { WikiPackageTransfers } from "./WikiPackageTransfers";
const RUNNING = new Set(["queued", "running"]);
const STATUS_LABEL: Record<string, string> = {
queued: "排队中",
running: "整理中",
completed: "已完成",
failed: "失败",
};
function formatBytes(value?: number | null) {
let current = Number(value || 0);
if (!current) return "—";
const units = ["B", "KB", "MB", "GB", "TB"];
let unit = 0;
while (current >= 1024 && unit < units.length - 1) {
current /= 1024;
unit += 1;
}
return `${current >= 10 || unit === 0 ? current.toFixed(0) : current.toFixed(1)} ${units[unit]}`;
}
export default function WeKnoraPackageTransfersPage() {
const { noteId = "" } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const isAdmin = getStoredAuth()?.system_role === "admin";
const runtime = useLlmWikiRuntime();
const knowledge = useLlmWikiKnowledgeBase(noteId || null, !!noteId);
const jobsKey = ["llmwiki-export-packages", noteId];
const jobs = useQuery({
queryKey: jobsKey,
queryFn: () => listLlmWikiExportPackages(noteId),
enabled: isAdmin && !!noteId && runtime.data?.provider === "weknora",
refetchInterval: (query) =>
query.state.data?.some((job) => RUNNING.has(job.status)) ? 3000 : false,
});
const createExport = useMutation({
mutationFn: () => createLlmWikiExportPackage(noteId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: jobsKey });
toast.success("已创建后台整理任务,完成后可在本页下载");
},
onError: (error) => toast.error(error.message),
});
if (!isAdmin) return <Navigate to="/page/workspace/knowledge" replace />;
return (
<div className="flex size-full min-h-0 flex-col bg-muted/20">
<KnowledgeProductNav />
<main className="min-h-0 flex-1 overflow-auto px-5 py-6 lg:px-8">
<div className="mx-auto max-w-6xl space-y-6">
<header className="flex flex-wrap items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() =>
navigate(
`/page/workspace/knowledge/${encodeURIComponent(noteId)}`,
)
}
>
<ArrowLeft className="size-5" />
</Button>
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Archive className="size-5" />
</div>
<div className="min-w-0">
<h1 className="truncate text-xl font-semibold">
知识库数据包任务
</h1>
<p className="truncate text-sm text-muted-foreground">
{knowledge.data?.name || "正在读取知识库"} ·
大文件在后台整理,不占用详情页面
</p>
</div>
</div>
<Button
onClick={() => createExport.mutate()}
disabled={createExport.isPending || !knowledge.data}
>
{createExport.isPending ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<PackagePlus className="mr-2 size-4" />
)}
新建导出任务
</Button>
</header>
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0 border-b">
<div>
<CardTitle className="text-base">
向量化数据包导出记录
</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
每次生成独立记录;即使文件达到数
GB,也只在后台落盘完成后提供下载。
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => void jobs.refetch()}
disabled={jobs.isFetching}
>
<RefreshCw
className={`mr-2 size-4 ${jobs.isFetching ? "animate-spin" : ""}`}
/>
刷新
</Button>
</CardHeader>
<CardContent className="p-0">
{jobs.data?.map((job) => {
const counts = job.counts || {};
const ready =
job.status === "completed" &&
job.file_exists &&
job.package_download_url;
return (
<div
key={job.id}
className="grid gap-4 border-b p-5 last:border-b-0 md:grid-cols-[auto_1fr_auto] md:items-center"
>
<div
className={`flex size-10 items-center justify-center rounded-full ${job.status === "completed" ? "bg-emerald-100 text-emerald-700" : job.status === "failed" ? "bg-destructive/10 text-destructive" : "bg-amber-100 text-amber-700"}`}
>
{RUNNING.has(job.status) ? (
<Loader2 className="size-4 animate-spin" />
) : (
<HardDrive className="size-4" />
)}
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">
任务 {job.id.slice(0, 8)}
</span>
<Badge variant="outline">
{STATUS_LABEL[job.status] || job.status}
</Badge>
<span className="text-xs text-muted-foreground">
{job.created_at
? new Date(job.created_at).toLocaleString()
: "—"}
</span>
</div>
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-sm text-muted-foreground">
<span>
Wiki {counts.wiki_page ?? counts.wiki_pages ?? 0}
</span>
<span>向量 {counts.vector ?? counts.vectors ?? 0}</span>
<span>文件 {formatBytes(job.file_size_bytes)}</span>
<span>阶段 {job.phase}</span>
</div>
{job.error && (
<p className="mt-2 text-sm text-destructive">
{job.error}
</p>
)}
</div>
{ready ? (
<Button asChild variant="outline">
<a href={job.package_download_url}>
<Download className="mr-2 size-4" />
下载数据包
</a>
</Button>
) : (
<Button variant="outline" disabled>
{job.status === "failed" ? "整理失败" : "等待完成"}
</Button>
)}
</div>
);
})}
{!jobs.isLoading && !jobs.data?.length && (
<div className="px-6 py-14 text-center text-sm text-muted-foreground">
暂无导出记录。点击右上角“新建导出任务”开始整理。
</div>
)}
{jobs.isLoading && (
<div className="flex items-center justify-center px-6 py-14 text-sm text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" />
加载导出记录
</div>
)}
</CardContent>
</Card>
<WikiPackageTransfers id={noteId} />
</div>
</main>
</div>
);
}
@@ -52,8 +52,11 @@ import {
usePublicLlmWikiWikiPages,
} from "@/strategy-components/api/llmwiki";
import { WikiProtectedImage } from "./WikiProtectedImage";
import { WikiLibraryReader } from "./WikiLibraryReader";
import { useAssistantKnowledgeBases, useAssistantKnowledgeSources } from "@/strategy-components/api/assistant-knowledge";
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();
@@ -120,6 +123,10 @@ export function WeKnoraWikiPageView({
const navigate = useNavigate();
const publicBackground = usePublicBackground();
const requestedWikiSlug = searchParams.get("wikiSlug")?.trim() || "";
const assistantBases = useAssistantKnowledgeBases(!publicEmbed);
const assistantSources = useAssistantKnowledgeSources(null, !publicEmbed);
const globalBase = assistantBases.data?.find(base => base.is_global);
const isDeposited = assistantSources.data?.some(source => source.source_key === noteId && source.status === "active");
const privateKnowledgeBase = useLlmWikiKnowledgeBase(
noteId || null,
!!noteId && !publicEmbed,
@@ -144,13 +151,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 +193,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 +208,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 +385,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 页面标识"}
@@ -434,6 +492,7 @@ export function WeKnoraWikiPageView({
</div>
</div>
<div className="flex items-center gap-2">
{globalBase && isDeposited && <Button variant="outline" size="sm" onClick={() => navigate(`/page/workspace/knowledge/assistant?${new URLSearchParams({ base_id: globalBase.id, wikiSlug: pageData.slug })}`)}>在知识梳理中查看</Button>}
<Button variant="outline" size="sm" onClick={() => void download("md")} disabled={downloading !== null}>
{downloading === "md" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
下载 MD
@@ -446,7 +505,7 @@ export function WeKnoraWikiPageView({
</header>
)}
<div className="flex min-h-0 flex-1 overflow-hidden">
{!publicEmbed ? <WikiLibraryReader pages={pagesList} activePage={pageData} mappingId={noteId} onNavigate={slug => navigate(buildWeKnoraWikiPagePath(noteId, slug))} pageHref={slug => buildAppHashUrl(buildWeKnoraWikiPagePath(noteId, slug))} /> : <div className="flex min-h-0 flex-1 overflow-hidden">
<aside className="flex w-64 shrink-0 flex-col border-r bg-background/90 lg:w-72">
<div className="shrink-0 border-b px-3 py-3">
<div className="flex items-center justify-between gap-3 px-1">
@@ -531,7 +590,7 @@ export function WeKnoraWikiPageView({
</section>
</article>
</main>
</div>
</div>}
</div>
);
}
@@ -0,0 +1,471 @@
import {
BookOpenText,
ChevronDown,
ChevronRight,
FileText,
Folder,
Link2,
Network,
Search,
X,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import {
buildWikiTree,
filterWikiPagesWithAncestors,
type WikiTreeNode,
} from "@/core/llmwiki/wiki-tree";
import {
renderableWikiMarkdown,
rewriteProtectedWikiImageSources,
} from "@/core/llmwiki/wiki-page-markdown";
import { reasoningPlugins } from "@/core/streamdown";
import type { LlmWikiWikiPage } from "@/strategy-components/api/llmwiki";
import { WikiProtectedImage } from "./WikiProtectedImage";
export type LibraryWikiPage = LlmWikiWikiPage & {
tags?: string[];
origins?: { mapping_id?: string; source_name: string; slug: string }[];
};
const TYPES: Record<string, string> = {
index: "索引",
summary: "摘要",
entity: "实体",
concept: "概念",
synthesis: "综合分析",
comparison: "对比分析",
article: "文章",
source: "资料",
relation: "关系",
};
export function WikiLibraryReader({
pages,
activePage,
onNavigate,
pageHref,
mappingId,
initiallyCollapseTree = false,
}: {
pages: LibraryWikiPage[];
activePage: LibraryWikiPage | null;
onNavigate: (slug: string) => void;
pageHref: (slug: string) => string;
mappingId?: string;
initiallyCollapseTree?: boolean;
}) {
const [query, setQuery] = useState("");
const [kind, setKind] = useState("");
const [tag, setTag] = useState("");
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [graph, setGraph] = useState(false);
const [directoryOpen, setDirectoryOpen] = useState(false);
const initialTreeCollapseApplied = useRef(false);
const navigate = (slug: string) => {
onNavigate(slug);
setDirectoryOpen(false);
};
const groups = useMemo(
() => [...new Set(pages.map((p) => p.page_type))],
[pages],
);
const tags = useMemo(
() =>
[
...new Set(
pages.flatMap((p) => [
...(p.tags || p.page_metadata?.tags || []),
...(p.aliases || []),
]),
),
].sort(),
[pages],
);
const tree = useMemo(
() =>
buildWikiTree(
filterWikiPagesWithAncestors(
pages.filter(
(p) =>
(!kind || p.page_type === kind) &&
(!tag ||
[
...(p.tags || p.page_metadata?.tags || []),
...(p.aliases || []),
].includes(tag)),
),
query,
),
),
[pages, kind, tag, query],
);
const bySlug = useMemo(() => new Map(pages.map((p) => [p.slug, p])), [pages]);
useEffect(() => {
const ancestors: string[] = [];
const branches: string[] = [];
const collectBranches = (nodes: WikiTreeNode[]) => {
for (const node of nodes) {
if (node.children.length) branches.push(node.key);
collectBranches(node.children);
}
};
const visit = (nodes: WikiTreeNode[], parents: string[]): boolean =>
nodes.some((node) => {
if (node.page?.slug === activePage?.slug) {
ancestors.push(...parents);
return true;
}
return visit(node.children, [...parents, node.key]);
});
collectBranches(tree.roots);
visit(tree.roots, []);
if (
initiallyCollapseTree &&
!initialTreeCollapseApplied.current &&
tree.roots.length
) {
initialTreeCollapseApplied.current = true;
setCollapsed(
new Set(branches.filter((key) => !ancestors.includes(key))),
);
return;
}
setCollapsed(
(current) =>
new Set([...current].filter((key) => !ancestors.includes(key))),
);
}, [activePage?.slug, initiallyCollapseTree, tree]);
const links = useMemo(() => {
const outgoing = new Set(activePage?.out_links || []);
for (const match of (activePage?.content || "").matchAll(
/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g,
))
outgoing.add(match[1].trim());
const incoming = new Set(activePage?.in_links || []);
for (const p of pages)
if (
p.slug !== activePage?.slug &&
((p.out_links || []).includes(activePage?.slug || "") ||
p.content?.includes(`[[${activePage?.slug}`))
)
incoming.add(p.slug);
return {
outgoing: [...outgoing].filter((s) => bySlug.has(s)),
incoming: [...incoming].filter((s) => bySlug.has(s)),
};
}, [activePage, pages, bySlug]);
const renderNode = (node: WikiTreeNode, depth = 0): React.ReactNode => {
const active = activePage?.slug === node.page?.slug;
const open = !!query || !collapsed.has(node.key);
const toggle = () =>
setCollapsed((current) => {
const next = new Set(current);
if (next.has(node.key)) next.delete(node.key);
else next.add(node.key);
return next;
});
return (
<div key={node.key}>
<div
style={{ paddingLeft: 8 + depth * 14 }}
className={`flex items-center gap-1 rounded-lg pr-2 text-sm ${active ? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200" : "text-muted-foreground hover:bg-muted"}`}
>
{node.children.length ? (
<button
aria-label={open ? "收起分类" : "展开分类"}
onClick={toggle}
className="p-1"
>
{open ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</button>
) : (
<span className="w-6" />
)}
<button
onClick={() => (node.page ? navigate(node.page.slug) : toggle())}
className="flex min-w-0 flex-1 items-center gap-2 py-2.5 text-left"
aria-current={active ? "page" : undefined}
>
{node.page ? (
<FileText className="size-4 shrink-0" />
) : (
<Folder className="size-4 shrink-0 text-amber-500" />
)}
<span className="truncate">{TYPES[node.label] || node.label}</span>
</button>
</div>
{open && node.children.map((child) => renderNode(child, depth + 1))}
</div>
);
};
const renderLinks = (values: string[]) =>
values.map((slug) => (
<button
key={slug}
onClick={() => navigate(slug)}
className="block w-full rounded-lg px-2 py-2 text-left text-sm text-primary hover:bg-muted"
>
<Link2 className="mr-2 inline size-3.5" />
{bySlug.get(slug)?.title || slug}
</button>
));
return (
<div className="relative flex min-h-0 flex-1 overflow-hidden bg-muted/20">
{directoryOpen && (
<button
aria-label="关闭 Wiki 目录"
className="absolute inset-0 z-20 bg-black/30 md:hidden"
onClick={() => setDirectoryOpen(false)}
/>
)}
<aside
className={`${directoryOpen ? "flex" : "hidden"} absolute inset-y-0 left-0 z-30 w-64 shrink-0 flex-col border-r bg-background md:static md:z-auto md:flex lg:w-72`}
>
<div className="space-y-3 border-b p-4">
<div className="flex items-center gap-2 font-semibold">
<BookOpenText className="size-4 text-emerald-600" />
Wiki 目录
<span className="ml-auto text-xs font-normal text-muted-foreground">
{pages.length} 篇
</span>
<button
className="md:hidden"
aria-label="收起 Wiki 目录"
onClick={() => setDirectoryOpen(false)}
>
<X className="size-4" />
</button>
</div>
<div className="relative">
<Search className="absolute left-2 top-3 size-4 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索标题、分类或标签"
className="pl-8"
/>
</div>
<select
aria-label="Wiki 分类"
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={kind}
onChange={(e) => setKind(e.target.value)}
>
<option value="">全部类型</option>
{groups.map((k) => (
<option key={k} value={k}>
{TYPES[k] || k}({pages.filter((p) => p.page_type === k).length}
)
</option>
))}
</select>
{tags.length > 0 && (
<select
aria-label="Wiki 标签"
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={tag}
onChange={(e) => setTag(e.target.value)}
>
<option value="">全部标签</option>
{tags.map((t) => (
<option key={t}>{t}</option>
))}
</select>
)}
</div>
<nav
className="min-h-0 flex-1 overflow-auto p-2"
aria-label="Wiki 目录"
>
{tree.roots.map((node) => renderNode(node))}
{!tree.roots.length && (
<p className="p-4 text-sm text-muted-foreground">暂无匹配页面</p>
)}
</nav>
</aside>
<main className="min-w-0 flex-1 overflow-auto p-3 md:p-5 lg:p-8">
<Button
variant="outline"
size="sm"
className="mb-3 md:hidden"
onClick={() => setDirectoryOpen(true)}
>
<BookOpenText className="mr-2 size-4" />
目录与分类({pages.length})
</Button>
{activePage ? (
<article className="mx-auto max-w-5xl rounded-2xl border bg-background shadow-sm">
<header className="space-y-3 border-b p-4 md:px-7 md:py-6">
<div className="text-xs text-muted-foreground">
{(activePage.category_path || []).join(" / ") || "知识库 Wiki"}
</div>
<div className="flex items-start justify-between gap-4">
<h1 className="text-2xl font-semibold tracking-tight">
{activePage.title}
</h1>
<Badge variant="outline">
{TYPES[activePage.page_type] || activePage.page_type}
</Badge>
</div>
<div className="flex flex-wrap gap-2">
{[
...new Set([
...(activePage.tags ||
activePage.page_metadata?.tags ||
[]),
...(activePage.aliases || []),
]),
].map((t) => (
<button
key={t}
onClick={() => {
setTag(t);
setDirectoryOpen(true);
}}
>
<Badge variant="secondary">{t}</Badge>
</button>
))}
</div>
{activePage.summary && (
<p className="text-sm leading-7 text-muted-foreground">
{activePage.summary}
</p>
)}
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
{activePage.updated_at && (
<span>
更新于 {new Date(activePage.updated_at).toLocaleString()}
</span>
)}
<span>
{links.outgoing.length} 个引用 · {links.incoming.length}{" "}
个反向链接
</span>
</div>
</header>
<section className="px-4 py-6 md:px-7 md:py-8 lg:px-10">
<MarkdownContent
content={rewriteProtectedWikiImageSources(
renderableWikiMarkdown(
activePage.title,
activePage.content,
mappingId || "assistant",
(_id, slug) => pageHref(slug),
pages,
).replace(/^# [^\n]*(?:\n|$)/, ""),
)}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
components={
mappingId
? {
img: (props) => (
<WikiProtectedImage
{...props}
knowledgeBaseId={mappingId}
/>
),
}
: undefined
}
linkClassName={(href) =>
pages.some((p) => pageHref(p.slug) === href)
? "wiki-content-link !text-primary border-b border-dashed"
: undefined
}
onLinkClick={(event, href) => {
const target = pages.find(
(p) =>
pageHref(p.slug) === href ||
href === p.slug ||
href === `/${p.slug}`,
);
if (target) {
event.preventDefault();
navigate(target.slug);
}
}}
/>
</section>
<section className="border-t px-4 py-5 md:px-7">
<Button
variant="ghost"
size="sm"
onClick={() => setGraph(!graph)}
>
<Network className="mr-2 size-4" />
{graph ? "收起关系图谱" : "查看关系图谱"}
</Button>
{graph && (
<div className="my-4 grid grid-cols-3 items-center gap-3 rounded-xl bg-muted/40 p-4">
<div>
{renderLinks(links.incoming)}
{!links.incoming.length && (
<span className="text-xs text-muted-foreground">
暂无反向链接
</span>
)}
</div>
<div className="rounded-xl border border-primary/30 bg-primary/10 p-4 text-center text-sm font-medium">
→ {activePage.title} →
</div>
<div>
{renderLinks(links.outgoing)}
{!links.outgoing.length && (
<span className="text-xs text-muted-foreground">
暂无引用
</span>
)}
</div>
</div>
)}
<div className="grid gap-5 sm:grid-cols-2">
<div>
<h3 className="my-2 text-sm font-medium">引用页面</h3>
{renderLinks(links.outgoing)}
</div>
<div>
<h3 className="my-2 text-sm font-medium">被哪些页面引用</h3>
{renderLinks(links.incoming)}
</div>
</div>
{(activePage.origins || [])
.filter(
(o, index, all) =>
o.mapping_id &&
all.findIndex(
(value) =>
value.mapping_id === o.mapping_id &&
value.slug === o.slug,
) === index,
)
.map((o) => (
<a
key={`${o.mapping_id}:${o.slug}`}
className="mt-3 block text-xs text-primary hover:underline"
href={`#/page/workspace/knowledge/${encodeURIComponent(o.mapping_id!)}/wiki/view?wikiSlug=${encodeURIComponent(o.slug)}`}
>
查看普通知识库来源 · {o.source_name}
</a>
))}
</section>
</article>
) : (
<div className="py-20 text-center text-sm text-muted-foreground">
选择一个 Wiki 页面开始阅读。完成向量化的数据会自动沉淀到知识梳理。
</div>
)}
</main>
</div>
);
}
@@ -0,0 +1,126 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Download, Loader2, Upload } from "lucide-react";
import { useRef } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
createAssistantExportPackage,
importOrdinaryWikiPackage,
listWikiTransfers,
} from "@/strategy-components/api/assistant-knowledge";
export function WikiPackageTransfers({
id,
assistant = false,
}: {
id: string;
assistant?: boolean;
}) {
const input = useRef<HTMLInputElement>(null);
const client = useQueryClient();
const key = ["wiki-transfers", assistant, id];
const jobs = useQuery({
queryKey: key,
queryFn: () => listWikiTransfers(id, assistant),
refetchInterval: (q) =>
q.state.data?.some((j) => ["queued", "running"].includes(j.status))
? 3000
: false,
});
const action = useMutation({
mutationFn: (file?: File) =>
assistant
? createAssistantExportPackage(id)
: importOrdinaryWikiPackage(id, file!),
onSuccess: () => {
void client.invalidateQueries({ queryKey: key });
toast.success(
assistant
? "已创建导出记录,整理完成后可下载"
: "文件已上传,后台正在校验并导入原有 Wiki 和向量",
);
},
onError: (e) => toast.error(e.message),
});
return (
<div className="space-y-3 rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold">
{assistant ? "导出 Wiki 向量数据包" : "导入 Wiki 向量数据包"}
</h3>
<p className="mt-1 text-xs text-muted-foreground">
{assistant
? "保留正文、分类、标签、链接和原始向量;后台整理完成后下载。"
: "选择助手知识库导出的文件,导入当前空库。模型一致时直接复用向量。"}
</p>
</div>
<input
type="file"
accept=".jsonl,.ndjson"
ref={input}
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) action.mutate(f);
e.target.value = "";
}}
/>
<Button
variant="outline"
size="sm"
disabled={action.isPending}
onClick={() =>
assistant ? action.mutate(undefined) : input.current?.click()
}
>
{action.isPending ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : assistant ? (
<Download className="mr-2 size-4" />
) : (
<Upload className="mr-2 size-4" />
)}
{assistant ? "生成导出包" : "上传并导入"}
</Button>
</div>
{jobs.error && (
<p className="text-sm text-destructive">{jobs.error.message}</p>
)}
{jobs.data?.map((job) => (
<div
key={job.id}
className="flex flex-wrap items-center gap-3 border-t pt-3 text-xs"
>
<Badge variant="outline">
{{
queued: "排队中",
running: "处理中",
failed: "失败",
completed: "已完成",
}[job.status] || job.status}
</Badge>
<span>
{job.created_at && new Date(job.created_at).toLocaleString()}
</span>
<span>
{job.counts?.wiki_page || 0} Wiki · {job.counts?.vector || 0} 向量
</span>
{job.file_size_bytes ? (
<span>{(job.file_size_bytes / 1024 / 1024).toFixed(2)} MB</span>
) : null}
{job.error && <span className="text-destructive">{job.error}</span>}
{assistant &&
job.status === "completed" &&
job.file_exists &&
job.package_download_url && (
<Button asChild size="sm" variant="outline" className="ml-auto">
<a href={job.package_download_url}>下载</a>
</Button>
)}
</div>
))}
</div>
);
}