Merge branch 'ji' into master
This commit is contained in:
commit
49d9ced04f
84 files changed
+13258
-662
No files matched your search
@@ -3,11 +3,13 @@
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DatabaseZap,
|
||||
Replace,
|
||||
SearchIcon,
|
||||
SparklesIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -18,17 +20,32 @@ import {
|
||||
WorkspaceHeader,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { SkillRow } from "@/components/workspace/settings/skill-settings-page";
|
||||
import { SkillKnowledgePanel } from "@/components/workspace/skills/SkillKnowledgePanel";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useAllSkillsAdmin } from "@/core/skills/hooks";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { useSkillKnowledgeBindings } from "@/strategy-components/api/skill-knowledge";
|
||||
import { SkillAddressesPanel } from "./SkillAddressesPage";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export default function AdminSkillsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const { skills, isLoading, error } = useAllSkillsAdmin(true);
|
||||
const knowledgeBindings = useSkillKnowledgeBindings();
|
||||
const activeTab = useMemo(() => {
|
||||
const tab = searchParams.get("tab");
|
||||
return tab === "addresses" || tab === "knowledge" ? tab : "skills";
|
||||
}, [searchParams]);
|
||||
|
||||
const changeTab = (value: string) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (value === "skills") next.delete("tab");
|
||||
else next.set("tab", value);
|
||||
setSearchParams(next, { replace: true });
|
||||
};
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
@@ -56,7 +73,8 @@ export default function AdminSkillsPage() {
|
||||
<WorkspaceHeader />
|
||||
<WorkspaceBody>
|
||||
<Tabs
|
||||
defaultValue="skills"
|
||||
value={activeTab}
|
||||
onValueChange={changeTab}
|
||||
className="flex size-full min-h-0 flex-col gap-0"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4 border-b px-6 pt-4">
|
||||
@@ -71,6 +89,10 @@ export default function AdminSkillsPage() {
|
||||
<Replace />
|
||||
技能地址管理
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="knowledge">
|
||||
<DatabaseZap />
|
||||
技能到知识库管理
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,6 +148,9 @@ export default function AdminSkillsPage() {
|
||||
skill={skill}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin
|
||||
knowledgeBindings={(knowledgeBindings.data ?? []).filter(
|
||||
(binding) => binding.skill_name === skill.name,
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -164,6 +189,12 @@ export default function AdminSkillsPage() {
|
||||
>
|
||||
<SkillAddressesPanel />
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="knowledge"
|
||||
className="mt-0 min-h-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<SkillKnowledgePanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</WorkspaceBody>
|
||||
</WorkspaceContainer>
|
||||
|
||||
@@ -118,6 +118,26 @@ function readPendingKnowledgeSelection(agentId: string | undefined): string[] |
|
||||
}
|
||||
}
|
||||
|
||||
function readPendingAssistantKnowledgeSelection(agentId: string | undefined): string[] | null {
|
||||
if (typeof window === "undefined" || !agentId) return null;
|
||||
try {
|
||||
const raw = sessionStorage.getItem("pendingAgentMessage");
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as {
|
||||
agentId?: string;
|
||||
assistantKnowledgeBaseIds?: unknown;
|
||||
};
|
||||
if (parsed.agentId !== agentId || !Array.isArray(parsed.assistantKnowledgeBaseIds)) {
|
||||
return null;
|
||||
}
|
||||
return parsed.assistantKnowledgeBaseIds
|
||||
.map((item) => String(item).trim())
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyForHiddenContext(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
@@ -334,11 +354,17 @@ export default function AgentChatPage({
|
||||
const pendingKnowledgeSelection = useRef(
|
||||
readPendingKnowledgeSelection(agent_id),
|
||||
);
|
||||
const pendingAssistantKnowledgeSelection = useRef(
|
||||
readPendingAssistantKnowledgeSelection(agent_id),
|
||||
);
|
||||
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>(
|
||||
() => pendingKnowledgeSelection.current ?? [],
|
||||
);
|
||||
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>(
|
||||
() => pendingAssistantKnowledgeSelection.current ?? [],
|
||||
);
|
||||
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(
|
||||
() => pendingKnowledgeSelection.current !== null,
|
||||
() => pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null,
|
||||
);
|
||||
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
|
||||
const knowledgeSelectionThread = useRef<string | null>(null);
|
||||
@@ -349,6 +375,10 @@ export default function AgentChatPage({
|
||||
setSelectedKnowledgeBaseIds(ids);
|
||||
setKnowledgeSelectionReady(true);
|
||||
}, []);
|
||||
const changeAssistantKnowledgeSelection = useCallback((ids: string[]) => {
|
||||
setSelectedAssistantKnowledgeBaseIds(ids);
|
||||
setKnowledgeSelectionReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
activeThreadIdRef.current = threadId;
|
||||
@@ -358,17 +388,23 @@ export default function AgentChatPage({
|
||||
if (knowledgeSelectionAgent.current === agent_id) return;
|
||||
knowledgeSelectionAgent.current = agent_id;
|
||||
const pending = readPendingKnowledgeSelection(agent_id);
|
||||
const pendingAssistant = readPendingAssistantKnowledgeSelection(agent_id);
|
||||
pendingKnowledgeSelection.current = pending;
|
||||
pendingAssistantKnowledgeSelection.current = pendingAssistant;
|
||||
knowledgeSelectionThread.current = null;
|
||||
setSelectedKnowledgeBaseIds(pending ?? []);
|
||||
setKnowledgeSelectionReady(pending !== null);
|
||||
setSelectedAssistantKnowledgeBaseIds(pendingAssistant ?? []);
|
||||
setKnowledgeSelectionReady(pending !== null || pendingAssistant !== null);
|
||||
}, [agent_id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewThread) {
|
||||
if (knowledgeSelectionThread.current !== "__new__") {
|
||||
setSelectedKnowledgeBaseIds(pendingKnowledgeSelection.current ?? []);
|
||||
setKnowledgeSelectionReady(pendingKnowledgeSelection.current !== null);
|
||||
setSelectedAssistantKnowledgeBaseIds(pendingAssistantKnowledgeSelection.current ?? []);
|
||||
setKnowledgeSelectionReady(
|
||||
pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null,
|
||||
);
|
||||
}
|
||||
knowledgeSelectionThread.current = "__new__";
|
||||
setKnowledgeSelectionLoading(false);
|
||||
@@ -383,18 +419,29 @@ export default function AgentChatPage({
|
||||
if (knowledgeSelectionThread.current !== threadId) return;
|
||||
const metadata = record.metadata as Record<string, unknown> | undefined;
|
||||
const raw = metadata?.llmwiki_knowledge_base_ids;
|
||||
const assistantRaw = metadata?.assistant_knowledge_base_ids;
|
||||
const llmwikiIds = Array.isArray(raw)
|
||||
? raw.map((item) => String(item).trim()).filter(Boolean)
|
||||
: [];
|
||||
const assistantIds = Array.isArray(assistantRaw)
|
||||
? assistantRaw.map((item) => String(item).trim()).filter(Boolean)
|
||||
: [];
|
||||
if (Array.isArray(raw)) {
|
||||
setSelectedKnowledgeBaseIds(
|
||||
raw.map((item) => String(item).trim()).filter(Boolean),
|
||||
);
|
||||
setKnowledgeSelectionReady(true);
|
||||
setSelectedKnowledgeBaseIds(llmwikiIds);
|
||||
} else {
|
||||
setSelectedKnowledgeBaseIds([]);
|
||||
setKnowledgeSelectionReady(false);
|
||||
}
|
||||
if (Array.isArray(assistantRaw)) {
|
||||
setSelectedAssistantKnowledgeBaseIds(assistantIds);
|
||||
} else {
|
||||
setSelectedAssistantKnowledgeBaseIds([]);
|
||||
}
|
||||
setKnowledgeSelectionReady(Array.isArray(raw) || Array.isArray(assistantRaw));
|
||||
})
|
||||
.catch(() => {
|
||||
if (knowledgeSelectionThread.current === threadId) {
|
||||
setSelectedKnowledgeBaseIds([]);
|
||||
setSelectedAssistantKnowledgeBaseIds([]);
|
||||
setKnowledgeSelectionReady(false);
|
||||
}
|
||||
})
|
||||
@@ -417,10 +464,13 @@ export default function AgentChatPage({
|
||||
() => ({
|
||||
...(taskId ? taskThreadMetadata(taskContext) : {}),
|
||||
...(knowledgeSelectionReady
|
||||
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
|
||||
? {
|
||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
||||
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
[knowledgeSelectionReady, selectedKnowledgeBaseIds, taskContext, taskId],
|
||||
[knowledgeSelectionReady, selectedAssistantKnowledgeBaseIds, selectedKnowledgeBaseIds, taskContext, taskId],
|
||||
);
|
||||
// 任务工作区(taskBind):每次运行都带隐藏任务上下文(任务id/名称/内容),
|
||||
// 新建对话的消息历史里没有开场信息时模型也能拿到任务id(见 taskBindRunContext)。
|
||||
@@ -436,7 +486,10 @@ export default function AgentChatPage({
|
||||
...settings.context,
|
||||
agent_id,
|
||||
...(knowledgeSelectionReady
|
||||
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
|
||||
? {
|
||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
||||
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
|
||||
}
|
||||
: {}),
|
||||
...(taskBindRunContextValue ?? {}),
|
||||
},
|
||||
@@ -1021,9 +1074,13 @@ export default function AgentChatPage({
|
||||
source="agent"
|
||||
agentId={agent_id}
|
||||
value={selectedKnowledgeBaseIds}
|
||||
assistantValue={selectedAssistantKnowledgeBaseIds}
|
||||
onChange={changeKnowledgeSelection}
|
||||
onAssistantChange={changeAssistantKnowledgeSelection}
|
||||
autoSelectAll={
|
||||
isNewThread && pendingKnowledgeSelection.current === null
|
||||
isNewThread &&
|
||||
pendingKnowledgeSelection.current === null &&
|
||||
pendingAssistantKnowledgeSelection.current === null
|
||||
}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
||||
label="知识空间"
|
||||
@@ -1108,7 +1165,9 @@ export default function AgentChatPage({
|
||||
source="agent"
|
||||
agentId={agent_id}
|
||||
value={selectedKnowledgeBaseIds}
|
||||
assistantValue={selectedAssistantKnowledgeBaseIds}
|
||||
onChange={changeKnowledgeSelection}
|
||||
onAssistantChange={changeAssistantKnowledgeSelection}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
||||
readOnly
|
||||
label="知识空间"
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowDownWideNarrow,
|
||||
Bot,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
FileText,
|
||||
FileX2,
|
||||
Library,
|
||||
Loader2,
|
||||
MessagesSquare,
|
||||
MessageSquareShare,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
WorkspaceHeader,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import {
|
||||
downloadArtifactFile,
|
||||
downloadArtifactMarkdownAsWord,
|
||||
} from "@/core/artifacts/download";
|
||||
import {
|
||||
fetchArtifactLibrary,
|
||||
type ArtifactLibraryItem,
|
||||
type ArtifactLibraryKind,
|
||||
type ArtifactLibrarySource,
|
||||
} from "@/core/artifacts/library";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { bootstrapResumePath, pathOfThread } from "@/core/threads/utils";
|
||||
import { formatDateTime } from "@/core/utils/datetime";
|
||||
import {
|
||||
getFileExtensionDisplayName,
|
||||
getFileIcon,
|
||||
} from "@/core/utils/files";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const FILTERS: Array<{ value: ArtifactLibraryKind; label: string }> = [
|
||||
{ value: "all", label: "全部" },
|
||||
{ value: "document", label: "文档" },
|
||||
{ value: "spreadsheet", label: "表格" },
|
||||
{ value: "text_data", label: "文本与数据" },
|
||||
{ value: "archive", label: "压缩包" },
|
||||
{ value: "other", label: "其他" },
|
||||
];
|
||||
|
||||
const SOURCE_FILTERS: Array<{
|
||||
value: ArtifactLibrarySource;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "all", label: "全部来源" },
|
||||
{ value: "general_qa", label: "通用问答" },
|
||||
{ value: "agent", label: "智能体" },
|
||||
];
|
||||
|
||||
type DownloadFormat = "original" | "word";
|
||||
|
||||
function isMarkdownArtifact(name: string): boolean {
|
||||
return /\.(md|markdown)$/i.test(name);
|
||||
}
|
||||
|
||||
function artifactDownloadKey(
|
||||
item: ArtifactLibraryItem,
|
||||
format: DownloadFormat,
|
||||
): string {
|
||||
return `${item.threadId}:${item.path}:${format}`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
const index = Math.min(
|
||||
Math.floor(Math.log(bytes) / Math.log(1024)),
|
||||
units.length - 1,
|
||||
);
|
||||
const value = bytes / 1024 ** index;
|
||||
const digits = index === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 2;
|
||||
return `${value.toFixed(digits)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function sourceConversationPath(item: ArtifactLibraryItem): string {
|
||||
const basePath =
|
||||
item.threadType === "bootstrap"
|
||||
? bootstrapResumePath(item.threadId)
|
||||
: pathOfThread({
|
||||
thread_id: item.threadId,
|
||||
metadata: item.agentId ? { agent_id: item.agentId } : undefined,
|
||||
});
|
||||
const search = new URLSearchParams({ artifact: item.path });
|
||||
return `${basePath}?${search.toString()}`;
|
||||
}
|
||||
|
||||
function FileRow({
|
||||
item,
|
||||
downloading,
|
||||
onDownload,
|
||||
}: {
|
||||
item: ArtifactLibraryItem;
|
||||
downloading: DownloadFormat | null;
|
||||
onDownload: (item: ArtifactLibraryItem, format: DownloadFormat) => void;
|
||||
}) {
|
||||
const sourcePath = sourceConversationPath(item);
|
||||
const displayName = desensitize(item.name);
|
||||
const sourceTitle = desensitize(item.threadTitle || "新对话");
|
||||
const sourceLabel = item.sourceType === "agent" ? "智能体" : "通用问答";
|
||||
const sourceName =
|
||||
item.sourceType === "agent"
|
||||
? desensitize(item.agentName || item.agentId || "未知智能体")
|
||||
: sourceLabel;
|
||||
const SourceIcon = item.sourceType === "agent" ? Bot : MessagesSquare;
|
||||
const busy = downloading !== null;
|
||||
|
||||
return (
|
||||
<tr className="group border-b transition-colors hover:bg-muted/35">
|
||||
<td className="min-w-0 py-3 pr-4 pl-1">
|
||||
<Link
|
||||
href={sourcePath}
|
||||
className="flex min-w-0 items-center gap-3 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
title={`打开原对话并查看 ${displayName}`}
|
||||
>
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground/75">
|
||||
{getFileIcon(item.name, "size-5")}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-foreground group-hover:text-primary">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-xs text-muted-foreground lg:hidden">
|
||||
{getFileExtensionDisplayName(item.name)} · {sourceName} · 来自「
|
||||
{sourceTitle}」
|
||||
</span>
|
||||
<span className="mt-1 hidden text-xs text-muted-foreground lg:block">
|
||||
{getFileExtensionDisplayName(item.name)} 文件
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="hidden min-w-0 px-3 py-3 lg:table-cell">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<SourceIcon className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-foreground">
|
||||
{sourceName}
|
||||
</span>
|
||||
<Link
|
||||
href={sourcePath}
|
||||
className="block truncate text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||
title={`打开原对话:${sourceTitle}`}
|
||||
>
|
||||
{item.sourceType === "agent" ? "智能体 · " : ""}
|
||||
{sourceTitle}
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3 text-sm text-muted-foreground">
|
||||
{formatDateTime(item.modifiedAt)}
|
||||
</td>
|
||||
<td className="hidden whitespace-nowrap px-3 py-3 text-sm text-muted-foreground md:table-cell">
|
||||
{formatBytes(item.sizeBytes)}
|
||||
</td>
|
||||
<td className="py-3 pr-1 pl-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-8"
|
||||
disabled={busy}
|
||||
onClick={() => onDownload(item, "original")}
|
||||
title={`下载原文件:${displayName}`}
|
||||
aria-label={`下载原文件:${displayName}`}
|
||||
>
|
||||
{downloading === "original" ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
{isMarkdownArtifact(item.name) && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 gap-1 px-2 text-xs"
|
||||
disabled={busy}
|
||||
onClick={() => onDownload(item, "word")}
|
||||
title={`下载为 Word:${displayName}`}
|
||||
aria-label={`下载为 Word:${displayName}`}
|
||||
>
|
||||
{downloading === "word" ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<FileText className="size-4" />
|
||||
)}
|
||||
Word
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
asChild
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-8"
|
||||
title={`打开原对话:${sourceTitle}`}
|
||||
>
|
||||
<Link href={sourcePath} aria-label={`打开原对话:${sourceTitle}`}>
|
||||
<MessageSquareShare className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingRows() {
|
||||
return Array.from({ length: 5 }, (_, index) => (
|
||||
<tr className="border-b" key={index}>
|
||||
<td className="py-3 pr-4 pl-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 shrink-0" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-3 w-1/3" />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden px-3 lg:table-cell"><Skeleton className="h-4 w-3/4" /></td>
|
||||
<td className="px-3"><Skeleton className="h-4 w-24" /></td>
|
||||
<td className="hidden px-3 md:table-cell"><Skeleton className="h-4 w-16" /></td>
|
||||
<td className="pr-1 pl-3"><Skeleton className="ml-auto size-8" /></td>
|
||||
</tr>
|
||||
));
|
||||
}
|
||||
|
||||
export default function ArtifactLibraryPage() {
|
||||
const { t } = useI18n();
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [fileKind, setFileKind] = useState<ArtifactLibraryKind>("all");
|
||||
const [sourceType, setSourceType] = useState<ArtifactLibrarySource>("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
|
||||
const forceRefreshRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `资料库 - ${t.pages.appName}`;
|
||||
}, [t.pages.appName]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch, fileKind, sourceType]);
|
||||
|
||||
const offset = (page - 1) * PAGE_SIZE;
|
||||
const libraryQuery = useQuery({
|
||||
queryKey: ["artifact-library", debouncedSearch, fileKind, sourceType, page],
|
||||
queryFn: async ({ signal }) => {
|
||||
const refresh = forceRefreshRef.current;
|
||||
try {
|
||||
return await fetchArtifactLibrary({
|
||||
query: debouncedSearch,
|
||||
fileKind,
|
||||
sourceType,
|
||||
offset,
|
||||
limit: PAGE_SIZE,
|
||||
refresh,
|
||||
signal,
|
||||
});
|
||||
} finally {
|
||||
forceRefreshRef.current = false;
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const total = libraryQuery.data?.total ?? 0;
|
||||
const items = libraryQuery.data?.items ?? [];
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const hasPrevious = page > 1;
|
||||
const hasNext = page < totalPages;
|
||||
|
||||
useEffect(() => {
|
||||
if (page > totalPages) setPage(totalPages);
|
||||
}, [page, totalPages]);
|
||||
|
||||
const itemKeys = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
items.flatMap((item) => [
|
||||
artifactDownloadKey(item, "original"),
|
||||
artifactDownloadKey(item, "word"),
|
||||
]),
|
||||
),
|
||||
[items],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (downloadingKey && !itemKeys.has(downloadingKey)) setDownloadingKey(null);
|
||||
}, [downloadingKey, itemKeys]);
|
||||
|
||||
const handleDownload = async (
|
||||
item: ArtifactLibraryItem,
|
||||
format: DownloadFormat,
|
||||
) => {
|
||||
const key = artifactDownloadKey(item, format);
|
||||
if (downloadingKey) return;
|
||||
setDownloadingKey(key);
|
||||
try {
|
||||
if (format === "word") {
|
||||
await downloadArtifactMarkdownAsWord({
|
||||
filepath: item.path,
|
||||
threadId: item.threadId,
|
||||
});
|
||||
toast.success("Word 文件已生成");
|
||||
} else {
|
||||
await downloadArtifactFile({
|
||||
filepath: item.path,
|
||||
threadId: item.threadId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(error instanceof Error ? error.message : "下载失败");
|
||||
} finally {
|
||||
setDownloadingKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
forceRefreshRef.current = true;
|
||||
void libraryQuery.refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader />
|
||||
<WorkspaceBody className="items-stretch">
|
||||
<div className="flex min-h-0 w-full flex-1 flex-col">
|
||||
<header className="shrink-0 border-b bg-card/40 px-5 py-6 sm:px-8">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-5">
|
||||
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-start">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Library className="size-6 text-primary" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">资料库</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
汇总当前账号在对话中生成的文件,不包含上传文件、图片、音频和视频。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={libraryQuery.isFetching}
|
||||
>
|
||||
<RefreshCw className={cn("size-4", libraryQuery.isFetching && "animate-spin")} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-xl">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
className="h-11 pl-9"
|
||||
placeholder="搜索文件名、来源对话或智能体名称"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex flex-wrap gap-1" aria-label="文件类型筛选">
|
||||
{FILTERS.map((filter) => (
|
||||
<Button
|
||||
key={filter.value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={fileKind === filter.value ? "secondary" : "ghost"}
|
||||
aria-pressed={fileKind === filter.value}
|
||||
onClick={() => setFileKind(filter.value)}
|
||||
>
|
||||
{filter.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Select
|
||||
value={sourceType}
|
||||
onValueChange={(value) =>
|
||||
setSourceType(value as ArtifactLibrarySource)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-32"
|
||||
aria-label="筛选文件来源"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SOURCE_FILTERS.map((filter) => (
|
||||
<SelectItem key={filter.value} value={filter.value}>
|
||||
{filter.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<ArrowDownWideNarrow className="size-4" />
|
||||
最近生成
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<main className="mx-auto w-full max-w-6xl px-5 py-5 sm:px-8">
|
||||
<div className="mb-2 text-xs text-muted-foreground tabular-nums">
|
||||
{libraryQuery.isLoading ? "正在统计文件…" : `共 ${total} 个文件`}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full table-fixed border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs font-normal text-muted-foreground">
|
||||
<th className="w-[48%] py-3 pr-4 pl-1 font-normal lg:w-[35%]">文件名</th>
|
||||
<th className="hidden w-[25%] px-3 py-3 font-normal lg:table-cell">来源</th>
|
||||
<th className="w-[34%] px-3 py-3 font-normal md:w-[22%] lg:w-[16%]">生成时间</th>
|
||||
<th className="hidden w-[13%] px-3 py-3 font-normal lg:w-[10%] md:table-cell">大小</th>
|
||||
<th className="w-[18%] py-3 pr-1 pl-3 text-right font-normal md:w-[17%] lg:w-[14%]">
|
||||
<span className="sr-only">操作</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{libraryQuery.isLoading ? (
|
||||
<LoadingRows />
|
||||
) : (
|
||||
items.map((item) => {
|
||||
const key = `${item.threadId}:${item.path}`;
|
||||
const activeDownload = downloadingKey?.startsWith(`${key}:`)
|
||||
? downloadingKey.endsWith(":word")
|
||||
? "word"
|
||||
: "original"
|
||||
: null;
|
||||
return (
|
||||
<FileRow
|
||||
key={key}
|
||||
item={item}
|
||||
downloading={activeDownload}
|
||||
onDownload={(target, format) =>
|
||||
void handleDownload(target, format)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!libraryQuery.isLoading && libraryQuery.isError && (
|
||||
<div className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<FileX2 className="size-9 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">资料库加载失败</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{libraryQuery.error instanceof Error
|
||||
? libraryQuery.error.message
|
||||
: "请稍后重试"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!libraryQuery.isLoading && !libraryQuery.isError && items.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 py-20 text-center">
|
||||
<FileX2 className="size-9 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{debouncedSearch || fileKind !== "all" || sourceType !== "all"
|
||||
? "没有找到匹配的文件"
|
||||
: "还没有生成文件"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
对话中生成的文档、表格和数据文件会自动出现在这里。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!libraryQuery.isLoading && !libraryQuery.isError && totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-3 py-7">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasPrevious}
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasNext}
|
||||
onClick={() => setPage((current) => current + 1)}
|
||||
>
|
||||
下一页
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</WorkspaceBody>
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
import { BookOpen, Database, Download, Loader2, Plus, RefreshCw, Search, ShieldCheck, Trash2, Upload } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { KnowledgeProductNav } from "@/components/workspace/knowledge-product-nav";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import {
|
||||
type AssistantKnowledgeBase,
|
||||
type AssistantWikiPage,
|
||||
useAssistantImportJobs,
|
||||
useAssistantKnowledgeActions,
|
||||
useAssistantKnowledgeBases,
|
||||
useAssistantKnowledgeSources,
|
||||
useAssistantWikiPages,
|
||||
} from "@/strategy-components/api/assistant-knowledge";
|
||||
|
||||
function baseSubtitle(base: AssistantKnowledgeBase) {
|
||||
if (base.is_global) return "全库沉淀总库 · 所有 WeKnora 导入数据都会同步进入这里并做实体对齐";
|
||||
return base.description || "自建助手知识库 · 可上传普通知识库导出的 Wiki 向量包";
|
||||
}
|
||||
|
||||
function EmptyInitializeCard({
|
||||
isAdmin,
|
||||
onInitialize,
|
||||
pending,
|
||||
}: {
|
||||
isAdmin: boolean;
|
||||
onInitialize: () => void;
|
||||
pending: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-8">
|
||||
<div className="max-w-lg rounded-xl border bg-card p-8 text-center">
|
||||
<Database className="mx-auto mb-4 size-10 text-primary" />
|
||||
<h1 className="text-xl font-semibold">初始化助手知识库</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
未配置 WeKnora 时会默认展示助手知识库。首次使用需要创建“知识梳理总库”,后续从 WeKnora 导入的数据都会沉淀到这里。
|
||||
</p>
|
||||
{isAdmin ? (
|
||||
<Button className="mt-5" disabled={pending} onClick={onInitialize}>
|
||||
{pending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Database className="mr-2 size-4" />}
|
||||
创建知识梳理总库
|
||||
</Button>
|
||||
) : (
|
||||
<p className="mt-5 text-sm">请联系管理员初始化。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AssistantKnowledgePage() {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedBaseId, setSelectedBaseId] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<AssistantWikiPage | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createDraft, setCreateDraft] = useState({ name: "", description: "" });
|
||||
const packageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const bases = useAssistantKnowledgeBases();
|
||||
const actions = useAssistantKnowledgeActions();
|
||||
|
||||
useEffect(() => {
|
||||
const values = bases.data ?? [];
|
||||
if (!values.length) {
|
||||
setSelectedBaseId(null);
|
||||
return;
|
||||
}
|
||||
if (selectedBaseId && values.some((base) => base.id === selectedBaseId)) return;
|
||||
setSelectedBaseId((values.find((base) => base.is_global) ?? values[0]).id);
|
||||
}, [bases.data, selectedBaseId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(null);
|
||||
}, [selectedBaseId]);
|
||||
|
||||
const selectedBase = useMemo(
|
||||
() => (bases.data ?? []).find((base) => base.id === selectedBaseId) ?? null,
|
||||
[bases.data, selectedBaseId],
|
||||
);
|
||||
const pages = useAssistantWikiPages(query, selectedBaseId);
|
||||
const sources = useAssistantKnowledgeSources(selectedBaseId);
|
||||
const jobs = useAssistantImportJobs(selectedBaseId);
|
||||
const activePage = selected ?? pages.data?.[0] ?? null;
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
pages: pages.data?.length ?? selectedBase?.page_count ?? 0,
|
||||
sources: sources.data?.length ?? selectedBase?.source_count ?? 0,
|
||||
jobs: jobs.data?.length ?? 0,
|
||||
}),
|
||||
[jobs.data, pages.data, selectedBase?.page_count, selectedBase?.source_count, sources.data],
|
||||
);
|
||||
|
||||
const initializeGlobal = () => {
|
||||
actions.initialize.mutate("知识梳理总库", {
|
||||
onSuccess: (base) => {
|
||||
setSelectedBaseId(base.id);
|
||||
toast.success("知识梳理总库已创建");
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
};
|
||||
|
||||
const createBase = async () => {
|
||||
const name = createDraft.name.trim();
|
||||
if (!name) {
|
||||
toast.error("请输入助手知识库名称");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = await actions.createBase.mutateAsync({
|
||||
name,
|
||||
description: createDraft.description.trim(),
|
||||
});
|
||||
setSelectedBaseId(base.id);
|
||||
setCreateDraft({ name: "", description: "" });
|
||||
setCreateOpen(false);
|
||||
toast.success("助手知识库已创建");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const uploadPackage = async (file: File | null | undefined) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
await actions.uploadWeKnoraPackage.mutateAsync({
|
||||
file,
|
||||
assistantBaseId: selectedBaseId,
|
||||
});
|
||||
toast.success("导出包已上传,后台正在异步导入");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
if (packageInputRef.current) packageInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
if (bases.isLoading) {
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
<KnowledgeProductNav />
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!bases.data?.length) {
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
<KnowledgeProductNav />
|
||||
<EmptyInitializeCard
|
||||
isAdmin={isAdmin}
|
||||
pending={actions.initialize.isPending}
|
||||
onInitialize={initializeGlobal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full min-h-0 flex-col bg-background">
|
||||
<KnowledgeProductNav />
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[280px_1fr]">
|
||||
<aside className="flex min-h-0 flex-col border-r bg-muted/20">
|
||||
<div className="border-b p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">助手知识库</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">总库置顶,自建库可上传 WeKnora 导出包</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Button size="icon" variant="outline" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-2 p-3">
|
||||
{(bases.data ?? []).map((base) => (
|
||||
<button
|
||||
key={base.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedBaseId(base.id)}
|
||||
className={`w-full rounded-lg border px-3 py-3 text-left transition ${
|
||||
selectedBaseId === base.id ? "border-primary bg-primary/10 text-primary" : "bg-card hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="size-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{base.name}</span>
|
||||
{base.is_global && <Badge variant="outline" className="text-[10px]">总库</Badge>}
|
||||
</div>
|
||||
<div className="mt-2 flex gap-3 text-[11px] text-muted-foreground">
|
||||
<span>{base.page_count ?? 0} 页</span>
|
||||
<span>{base.source_count ?? 0} 来源</span>
|
||||
<span>{base.entity_count ?? 0} 实体</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-h-0 flex-col">
|
||||
<header className="flex items-center gap-4 border-b px-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-xl font-semibold">{selectedBase?.name}</h1>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{selectedBase ? baseSubtitle(selectedBase) : "请选择助手知识库"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
<ShieldCheck className="mr-1 size-3" />
|
||||
实体对齐
|
||||
</Badge>
|
||||
{isAdmin && (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<input
|
||||
ref={packageInputRef}
|
||||
type="file"
|
||||
accept=".jsonl,.ndjson,application/x-ndjson,application/json"
|
||||
className="hidden"
|
||||
onChange={(event) => void uploadPackage(event.target.files?.[0])}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={actions.uploadWeKnoraPackage.isPending || !selectedBaseId}
|
||||
onClick={() => packageInputRef.current?.click()}
|
||||
>
|
||||
{actions.uploadWeKnoraPackage.isPending ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 size-4" />
|
||||
)}
|
||||
上传 WeKnora 导出包
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<Tabs defaultValue="wiki" className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-4 border-b px-6">
|
||||
<TabsList className="my-2">
|
||||
<TabsTrigger value="wiki">Wiki({counts.pages})</TabsTrigger>
|
||||
<TabsTrigger value="sources">来源({counts.sources})</TabsTrigger>
|
||||
<TabsTrigger value="jobs">导入记录({counts.jobs})</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="relative ml-auto w-72">
|
||||
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索标题、实体别名和正文"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="wiki" className="mt-0 grid min-h-0 flex-1 grid-cols-[300px_1fr]">
|
||||
<ScrollArea className="border-r">
|
||||
<div className="space-y-1 p-3">
|
||||
{pages.isLoading && <Loader2 className="m-4 size-5 animate-spin" />}
|
||||
{pages.data?.map((page) => (
|
||||
<button
|
||||
key={page.id}
|
||||
type="button"
|
||||
onClick={() => setSelected(page)}
|
||||
className={`w-full rounded-md px-3 py-2 text-left ${
|
||||
activePage?.id === page.id ? "bg-primary/10 text-primary" : "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<div className="truncate text-sm font-medium">{page.title}</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>{page.page_type}</span>
|
||||
<span>{new Date(page.updated_at).toLocaleString()}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{!pages.isLoading && !pages.data?.length && (
|
||||
<div className="p-4 text-xs text-muted-foreground">
|
||||
暂无 Wiki 页面。请先到普通知识库详情页下载向量化数据包,再在当前助手知识库上传。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<ScrollArea>
|
||||
<article className="mx-auto max-w-5xl p-8">
|
||||
{activePage ? (
|
||||
<>
|
||||
<h1 className="mb-1 flex items-center gap-2 text-2xl font-semibold">
|
||||
<BookOpen className="size-5 text-primary" />
|
||||
{activePage.title}
|
||||
</h1>
|
||||
<p className="mb-6 text-xs text-muted-foreground">{activePage.slug}</p>
|
||||
<MarkdownContent content={activePage.content_markdown} isLoading={false} rehypePlugins={[]} />
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无 Wiki 页面。</p>
|
||||
)}
|
||||
</article>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sources" className="mt-0 min-h-0 flex-1 overflow-auto p-6">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{sources.data?.map((source) => (
|
||||
<div key={source.id} className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="size-4" />
|
||||
<span className="truncate font-medium">{source.source_name}</span>
|
||||
<Badge variant="outline" className="ml-auto">{source.source_type}</Badge>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">业务键:{source.source_key}</p>
|
||||
{isAdmin && (
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={source.source_type !== "weknora" || actions.reimportSource.isPending}
|
||||
onClick={() =>
|
||||
actions.reimportSource.mutate(source.id, {
|
||||
onSuccess: () => toast.success("已发起重新导入"),
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
}
|
||||
>
|
||||
<RefreshCw className="mr-2 size-4" />
|
||||
重新导入
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={actions.deleteSource.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`确认删除来源“${source.source_name}”的贡献?仍被其他来源引用的知识会保留。`)) {
|
||||
actions.deleteSource.mutate(source.id, {
|
||||
onSuccess: () => toast.success("来源贡献已删除"),
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
删除来源
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!sources.data?.length && <p className="text-sm text-muted-foreground">暂无来源。</p>}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="jobs" className="mt-0 min-h-0 flex-1 overflow-auto p-6">
|
||||
<div className="space-y-3">
|
||||
{jobs.data?.map((job) => (
|
||||
<div key={job.id} className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge>{job.status}</Badge>
|
||||
<span className="text-sm">{job.trigger}</span>
|
||||
<span className="text-xs text-muted-foreground">阶段:{job.phase}</span>
|
||||
<span className="ml-auto text-xs">{new Date(job.created_at).toLocaleString()}</span>
|
||||
{isAdmin && job.package_download_url && (
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={job.package_download_url} target="_blank" rel="noreferrer">
|
||||
<Download className="mr-2 size-4" />
|
||||
下载导出包
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
{Object.entries(job.counts || {}).map(([key, value]) => `${key} ${value}`).join(" · ") || "暂无统计"}
|
||||
</div>
|
||||
{job.last_error_message && (
|
||||
<div className="mt-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{job.last_error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!jobs.data?.length && <p className="text-sm text-muted-foreground">暂无导入记录。</p>}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建助手知识库</DialogTitle>
|
||||
<DialogDescription>
|
||||
自建助手知识库用于承接从 WeKnora 普通知识库下载的 Wiki 向量包;导入时同一份数据也会沉淀到“知识梳理总库”。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">名称</label>
|
||||
<Input
|
||||
value={createDraft.name}
|
||||
onChange={(event) => setCreateDraft((prev) => ({ ...prev, name: event.target.value }))}
|
||||
placeholder="例如:政策资料库"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">描述</label>
|
||||
<Textarea
|
||||
value={createDraft.description}
|
||||
onChange={(event) => setCreateDraft((prev) => ({ ...prev, description: event.target.value }))}
|
||||
placeholder="可选,用于说明这个助手知识库的范围"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={actions.createBase.isPending} onClick={() => void createBase()}>
|
||||
{actions.createBase.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Plus className="mr-2 size-4" />}
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -142,6 +142,7 @@ export default function ChatPage() {
|
||||
// per-thread header toggle so stale localStorage can't re-enable it.
|
||||
const [knowledgeRagEnabled] = useKnowledgeRagEnabled();
|
||||
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
|
||||
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>([]);
|
||||
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(false);
|
||||
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
|
||||
const knowledgeSelectionThread = useRef<string | null>(null);
|
||||
@@ -150,18 +151,26 @@ export default function ChatPage() {
|
||||
setSelectedKnowledgeBaseIds(ids);
|
||||
setKnowledgeSelectionReady(true);
|
||||
}, []);
|
||||
const changeAssistantKnowledgeSelection = useCallback((ids: string[]) => {
|
||||
setSelectedAssistantKnowledgeBaseIds(ids);
|
||||
setKnowledgeSelectionReady(true);
|
||||
}, []);
|
||||
const streamContext = useMemo(
|
||||
() => ({
|
||||
...settings.context,
|
||||
knowledge_rag_enabled: knowledgeRagEnabled,
|
||||
...(knowledgeSelectionReady
|
||||
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
|
||||
? {
|
||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
||||
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
[
|
||||
settings.context,
|
||||
knowledgeRagEnabled,
|
||||
knowledgeSelectionReady,
|
||||
selectedAssistantKnowledgeBaseIds,
|
||||
selectedKnowledgeBaseIds,
|
||||
],
|
||||
);
|
||||
@@ -200,6 +209,7 @@ export default function ChatPage() {
|
||||
setHasStartedConversation(false);
|
||||
setPromptPrefixOn(false);
|
||||
setSelectedKnowledgeBaseIds([]);
|
||||
setSelectedAssistantKnowledgeBaseIds([]);
|
||||
setKnowledgeSelectionReady(false);
|
||||
setKnowledgeSelectionLoading(false);
|
||||
knowledgeSelectionThread.current = "__new__";
|
||||
@@ -225,18 +235,29 @@ export default function ChatPage() {
|
||||
if (knowledgeSelectionThread.current !== threadId) return;
|
||||
const metadata = record.metadata as Record<string, unknown> | undefined;
|
||||
const raw = metadata?.llmwiki_knowledge_base_ids;
|
||||
const assistantRaw = metadata?.assistant_knowledge_base_ids;
|
||||
const llmwikiIds = Array.isArray(raw)
|
||||
? raw.map((item) => String(item).trim()).filter(Boolean)
|
||||
: [];
|
||||
const assistantIds = Array.isArray(assistantRaw)
|
||||
? assistantRaw.map((item) => String(item).trim()).filter(Boolean)
|
||||
: [];
|
||||
if (Array.isArray(raw)) {
|
||||
setSelectedKnowledgeBaseIds(
|
||||
raw.map((item) => String(item).trim()).filter(Boolean),
|
||||
);
|
||||
setKnowledgeSelectionReady(true);
|
||||
setSelectedKnowledgeBaseIds(llmwikiIds);
|
||||
} else {
|
||||
setSelectedKnowledgeBaseIds([]);
|
||||
setKnowledgeSelectionReady(false);
|
||||
}
|
||||
if (Array.isArray(assistantRaw)) {
|
||||
setSelectedAssistantKnowledgeBaseIds(assistantIds);
|
||||
} else {
|
||||
setSelectedAssistantKnowledgeBaseIds([]);
|
||||
}
|
||||
setKnowledgeSelectionReady(Array.isArray(raw) || Array.isArray(assistantRaw));
|
||||
})
|
||||
.catch(() => {
|
||||
if (knowledgeSelectionThread.current === threadId) {
|
||||
setSelectedKnowledgeBaseIds([]);
|
||||
setSelectedAssistantKnowledgeBaseIds([]);
|
||||
setKnowledgeSelectionReady(false);
|
||||
}
|
||||
})
|
||||
@@ -259,6 +280,7 @@ export default function ChatPage() {
|
||||
if (!known.some((a) => a.id === selectedAgentId)) {
|
||||
setSelectedAgentId(null);
|
||||
setSelectedKnowledgeBaseIds([]);
|
||||
setSelectedAssistantKnowledgeBaseIds([]);
|
||||
setKnowledgeSelectionReady(false);
|
||||
}
|
||||
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
|
||||
@@ -269,7 +291,10 @@ export default function ChatPage() {
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
context: streamContext,
|
||||
threadMetadata: knowledgeSelectionReady
|
||||
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
|
||||
? {
|
||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
||||
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
|
||||
}
|
||||
: undefined,
|
||||
isMock,
|
||||
onStart: (createdThreadId) => {
|
||||
@@ -443,7 +468,10 @@ export default function ChatPage() {
|
||||
metadata: {
|
||||
agent_id: selectedAgentId,
|
||||
...(knowledgeSelectionReady
|
||||
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
|
||||
? {
|
||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
||||
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
@@ -454,7 +482,10 @@ export default function ChatPage() {
|
||||
threadId: created.thread_id,
|
||||
text,
|
||||
...(knowledgeSelectionReady
|
||||
? { knowledgeBaseIds: selectedKnowledgeBaseIds }
|
||||
? {
|
||||
knowledgeBaseIds: selectedKnowledgeBaseIds,
|
||||
assistantKnowledgeBaseIds: selectedAssistantKnowledgeBaseIds,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
@@ -516,6 +547,7 @@ export default function ChatPage() {
|
||||
hideAdminPrompt,
|
||||
preferencesLoading,
|
||||
selectedAgentId,
|
||||
selectedAssistantKnowledgeBaseIds,
|
||||
selectedKnowledgeBaseIds,
|
||||
knowledgeSelectionReady,
|
||||
agentHandoffPending,
|
||||
@@ -619,6 +651,7 @@ export default function ChatPage() {
|
||||
const handleAgentChange = useCallback((agentId: string | null) => {
|
||||
setSelectedAgentId(agentId);
|
||||
setSelectedKnowledgeBaseIds([]);
|
||||
setSelectedAssistantKnowledgeBaseIds([]);
|
||||
setKnowledgeSelectionReady(false);
|
||||
saveLastAgentId(agentId);
|
||||
}, []);
|
||||
@@ -907,7 +940,9 @@ export default function ChatPage() {
|
||||
source={selectedAgentId ? "agent" : "all"}
|
||||
agentId={selectedAgentId}
|
||||
value={selectedKnowledgeBaseIds}
|
||||
assistantValue={selectedAssistantKnowledgeBaseIds}
|
||||
onChange={changeKnowledgeSelection}
|
||||
onAssistantChange={changeAssistantKnowledgeSelection}
|
||||
autoSelectAll={isLandingState && !!selectedAgentId}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
|
||||
@@ -3,6 +3,11 @@ import { useState } from "react";
|
||||
import { Navigate, useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
KNOWLEDGE_NORMAL_VIEW_VALUE,
|
||||
KnowledgeProductNav,
|
||||
useKnowledgeBaseProductVersion,
|
||||
} from "@/components/workspace/knowledge-product-nav";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -70,7 +75,9 @@ export default function LlmWikiKnowledgePage({
|
||||
const { noteId = "" } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const productVersion = useKnowledgeBaseProductVersion();
|
||||
const wikiSlug = searchParams.get("wikiSlug")?.trim() || "";
|
||||
const explicitNormalView = searchParams.get("view") === KNOWLEDGE_NORMAL_VIEW_VALUE;
|
||||
|
||||
if (mode === "detail" && noteId && wikiSlug) {
|
||||
return <Navigate to={buildWeKnoraWikiPagePath(noteId, wikiSlug)} replace />;
|
||||
@@ -80,7 +87,14 @@ export default function LlmWikiKnowledgePage({
|
||||
return <RuntimeLoading />;
|
||||
}
|
||||
|
||||
if (mode === "list" && productVersion.data === "v2" && !explicitNormalView) {
|
||||
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
|
||||
}
|
||||
|
||||
if (runtime.isError || runtime.data?.provider !== "weknora") {
|
||||
if (mode === "list" || mode === "detail") {
|
||||
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
|
||||
}
|
||||
if (mode === "graph") {
|
||||
return <KnowledgeGraphPage />;
|
||||
}
|
||||
@@ -95,5 +109,5 @@ export default function LlmWikiKnowledgePage({
|
||||
return <WeKnoraKnowledgeDetailPage />;
|
||||
}
|
||||
|
||||
return <WeKnoraKnowledgeWorkspace />;
|
||||
return <div className="flex size-full min-h-0 flex-col"><KnowledgeProductNav /><div className="min-h-0 flex-1"><WeKnoraKnowledgeWorkspace /></div></div>;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { agentRefOfThread } from '@/core/threads/utils';
|
||||
|
||||
import ChatsPage from './ChatsPage';
|
||||
import ChatPage from './ChatPage';
|
||||
import ArtifactLibraryPage from './ArtifactLibraryPage';
|
||||
import IframeChatPage from './IframeChatPage';
|
||||
import AgentsPage from './AgentsPage';
|
||||
import AgentChatPage from './AgentChatPage';
|
||||
@@ -41,6 +42,7 @@ import AdminSkillsPage from './AdminSkillsPage';
|
||||
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
|
||||
import ScheduledTasksPage from './ScheduledTasksPage';
|
||||
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
|
||||
import AssistantKnowledgePage from './AssistantKnowledgePage';
|
||||
import WeKnoraWikiManagementPage from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage';
|
||||
import WeKnoraWikiPageView from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView';
|
||||
import MarkdownDocxEditorPage from './MarkdownDocxEditorPage';
|
||||
@@ -95,6 +97,7 @@ function shouldShowCompactTopActions(pathname: string): boolean {
|
||||
pathname === '/page/workspace/agents' ||
|
||||
pathname === '/page/workspace/skills' ||
|
||||
pathname === '/page/workspace/memory' ||
|
||||
pathname === '/page/workspace/library' ||
|
||||
pathname === '/page/workspace/knowledge' ||
|
||||
pathname.startsWith('/page/workspace/knowledge/')
|
||||
);
|
||||
@@ -518,6 +521,20 @@ export default function WorkspaceRoutes({
|
||||
path="chats/:thread_id"
|
||||
element={<ChatThreadEntryRoute layoutProps={layoutProps} />}
|
||||
/>
|
||||
<Route
|
||||
path="library"
|
||||
element={
|
||||
<WorkspaceLayout
|
||||
{...layoutProps}
|
||||
workspaceSidebarHidden={
|
||||
layoutProps.workspaceSidebarHidden ||
|
||||
!layoutProps.pageSidebarHidden
|
||||
}
|
||||
>
|
||||
<ArtifactLibraryPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="chats_iframe/new"
|
||||
element={<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam={false} />}
|
||||
@@ -753,6 +770,14 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="knowledge/assistant"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AssistantKnowledgePage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="knowledge/:noteId/wiki/view"
|
||||
element={
|
||||
|
||||
Reference in new issue
Block a user