Merge branch 'ji' into master
This commit is contained in:
commit
49d9ced04f
84 files changed
+13258
-662
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FilesIcon, XIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import {
|
||||
artifactPathname,
|
||||
listThreadArtifacts,
|
||||
mergeArtifactPaths,
|
||||
} from "@/core/artifacts/listing";
|
||||
@@ -94,7 +95,13 @@ const ChatBox: React.FC<{
|
||||
knowledgeReferencesEnabled = false,
|
||||
} = useThread();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const requestedArtifact = useMemo(() => {
|
||||
const value = searchParams.get("artifact")?.trim();
|
||||
return value ? artifactPathname(value) : null;
|
||||
}, [searchParams]);
|
||||
const threadIdRef = useRef(threadId);
|
||||
const openedRequestedArtifactRef = useRef<string | null>(null);
|
||||
const artifactsCountRef = useRef(0);
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const latestReferenceBatchIdRef = useRef<string | null>(null);
|
||||
@@ -135,7 +142,7 @@ const ChatBox: React.FC<{
|
||||
const { skills } = useSkills();
|
||||
|
||||
const canScanArtifactDirectory =
|
||||
artifactDirectoryModeEnabled &&
|
||||
(artifactDirectoryModeEnabled || !!requestedArtifact) &&
|
||||
!disableArtifacts &&
|
||||
!isMock &&
|
||||
!!threadId &&
|
||||
@@ -153,17 +160,21 @@ const ChatBox: React.FC<{
|
||||
);
|
||||
const checkpointArtifactSignature = checkpointArtifacts.join("\n");
|
||||
const availableArtifacts = useMemo(
|
||||
() =>
|
||||
artifactDirectoryModeEnabled
|
||||
? mergeArtifactPaths(
|
||||
checkpointArtifacts,
|
||||
(artifactListingQuery.data?.files ?? []).map((file) => file.path),
|
||||
)
|
||||
: checkpointArtifacts,
|
||||
() => {
|
||||
if (!artifactDirectoryModeEnabled && !requestedArtifact) {
|
||||
return checkpointArtifacts;
|
||||
}
|
||||
const discoveredArtifacts = (artifactListingQuery.data?.files ?? []).map(
|
||||
(file) => file.path,
|
||||
);
|
||||
if (requestedArtifact) discoveredArtifacts.push(requestedArtifact);
|
||||
return mergeArtifactPaths(checkpointArtifacts, discoveredArtifacts);
|
||||
},
|
||||
[
|
||||
artifactDirectoryModeEnabled,
|
||||
artifactListingQuery.data?.files,
|
||||
checkpointArtifacts,
|
||||
requestedArtifact,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -207,6 +218,29 @@ const ChatBox: React.FC<{
|
||||
thread.isLoading,
|
||||
);
|
||||
}, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
|
||||
useEffect(() => {
|
||||
if (
|
||||
disableArtifacts ||
|
||||
!requestedArtifact ||
|
||||
!threadId ||
|
||||
threadId === "new"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const requestKey = `${threadId}:${artifactPathname(requestedArtifact)}`;
|
||||
if (openedRequestedArtifactRef.current === requestKey) return;
|
||||
openedRequestedArtifactRef.current = requestKey;
|
||||
selectArtifact(requestedArtifact);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
}, [
|
||||
disableArtifacts,
|
||||
requestedArtifact,
|
||||
selectArtifact,
|
||||
setArtifactsOpen,
|
||||
threadId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldFetchBackendReferenceBatches) {
|
||||
setBackendReferenceBatches([]);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { BookOpen, Database } from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import {
|
||||
loadKnowledgeBaseProductVersion,
|
||||
type KnowledgeBaseProductVersion,
|
||||
updateKnowledgeBaseProductVersion,
|
||||
} from "@/core/system-settings/api";
|
||||
|
||||
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const;
|
||||
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
|
||||
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
|
||||
|
||||
export function useKnowledgeBaseProductVersion() {
|
||||
return useQuery({ queryKey: KNOWLEDGE_PRODUCT_KEY, queryFn: loadKnowledgeBaseProductVersion, staleTime: 60_000 });
|
||||
}
|
||||
|
||||
export function KnowledgeProductNav() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const client = useQueryClient();
|
||||
const version = useKnowledgeBaseProductVersion();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const save = useMutation({
|
||||
mutationFn: updateKnowledgeBaseProductVersion,
|
||||
onSuccess: (value) => {
|
||||
client.setQueryData(KNOWLEDGE_PRODUCT_KEY, value);
|
||||
toast.success(`默认知识库体验已切换为 ${value}`);
|
||||
},
|
||||
});
|
||||
const assistant = location.pathname.includes("/knowledge/assistant");
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-b bg-card px-6 py-2">
|
||||
<Button size="sm" variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
||||
<BookOpen />普通知识库
|
||||
</Button>
|
||||
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
||||
<Database />助手知识库
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||
默认产品版本
|
||||
<Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
|
||||
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="v1">v1 普通知识库</SelectItem><SelectItem value="v2">v2 助手知识库</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type AssistantKnowledgeBase,
|
||||
useAssistantKnowledgeBases,
|
||||
} from "@/strategy-components/api/assistant-knowledge";
|
||||
import {
|
||||
type LlmWikiKnowledgeBase,
|
||||
useAgentLlmWikiKnowledgeBases,
|
||||
@@ -66,10 +70,26 @@ function SpaceIcon({ item }: { item: LlmWikiKnowledgeBase }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantSpaceIcon({ item }: { item: AssistantKnowledgeBase }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-md",
|
||||
item.is_global ? "bg-amber-500/10 text-amber-600 dark:text-amber-400" : "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||
)}
|
||||
>
|
||||
<Database className="size-3.5" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectionContent({
|
||||
rows,
|
||||
assistantRows,
|
||||
value,
|
||||
assistantValue,
|
||||
onChange,
|
||||
onAssistantChange,
|
||||
search,
|
||||
onSearchChange,
|
||||
loading,
|
||||
@@ -79,8 +99,11 @@ function SelectionContent({
|
||||
readOnly,
|
||||
}: {
|
||||
rows: LlmWikiKnowledgeBase[];
|
||||
assistantRows: AssistantKnowledgeBase[];
|
||||
value: string[];
|
||||
assistantValue: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
onAssistantChange: (value: string[]) => void;
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
loading: boolean;
|
||||
@@ -90,6 +113,7 @@ function SelectionContent({
|
||||
readOnly: boolean;
|
||||
}) {
|
||||
const selected = useMemo(() => new Set(value), [value]);
|
||||
const selectedAssistant = useMemo(() => new Set(assistantValue), [assistantValue]);
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLocaleLowerCase();
|
||||
if (!query) return rows;
|
||||
@@ -97,11 +121,28 @@ function SelectionContent({
|
||||
`${item.name} ${item.description}`.toLocaleLowerCase().includes(query),
|
||||
);
|
||||
}, [rows, search]);
|
||||
const filteredAssistant = useMemo(() => {
|
||||
const query = search.trim().toLocaleLowerCase();
|
||||
if (!query) return assistantRows;
|
||||
return assistantRows.filter((item) =>
|
||||
`${item.name} ${item.description ?? ""}`.toLocaleLowerCase().includes(query),
|
||||
);
|
||||
}, [assistantRows, search]);
|
||||
const groups = useMemo(() => groupRows(filtered), [filtered]);
|
||||
const toggle = (id: string) => {
|
||||
if (readOnly) return;
|
||||
onChange(selected.has(id) ? value.filter((item) => item !== id) : [...value, id]);
|
||||
};
|
||||
const toggleAssistant = (id: string) => {
|
||||
if (readOnly) return;
|
||||
onAssistantChange(
|
||||
selectedAssistant.has(id)
|
||||
? assistantValue.filter((item) => item !== id)
|
||||
: [...assistantValue, id],
|
||||
);
|
||||
};
|
||||
const selectedCount = value.length + assistantValue.length;
|
||||
const hasRows = groups.length > 0 || filteredAssistant.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -127,7 +168,7 @@ function SelectionContent({
|
||||
<div className="m-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
|
||||
{error instanceof Error ? error.message : "知识空间读取失败"}
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
) : !hasRows ? (
|
||||
<div className="flex h-28 items-center justify-center px-4 text-center text-xs text-muted-foreground">
|
||||
{search
|
||||
? "没有匹配的知识空间"
|
||||
@@ -136,91 +177,181 @@ function SelectionContent({
|
||||
: "当前账号暂无可用知识空间"}
|
||||
</div>
|
||||
) : (
|
||||
groups.map((group, groupIndex) => (
|
||||
<div key={group.key}>
|
||||
{groupIndex > 0 && (
|
||||
variant === "popover"
|
||||
? <DropdownMenuSeparator className="my-1.5" />
|
||||
: <div className="my-1.5 border-t" />
|
||||
)}
|
||||
{variant === "popover" ? (
|
||||
<DropdownMenuLabel className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
|
||||
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
|
||||
</DropdownMenuLabel>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
|
||||
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
|
||||
</div>
|
||||
)}
|
||||
{group.rows.map((item) => {
|
||||
const rowContent = (
|
||||
<>
|
||||
<SpaceIcon item={item} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium">{item.name}</div>
|
||||
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
|
||||
{item.type === "faq" ? "FAQ" : "文档库"} · {item.knowledge_count ?? 0} 项知识
|
||||
</div>
|
||||
</div>
|
||||
{item.publication_status === "published" && (
|
||||
<Badge variant="outline" className="h-4 px-1 text-[9px]">公共</Badge>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return variant === "popover" ? (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={item.id}
|
||||
checked={selected.has(item.id)}
|
||||
disabled={readOnly}
|
||||
onCheckedChange={() => toggle(item.id)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
className={cn(
|
||||
"my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7",
|
||||
selected.has(item.id) && "bg-primary/8 focus:bg-primary/12",
|
||||
)}
|
||||
>
|
||||
{rowContent}
|
||||
</DropdownMenuCheckboxItem>
|
||||
<>
|
||||
{groups.map((group, groupIndex) => (
|
||||
<div key={group.key}>
|
||||
{groupIndex > 0 && (
|
||||
variant === "popover"
|
||||
? <DropdownMenuSeparator className="my-1.5" />
|
||||
: <div className="my-1.5 border-t" />
|
||||
)}
|
||||
{variant === "popover" ? (
|
||||
<DropdownMenuLabel className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
|
||||
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
|
||||
</DropdownMenuLabel>
|
||||
) : (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={selected.has(item.id)}
|
||||
disabled={readOnly}
|
||||
onClick={() => toggle(item.id)}
|
||||
className={cn(
|
||||
"my-0.5 flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary",
|
||||
selected.has(item.id) && "bg-primary/8",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"flex size-4 shrink-0 items-center justify-center rounded border",
|
||||
selected.has(item.id) && "border-primary bg-primary text-primary-foreground",
|
||||
)}>
|
||||
{selected.has(item.id) && <Check className="size-3" />}
|
||||
</span>
|
||||
{rowContent}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
|
||||
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
|
||||
</div>
|
||||
)}
|
||||
{group.rows.map((item) => {
|
||||
const rowContent = (
|
||||
<>
|
||||
<SpaceIcon item={item} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium">{item.name}</div>
|
||||
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
|
||||
{item.type === "faq" ? "FAQ" : "文档库"} · {item.knowledge_count ?? 0} 项知识
|
||||
</div>
|
||||
</div>
|
||||
{item.publication_status === "published" && (
|
||||
<Badge variant="outline" className="h-4 px-1 text-[9px]">公共</Badge>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return variant === "popover" ? (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={item.id}
|
||||
checked={selected.has(item.id)}
|
||||
disabled={readOnly}
|
||||
onCheckedChange={() => toggle(item.id)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
className={cn(
|
||||
"my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7",
|
||||
selected.has(item.id) && "bg-primary/8 focus:bg-primary/12",
|
||||
)}
|
||||
>
|
||||
{rowContent}
|
||||
</DropdownMenuCheckboxItem>
|
||||
) : (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={selected.has(item.id)}
|
||||
disabled={readOnly}
|
||||
onClick={() => toggle(item.id)}
|
||||
className={cn(
|
||||
"my-0.5 flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary",
|
||||
selected.has(item.id) && "bg-primary/8",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"flex size-4 shrink-0 items-center justify-center rounded border",
|
||||
selected.has(item.id) && "border-primary bg-primary text-primary-foreground",
|
||||
)}>
|
||||
{selected.has(item.id) && <Check className="size-3" />}
|
||||
</span>
|
||||
{rowContent}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{filteredAssistant.length > 0 && (
|
||||
<div>
|
||||
{(groups.length > 0) && (
|
||||
variant === "popover"
|
||||
? <DropdownMenuSeparator className="my-1.5" />
|
||||
: <div className="my-1.5 border-t" />
|
||||
)}
|
||||
{variant === "popover" ? (
|
||||
<DropdownMenuLabel className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
|
||||
<Database className="size-3" />助手知识库
|
||||
</DropdownMenuLabel>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
|
||||
<Database className="size-3" />助手知识库
|
||||
</div>
|
||||
)}
|
||||
{filteredAssistant.map((item) => {
|
||||
const rowContent = (
|
||||
<>
|
||||
<AssistantSpaceIcon item={item} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium">{item.name}</div>
|
||||
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
|
||||
Wiki · {item.page_count ?? 0} 页 · {item.entity_count ?? 0} 实体
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="h-4 px-1 text-[9px]">
|
||||
{item.is_global ? "总库" : "助手"}
|
||||
</Badge>
|
||||
</>
|
||||
);
|
||||
return variant === "popover" ? (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={`assistant:${item.id}`}
|
||||
checked={selectedAssistant.has(item.id)}
|
||||
disabled={readOnly}
|
||||
onCheckedChange={() => toggleAssistant(item.id)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
className={cn(
|
||||
"my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7",
|
||||
selectedAssistant.has(item.id) && "bg-primary/8 focus:bg-primary/12",
|
||||
)}
|
||||
>
|
||||
{rowContent}
|
||||
</DropdownMenuCheckboxItem>
|
||||
) : (
|
||||
<button
|
||||
key={`assistant:${item.id}`}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={selectedAssistant.has(item.id)}
|
||||
disabled={readOnly}
|
||||
onClick={() => toggleAssistant(item.id)}
|
||||
className={cn(
|
||||
"my-0.5 flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary",
|
||||
selectedAssistant.has(item.id) && "bg-primary/8",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"flex size-4 shrink-0 items-center justify-center rounded border",
|
||||
selectedAssistant.has(item.id) && "border-primary bg-primary text-primary-foreground",
|
||||
)}>
|
||||
{selectedAssistant.has(item.id) && <Check className="size-3" />}
|
||||
</span>
|
||||
{rowContent}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{readOnly ? `本对话已选 ${value.length} 个` : `已选择 ${value.length} 个`}
|
||||
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
|
||||
</span>
|
||||
{readOnly ? (
|
||||
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange(filtered.map((item) => item.id))}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => {
|
||||
onChange(filtered.map((item) => item.id));
|
||||
onAssistantChange(filteredAssistant.map((item) => item.id));
|
||||
}}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange([])}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => {
|
||||
onChange([]);
|
||||
onAssistantChange([]);
|
||||
}}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
@@ -232,7 +363,9 @@ function SelectionContent({
|
||||
|
||||
export function LlmWikiKnowledgeSelector({
|
||||
value,
|
||||
assistantValue = [],
|
||||
onChange,
|
||||
onAssistantChange = () => undefined,
|
||||
source = "all",
|
||||
agentId,
|
||||
autoSelectAll = false,
|
||||
@@ -243,7 +376,9 @@ export function LlmWikiKnowledgeSelector({
|
||||
variant = "popover",
|
||||
}: {
|
||||
value: string[];
|
||||
assistantValue?: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
onAssistantChange?: (value: string[]) => void;
|
||||
source?: SelectorSource;
|
||||
agentId?: string | null;
|
||||
autoSelectAll?: boolean;
|
||||
@@ -257,28 +392,39 @@ export function LlmWikiKnowledgeSelector({
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const weknoraEnabled = runtime.data?.provider === "weknora";
|
||||
const all = useLlmWikiKnowledgeBases(
|
||||
"all",
|
||||
runtime.data?.provider === "weknora",
|
||||
weknoraEnabled,
|
||||
);
|
||||
const agent = useAgentLlmWikiKnowledgeBases(
|
||||
agentId,
|
||||
runtime.data?.provider === "weknora" && source === "agent" && !!agentId,
|
||||
weknoraEnabled && source === "agent" && !!agentId,
|
||||
);
|
||||
const rows = all.data ?? [];
|
||||
const assistantBases = useAssistantKnowledgeBases();
|
||||
const rows = weknoraEnabled ? (all.data ?? []) : [];
|
||||
const assistantRows = assistantBases.data ?? [];
|
||||
const defaultIds = useMemo(() => {
|
||||
if (source !== "agent") {
|
||||
return rows.map((item) => item.id);
|
||||
}
|
||||
return (agent.data ?? []).map((item) => item.id);
|
||||
}, [agent.data, rows, source]);
|
||||
const loading = all.isLoading || (source === "agent" && !!agentId && agent.isLoading);
|
||||
const error = all.error ?? (source === "agent" ? agent.error : null);
|
||||
const loading = runtime.isLoading || (weknoraEnabled && all.isLoading) || assistantBases.isLoading || (source === "agent" && !!agentId && agent.isLoading);
|
||||
const error = (weknoraEnabled ? all.error ?? (source === "agent" ? agent.error : null) : null) ?? assistantBases.error;
|
||||
const availableIds = useMemo(() => new Set(rows.map((item) => item.id)), [rows]);
|
||||
const assistantAvailableIds = useMemo(
|
||||
() => new Set(assistantRows.map((item) => item.id)),
|
||||
[assistantRows],
|
||||
);
|
||||
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
|
||||
const initialized = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtime.isLoading && !weknoraEnabled) {
|
||||
if (value.length > 0) onChange([]);
|
||||
return;
|
||||
}
|
||||
if (!all.data || (source === "agent" && agentId && !agent.data) || initialized.current === initializationKey) return;
|
||||
initialized.current = initializationKey;
|
||||
const valid = value.filter((id) => availableIds.has(id));
|
||||
@@ -288,19 +434,30 @@ export function LlmWikiKnowledgeSelector({
|
||||
if (next.length !== value.length || next.some((id, index) => id !== value[index])) {
|
||||
onChange(next);
|
||||
}
|
||||
}, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, source, value]);
|
||||
}, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, runtime.isLoading, source, value, weknoraEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!assistantBases.data) return;
|
||||
const valid = assistantValue.filter((id) => assistantAvailableIds.has(id));
|
||||
if (valid.length !== assistantValue.length || valid.some((id, index) => id !== assistantValue[index])) {
|
||||
onAssistantChange(valid);
|
||||
}
|
||||
}, [assistantAvailableIds, assistantBases.data, assistantValue, onAssistantChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setSearch("");
|
||||
}, [open]);
|
||||
|
||||
if (runtime.isError || runtime.data?.provider !== "weknora") return null;
|
||||
if ((runtime.isError || !weknoraEnabled) && !assistantBases.isLoading && assistantRows.length === 0) return null;
|
||||
|
||||
const content = (
|
||||
<SelectionContent
|
||||
rows={rows}
|
||||
assistantRows={assistantRows}
|
||||
value={value}
|
||||
assistantValue={assistantValue}
|
||||
onChange={onChange}
|
||||
onAssistantChange={onAssistantChange}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
loading={loading}
|
||||
@@ -323,7 +480,7 @@ export function LlmWikiKnowledgeSelector({
|
||||
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0">{value.length} 个</Badge>
|
||||
<Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge>
|
||||
</div>
|
||||
{content}
|
||||
</div>
|
||||
@@ -333,10 +490,15 @@ export function LlmWikiKnowledgeSelector({
|
||||
const selectedNames = value
|
||||
.map((id) => rows.find((item) => item.id === id)?.name)
|
||||
.filter((name): name is string => !!name);
|
||||
const selectedLabel = selectedNames.length > 0
|
||||
? selectedNames.join("、")
|
||||
: value.length > 0
|
||||
? `已选 ${value.length} 个知识空间`
|
||||
const selectedAssistantNames = assistantValue
|
||||
.map((id) => assistantRows.find((item) => item.id === id)?.name)
|
||||
.filter((name): name is string => !!name);
|
||||
const allSelectedNames = [...selectedNames, ...selectedAssistantNames];
|
||||
const selectedCount = value.length + assistantValue.length;
|
||||
const selectedLabel = allSelectedNames.length > 0
|
||||
? allSelectedNames.join("、")
|
||||
: selectedCount > 0
|
||||
? `已选 ${selectedCount} 个知识空间`
|
||||
: label;
|
||||
|
||||
return (
|
||||
@@ -348,20 +510,20 @@ export function LlmWikiKnowledgeSelector({
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 max-w-52 gap-1.5 rounded-full border-0 bg-transparent px-2.5 text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground",
|
||||
value.length > 0 && "bg-primary/8 text-primary hover:bg-primary/12 hover:text-primary",
|
||||
selectedCount > 0 && "bg-primary/8 text-primary hover:bg-primary/12 hover:text-primary",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled || loading}
|
||||
title={value.length > 0 ? selectedLabel : undefined}
|
||||
title={selectedCount > 0 ? selectedLabel : undefined}
|
||||
>
|
||||
{loading ? <Loader2 className="size-3.5 animate-spin" /> : <Database className="size-3.5" />}
|
||||
<span className="truncate text-xs font-normal">{selectedLabel}</span>
|
||||
{value.length > 0 && (
|
||||
{selectedCount > 0 && (
|
||||
<span className="flex size-4 items-center justify-center rounded-full bg-primary text-[9px] font-semibold text-primary-foreground">
|
||||
{value.length}
|
||||
{selectedCount}
|
||||
</span>
|
||||
)}
|
||||
{value.length > 0 && value.length === rows.length && <Check className="size-3" />}
|
||||
{selectedCount > 0 && selectedCount === rows.length + assistantRows.length && <Check className="size-3" />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={8} className="w-80 overflow-hidden rounded-xl p-0 shadow-xl">
|
||||
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
useViewMode,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import type { SkillKnowledgeBinding } from "@/strategy-components/api/skill-knowledge";
|
||||
import { formatDateTime } from "@/core/utils/datetime";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
@@ -601,6 +602,7 @@ export function SkillRow({
|
||||
isAdmin,
|
||||
layout = "row",
|
||||
onPublisherClick,
|
||||
knowledgeBindings = [],
|
||||
}: {
|
||||
skill: Skill;
|
||||
currentUserId: string | null;
|
||||
@@ -610,6 +612,8 @@ export function SkillRow({
|
||||
/** Click the 发布者 line to filter the list to that publisher. Receives the
|
||||
* owner_user_id + its desensitized display label. */
|
||||
onPublisherClick?: (publisher: { id: string; label: string }) => void;
|
||||
/** Admin list can inject its single batched binding query here (never N+1). */
|
||||
knowledgeBindings?: SkillKnowledgeBinding[];
|
||||
}) {
|
||||
const isCard = layout === "card";
|
||||
const { t } = useI18n();
|
||||
@@ -858,6 +862,20 @@ export function SkillRow({
|
||||
常驻
|
||||
</Badge>
|
||||
)}
|
||||
{knowledgeBindings.slice(0, 3).map((binding) => (
|
||||
<Badge
|
||||
key={binding.id}
|
||||
variant="outline"
|
||||
className="border-indigo-500/30 text-indigo-700 dark:text-indigo-300 text-xs"
|
||||
title={`${binding.target_name} · ${binding.status}${binding.remote_business_key ? ` · ${binding.remote_business_key}` : ""}`}
|
||||
>
|
||||
知识库:{binding.target_name || binding.target_id}
|
||||
{binding.status === "stale" ? "(待更新)" : ""}
|
||||
</Badge>
|
||||
))}
|
||||
{knowledgeBindings.length > 3 && (
|
||||
<Badge variant="secondary" className="text-xs">+{knowledgeBindings.length - 3}</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { Database, Loader2, RefreshCw, Search, Sparkles } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useAllSkillsAdmin } from "@/core/skills/hooks";
|
||||
import { useLlmWikiKnowledgeBases, useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||||
import {
|
||||
type SkillKnowledgeTarget,
|
||||
useSkillKnowledgeActions,
|
||||
useSkillKnowledgeBindings,
|
||||
useSkillKnowledgeJobs,
|
||||
} from "@/strategy-components/api/skill-knowledge";
|
||||
|
||||
const PHASE_LABELS: Record<string, string> = {
|
||||
scanning: "扫描知识文件",
|
||||
parsing: "解析资料",
|
||||
extracting: "抽取实体关系",
|
||||
awaiting_review: "等待审核",
|
||||
merging: "合并实体关系",
|
||||
writing_wiki: "写入 Wiki",
|
||||
activating: "激活新版本",
|
||||
completed: "完成",
|
||||
};
|
||||
|
||||
export function SkillKnowledgePanel() {
|
||||
const { skills, isLoading } = useAllSkillsAdmin(true);
|
||||
const liveSkills = useMemo(() => skills.filter((skill) => (skill.state ?? "active") === "active"), [skills]);
|
||||
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
||||
const [skillQuery, setSkillQuery] = useState("");
|
||||
const [selectedTargetKey, setSelectedTargetKey] = useState<string | null>(null);
|
||||
const [reviewMode, setReviewMode] = useState<"auto_high_confidence" | "off">("auto_high_confidence");
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const knowledgeBases = useLlmWikiKnowledgeBases("all", runtime.data?.provider === "weknora");
|
||||
const bindings = useSkillKnowledgeBindings();
|
||||
const jobs = useSkillKnowledgeJobs();
|
||||
const actions = useSkillKnowledgeActions();
|
||||
|
||||
const targets = useMemo<SkillKnowledgeTarget[]>(() => {
|
||||
return (knowledgeBases.data ?? [])
|
||||
.filter((kb) => {
|
||||
const strategy = kb.indexing_strategy ?? {};
|
||||
const capabilities = kb.capabilities ?? {};
|
||||
const wikiEnabled =
|
||||
kb.type === "wiki" ||
|
||||
kb.wiki_index_enabled === true ||
|
||||
strategy.wiki_enabled === true ||
|
||||
capabilities.wiki_enabled === true ||
|
||||
(kb.wiki_config && Object.keys(kb.wiki_config).length > 0);
|
||||
return kb.can_write && kb.remote_status !== "missing" && !kb.is_conversation_deposit && kb.type !== "faq" && wikiEnabled;
|
||||
})
|
||||
.map((kb) => ({
|
||||
target_type: "weknora",
|
||||
target_mode: "wiki",
|
||||
target_id: kb.id,
|
||||
target_name: kb.name,
|
||||
remote_business_key: `weknora:kb:${kb.id}`,
|
||||
}));
|
||||
}, [knowledgeBases.data]);
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
const needle = skillQuery.trim().toLowerCase();
|
||||
if (!needle) return liveSkills;
|
||||
return liveSkills.filter((skill) => {
|
||||
const tagText = (skill.tags ?? []).map((tag) => tag.name).join(" ");
|
||||
const haystack = [
|
||||
skill.name,
|
||||
skill.name_zh,
|
||||
skill.description,
|
||||
skill.category,
|
||||
tagText,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.toLowerCase();
|
||||
return haystack.includes(needle);
|
||||
});
|
||||
}, [liveSkills, skillQuery]);
|
||||
|
||||
const filteredSkillNames = useMemo(() => filteredSkills.map((skill) => skill.name), [filteredSkills]);
|
||||
const allFilteredSelected = filteredSkillNames.length > 0 && filteredSkillNames.every((name) => selectedSkills.includes(name));
|
||||
|
||||
const toggleFilteredSkills = () => {
|
||||
if (!filteredSkillNames.length) return;
|
||||
setSelectedSkills((current) => {
|
||||
if (allFilteredSelected) {
|
||||
const filtered = new Set(filteredSkillNames);
|
||||
return current.filter((name) => !filtered.has(name));
|
||||
}
|
||||
return [...current, ...filteredSkillNames.filter((name) => !current.includes(name))];
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTargetKey && !targets.some((target) => `${target.target_type}:${target.target_id}` === selectedTargetKey)) {
|
||||
setSelectedTargetKey(null);
|
||||
}
|
||||
}, [selectedTargetKey, targets]);
|
||||
|
||||
const toggle = (values: string[], value: string, setter: (next: string[]) => void) =>
|
||||
setter(values.includes(value) ? values.filter((item) => item !== value) : [...values, value]);
|
||||
|
||||
const submit = async () => {
|
||||
const selectedTargets = targets.filter((target) =>
|
||||
selectedTargetKey === `${target.target_type}:${target.target_id}`,
|
||||
);
|
||||
if (!selectedSkills.length || !selectedTargets.length) {
|
||||
toast.error("请至少选择一个技能和一个目标知识库");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const job = await actions.createJob.mutateAsync({
|
||||
skill_names: selectedSkills,
|
||||
targets: selectedTargets,
|
||||
review_mode: reviewMode,
|
||||
});
|
||||
toast.success(`已创建 ${job.total_count} 个归纳项`);
|
||||
setSelectedSkills([]);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid size-full min-h-0 grid-cols-[minmax(320px,0.9fr)_minmax(420px,1.1fr)] gap-4 overflow-hidden p-6">
|
||||
<section className="flex min-h-0 flex-col rounded-lg border bg-card">
|
||||
<div className="border-b p-4">
|
||||
<h2 className="font-semibold">技能到知识库管理</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">一次选择多个技能归纳到一个 WeKnora Wiki 知识库;代码与脚本不会被读取。</p>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-5 p-4">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">1. 选择技能({selectedSkills.length})</span>
|
||||
<Button variant="ghost" size="sm" onClick={toggleFilteredSkills}>
|
||||
{allFilteredSelected ? "清空当前结果" : "选择当前结果"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative mb-2">
|
||||
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={skillQuery}
|
||||
onChange={(event) => setSkillQuery(event.target.value)}
|
||||
placeholder="按技能中文名、英文名、描述、分类或标签搜索"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-60 space-y-1 overflow-y-auto rounded-md border p-2">
|
||||
{isLoading ? <Loader2 className="m-4 size-5 animate-spin" /> : filteredSkills.map((skill) => (
|
||||
<label key={skill.name} className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-muted">
|
||||
<Checkbox checked={selectedSkills.includes(skill.name)} onCheckedChange={() => toggle(selectedSkills, skill.name, setSelectedSkills)} />
|
||||
<span className="truncate text-sm">{skill.name_zh || skill.name}</span>
|
||||
<span className="ml-auto truncate text-xs text-muted-foreground">{skill.name}</span>
|
||||
</label>
|
||||
))}
|
||||
{!isLoading && !filteredSkills.length && (
|
||||
<p className="p-3 text-xs text-muted-foreground">没有匹配的技能,请换个关键词。</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
当前显示 {filteredSkills.length}/{liveSkills.length} 个技能。
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">2. 选择目标知识库({selectedTargetKey ? 1 : 0})</span>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-md border p-2">
|
||||
{targets.map((target) => {
|
||||
const key = `${target.target_type}:${target.target_id}`;
|
||||
return (
|
||||
<label key={key} className="flex cursor-pointer items-center gap-2 rounded px-2 py-2 hover:bg-muted">
|
||||
<Checkbox
|
||||
checked={selectedTargetKey === key}
|
||||
onCheckedChange={() => setSelectedTargetKey((current) => (current === key ? null : key))}
|
||||
/>
|
||||
<Database className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm">{target.target_name}</span>
|
||||
<Badge variant="outline" className="ml-auto text-[10px]">WeKnora Wiki</Badge>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{(runtime.isLoading || knowledgeBases.isLoading) && <Loader2 className="m-4 size-5 animate-spin" />}
|
||||
{!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && (
|
||||
<p className="p-3 text-xs text-destructive">
|
||||
读取 WeKnora 普通知识库失败:
|
||||
{knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)}
|
||||
</p>
|
||||
)}
|
||||
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
|
||||
<p className="p-3 text-xs text-muted-foreground">
|
||||
{runtime.data?.provider !== "weknora"
|
||||
? "当前未配置 WeKnora 普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
|
||||
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium">审核策略</span>
|
||||
<Select value={reviewMode} onValueChange={(value) => setReviewMode(value as typeof reviewMode)}>
|
||||
<SelectTrigger className="w-52"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto_high_confidence">高置信自动通过</SelectItem>
|
||||
<SelectItem value="off">不阻塞写入</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button className="w-full" disabled={actions.createJob.isPending} onClick={() => void submit()}>
|
||||
{actions.createJob.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
|
||||
开始归纳({selectedSkills.length * (selectedTargetKey ? 1 : 0)} 项)
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-0 flex-col gap-4 overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col rounded-lg border bg-card">
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div><h2 className="font-semibold">归纳任务</h2><p className="text-xs text-muted-foreground">后台执行、失败可单项重试</p></div>
|
||||
<Button size="icon" variant="ghost" onClick={() => void jobs.refetch()}><RefreshCw className="size-4" /></Button>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-3 p-4">
|
||||
{(jobs.data ?? []).map((job) => {
|
||||
const done = job.success_count + job.partial_failed_count + job.failed_count;
|
||||
const progress = job.total_count ? Math.round((done / job.total_count) * 100) : 0;
|
||||
return (
|
||||
<div key={job.id} className="rounded-md border p-3">
|
||||
<div className="flex items-center gap-2 text-sm"><Badge variant={job.status === "completed" ? "default" : "secondary"}>{job.status}</Badge><span>{done}/{job.total_count} 项</span><span className="ml-auto text-xs text-muted-foreground">{new Date(job.created_at).toLocaleString()}</span></div>
|
||||
<Progress className="my-2" value={progress} />
|
||||
{job.items?.map((item) => (
|
||||
<div key={item.id} className="mt-2 flex items-center gap-2 border-t pt-2 text-xs">
|
||||
<span className="max-w-36 truncate font-medium">{item.skill_name}</span>
|
||||
<span className="text-muted-foreground">{PHASE_LABELS[item.phase] || item.phase}</span>
|
||||
<span className="ml-auto">{item.progress}%</span>
|
||||
{item.status === "failed" && <Button size="sm" variant="outline" onClick={() => actions.retry.mutate(item.id)}>重试</Button>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!jobs.data?.length && <p className="py-10 text-center text-sm text-muted-foreground">暂无归纳任务</p>}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-auto rounded-lg border bg-card p-3 text-xs">
|
||||
<div className="mb-2 text-muted-foreground">当前有效绑定:{bindings.data?.length ?? 0};同名同类型实体按来源贡献合并。</div>
|
||||
<div className="space-y-2">{bindings.data?.map((binding) => <div key={binding.id} className="flex flex-wrap items-center gap-2 rounded border p-2"><span className="max-w-32 truncate font-medium">{binding.skill_name}</span><span className="max-w-40 truncate text-muted-foreground">→ {binding.target_name}</span><Badge variant="outline">{binding.status}</Badge><code className="max-w-40 truncate text-[10px] text-muted-foreground">{binding.remote_business_key}</code><Button size="sm" variant="outline" className="ml-auto" disabled={actions.resync.isPending} onClick={() => actions.resync.mutate(binding.id)}>重归纳</Button><Button size="sm" variant="ghost" onClick={() => { if (window.confirm("解除关联并保留目标知识内容?")) actions.detach.mutate({ id: binding.id, deleteContributions: false }); }}>解绑并保留</Button><Button size="sm" variant="destructive" onClick={() => { if (window.confirm("解除关联并删除此技能贡献?共享知识仍会保留其他来源。")) actions.detach.mutate({ id: binding.id, deleteContributions: true }); }}>解绑并删除贡献</Button></div>)}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { BookOpen, BotIcon, BrainIcon, FileSearch, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, FileSearch, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
||||
|
||||
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
@@ -65,6 +65,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
path: "/page/workspace/chats",
|
||||
match: (pathname) => pathname === "/page/workspace/chats",
|
||||
},
|
||||
{
|
||||
id: "workspace-library",
|
||||
label: "资料库",
|
||||
icon: Library,
|
||||
path: "/page/workspace/library",
|
||||
match: (pathname) => pathname === "/page/workspace/library",
|
||||
},
|
||||
{
|
||||
id: "workspace-agents",
|
||||
label: "智能体",
|
||||
|
||||
@@ -254,6 +254,14 @@ export function WorkspaceNavMenu() {
|
||||
技能管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/skills?tab=knowledge")}
|
||||
>
|
||||
<DatabaseZapIcon />
|
||||
技能到知识库管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/models")}
|
||||
|
||||
Reference in new issue
Block a user