diff --git a/frontend-web/docs/skill-knowledge-and-assistant-knowledge.md b/frontend-web/docs/skill-knowledge-and-assistant-knowledge.md new file mode 100644 index 00000000..8d929308 --- /dev/null +++ b/frontend-web/docs/skill-knowledge-and-assistant-knowledge.md @@ -0,0 +1,7 @@ +# 技能知识归纳与“知识梳理”助手知识库 + +管理员在「技能管理 → 技能知识归纳」中选择一个或多个技能、一个或多个目标,系统按“技能 × 目标”创建后台任务。界面提供总体进度、单项失败重试、实体/关系审核、绑定重归纳、自动同步和两种解绑动作。技能列表通过一次 bindings 查询展示归纳目标,避免逐技能请求。 + +扫描仅处理知识资料。代码、脚本、锁文件、密钥文件以及 Markdown 代码块不会进入上传、摘要、提示词或知识指纹;ZIP 只读取逐项验证通过的知识成员。自动监听只比较该知识指纹,所以纯代码改动不会产生 stale 状态。 + +知识库页面新增「普通知识库 / 助手知识库」并行入口。管理员可将系统默认入口设为 v1(普通 WeKnora)或 v2(全局“知识梳理”)。助手库无需 WeKnora 即可初始化,所有用户可只读查看和搜索 Wiki;管理员可上传本地文件、从普通库导入、重新导入或删除来源、查看版本并回滚。来源删除只移除对应贡献,共享实体与关系继续保留。 diff --git a/frontend-web/src/components/workspace/knowledge-product-nav.tsx b/frontend-web/src/components/workspace/knowledge-product-nav.tsx new file mode 100644 index 00000000..b6417d86 --- /dev/null +++ b/frontend-web/src/components/workspace/knowledge-product-nav.tsx @@ -0,0 +1,55 @@ +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 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 ( +
+ + + {isAdmin && ( +
+ 默认产品版本 + +
+ )} +
+ ); +} diff --git a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx index d63df2c6..2f359044 100644 --- a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx @@ -92,7 +92,8 @@ import { useSkillCompressionSettings, useViewMode, } from "@/core/system-settings/hooks"; -import type { Skill } from "@/core/skills/type"; +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"; @@ -599,8 +600,9 @@ export function SkillRow({ skill, currentUserId, isAdmin, - layout = "row", - onPublisherClick, + layout = "row", + onPublisherClick, + knowledgeBindings = [], }: { skill: Skill; currentUserId: string | null; @@ -609,7 +611,9 @@ export function SkillRow({ layout?: "row" | "card"; /** 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; + 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(); @@ -849,7 +853,7 @@ export function SkillRow({ {t.settings.skills.badgePinned} )} - {skill.always_on && ( + {skill.always_on && ( 常驻 - )} + )} + {knowledgeBindings.slice(0, 3).map((binding) => ( + + 知识库:{binding.target_name || binding.target_id} + {binding.status === "stale" ? "(待更新)" : ""} + + ))} + {knowledgeBindings.length > 3 && ( + +{knowledgeBindings.length - 3} + )} ); diff --git a/frontend-web/src/components/workspace/skills/SkillKnowledgePanel.tsx b/frontend-web/src/components/workspace/skills/SkillKnowledgePanel.tsx new file mode 100644 index 00000000..77fe43d7 --- /dev/null +++ b/frontend-web/src/components/workspace/skills/SkillKnowledgePanel.tsx @@ -0,0 +1,226 @@ +import { CheckCircle2, Database, Loader2, RefreshCw, 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 { + useAssistantKnowledgeActions, + useAssistantKnowledgeBase, +} from "@/strategy-components/api/assistant-knowledge"; +import { useLlmWikiKnowledgeBases, useLlmWikiRuntime } from "@/strategy-components/api/llmwiki"; +import { + type SkillKnowledgeTarget, + useSkillKnowledgeActions, + useSkillKnowledgeAutoSyncSettings, + useSkillKnowledgeBindings, + useSkillKnowledgeJobs, + useSkillKnowledgeReviews, +} from "@/strategy-components/api/skill-knowledge"; + +const PHASE_LABELS: Record = { + 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([]); + const [selectedTargetKeys, setSelectedTargetKeys] = useState([]); + const [reviewMode, setReviewMode] = useState<"required" | "auto_high_confidence" | "off">("auto_high_confidence"); + const runtime = useLlmWikiRuntime(); + const knowledgeBases = useLlmWikiKnowledgeBases("all", runtime.data?.provider === "weknora"); + const assistantBase = useAssistantKnowledgeBase(); + const assistantActions = useAssistantKnowledgeActions(); + const bindings = useSkillKnowledgeBindings(); + const jobs = useSkillKnowledgeJobs(); + const reviews = useSkillKnowledgeReviews(); + const actions = useSkillKnowledgeActions(); + const autoSettings = useSkillKnowledgeAutoSyncSettings(); + const [scheduleCron, setScheduleCron] = useState("0 */6 * * *"); + const [debounceSeconds, setDebounceSeconds] = useState(120); + + useEffect(() => { + if (!autoSettings.data) return; + setScheduleCron(autoSettings.data.schedule_cron); + setDebounceSeconds(autoSettings.data.debounce_seconds); + }, [autoSettings.data]); + + const targets = useMemo(() => { + const rows: SkillKnowledgeTarget[] = (knowledgeBases.data ?? []).map((kb) => ({ + target_type: "weknora", + target_mode: kb.can_write ? kb.type === "faq" ? "faq" : kb.type === "wiki" ? "wiki" : "document" : "readonly_sidecar", + target_id: kb.id, + target_name: kb.name, + remote_business_key: `weknora:kb:${kb.id}`, + readonly: !kb.can_write, + })); + if (assistantBase.data) { + rows.unshift({ + target_type: "assistant", + target_mode: "assistant", + target_id: assistantBase.data.id, + target_name: assistantBase.data.name, + remote_business_key: `assistant:base:${assistantBase.data.id}`, + }); + } + return rows; + }, [assistantBase.data, knowledgeBases.data]); + + 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) => + selectedTargetKeys.includes(`${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 ( +
+
+
+

批量归纳

+

按“技能 × 目标知识库”生成独立后台任务;代码与脚本不会被读取。

+
+ +
+
+
+ 1. 选择技能({selectedSkills.length}) + +
+
+ {isLoading ? : liveSkills.map((skill) => ( + + ))} +
+
+
+
+ 2. 选择目标({selectedTargetKeys.length}) + {!assistantBase.data && ( + + )} +
+
+ {targets.map((target) => { + const key = `${target.target_type}:${target.target_id}`; + return ( + + ); + })} + {!targets.length &&

未配置普通知识库;可先创建全局“知识梳理”助手库。

} +
+
+
+ 审核策略 + +
+ +
+
+
+ +
+
+
+

归纳任务

后台执行、失败可单项重试

+ +
+ +
+ {(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 ( +
+
{job.status}{done}/{job.total_count} 项{new Date(job.created_at).toLocaleString()}
+ + {job.items?.map((item) => ( +
+ {item.skill_name} + {PHASE_LABELS[item.phase] || item.phase} + {item.progress}% + {item.status === "failed" && } +
+ ))} +
+ ); + })} + {!jobs.data?.length &&

暂无归纳任务

} +
+
+
+ {(reviews.data?.length ?? 0) > 0 && ( +
+

待审核({reviews.data!.length})

+ {reviews.data!.map((review) =>
{review.object_type}{String(review.original.name || review.original.predicate || review.object_key)}
)} +
+ )} +
+
+ 自动同步 + {autoSettings.data && <> + + + + setScheduleCron(event.target.value)} aria-label="自动同步 cron" /> + setDebounceSeconds(Number(event.target.value))} aria-label="防抖秒数" /> + + } +
+
当前有效绑定:{bindings.data?.length ?? 0};同名同类型实体按来源贡献合并。
+
{bindings.data?.map((binding) =>
{binding.skill_name}→ {binding.target_name}{binding.status}{binding.remote_business_key}
)}
+
+
+
+ ); +} diff --git a/frontend-web/src/core/system-settings/api.ts b/frontend-web/src/core/system-settings/api.ts index bf59e850..d24f9f7e 100644 --- a/frontend-web/src/core/system-settings/api.ts +++ b/frontend-web/src/core/system-settings/api.ts @@ -8,6 +8,31 @@ export interface PromptPrefixSettings { prompt_prefix: string; } +export type KnowledgeBaseProductVersion = "v1" | "v2"; + +export async function loadKnowledgeBaseProductVersion(): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`, + ); + if (!response.ok) throw new Error(`Failed to load knowledge base product version: ${response.status}`); + return ((await response.json()) as { version: KnowledgeBaseProductVersion }).version; +} + +export async function updateKnowledgeBaseProductVersion( + version: KnowledgeBaseProductVersion, +): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ version }), + }, + ); + if (!response.ok) throw new Error(`Failed to update knowledge base product version: ${response.status}`); + return ((await response.json()) as { version: KnowledgeBaseProductVersion }).version; +} + export interface PluginDomainAgentMappingDto { id: string; domain: string; diff --git a/frontend-web/src/pages/AdminSkillsPage.tsx b/frontend-web/src/pages/AdminSkillsPage.tsx index 2dba69ed..4d3e0407 100644 --- a/frontend-web/src/pages/AdminSkillsPage.tsx +++ b/frontend-web/src/pages/AdminSkillsPage.tsx @@ -3,6 +3,7 @@ import { ChevronLeftIcon, ChevronRightIcon, + DatabaseZap, Replace, SearchIcon, SparklesIcon, @@ -18,9 +19,11 @@ 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; @@ -29,6 +32,7 @@ export default function AdminSkillsPage() { const [search, setSearch] = useState(""); const [page, setPage] = useState(1); const { skills, isLoading, error } = useAllSkillsAdmin(true); + const knowledgeBindings = useSkillKnowledgeBindings(); const storedAuth = useMemo(() => getStoredAuth(), []); const currentUserId = storedAuth?.user_id ?? null; @@ -71,6 +75,10 @@ export default function AdminSkillsPage() { 技能地址管理 + + + 技能知识归纳 + @@ -126,6 +134,9 @@ export default function AdminSkillsPage() { skill={skill} currentUserId={currentUserId} isAdmin + knowledgeBindings={(knowledgeBindings.data ?? []).filter( + (binding) => binding.skill_name === skill.name, + )} /> ))} @@ -164,6 +175,12 @@ export default function AdminSkillsPage() { > + + + diff --git a/frontend-web/src/pages/AssistantKnowledgePage.tsx b/frontend-web/src/pages/AssistantKnowledgePage.tsx new file mode 100644 index 00000000..a0009085 --- /dev/null +++ b/frontend-web/src/pages/AssistantKnowledgePage.tsx @@ -0,0 +1,74 @@ +import { Database, FileUp, History, Loader2, RefreshCw, Search, ShieldCheck, Trash2 } from "lucide-react"; +import { useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; +import { KnowledgeProductNav } from "@/components/workspace/knowledge-product-nav"; +import { getStoredAuth } from "@/core/auth"; +import { + listAssistantWikiRevisions, + type AssistantWikiPage, + type AssistantWikiRevision, + useAssistantImportJobs, + useAssistantKnowledgeActions, + useAssistantKnowledgeBase, + useAssistantKnowledgeSources, + useAssistantWikiPages, +} from "@/strategy-components/api/assistant-knowledge"; + +export default function AssistantKnowledgePage() { + const isAdmin = getStoredAuth()?.system_role === "admin"; + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState(null); + const [revisions, setRevisions] = useState([]); + const fileRef = useRef(null); + const base = useAssistantKnowledgeBase(); + const pages = useAssistantWikiPages(query); + const sources = useAssistantKnowledgeSources(); + const jobs = useAssistantImportJobs(); + const actions = useAssistantKnowledgeActions(); + const activePage = selected ?? pages.data?.[0] ?? null; + + const counts = useMemo(() => ({ pages: pages.data?.length ?? 0, sources: sources.data?.length ?? 0, jobs: jobs.data?.length ?? 0 }), [pages.data, sources.data, jobs.data]); + + if (base.isLoading) return
; + if (!base.data) { + return

创建“知识梳理”助手知识库

无需配置普通知识库服务。创建后所有用户可查看 Wiki 与搜索,只有管理员可导入和维护。

{isAdmin ? :

请联系管理员初始化。

}
; + } + + const uploadFiles = async (files: FileList | null) => { + if (!files?.length) return; + try { + await actions.upload.mutateAsync(Array.from(files)); + toast.success("文件已安全扫描并导入"); + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)); + } finally { + if (fileRef.current) fileRef.current.value = ""; + } + }; + + const openHistory = async () => { + if (!activePage) return; + setRevisions(await listAssistantWikiRevisions(activePage.slug)); + }; + + return ( +
+ +

{base.data.name}

全局助手知识库 · 普通用户只读

实体对齐与版本管理
{isAdmin && <> void uploadFiles(event.target.files)} />}
+ +
Wiki({counts.pages})来源({counts.sources})导入记录({counts.jobs})版本({revisions.length})
setQuery(event.target.value)} placeholder="搜索标题、实体别名和正文" className="pl-8" />
+
{pages.data?.map((page) => )}
{activePage ? <>

{activePage.title}

{activePage.slug}

:

暂无 Wiki 页面。

}
+
{sources.data?.map((source) =>
{source.source_name}{source.source_type}

业务键:{source.source_key}

{isAdmin &&
}
)}
+
{jobs.data?.map((job) =>
{job.status}{job.trigger}{Object.entries(job.counts || {}).map(([key, value]) => `${key} ${value}`).join(" · ")}{new Date(job.created_at).toLocaleString()}
)}
+
{revisions.map((revision) =>
v{revision.revision_no}{revision.change_type}{revision.summary}{new Date(revision.created_at).toLocaleString()}{isAdmin && activePage && revision.id !== activePage.current_revision_id && }
)}{!revisions.length &&

请先在 Wiki 页选择页面并点击“页面版本”。

}
+
+
+ ); +} diff --git a/frontend-web/src/pages/LlmWikiKnowledgePage.tsx b/frontend-web/src/pages/LlmWikiKnowledgePage.tsx index 2e3d745d..c15203e8 100644 --- a/frontend-web/src/pages/LlmWikiKnowledgePage.tsx +++ b/frontend-web/src/pages/LlmWikiKnowledgePage.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { Navigate, useParams, useSearchParams } from "react-router-dom"; import { Button } from "@/components/ui/button"; +import { KnowledgeProductNav, useKnowledgeBaseProductVersion } from "@/components/workspace/knowledge-product-nav"; import { Dialog, DialogContent, @@ -70,6 +71,7 @@ export default function LlmWikiKnowledgePage({ const { noteId = "" } = useParams(); const [searchParams] = useSearchParams(); const runtime = useLlmWikiRuntime(); + const productVersion = useKnowledgeBaseProductVersion(); const wikiSlug = searchParams.get("wikiSlug")?.trim() || ""; if (mode === "detail" && noteId && wikiSlug) { @@ -80,11 +82,15 @@ export default function LlmWikiKnowledgePage({ return ; } + if (mode === "list" && productVersion.data === "v2") { + return ; + } + if (runtime.isError || runtime.data?.provider !== "weknora") { if (mode === "graph") { return ; } - return ; + return mode === "list" ?
: ; } if (mode === "graph") { @@ -95,5 +101,5 @@ export default function LlmWikiKnowledgePage({ return ; } - return ; + return
; } diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index 92bab8fc..d44866eb 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -40,6 +40,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'; @@ -689,6 +690,14 @@ export default function WorkspaceRoutes({ } /> + + + + } + /> `${getBackendBaseURL()}/api/assistant-knowledge`; + +export interface AssistantKnowledgeBase { + id: string; + name: string; + status: string; + created_at: string; + updated_at: string; +} + +export interface AssistantWikiPage { + id: string; + slug: string; + title: string; + page_type: string; + content_markdown: string; + summary: string; + current_revision_id?: string | null; + updated_at: string; +} + +export interface AssistantWikiRevision { + id: string; + page_id: string; + revision_no: number; + summary: string; + content_markdown: string; + change_type: string; + created_by?: string | null; + created_at: string; +} + +export interface AssistantKnowledgeSource { + id: string; + source_type: "weknora" | "skill" | "local_file"; + source_name: string; + source_key: string; + status: string; + updated_at: string; +} + +export interface AssistantImportJob { + id: string; + source_id: string; + trigger: string; + status: string; + phase: string; + counts: Record; + created_at: string; +} + +async function readJson(response: Response): Promise { + if (!response.ok) { + const data = await response.json().catch(() => null); + const detail = data && typeof data === "object" && "detail" in data + ? (data as { detail: unknown }).detail + : `请求失败 (${response.status})`; + throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail)); + } + return (await response.json()) as T; +} + +export async function getAssistantKnowledgeBase() { + const data = await readJson<{ base: AssistantKnowledgeBase | null }>(await apiFetch(`${BASE()}/base`)); + return data.base; +} + +export async function initializeAssistantKnowledgeBase(name = "知识梳理") { + const data = await readJson<{ base: AssistantKnowledgeBase }>( + await apiFetch(`${BASE()}/base/initialize`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }), + ); + return data.base; +} + +export async function listAssistantWikiPages(q = "") { + const params = new URLSearchParams(); + if (q.trim()) params.set("q", q.trim()); + return (await readJson<{ pages: AssistantWikiPage[] }>(await apiFetch(`${BASE()}/wiki-pages?${params}`))).pages; +} + +export async function listAssistantKnowledgeSources() { + return (await readJson<{ sources: AssistantKnowledgeSource[] }>(await apiFetch(`${BASE()}/sources`))).sources; +} + +export async function listAssistantImportJobs() { + return (await readJson<{ jobs: AssistantImportJob[] }>(await apiFetch(`${BASE()}/import-jobs`))).jobs; +} + +export async function listAssistantWikiRevisions(slug: string) { + return (await readJson<{ revisions: AssistantWikiRevision[] }>( + await apiFetch(`${BASE()}/wiki-pages/${encodeURIComponent(slug)}/revisions`), + )).revisions; +} + +export async function rollbackAssistantWikiRevision(slug: string, revisionId: string) { + return readJson( + await apiFetch( + `${BASE()}/wiki-pages/${encodeURIComponent(slug)}/revisions/${encodeURIComponent(revisionId)}/rollback`, + { method: "POST" }, + ), + ); +} + +export async function uploadAssistantKnowledgeFiles(files: File[]) { + const form = new FormData(); + files.forEach((file) => form.append("files", file)); + return readJson<{ batch_id: string; jobs: AssistantImportJob[] }>( + await apiFetch(`${BASE()}/imports/files`, { method: "POST", body: form }), + ); +} + +export async function exportLlmWikiToAssistant(mappingId: string) { + return readJson( + await apiFetch( + `${getBackendBaseURL()}/api/llmwiki/knowledge-bases/${encodeURIComponent(mappingId)}/export-to-assistant`, + { method: "POST" }, + ), + ); +} + +export async function reimportAssistantKnowledgeSource(sourceId: string) { + return readJson( + await apiFetch(`${BASE()}/sources/${encodeURIComponent(sourceId)}/reimport`, { + method: "POST", + }), + ); +} + +export async function deleteAssistantKnowledgeSource(sourceId: string) { + const response = await apiFetch(`${BASE()}/sources/${encodeURIComponent(sourceId)}`, { + method: "DELETE", + }); + if (!response.ok) await readJson(response); +} + +const KEY = "assistant-knowledge"; + +export function useAssistantKnowledgeBase() { + return useQuery({ queryKey: [KEY, "base"], queryFn: getAssistantKnowledgeBase }); +} + +export function useAssistantWikiPages(query = "") { + return useQuery({ queryKey: [KEY, "pages", query], queryFn: () => listAssistantWikiPages(query) }); +} + +export function useAssistantKnowledgeSources() { + return useQuery({ queryKey: [KEY, "sources"], queryFn: listAssistantKnowledgeSources }); +} + +export function useAssistantImportJobs() { + return useQuery({ queryKey: [KEY, "jobs"], queryFn: listAssistantImportJobs }); +} + +export function useAssistantKnowledgeActions() { + const client = useQueryClient(); + const invalidate = () => client.invalidateQueries({ queryKey: [KEY] }); + return { + initialize: useMutation({ mutationFn: initializeAssistantKnowledgeBase, onSuccess: invalidate }), + upload: useMutation({ mutationFn: uploadAssistantKnowledgeFiles, onSuccess: invalidate }), + reimportSource: useMutation({ mutationFn: reimportAssistantKnowledgeSource, onSuccess: invalidate }), + deleteSource: useMutation({ mutationFn: deleteAssistantKnowledgeSource, onSuccess: invalidate }), + rollback: useMutation({ + mutationFn: ({ slug, revisionId }: { slug: string; revisionId: string }) => + rollbackAssistantWikiRevision(slug, revisionId), + onSuccess: invalidate, + }), + }; +} diff --git a/frontend-web/src/strategy-components/api/skill-knowledge.ts b/frontend-web/src/strategy-components/api/skill-knowledge.ts new file mode 100644 index 00000000..cc8f7ccc --- /dev/null +++ b/frontend-web/src/strategy-components/api/skill-knowledge.ts @@ -0,0 +1,277 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { apiFetch } from "@/core/api/fetch-client"; +import { getBackendBaseURL } from "@/core/config"; + +const BASE = () => `${getBackendBaseURL()}/api/skill-knowledge`; + +export type SkillKnowledgeTargetType = "weknora" | "assistant"; +export type SkillKnowledgeTargetMode = + | "wiki" + | "document" + | "faq" + | "conversation_archive" + | "readonly_sidecar" + | "assistant"; + +export interface SkillKnowledgeTarget { + target_type: SkillKnowledgeTargetType; + target_mode: SkillKnowledgeTargetMode; + target_id: string; + target_name: string; + remote_business_key?: string | null; + readonly?: boolean; +} + +export interface SkillKnowledgeBinding { + id: string; + skill_name: string; + target_type: SkillKnowledgeTargetType; + target_mode: SkillKnowledgeTargetMode; + target_id: string; + target_name: string; + remote_business_key?: string | null; + remote_refs?: Array<{ + provider: string; + remote_kind: string; + remote_id: string; + slug?: string; + }>; + status: string; + active: boolean; + auto_sync_enabled: boolean; + synced_digest?: string | null; + last_synced_at?: string | null; + last_error_message?: string | null; +} + +export interface SkillKnowledgeSyncItem { + id: string; + job_id: string; + binding_id: string; + skill_name: string; + target_type: SkillKnowledgeTargetType; + target_mode: SkillKnowledgeTargetMode; + target_id: string; + status: string; + phase: string; + progress: number; + attempts: number; + last_error_message?: string | null; +} + +export interface SkillKnowledgeJob { + id: string; + status: string; + total_count: number; + success_count: number; + partial_failed_count: number; + failed_count: number; + review_mode: "required" | "auto_high_confidence" | "off"; + created_at: string; + completed_at?: string | null; + items?: SkillKnowledgeSyncItem[]; +} + +export interface SkillKnowledgeReview { + id: string; + job_id: string; + item_id: string; + binding_id: string; + object_type: "entity" | "relation" | "wiki" | "alignment"; + object_key: string; + original: Record; + edited?: Record | null; + status: string; + reason?: string | null; +} + +export interface SkillKnowledgeAutoSyncSettings { + watch_enabled: boolean; + schedule_enabled: boolean; + schedule_cron: string; + debounce_seconds: number; + write_remote_enabled: boolean; + review_mode: "required" | "auto_high_confidence" | "off"; +} + +async function readJson(response: Response): Promise { + if (!response.ok) { + const data = await response.json().catch(() => null); + const detail = + data && typeof data === "object" && "detail" in data + ? JSON.stringify((data as { detail: unknown }).detail) + : `请求失败 (${response.status})`; + throw new Error(detail.replace(/^"|"$/g, "")); + } + return (await response.json()) as T; +} + +export async function listSkillKnowledgeBindings( + skillNames?: string[], +): Promise { + const params = new URLSearchParams(); + for (const name of skillNames ?? []) params.append("skill_names", name); + const response = await apiFetch(`${BASE()}/bindings?${params.toString()}`); + return (await readJson<{ bindings: SkillKnowledgeBinding[] }>(response)).bindings; +} + +export async function createSkillKnowledgeJob(input: { + skill_names: string[]; + targets: SkillKnowledgeTarget[]; + force?: boolean; + review_mode?: "required" | "auto_high_confidence" | "off"; +}): Promise { + return readJson( + await apiFetch(`${BASE()}/sync-jobs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }), + ); +} + +export async function listSkillKnowledgeJobs(): Promise { + const response = await apiFetch(`${BASE()}/sync-jobs`); + return (await readJson<{ jobs: SkillKnowledgeJob[] }>(response)).jobs; +} + +export async function getSkillKnowledgeJob(id: string): Promise { + return readJson(await apiFetch(`${BASE()}/sync-jobs/${encodeURIComponent(id)}`)); +} + +export async function listSkillKnowledgeReviews(): Promise { + const response = await apiFetch(`${BASE()}/reviews?status=pending`); + return (await readJson<{ reviews: SkillKnowledgeReview[] }>(response)).reviews; +} + +export async function reviewSkillKnowledge(input: { + review_ids: string[]; + action: "approve" | "reject"; + edits?: Record>; + reason?: string; +}) { + return readJson<{ updated: number; resumed_item_ids: string[] }>( + await apiFetch(`${BASE()}/reviews/actions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }), + ); +} + +export async function retrySkillKnowledgeItem(itemId: string) { + return readJson( + await apiFetch(`${BASE()}/sync-items/${encodeURIComponent(itemId)}/retry`, { + method: "POST", + }), + ); +} + +export async function detachSkillKnowledgeBinding( + bindingId: string, + deleteContributions: boolean, +) { + return readJson( + await apiFetch(`${BASE()}/bindings/${encodeURIComponent(bindingId)}/detach`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ delete_contributions: deleteContributions }), + }), + ); +} + +export async function resyncSkillKnowledgeBinding(bindingId: string) { + return readJson( + await apiFetch(`${BASE()}/bindings/${encodeURIComponent(bindingId)}/resync`, { + method: "POST", + }), + ); +} + +export async function setSkillKnowledgeBindingAutoSync( + bindingId: string, + enabled: boolean, +) { + return readJson( + await apiFetch( + `${BASE()}/bindings/${encodeURIComponent(bindingId)}/${enabled ? "enable" : "disable"}-auto-sync`, + { method: "POST" }, + ), + ); +} + +export async function getSkillKnowledgeAutoSyncSettings() { + return readJson( + await apiFetch(`${BASE()}/auto-sync-settings`), + ); +} + +export async function updateSkillKnowledgeAutoSyncSettings( + input: Partial, +) { + return readJson( + await apiFetch(`${BASE()}/auto-sync-settings`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }), + ); +} + +const KEY = "skill-knowledge"; + +export function useSkillKnowledgeBindings(skillNames?: string[]) { + return useQuery({ + queryKey: [KEY, "bindings", ...(skillNames ?? [])], + queryFn: () => listSkillKnowledgeBindings(skillNames), + refetchOnWindowFocus: false, + }); +} + +export function useSkillKnowledgeJobs() { + return useQuery({ + queryKey: [KEY, "jobs"], + queryFn: listSkillKnowledgeJobs, + refetchInterval: (query) => + (query.state.data ?? []).some((job) => ["queued", "running"].includes(job.status)) + ? 1500 + : false, + }); +} + +export function useSkillKnowledgeReviews() { + return useQuery({ queryKey: [KEY, "reviews"], queryFn: listSkillKnowledgeReviews }); +} + +export function useSkillKnowledgeAutoSyncSettings() { + return useQuery({ + queryKey: [KEY, "auto-sync-settings"], + queryFn: getSkillKnowledgeAutoSyncSettings, + }); +} + +export function useSkillKnowledgeActions() { + const client = useQueryClient(); + const invalidate = () => client.invalidateQueries({ queryKey: [KEY] }); + return { + createJob: useMutation({ mutationFn: createSkillKnowledgeJob, onSuccess: invalidate }), + review: useMutation({ mutationFn: reviewSkillKnowledge, onSuccess: invalidate }), + retry: useMutation({ mutationFn: retrySkillKnowledgeItem, onSuccess: invalidate }), + resync: useMutation({ mutationFn: resyncSkillKnowledgeBinding, onSuccess: invalidate }), + setAutoSync: useMutation({ + mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => + setSkillKnowledgeBindingAutoSync(id, enabled), + onSuccess: invalidate, + }), + updateAutoSyncSettings: useMutation({ + mutationFn: updateSkillKnowledgeAutoSyncSettings, + onSuccess: invalidate, + }), + detach: useMutation({ + mutationFn: ({ id, deleteContributions }: { id: string; deleteContributions: boolean }) => + detachSkillKnowledgeBinding(id, deleteContributions), + onSuccess: invalidate, + }), + }; +} diff --git a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx index 136f79c9..593e2a4a 100644 --- a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx +++ b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx @@ -1,4 +1,4 @@ -import { AlertCircle, Edit3, FileText, Loader2, RefreshCw, Trash2 } from "lucide-react"; +import { AlertCircle, Database, Edit3, FileText, Loader2, RefreshCw, Trash2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { Navigate, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { toast } from "sonner"; @@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { MarkdownCodeEditor } from "@/components/wiki/MarkdownCodeEditor"; import { getBackendBaseURL } from "@/core/config"; +import { getStoredAuth } from "@/core/auth"; import { buildWeKnoraWikiPagePath } from "@/core/llmwiki/source-links"; import { createLlmWikiWeKnoraEmbedSession, @@ -23,6 +24,7 @@ import { useLlmWikiWikiPage, useLlmWikiRuntime, } from "@/strategy-components/api/llmwiki"; +import { exportLlmWikiToAssistant } from "@/strategy-components/api/assistant-knowledge"; const LIST_PATH = "/page/workspace/knowledge"; @@ -104,6 +106,7 @@ export function WeKnoraKnowledgeDetailPage() { const [frameSrc, setFrameSrc] = useState(""); const [frameVersion, setFrameVersion] = useState(0); const [frameError, setFrameError] = useState(null); + const [assistantImporting, setAssistantImporting] = useState(false); const runtime = useLlmWikiRuntime(); const details = useLlmWikiKnowledgeBase(knowledgeBaseId || null, !!knowledgeBaseId); @@ -255,6 +258,23 @@ export function WeKnoraKnowledgeDetailPage() { )}
+ {getStoredAuth()?.system_role === "admin" && ( + + )}