Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-nj
This commit is contained in:
commit
f2e81d8875
66 files changed
+8516
-253
No files matched your search
+18
-10
@@ -1,6 +1,9 @@
|
||||
# Workspace/editor
|
||||
.vscode/
|
||||
frontend-web/.vscode/
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/extensions.json
|
||||
frontend-web/.vscode/
|
||||
node_modules/
|
||||
# Claude Code local-only settings (per-user permissions, not shared)
|
||||
.claude/settings.local.json
|
||||
@@ -52,8 +55,9 @@ offline-backend-20260505/bundle/start_backend.sh
|
||||
offline-backend-20260505/bundle/stop_backend.sh
|
||||
|
||||
# offline-backend-20260512 runtime artifacts (large files, local config, data)
|
||||
offline-backend-20260512/.env.example
|
||||
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
|
||||
offline-backend-20260512/.env.example
|
||||
offline-backend-20260512/.env
|
||||
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
|
||||
offline-backend-20260512/SHA256SUMS
|
||||
offline-backend-20260512/config.yaml.example
|
||||
offline-backend-20260512/data/
|
||||
@@ -61,12 +65,14 @@ offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
|
||||
offline-backend-20260512/extensions_config.json
|
||||
offline-backend-20260512/skills/
|
||||
offline-backend-20260512/wheelhouse/
|
||||
offline-backend-20260512/backend/.deer-flow/data/*.db
|
||||
offline-backend-20260512/backend/.deer-flow/data/*.db-*
|
||||
offline-backend-20260512/backend/.deer-flow/*
|
||||
offline-backend-20260512/start_backend.sh
|
||||
offline-backend-20260512/stop_backend.sh
|
||||
offline-backend-20260512/check_backend.sh
|
||||
offline-backend-20260512/backend/.deer-flow/data/*.db
|
||||
offline-backend-20260512/backend/.deer-flow/data/*.db-*
|
||||
offline-backend-20260512/backend/.deer-flow/*
|
||||
offline-backend-20260512/backend/.env
|
||||
offline-backend-20260512/backend/.env.local
|
||||
offline-backend-20260512/start_backend.sh
|
||||
offline-backend-20260512/stop_backend.sh
|
||||
offline-backend-20260512/check_backend.sh
|
||||
|
||||
hermes-agent-main/
|
||||
|
||||
@@ -81,6 +87,8 @@ skills/
|
||||
hermes-agent-main/
|
||||
.gitignore
|
||||
offline-backend-20260512/backend/tests/*
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
|
||||
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
|
||||
# Keep the model configuration regression coverage versioned even though
|
||||
# this deployment repository otherwise ignores newly added backend tests.
|
||||
!offline-backend-20260512/backend/tests/test_model_management.py
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"ms-python.python",
|
||||
"ms-python.debugpy"
|
||||
]
|
||||
}
|
||||
Vendored
+66
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "DeerFlow Backend FastAPI (8001)",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "uvicorn",
|
||||
"args": [
|
||||
"app.gateway.app:app",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"8001",
|
||||
"--reload"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/offline-backend-20260512/backend",
|
||||
"python": "${workspaceFolder}/offline-backend-20260512/backend/.venv/Scripts/python.exe",
|
||||
"envFile": "${workspaceFolder}/offline-backend-20260512/.env",
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/offline-backend-20260512/backend;${workspaceFolder}/offline-backend-20260512/backend/.deer-flow/python-vendor",
|
||||
"GATEWAY_CORS_ORIGINS": "http://localhost:5174,http://127.0.0.1:5174,http://localhost:8080,http://127.0.0.1:8080"
|
||||
},
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": false,
|
||||
"subProcess": true
|
||||
},
|
||||
{
|
||||
"name": "DeerFlow Frontend Vite (5174)",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": [
|
||||
"exec",
|
||||
"vite",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"5174"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/frontend-web",
|
||||
"env": {
|
||||
"VITE_BACKEND_BASE_URL": "http://127.0.0.1:8001",
|
||||
"VITE_LANGGRAPH_BASE_URL": "http://127.0.0.1:8001/api"
|
||||
},
|
||||
"console": "integratedTerminal",
|
||||
"outputCapture": "std",
|
||||
"serverReadyAction": {
|
||||
"pattern": "Local:\\s+(https?://[^\\s]+)",
|
||||
"uriFormat": "%s",
|
||||
"action": "debugWithChrome",
|
||||
"webRoot": "${workspaceFolder}/frontend-web"
|
||||
}
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "DeerFlow Full Stack (Backend + Frontend)",
|
||||
"configurations": [
|
||||
"DeerFlow Backend FastAPI (8001)",
|
||||
"DeerFlow Frontend Vite (5174)"
|
||||
],
|
||||
"stopAll": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "DeerFlow:检查本地开发环境",
|
||||
"type": "shell",
|
||||
"command": "$backendPython = '${workspaceFolder}\\offline-backend-20260512\\backend\\.venv\\Scripts\\python.exe'; if (-not (Test-Path -LiteralPath $backendPython)) { throw '后端虚拟环境不存在,请先在 backend 目录执行 uv sync' }; & $backendPython -c \"import uvicorn; print('Backend Python OK')\"; pnpm --dir '${workspaceFolder}\\frontend-web' exec vite --version",
|
||||
"options": {
|
||||
"shell": {
|
||||
"executable": "powershell.exe",
|
||||
"args": [
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command"
|
||||
]
|
||||
}
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "DeerFlow:前端生产构建",
|
||||
"type": "process",
|
||||
"command": "pnpm",
|
||||
"args": [
|
||||
"build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend-web"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "DeerFlow:WeKnora 专项测试",
|
||||
"type": "process",
|
||||
"command": "${workspaceFolder}/offline-backend-20260512/backend/.venv/Scripts/python.exe",
|
||||
"args": [
|
||||
"-m",
|
||||
"pytest",
|
||||
"tests/test_llmwiki_weknora.py",
|
||||
"-v"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/offline-backend-20260512/backend",
|
||||
"env": {
|
||||
"PYTHONPATH": "."
|
||||
}
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
SlidersHorizontal,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -18,7 +19,7 @@ function HeaderButton({
|
||||
variant = "outline",
|
||||
className,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: "outline" | "primary";
|
||||
@@ -52,6 +53,7 @@ export function WikiHeader({
|
||||
onCreate,
|
||||
onManageFolders,
|
||||
onManageTemplates,
|
||||
extraActions,
|
||||
}: {
|
||||
onToggleSidebar: () => void;
|
||||
onImportFile: () => void;
|
||||
@@ -61,6 +63,7 @@ export function WikiHeader({
|
||||
onCreate: () => void;
|
||||
onManageFolders: () => void;
|
||||
onManageTemplates: () => void;
|
||||
extraActions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900">
|
||||
@@ -79,6 +82,7 @@ export function WikiHeader({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{extraActions}
|
||||
<HeaderButton
|
||||
icon={<Upload className="size-4" />}
|
||||
label="导入文件"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
BotIcon,
|
||||
CheckIcon,
|
||||
DatabaseIcon,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
|
||||
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
|
||||
import { AgentRecentChats } from "@/components/workspace/agents/agent-recent-chats";
|
||||
import { useArtifactsOptional } from "@/components/workspace/artifacts";
|
||||
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
|
||||
@@ -80,6 +82,7 @@ interface AgentDraft {
|
||||
templateName: string;
|
||||
/** 会商席位「技能识别强化」开关。 */
|
||||
seatSkillDirective: boolean;
|
||||
knowledgeBaseIds: string[];
|
||||
}
|
||||
|
||||
function draftFromAgent(agent: Agent): AgentDraft {
|
||||
@@ -93,6 +96,7 @@ function draftFromAgent(agent: Agent): AgentDraft {
|
||||
featured: agent.featured_order != null,
|
||||
templateName: agent.template_name ?? "",
|
||||
seatSkillDirective: agent.seat_skill_directive ?? false,
|
||||
knowledgeBaseIds: agent.llmwiki_knowledge_base_ids ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -334,6 +338,11 @@ export function AgentDetailPanel({
|
||||
request.template_name = draft.templateName.trim();
|
||||
if (draft.seatSkillDirective !== (agent.seat_skill_directive ?? false))
|
||||
request.seat_skill_directive = draft.seatSkillDirective;
|
||||
if (
|
||||
JSON.stringify(draft.knowledgeBaseIds) !==
|
||||
JSON.stringify(agent.llmwiki_knowledge_base_ids ?? [])
|
||||
)
|
||||
request.llmwiki_knowledge_base_ids = draft.knowledgeBaseIds;
|
||||
if (nextPublished !== agent.published) request.published = nextPublished;
|
||||
if (JSON.stringify(draft.skills) !== JSON.stringify(agent.skills ?? []))
|
||||
request.skills = draft.skills;
|
||||
@@ -630,6 +639,13 @@ export function AgentDetailPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!editing && (agent.llmwiki_knowledge_base_ids?.length ?? 0) > 0 && (
|
||||
<div className="text-muted-foreground mt-2 flex items-center gap-1 text-xs">
|
||||
<DatabaseIcon className="size-3 shrink-0" />
|
||||
LLMWiki 知识库:{agent.llmwiki_knowledge_base_ids?.length ?? 0} 个
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Publish / featured toggles — only meaningful in edit mode. */}
|
||||
{editing && draft && (
|
||||
<div className="mt-3 space-y-2">
|
||||
@@ -647,6 +663,14 @@ export function AgentDetailPanel({
|
||||
配置后,对话生成的 Markdown 文件可一键发布到知识入库接口。
|
||||
</p>
|
||||
</div>
|
||||
<LlmWikiKnowledgeSelector
|
||||
source="all"
|
||||
value={draft.knowledgeBaseIds}
|
||||
onChange={(knowledgeBaseIds) =>
|
||||
setDraft({ ...draft, knowledgeBaseIds })
|
||||
}
|
||||
variant="panel"
|
||||
/>
|
||||
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
|
||||
<div className="pr-3">
|
||||
<p className="text-sm font-medium">会商技能识别强化</p>
|
||||
|
||||
@@ -22,9 +22,10 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
} from "react";
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
PromptInput,
|
||||
@@ -133,8 +134,9 @@ export function InputBox({
|
||||
onFollowupsVisibilityChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
promptPrefix,
|
||||
promptPrefix,
|
||||
agentSelector,
|
||||
knowledgeSelector,
|
||||
extraModels = [],
|
||||
assistantId,
|
||||
hideAdminRecommendedQuestions,
|
||||
@@ -196,6 +198,8 @@ export function InputBox({
|
||||
selectedAgentId: string | null;
|
||||
onAgentChange: (agentId: string | null) => void;
|
||||
};
|
||||
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
|
||||
knowledgeSelector?: ReactNode;
|
||||
/** Page-scoped model choices implemented outside the DeerFlow model API. */
|
||||
extraModels?: Model[];
|
||||
}) {
|
||||
@@ -1173,9 +1177,10 @@ export function InputBox({
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
)}
|
||||
</PromptInputTools>
|
||||
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
|
||||
{agentSelector && (
|
||||
</PromptInputTools>
|
||||
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
|
||||
{knowledgeSelector}
|
||||
{agentSelector && (
|
||||
<AgentSelector
|
||||
selectedAgentId={agentSelector.selectedAgentId}
|
||||
onAgentChange={agentSelector.onAgentChange}
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Check,
|
||||
Database,
|
||||
Folder,
|
||||
Globe2,
|
||||
Loader2,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type LlmWikiKnowledgeBase,
|
||||
useAgentLlmWikiKnowledgeBases,
|
||||
useLlmWikiKnowledgeBases,
|
||||
useLlmWikiRuntime,
|
||||
} from "@/strategy-components/api/llmwiki";
|
||||
|
||||
type SelectorSource = "all" | "agent";
|
||||
type SelectorVariant = "popover" | "panel";
|
||||
|
||||
function groupRows(rows: LlmWikiKnowledgeBase[]) {
|
||||
return [
|
||||
{
|
||||
key: "personal",
|
||||
label: "我的知识空间",
|
||||
rows: rows.filter((item) => item.publication_status !== "published" || item.is_owner),
|
||||
},
|
||||
{
|
||||
key: "public",
|
||||
label: "公共知识空间",
|
||||
rows: rows.filter((item) => item.publication_status === "published" && !item.is_owner),
|
||||
},
|
||||
].filter((group) => group.rows.length > 0);
|
||||
}
|
||||
|
||||
function SpaceIcon({ item }: { item: LlmWikiKnowledgeBase }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-md",
|
||||
item.publication_status === "published"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{item.publication_status === "published" ? (
|
||||
<Globe2 className="size-3.5" />
|
||||
) : (
|
||||
<Folder className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectionContent({
|
||||
rows,
|
||||
value,
|
||||
onChange,
|
||||
search,
|
||||
onSearchChange,
|
||||
loading,
|
||||
error,
|
||||
source,
|
||||
variant,
|
||||
}: {
|
||||
rows: LlmWikiKnowledgeBase[];
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
loading: boolean;
|
||||
error: unknown;
|
||||
source: SelectorSource;
|
||||
variant: SelectorVariant;
|
||||
}) {
|
||||
const selected = useMemo(() => new Set(value), [value]);
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLocaleLowerCase();
|
||||
if (!query) return rows;
|
||||
return rows.filter((item) =>
|
||||
`${item.name} ${item.description}`.toLocaleLowerCase().includes(query),
|
||||
);
|
||||
}, [rows, search]);
|
||||
const groups = useMemo(() => groupRows(filtered), [filtered]);
|
||||
const toggle = (id: string) => {
|
||||
onChange(selected.has(id) ? value.filter((item) => item !== id) : [...value, id]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-b p-2.5">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
placeholder="搜索知识空间"
|
||||
className="h-8 bg-muted/40 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn("overflow-y-auto p-1.5", variant === "popover" ? "max-h-64" : "max-h-80")}>
|
||||
{loading ? (
|
||||
<div className="flex h-28 items-center justify-center text-xs text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />正在读取知识空间
|
||||
</div>
|
||||
) : error ? (
|
||||
<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 ? (
|
||||
<div className="flex h-28 items-center justify-center px-4 text-center text-xs text-muted-foreground">
|
||||
{search
|
||||
? "没有匹配的知识空间"
|
||||
: source === "agent"
|
||||
? "该智能体尚未绑定可用知识空间"
|
||||
: "当前账号暂无可用知识空间"}
|
||||
</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)}
|
||||
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)}
|
||||
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>
|
||||
|
||||
<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">已选择 {value.length} 个</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>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange([])}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function LlmWikiKnowledgeSelector({
|
||||
value,
|
||||
onChange,
|
||||
source = "all",
|
||||
agentId,
|
||||
autoSelectAll = false,
|
||||
disabled = false,
|
||||
className,
|
||||
label = "知识空间",
|
||||
variant = "popover",
|
||||
}: {
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
source?: SelectorSource;
|
||||
agentId?: string | null;
|
||||
autoSelectAll?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
label?: string;
|
||||
variant?: SelectorVariant;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const all = useLlmWikiKnowledgeBases(
|
||||
"all",
|
||||
runtime.data?.provider === "weknora",
|
||||
);
|
||||
const agent = useAgentLlmWikiKnowledgeBases(
|
||||
agentId,
|
||||
runtime.data?.provider === "weknora" && source === "agent" && !!agentId,
|
||||
);
|
||||
const rows = all.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 availableIds = useMemo(() => new Set(rows.map((item) => item.id)), [rows]);
|
||||
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
|
||||
const initialized = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!all.data || (source === "agent" && agentId && !agent.data) || initialized.current === initializationKey) return;
|
||||
initialized.current = initializationKey;
|
||||
const valid = value.filter((id) => availableIds.has(id));
|
||||
const next = autoSelectAll && valid.length === 0 && defaultIds.length > 0
|
||||
? defaultIds.filter((id) => availableIds.has(id))
|
||||
: valid;
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setSearch("");
|
||||
}, [open]);
|
||||
|
||||
if (runtime.isError || runtime.data?.provider !== "weknora") return null;
|
||||
|
||||
const content = (
|
||||
<SelectionContent
|
||||
rows={rows}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
loading={loading}
|
||||
error={error}
|
||||
source={source}
|
||||
variant={variant}
|
||||
/>
|
||||
);
|
||||
|
||||
if (variant === "panel") {
|
||||
return (
|
||||
<div className={cn("overflow-hidden rounded-xl border bg-background shadow-sm", className)}>
|
||||
<div className="flex items-start justify-between gap-4 border-b px-4 py-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Database className="size-4 text-primary" />绑定知识空间
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0">{value.length} 个</Badge>
|
||||
</div>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedName = value.length === 1
|
||||
? rows.find((item) => item.id === value[0])?.name
|
||||
: null;
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{loading ? <Loader2 className="size-3.5 animate-spin" /> : <Database className="size-3.5" />}
|
||||
<span className="truncate text-xs font-normal">{selectedName || label}</span>
|
||||
{value.length > 0 && (
|
||||
<span className="flex size-4 items-center justify-center rounded-full bg-primary text-[9px] font-semibold text-primary-foreground">
|
||||
{value.length}
|
||||
</span>
|
||||
)}
|
||||
{value.length > 0 && value.length === rows.length && <Check className="size-3" />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={8} className="w-80 overflow-hidden rounded-xl p-0 shadow-xl">
|
||||
{content}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default LlmWikiKnowledgeSelector;
|
||||
@@ -92,7 +92,7 @@ export function SourceCard({
|
||||
knowledge_base_id: previewTarget.knowledgeBaseId,
|
||||
chunk_id: previewTarget.chunkId,
|
||||
});
|
||||
void apiFetch(`${getBackendBaseURL()}/api/weknora/source-context?${params}`)
|
||||
void apiFetch(`${getBackendBaseURL()}/api/llmwiki/sources/context?${params}`)
|
||||
.then(async (response) => {
|
||||
const payload = (await response.json().catch(() => null)) as unknown;
|
||||
if (!response.ok || !isWeKnoraSourceContextResponse(payload)) {
|
||||
@@ -240,7 +240,18 @@ type WeKnoraSourceContext = {
|
||||
knowledge_base_name?: string;
|
||||
document_title?: string;
|
||||
document_file_name?: string;
|
||||
display_mode?: "wiki" | "chunks";
|
||||
focus_chunk_id: string;
|
||||
wiki_page?: {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
title?: string;
|
||||
page_type?: string;
|
||||
status?: string;
|
||||
summary?: string;
|
||||
content?: string;
|
||||
source_refs?: string[];
|
||||
} | null;
|
||||
chunks: Array<{
|
||||
id: string;
|
||||
chunk_index?: number;
|
||||
@@ -312,6 +323,33 @@ function WeKnoraSourceContextPanel({
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
const wikiPage = context.wiki_page;
|
||||
const wikiContent = cleanReferenceText(
|
||||
[wikiPage?.summary, wikiPage?.content].filter(Boolean).join("\n\n"),
|
||||
[],
|
||||
);
|
||||
if (wikiPage && wikiContent) {
|
||||
return (
|
||||
<div className="max-h-[55vh] space-y-3 overflow-y-auto pr-1">
|
||||
<div className="bg-muted/60 text-muted-foreground rounded-lg px-3 py-2 text-xs">
|
||||
<span>{context.knowledge_base_name || "WeKnora 知识库"}</span>
|
||||
<span> · LLMWiki</span>
|
||||
{wikiPage.page_type && <span> · {wikiPage.page_type}</span>}
|
||||
</div>
|
||||
<section className="border-primary/40 bg-primary/5 rounded-lg border p-3 text-sm leading-relaxed shadow-sm">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{wikiPage.title || wikiPage.slug || "LLMWiki 页面"}
|
||||
</span>
|
||||
<span className="bg-primary text-primary-foreground rounded px-1.5 py-0.5 text-xs font-medium">
|
||||
清洗页
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3 whitespace-pre-wrap">{wikiContent}</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="max-h-[55vh] space-y-3 overflow-y-auto pr-1">
|
||||
<div className="bg-muted/60 text-muted-foreground rounded-lg px-3 py-2 text-xs">
|
||||
|
||||
@@ -61,7 +61,9 @@ export interface Agent {
|
||||
/** 会商席位「技能识别强化」开关。作为会商席位运行时,是否把本智能体配置的技能
|
||||
* (名称 + SKILL.md 路径 + 必须先 read_file 的硬指令) 拼进本轮任务以补偿弱模型。
|
||||
* 与业务链条同名开关取 OR。null/false = 关 (默认)。 */
|
||||
seat_skill_directive?: boolean | null;
|
||||
seat_skill_directive?: boolean | null;
|
||||
/** DeerFlow LLMWiki mapping ids this agent allows conversations to select. */
|
||||
llmwiki_knowledge_base_ids?: string[] | null;
|
||||
tags?: Tag[];
|
||||
featured_order?: number | null;
|
||||
/** Whether the current user has favorited (收藏) this agent. */
|
||||
@@ -96,7 +98,8 @@ export interface CreateAgentRequest {
|
||||
common_questions?: AgentQuestion[] | null;
|
||||
template_name?: string | null;
|
||||
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
|
||||
seat_skill_directive?: boolean | null;
|
||||
seat_skill_directive?: boolean | null;
|
||||
llmwiki_knowledge_base_ids?: string[] | null;
|
||||
}
|
||||
|
||||
export interface UpdateAgentRequest {
|
||||
@@ -113,5 +116,6 @@ export interface UpdateAgentRequest {
|
||||
/** 入库模板名;传空字符串表示清除。 */
|
||||
template_name?: string | null;
|
||||
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
|
||||
seat_skill_directive?: boolean | null;
|
||||
}
|
||||
seat_skill_directive?: boolean | null;
|
||||
llmwiki_knowledge_base_ids?: string[] | null;
|
||||
}
|
||||
@@ -13,19 +13,33 @@ export function buildIntranetSourceUrl(recUuid?: string | null): string {
|
||||
return `${INTRANET_SOURCE_URL_BASE}${encodeURIComponent(recUuid)}`;
|
||||
}
|
||||
|
||||
function getBaseOrigin() {
|
||||
function getBaseOrigin() {
|
||||
if (typeof window !== "undefined") {
|
||||
return window.location.origin;
|
||||
}
|
||||
// Fallback for SSR
|
||||
return "http://localhost:2026";
|
||||
}
|
||||
|
||||
export function getBackendBaseURL() {
|
||||
if (env.NEXT_PUBLIC_BACKEND_BASE_URL) {
|
||||
return new URL(env.NEXT_PUBLIC_BACKEND_BASE_URL, getBaseOrigin())
|
||||
.toString()
|
||||
.replace(/\/+$/, "");
|
||||
return "http://localhost:2026";
|
||||
}
|
||||
|
||||
function alignLoopbackHostname(url: URL): URL {
|
||||
if (typeof window === "undefined") return url;
|
||||
const loopbackHosts = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
||||
if (
|
||||
loopbackHosts.has(url.hostname) &&
|
||||
loopbackHosts.has(window.location.hostname)
|
||||
) {
|
||||
url.hostname = window.location.hostname;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function getBackendBaseURL() {
|
||||
if (env.NEXT_PUBLIC_BACKEND_BASE_URL) {
|
||||
return alignLoopbackHostname(
|
||||
new URL(env.NEXT_PUBLIC_BACKEND_BASE_URL, getBaseOrigin()),
|
||||
)
|
||||
.toString()
|
||||
.replace(/\/+$/, "");
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { createLlmWikiConversationDeposit } from "@/strategy-components/api/llmwiki";
|
||||
|
||||
function isHidden(message: Message): boolean {
|
||||
const extra = (message as { additional_kwargs?: Record<string, unknown> })
|
||||
.additional_kwargs;
|
||||
return extra?.hide_from_ui === true;
|
||||
}
|
||||
|
||||
function messageText(message: Message | undefined): string {
|
||||
return (message ? textOfMessage(message) : "")?.trim() ?? "";
|
||||
}
|
||||
|
||||
function hasText(message: Message | undefined): message is Message {
|
||||
return !!message && messageText(message).length > 0;
|
||||
}
|
||||
|
||||
export function queueLlmWikiConversationDeposit({
|
||||
threadId,
|
||||
messages,
|
||||
assistantId,
|
||||
knowledgeBaseIds,
|
||||
}: {
|
||||
threadId: string | null | undefined;
|
||||
messages: Message[] | undefined;
|
||||
assistantId?: string | null;
|
||||
knowledgeBaseIds?: string[];
|
||||
}) {
|
||||
if (!threadId || !messages?.length) {
|
||||
return;
|
||||
}
|
||||
const visible = messages.filter((message) => !isHidden(message));
|
||||
const assistantIndex = [...visible]
|
||||
.map((message, index) => ({ message, index }))
|
||||
.reverse()
|
||||
.find(({ message }) => message.type === "ai" && hasText(message))?.index;
|
||||
if (assistantIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
const assistantMessage = visible[assistantIndex];
|
||||
const humanMessage = [...visible.slice(0, assistantIndex)]
|
||||
.reverse()
|
||||
.find((message) => message.type === "human" && hasText(message));
|
||||
if (!assistantMessage?.id || !humanMessage) {
|
||||
return;
|
||||
}
|
||||
const question = messageText(humanMessage);
|
||||
const answer = messageText(assistantMessage);
|
||||
if (!question || !answer) {
|
||||
return;
|
||||
}
|
||||
|
||||
void createLlmWikiConversationDeposit({
|
||||
thread_id: threadId,
|
||||
question,
|
||||
answer,
|
||||
human_message_id: humanMessage.id ?? null,
|
||||
assistant_message_id: assistantMessage.id,
|
||||
assistant_id: assistantId ?? null,
|
||||
knowledge_base_ids: knowledgeBaseIds ?? [],
|
||||
}).catch((error) => {
|
||||
console.warn("Failed to queue LLMWiki conversation deposit", error);
|
||||
});
|
||||
}
|
||||
@@ -1146,20 +1146,26 @@ export function useThreadStream({
|
||||
}),
|
||||
);
|
||||
|
||||
const ragModeEnabled =
|
||||
localSettings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
if (ragModeEnabled) {
|
||||
lockThreadRagMode(threadId);
|
||||
}
|
||||
const mergedContext = {
|
||||
...context,
|
||||
...extraContext,
|
||||
};
|
||||
const selectedLlmWikiIds = Array.isArray(
|
||||
mergedContext.llmwiki_knowledge_base_ids,
|
||||
)
|
||||
? mergedContext.llmwiki_knowledge_base_ids
|
||||
: [];
|
||||
const ragModeEnabled =
|
||||
localSettings.display.ragModeEnabled ||
|
||||
isThreadRagModeLocked(threadId) ||
|
||||
selectedLlmWikiIds.length > 0;
|
||||
if (ragModeEnabled) {
|
||||
lockThreadRagMode(threadId);
|
||||
}
|
||||
|
||||
const contextCompressionEnabled =
|
||||
localSettings.display.contextCompressionEnabled ?? false;
|
||||
|
||||
const mergedContext = {
|
||||
...context,
|
||||
...extraContext,
|
||||
};
|
||||
|
||||
const hiddenContextMessages = normalizeHiddenContextMessages(options);
|
||||
const submittedMessages: Message[] = [
|
||||
...hiddenContextMessages.map((item) => ({
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
||||
summarization_enabled?: boolean;
|
||||
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
|
||||
knowledge_rag_enabled?: boolean;
|
||||
/** DeerFlow LLMWiki mapping ids selected for this conversation. */
|
||||
llmwiki_knowledge_base_ids?: string[];
|
||||
/** Direct knowledge-base RAG mode; this request never enters Lead Agent. */
|
||||
execution_profile?: "single_round_rag";
|
||||
agent_id?: string;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { QuestionNavigator } from "@/components/workspace/question-navigator";
|
||||
@@ -24,7 +25,9 @@ import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { normalizeAgentQuestions, useAgent } from "@/core/agents";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
|
||||
@@ -69,6 +72,26 @@ const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder";
|
||||
const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000;
|
||||
const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000;
|
||||
|
||||
function readPendingKnowledgeSelection(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;
|
||||
knowledgeBaseIds?: unknown;
|
||||
};
|
||||
if (parsed.agentId !== agentId || !Array.isArray(parsed.knowledgeBaseIds)) {
|
||||
return null;
|
||||
}
|
||||
return parsed.knowledgeBaseIds
|
||||
.map((item) => String(item).trim())
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyForHiddenContext(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
@@ -244,6 +267,79 @@ export default function AgentChatPage({
|
||||
useThreadChat();
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
const pendingKnowledgeSelection = useRef(
|
||||
readPendingKnowledgeSelection(agent_id),
|
||||
);
|
||||
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>(
|
||||
() => pendingKnowledgeSelection.current ?? [],
|
||||
);
|
||||
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(
|
||||
() => pendingKnowledgeSelection.current !== null,
|
||||
);
|
||||
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
|
||||
const knowledgeSelectionThread = useRef<string | null>(null);
|
||||
const knowledgeSelectionAgent = useRef(agent_id);
|
||||
const activeThreadIdRef = useRef<string | null | undefined>(threadId);
|
||||
|
||||
const changeKnowledgeSelection = useCallback((ids: string[]) => {
|
||||
setSelectedKnowledgeBaseIds(ids);
|
||||
setKnowledgeSelectionReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
activeThreadIdRef.current = threadId;
|
||||
}, [threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (knowledgeSelectionAgent.current === agent_id) return;
|
||||
knowledgeSelectionAgent.current = agent_id;
|
||||
const pending = readPendingKnowledgeSelection(agent_id);
|
||||
pendingKnowledgeSelection.current = pending;
|
||||
knowledgeSelectionThread.current = null;
|
||||
setSelectedKnowledgeBaseIds(pending ?? []);
|
||||
setKnowledgeSelectionReady(pending !== null);
|
||||
}, [agent_id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewThread) {
|
||||
if (knowledgeSelectionThread.current !== "__new__") {
|
||||
setSelectedKnowledgeBaseIds(pendingKnowledgeSelection.current ?? []);
|
||||
setKnowledgeSelectionReady(pendingKnowledgeSelection.current !== null);
|
||||
}
|
||||
knowledgeSelectionThread.current = "__new__";
|
||||
setKnowledgeSelectionLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!threadId || knowledgeSelectionThread.current === threadId) return;
|
||||
knowledgeSelectionThread.current = threadId;
|
||||
setKnowledgeSelectionLoading(true);
|
||||
void getAPIClient()
|
||||
.threads.get(threadId)
|
||||
.then((record) => {
|
||||
if (knowledgeSelectionThread.current !== threadId) return;
|
||||
const metadata = record.metadata as Record<string, unknown> | undefined;
|
||||
const raw = metadata?.llmwiki_knowledge_base_ids;
|
||||
if (Array.isArray(raw)) {
|
||||
setSelectedKnowledgeBaseIds(
|
||||
raw.map((item) => String(item).trim()).filter(Boolean),
|
||||
);
|
||||
setKnowledgeSelectionReady(true);
|
||||
} else {
|
||||
setSelectedKnowledgeBaseIds([]);
|
||||
setKnowledgeSelectionReady(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (knowledgeSelectionThread.current === threadId) {
|
||||
setKnowledgeSelectionReady(false);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (knowledgeSelectionThread.current === threadId) {
|
||||
setKnowledgeSelectionLoading(false);
|
||||
}
|
||||
});
|
||||
}, [isNewThread, threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewThread) {
|
||||
@@ -253,14 +349,34 @@ export default function AgentChatPage({
|
||||
}, [isNewThread, threadId, agent_id]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
const conversationMetadata = useMemo(
|
||||
() => ({
|
||||
...(taskId ? taskThreadMetadata(taskContext) : {}),
|
||||
...(knowledgeSelectionReady
|
||||
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
|
||||
: {}),
|
||||
}),
|
||||
[knowledgeSelectionReady, selectedKnowledgeBaseIds, taskContext, taskId],
|
||||
);
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
assistantId: agent_id,
|
||||
context: { ...settings.context, agent_id },
|
||||
context: {
|
||||
...settings.context,
|
||||
agent_id,
|
||||
...(knowledgeSelectionReady
|
||||
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
|
||||
: {}),
|
||||
},
|
||||
// 任务闸门:新建 thread 打 metadata.taskId 标签,供任务对话列表(1:N)按任务过滤;
|
||||
// xdfx 额外打 metadata.actionId 标签,按「行动id」划分聊天记录(见 taskThreadMetadata)。
|
||||
threadMetadata: taskId ? taskThreadMetadata(taskContext) : undefined,
|
||||
threadMetadata:
|
||||
Object.keys(conversationMetadata).length > 0
|
||||
? conversationMetadata
|
||||
: undefined,
|
||||
onStart: (createdThreadId) => {
|
||||
knowledgeSelectionThread.current = createdThreadId;
|
||||
activeThreadIdRef.current = createdThreadId;
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
// Keep the current component mounted while updating the hash route.
|
||||
@@ -286,6 +402,12 @@ export default function AgentChatPage({
|
||||
} catch {
|
||||
/* 上报绝不影响问答 */
|
||||
}
|
||||
queueLlmWikiConversationDeposit({
|
||||
threadId: activeThreadIdRef.current,
|
||||
messages: state.messages,
|
||||
assistantId: agent_id,
|
||||
knowledgeBaseIds: selectedKnowledgeBaseIds,
|
||||
});
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
@@ -581,10 +703,23 @@ export default function AgentChatPage({
|
||||
autoFocus
|
||||
status={status}
|
||||
context={settings.context}
|
||||
disabled={isDemoMode}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
knowledgeSelector={
|
||||
<LlmWikiKnowledgeSelector
|
||||
source="agent"
|
||||
agentId={agent_id}
|
||||
value={selectedKnowledgeBaseIds}
|
||||
onChange={changeKnowledgeSelection}
|
||||
autoSelectAll={
|
||||
isNewThread && pendingKnowledgeSelection.current === null
|
||||
}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
||||
label="知识空间"
|
||||
/>
|
||||
}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
@@ -636,7 +771,7 @@ export default function AgentChatPage({
|
||||
assistantId={agent_id}
|
||||
status={status}
|
||||
context={settings.context}
|
||||
disabled={isDemoMode}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { KnowledgeSaveTrigger } from "@/components/workspace/knowledge-save-trigger";
|
||||
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import {
|
||||
MessageList,
|
||||
@@ -56,6 +57,7 @@ import {
|
||||
useThreadSettings,
|
||||
} from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
|
||||
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
|
||||
import { useLayoutMode } from "@/core/system-settings/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
@@ -126,12 +128,29 @@ export default function ChatPage() {
|
||||
// In-memory only — explicitly overrides any value persisted by the old
|
||||
// per-thread header toggle so stale localStorage can't re-enable it.
|
||||
const [knowledgeRagEnabled] = useKnowledgeRagEnabled();
|
||||
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
|
||||
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(false);
|
||||
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
|
||||
const knowledgeSelectionThread = useRef<string | null>(null);
|
||||
const activeThreadIdRef = useRef<string | null | undefined>(threadId);
|
||||
const changeKnowledgeSelection = useCallback((ids: string[]) => {
|
||||
setSelectedKnowledgeBaseIds(ids);
|
||||
setKnowledgeSelectionReady(true);
|
||||
}, []);
|
||||
const streamContext = useMemo(
|
||||
() => ({
|
||||
...settings.context,
|
||||
knowledge_rag_enabled: knowledgeRagEnabled,
|
||||
...(knowledgeSelectionReady
|
||||
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
|
||||
: {}),
|
||||
}),
|
||||
[settings.context, knowledgeRagEnabled],
|
||||
[
|
||||
settings.context,
|
||||
knowledgeRagEnabled,
|
||||
knowledgeSelectionReady,
|
||||
selectedKnowledgeBaseIds,
|
||||
],
|
||||
);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
@@ -155,6 +174,10 @@ export default function ChatPage() {
|
||||
const { selectedArtifact } = useArtifacts();
|
||||
useSpecificChatMode();
|
||||
|
||||
useEffect(() => {
|
||||
activeThreadIdRef.current = threadId;
|
||||
}, [threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
@@ -163,12 +186,54 @@ export default function ChatPage() {
|
||||
if (isNewThread) {
|
||||
setHasStartedConversation(false);
|
||||
setPromptPrefixOn(false);
|
||||
setSelectedKnowledgeBaseIds([]);
|
||||
setKnowledgeSelectionReady(false);
|
||||
setKnowledgeSelectionLoading(false);
|
||||
knowledgeSelectionThread.current = "__new__";
|
||||
// Pre-select the agent the user last started a conversation with so a
|
||||
// fresh chat defaults to it. Validated against the known agent list below.
|
||||
setSelectedAgentId(getLastAgentId());
|
||||
}
|
||||
}, [isNewThread, threadId]);
|
||||
|
||||
// The knowledge-base choice belongs to the conversation, not to a browser
|
||||
// component instance. Restore it from LangGraph thread metadata whenever an
|
||||
// existing conversation is opened. An explicit empty array means the user
|
||||
// deliberately disabled LLMWiki retrieval for that conversation.
|
||||
useEffect(() => {
|
||||
if (isNewThread || !threadId || knowledgeSelectionThread.current === threadId) {
|
||||
return;
|
||||
}
|
||||
knowledgeSelectionThread.current = threadId;
|
||||
setKnowledgeSelectionLoading(true);
|
||||
void getAPIClient(isMock)
|
||||
.threads.get(threadId)
|
||||
.then((record) => {
|
||||
if (knowledgeSelectionThread.current !== threadId) return;
|
||||
const metadata = record.metadata as Record<string, unknown> | undefined;
|
||||
const raw = metadata?.llmwiki_knowledge_base_ids;
|
||||
if (Array.isArray(raw)) {
|
||||
setSelectedKnowledgeBaseIds(
|
||||
raw.map((item) => String(item).trim()).filter(Boolean),
|
||||
);
|
||||
setKnowledgeSelectionReady(true);
|
||||
} else {
|
||||
setSelectedKnowledgeBaseIds([]);
|
||||
setKnowledgeSelectionReady(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (knowledgeSelectionThread.current === threadId) {
|
||||
setKnowledgeSelectionReady(false);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (knowledgeSelectionThread.current === threadId) {
|
||||
setKnowledgeSelectionLoading(false);
|
||||
}
|
||||
});
|
||||
}, [isMock, isNewThread, threadId]);
|
||||
|
||||
// Drop a restored last-used agent that is no longer in the selector list
|
||||
// (deleted / unpublished / no longer curated), so we never hand off to a
|
||||
// dead agent. Wait for the list to load before deciding.
|
||||
@@ -180,6 +245,8 @@ export default function ChatPage() {
|
||||
if (known.length === 0) return;
|
||||
if (!known.some((a) => a.id === selectedAgentId)) {
|
||||
setSelectedAgentId(null);
|
||||
setSelectedKnowledgeBaseIds([]);
|
||||
setKnowledgeSelectionReady(false);
|
||||
}
|
||||
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
|
||||
|
||||
@@ -188,8 +255,13 @@ export default function ChatPage() {
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
context: streamContext,
|
||||
threadMetadata: knowledgeSelectionReady
|
||||
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
|
||||
: undefined,
|
||||
isMock,
|
||||
onStart: (createdThreadId) => {
|
||||
knowledgeSelectionThread.current = createdThreadId;
|
||||
activeThreadIdRef.current = createdThreadId;
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
// Keep the current component mounted while updating the hash route.
|
||||
@@ -215,6 +287,12 @@ export default function ChatPage() {
|
||||
} catch {
|
||||
/* 上报绝不影响问答 */
|
||||
}
|
||||
queueLlmWikiConversationDeposit({
|
||||
threadId: activeThreadIdRef.current,
|
||||
messages: state.messages,
|
||||
assistantId: selectedAgentId,
|
||||
knowledgeBaseIds: selectedKnowledgeBaseIds,
|
||||
});
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages.at(-1);
|
||||
@@ -321,7 +399,12 @@ export default function ChatPage() {
|
||||
void (async () => {
|
||||
try {
|
||||
const created = await getAPIClient(isMock).threads.create({
|
||||
metadata: { agent_id: selectedAgentId },
|
||||
metadata: {
|
||||
agent_id: selectedAgentId,
|
||||
...(knowledgeSelectionReady
|
||||
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
sessionStorage.setItem(
|
||||
"pendingAgentMessage",
|
||||
@@ -329,6 +412,9 @@ export default function ChatPage() {
|
||||
agentId: selectedAgentId,
|
||||
threadId: created.thread_id,
|
||||
text,
|
||||
...(knowledgeSelectionReady
|
||||
? { knowledgeBaseIds: selectedKnowledgeBaseIds }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
saveLastAgentId(selectedAgentId);
|
||||
@@ -387,6 +473,8 @@ export default function ChatPage() {
|
||||
hideAdminPrompt,
|
||||
preferencesLoading,
|
||||
selectedAgentId,
|
||||
selectedKnowledgeBaseIds,
|
||||
knowledgeSelectionReady,
|
||||
agentHandoffPending,
|
||||
isMock,
|
||||
router,
|
||||
@@ -483,6 +571,8 @@ export default function ChatPage() {
|
||||
// Passing null (the ✕ clear) wipes the stored preference.
|
||||
const handleAgentChange = useCallback((agentId: string | null) => {
|
||||
setSelectedAgentId(agentId);
|
||||
setSelectedKnowledgeBaseIds([]);
|
||||
setKnowledgeSelectionReady(false);
|
||||
saveLastAgentId(agentId);
|
||||
}, []);
|
||||
const handleStop = useCallback(async () => {
|
||||
@@ -691,6 +781,7 @@ export default function ChatPage() {
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
isUploading ||
|
||||
agentHandoffPending ||
|
||||
knowledgeSelectionLoading ||
|
||||
singleRoundRag.isLoading
|
||||
}
|
||||
onContextChange={(context) =>
|
||||
@@ -705,6 +796,22 @@ export default function ChatPage() {
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
knowledgeSelector={
|
||||
isLandingState ? (
|
||||
<LlmWikiKnowledgeSelector
|
||||
source={selectedAgentId ? "agent" : "all"}
|
||||
agentId={selectedAgentId}
|
||||
value={selectedKnowledgeBaseIds}
|
||||
onChange={changeKnowledgeSelection}
|
||||
autoSelectAll={!!selectedAgentId}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
knowledgeSelectionLoading
|
||||
}
|
||||
label="知识空间"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -530,7 +530,11 @@ function VaultPageDialog({
|
||||
);
|
||||
}
|
||||
|
||||
export default function KnowledgeBasePage() {
|
||||
export default function KnowledgeBasePage({
|
||||
headerExtraActions,
|
||||
}: {
|
||||
headerExtraActions?: ReactNode;
|
||||
} = {}) {
|
||||
const navigate = useNavigate();
|
||||
const { noteId } = useParams<{ noteId: string }>();
|
||||
|
||||
@@ -653,6 +657,7 @@ export default function KnowledgeBasePage() {
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
onManageFolders={() => setFoldersOpen(true)}
|
||||
onManageTemplates={() => setTemplatesOpen(true)}
|
||||
extraActions={headerExtraActions}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Loader2, Settings2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||||
import WeKnoraKnowledgeWorkspace, {
|
||||
ConnectionSettings,
|
||||
} from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
|
||||
import WeKnoraKnowledgeDetailPage from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage";
|
||||
|
||||
import KnowledgeBasePage from "./KnowledgeBasePage";
|
||||
import KnowledgeGraphPage from "./KnowledgeGraphPage";
|
||||
|
||||
type LlmWikiKnowledgePageMode = "list" | "detail" | "graph";
|
||||
|
||||
function RuntimeLoading() {
|
||||
return (
|
||||
<div className="flex h-full min-h-64 items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-5 animate-spin" />
|
||||
正在读取 LLMWiki 运行模式
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminWeKnoraSettingsButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
|
||||
<Settings2 className="mr-2 size-4" />
|
||||
配置 WeKnora
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>LLMWiki 运行模式</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ConnectionSettings />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LegacyKnowledgePage() {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
|
||||
return (
|
||||
<KnowledgeBasePage
|
||||
headerExtraActions={isAdmin ? <AdminWeKnoraSettingsButton /> : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LlmWikiKnowledgePage({
|
||||
mode = "list",
|
||||
}: {
|
||||
mode?: LlmWikiKnowledgePageMode;
|
||||
}) {
|
||||
const runtime = useLlmWikiRuntime();
|
||||
|
||||
if (runtime.isLoading) {
|
||||
return <RuntimeLoading />;
|
||||
}
|
||||
|
||||
if (runtime.isError || runtime.data?.provider !== "weknora") {
|
||||
if (mode === "graph") {
|
||||
return <KnowledgeGraphPage />;
|
||||
}
|
||||
return <LegacyKnowledgePage />;
|
||||
}
|
||||
|
||||
if (mode === "graph") {
|
||||
return <Navigate to="/page/workspace/knowledge" replace />;
|
||||
}
|
||||
|
||||
if (mode === "detail") {
|
||||
return <WeKnoraKnowledgeDetailPage />;
|
||||
}
|
||||
|
||||
return <WeKnoraKnowledgeWorkspace />;
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
|
||||
import {
|
||||
MessageList,
|
||||
type SetupAgentApprovalPayload,
|
||||
@@ -95,6 +96,7 @@ export default function NewAgentPage() {
|
||||
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
|
||||
const [isSavingDraft, setIsSavingDraft] = useState(false);
|
||||
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
||||
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
|
||||
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
|
||||
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
||||
// 恢复已有 bootstrap 会话时该 thread 已存在,无需再 create(置为 true,跳过创建)。
|
||||
@@ -230,6 +232,7 @@ export default function NewAgentPage() {
|
||||
model: modelOverride,
|
||||
template_name: templateNameInput.trim(),
|
||||
seat_skill_directive: seatSkillDirectiveInput,
|
||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
||||
});
|
||||
setAgent(updated);
|
||||
return updated;
|
||||
@@ -252,6 +255,7 @@ export default function NewAgentPage() {
|
||||
model: modelOverride,
|
||||
template_name: templateNameInput.trim(),
|
||||
seat_skill_directive: seatSkillDirectiveInput,
|
||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
||||
});
|
||||
setAgentId(created.id);
|
||||
setAgent(created);
|
||||
@@ -280,6 +284,7 @@ export default function NewAgentPage() {
|
||||
descriptionInput,
|
||||
modelInput,
|
||||
selectedSkills,
|
||||
selectedKnowledgeBaseIds,
|
||||
soulInput,
|
||||
templateNameInput,
|
||||
seatSkillDirectiveInput,
|
||||
@@ -590,6 +595,13 @@ export default function NewAgentPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LlmWikiKnowledgeSelector
|
||||
source="all"
|
||||
value={selectedKnowledgeBaseIds}
|
||||
onChange={setSelectedKnowledgeBaseIds}
|
||||
variant="panel"
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">SOUL.md</label>
|
||||
<Textarea
|
||||
|
||||
@@ -35,8 +35,7 @@ import AdminScheduledTasksPage from './AdminScheduledTasksPage';
|
||||
import AdminSkillsPage from './AdminSkillsPage';
|
||||
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
|
||||
import ScheduledTasksPage from './ScheduledTasksPage';
|
||||
import KnowledgeBasePage from './KnowledgeBasePage';
|
||||
import KnowledgeGraphPage from './KnowledgeGraphPage';
|
||||
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
|
||||
import LlmMetricsPage from './LlmMetricsPage';
|
||||
import CheckpointMigrationPage from './CheckpointMigrationPage';
|
||||
import MemoryPage from './MemoryPage';
|
||||
@@ -605,7 +604,7 @@ export default function WorkspaceRoutes({
|
||||
path="knowledge"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<KnowledgeBasePage />
|
||||
<LlmWikiKnowledgePage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
@@ -613,7 +612,7 @@ export default function WorkspaceRoutes({
|
||||
path="knowledge/graph"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<KnowledgeGraphPage />
|
||||
<LlmWikiKnowledgePage mode="graph" />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
@@ -621,7 +620,7 @@ export default function WorkspaceRoutes({
|
||||
path="knowledge/:noteId"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<KnowledgeBasePage />
|
||||
<LlmWikiKnowledgePage mode="detail" />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,719 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
const API = () => `${getBackendBaseURL()}/api/llmwiki`;
|
||||
const SETTINGS_API = () => `${getBackendBaseURL()}/api/system-settings/llmwiki`;
|
||||
|
||||
export type LlmWikiProvider = "legacy" | "weknora";
|
||||
export type LlmWikiScope = "personal" | "public" | "all";
|
||||
|
||||
export interface LlmWikiRuntime {
|
||||
provider: LlmWikiProvider;
|
||||
api_base_url: string;
|
||||
web_base_url: string;
|
||||
override_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface LlmWikiSettings extends LlmWikiRuntime {
|
||||
file_api_base_url: string;
|
||||
file_web_base_url: string;
|
||||
effective_provider: LlmWikiProvider;
|
||||
}
|
||||
|
||||
export interface LlmWikiKnowledgeBase {
|
||||
id: string;
|
||||
remote_status: "available" | "missing";
|
||||
name: string;
|
||||
description: string;
|
||||
type: "document" | "faq";
|
||||
publication_status: "private" | "pending" | "published" | "rejected";
|
||||
owner_user_id?: string | null;
|
||||
is_owner: boolean;
|
||||
can_write: boolean;
|
||||
knowledge_count: number;
|
||||
chunk_count: number;
|
||||
processing_count: number;
|
||||
embedding_model_id?: string | null;
|
||||
vector_store_id?: string | null;
|
||||
vector_store_name?: string | null;
|
||||
vector_store_source?: string | null;
|
||||
vector_store_engine_type?: string | null;
|
||||
vector_store_status?: string | null;
|
||||
chunking_config?: Record<string, unknown>;
|
||||
indexing_strategy?: Record<string, boolean>;
|
||||
capabilities?: Record<string, boolean>;
|
||||
extract_config?: Record<string, unknown>;
|
||||
wiki_config?: Record<string, unknown>;
|
||||
question_generation_config?: Record<string, unknown>;
|
||||
summary_model_id?: string | null;
|
||||
storage_provider?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
published_at?: string | null;
|
||||
is_conversation_deposit?: boolean;
|
||||
}
|
||||
|
||||
export interface LlmWikiDocument {
|
||||
id: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
source?: string;
|
||||
channel?: string;
|
||||
file_name?: string;
|
||||
file_type?: string;
|
||||
file_size?: number;
|
||||
storage_size?: number;
|
||||
parse_status?: string;
|
||||
pending_subtasks_count?: number;
|
||||
summary_status?: string;
|
||||
enable_status?: string;
|
||||
embedding_model_id?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
processed_at?: string;
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
export interface LlmWikiChunk {
|
||||
id: string;
|
||||
content: string;
|
||||
chunk_index: number;
|
||||
is_enabled: boolean;
|
||||
status?: number | string | null;
|
||||
start_at: number;
|
||||
end_at: number;
|
||||
chunk_type: string;
|
||||
parent_chunk_id?: string;
|
||||
relation_chunk_ids: string[];
|
||||
indirect_relation_chunk_ids: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface LlmWikiChunkPage {
|
||||
items: LlmWikiChunk[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface LlmWikiGraph {
|
||||
nodes: Array<{ id: string; title: string; type: string; link_count: number }>;
|
||||
edges: Array<{ source: string; target: string }>;
|
||||
meta: {
|
||||
mode?: string;
|
||||
total?: number;
|
||||
returned?: number;
|
||||
truncated?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LlmWikiWikiPage {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
page_type: string;
|
||||
status: string;
|
||||
content: string;
|
||||
summary: string;
|
||||
aliases: string[];
|
||||
parent_slug?: string;
|
||||
category_path?: string[];
|
||||
depth?: number;
|
||||
source_refs: string[];
|
||||
in_links: string[];
|
||||
out_links: string[];
|
||||
version: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface LlmWikiWikiPageList {
|
||||
pages: LlmWikiWikiPage[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface LlmWikiSearchResult {
|
||||
chunk_id: string;
|
||||
content: string;
|
||||
knowledge_id: string;
|
||||
knowledge_base_id: string;
|
||||
knowledge_base_name: string;
|
||||
title: string;
|
||||
filename: string;
|
||||
score: number;
|
||||
chunk_index: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface LlmWikiWeKnoraEmbedSession {
|
||||
frame_path: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
async function readJson<T>(response: Response, context: string): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null as unknown);
|
||||
const detail =
|
||||
payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail: unknown }).detail)
|
||||
: `${context}失败(${response.status})`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function getLlmWikiRuntime(): Promise<LlmWikiRuntime> {
|
||||
return readJson(
|
||||
await apiFetch(`${API()}/runtime`),
|
||||
"读取 LLMWiki 运行模式",
|
||||
);
|
||||
}
|
||||
|
||||
export async function listLlmWikiKnowledgeBases(
|
||||
scope: LlmWikiScope,
|
||||
): Promise<LlmWikiKnowledgeBase[]> {
|
||||
const response = await apiFetch(
|
||||
`${API()}/knowledge-bases?scope=${encodeURIComponent(scope)}`,
|
||||
);
|
||||
const payload = await readJson<{ knowledge_bases: LlmWikiKnowledgeBase[] }>(
|
||||
response,
|
||||
"读取知识库",
|
||||
);
|
||||
return payload.knowledge_bases ?? [];
|
||||
}
|
||||
|
||||
export async function listAgentLlmWikiKnowledgeBases(
|
||||
agentId: string,
|
||||
): Promise<LlmWikiKnowledgeBase[]> {
|
||||
const payload = await readJson<{ knowledge_bases: LlmWikiKnowledgeBase[] }>(
|
||||
await apiFetch(
|
||||
`${API()}/agents/${encodeURIComponent(agentId)}/knowledge-bases`,
|
||||
),
|
||||
"读取智能体知识库",
|
||||
);
|
||||
return payload.knowledge_bases ?? [];
|
||||
}
|
||||
|
||||
export async function getLlmWikiKnowledgeBase(
|
||||
id: string,
|
||||
): Promise<LlmWikiKnowledgeBase> {
|
||||
return readJson(
|
||||
await apiFetch(`${API()}/knowledge-bases/${encodeURIComponent(id)}`),
|
||||
"读取知识库详情",
|
||||
);
|
||||
}
|
||||
|
||||
export async function createLlmWikiWeKnoraEmbedSession(
|
||||
id: string,
|
||||
tab: "documents" | "wiki" | "graph",
|
||||
): Promise<LlmWikiWeKnoraEmbedSession> {
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(id)}/weknora/session?tab=${encodeURIComponent(tab)}`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
"创建 WeKnora 详情会话",
|
||||
);
|
||||
}
|
||||
|
||||
export async function createLlmWikiKnowledgeBase(input: {
|
||||
name: string;
|
||||
description: string;
|
||||
type: "document" | "faq";
|
||||
wiki_enabled?: boolean;
|
||||
}): Promise<LlmWikiKnowledgeBase> {
|
||||
return readJson(
|
||||
await apiFetch(`${API()}/knowledge-bases`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
"创建知识库",
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateLlmWikiKnowledgeBase(
|
||||
id: string,
|
||||
input: { name?: string; description?: string },
|
||||
): Promise<LlmWikiKnowledgeBase> {
|
||||
return readJson(
|
||||
await apiFetch(`${API()}/knowledge-bases/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
"更新知识库",
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteLlmWikiKnowledgeBase(id: string): Promise<void> {
|
||||
await readJson<void>(
|
||||
await apiFetch(`${API()}/knowledge-bases/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
"删除知识库",
|
||||
);
|
||||
}
|
||||
|
||||
export async function listLlmWikiDocuments(
|
||||
knowledgeBaseId: string,
|
||||
): Promise<LlmWikiDocument[]> {
|
||||
const payload = await readJson<{ items: LlmWikiDocument[] }>(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents`,
|
||||
),
|
||||
"读取文档",
|
||||
);
|
||||
return payload.items ?? [];
|
||||
}
|
||||
|
||||
export async function getLlmWikiDocument(
|
||||
knowledgeBaseId: string,
|
||||
documentId: string,
|
||||
): Promise<LlmWikiDocument> {
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents/${encodeURIComponent(documentId)}`,
|
||||
),
|
||||
"读取文档详情",
|
||||
);
|
||||
}
|
||||
|
||||
export async function listLlmWikiDocumentChunks(
|
||||
knowledgeBaseId: string,
|
||||
documentId: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): Promise<LlmWikiChunkPage> {
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents/${encodeURIComponent(documentId)}/chunks?page=${page}&page_size=${pageSize}`,
|
||||
),
|
||||
"读取文档索引",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getLlmWikiDocumentPreview(
|
||||
knowledgeBaseId: string,
|
||||
documentId: string,
|
||||
): Promise<Blob> {
|
||||
const response = await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents/${encodeURIComponent(documentId)}/preview`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
await readJson<void>(response, "读取文档原件");
|
||||
throw new Error("读取文档原件失败");
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export async function getLlmWikiGraph(knowledgeBaseId: string): Promise<LlmWikiGraph> {
|
||||
return readJson(
|
||||
await apiFetch(`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/graph?limit=200`),
|
||||
"读取知识图谱",
|
||||
);
|
||||
}
|
||||
|
||||
export async function listLlmWikiWikiPages(
|
||||
knowledgeBaseId: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
query = "",
|
||||
): Promise<LlmWikiWikiPageList> {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
||||
if (query.trim()) params.set("query", query.trim());
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/pages?${params.toString()}`,
|
||||
),
|
||||
"读取 Wiki 页面",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getLlmWikiWikiPage(
|
||||
knowledgeBaseId: string,
|
||||
slug: string,
|
||||
): Promise<LlmWikiWikiPage> {
|
||||
const safeSlug = slug.split("/").map(encodeURIComponent).join("/");
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/pages/${safeSlug}`,
|
||||
),
|
||||
"读取 Wiki 页面详情",
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadLlmWikiDocument(
|
||||
knowledgeBaseId: string,
|
||||
file: File,
|
||||
): Promise<LlmWikiDocument> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents`,
|
||||
{ method: "POST", body: form },
|
||||
),
|
||||
"上传文档",
|
||||
);
|
||||
}
|
||||
|
||||
export async function importLlmWikiDocumentUrl(
|
||||
knowledgeBaseId: string,
|
||||
url: string,
|
||||
): Promise<LlmWikiDocument> {
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents/url`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
},
|
||||
),
|
||||
"导入网页",
|
||||
);
|
||||
}
|
||||
|
||||
export async function createManualLlmWikiDocument(
|
||||
knowledgeBaseId: string,
|
||||
input: { title: string; content: string },
|
||||
): Promise<LlmWikiDocument> {
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents/manual`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
),
|
||||
"创建 Wiki 内容",
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteLlmWikiDocument(
|
||||
knowledgeBaseId: string,
|
||||
documentId: string,
|
||||
): Promise<void> {
|
||||
await readJson<void>(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents/${encodeURIComponent(documentId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
"删除文档",
|
||||
);
|
||||
}
|
||||
|
||||
export async function reprocessLlmWikiDocument(
|
||||
knowledgeBaseId: string,
|
||||
documentId: string,
|
||||
): Promise<void> {
|
||||
await readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents/${encodeURIComponent(documentId)}/reprocess`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
"重新解析文档",
|
||||
);
|
||||
}
|
||||
|
||||
export async function setLlmWikiPublication(
|
||||
knowledgeBaseId: string,
|
||||
publish: boolean,
|
||||
): Promise<void> {
|
||||
await readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/${publish ? "publish" : "unpublish"}`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
publish ? "发布知识库" : "取消发布",
|
||||
);
|
||||
}
|
||||
|
||||
export async function searchLlmWiki(input: {
|
||||
query: string;
|
||||
scope: LlmWikiScope;
|
||||
knowledge_base_ids?: string[];
|
||||
}): Promise<LlmWikiSearchResult[]> {
|
||||
const payload = await readJson<{ results: LlmWikiSearchResult[] }>(
|
||||
await apiFetch(`${API()}/search`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
"检索知识库",
|
||||
);
|
||||
return payload.results ?? [];
|
||||
}
|
||||
|
||||
export async function createLlmWikiConversationDeposit(input: {
|
||||
thread_id: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
human_message_id?: string | null;
|
||||
assistant_message_id: string;
|
||||
assistant_id?: string | null;
|
||||
knowledge_base_ids?: string[];
|
||||
}): Promise<{ queued: boolean; provider: LlmWikiProvider }> {
|
||||
return readJson(
|
||||
await apiFetch(`${API()}/conversation-deposits/turn`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
"写入对话沉淀",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getLlmWikiSettings(): Promise<LlmWikiSettings> {
|
||||
return readJson(await apiFetch(SETTINGS_API()), "读取 WeKnora 连接配置");
|
||||
}
|
||||
|
||||
export async function saveLlmWikiSettings(input: {
|
||||
override_enabled: boolean;
|
||||
api_base_url: string;
|
||||
web_base_url: string;
|
||||
}): Promise<LlmWikiRuntime> {
|
||||
return readJson(
|
||||
await apiFetch(SETTINGS_API(), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
"保存 WeKnora 连接配置",
|
||||
);
|
||||
}
|
||||
|
||||
export async function resetLlmWikiSettings(): Promise<LlmWikiRuntime> {
|
||||
return readJson(
|
||||
await apiFetch(`${SETTINGS_API()}/override`, { method: "DELETE" }),
|
||||
"恢复配置文件设置",
|
||||
);
|
||||
}
|
||||
|
||||
export async function testLlmWikiConnection(
|
||||
apiBaseUrl: string,
|
||||
): Promise<void> {
|
||||
await readJson(
|
||||
await apiFetch(`${SETTINGS_API()}/test`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ api_base_url: apiBaseUrl }),
|
||||
}),
|
||||
"测试 WeKnora 连接",
|
||||
);
|
||||
}
|
||||
|
||||
const runtimeKey = ["llmwiki", "runtime"] as const;
|
||||
const settingsKey = ["llmwiki", "settings"] as const;
|
||||
const knowledgeBasesKey = (userId: string | null, scope: LlmWikiScope) =>
|
||||
["llmwiki", "knowledge-bases", userId ?? "anonymous", scope] as const;
|
||||
|
||||
export function useLlmWikiRuntime() {
|
||||
return useQuery({
|
||||
queryKey: runtimeKey,
|
||||
queryFn: getLlmWikiRuntime,
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiKnowledgeBases(
|
||||
scope: LlmWikiScope,
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: knowledgeBasesKey(userId, scope),
|
||||
queryFn: () => listLlmWikiKnowledgeBases(scope),
|
||||
enabled,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAgentLlmWikiKnowledgeBases(
|
||||
agentId: string | null | undefined,
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "agents", agentId, "knowledge-bases", userId ?? "anonymous"],
|
||||
queryFn: () => listAgentLlmWikiKnowledgeBases(agentId!),
|
||||
enabled: enabled && !!agentId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiKnowledgeBase(
|
||||
knowledgeBaseId: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "knowledge-base", knowledgeBaseId, userId ?? "anonymous"],
|
||||
queryFn: () => getLlmWikiKnowledgeBase(knowledgeBaseId!),
|
||||
enabled: enabled && !!knowledgeBaseId,
|
||||
refetchInterval: 8_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiDocuments(
|
||||
knowledgeBaseId: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "documents", knowledgeBaseId, userId ?? "anonymous"],
|
||||
queryFn: () => listLlmWikiDocuments(knowledgeBaseId!),
|
||||
enabled: enabled && !!knowledgeBaseId,
|
||||
refetchInterval: 8_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiDocument(
|
||||
knowledgeBaseId: string | null,
|
||||
documentId: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "document", knowledgeBaseId, documentId, userId ?? "anonymous"],
|
||||
queryFn: () => getLlmWikiDocument(knowledgeBaseId!, documentId!),
|
||||
enabled: enabled && !!knowledgeBaseId && !!documentId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiDocumentChunks(
|
||||
knowledgeBaseId: string | null,
|
||||
documentId: string | null,
|
||||
page: number,
|
||||
pageSize = 20,
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "document-chunks", knowledgeBaseId, documentId, page, pageSize, userId ?? "anonymous"],
|
||||
queryFn: () => listLlmWikiDocumentChunks(knowledgeBaseId!, documentId!, page, pageSize),
|
||||
enabled: enabled && !!knowledgeBaseId && !!documentId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiGraph(knowledgeBaseId: string | null, enabled = true) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "graph", knowledgeBaseId, userId ?? "anonymous"],
|
||||
queryFn: () => getLlmWikiGraph(knowledgeBaseId!),
|
||||
enabled: enabled && !!knowledgeBaseId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiWikiPages(
|
||||
knowledgeBaseId: string | null,
|
||||
page: number,
|
||||
pageSize = 50,
|
||||
query = "",
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "wiki-pages", knowledgeBaseId, page, pageSize, query, userId ?? "anonymous"],
|
||||
queryFn: () => listLlmWikiWikiPages(knowledgeBaseId!, page, pageSize, query),
|
||||
enabled: enabled && !!knowledgeBaseId,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiWikiPage(
|
||||
knowledgeBaseId: string | null,
|
||||
slug: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "wiki-page", knowledgeBaseId, slug, userId ?? "anonymous"],
|
||||
queryFn: () => getLlmWikiWikiPage(knowledgeBaseId!, slug!),
|
||||
enabled: enabled && !!knowledgeBaseId && !!slug,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiSettings(enabled = true) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: [...settingsKey, userId ?? "anonymous"],
|
||||
queryFn: getLlmWikiSettings,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidateKnowledge = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["llmwiki"] });
|
||||
return {
|
||||
create: useMutation({
|
||||
mutationFn: createLlmWikiKnowledgeBase,
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
update: useMutation({
|
||||
mutationFn: ({ id, ...input }: { id: string; name?: string; description?: string }) =>
|
||||
updateLlmWikiKnowledgeBase(id, input),
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
remove: useMutation({
|
||||
mutationFn: deleteLlmWikiKnowledgeBase,
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
upload: useMutation({
|
||||
mutationFn: ({ id, file }: { id: string; file: File }) =>
|
||||
uploadLlmWikiDocument(id, file),
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
importUrl: useMutation({
|
||||
mutationFn: ({ id, url }: { id: string; url: string }) =>
|
||||
importLlmWikiDocumentUrl(id, url),
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
createManual: useMutation({
|
||||
mutationFn: ({ id, title, content }: { id: string; title: string; content: string }) =>
|
||||
createManualLlmWikiDocument(id, { title, content }),
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
removeDocument: useMutation({
|
||||
mutationFn: ({ id, documentId }: { id: string; documentId: string }) =>
|
||||
deleteLlmWikiDocument(id, documentId),
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
reprocessDocument: useMutation({
|
||||
mutationFn: ({ id, documentId }: { id: string; documentId: string }) =>
|
||||
reprocessLlmWikiDocument(id, documentId),
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
publication: useMutation({
|
||||
mutationFn: ({ id, publish }: { id: string; publish: boolean }) =>
|
||||
setLlmWikiPublication(id, publish),
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function useLlmWikiSettingsActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["llmwiki"] });
|
||||
};
|
||||
return {
|
||||
save: useMutation({ mutationFn: saveLlmWikiSettings, onSuccess: invalidate }),
|
||||
reset: useMutation({ mutationFn: resetLlmWikiSettings, onSuccess: invalidate }),
|
||||
test: useMutation({ mutationFn: testLlmWikiConnection }),
|
||||
};
|
||||
}
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
import { AlertCircle, Loader2, RefreshCw, Upload } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Navigate, useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import {
|
||||
createLlmWikiWeKnoraEmbedSession,
|
||||
useLlmWikiActions,
|
||||
useLlmWikiKnowledgeBase,
|
||||
useLlmWikiRuntime,
|
||||
} from "@/strategy-components/api/llmwiki";
|
||||
|
||||
const LIST_PATH = "/page/workspace/knowledge";
|
||||
|
||||
type DeerFlowThemePayload = {
|
||||
type: "deerflow-weknora-theme";
|
||||
theme: "dark" | "light";
|
||||
vars: Record<string, string>;
|
||||
};
|
||||
|
||||
function cssVar(name: string, fallback = ""): string {
|
||||
if (typeof window === "undefined") return fallback;
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||||
}
|
||||
|
||||
function collectThemePayload(): DeerFlowThemePayload {
|
||||
const isDark =
|
||||
document.documentElement.classList.contains("dark") ||
|
||||
document.documentElement.dataset.theme === "dark";
|
||||
return {
|
||||
type: "deerflow-weknora-theme",
|
||||
theme: isDark ? "dark" : "light",
|
||||
vars: {
|
||||
primary: cssVar("--primary", isDark ? "#2b81ff" : "#0062d9"),
|
||||
primaryForeground: cssVar("--button-foreground", cssVar("--primary-foreground", "#ffffff")),
|
||||
background: cssVar("--background", isDark ? "#0e172b" : "#ffffff"),
|
||||
foreground: cssVar("--foreground", isDark ? "#eff2f5" : "#202a3a"),
|
||||
card: cssVar("--card", isDark ? "#1a2539" : "#ffffff"),
|
||||
cardForeground: cssVar("--card-foreground", cssVar("--foreground", isDark ? "#eff2f5" : "#202a3a")),
|
||||
muted: cssVar("--muted", isDark ? "#232e40" : "#f3f5f8"),
|
||||
mutedForeground: cssVar("--muted-foreground", isDark ? "#8e9aa4" : "#7f7f7f"),
|
||||
border: cssVar("--border", isDark ? "rgba(255,255,255,0.12)" : "#dbdbdb"),
|
||||
popover: cssVar("--popover", cssVar("--card", isDark ? "#1a2539" : "#ffffff")),
|
||||
destructive: cssVar("--destructive", "#d54941"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function postThemeToFrame(frame: HTMLIFrameElement | null) {
|
||||
if (!frame?.contentWindow) return;
|
||||
const src = frame.getAttribute("src") || "";
|
||||
const targetOrigin = new URL(src, window.location.href).origin;
|
||||
frame.contentWindow.postMessage(collectThemePayload(), targetOrigin);
|
||||
}
|
||||
|
||||
export function WeKnoraKnowledgeDetailPage() {
|
||||
const {
|
||||
knowledge_base_id: strategyKnowledgeBaseId,
|
||||
noteId: workspaceKnowledgeBaseId,
|
||||
} = useParams();
|
||||
const knowledgeBaseId = strategyKnowledgeBaseId ?? workspaceKnowledgeBaseId ?? "";
|
||||
const navigate = useNavigate();
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [frameSrc, setFrameSrc] = useState("");
|
||||
const [frameError, setFrameError] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const details = useLlmWikiKnowledgeBase(knowledgeBaseId || null, !!knowledgeBaseId);
|
||||
const actions = useLlmWikiActions();
|
||||
|
||||
const detailsReady = !!details.data;
|
||||
const preferredTab = details.data?.indexing_strategy?.wiki_enabled ? "wiki" : "documents";
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setFrameSrc("");
|
||||
setFrameError(null);
|
||||
if (!knowledgeBaseId || runtime.data?.provider !== "weknora" || !detailsReady) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
void createLlmWikiWeKnoraEmbedSession(knowledgeBaseId, preferredTab)
|
||||
.then((session) => {
|
||||
if (cancelled) return;
|
||||
const base = getBackendBaseURL().replace(/\/+$/, "");
|
||||
const path = session.frame_path.startsWith("/")
|
||||
? session.frame_path
|
||||
: `/${session.frame_path}`;
|
||||
setFrameSrc(`${base}${path}`);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
const message = error instanceof Error ? error.message : "创建 WeKnora 详情会话失败";
|
||||
setFrameError(message);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [detailsReady, knowledgeBaseId, preferredTab, runtime.data?.provider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!frameSrc) return;
|
||||
const frame = frameRef.current;
|
||||
const send = () => postThemeToFrame(frame);
|
||||
const timer = window.setTimeout(send, 300);
|
||||
const observer = new MutationObserver(send);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style", "data-theme"],
|
||||
});
|
||||
window.addEventListener("storage", send);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
observer.disconnect();
|
||||
window.removeEventListener("storage", send);
|
||||
};
|
||||
}, [frameSrc]);
|
||||
|
||||
if (runtime.isLoading || details.isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-5 animate-spin" />
|
||||
加载中
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (runtime.data?.provider !== "weknora") return <Navigate to={LIST_PATH} replace />;
|
||||
|
||||
if (frameError) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 bg-background px-6 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||
<AlertCircle className="size-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-foreground">无法打开 WeKnora 详情</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{frameError}</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>
|
||||
返回知识库
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (details.error || !details.data) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 bg-background px-6 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||
<AlertCircle className="size-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-foreground">无法打开知识库详情</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
当前账号没有访问权限,或 WeKnora 资源已不存在。
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>
|
||||
返回知识库
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const canUpload = details.data.can_write && !details.data.is_conversation_deposit;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
{canUpload && (
|
||||
<div className="flex h-12 shrink-0 items-center justify-end border-b px-4">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.currentTarget.files ?? []);
|
||||
event.currentTarget.value = "";
|
||||
if (files.length === 0) return;
|
||||
void (async () => {
|
||||
setUploading(true);
|
||||
const failed: string[] = [];
|
||||
try {
|
||||
for (const file of files) {
|
||||
try {
|
||||
await actions.upload.mutateAsync({ id: knowledgeBaseId, file });
|
||||
} catch {
|
||||
failed.push(file.name);
|
||||
}
|
||||
}
|
||||
const succeeded = files.length - failed.length;
|
||||
if (succeeded > 0) {
|
||||
await details.refetch();
|
||||
if (frameRef.current) frameRef.current.src = frameSrc;
|
||||
}
|
||||
if (failed.length === 0) {
|
||||
toast.success(`已上传 ${succeeded} 个文件,正在处理`);
|
||||
} else if (succeeded > 0) {
|
||||
toast.warning(`上传完成:成功 ${succeeded} 个,失败 ${failed.length} 个`);
|
||||
} else {
|
||||
toast.error("文件上传失败");
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 size-4" />
|
||||
)}
|
||||
上传文件
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{frameSrc && (
|
||||
<iframe
|
||||
key={frameSrc}
|
||||
ref={frameRef}
|
||||
title={`${details.data.name || "WeKnora"} 详情`}
|
||||
src={frameSrc}
|
||||
className="h-full min-h-0 w-full flex-1 border-0 bg-background"
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-downloads allow-popups"
|
||||
referrerPolicy="no-referrer"
|
||||
onLoad={() => postThemeToFrame(frameRef.current)}
|
||||
/>
|
||||
)}
|
||||
{!frameSrc && (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center bg-background text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-5 animate-spin" />
|
||||
加载中
|
||||
</div>
|
||||
)}
|
||||
{details.isFetching && (
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 rounded-full border bg-card px-3 py-2 text-xs text-muted-foreground shadow-sm">
|
||||
<RefreshCw className="mr-2 inline size-3 animate-spin" />
|
||||
同步知识库状态
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WeKnoraKnowledgeDetailPage;
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
import {
|
||||
BookOpen,
|
||||
Database,
|
||||
FileText,
|
||||
Globe2,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import {
|
||||
type LlmWikiKnowledgeBase,
|
||||
type LlmWikiScope,
|
||||
searchLlmWiki,
|
||||
useLlmWikiActions,
|
||||
useLlmWikiKnowledgeBases,
|
||||
useLlmWikiSettings,
|
||||
useLlmWikiSettingsActions,
|
||||
} from "@/strategy-components/api/llmwiki";
|
||||
|
||||
type WorkspaceTab = "personal" | "public" | "settings";
|
||||
const LIST_PATH = "/page/workspace/knowledge";
|
||||
|
||||
const publicationLabels: Record<string, string> = {
|
||||
private: "仅自己",
|
||||
pending: "仅自己",
|
||||
published: "已公开",
|
||||
rejected: "仅自己",
|
||||
};
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "操作失败,请稍后重试";
|
||||
}
|
||||
|
||||
export function WeKnoraKnowledgeWorkspace() {
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [tab, setTab] = useState<WorkspaceTab>("personal");
|
||||
const scope: LlmWikiScope = tab === "public" ? "public" : "personal";
|
||||
const knowledge = useLlmWikiKnowledgeBases(scope, tab !== "settings");
|
||||
const actions = useLlmWikiActions();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<Array<{
|
||||
chunk_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
score: number;
|
||||
knowledge_base_name: string;
|
||||
}>>([]);
|
||||
|
||||
useEffect(() => setSearchResults([]), [tab]);
|
||||
|
||||
const busy = actions.create.isPending || actions.remove.isPending || actions.publication.isPending;
|
||||
const openDetails = (item: LlmWikiKnowledgeBase) => {
|
||||
if (item.remote_status === "missing") {
|
||||
toast.error("该知识库已从 WeKnora 删除,只能清理当前记录");
|
||||
return;
|
||||
}
|
||||
navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}`);
|
||||
};
|
||||
|
||||
const runSearch = async () => {
|
||||
const query = searchText.trim();
|
||||
if (!query) return;
|
||||
setSearching(true);
|
||||
try {
|
||||
setSearchResults(await searchLlmWiki({ query, scope }));
|
||||
} catch (error) {
|
||||
toast.error(messageOf(error));
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeKnowledgeBase = async (item: LlmWikiKnowledgeBase) => {
|
||||
const warning = item.remote_status === "missing"
|
||||
? `知识库“${item.name}”已不在 WeKnora 中。确定清理 cmzs 中的这条失效记录吗?`
|
||||
: `确定删除知识库“${item.name}”吗?WeKnora 中的文档和索引也会删除。`;
|
||||
if (!window.confirm(warning)) return;
|
||||
try {
|
||||
await actions.remove.mutateAsync(item.id);
|
||||
toast.success("知识库已删除");
|
||||
} catch (error) {
|
||||
toast.error(messageOf(error));
|
||||
}
|
||||
};
|
||||
|
||||
const togglePublication = async (item: LlmWikiKnowledgeBase) => {
|
||||
const publish = item.publication_status !== "published";
|
||||
try {
|
||||
await actions.publication.mutateAsync({ id: item.id, publish });
|
||||
toast.success(publish ? "知识库已发布到公共 LLMWiki" : "知识库已取消公开");
|
||||
} catch (error) {
|
||||
toast.error(messageOf(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
<div className="border-b bg-gradient-to-r from-primary/8 via-card to-card px-6 py-5">
|
||||
<div className="mx-auto flex max-w-[1500px] flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="size-5 text-primary" />
|
||||
<h1 className="text-xl font-semibold">问答梳理</h1>
|
||||
<Badge variant="secondary">WeKnora</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">由 WeKnora 负责解析与检索,用户、权限和发布范围由 cmzs 管理。</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-full border bg-background/80 p-1 shadow-sm backdrop-blur">
|
||||
{tab !== "settings" && (
|
||||
<Button variant="ghost" size="sm" className="rounded-full" onClick={() => knowledge.refetch()} disabled={knowledge.isFetching}>
|
||||
<RefreshCw className={`mr-2 size-4 ${knowledge.isFetching ? "animate-spin" : ""}`} />刷新
|
||||
</Button>
|
||||
)}
|
||||
{tab === "personal" && <Button size="sm" className="rounded-full px-4 shadow-sm" onClick={() => setCreateOpen(true)}><Plus className="mr-2 size-4" />新建知识库</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto px-6 py-5">
|
||||
<div className="mx-auto max-w-[1500px]">
|
||||
<Tabs value={tab} onValueChange={(value) => setTab(value as WorkspaceTab)}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border bg-card/80 p-3 shadow-sm">
|
||||
<TabsList className="rounded-xl bg-muted/70">
|
||||
<TabsTrigger value="personal"><BookOpen className="mr-2 size-4" />我的知识库</TabsTrigger>
|
||||
<TabsTrigger value="public"><Globe2 className="mr-2 size-4" />公共 LLMWiki</TabsTrigger>
|
||||
{isAdmin && <TabsTrigger value="settings"><Settings2 className="mr-2 size-4" />连接配置</TabsTrigger>}
|
||||
</TabsList>
|
||||
{tab !== "settings" && (
|
||||
<div className="flex w-full max-w-xl items-center gap-2 rounded-xl border bg-background/80 p-1 shadow-sm sm:w-auto">
|
||||
<div className="relative min-w-0 flex-1 sm:w-80">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input value={searchText} onChange={(event) => setSearchText(event.target.value)} onKeyDown={(event) => event.key === "Enter" && void runSearch()} placeholder={tab === "public" ? "检索公共知识" : "检索我的知识"} className="border-0 bg-transparent pl-9 shadow-none focus-visible:ring-0" />
|
||||
</div>
|
||||
<Button variant="secondary" className="rounded-lg px-4" onClick={() => void runSearch()} disabled={searching || !searchText.trim()}>{searching ? <Loader2 className="size-4 animate-spin" /> : "检索"}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TabsContent value="personal" className="mt-5"><KnowledgeGrid rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="还没有个人知识库,点击右上角创建。" busy={busy} onOpen={openDetails} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
|
||||
<TabsContent value="public" className="mt-5"><KnowledgeGrid rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="公共 LLMWiki 暂无已发布知识库。" busy={busy} onOpen={openDetails} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
|
||||
{isAdmin && <TabsContent value="settings" className="mt-5"><ConnectionSettings /></TabsContent>}
|
||||
</Tabs>
|
||||
|
||||
{searchResults.length > 0 && tab !== "settings" && (
|
||||
<Card className="mt-5">
|
||||
<CardHeader className="pb-3"><CardTitle className="text-base">检索结果</CardTitle><CardDescription>结果由 WeKnora 检索返回,问答中使用同一授权链路。</CardDescription></CardHeader>
|
||||
<CardContent className="space-y-3">{searchResults.map((result) => <div key={result.chunk_id} className="rounded-lg border p-3"><div className="flex items-center justify-between gap-3 text-sm font-medium"><span>{result.title || "未命名来源"}</span><Badge variant="outline">{result.knowledge_base_name}</Badge></div><p className="mt-2 line-clamp-3 whitespace-pre-wrap text-sm text-muted-foreground">{result.content}</p></div>)}</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateKnowledgeDialog open={createOpen} pending={actions.create.isPending} onOpenChange={setCreateOpen} onCreate={async (input) => {
|
||||
try {
|
||||
const created = await actions.create.mutateAsync(input);
|
||||
setCreateOpen(false);
|
||||
navigate(`${LIST_PATH}/${encodeURIComponent(created.id)}`);
|
||||
toast.success("知识库已创建");
|
||||
} catch (error) {
|
||||
toast.error(messageOf(error));
|
||||
}
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KnowledgeGrid({
|
||||
rows,
|
||||
loading,
|
||||
error,
|
||||
emptyText,
|
||||
busy,
|
||||
onOpen,
|
||||
onPublish,
|
||||
onDelete,
|
||||
}: {
|
||||
rows: LlmWikiKnowledgeBase[];
|
||||
loading: boolean;
|
||||
error: unknown;
|
||||
emptyText: string;
|
||||
busy: boolean;
|
||||
onOpen: (item: LlmWikiKnowledgeBase) => void;
|
||||
onPublish: (item: LlmWikiKnowledgeBase) => void;
|
||||
onDelete: (item: LlmWikiKnowledgeBase) => void;
|
||||
}) {
|
||||
if (loading) return <div className="flex h-64 items-center justify-center text-muted-foreground"><Loader2 className="mr-2 size-5 animate-spin" />正在读取知识库</div>;
|
||||
if (error) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-6 text-sm text-destructive">{messageOf(error)}</div>;
|
||||
if (!rows.length) return <div className="flex h-64 items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">{emptyText}</div>;
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{rows.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
className={`group overflow-hidden rounded-2xl transition-all hover:-translate-y-0.5 hover:shadow-lg ${
|
||||
item.remote_status === "missing"
|
||||
? "border-amber-500/40 bg-amber-500/[0.03]"
|
||||
: "border-border/70 bg-card/95 hover:border-primary/30"
|
||||
}`}
|
||||
>
|
||||
<div className={`h-1 ${item.publication_status === "published" ? "bg-primary" : "bg-muted"}`} />
|
||||
<button type="button" disabled={item.remote_status === "missing"} className="w-full text-left outline-none transition-colors enabled:hover:bg-muted/25 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-default" onClick={() => onOpen(item)}>
|
||||
<CardHeader className="pb-3 pt-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0"><CardTitle className="truncate text-base">{item.name}</CardTitle><CardDescription className="mt-1 line-clamp-2 min-h-10">{item.description || "暂无描述"}</CardDescription></div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1.5">{item.remote_status === "missing" ? <Badge variant="outline" className="border-amber-500/50 text-amber-700 dark:text-amber-300">远端已删除</Badge> : <Badge variant={item.publication_status === "published" ? "default" : "secondary"}>{publicationLabels[item.publication_status] ?? "仅自己"}</Badge>}{item.remote_status !== "missing" && item.indexing_strategy?.wiki_enabled && <Badge className="bg-violet-100 text-violet-700 hover:bg-violet-100 dark:bg-violet-950 dark:text-violet-300">Wiki</Badge>}</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div className="px-6 pb-4"><div className="grid grid-cols-3 gap-2 rounded-xl border bg-muted/35 p-3 text-center text-xs"><div><div className="text-base font-semibold text-foreground">{item.knowledge_count}</div><span className="text-muted-foreground">文档</span></div><div><div className="text-base font-semibold text-foreground">{item.chunk_count}</div><span className="text-muted-foreground">分块</span></div><div><div className="text-base font-semibold text-foreground">{item.processing_count}</div><span className="text-muted-foreground">处理中</span></div></div></div>
|
||||
</button>
|
||||
<CardContent className="border-t bg-muted/20 px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{item.remote_status === "missing" ? <span className="text-xs text-muted-foreground">WeKnora 资源不存在,可清理本地记录</span> : <Button size="sm" variant="outline" className="rounded-full" onClick={() => onOpen(item)}><FileText className="mr-2 size-4" />查看详情</Button>}
|
||||
{item.remote_status !== "missing" && item.is_owner && !item.is_conversation_deposit && <Button size="sm" variant="secondary" className="rounded-full" disabled={busy} onClick={() => void onPublish(item)}>{item.publication_status === "published" ? "取消公开" : "发布公共库"}</Button>}
|
||||
</div>
|
||||
{item.can_write && !item.is_conversation_deposit && <Button size="icon" variant="ghost" className="size-8 shrink-0 rounded-full text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label={item.remote_status === "missing" ? `清理失效记录 ${item.name}` : `删除知识库 ${item.name}`} title={item.remote_status === "missing" ? "清理失效记录" : "删除知识库"} disabled={busy} onClick={() => void onDelete(item)}><Trash2 className="size-4" /></Button>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateKnowledgeDialog({
|
||||
open,
|
||||
pending,
|
||||
onOpenChange,
|
||||
onCreate,
|
||||
}: {
|
||||
open: boolean;
|
||||
pending: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
onCreate: (input: { name: string; description: string; type: "document" | "faq"; wiki_enabled: boolean }) => Promise<void>;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [wikiEnabled, setWikiEnabled] = useState(true);
|
||||
useEffect(() => {
|
||||
if (!open) { setName(""); setDescription(""); setWikiEnabled(true); }
|
||||
}, [open]);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>新建个人知识库</DialogTitle><DialogDescription>选择知识库模式;模型、切块和存储策略仍由 WeKnora 管理。</DialogDescription></DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-name">名称</label><Input id="llmwiki-name" value={name} onChange={(event) => setName(event.target.value)} maxLength={255} /></div>
|
||||
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-description">描述</label><Textarea id="llmwiki-description" value={description} onChange={(event) => setDescription(event.target.value)} rows={4} maxLength={2048} /></div>
|
||||
<div className="space-y-2"><div className="text-sm font-medium">知识库模式</div><div className="grid gap-3 sm:grid-cols-2">
|
||||
<button type="button" className={`rounded-xl border p-4 text-left transition-colors ${wikiEnabled ? "border-primary bg-primary/5 ring-1 ring-primary" : "hover:bg-muted/40"}`} onClick={() => setWikiEnabled(true)}><div className="flex items-center gap-2 text-sm font-semibold"><BookOpen className="size-4 text-violet-500" />Wiki 知识库</div><p className="mt-2 text-xs leading-5 text-muted-foreground">自动综合资料,生成 Wiki 页面和页面关系图谱。</p></button>
|
||||
<button type="button" className={`rounded-xl border p-4 text-left transition-colors ${!wikiEnabled ? "border-primary bg-primary/5 ring-1 ring-primary" : "hover:bg-muted/40"}`} onClick={() => setWikiEnabled(false)}><div className="flex items-center gap-2 text-sm font-semibold"><FileText className="size-4 text-blue-500" />标准知识库</div><p className="mt-2 text-xs leading-5 text-muted-foreground">仅进行文档解析、切块与向量/关键词索引。</p></button>
|
||||
</div></div>
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button><Button disabled={pending || !name.trim()} onClick={() => void onCreate({ name: name.trim(), description: description.trim(), type: "document", wiki_enabled: wikiEnabled })}>{pending && <Loader2 className="mr-2 size-4 animate-spin" />}创建</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectionSettings() {
|
||||
const settings = useLlmWikiSettings(true);
|
||||
const actions = useLlmWikiSettingsActions();
|
||||
const [apiUrl, setApiUrl] = useState("");
|
||||
const [webUrl, setWebUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings.data) return;
|
||||
setApiUrl(settings.data.api_base_url || settings.data.file_api_base_url || "");
|
||||
setWebUrl(settings.data.web_base_url || settings.data.file_web_base_url || "");
|
||||
}, [settings.data]);
|
||||
|
||||
const status = useMemo(() => settings.data?.effective_provider === "weknora" ? "WeKnora 模式" : "原 LLMWiki 模式", [settings.data]);
|
||||
if (settings.isLoading) return <div className="flex h-52 items-center justify-center"><Loader2 className="size-5 animate-spin" /></div>;
|
||||
if (settings.error) return <div className="rounded-lg border border-destructive/30 p-5 text-sm text-destructive">{messageOf(settings.error)}</div>;
|
||||
return (
|
||||
<Card className="max-w-3xl">
|
||||
<CardHeader><CardTitle className="flex items-center gap-2 text-base"><Settings2 className="size-5" />WeKnora 连接</CardTitle><CardDescription>这里只管理 cmzs 到 WeKnora 的连接地址。数据库、Redis、模型、存储和解析器仍在 WeKnora 自己的配置中维护。</CardDescription></CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="flex flex-wrap gap-2 text-sm"><Badge>{status}</Badge><Badge variant="secondary">服务端代理鉴权</Badge>{settings.data?.override_enabled && <Badge variant="outline">页面配置覆盖中</Badge>}</div>
|
||||
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="weknora-api-url">WeKnora API 地址</label><Input id="weknora-api-url" value={apiUrl} onChange={(event) => setApiUrl(event.target.value)} placeholder="http://weknora-app:8080" /><p className="text-xs text-muted-foreground">非空启用 WeKnora;保存为空会切回原 LLMWiki。</p></div>
|
||||
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="weknora-web-url">WeKnora 管理地址(可选)</label><Input id="weknora-web-url" value={webUrl} onChange={(event) => setWebUrl(event.target.value)} placeholder="https://weknora-admin.example.com" /></div>
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">配置文件地址:{settings.data?.file_api_base_url || "未配置"}。页面只配置地址;WeKnora iframe 免登录所需的管理员账号/密码只从后端 config.yaml 或环境变量读取,不在前端保存。</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" disabled={!apiUrl.trim() || actions.test.isPending} onClick={async () => { try { await actions.test.mutateAsync(apiUrl.trim()); toast.success("WeKnora 连接正常"); } catch (error) { toast.error(messageOf(error)); } }}>{actions.test.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}测试连接</Button>
|
||||
<Button disabled={actions.save.isPending} onClick={async () => { try { await actions.save.mutateAsync({ override_enabled: true, api_base_url: apiUrl.trim(), web_base_url: webUrl.trim() }); toast.success(apiUrl.trim() ? "已启用 WeKnora 模式" : "已切换到原 LLMWiki 模式"); } catch (error) { toast.error(messageOf(error)); } }}>{actions.save.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}保存页面配置</Button>
|
||||
<Button variant="secondary" disabled={actions.reset.isPending} onClick={async () => { try { await actions.reset.mutateAsync(); toast.success("已恢复 config.yaml 配置"); } catch (error) { toast.error(messageOf(error)); } }}>恢复配置文件</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default WeKnoraKnowledgeWorkspace;
|
||||
@@ -1,13 +1,13 @@
|
||||
# DeerFlow 离线后端部署补充
|
||||
|
||||
本目录提供与原始 `start_backend.sh` 配套的 wheelhouse。启动脚本保持原有结构,只新增一行 Redis Python 客户端的离线安装命令。
|
||||
本目录提供与 `start_backend.sh` 配套的 wheelhouse。启动脚本会在容器启动时检查可选运行时依赖,并在缺包时仅从本地 wheelhouse 安装。
|
||||
|
||||
## 文件
|
||||
|
||||
- `offline-runtime-requirements.txt`:固定版本的完整依赖闭包。
|
||||
- `prepare_wheelhouse.sh`:联网机器下载 Linux/Python 3.12 wheel,并验证传递依赖完整性。
|
||||
- `start_backend.sh`:原始 Docker 启动脚本,加一行 Redis 缺包安装命令。
|
||||
- `wheelhouse/`:22 个 wheel 和 `SHA256SUMS`,需要随发布目录一起复制。
|
||||
- `start_backend.sh`:Docker 启动脚本,包含 `croniter`、`hindsight-client`、`aiomysql` 和 Redis 客户端的缺包补装逻辑。
|
||||
- `wheelhouse/`:24 个 wheel 和 `SHA256SUMS`,需要随发布目录一起复制。
|
||||
|
||||
## 联网机器准备
|
||||
|
||||
@@ -23,8 +23,9 @@ bash prepare_wheelhouse.sh
|
||||
- `croniter==6.2.2`
|
||||
- `hindsight-client==0.6.2`
|
||||
- `redis==5.2.1`
|
||||
- `aiomysql==0.3.2`
|
||||
|
||||
上述依赖在 Python 3.12/Linux x86_64 下的 19 个传递依赖也已锁定在 `offline-runtime-requirements.txt`。
|
||||
上述依赖在 Python 3.12/Linux x86_64 下的 20 个传递依赖(包括 `PyMySQL==1.2.0`)也已锁定在 `offline-runtime-requirements.txt`。
|
||||
|
||||
国内镜像示例:
|
||||
|
||||
@@ -57,7 +58,9 @@ bash start_backend.sh
|
||||
docker logs -f deerflow-backend
|
||||
```
|
||||
|
||||
原脚本已有 `croniter` 和 `hindsight-client` 的离线补装逻辑;新增的一行命令只负责在缺少 `redis` 模块时,从 `/app/wheelhouse` 安装 `redis==5.2.1`。
|
||||
脚本会先尝试导入依赖,仅在缺少模块时从 `/app/wheelhouse` 安装。`aiomysql` 补装默认开启,可通过 `AIOMYSQL_INSTALL_DEPS=0` 禁用,或通过 `AIOMYSQL_PIP_PACKAGES` 覆盖默认的 `aiomysql==0.3.2`。
|
||||
|
||||
MySQL 连接 URL 使用 aiomysql 驱动时应写为 `mysql+aiomysql://user:password@host:3306/database?charset=utf8mb4`。
|
||||
|
||||
## Redis 说明
|
||||
|
||||
|
||||
@@ -278,7 +278,7 @@ Configuration priority:
|
||||
|
||||
### Gateway API (`app/gateway/`)
|
||||
|
||||
FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWAY_ENABLE_DOCS=false` to disable `/docs`, `/redoc`, and `/openapi.json` in production (default: enabled).
|
||||
FastAPI application on port 8001 with health checks at `GET|HEAD /health`, `/api/health`, and `/api/langgraph/health`. These exact paths are always public in `auth_middleware.PUBLIC_HEALTH_PATHS`, including when authentication is enabled; reverse-proxy prefixes and trailing slashes are normalized before the whitelist check. Set `GATEWAY_ENABLE_DOCS=false` to disable `/docs`, `/redoc`, and `/openapi.json` in production (default: enabled). Regression coverage: `tests/test_health_auth_bypass.py`.
|
||||
|
||||
**Routers**:
|
||||
|
||||
@@ -630,7 +630,7 @@ Operators upgrading an existing DB run the whole file (`USE <db>;` then execute)
|
||||
### Tool System (`packages/harness/deerflow/tools/`)
|
||||
|
||||
`get_available_tools(groups, include_mcp, model_name, subagent_enabled)` assembles:
|
||||
1. **Config-defined tools** - Resolved from `config.yaml` via `resolve_variable()`
|
||||
1. **Config-defined tools** - Enabled entries are resolved from `config.yaml` via `resolve_variable()`; entries with `enabled: false` are omitted before import and are not registered with the agent
|
||||
2. **MCP tools** - From enabled MCP servers (lazy initialized, cached with mtime invalidation)
|
||||
3. **Built-in tools**:
|
||||
- `present_files` - Make output files visible to user (only `/mnt/user-data/outputs`)
|
||||
@@ -643,6 +643,7 @@ Operators upgrading an existing DB run the whole file (`USE <db>;` then execute)
|
||||
- `tavily/` - Web search (5 results default) and web fetch (4KB limit)
|
||||
- `jina_ai/` - Web fetch via Jina reader API with readability extraction
|
||||
- `firecrawl/` - Web scraping via Firecrawl API
|
||||
- `configurable_search/` - Config-driven `POST` JSON `web_search` replacement. It deep-copies the configured payload and overwrites only `query`, supports HTTP/HTTPS plus configurable `verify_ssl`, timeout and result limit, normalizes nullable `recUuid`/`content`/`title`/`url` fields, and rewrites a result URL through `result_url_template` only when `recUuid` is non-empty. The required response envelope is `{"results": [...]}`. Tests use `httpx.MockTransport` and never require the deployment endpoint.
|
||||
|
||||
**ACP agent tools**:
|
||||
- `invoke_acp_agent` - Invokes external ACP-compatible agents from `config.yaml`
|
||||
@@ -951,9 +952,9 @@ Both endpoints are whitelisted in `auth_middleware._PUBLIC_EXACT_PATHS`. The sha
|
||||
|
||||
Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` methods.
|
||||
|
||||
### WeKnora Retrieval Skill
|
||||
### Conditional WeKnora LLMWiki
|
||||
|
||||
`skills/custom/weknora-knowledge-search/` is a read-only integration for a WeKnora tenant. The Skill itself only directs the model to two MCP tools, `weknora_search` and `weknora_list_knowledge_bases`; the credential remains inside the stdio MCP subprocess (`scripts/weknora_retrieval_mcp.py`). Its retrieval core groups document/FAQ knowledge bases by `embedding_model_id` and calls WeKnora's multi-KB `hybrid-search` API once per group, preventing invalid cross-embedding score comparisons. It does not discard a KB merely because the legacy list API reports `chunk_count=0`; hybrid-search decides its actual readiness. Wiki pages are opt-in and use their dedicated Wiki search route. In explicit DeerFlow sharing mode, `WEKNORA_DEERFLOW_SHARE_TOKEN_FILE` selects WeKnora's narrow `/api/v1/deerflow/*` capability API: only owner-published document/FAQ knowledge bases are discoverable and searchable, while Wiki is skipped so the skill cannot fall back to a user bearer-token endpoint. Shared document hits include a credential-free source-preview URL. The frontend source card recognizes it and opens a context dialog; its call to authenticated Gateway route `/api/weknora/source-context` is proxied to WeKnora's token-gated `/api/v1/deerflow/knowledge-bases/:id/chunks/:chunk_id/context`, returning only the matching chunk and two neighbours on either side. Never expose the share token to the browser or add it to a URL. Configure through the bundled `templates/extensions_config.fragment.json`; otherwise prefer `WEKNORA_API_KEY_FILE` for scoped-key deployments and `WEKNORA_BEARER_TOKEN_FILE` for legacy deployments. `WEKNORA_ALLOWED_KB_IDS` remains an optional allow-list. Tests: `tests/test_weknora_knowledge_search_skill.py`, `tests/test_weknora_source_preview.py`.
|
||||
`llmwiki.weknora.api_base_url` is the provider switch: non-empty enables WeKnora, empty preserves the legacy LLMWiki UI. WeKnora is independently deployed and owns PostgreSQL/Redis/object storage/vector storage/parsers/models; DeerFlow config contains only `api_base_url` and optional `web_base_url`. DeerFlow deliberately sends no WeKnora credential, so the WeKnora endpoint must be restricted to a trusted network and allow DeerFlow traffic itself or through its reverse proxy. There is intentionally no `default_embedding_model_id`: `WeKnoraClient` queries `/api/v1/models` and uses WeKnora's active Embedding model when creating a KB; Wiki creation additionally resolves an active KnowledgeQA model inside WeKnora for synthesis. The native BFF is `app/gateway/routers/llmwiki.py`; ownership/publication mappings live in `deerflow.persistence.llmwiki`; the browser only sees DeerFlow mapping IDs. Knowledge-base owners publish immediately (`private` -> `published`) without a review queue, and can unpublish ordinary bases. The conversation-deposit KB is system-owned, always published, shared by all DeerFlow users, excluded from My Knowledge, and immutable through user/admin detail operations; recognition requires both the reserved name and `system` owner. Existing same-name remote mappings are adopted in place as the system deposit, preserving mapping ids and document links. Its user-facing description, generated Markdown source and tags use `cmzs` branding; list/detail reads synchronize stale remote descriptions, and search results, source previews and embedded-detail JSON recursively replace legacy brand text in historical conversation-deposit payloads. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base list/search views are scoped to the current DeerFlow account even for admins; frontend LLMWiki caches include the live account id, and admin write power does not make other users' private KBs appear as owned. Only `system_role === "admin"` sees the WeKnora connection-config tab. Knowledge cards navigate to a dedicated WeKnora-style detail route. Wiki-enabled bases render and default to `Wiki -> documents -> graph`; standard bases omit Wiki and render `documents -> graph`. The detail page supports sequential multi-file upload with an aggregate result for writable ordinary bases, hides it for public read-only/system-deposit bases, and uses a generic loading label while the embed session starts. It also supports URL and published manual-Markdown imports, browses real Wiki pages/content, proxies authenticated original-file previews, pages through `/chunks/{knowledge_id}`, and renders the Wiki page graph from `/knowledgebase/{kb_id}/wiki/graph`; document details use a right sheet rather than the removed KB modal. The credential-free proxy uses one WeKnora service identity, so per-user separation is enforced by DeerFlow mappings while all remote KBs physically share that identity's WeKnora workspace. WeKnora iframe detail pages first call `/api/llmwiki/knowledge-bases/{id}/weknora/session` through the normal DeerFlow API auth path, then load `/platform/knowledge-bases/{weknora_id}` with a signed `deerflow_weknora_embed` cookie for `/platform`, `/assets`, `/locales`, root assets, and API calls. Injected fetch/XHR rewriting sends all iframe `/api/v1/*`, including colliding `/api/v1/auth/*`, through `/api/llmwiki/weknora-embed/api/v1/*`; Gateway auth/CSRF bypass only applies to those signed iframe proxy requests, the proxy still revalidates the mapped KB, and cookie security honors `X-Forwarded-Proto` for HTTPS reverse proxies. `llmwiki_search` is conditionally registered in `tools.py`, is read-only, and rechecks the current DeerFlow user, agent `llmwiki_knowledge_base_ids`, and thread-context selection on every call. Source previews prefer the cleaned Wiki/LLMWiki page text when a retrieved chunk maps to a Wiki page, with raw split chunks as the fallback. Agent bindings use an inline WeKnora-style searchable/grouped multi-select in the standard agent create/detail UI; new chats use the same interaction pattern as a compact composer dropdown, and selections are written to thread metadata/context and restored on reopen. The DeerFlow frontend is native React and the WeKnora frontend is not modified or embedded. Full deployment and behavior notes: `docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md`. Tests: `tests/test_llmwiki_weknora.py`.
|
||||
|
||||
### Embedded Client (`packages/harness/deerflow/client.py`)
|
||||
|
||||
@@ -1183,7 +1184,7 @@ and timestamps; do not persist Markdown content or call a formal delivery
|
||||
interface. Previews continue to read the DeerFlow artifact by `threadId + path`.
|
||||
|
||||
See `docs/` directory for detailed documentation:
|
||||
- [../README.md](../README.md) - Offline Docker wheelhouse preparation and startup workflow. The pinned versions in `../offline-runtime-requirements.txt` and `../start_backend.sh` must stay synchronized. `start_backend.sh` stays aligned with the deployment script and adds only the Redis wheel fallback line. The download target defaults to CPython 3.12 on Linux x86_64 and verifies dependency closure using wheelhouse only.
|
||||
- [../README.md](../README.md) - Offline Docker wheelhouse preparation and startup workflow. The pinned versions in `../offline-runtime-requirements.txt` and `../start_backend.sh` must stay synchronized. Startup checks `croniter`, `hindsight-client`, `aiomysql`, and Redis, installing missing packages from wheelhouse only; `aiomysql==0.3.2` includes the pinned `PyMySQL==1.2.0` transitive dependency. The download target defaults to CPython 3.12 on Linux x86_64 and verifies dependency closure using wheelhouse only.
|
||||
- [CONFIGURATION.md](docs/CONFIGURATION.md) - Configuration options
|
||||
- [ARCHITECTURE.md](docs/ARCHITECTURE.md) - Architecture details
|
||||
- [API.md](docs/API.md) - API reference
|
||||
|
||||
@@ -39,6 +39,8 @@ DeerFlow is a LangGraph-based AI super agent with sandbox execution, persistent
|
||||
- `/api/*` (other) → Gateway API - models, MCP, skills, memory, artifacts, uploads, thread-local cleanup
|
||||
- `/` (non-API) → Frontend - Next.js web interface
|
||||
|
||||
Health probes are always unauthenticated, even when API authentication is enabled: `GET|HEAD /health`, `/api/health`, and `/api/langgraph/health` all return the Gateway health status without requiring a cookie or bearer token.
|
||||
|
||||
---
|
||||
|
||||
## Core Components
|
||||
@@ -78,7 +80,7 @@ Per-thread isolated execution with virtual path translation:
|
||||
- **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories
|
||||
- **Skills path**: `/mnt/skills` → `deer-flow/skills/` directory
|
||||
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
|
||||
- **WeKnora retrieval skill**: `skills/custom/weknora-knowledge-search` pairs a concise Skill with a stdio MCP server. It retrieves tenant-scoped WeKnora document/FAQ passages through the public API, groups multi-KB calls by embedding model, optionally searches Wiki pages, and can use the local reranker. It does not reject a KB solely because the legacy list API reports `chunk_count=0`; hybrid-search remains the source of truth. For WeKnora's explicit DeerFlow sharing mode, configure `WEKNORA_DEERFLOW_SHARE_TOKEN_FILE`: the MCP uses the restricted `/api/v1/deerflow/*` capability API and can only list/search knowledge bases whose owner enabled sharing; it never receives a user bearer token. Shared document results now contain a credential-free `url` pointer; the source card's “查看原文” dialog calls DeerFlow's authenticated `/api/weknora/source-context` proxy, which retrieves the hit plus two adjacent chunks while keeping the share token server-side. Shared mode currently excludes Wiki pages. Otherwise use `WEKNORA_API_KEY_FILE` for scoped-key deployments or `WEKNORA_BEARER_TOKEN_FILE` for legacy deployments; never put any credential in `SKILL.md` or a chat prompt.
|
||||
- **Conditional WeKnora LLMWiki**: a non-empty `llmwiki.weknora.api_base_url` enables the DeerFlow-native WeKnora BFF and UI; an empty address preserves the existing LLMWiki page. WeKnora remains independently deployed and owns its database, Redis, storage, parsers and models. DeerFlow keeps only the API/admin addresses, user/publication mappings, agent knowledge-base bindings and per-conversation selections; it stores and sends no WeKnora credential, so the endpoint must be restricted to a trusted network and allow DeerFlow traffic. Owners publish directly to the public LLMWiki without an approval queue for ordinary bases; the system-owned conversation-deposit base is always published, shared by all users, excluded from My Knowledge, immutable through user/admin detail operations, and recognized by both its reserved name and `system` owner. An existing same-name remote mapping is adopted in place as the system deposit so its mapping id and document links stay valid. Its user-facing knowledge-base description and generated document metadata use the `cmzs` brand; list/detail reads synchronize stale remote descriptions, while conversation-deposit search results, source previews and embedded-detail JSON rewrite legacy brand text to `cmzs`. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base lists are scoped to the current DeerFlow account even for admins, while frontend LLMWiki query caches also include the active account id so switching accounts cannot reuse another account's list/detail data. Admins remain the only role that sees the WeKnora connection-config tab. Knowledge cards open a dedicated WeKnora-style detail route: Wiki-enabled bases default to Wiki, then documents and graph; standard bases hide Wiki and default to documents. Writable ordinary knowledge-base details expose a native multi-file upload action that submits files sequentially and reports aggregate success/failure; read-only public bases and the system deposit base do not. It supports file/URL/manual-Markdown imports, Wiki page browsing, document preview, real parsed chunks and Wiki graph nodes/edges. New chats use a compact WeKnora-style searchable/grouped multi-select inside the composer, while agent create/edit pages bind spaces through an inline panel. A single proxy service identity means DeerFlow users are isolated logically while their WeKnora entities share that identity's workspace. The read-only `llmwiki_search` tool reauthorizes the current DeerFlow user on every call and renders credential-free source previews; source previews prefer cleaned Wiki/LLMWiki text when a retrieved chunk maps to a Wiki page, with raw chunks as the fallback. The detail page first creates a signed iframe session through the normal DeerFlow API auth path, then points the iframe directly at the proxied WeKnora `/platform/...` route using the signed `deerflow_weknora_embed` cookie; global auth/CSRF bypass is limited to those signed iframe proxy requests, and HTTPS cookie attributes honor `X-Forwarded-Proto` behind reverse proxies. The iframe proxy keeps WeKnora's root-relative `/platform`, `/assets`, `/locales`, and `/api/v1/*` paths, but is registered last through `llmwiki.proxy_router` so it cannot intercept first-party Gateway APIs such as `POST /api/v1/auth/login/username`. See [docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md](docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md).
|
||||
- **File-write safety**: `str_replace` serializes read-modify-write per `(sandbox.id, path)` so isolated sandboxes keep concurrency even when virtual paths match
|
||||
- **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace` (`bash` is disabled by default when using `LocalSandboxProvider`; use `AioSandboxProvider` for isolated shell access)
|
||||
|
||||
@@ -111,6 +113,19 @@ LLM-powered persistent context retention across conversations:
|
||||
| **MCP** | Any Model Context Protocol server (stdio, SSE, HTTP transports) |
|
||||
| **Skills** | Domain-specific workflows injected via system prompt |
|
||||
|
||||
#### Configurable HTTP web search
|
||||
|
||||
The config-defined `web_search` tool can call a deployment-specific JSON API
|
||||
instead of a public search provider. Set its `endpoint`, static `payload`, SSL
|
||||
verification, timeout, result limit, and `result_url_template` in `config.yaml`.
|
||||
At runtime only `payload.query` is overwritten with the model's search text.
|
||||
The API response must use `{"results": [{"recUuid": "", "content": "",
|
||||
"title": "", "url": ""}]}`; missing or null result fields are normalized to empty strings. When
|
||||
`recUuid` is present, `{recUuid}` in `result_url_template` replaces the original
|
||||
result URL; otherwise the original URL is retained. Set `enabled: false` to keep
|
||||
the tool completely out of the agent's registered tool list. `verify_ssl: false`
|
||||
supports trusted internal HTTPS services with self-signed certificates.
|
||||
|
||||
### Gateway API
|
||||
|
||||
FastAPI application providing REST endpoints for frontend integration:
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from time import perf_counter
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from time import perf_counter
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
# Load .env BEFORE any module that reads os.environ at import time.
|
||||
# Without this, GATEWAY_CORS_ORIGINS / AUTH_JWT_SECRET / DEER_FLOW_AUTH_DISABLED
|
||||
# placed in /workspace/.env are silently ignored when the container is started
|
||||
# without explicit `-e` flags, leading to ephemeral JWT secrets, missing CORS
|
||||
# origins, and "Failed to fetch" in the browser.
|
||||
load_dotenv()
|
||||
|
||||
from app.gateway.auth.disabled_mode import is_auth_disabled # noqa: E402
|
||||
# origins, and "Failed to fetch" in the browser.
|
||||
load_dotenv()
|
||||
|
||||
from app.gateway.auth.disabled_mode import is_auth_disabled # noqa: E402
|
||||
from app.gateway.auth_middleware import AuthMiddleware
|
||||
from app.gateway.config import get_gateway_config
|
||||
from app.gateway.csrf_middleware import CSRFMiddleware
|
||||
@@ -43,9 +43,10 @@ from app.gateway.routers import (
|
||||
intent,
|
||||
knowledge,
|
||||
knowledge_ingest,
|
||||
light_apps,
|
||||
llm_metrics,
|
||||
mcp,
|
||||
light_apps,
|
||||
llm_metrics,
|
||||
llmwiki,
|
||||
mcp,
|
||||
memory,
|
||||
menu_overrides,
|
||||
model_management,
|
||||
@@ -53,7 +54,7 @@ from app.gateway.routers import (
|
||||
multi_agent,
|
||||
notifications,
|
||||
open_3qfx,
|
||||
open_chat,
|
||||
open_chat,
|
||||
parallel_agent_tasks,
|
||||
parallel_agents,
|
||||
position_roundtable,
|
||||
@@ -91,8 +92,7 @@ from app.gateway.routers import (
|
||||
uploads,
|
||||
user_preferences,
|
||||
user_prompts,
|
||||
weknora_sources,
|
||||
writing,
|
||||
writing,
|
||||
)
|
||||
from deerflow.config import app_config as deerflow_app_config
|
||||
from deerflow.config.app_config import apply_logging_level
|
||||
@@ -1434,8 +1434,8 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# MCP API is mounted at /api/mcp
|
||||
app.include_router(mcp.router)
|
||||
|
||||
# Authenticated source-context proxy for the WeKnora retrieval skill.
|
||||
app.include_router(weknora_sources.router)
|
||||
# DeerFlow-native LLMWiki management/search BFF (conditionally backed by WeKnora).
|
||||
app.include_router(llmwiki.router)
|
||||
|
||||
# Memory API is mounted at /api/memory
|
||||
app.include_router(memory.router)
|
||||
@@ -1625,24 +1625,30 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# ??? app/gateway/routers/{multi_agent,intent,recommend}.py?
|
||||
# ??? frontend-web/docs/multi-agent-backend-dev.md
|
||||
app.include_router(multi_agent.router) # Step 2?init(SSE) + run/stream(leader|special)
|
||||
app.include_router(intent.router) # Step 1?/api/intent/init + /stream
|
||||
app.include_router(recommend.router) # Step 1?2?/api/recommend/stream?? init?
|
||||
# Step 3 ????/?????? artifacts.router?/api/threads/{id}/artifacts?
|
||||
|
||||
# ?????????(? auth_middleware._PUBLIC_PATH_PREFIXES)?
|
||||
# ????? ``/health`` ?? API ??? ``/api/health``,?????
|
||||
# ?? base URL ????,?????????? 401?
|
||||
@app.get("/health", tags=["health"])
|
||||
@app.get("/api/health", tags=["health"])
|
||||
app.include_router(intent.router) # Step 1?/api/intent/init + /stream
|
||||
app.include_router(recommend.router) # Step 1?2?/api/recommend/stream?? init?
|
||||
# Step 3 ????/?????? artifacts.router?/api/threads/{id}/artifacts?
|
||||
|
||||
# Health probes are always unauthenticated (see
|
||||
# auth_middleware.PUBLIC_HEALTH_PATHS). Support the bare Gateway path,
|
||||
# API base URL, and LangGraph proxy base URL for deployment monitors.
|
||||
@app.api_route("/health", methods=["GET", "HEAD"], tags=["health"])
|
||||
@app.api_route("/api/health", methods=["GET", "HEAD"], tags=["health"])
|
||||
@app.api_route("/api/langgraph/health", methods=["GET", "HEAD"], tags=["health"])
|
||||
async def health_check() -> dict:
|
||||
"""Health check endpoint.
|
||||
|
||||
Returns:
|
||||
Service health status information.
|
||||
"""
|
||||
return {"status": "healthy", "service": "deer-flow-gateway"}
|
||||
|
||||
return app
|
||||
"""
|
||||
return {"status": "healthy", "service": "deer-flow-gateway"}
|
||||
|
||||
# WeKnora's embedded frontend uses root-relative routes such as /platform,
|
||||
# /assets, /locales and /api/v1/*. Register that proxy last so it stays a
|
||||
# true fallback and cannot intercept DeerFlow APIs.
|
||||
app.include_router(llmwiki.proxy_router)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# Create app instance for uvicorn
|
||||
|
||||
@@ -19,20 +19,35 @@ from starlette.types import ASGIApp
|
||||
|
||||
from app.gateway.auth.disabled_mode import get_assumed_user_for_disabled_auth, is_auth_disabled
|
||||
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
|
||||
from app.gateway.authz import _ALL_PERMISSIONS, AuthContext
|
||||
from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token
|
||||
from app.gateway.proxy_path import app_relative_path
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
from app.gateway.authz import _ALL_PERMISSIONS, AuthContext
|
||||
from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token
|
||||
from app.gateway.proxy_path import app_relative_path
|
||||
from app.gateway.weknora_embed import (
|
||||
WEKNORA_EMBED_COOKIE,
|
||||
has_valid_weknora_embed_cookie,
|
||||
is_weknora_embed_proxy_path,
|
||||
)
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
|
||||
# Paths that never require authentication.
|
||||
# Health probes are deliberately public, including the LangGraph-compatible
|
||||
# gateway alias used by some deployments. Keep these as exact paths: health
|
||||
# checks must bypass authentication, but similarly named application routes
|
||||
# must not inherit that exemption.
|
||||
PUBLIC_HEALTH_PATHS: frozenset[str] = frozenset(
|
||||
{
|
||||
"/health",
|
||||
"/api/health",
|
||||
"/api/langgraph/health",
|
||||
}
|
||||
)
|
||||
|
||||
# Path prefixes that never require authentication.
|
||||
#
|
||||
# ``/api/public/`` is a namespace reserved for intentionally world-readable,
|
||||
# read-only endpoints (e.g. public conversation share snapshots). Only mount
|
||||
# safe read-only routes under it — everything there bypasses auth entirely.
|
||||
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
|
||||
"/health",
|
||||
"/api/health",
|
||||
"/docs",
|
||||
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
"/api/public/",
|
||||
@@ -43,9 +58,10 @@ _PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
|
||||
|
||||
# Exact auth paths that are public (login/register/status check).
|
||||
# /api/v1/auth/me, /api/v1/auth/change-password etc. are NOT public.
|
||||
_PUBLIC_EXACT_PATHS: frozenset[str] = frozenset(
|
||||
{
|
||||
"/api/v1/auth/login/local",
|
||||
_PUBLIC_EXACT_PATHS: frozenset[str] = frozenset(
|
||||
{
|
||||
*PUBLIC_HEALTH_PATHS,
|
||||
"/api/v1/auth/login/local",
|
||||
"/api/v1/auth/login/username",
|
||||
"/api/v1/auth/login/token",
|
||||
"/api/v1/auth/register",
|
||||
@@ -54,15 +70,31 @@ _PUBLIC_EXACT_PATHS: frozenset[str] = frozenset(
|
||||
"/api/v1/auth/initialize",
|
||||
# 并行多智能体面板专用的「绕过常规登录」取 token 端点(内网部署限制了标准登录时用)。
|
||||
"/api/parallel-agents/auth/token",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_public(path: str) -> bool:
|
||||
stripped = path.rstrip("/")
|
||||
if stripped in _PUBLIC_EXACT_PATHS:
|
||||
return True
|
||||
return any(path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_public(path: str) -> bool:
|
||||
stripped = path.rstrip("/")
|
||||
if stripped in _PUBLIC_EXACT_PATHS:
|
||||
return True
|
||||
return any(path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES)
|
||||
|
||||
|
||||
def _is_weknora_embed_proxy_request(request: Request) -> bool:
|
||||
"""Let the WeKnora embed proxy verify its own signed iframe cookie.
|
||||
|
||||
The embedded WeKnora frontend loads root-relative routes (``/platform``,
|
||||
``/assets``, ``/locales`` and ``/api/v1/*``). Those requests do not carry
|
||||
DeerFlow's Authorization header, but the iframe bootstrap has already set a
|
||||
short-lived signed cookie that the proxy router validates against the
|
||||
requested knowledge base.
|
||||
"""
|
||||
|
||||
path = app_relative_path(request)
|
||||
if not is_weknora_embed_proxy_path(path):
|
||||
return False
|
||||
return has_valid_weknora_embed_cookie(request.cookies.get(WEKNORA_EMBED_COOKIE))
|
||||
|
||||
|
||||
# 「发起问答」的 run 创建接口(会真正调用大模型):
|
||||
@@ -103,12 +135,14 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
super().__init__(app)
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
# Match the application-relative path so a reverse-proxy prefix
|
||||
# (e.g. /xxx/api/public/...) does not defeat the public whitelist.
|
||||
if _is_public(app_relative_path(request)):
|
||||
return await call_next(request)
|
||||
|
||||
if is_auth_disabled():
|
||||
# Match the application-relative path so a reverse-proxy prefix
|
||||
# (e.g. /xxx/api/public/...) does not defeat the public whitelist.
|
||||
if _is_public(app_relative_path(request)):
|
||||
return await call_next(request)
|
||||
if _is_weknora_embed_proxy_request(request):
|
||||
return await call_next(request)
|
||||
|
||||
if is_auth_disabled():
|
||||
user = await get_assumed_user_for_disabled_auth()
|
||||
request.state.user = user
|
||||
request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS)
|
||||
|
||||
@@ -7,16 +7,21 @@ State-changing operations require CSRF protection.
|
||||
import secrets
|
||||
from collections.abc import Callable
|
||||
|
||||
from app.gateway.auth.disabled_mode import is_auth_disabled
|
||||
from app.gateway.proxy_path import app_relative_path
|
||||
from fastapi import Request, Response
|
||||
from app.gateway.auth.disabled_mode import is_auth_disabled
|
||||
from app.gateway.proxy_path import app_relative_path
|
||||
from app.gateway.weknora_embed import (
|
||||
WEKNORA_EMBED_COOKIE,
|
||||
has_valid_weknora_embed_cookie,
|
||||
is_weknora_embed_proxy_path,
|
||||
)
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
CSRF_COOKIE_NAME = "csrf_token"
|
||||
CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||
CSRF_TOKEN_LENGTH = 64 # bytes
|
||||
CSRF_COOKIE_NAME = "csrf_token"
|
||||
CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||
CSRF_TOKEN_LENGTH = 64 # bytes
|
||||
|
||||
|
||||
def is_secure_request(request: Request) -> bool:
|
||||
@@ -29,7 +34,7 @@ def generate_csrf_token() -> str:
|
||||
return secrets.token_urlsafe(CSRF_TOKEN_LENGTH)
|
||||
|
||||
|
||||
def should_check_csrf(request: Request) -> bool:
|
||||
def should_check_csrf(request: Request) -> bool:
|
||||
"""Determine if a request needs CSRF validation.
|
||||
|
||||
CSRF is checked for state-changing methods (POST, PUT, DELETE, PATCH).
|
||||
@@ -54,9 +59,18 @@ def should_check_csrf(request: Request) -> bool:
|
||||
return False
|
||||
# 开放问答接口同样是匿名、无会话的(见 auth_middleware._PUBLIC_PATH_PREFIXES),
|
||||
# 双提交 Cookie 既无 token 可比、也无会话可被伪造,豁免之。
|
||||
if path.startswith("/api/open/"):
|
||||
return False
|
||||
return True
|
||||
if path.startswith("/api/open/"):
|
||||
return False
|
||||
if is_weknora_embed_proxy_request(request):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_weknora_embed_proxy_request(request: Request) -> bool:
|
||||
path = app_relative_path(request)
|
||||
if not is_weknora_embed_proxy_path(path):
|
||||
return False
|
||||
return has_valid_weknora_embed_cookie(request.cookies.get(WEKNORA_EMBED_COOKIE))
|
||||
|
||||
|
||||
_AUTH_EXEMPT_PATHS: frozenset[str] = frozenset(
|
||||
|
||||
@@ -293,10 +293,16 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.dashboard_session_store = make_dashboard_session_store(sf)
|
||||
|
||||
from deerflow.persistence.task_buttons import make_task_button_store
|
||||
|
||||
from deerflow.persistence.task_buttons import make_task_button_store
|
||||
|
||||
app.state.task_button_store = make_task_button_store(sf)
|
||||
|
||||
from deerflow.integrations.weknora.runtime import register_llmwiki_store
|
||||
from deerflow.persistence.llmwiki import make_llmwiki_store
|
||||
|
||||
app.state.llmwiki_store = make_llmwiki_store(sf)
|
||||
register_llmwiki_store(app.state.llmwiki_store)
|
||||
|
||||
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
|
||||
|
||||
app.state.taskcop_task_store = make_taskcop_task_store(sf)
|
||||
@@ -618,27 +624,34 @@ _cached_local_provider: LocalAuthProvider | None = None
|
||||
_cached_repo: SQLiteUserRepository | None = None
|
||||
|
||||
|
||||
def get_request_access_token(request: Request) -> str | None:
|
||||
"""Extract access token from cookie or Authorization header.
|
||||
|
||||
Priority:
|
||||
1. ``access_token`` cookie (browser session mode)
|
||||
2. ``Authorization: Bearer <token>`` header (API client mode)
|
||||
"""
|
||||
cookie_token = request.cookies.get("access_token")
|
||||
if cookie_token:
|
||||
return cookie_token
|
||||
|
||||
authorization = request.headers.get("authorization", "").strip()
|
||||
if not authorization:
|
||||
return None
|
||||
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if scheme.lower() != "bearer":
|
||||
return None
|
||||
|
||||
token = token.strip()
|
||||
return token or None
|
||||
def get_request_access_token(request: Request) -> str | None:
|
||||
"""Extract access token from cookie or Authorization header.
|
||||
|
||||
Priority:
|
||||
1. ``access_token`` cookie (browser session mode)
|
||||
2. ``Authorization: Bearer <token>`` header (API client mode)
|
||||
3. ``access_token`` query parameter for the WeKnora iframe bootstrap only
|
||||
"""
|
||||
cookie_token = request.cookies.get("access_token")
|
||||
if cookie_token:
|
||||
return cookie_token
|
||||
|
||||
authorization = request.headers.get("authorization", "").strip()
|
||||
if authorization:
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if scheme.lower() == "bearer":
|
||||
token = token.strip()
|
||||
if token:
|
||||
return token
|
||||
|
||||
from app.gateway.proxy_path import app_relative_path
|
||||
|
||||
path = app_relative_path(request).rstrip("/")
|
||||
if path.startswith("/api/llmwiki/knowledge-bases/") and path.endswith("/weknora/frame"):
|
||||
query_token = (request.query_params.get("access_token") or "").strip()
|
||||
if query_token:
|
||||
return query_token
|
||||
return None
|
||||
|
||||
|
||||
def get_local_provider() -> LocalAuthProvider:
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
"""Asynchronous conversation sedimentation into WeKnora-backed LLMWiki."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from deerflow.integrations.weknora.client import WeKnoraClient, WeKnoraError
|
||||
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
|
||||
from deerflow.persistence.llmwiki import LlmWikiStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONVERSATION_DEPOSIT_KB_NAME = "对话沉淀"
|
||||
CONVERSATION_DEPOSIT_OWNER_USER_ID = "system"
|
||||
CONVERSATION_DEPOSIT_KB_DESCRIPTION = "cmzs 全局问答沉淀知识库。所有用户可见,不默认参与问答检索。"
|
||||
_DEPOSIT_KB_LOCK = asyncio.Lock()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConversationTurnDeposit:
|
||||
thread_id: str
|
||||
question: str
|
||||
answer: str
|
||||
human_message_id: str | None
|
||||
assistant_message_id: str
|
||||
assistant_id: str | None
|
||||
knowledge_base_ids: list[str]
|
||||
created_by_user_id: str | None
|
||||
|
||||
|
||||
def _store(app: Any) -> LlmWikiStore | None:
|
||||
return getattr(app.state, "llmwiki_store", None)
|
||||
|
||||
|
||||
def _client(app: Any) -> WeKnoraClient | None:
|
||||
runtime = get_resolved_llmwiki_runtime(getattr(app.state, "config", None))
|
||||
if not runtime.weknora_enabled:
|
||||
return None
|
||||
try:
|
||||
return build_weknora_client(runtime)
|
||||
except RuntimeError:
|
||||
logger.debug("LLMWiki conversation deposit skipped: WeKnora client is unavailable", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _shorten(text: str, max_chars: int) -> str:
|
||||
value = " ".join(str(text or "").split())
|
||||
if len(value) <= max_chars:
|
||||
return value
|
||||
return f"{value[: max_chars - 1].rstrip()}…"
|
||||
|
||||
|
||||
def _title_for_turn(turn: ConversationTurnDeposit) -> str:
|
||||
question = _shorten(turn.question, 48)
|
||||
return f"对话沉淀 - {question or turn.assistant_message_id[:12]}"
|
||||
|
||||
|
||||
def _format_markdown(turn: ConversationTurnDeposit, title: str) -> str:
|
||||
kb_line = "、".join(turn.knowledge_base_ids) if turn.knowledge_base_ids else "未显式选择知识库"
|
||||
return f"""---
|
||||
source: cmzs
|
||||
type: conversation-deposit
|
||||
thread_id: {turn.thread_id}
|
||||
human_message_id: {turn.human_message_id or ""}
|
||||
assistant_message_id: {turn.assistant_message_id}
|
||||
assistant_id: {turn.assistant_id or ""}
|
||||
knowledge_base_ids: {kb_line}
|
||||
created_by_user_id: {turn.created_by_user_id or ""}
|
||||
---
|
||||
|
||||
# {title}
|
||||
|
||||
> 本页由 cmzs 在一轮问答结束后自动沉淀,用于后续 LLMWiki 检索、Wiki 索引与图谱构建。
|
||||
|
||||
## 摘要
|
||||
|
||||
{_shorten(turn.answer, 360) or "本轮回答为空。"}
|
||||
|
||||
## 用户问题
|
||||
|
||||
{turn.question.strip() or "(空)"}
|
||||
|
||||
## 助手回答
|
||||
|
||||
{turn.answer.strip() or "(空)"}
|
||||
|
||||
## 结构化线索
|
||||
|
||||
- 来源线程:`{turn.thread_id}`
|
||||
- 用户消息:`{turn.human_message_id or ""}`
|
||||
- 回答消息:`{turn.assistant_message_id}`
|
||||
- 智能体:`{turn.assistant_id or "通用问答"}`
|
||||
- 本轮显式知识库:{kb_line}
|
||||
|
||||
## LLMWiki 标签
|
||||
|
||||
#对话沉淀 #cmzs #问答知识
|
||||
"""
|
||||
|
||||
|
||||
async def _sync_conversation_deposit_description(
|
||||
store: LlmWikiStore,
|
||||
client: WeKnoraClient,
|
||||
row: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if str(row.get("description") or "") == CONVERSATION_DEPOSIT_KB_DESCRIPTION:
|
||||
return row
|
||||
try:
|
||||
await client.update_knowledge_base(
|
||||
str(row["weknora_id"]),
|
||||
{"description": CONVERSATION_DEPOSIT_KB_DESCRIPTION},
|
||||
)
|
||||
except WeKnoraError:
|
||||
logger.warning("Failed to update the conversation deposit knowledge-base description", exc_info=True)
|
||||
return row
|
||||
updated = await store.update_mapping(
|
||||
str(row["id"]),
|
||||
description=CONVERSATION_DEPOSIT_KB_DESCRIPTION,
|
||||
)
|
||||
return updated or row
|
||||
|
||||
|
||||
async def ensure_conversation_deposit_mapping(
|
||||
store: LlmWikiStore,
|
||||
client: WeKnoraClient,
|
||||
*,
|
||||
remote_knowledge_bases: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
existing = await store.get_conversation_deposit_mapping()
|
||||
if existing is not None:
|
||||
return await _sync_conversation_deposit_description(store, client, existing)
|
||||
|
||||
async with _DEPOSIT_KB_LOCK:
|
||||
existing = await store.get_conversation_deposit_mapping()
|
||||
if existing is not None:
|
||||
return await _sync_conversation_deposit_description(store, client, existing)
|
||||
|
||||
remote_id = ""
|
||||
try:
|
||||
remote_items = remote_knowledge_bases
|
||||
if remote_items is None:
|
||||
remote_items = await client.list_knowledge_bases()
|
||||
for remote in remote_items:
|
||||
if str(remote.get("name") or "").strip() == CONVERSATION_DEPOSIT_KB_NAME and remote.get("id"):
|
||||
remote_id = str(remote["id"])
|
||||
break
|
||||
except WeKnoraError:
|
||||
logger.debug("Could not scan WeKnora knowledge bases before creating deposit base", exc_info=True)
|
||||
|
||||
if remote_id:
|
||||
mapped = await store.get_by_weknora_id(remote_id)
|
||||
if mapped is not None:
|
||||
try:
|
||||
await client.update_knowledge_base(
|
||||
remote_id,
|
||||
{"description": CONVERSATION_DEPOSIT_KB_DESCRIPTION},
|
||||
)
|
||||
except WeKnoraError:
|
||||
logger.warning("Failed to update the existing conversation deposit knowledge-base description", exc_info=True)
|
||||
return await store.adopt_conversation_deposit_mapping(
|
||||
str(mapped["id"]),
|
||||
name=CONVERSATION_DEPOSIT_KB_NAME,
|
||||
description=CONVERSATION_DEPOSIT_KB_DESCRIPTION,
|
||||
)
|
||||
try:
|
||||
remote = await client.update_knowledge_base(
|
||||
remote_id,
|
||||
{"description": CONVERSATION_DEPOSIT_KB_DESCRIPTION},
|
||||
)
|
||||
except WeKnoraError:
|
||||
logger.warning("Failed to update the existing conversation deposit knowledge-base description", exc_info=True)
|
||||
remote = {
|
||||
"id": remote_id,
|
||||
"name": CONVERSATION_DEPOSIT_KB_NAME,
|
||||
"description": CONVERSATION_DEPOSIT_KB_DESCRIPTION,
|
||||
"type": "document",
|
||||
}
|
||||
else:
|
||||
try:
|
||||
remote = await client.create_knowledge_base(
|
||||
name=CONVERSATION_DEPOSIT_KB_NAME,
|
||||
description=CONVERSATION_DEPOSIT_KB_DESCRIPTION,
|
||||
kb_type="document",
|
||||
wiki_enabled=True,
|
||||
)
|
||||
except WeKnoraError:
|
||||
logger.warning("Failed to create WeKnora conversation deposit knowledge base", exc_info=True)
|
||||
return None
|
||||
|
||||
try:
|
||||
row = await store.create_mapping(
|
||||
weknora_id=str(remote["id"]),
|
||||
owner_user_id=CONVERSATION_DEPOSIT_OWNER_USER_ID,
|
||||
name=CONVERSATION_DEPOSIT_KB_NAME,
|
||||
description=CONVERSATION_DEPOSIT_KB_DESCRIPTION,
|
||||
kb_type="document",
|
||||
)
|
||||
published = await store.request_publish(str(row["id"]), CONVERSATION_DEPOSIT_OWNER_USER_ID, is_admin=True)
|
||||
return published or row
|
||||
except Exception:
|
||||
logger.exception("Failed to persist conversation deposit knowledge-base mapping")
|
||||
return None
|
||||
|
||||
|
||||
async def ensure_conversation_deposit_knowledge_base(
|
||||
app: Any,
|
||||
*,
|
||||
owner_user_id: str | None,
|
||||
) -> dict[str, Any] | None:
|
||||
store = _store(app)
|
||||
client = _client(app)
|
||||
if store is None or client is None:
|
||||
return None
|
||||
return await ensure_conversation_deposit_mapping(store, client)
|
||||
|
||||
|
||||
async def deposit_conversation_turn(app: Any, turn: ConversationTurnDeposit) -> None:
|
||||
if not turn.thread_id or not turn.assistant_message_id:
|
||||
return
|
||||
if not turn.question.strip() and not turn.answer.strip():
|
||||
return
|
||||
|
||||
store = _store(app)
|
||||
client = _client(app)
|
||||
if store is None or client is None:
|
||||
return
|
||||
|
||||
kb = await ensure_conversation_deposit_knowledge_base(app, owner_user_id=turn.created_by_user_id)
|
||||
if kb is None:
|
||||
return
|
||||
|
||||
previous = await store.get_conversation_deposit(
|
||||
thread_id=turn.thread_id,
|
||||
assistant_message_id=turn.assistant_message_id,
|
||||
)
|
||||
previous_doc_id = str((previous or {}).get("weknora_knowledge_id") or "")
|
||||
if previous_doc_id:
|
||||
try:
|
||||
await client.delete_document(previous_doc_id)
|
||||
except WeKnoraError as exc:
|
||||
if exc.status_code != 404:
|
||||
logger.warning("Failed to delete old conversation deposit document %s", previous_doc_id, exc_info=True)
|
||||
|
||||
title = _title_for_turn(turn)
|
||||
content = _format_markdown(turn, title)
|
||||
try:
|
||||
remote_doc = await client.create_manual_document(
|
||||
str(kb["weknora_id"]),
|
||||
title=title,
|
||||
content=content,
|
||||
)
|
||||
except WeKnoraError:
|
||||
logger.warning("Failed to create conversation deposit document", exc_info=True)
|
||||
return
|
||||
|
||||
await store.upsert_conversation_deposit(
|
||||
thread_id=turn.thread_id,
|
||||
human_message_id=turn.human_message_id,
|
||||
assistant_message_id=turn.assistant_message_id,
|
||||
knowledge_base_mapping_id=str(kb["id"]),
|
||||
weknora_knowledge_id=str(remote_doc.get("id") or remote_doc.get("knowledge_id") or ""),
|
||||
title=title,
|
||||
created_by_user_id=turn.created_by_user_id,
|
||||
)
|
||||
|
||||
|
||||
async def delete_conversation_deposit_documents(
|
||||
app: Any,
|
||||
*,
|
||||
thread_id: str,
|
||||
message_ids: list[str] | None = None,
|
||||
) -> None:
|
||||
store = _store(app)
|
||||
client = _client(app)
|
||||
if store is None or client is None:
|
||||
return
|
||||
try:
|
||||
rows = await store.delete_conversation_deposits(thread_id=thread_id, message_ids=message_ids)
|
||||
except Exception:
|
||||
logger.debug("Failed to delete local conversation deposit rows", exc_info=True)
|
||||
return
|
||||
for row in rows:
|
||||
doc_id = str(row.get("weknora_knowledge_id") or "")
|
||||
if not doc_id:
|
||||
continue
|
||||
try:
|
||||
await client.delete_document(doc_id)
|
||||
except WeKnoraError as exc:
|
||||
if exc.status_code != 404:
|
||||
logger.warning("Failed to delete WeKnora conversation deposit document %s", doc_id, exc_info=True)
|
||||
@@ -0,0 +1,290 @@
|
||||
"""LLMWiki retrieval helpers for normal DeerFlow chat runs.
|
||||
|
||||
The public chat API accepts DeerFlow-local LLMWiki knowledge-base mapping ids.
|
||||
When WeKnora mode is enabled, this module resolves those local ids under the
|
||||
current DeerFlow user's permissions, searches WeKnora once before the model
|
||||
call, and attaches both:
|
||||
|
||||
* a hidden prompt with numbered context snippets, and
|
||||
* structured citation sources for the existing reference panel.
|
||||
|
||||
The helper lives in ``app.gateway`` because it needs Gateway auth state and the
|
||||
DeerFlow-owned mapping store. Harness code remains app-import free.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from app.gateway.deps import get_optional_user_from_request
|
||||
from app.gateway.utils import sanitize_log_param
|
||||
from deerflow.config.agents_config import load_agent_config, validate_agent_id
|
||||
from deerflow.integrations.weknora.client import WeKnoraError
|
||||
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
|
||||
from deerflow.persistence.llmwiki import LlmWikiStore
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_SOURCES = 8
|
||||
_MAX_SOURCE_CHARS = 1400
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LlmWikiRagContext:
|
||||
knowledge_base_ids: list[str]
|
||||
source_count: int
|
||||
prompt: str
|
||||
sources: list[dict[str, Any]]
|
||||
query: str
|
||||
|
||||
|
||||
def _store(request: Request) -> LlmWikiStore | None:
|
||||
return getattr(request.app.state, "llmwiki_store", None)
|
||||
|
||||
|
||||
async def _actor(request: Request) -> tuple[str, bool]:
|
||||
user = await get_optional_user_from_request(request)
|
||||
if user is None:
|
||||
return get_effective_user_id(), True
|
||||
return str(user.id), getattr(user, "system_role", None) == "admin"
|
||||
|
||||
|
||||
def _as_id_list(value: Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
parts = [value]
|
||||
elif isinstance(value, (list, tuple, set)):
|
||||
parts = list(value)
|
||||
else:
|
||||
return []
|
||||
return list(dict.fromkeys(str(item).strip() for item in parts if str(item).strip()))
|
||||
|
||||
|
||||
def _body_selected_ids(body: Any) -> list[str]:
|
||||
for source in (getattr(body, "context", None), getattr(body, "metadata", None)):
|
||||
if isinstance(source, Mapping) and "llmwiki_knowledge_base_ids" in source:
|
||||
return _as_id_list(source.get("llmwiki_knowledge_base_ids"))
|
||||
return []
|
||||
|
||||
|
||||
def _assistant_agent_id(body: Any) -> str | None:
|
||||
assistant_id = validate_agent_id(getattr(body, "assistant_id", None))
|
||||
if assistant_id:
|
||||
return assistant_id
|
||||
context = getattr(body, "context", None)
|
||||
if isinstance(context, Mapping):
|
||||
return validate_agent_id(str(context.get("agent_id") or "")) if context.get("agent_id") else None
|
||||
metadata = getattr(body, "metadata", None)
|
||||
if isinstance(metadata, Mapping):
|
||||
return validate_agent_id(str(metadata.get("agent_id") or "")) if metadata.get("agent_id") else None
|
||||
return None
|
||||
|
||||
|
||||
def _agent_bound_ids(body: Any) -> list[str]:
|
||||
agent_id = _assistant_agent_id(body)
|
||||
if not agent_id:
|
||||
return []
|
||||
try:
|
||||
config = load_agent_config(agent_id)
|
||||
except (FileNotFoundError, ValueError):
|
||||
return []
|
||||
return _as_id_list(config.llmwiki_knowledge_base_ids if config else None)
|
||||
|
||||
|
||||
def resolve_requested_llmwiki_knowledge_base_ids(body: Any) -> list[str]:
|
||||
"""Return selected ids plus custom-agent defaults, de-duplicated in order."""
|
||||
|
||||
return list(dict.fromkeys([*_agent_bound_ids(body), *_body_selected_ids(body)]))
|
||||
|
||||
|
||||
def llmwiki_selection_requested(body: Any) -> bool:
|
||||
return bool(resolve_requested_llmwiki_knowledge_base_ids(body))
|
||||
|
||||
|
||||
def _latest_human_query(graph_input: dict[str, Any]) -> str:
|
||||
messages = graph_input.get("messages")
|
||||
if not isinstance(messages, list):
|
||||
return ""
|
||||
for message in reversed(messages):
|
||||
if isinstance(message, HumanMessage):
|
||||
return _message_text(getattr(message, "content", ""))
|
||||
if isinstance(message, dict) and str(message.get("type") or message.get("role") or "") in {"human", "user"}:
|
||||
return _message_text(message.get("content"))
|
||||
return ""
|
||||
|
||||
|
||||
def _message_text(content: Any) -> str:
|
||||
if isinstance(content, str):
|
||||
return content.strip()
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for item in content:
|
||||
if isinstance(item, str):
|
||||
parts.append(item)
|
||||
elif isinstance(item, Mapping):
|
||||
text = item.get("text") or item.get("content")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
return "\n".join(part.strip() for part in parts if part and part.strip()).strip()
|
||||
return str(content or "").strip()
|
||||
|
||||
|
||||
def _truncate(text: str, max_chars: int) -> str:
|
||||
value = text.strip()
|
||||
if len(value) <= max_chars:
|
||||
return value
|
||||
return f"{value[: max_chars - 1].rstrip()}…"
|
||||
|
||||
|
||||
def _source_from_result(result: dict[str, Any], mapping: dict[str, Any], index: int) -> dict[str, Any]:
|
||||
chunk_id = str(result.get("chunk_id") or "")
|
||||
title = str(result.get("title") or result.get("filename") or mapping.get("name") or f"LLMWiki 片段 {index}")
|
||||
content = str(result.get("content") or "")
|
||||
params = urlencode({"knowledge_base_id": mapping["id"], "chunk_id": chunk_id})
|
||||
raw = {
|
||||
**result,
|
||||
"knowledge_base_id": mapping["id"],
|
||||
"knowledge_base_name": mapping.get("name") or "",
|
||||
"source": mapping.get("name") or "LLMWiki",
|
||||
"url": f"/weknora-source-preview?{params}",
|
||||
}
|
||||
return {
|
||||
"index": index,
|
||||
"id": chunk_id or str(result.get("knowledge_id") or f"{mapping['id']}:{index}"),
|
||||
"title": title,
|
||||
"type": "llmwiki",
|
||||
"snippet": _truncate(content, 360),
|
||||
"content": content,
|
||||
"source": mapping.get("name") or "LLMWiki",
|
||||
"score": float(result.get("score") or 0),
|
||||
"url": f"/weknora-source-preview?{params}",
|
||||
"skillName": "llmwiki_search",
|
||||
"mode": "citation",
|
||||
"raw": raw,
|
||||
}
|
||||
|
||||
|
||||
def _build_prompt(query: str, sources: list[dict[str, Any]]) -> str:
|
||||
if not sources:
|
||||
return (
|
||||
"当前用户选择了 LLMWiki 知识库,但 DeerFlow 未检索到可用片段。"
|
||||
"请直接说明没有检索到可用背景资料,不要编造知识库内容或引用编号。"
|
||||
)
|
||||
|
||||
lines = [
|
||||
"当前用户选择了 LLMWiki 知识库。DeerFlow 已在模型回答前自动检索到以下背景资料。",
|
||||
"请优先依据这些资料回答;如果资料不足,请明确说明不足之处,不要编造。",
|
||||
"引用规则:正文关键结论必须使用 [1]、[2] 这样的编号引用下方资料;只能引用存在的编号;不要在正文末尾重复输出完整参考文献列表,前端会展示引用卡片。",
|
||||
"",
|
||||
f"用户问题:{query}",
|
||||
"",
|
||||
f"<llmwiki_reference_batch source_count=\"{len(sources)}\">",
|
||||
]
|
||||
for source in sources:
|
||||
meta = " / ".join(
|
||||
str(item)
|
||||
for item in (
|
||||
source.get("source"),
|
||||
f"score={source.get('score'):.4f}" if isinstance(source.get("score"), float) else None,
|
||||
)
|
||||
if item
|
||||
)
|
||||
lines.append(f"[{source['index']}] {source.get('title') or 'LLMWiki 片段'}")
|
||||
if meta:
|
||||
lines.append(f"来源:{meta}")
|
||||
lines.append(_truncate(str(source.get("content") or source.get("snippet") or ""), _MAX_SOURCE_CHARS))
|
||||
lines.append("")
|
||||
lines.append("</llmwiki_reference_batch>")
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
async def build_llmwiki_rag_context(
|
||||
*,
|
||||
request: Request,
|
||||
body: Any,
|
||||
graph_input: dict[str, Any],
|
||||
) -> LlmWikiRagContext:
|
||||
selected_ids = resolve_requested_llmwiki_knowledge_base_ids(body)
|
||||
if not selected_ids:
|
||||
return LlmWikiRagContext([], 0, "", [], "")
|
||||
|
||||
runtime = get_resolved_llmwiki_runtime(getattr(request.app.state, "config", None))
|
||||
if not runtime.weknora_enabled:
|
||||
return LlmWikiRagContext(selected_ids, 0, "", [], "")
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
return LlmWikiRagContext(selected_ids, 0, "", [], "")
|
||||
|
||||
query = _latest_human_query(graph_input)
|
||||
if not query:
|
||||
return LlmWikiRagContext(selected_ids, 0, "", [], "")
|
||||
|
||||
user_id, is_admin = await _actor(request)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for mapping_id in selected_ids:
|
||||
row = await store.get_authorized(mapping_id, user_id, write=False, is_admin=is_admin)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail=f"Knowledge base {mapping_id} not found")
|
||||
rows.append(row)
|
||||
remote_to_local = {str(row["weknora_id"]): row for row in rows}
|
||||
|
||||
try:
|
||||
results = await build_weknora_client(runtime).search(query, [str(row["weknora_id"]) for row in rows])
|
||||
except WeKnoraError as exc:
|
||||
logger.warning(
|
||||
"LLMWiki pre-retrieval failed for user=%s kb_count=%s: %s",
|
||||
sanitize_log_param(user_id),
|
||||
len(rows),
|
||||
exc,
|
||||
)
|
||||
return LlmWikiRagContext(
|
||||
selected_ids,
|
||||
0,
|
||||
f"当前用户选择了 LLMWiki 知识库,但自动检索失败:{exc}。请说明暂时无法检索知识库背景资料,不要编造引用。",
|
||||
[],
|
||||
query,
|
||||
)
|
||||
|
||||
sources: list[dict[str, Any]] = []
|
||||
for result in results:
|
||||
mapping = remote_to_local.get(str(result.get("knowledge_base_id") or ""))
|
||||
if mapping is None or not str(result.get("content") or "").strip():
|
||||
continue
|
||||
sources.append(_source_from_result(result, mapping, len(sources) + 1))
|
||||
if len(sources) >= _MAX_SOURCES:
|
||||
break
|
||||
return LlmWikiRagContext(selected_ids, len(sources), _build_prompt(query, sources), sources, query)
|
||||
|
||||
|
||||
def attach_llmwiki_rag_to_graph_input(
|
||||
graph_input: dict[str, Any],
|
||||
rag: LlmWikiRagContext,
|
||||
) -> dict[str, Any]:
|
||||
if not rag.prompt and not rag.sources:
|
||||
return graph_input
|
||||
messages = graph_input.get("messages")
|
||||
if not isinstance(messages, list) or not messages:
|
||||
return graph_input
|
||||
new_messages = list(messages)
|
||||
for index, message in enumerate(new_messages):
|
||||
if not isinstance(message, HumanMessage):
|
||||
continue
|
||||
extra = dict(getattr(message, "additional_kwargs", None) or {})
|
||||
existing = str(extra.get("prompt_prefix") or "").strip()
|
||||
extra["prompt_prefix"] = f"{existing}\n\n{rag.prompt}".strip() if existing else rag.prompt
|
||||
extra["llmwiki_knowledge_base_ids"] = rag.knowledge_base_ids
|
||||
extra["llmwiki_reference_query"] = rag.query
|
||||
if rag.sources:
|
||||
extra["llmwiki_reference_sources"] = rag.sources
|
||||
new_messages[index] = message.model_copy(update={"additional_kwargs": extra})
|
||||
break
|
||||
return {**graph_input, "messages": new_messages}
|
||||
@@ -64,10 +64,15 @@ class AgentResponse(BaseModel):
|
||||
description="入库模板名 (external knowledge-ingest templateName). When set, markdown artifacts produced in this agent's chats can be published to the external knowledge-ingest API.",
|
||||
)
|
||||
|
||||
seat_skill_directive: bool | None = Field(
|
||||
seat_skill_directive: bool | None = Field(
|
||||
default=None,
|
||||
description="会商席位「技能识别强化」开关 (config.yaml). True = 本智能体作为会商席位时强化技能识别 (与业务链条同名开关取 OR). None/False = 关 (默认).",
|
||||
)
|
||||
)
|
||||
|
||||
llmwiki_knowledge_base_ids: list[str] | None = Field(
|
||||
default=None,
|
||||
description="DeerFlow LLMWiki mapping ids this agent may offer for conversation retrieval.",
|
||||
)
|
||||
|
||||
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent")
|
||||
|
||||
@@ -120,7 +125,12 @@ class AgentCreateRequest(BaseModel):
|
||||
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
|
||||
common_questions: list[CommonQuestion] | None = Field(default=None, description="Recommended questions (常用问题) persisted to config.yaml. Each item is {title, prompt}; legacy strings are coerced.")
|
||||
template_name: str | None = Field(default=None, description="入库模板名 persisted to config.yaml. Enables publishing markdown artifacts to the external knowledge-ingest API.")
|
||||
seat_skill_directive: bool | None = Field(default=None, description="会商席位「技能识别强化」开关 persisted to config.yaml. None/False = off (默认).")
|
||||
seat_skill_directive: bool | None = Field(default=None, description="会商席位「技能识别强化」开关 persisted to config.yaml. None/False = off (默认).")
|
||||
llmwiki_knowledge_base_ids: list[str] | None = Field(
|
||||
default=None,
|
||||
max_length=100,
|
||||
description="Allowed DeerFlow LLMWiki mapping ids persisted to config.yaml.",
|
||||
)
|
||||
|
||||
|
||||
class AgentUpdateRequest(BaseModel):
|
||||
@@ -137,7 +147,12 @@ class AgentUpdateRequest(BaseModel):
|
||||
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
|
||||
common_questions: list[CommonQuestion] | None = Field(default=None, description="Updated recommended questions (常用问题, config.yaml). Each item is {title, prompt}; legacy strings are coerced.")
|
||||
template_name: str | None = Field(default=None, description="Updated 入库模板名 (config.yaml). Empty string clears it.")
|
||||
seat_skill_directive: bool | None = Field(default=None, description="Updated 会商席位「技能识别强化」开关 (config.yaml).")
|
||||
seat_skill_directive: bool | None = Field(default=None, description="Updated 会商席位「技能识别强化」开关 (config.yaml).")
|
||||
llmwiki_knowledge_base_ids: list[str] | None = Field(
|
||||
default=None,
|
||||
max_length=100,
|
||||
description="Updated allowed DeerFlow LLMWiki mapping ids (config.yaml).",
|
||||
)
|
||||
|
||||
|
||||
class AgentSelectorResponse(BaseModel):
|
||||
@@ -178,10 +193,38 @@ def _current_user_id(request: Request) -> str:
|
||||
return get_effective_user_id()
|
||||
|
||||
|
||||
def _is_admin_user(request: Request) -> bool:
|
||||
def _is_admin_user(request: Request) -> bool:
|
||||
"""Return True when the request is authenticated as a system admin."""
|
||||
user = getattr(request.state, "user", None)
|
||||
return getattr(user, "system_role", None) == "admin"
|
||||
return getattr(user, "system_role", None) == "admin"
|
||||
|
||||
|
||||
async def _validate_llmwiki_bindings(
|
||||
request: Request,
|
||||
mapping_ids: list[str] | None,
|
||||
) -> list[str] | None:
|
||||
"""Normalize bindings and require read access for the editing actor."""
|
||||
|
||||
if mapping_ids is None:
|
||||
return None
|
||||
normalized = list(dict.fromkeys(item.strip() for item in mapping_ids if item.strip()))
|
||||
if not normalized:
|
||||
return []
|
||||
store = getattr(request.app.state, "llmwiki_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="LLMWiki metadata store is unavailable")
|
||||
user_id = _current_user_id(request)
|
||||
is_admin = _is_admin_user(request)
|
||||
for mapping_id in normalized:
|
||||
row = await store.get_authorized(
|
||||
mapping_id,
|
||||
user_id,
|
||||
write=False,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=422, detail=f"LLMWiki knowledge base '{mapping_id}' is unavailable")
|
||||
return normalized
|
||||
|
||||
|
||||
def _email_local_part(email: str | None) -> str | None:
|
||||
@@ -233,7 +276,7 @@ def _validate_storage_id(agent_id: str) -> str:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _write_agent_config(
|
||||
def _write_agent_config(
|
||||
agent_id: str,
|
||||
*,
|
||||
name: str,
|
||||
@@ -243,8 +286,9 @@ def _write_agent_config(
|
||||
tool_groups: list[str] | None = None,
|
||||
common_questions: list[CommonQuestion] | None = None,
|
||||
template_name: str | None = None,
|
||||
seat_skill_directive: bool | None = None,
|
||||
) -> None:
|
||||
seat_skill_directive: bool | None = None,
|
||||
llmwiki_knowledge_base_ids: list[str] | None = None,
|
||||
) -> None:
|
||||
agent_dir = get_paths().agent_dir(agent_id)
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
config_file = agent_dir / "config.yaml"
|
||||
@@ -273,15 +317,19 @@ def _write_agent_config(
|
||||
if template_name is not None and template_name.strip():
|
||||
config_data["template_name"] = template_name.strip()
|
||||
# 会商席位技能识别强化开关:仅当显式设置时写入(None = 不写, 由 AgentConfig 默认为关)。
|
||||
if seat_skill_directive is not None:
|
||||
config_data["seat_skill_directive"] = bool(seat_skill_directive)
|
||||
if seat_skill_directive is not None:
|
||||
config_data["seat_skill_directive"] = bool(seat_skill_directive)
|
||||
if llmwiki_knowledge_base_ids is not None:
|
||||
config_data["llmwiki_knowledge_base_ids"] = list(
|
||||
dict.fromkeys(item.strip() for item in llmwiki_knowledge_base_ids if item.strip())
|
||||
)
|
||||
with open(config_file, "w", encoding="utf-8") as f:
|
||||
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
|
||||
|
||||
|
||||
def _load_config_extras(agent_id: str) -> dict[str, Any]:
|
||||
"""Read skills/model/tool_groups/common_questions from the agent's config.yaml, tolerant of missing files."""
|
||||
empty = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None}
|
||||
empty = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None, "llmwiki_knowledge_base_ids": None}
|
||||
try:
|
||||
cfg = load_agent_config(agent_id)
|
||||
except (FileNotFoundError, ValueError):
|
||||
@@ -294,7 +342,8 @@ def _load_config_extras(agent_id: str) -> dict[str, Any]:
|
||||
"tool_groups": cfg.tool_groups,
|
||||
"common_questions": cfg.common_questions,
|
||||
"template_name": cfg.template_name,
|
||||
"seat_skill_directive": cfg.seat_skill_directive,
|
||||
"seat_skill_directive": cfg.seat_skill_directive,
|
||||
"llmwiki_knowledge_base_ids": cfg.llmwiki_knowledge_base_ids,
|
||||
}
|
||||
|
||||
|
||||
@@ -455,7 +504,7 @@ def _record_to_response(
|
||||
can happen lazily when the agent is actually opened.
|
||||
"""
|
||||
soul: str | None = None
|
||||
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None}
|
||||
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None, "llmwiki_knowledge_base_ids": None}
|
||||
if include_extras:
|
||||
_ensure_agent_dir_from_record(record)
|
||||
extras = _load_config_extras(record["id"])
|
||||
@@ -479,7 +528,8 @@ def _record_to_response(
|
||||
skills=extras["skills"],
|
||||
common_questions=extras.get("common_questions"),
|
||||
template_name=extras.get("template_name"),
|
||||
seat_skill_directive=extras.get("seat_skill_directive"),
|
||||
seat_skill_directive=extras.get("seat_skill_directive"),
|
||||
llmwiki_knowledge_base_ids=extras.get("llmwiki_knowledge_base_ids"),
|
||||
featured_order=record.get("featured_order"),
|
||||
)
|
||||
|
||||
@@ -780,7 +830,7 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
|
||||
summary="Create Agent",
|
||||
description="Create a new user-owned agent. The display name can be Chinese and may duplicate existing names.",
|
||||
)
|
||||
async def create_agent_endpoint(request: AgentCreateRequest, http_request: Request) -> AgentResponse:
|
||||
async def create_agent_endpoint(request: AgentCreateRequest, http_request: Request) -> AgentResponse:
|
||||
_require_agents_api_enabled()
|
||||
name = _validate_display_name(request.name)
|
||||
store = get_agent_store(http_request)
|
||||
@@ -789,9 +839,13 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
|
||||
agent_id = _validate_storage_id(request.id)
|
||||
if await store.get_any(agent_id) is not None:
|
||||
raise HTTPException(status_code=409, detail=f"Agent id '{agent_id}' already exists")
|
||||
else:
|
||||
agent_id = uuid4().hex
|
||||
agent_dir = get_paths().agent_dir(agent_id)
|
||||
else:
|
||||
agent_id = uuid4().hex
|
||||
agent_dir = get_paths().agent_dir(agent_id)
|
||||
llmwiki_ids = await _validate_llmwiki_bindings(
|
||||
http_request,
|
||||
request.llmwiki_knowledge_base_ids,
|
||||
)
|
||||
|
||||
try:
|
||||
if agent_dir.exists():
|
||||
@@ -806,7 +860,8 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
|
||||
tool_groups=request.tool_groups,
|
||||
common_questions=request.common_questions,
|
||||
template_name=request.template_name,
|
||||
seat_skill_directive=request.seat_skill_directive,
|
||||
seat_skill_directive=request.seat_skill_directive,
|
||||
llmwiki_knowledge_base_ids=llmwiki_ids,
|
||||
)
|
||||
_write_agent_soul(agent_id, request.soul)
|
||||
|
||||
@@ -887,9 +942,14 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
||||
next_tool_groups = request.tool_groups if "tool_groups" in fields_set else existing_extras["tool_groups"]
|
||||
next_common_questions = request.common_questions if "common_questions" in fields_set else existing_extras["common_questions"]
|
||||
next_template_name = request.template_name if "template_name" in fields_set else existing_extras["template_name"]
|
||||
next_seat_skill_directive = request.seat_skill_directive if "seat_skill_directive" in fields_set else existing_extras["seat_skill_directive"]
|
||||
|
||||
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions", "template_name", "seat_skill_directive"))
|
||||
next_seat_skill_directive = request.seat_skill_directive if "seat_skill_directive" in fields_set else existing_extras["seat_skill_directive"]
|
||||
next_llmwiki_ids = (
|
||||
await _validate_llmwiki_bindings(http_request, request.llmwiki_knowledge_base_ids)
|
||||
if "llmwiki_knowledge_base_ids" in fields_set
|
||||
else existing_extras["llmwiki_knowledge_base_ids"]
|
||||
)
|
||||
|
||||
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions", "template_name", "seat_skill_directive", "llmwiki_knowledge_base_ids"))
|
||||
if config_dirty:
|
||||
_write_agent_config(
|
||||
agent_id,
|
||||
@@ -900,7 +960,8 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
||||
tool_groups=next_tool_groups,
|
||||
common_questions=next_common_questions,
|
||||
template_name=next_template_name,
|
||||
seat_skill_directive=next_seat_skill_directive,
|
||||
seat_skill_directive=next_seat_skill_directive,
|
||||
llmwiki_knowledge_base_ids=next_llmwiki_ids,
|
||||
)
|
||||
# Keep the listing cache in lock-step with config.yaml, but only
|
||||
# when the cached fields were actually touched — a partial update
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -12,11 +12,12 @@ matching the LangGraph Platform wire format expected by the
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
@@ -26,6 +27,7 @@ from sqlalchemy import select
|
||||
|
||||
from app.gateway.authz import require_permission
|
||||
from app.gateway.deps import get_checkpointer
|
||||
from app.gateway.llmwiki_deposit import delete_conversation_deposit_documents
|
||||
from app.gateway.utils import sanitize_log_param
|
||||
from deerflow.config.extensions_config import SkillDisplayConfig
|
||||
from deerflow.config.paths import Paths, get_paths
|
||||
@@ -34,10 +36,12 @@ from deerflow.persistence.skills.model import SkillDisplayAdapterRow
|
||||
from deerflow.runtime import serialize_channel_values
|
||||
from deerflow.runtime.references import (
|
||||
adapt_reference_batches_with_displays,
|
||||
build_reference_batches as build_runtime_reference_batches,
|
||||
build_reference_debug_payload,
|
||||
build_reference_debug_summary,
|
||||
)
|
||||
from deerflow.runtime.references import (
|
||||
build_reference_batches as build_runtime_reference_batches,
|
||||
)
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
from deerflow.utils.time import coerce_iso, now_iso
|
||||
|
||||
@@ -1250,6 +1254,13 @@ async def clear_thread_messages(thread_id: str, request: Request) -> dict[str, A
|
||||
len(messages),
|
||||
sanitize_log_param(thread_id),
|
||||
)
|
||||
asyncio.create_task(
|
||||
delete_conversation_deposit_documents(
|
||||
request.app,
|
||||
thread_id=thread_id,
|
||||
message_ids=None,
|
||||
)
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"deleted_ids": deleted_ids,
|
||||
@@ -1345,6 +1356,13 @@ async def delete_thread_message(thread_id: str, message_id: str, request: Reques
|
||||
len(messages) - len(filtered),
|
||||
sanitize_log_param(thread_id),
|
||||
)
|
||||
asyncio.create_task(
|
||||
delete_conversation_deposit_documents(
|
||||
request.app,
|
||||
thread_id=thread_id,
|
||||
message_ids=deleted_ids,
|
||||
)
|
||||
)
|
||||
return {"ok": True, "deleted_ids": deleted_ids, "remaining": len(filtered)}
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ from fastapi import HTTPException, Request
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from app.gateway.deps import get_concurrency_gate, get_run_context, get_run_manager, get_stream_bridge
|
||||
from app.gateway.llmwiki_rag import (
|
||||
attach_llmwiki_rag_to_graph_input,
|
||||
build_llmwiki_rag_context,
|
||||
llmwiki_selection_requested,
|
||||
)
|
||||
from app.gateway.utils import sanitize_log_param
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.config.agents_config import load_agent_config, validate_agent_id
|
||||
@@ -275,6 +280,8 @@ def _prompt_prefix_opted_in(body: Any) -> bool:
|
||||
|
||||
|
||||
def _rag_mode_requested(body: Any) -> bool:
|
||||
if llmwiki_selection_requested(body):
|
||||
return True
|
||||
context = getattr(body, "context", None)
|
||||
if isinstance(context, Mapping) and context.get("rag_mode_enabled"):
|
||||
return True
|
||||
@@ -325,6 +332,7 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
|
||||
"thinking_force_disabled",
|
||||
"reasoning_effort",
|
||||
"rag_mode_enabled",
|
||||
"llmwiki_knowledge_base_ids",
|
||||
"writing_mode",
|
||||
"writing_artifact_path",
|
||||
"canvas_artifact_title",
|
||||
@@ -1003,6 +1011,27 @@ async def start_run(
|
||||
prefix_settings = load_system_settings().prompt_prefix
|
||||
if prefix_settings.enabled and prefix_settings.prompt_prefix.strip():
|
||||
graph_input = _prepend_prompt_prefix(graph_input, prefix_settings.prompt_prefix.strip())
|
||||
llmwiki_rag = await build_llmwiki_rag_context(
|
||||
request=request,
|
||||
body=body,
|
||||
graph_input=graph_input,
|
||||
)
|
||||
if llmwiki_rag.prompt or llmwiki_rag.sources:
|
||||
graph_input = attach_llmwiki_rag_to_graph_input(graph_input, llmwiki_rag)
|
||||
effective_rag_mode = True
|
||||
run_metadata["llmwiki_knowledge_base_ids"] = llmwiki_rag.knowledge_base_ids
|
||||
run_metadata["llmwiki_rag_source_count"] = llmwiki_rag.source_count
|
||||
try:
|
||||
await run_ctx.thread_store.update_metadata(
|
||||
thread_id,
|
||||
{
|
||||
"llmwiki_knowledge_base_ids": llmwiki_rag.knowledge_base_ids,
|
||||
"rag_mode_enabled": True,
|
||||
"rag_mode": True,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to persist LLMWiki RAG metadata for %s", sanitize_log_param(thread_id), exc_info=True)
|
||||
if effective_rag_mode:
|
||||
graph_input = _append_invisible_prompt(graph_input, _RAG_CITATION_PROMPT)
|
||||
config = build_run_config(thread_id, body.config, body.metadata, assistant_id=body.assistant_id)
|
||||
@@ -1014,6 +1043,8 @@ async def start_run(
|
||||
body_context = dict(getattr(body, "context", None) or {})
|
||||
if effective_rag_mode:
|
||||
body_context["rag_mode_enabled"] = True
|
||||
if "llmwiki_knowledge_base_ids" not in body_context and llmwiki_rag.knowledge_base_ids:
|
||||
body_context["llmwiki_knowledge_base_ids"] = llmwiki_rag.knowledge_base_ids
|
||||
# Promote whitelisted top-level body fields into the context map so
|
||||
# callers can pass them either nested under ``context`` or as flat
|
||||
# top-level fields (``excluded_tools``, ``skill_stop_names``, …).
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Shared helpers for WeKnora iframe proxy sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
WEKNORA_EMBED_COOKIE = "deerflow_weknora_embed"
|
||||
WEKNORA_EMBED_TTL_SECONDS = 15 * 60
|
||||
WEKNORA_EMBED_API_PREFIX = "/api/llmwiki/weknora-embed"
|
||||
WEKNORA_PROXY_SECRET = os.getenv("DEERFLOW_LLMWIKI_PROXY_SECRET") or secrets.token_urlsafe(48)
|
||||
WEKNORA_ROOT_ASSET_PATHS: frozenset[str] = frozenset(
|
||||
{
|
||||
"/favicon.ico",
|
||||
"/favicon.svg",
|
||||
"/config.js",
|
||||
"/logo.svg",
|
||||
"/manifest.webmanifest",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WeKnoraEmbedContext:
|
||||
mapping_id: str
|
||||
weknora_id: str
|
||||
user_id: str
|
||||
is_admin: bool
|
||||
can_write: bool
|
||||
exp: int
|
||||
is_conversation_deposit: bool = False
|
||||
|
||||
|
||||
def is_weknora_embed_proxy_path(path: str) -> bool:
|
||||
return (
|
||||
path == "/platform"
|
||||
or path.startswith("/platform/")
|
||||
or path.startswith("/assets/")
|
||||
or path.startswith("/locales/")
|
||||
or path.startswith("/tdesign-icons/")
|
||||
or path in WEKNORA_ROOT_ASSET_PATHS
|
||||
or path.startswith(f"{WEKNORA_EMBED_API_PREFIX}/api/v1/")
|
||||
or (path.startswith("/api/v1/") and not path.startswith("/api/v1/auth/"))
|
||||
)
|
||||
|
||||
|
||||
def _b64url_encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _b64url_decode(data: str) -> bytes:
|
||||
padded = data + ("=" * (-len(data) % 4))
|
||||
return base64.urlsafe_b64decode(padded.encode("ascii"))
|
||||
|
||||
|
||||
def sign_weknora_embed_payload(payload: dict[str, Any]) -> str:
|
||||
body = _b64url_encode(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8"))
|
||||
sig = hmac.new(WEKNORA_PROXY_SECRET.encode("utf-8"), body.encode("ascii"), hashlib.sha256).digest()
|
||||
return f"{body}.{_b64url_encode(sig)}"
|
||||
|
||||
|
||||
def verify_weknora_embed_cookie(raw: str | None) -> WeKnoraEmbedContext:
|
||||
if not raw or "." not in raw:
|
||||
raise HTTPException(status_code=401, detail="WeKnora iframe session is missing")
|
||||
body, sig = raw.rsplit(".", 1)
|
||||
expected = _b64url_encode(
|
||||
hmac.new(WEKNORA_PROXY_SECRET.encode("utf-8"), body.encode("ascii"), hashlib.sha256).digest()
|
||||
)
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
raise HTTPException(status_code=401, detail="WeKnora iframe session is invalid")
|
||||
try:
|
||||
payload = json.loads(_b64url_decode(body))
|
||||
except (ValueError, TypeError, json.JSONDecodeError):
|
||||
raise HTTPException(status_code=401, detail="WeKnora iframe session is invalid") from None
|
||||
if int(payload.get("exp") or 0) < int(time.time()):
|
||||
raise HTTPException(status_code=401, detail="WeKnora iframe session has expired")
|
||||
return WeKnoraEmbedContext(
|
||||
mapping_id=str(payload.get("mapping_id") or ""),
|
||||
weknora_id=str(payload.get("weknora_id") or ""),
|
||||
user_id=str(payload.get("user_id") or ""),
|
||||
is_admin=bool(payload.get("is_admin")),
|
||||
can_write=bool(payload.get("can_write")),
|
||||
exp=int(payload.get("exp") or 0),
|
||||
is_conversation_deposit=bool(payload.get("is_conversation_deposit")),
|
||||
)
|
||||
|
||||
|
||||
def has_valid_weknora_embed_cookie(raw: str | None) -> bool:
|
||||
try:
|
||||
verify_weknora_embed_cookie(raw)
|
||||
except HTTPException:
|
||||
return False
|
||||
return True
|
||||
@@ -246,8 +246,20 @@ tool_groups:
|
||||
tools:
|
||||
- name: web_search
|
||||
group: web
|
||||
use: deerflow.community.ddg_search.tools:web_search_tool
|
||||
use: deerflow.community.configurable_search.tools:web_search_tool
|
||||
# Keep disabled until the deployment-specific endpoint is filled in.
|
||||
# When false, web_search is not registered with the agent at all.
|
||||
enabled: false
|
||||
endpoint: "https://xxx.xxx.xxx/searchName"
|
||||
verify_ssl: false
|
||||
timeout: 30
|
||||
max_results: 5
|
||||
result_url_template: "https://xxx.xxx.xxx/mindId/xxx?recUuid={recUuid}"
|
||||
# The runtime query always overwrites payload.query; all other fields are
|
||||
# sent unchanged so deployment-specific request parameters stay in config.
|
||||
payload:
|
||||
query: ""
|
||||
xxx: "xxxx"
|
||||
|
||||
- name: web_fetch
|
||||
group: web
|
||||
@@ -466,6 +478,21 @@ ai_writing:
|
||||
writer: false # 作家(逐章正文写作,逐章流式与严格模式求助逻辑不变)
|
||||
editor: false # 编辑(事实/逻辑/语言三维度审查)
|
||||
|
||||
# Optional independently deployed WeKnora integration. DeerFlow owns only
|
||||
# these connection values; database/Redis/storage/parser/model settings stay
|
||||
# in WeKnora's own docker-compose/.env/config files. A non-empty api_base_url
|
||||
# enables WeKnora mode; leaving it empty preserves the existing LLMWiki iframe.
|
||||
# admin_email/admin_password are used only by DeerFlow's server-side WeKnora
|
||||
# detail-page iframe proxy. Prefer environment variables in production, e.g.
|
||||
# admin_email: "$WEKNORA_ADMIN_EMAIL"
|
||||
# admin_password: "$WEKNORA_ADMIN_PASSWORD"
|
||||
llmwiki:
|
||||
weknora:
|
||||
api_base_url: "http://119.254.86.251:8088/deerflow-api"
|
||||
web_base_url: "http://119.254.86.251:8088"
|
||||
admin_email: "lqq@qq.com"
|
||||
admin_password: "$WEKNORA_ADMIN_PASSWORD"
|
||||
|
||||
log_system:
|
||||
enabled: true
|
||||
url: 'https://your-log-system.example.com'
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# LLMWiki × WeKnora 集成与部署
|
||||
|
||||
## 目标与边界
|
||||
|
||||
DeerFlow 只把 WeKnora 当作独立部署的知识抽取、向量化和检索服务,不复制或修改 WeKnora 前端,也不接管它的运行配置。
|
||||
|
||||
本版本按 WeKnora `v0.7.2` 的官方 REST API 实现;升级 WeKnora 大版本前应先回归下文列出的主要接口。
|
||||
|
||||
- WeKnora 自己负责 PostgreSQL、Redis、对象存储、解析器、向量库、Embedding/Rerank/LLM 等依赖。
|
||||
- DeerFlow 只保存 WeKnora 连接地址,以及 DeerFlow 用户、发布状态、智能体绑定与 WeKnora 知识库之间的映射。
|
||||
- DeerFlow 不保存也不发送 WeKnora API Key、Bearer Token 或其他 WeKnora 凭证;浏览器拿不到原始 WeKnora 知识库 ID。
|
||||
- 用户权限、智能体所有权和页面风格全部沿用 DeerFlow;库所有者发布后立即进入公共 LLMWiki,不经过审核队列。
|
||||
|
||||
## 模式开关
|
||||
|
||||
`config.yaml` 中 `llmwiki.weknora.api_base_url` 非空时启用 WeKnora 模式;为空时继续使用原有 LLMWiki 页面和流程。
|
||||
|
||||
```yaml
|
||||
llmwiki:
|
||||
weknora:
|
||||
api_base_url: "http://weknora-app:8080"
|
||||
web_base_url: "http://weknora-app:8080"
|
||||
```
|
||||
|
||||
管理员也可在 DeerFlow 原 LLMWiki 页面中的“集成配置”修改地址。页面配置是运行时覆盖项:启用覆盖并填写地址会切换到 WeKnora;启用覆盖但留空会强制回到原模式;“恢复配置文件设置”会重新采用 `config.yaml`。
|
||||
|
||||
这两个地址是 DeerFlow 中全部的 WeKnora 配置,不需要创建密钥文件,也不需要在 DeerFlow 中配置 WeKnora 的数据库、模型或存储参数。
|
||||
|
||||
## WeKnora 侧准备
|
||||
|
||||
先按 WeKnora 官方部署方式完整启动 WeKnora。数据库、Redis、MinIO/S3、解析服务和所有模型均在 WeKnora 自己的 compose、`.env` 或管理页面配置,DeerFlow 无对应字段。
|
||||
|
||||
DeerFlow 对 WeKnora 使用无凭证的内部 HTTP 请求,因此 `api_base_url` 必须指向可信内网服务,不能把匿名接口直接暴露到公网。需要在 WeKnora 自身或前置反向代理中,仅按容器网络、源 IP、安全组或 mTLS 等基础设施边界放行 DeerFlow。若 WeKnora 保持默认接口鉴权且未放行,连接测试会收到 401/403,并提示配置 WeKnora 或反向代理;DeerFlow 不提供凭证回退。
|
||||
|
||||
WeKnora 中至少需要一个可用且标为默认的 Embedding 模型。创建知识库时,DeerFlow 调用 WeKnora `/api/v1/models` 查找 `type=Embedding` 的默认模型,并把它的 ID 传给 WeKnora;因此 DeerFlow 不提供 `default_embedding_model_id` 配置。这个 ID 的含义是“新知识库使用哪个编码模型生成向量”,其生命周期属于 WeKnora。
|
||||
|
||||
WeKnora 侧放行范围需要覆盖本版本调用的模型读取、知识库管理、文档写入和知识检索接口。如果基础设施只放行检索接口,可以检索已有知识库,但无法通过 DeerFlow 创建知识库或上传文档。
|
||||
|
||||
## 数据与权限模型
|
||||
|
||||
DeerFlow 的 `llmwiki_knowledge_bases` 表保存以下控制面数据:DeerFlow 映射 ID、WeKnora ID、所有者用户 ID、名称快照和公开状态。文档、切片、向量和模型参数仍只存在 WeKnora。
|
||||
|
||||
- 个人库:仅所有者和 DeerFlow 管理员可管理。
|
||||
- 公共库:所有者点击发布后立即公开,全部 DeerFlow 用户只读可检索;所有者可随时取消公开。
|
||||
- 删除个人库时先删除 WeKnora 实体,再删除 DeerFlow 映射。
|
||||
- 所有 BFF 接口和智能体工具都会按当前 DeerFlow 登录用户重新鉴权,不能靠前端提交任意知识库 ID 越权。
|
||||
|
||||
当前无凭证/反向代理方案使用一个固定的 WeKnora 服务身份。因此 DeerFlow 的用户隔离是映射层的逻辑隔离:不同 DeerFlow 用户只能看到自己拥有的库和已公开库;但这些库在 WeKnora 物理上都创建于该服务身份所属的同一个空间。上传文件不会落到 DeerFlow 用户目录,而是经 DeerFlow BFF 转发给 WeKnora,由 WeKnora 把文件对象、知识元数据、切片和向量分别写入自己的对象存储、数据库和向量存储。需要在 WeKnora 后台核对时,应登录这个服务身份所属空间,在“知识库管理”中按同名知识库进入查看。
|
||||
|
||||
如果要求 WeKnora 本身也按 DeerFlow 用户划分空间,则需要另一种部署:为每个 DeerFlow 用户建立 WeKnora 用户/租户并安全保存、轮换各自凭证。它与当前“不在 DeerFlow 配置 WeKnora 凭证”的单服务身份方案不同,不能只靠前端切换实现。
|
||||
|
||||
## 智能体和对话选择
|
||||
|
||||
智能体 `config.yaml` 可保存:
|
||||
|
||||
```yaml
|
||||
llmwiki_knowledge_base_ids:
|
||||
- "deerflow-mapping-id-1"
|
||||
- "deerflow-mapping-id-2"
|
||||
```
|
||||
|
||||
这里必须是 DeerFlow 映射 ID,不是 WeKnora 原始 ID。智能体创建页和详情编辑页均提供内嵌的“绑定知识空间”面板,可直接搜索、分组、多选、全选或清空当前用户有权读取的个人库和公共库,不再通过额外弹窗配置。
|
||||
|
||||
新建智能体对话时,页面只展示“智能体已绑定”且“当前登录用户仍有权读取”的交集,默认全选,可在发送第一条消息前调整或清空。普通新建对话也可选择知识库;选中了智能体时会自动切换成该智能体的绑定范围。问答选择器仿照 WeKnora 的交互结构,作为输入框工具栏的锚定下拉呈现,包含搜索、个人/公共分组、文档数量、多选、全选和清空,不再在输入框外单独占据一行。选择结果写入 LangGraph thread metadata 和运行 context,重新打开对话会恢复,开始对话后不可中途扩大权限范围。
|
||||
|
||||
`llmwiki_search` 是只读工具,只在 WeKnora 模式注册。每次调用会同时校验:
|
||||
|
||||
1. 当前对话选择;
|
||||
2. 智能体允许的绑定范围;
|
||||
3. 当前 DeerFlow 用户对映射的实时读取权限。
|
||||
|
||||
明确选择空列表表示本次对话不使用 LLMWiki;旧对话没有选择字段时,智能体对话默认使用其绑定范围,通用 Lead Agent 默认使用当前用户全部可见知识库。
|
||||
|
||||
## 前端实现
|
||||
|
||||
WeKnora 前端不做任何修改。DeerFlow 原“知识库资源管理”入口根据运行模式渲染:
|
||||
|
||||
- 原模式:保留现有 LLMWiki iframe 页面;
|
||||
- WeKnora 模式:渲染 DeerFlow 原生 React 页面,提供个人库、公共库、直接发布和检索。点击卡片进入独立知识库路由,内容区复刻 WeKnora 的详情页结构,同时保留 DeerFlow 的导航、登录和权限壳。Wiki 库按 **Wiki → 文档 → 图谱** 排列并默认打开 Wiki;标准库不显示 Wiki 标签,按 **文档 → 图谱** 排列。Wiki 视图包含页面目录、搜索、页面正文和引用摘要;文档以卡片网格展示,点击后从右侧抽屉查看元数据、原文件与真实解析分块;图谱视图渲染 WeKnora Wiki 页面节点和边。新建知识库可选择“Wiki 知识库”或“标准知识库”,详情页支持文件、网页 URL、手工 Markdown 三种导入方式。问答选择器沿用 WeKnora 的紧凑搜索/分组/多选交互,颜色、间距和组件沿用 DeerFlow 设计系统;智能体创建和编辑页使用同一数据源的内嵌绑定面板。
|
||||
|
||||
问答检索命中以 DeerFlow 来源卡片展示。“查看原文”调用 DeerFlow 的 `/api/llmwiki/sources/context`,由服务端在重新校验 DeerFlow 用户权限后向 WeKnora 读取命中切片及相邻上下文,不向浏览器暴露 WeKnora 原始资源 ID。
|
||||
|
||||
## 主要接口
|
||||
|
||||
- `GET /api/llmwiki/runtime`:当前原模式/WeKnora 模式。
|
||||
- `/api/system-settings/llmwiki`:管理员地址配置、重置和连通性测试。
|
||||
- `/api/llmwiki/knowledge-bases`:知识库 CRUD、标准/Wiki 模式创建、详情、文档上传、解析、直接发布和取消公开。
|
||||
- `POST /api/llmwiki/knowledge-bases/{id}/documents/url`:导入网页 URL。
|
||||
- `POST /api/llmwiki/knowledge-bases/{id}/documents/manual`:发布手工 Markdown 内容。
|
||||
- `GET /api/llmwiki/knowledge-bases/{id}/documents/{document_id}`:具体文档元数据。
|
||||
- `GET /api/llmwiki/knowledge-bases/{id}/documents/{document_id}/preview`:鉴权后代理原文件预览。
|
||||
- `GET /api/llmwiki/knowledge-bases/{id}/documents/{document_id}/chunks`:分页读取解析后的真实分块索引。
|
||||
- `GET /api/llmwiki/knowledge-bases/{id}/graph`:读取 Wiki 页面图谱的节点和边;未开启 Wiki 索引时返回明确的空状态。
|
||||
- `GET /api/llmwiki/knowledge-bases/{id}/wiki/pages` 与 `.../wiki/pages/{slug}`:读取 Wiki 页面目录和正文。
|
||||
- `GET /api/llmwiki/agents/{agent_id}/knowledge-bases`:当前用户可用于该智能体的绑定交集。
|
||||
- `POST /api/llmwiki/search`:页面检索。
|
||||
- `GET /api/llmwiki/sources/context`:引用原文上下文。
|
||||
|
||||
## 验收建议
|
||||
|
||||
1. 地址留空启动,确认原 LLMWiki 页面和原有流程不变。
|
||||
2. 在可信网络中放行 DeerFlow,配置地址,测试连接并创建个人库、上传文档、等待解析完成;确认 DeerFlow 请求没有认证头。
|
||||
3. 普通用户直接发布,换另一用户确认能立即读取和检索、但不能修改;再由所有者取消公开并确认访问随即失效。
|
||||
4. 给智能体绑定两个库,新建对话取消其中一个,确认引用只来自保留的库;重新打开对话确认选择恢复。
|
||||
5. 撤销公共发布或删除用户权限后继续提问,确认工具实时拒绝已失效的知识库。
|
||||
@@ -1,24 +1,5 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"weknora-retrieval": {
|
||||
"enabled": true,
|
||||
"type": "stdio",
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"run",
|
||||
"--no-sync",
|
||||
"python",
|
||||
"skills/custom/weknora-knowledge-search/scripts/weknora_retrieval_mcp.py"
|
||||
],
|
||||
"env": {
|
||||
"WEKNORA_BASE_URL": "http://[::1]:8081",
|
||||
"WEKNORA_DEERFLOW_SHARE_TOKEN_FILE": "F:\\react01\\deeflow-code\\deerflow-server\\offline-backend-20260512\\backend\\.runtime\\secrets\\weknora_deerflow_share_token",
|
||||
"WEKNORA_ALLOWED_KB_IDS": "",
|
||||
"WEKNORA_REQUEST_TIMEOUT_SECONDS": "30",
|
||||
"WEKNORA_RERANK_URL": "http://47.88.25.99:7022"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mcpServers": {},
|
||||
"skills": {
|
||||
"web-research": {
|
||||
"enabled": true
|
||||
@@ -49,9 +30,6 @@
|
||||
},
|
||||
"browser_act": {
|
||||
"enabled": true
|
||||
},
|
||||
"weknora-knowledge-search": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
from .tools import web_search_tool
|
||||
|
||||
__all__ = ["web_search_tool"]
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
"""Configurable JSON-over-HTTP implementation of the ``web_search`` tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
from langchain.tools import tool
|
||||
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.config.tool_config import ToolConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConfigurableSearchSettings:
|
||||
"""Validated runtime settings read from the ``web_search`` tool entry."""
|
||||
|
||||
enabled: bool
|
||||
endpoint: str
|
||||
payload: dict[str, Any]
|
||||
verify_ssl: bool
|
||||
timeout: float
|
||||
max_results: int
|
||||
result_url_template: str
|
||||
|
||||
@classmethod
|
||||
def from_tool_config(cls, config: ToolConfig | None) -> ConfigurableSearchSettings:
|
||||
if config is None:
|
||||
raise ValueError("web_search is not configured")
|
||||
|
||||
extra = config.model_extra or {}
|
||||
endpoint = str(extra.get("endpoint") or "").strip()
|
||||
if not endpoint.startswith(("http://", "https://")):
|
||||
raise ValueError("web_search.endpoint must use http:// or https://")
|
||||
|
||||
raw_payload = extra.get("payload", {})
|
||||
if not isinstance(raw_payload, dict):
|
||||
raise ValueError("web_search.payload must be an object")
|
||||
|
||||
try:
|
||||
timeout = float(extra.get("timeout", 30))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("web_search.timeout must be a number") from exc
|
||||
if timeout <= 0:
|
||||
raise ValueError("web_search.timeout must be greater than zero")
|
||||
|
||||
try:
|
||||
max_results = int(extra.get("max_results", 5))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("web_search.max_results must be an integer") from exc
|
||||
if max_results <= 0:
|
||||
raise ValueError("web_search.max_results must be greater than zero")
|
||||
|
||||
result_url_template = str(extra.get("result_url_template") or "").strip()
|
||||
if result_url_template and "{recUuid}" not in result_url_template:
|
||||
raise ValueError("web_search.result_url_template must contain {recUuid}")
|
||||
|
||||
verify_ssl = extra.get("verify_ssl", True)
|
||||
if not isinstance(verify_ssl, bool):
|
||||
raise ValueError("web_search.verify_ssl must be true or false")
|
||||
|
||||
return cls(
|
||||
enabled=config.enabled,
|
||||
endpoint=endpoint,
|
||||
payload=deepcopy(raw_payload),
|
||||
verify_ssl=verify_ssl,
|
||||
timeout=timeout,
|
||||
max_results=max_results,
|
||||
result_url_template=result_url_template,
|
||||
)
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
"""Normalize optional scalar result fields without leaking ``None``."""
|
||||
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
def _error_result(query: str, message: str) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"query": query,
|
||||
"total_results": 0,
|
||||
"results": [],
|
||||
"error": message,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_results(data: Any, settings: ConfigurableSearchSettings) -> list[dict[str, str]]:
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("search API response must be a JSON object")
|
||||
|
||||
raw_results = data.get("results")
|
||||
if not isinstance(raw_results, list):
|
||||
raise ValueError("search API response field 'results' must be an array")
|
||||
|
||||
normalized: list[dict[str, str]] = []
|
||||
for item in raw_results[: settings.max_results]:
|
||||
if not isinstance(item, dict):
|
||||
logger.warning("Ignoring non-object search result: %r", item)
|
||||
continue
|
||||
|
||||
rec_uuid = _text(item.get("recUuid")).strip()
|
||||
original_url = _text(item.get("url")).strip()
|
||||
result_url = original_url
|
||||
if rec_uuid and settings.result_url_template:
|
||||
result_url = settings.result_url_template.replace(
|
||||
"{recUuid}",
|
||||
quote(rec_uuid, safe=""),
|
||||
)
|
||||
|
||||
normalized.append(
|
||||
{
|
||||
"recUuid": rec_uuid,
|
||||
"content": _text(item.get("content")),
|
||||
"title": _text(item.get("title")),
|
||||
"url": result_url,
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
async def execute_search(
|
||||
query: str,
|
||||
settings: ConfigurableSearchSettings,
|
||||
*,
|
||||
transport: httpx.AsyncBaseTransport | None = None,
|
||||
) -> str:
|
||||
"""Execute a search request; ``transport`` exists for network-free tests."""
|
||||
|
||||
normalized_query = query.strip()
|
||||
if not normalized_query:
|
||||
return _error_result(query, "Search query must not be empty")
|
||||
if not settings.enabled:
|
||||
return _error_result(normalized_query, "web_search is disabled")
|
||||
|
||||
payload = deepcopy(settings.payload)
|
||||
payload["query"] = normalized_query
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=settings.timeout,
|
||||
verify=settings.verify_ssl,
|
||||
transport=transport,
|
||||
) as client:
|
||||
response = await client.post(settings.endpoint, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
results = _normalize_results(data, settings)
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("Configured web search timed out for endpoint %s", settings.endpoint)
|
||||
return _error_result(normalized_query, "Search API request timed out")
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.warning(
|
||||
"Configured web search returned HTTP %s for endpoint %s",
|
||||
exc.response.status_code,
|
||||
settings.endpoint,
|
||||
)
|
||||
return _error_result(normalized_query, f"Search API returned HTTP {exc.response.status_code}")
|
||||
except (httpx.HTTPError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Configured web search request failed: %s", exc)
|
||||
return _error_result(normalized_query, f"Search API request failed: {type(exc).__name__}")
|
||||
except ValueError as exc:
|
||||
logger.warning("Configured web search returned an invalid response: %s", exc)
|
||||
return _error_result(normalized_query, str(exc))
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"query": normalized_query,
|
||||
"total_results": len(results),
|
||||
"results": results,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
@tool("web_search", parse_docstring=True)
|
||||
async def web_search_tool(query: str) -> str:
|
||||
"""Search the configured information service for current facts and source passages.
|
||||
|
||||
Args:
|
||||
query: Focused search keywords or a natural-language retrieval question.
|
||||
"""
|
||||
|
||||
try:
|
||||
settings = ConfigurableSearchSettings.from_tool_config(get_app_config().get_tool_config("web_search"))
|
||||
except ValueError as exc:
|
||||
return _error_result(query, str(exc))
|
||||
return await execute_search(query, settings)
|
||||
@@ -7,7 +7,7 @@ import threading
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, model_validator
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
|
||||
from deerflow.config.paths import get_paths
|
||||
|
||||
@@ -82,7 +82,7 @@ class CommonQuestion(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
class AgentConfig(BaseModel):
|
||||
class AgentConfig(BaseModel):
|
||||
"""Configuration for a custom agent."""
|
||||
|
||||
id: str | None = None
|
||||
@@ -108,7 +108,20 @@ class AgentConfig(BaseModel):
|
||||
# 拼进本轮任务,避免弱模型无视系统提示里的技能块。None/False = 关(默认);True = 开。
|
||||
# 与「业务链条配置」里的同名开关取 OR:任一打开即对该席位生效。见
|
||||
# ``app/gateway/roundtable_seat_skills.py``。
|
||||
seat_skill_directive: bool | None = None
|
||||
seat_skill_directive: bool | None = None
|
||||
# DeerFlow mapping ids this agent may offer in its new-conversation
|
||||
# knowledge selector. WeKnora ids and credentials never enter agent files.
|
||||
llmwiki_knowledge_base_ids: list[str] | None = None
|
||||
|
||||
@field_validator("llmwiki_knowledge_base_ids")
|
||||
@classmethod
|
||||
def _normalize_llmwiki_ids(cls, value: list[str] | None) -> list[str] | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = list(dict.fromkeys(str(item).strip() for item in value if str(item).strip()))
|
||||
if len(normalized) > 100:
|
||||
raise ValueError("An agent can bind at most 100 LLMWiki knowledge bases")
|
||||
return normalized
|
||||
|
||||
|
||||
def load_agent_config(agent_id: str | None) -> AgentConfig | None:
|
||||
|
||||
@@ -17,10 +17,11 @@ from deerflow.config.database_config import DatabaseConfig
|
||||
from deerflow.config.extensions_config import ExtensionsConfig
|
||||
from deerflow.config.gateway_config import GatewayConfig
|
||||
from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
|
||||
from deerflow.config.knowledge_config import KnowledgeConfig
|
||||
from deerflow.config.knowledge_ingest_config import KnowledgeIngestConfig
|
||||
from deerflow.config.log_system_config import LogSystemConfig
|
||||
from deerflow.config.login_config import AuthLoginConfig
|
||||
from deerflow.config.knowledge_config import KnowledgeConfig
|
||||
from deerflow.config.knowledge_ingest_config import KnowledgeIngestConfig
|
||||
from deerflow.config.llmwiki_config import LlmWikiConfig
|
||||
from deerflow.config.log_system_config import LogSystemConfig
|
||||
from deerflow.config.login_config import AuthLoginConfig
|
||||
from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict
|
||||
from deerflow.config.model_config import ModelConfig
|
||||
from deerflow.config.run_events_config import RunEventsConfig
|
||||
@@ -42,7 +43,8 @@ from deerflow.config.tool_search_config import ToolSearchConfig, load_tool_searc
|
||||
from deerflow.config.vision_config import VisionConfig
|
||||
from deerflow.config.y_log_config import YLogConfig
|
||||
|
||||
load_dotenv()
|
||||
load_dotenv()
|
||||
load_dotenv(Path(__file__).resolve().parents[4] / ".env.local", override=True)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -115,7 +117,8 @@ class AppConfig(BaseModel):
|
||||
models: list[ModelConfig] = Field(default_factory=list, description="Available models")
|
||||
vision: VisionConfig = Field(default_factory=VisionConfig, description="Image recognition (识图) configuration")
|
||||
auth_login: AuthLoginConfig = Field(default_factory=AuthLoginConfig, description="Custom login modes (username password gate, token exchange)")
|
||||
log_system: LogSystemConfig = Field(default_factory=LogSystemConfig, description="Admin-only external log-system entry shown in the settings menu")
|
||||
log_system: LogSystemConfig = Field(default_factory=LogSystemConfig, description="Admin-only external log-system entry shown in the settings menu")
|
||||
llmwiki: LlmWikiConfig = Field(default_factory=LlmWikiConfig, description="Conditional legacy/WeKnora LLMWiki integration")
|
||||
sandbox: SandboxConfig = Field(description="Sandbox configuration")
|
||||
tools: list[ToolConfig] = Field(default_factory=list, description="Available tools")
|
||||
tool_groups: list[ToolGroupConfig] = Field(default_factory=list, description="Available tool groups")
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Conditional LLMWiki provider configuration.
|
||||
|
||||
DeerFlow owns only the connection to WeKnora. Database, Redis, document
|
||||
parsing, storage and model configuration stay entirely in WeKnora's own
|
||||
deployment files. An effective non-empty API URL is the sole provider switch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
def normalize_service_url(value: str | None) -> str:
|
||||
"""Normalize an optional HTTP(S) service URL and reject embedded secrets."""
|
||||
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
parsed = urlsplit(raw)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||
raise ValueError("WeKnora addresses must be absolute http(s) URLs")
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise ValueError("WeKnora addresses must not contain credentials")
|
||||
if parsed.query or parsed.fragment:
|
||||
raise ValueError("WeKnora addresses must not contain query strings or fragments")
|
||||
path = parsed.path.rstrip("/")
|
||||
return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
|
||||
|
||||
|
||||
class WeKnoraConnectionConfig(BaseModel):
|
||||
"""The complete DeerFlow-owned connection surface for WeKnora."""
|
||||
|
||||
api_base_url: str = Field(default="", description="WeKnora app API base URL; non-empty enables WeKnora mode")
|
||||
web_base_url: str = Field(default="", description="Optional internal WeKnora administration URL")
|
||||
admin_email: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
"Optional WeKnora administrator email used only by DeerFlow's "
|
||||
"server-side detail-page iframe proxy. Prefer $ENV references."
|
||||
),
|
||||
)
|
||||
admin_password: str = Field(
|
||||
default="",
|
||||
repr=False,
|
||||
description=(
|
||||
"Optional WeKnora administrator password used only by DeerFlow's "
|
||||
"server-side detail-page iframe proxy. Prefer $ENV references."
|
||||
),
|
||||
)
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
@field_validator("api_base_url", "web_base_url")
|
||||
@classmethod
|
||||
def _validate_url(cls, value: str) -> str:
|
||||
return normalize_service_url(value)
|
||||
|
||||
|
||||
class LlmWikiConfig(BaseModel):
|
||||
"""Top-level ``config.yaml`` section for conditional WeKnora mode."""
|
||||
|
||||
weknora: WeKnoraConnectionConfig = Field(default_factory=WeKnoraConnectionConfig)
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
class LlmWikiRuntimeOverride(BaseModel):
|
||||
"""Administrator-written URL override stored in system settings.
|
||||
|
||||
``enabled=False`` means "use config.yaml". When enabled, an empty API URL
|
||||
deliberately forces legacy mode even if the source file has an address.
|
||||
Secrets are never stored here.
|
||||
"""
|
||||
|
||||
enabled: bool = False
|
||||
api_base_url: str = ""
|
||||
web_base_url: str = ""
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
@field_validator("api_base_url", "web_base_url")
|
||||
@classmethod
|
||||
def _validate_url(cls, value: str) -> str:
|
||||
return normalize_service_url(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedLlmWikiRuntime:
|
||||
provider: str
|
||||
api_base_url: str
|
||||
web_base_url: str
|
||||
admin_email: str
|
||||
admin_password: str
|
||||
override_enabled: bool
|
||||
|
||||
@property
|
||||
def weknora_enabled(self) -> bool:
|
||||
return self.provider == "weknora"
|
||||
|
||||
|
||||
def resolve_llmwiki_runtime(
|
||||
config: LlmWikiConfig,
|
||||
override: LlmWikiRuntimeOverride | None = None,
|
||||
) -> ResolvedLlmWikiRuntime:
|
||||
"""Resolve the deterministic provider from file config plus URL override."""
|
||||
|
||||
use_override = bool(override and override.enabled)
|
||||
api_base_url = override.api_base_url if use_override and override else config.weknora.api_base_url
|
||||
web_base_url = override.web_base_url if use_override and override else config.weknora.web_base_url
|
||||
return ResolvedLlmWikiRuntime(
|
||||
provider="weknora" if api_base_url else "legacy",
|
||||
api_base_url=api_base_url,
|
||||
web_base_url=web_base_url,
|
||||
admin_email=config.weknora.admin_email,
|
||||
admin_password=config.weknora.admin_password,
|
||||
override_enabled=use_override,
|
||||
)
|
||||
@@ -23,6 +23,7 @@ from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from deerflow.config.llmwiki_config import LlmWikiRuntimeOverride
|
||||
from deerflow.config.runtime_paths import runtime_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -499,6 +500,7 @@ class SystemSettings(BaseModel):
|
||||
agent_modules: AgentModuleSettings = Field(default_factory=AgentModuleSettings)
|
||||
concurrency_monitor: ConcurrencyMonitorSettings = Field(default_factory=ConcurrencyMonitorSettings)
|
||||
model_concurrency: ModelConcurrencySettings = Field(default_factory=ModelConcurrencySettings)
|
||||
llmwiki: LlmWikiRuntimeOverride = Field(default_factory=LlmWikiRuntimeOverride)
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ToolGroupConfig(BaseModel):
|
||||
"""Config section for a tool group"""
|
||||
|
||||
name: str = Field(..., description="Unique name for the tool group")
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class ToolConfig(BaseModel):
|
||||
"""Config section for a tool"""
|
||||
|
||||
name: str = Field(..., description="Unique name for the tool")
|
||||
group: str = Field(..., description="Group name for the tool")
|
||||
use: str = Field(
|
||||
...,
|
||||
description="Variable name of the tool provider(e.g. deerflow.sandbox.tools:bash_tool)",
|
||||
)
|
||||
model_config = ConfigDict(extra="allow")
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ToolGroupConfig(BaseModel):
|
||||
"""Config section for a tool group"""
|
||||
|
||||
name: str = Field(..., description="Unique name for the tool group")
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class ToolConfig(BaseModel):
|
||||
"""Config section for a tool"""
|
||||
|
||||
name: str = Field(..., description="Unique name for the tool")
|
||||
group: str = Field(..., description="Group name for the tool")
|
||||
enabled: bool = Field(default=True, description="Whether the tool is registered with the agent")
|
||||
use: str = Field(
|
||||
...,
|
||||
description="Variable name of the tool provider(e.g. deerflow.sandbox.tools:bash_tool)",
|
||||
)
|
||||
model_config = ConfigDict(extra="allow")
|
||||
@@ -0,0 +1,2 @@
|
||||
"""External service integrations owned by the DeerFlow harness."""
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
"""WeKnora REST integration."""
|
||||
|
||||
from deerflow.integrations.weknora.client import WeKnoraClient, WeKnoraError
|
||||
|
||||
__all__ = ["WeKnoraClient", "WeKnoraError"]
|
||||
+450
@@ -0,0 +1,450 @@
|
||||
"""Small, version-pinned adapter over WeKnora's official REST API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class WeKnoraError(RuntimeError):
|
||||
"""Normalized upstream failure that is safe for DeerFlow to translate."""
|
||||
|
||||
def __init__(self, message: str, *, status_code: int = 502) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def _data(payload: Any) -> Any:
|
||||
if not isinstance(payload, dict):
|
||||
raise WeKnoraError("WeKnora returned an invalid response")
|
||||
if payload.get("success") is False:
|
||||
message = payload.get("message") or payload.get("error") or "WeKnora rejected the request"
|
||||
raise WeKnoraError(str(message), status_code=400)
|
||||
return payload.get("data", payload)
|
||||
|
||||
|
||||
def _as_list(value: Any) -> list[dict[str, Any]]:
|
||||
if isinstance(value, list):
|
||||
return [dict(item) for item in value if isinstance(item, Mapping)]
|
||||
if isinstance(value, dict):
|
||||
for key in ("items", "list", "knowledge_bases", "knowledge", "records"):
|
||||
nested = value.get(key)
|
||||
if isinstance(nested, list):
|
||||
return [dict(item) for item in nested if isinstance(item, Mapping)]
|
||||
return []
|
||||
|
||||
|
||||
class WeKnoraClient:
|
||||
"""Async client for a trusted-network WeKnora endpoint.
|
||||
|
||||
The caller may inject an ``httpx.AsyncClient`` for connection pooling and
|
||||
tests. When omitted, each public method creates and closes a short-lived
|
||||
client. DeerFlow deliberately sends no WeKnora credential; the independently
|
||||
deployed service or its reverse proxy must allow DeerFlow's internal traffic.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
timeout_seconds: float = 30.0,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self._http_client = http_client
|
||||
if not self.base_url:
|
||||
raise ValueError("WeKnora base URL is required")
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json: Any = None,
|
||||
params: Mapping[str, Any] | None = None,
|
||||
files: Any = None,
|
||||
data: Mapping[str, Any] | None = None,
|
||||
) -> Any:
|
||||
headers = {"Accept": "application/json"}
|
||||
owns_client = self._http_client is None
|
||||
client = self._http_client or httpx.AsyncClient(timeout=httpx.Timeout(self.timeout_seconds), follow_redirects=False)
|
||||
try:
|
||||
response = await client.request(
|
||||
method,
|
||||
f"{self.base_url}{path}",
|
||||
headers=headers,
|
||||
json=json,
|
||||
params=params,
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise WeKnoraError("WeKnora request timed out", status_code=504) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise WeKnoraError("WeKnora is unavailable", status_code=502) from exc
|
||||
finally:
|
||||
if owns_client:
|
||||
await client.aclose()
|
||||
if response.status_code == 404:
|
||||
raise WeKnoraError("WeKnora resource was not found", status_code=404)
|
||||
if response.status_code in {401, 403}:
|
||||
raise WeKnoraError(
|
||||
"WeKnora rejected DeerFlow's anonymous internal request; allow DeerFlow in WeKnora or its reverse proxy",
|
||||
status_code=502,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
detail = ""
|
||||
try:
|
||||
body = response.json()
|
||||
if isinstance(body, dict):
|
||||
detail = str(body.get("message") or body.get("error") or body.get("detail") or "")
|
||||
except ValueError:
|
||||
detail = ""
|
||||
raise WeKnoraError(detail or f"WeKnora returned HTTP {response.status_code}", status_code=502)
|
||||
if response.status_code == 204 or not response.content:
|
||||
return {"success": True}
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise WeKnoraError("WeKnora returned invalid JSON") from exc
|
||||
|
||||
async def health(self) -> dict[str, Any]:
|
||||
payload = await self._request("GET", "/health")
|
||||
return dict(payload) if isinstance(payload, dict) else {"status": "ok"}
|
||||
|
||||
async def list_knowledge_bases(self) -> list[dict[str, Any]]:
|
||||
return _as_list(_data(await self._request("GET", "/api/v1/knowledge-bases")))
|
||||
|
||||
async def get_knowledge_base(self, knowledge_base_id: str) -> dict[str, Any]:
|
||||
value = _data(await self._request("GET", f"/api/v1/knowledge-bases/{quote(knowledge_base_id, safe='')}"))
|
||||
if not isinstance(value, dict):
|
||||
raise WeKnoraError("WeKnora returned an invalid knowledge base")
|
||||
return dict(value)
|
||||
|
||||
async def list_models(self) -> list[dict[str, Any]]:
|
||||
"""List models from WeKnora; model credentials are never returned."""
|
||||
|
||||
return _as_list(_data(await self._request("GET", "/api/v1/models")))
|
||||
|
||||
async def get_default_embedding_model_id(self) -> str:
|
||||
models = [item for item in await self.list_models() if str(item.get("type") or "").strip().lower() == "embedding"]
|
||||
preferred = next((item for item in models if item.get("is_default") is True), None)
|
||||
selected = preferred or next(
|
||||
(item for item in models if str(item.get("status") or "active").lower() == "active"),
|
||||
None,
|
||||
)
|
||||
model_id = str((selected or {}).get("id") or "")
|
||||
if not model_id:
|
||||
raise WeKnoraError(
|
||||
"WeKnora has no available embedding model; configure its default model in WeKnora",
|
||||
status_code=400,
|
||||
)
|
||||
return model_id
|
||||
|
||||
async def get_default_knowledge_qa_model_id(self) -> str:
|
||||
"""Resolve WeKnora's own active synthesis model for Wiki indexing."""
|
||||
|
||||
models = [item for item in await self.list_models() if str(item.get("type") or "").strip().lower() == "knowledgeqa"]
|
||||
preferred = next((item for item in models if item.get("is_default") is True), None)
|
||||
selected = preferred or next(
|
||||
(item for item in models if str(item.get("status") or "active").lower() == "active"),
|
||||
None,
|
||||
)
|
||||
model_id = str((selected or {}).get("id") or "")
|
||||
if not model_id:
|
||||
raise WeKnoraError(
|
||||
"WeKnora has no available KnowledgeQA model; configure one in WeKnora before creating a Wiki",
|
||||
status_code=400,
|
||||
)
|
||||
return model_id
|
||||
|
||||
async def create_knowledge_base(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
description: str = "",
|
||||
kb_type: str = "document",
|
||||
embedding_model_id: str | None = None,
|
||||
wiki_enabled: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
model_id = embedding_model_id or await self.get_default_embedding_model_id()
|
||||
create_payload: dict[str, Any] = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"type": kb_type,
|
||||
"embedding_model_id": model_id,
|
||||
}
|
||||
if wiki_enabled:
|
||||
synthesis_model_id = await self.get_default_knowledge_qa_model_id()
|
||||
create_payload.update(
|
||||
{
|
||||
"summary_model_id": synthesis_model_id,
|
||||
"indexing_strategy": {
|
||||
"vector_enabled": True,
|
||||
"keyword_enabled": True,
|
||||
"wiki_enabled": True,
|
||||
"graph_enabled": False,
|
||||
},
|
||||
"wiki_config": {
|
||||
"synthesis_model_id": synthesis_model_id,
|
||||
"extraction_granularity": "standard",
|
||||
},
|
||||
}
|
||||
)
|
||||
payload = await self._request(
|
||||
"POST",
|
||||
"/api/v1/knowledge-bases",
|
||||
json=create_payload,
|
||||
)
|
||||
value = _data(payload)
|
||||
if not isinstance(value, dict) or not value.get("id"):
|
||||
raise WeKnoraError("WeKnora did not return the created knowledge base")
|
||||
return dict(value)
|
||||
|
||||
async def update_knowledge_base(self, knowledge_base_id: str, changes: Mapping[str, Any]) -> dict[str, Any]:
|
||||
payload = dict(changes)
|
||||
if not str(payload.get("name") or "").strip():
|
||||
current = await self.get_knowledge_base(knowledge_base_id)
|
||||
current_name = str(current.get("name") or "").strip()
|
||||
if not current_name:
|
||||
raise WeKnoraError("WeKnora knowledge base name is required for updates")
|
||||
payload["name"] = current_name
|
||||
value = _data(
|
||||
await self._request(
|
||||
"PUT",
|
||||
f"/api/v1/knowledge-bases/{quote(knowledge_base_id, safe='')}",
|
||||
json=payload,
|
||||
)
|
||||
)
|
||||
return dict(value) if isinstance(value, dict) else {}
|
||||
|
||||
async def delete_knowledge_base(self, knowledge_base_id: str) -> None:
|
||||
await self._request("DELETE", f"/api/v1/knowledge-bases/{quote(knowledge_base_id, safe='')}")
|
||||
|
||||
async def list_documents(self, knowledge_base_id: str, *, page: int = 1, page_size: int = 100) -> dict[str, Any]:
|
||||
payload = await self._request(
|
||||
"GET",
|
||||
f"/api/v1/knowledge-bases/{quote(knowledge_base_id, safe='')}/knowledge",
|
||||
params={"page": page, "page_size": page_size},
|
||||
)
|
||||
rows = _as_list(_data(payload))
|
||||
total = payload.get("total", len(rows)) if isinstance(payload, dict) else len(rows)
|
||||
return {"items": rows, "total": int(total or 0), "page": page, "page_size": page_size}
|
||||
|
||||
async def upload_document(
|
||||
self,
|
||||
knowledge_base_id: str,
|
||||
*,
|
||||
filename: str,
|
||||
content: bytes,
|
||||
content_type: str = "application/octet-stream",
|
||||
) -> dict[str, Any]:
|
||||
value = _data(
|
||||
await self._request(
|
||||
"POST",
|
||||
f"/api/v1/knowledge-bases/{quote(knowledge_base_id, safe='')}/knowledge/file",
|
||||
files={"file": (filename, content, content_type)},
|
||||
)
|
||||
)
|
||||
return dict(value) if isinstance(value, dict) else {}
|
||||
|
||||
async def import_document_url(self, knowledge_base_id: str, *, url: str) -> dict[str, Any]:
|
||||
value = _data(
|
||||
await self._request(
|
||||
"POST",
|
||||
f"/api/v1/knowledge-bases/{quote(knowledge_base_id, safe='')}/knowledge/url",
|
||||
json={"url": url},
|
||||
)
|
||||
)
|
||||
return dict(value) if isinstance(value, dict) else {}
|
||||
|
||||
async def create_manual_document(
|
||||
self,
|
||||
knowledge_base_id: str,
|
||||
*,
|
||||
title: str,
|
||||
content: str,
|
||||
) -> dict[str, Any]:
|
||||
value = _data(
|
||||
await self._request(
|
||||
"POST",
|
||||
f"/api/v1/knowledge-bases/{quote(knowledge_base_id, safe='')}/knowledge/manual",
|
||||
json={"title": title, "content": content, "status": "publish"},
|
||||
)
|
||||
)
|
||||
return dict(value) if isinstance(value, dict) else {}
|
||||
|
||||
async def delete_document(self, knowledge_id: str) -> None:
|
||||
await self._request("DELETE", f"/api/v1/knowledge/{quote(knowledge_id, safe='')}")
|
||||
|
||||
async def get_document(self, knowledge_id: str) -> dict[str, Any]:
|
||||
value = _data(await self._request("GET", f"/api/v1/knowledge/{quote(knowledge_id, safe='')}"))
|
||||
if not isinstance(value, dict):
|
||||
raise WeKnoraError("WeKnora returned an invalid document")
|
||||
return dict(value)
|
||||
|
||||
async def get_document_preview(self, knowledge_id: str) -> tuple[bytes, str]:
|
||||
"""Return the original document preview bytes and its media type."""
|
||||
|
||||
owns_client = self._http_client is None
|
||||
client = self._http_client or httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self.timeout_seconds),
|
||||
follow_redirects=True,
|
||||
)
|
||||
try:
|
||||
response = await client.request(
|
||||
"GET",
|
||||
f"{self.base_url}/api/v1/knowledge/{quote(knowledge_id, safe='')}/preview",
|
||||
headers={"Accept": "*/*"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise WeKnoraError("WeKnora preview request timed out", status_code=504) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise WeKnoraError("WeKnora preview is unavailable", status_code=502) from exc
|
||||
finally:
|
||||
if owns_client:
|
||||
await client.aclose()
|
||||
if response.status_code == 404:
|
||||
raise WeKnoraError("WeKnora document preview was not found", status_code=404)
|
||||
if response.status_code in {401, 403}:
|
||||
raise WeKnoraError(
|
||||
"WeKnora rejected DeerFlow's anonymous internal request; allow DeerFlow in WeKnora or its reverse proxy",
|
||||
status_code=502,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise WeKnoraError(f"WeKnora returned HTTP {response.status_code}", status_code=502)
|
||||
media_type = response.headers.get("content-type", "application/octet-stream").split(";", 1)[0].strip()
|
||||
return response.content, media_type or "application/octet-stream"
|
||||
|
||||
async def reprocess_document(self, knowledge_id: str) -> dict[str, Any]:
|
||||
value = _data(await self._request("POST", f"/api/v1/knowledge/{quote(knowledge_id, safe='')}/reparse"))
|
||||
return dict(value) if isinstance(value, dict) else {}
|
||||
|
||||
async def list_chunks(self, knowledge_id: str, *, page: int = 1, page_size: int = 20) -> dict[str, Any]:
|
||||
payload = await self._request(
|
||||
"GET",
|
||||
f"/api/v1/chunks/{quote(knowledge_id, safe='')}",
|
||||
params={"page": page, "page_size": page_size},
|
||||
)
|
||||
rows = _as_list(_data(payload))
|
||||
total = payload.get("total", len(rows)) if isinstance(payload, dict) else len(rows)
|
||||
return {"items": rows, "total": int(total or 0), "page": page, "page_size": page_size}
|
||||
|
||||
async def get_wiki_graph(self, knowledge_base_id: str, *, limit: int = 200) -> dict[str, Any]:
|
||||
value = _data(
|
||||
await self._request(
|
||||
"GET",
|
||||
f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/graph",
|
||||
params={"mode": "overview", "limit": max(1, min(limit, 500))},
|
||||
)
|
||||
)
|
||||
if not isinstance(value, dict):
|
||||
raise WeKnoraError("WeKnora returned an invalid wiki graph")
|
||||
return dict(value)
|
||||
|
||||
async def list_wiki_pages(
|
||||
self,
|
||||
knowledge_base_id: str,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
query: str = "",
|
||||
) -> dict[str, Any]:
|
||||
payload = await self._request(
|
||||
"GET",
|
||||
f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/pages",
|
||||
params={"page": page, "page_size": page_size, "query": query or None},
|
||||
)
|
||||
value = _data(payload)
|
||||
source = value if isinstance(value, dict) else payload if isinstance(payload, dict) else {}
|
||||
rows = _as_list(source.get("pages") if isinstance(source, dict) else value)
|
||||
return {
|
||||
"pages": rows,
|
||||
"total": int(source.get("total") or len(rows)),
|
||||
"page": int(source.get("page") or page),
|
||||
"page_size": int(source.get("page_size") or page_size),
|
||||
"total_pages": int(source.get("total_pages") or max(1, (int(source.get("total") or len(rows)) + page_size - 1) // page_size)),
|
||||
}
|
||||
|
||||
async def get_wiki_page(self, knowledge_base_id: str, slug: str) -> dict[str, Any]:
|
||||
safe_slug = "/".join(quote(part, safe="") for part in slug.split("/"))
|
||||
value = _data(
|
||||
await self._request(
|
||||
"GET",
|
||||
f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/pages/{safe_slug}",
|
||||
)
|
||||
)
|
||||
if not isinstance(value, dict):
|
||||
raise WeKnoraError("WeKnora returned an invalid Wiki page")
|
||||
return dict(value)
|
||||
|
||||
async def search(self, query: str, knowledge_base_ids: Sequence[str]) -> list[dict[str, Any]]:
|
||||
ids = [str(value) for value in knowledge_base_ids if str(value)]
|
||||
if not ids:
|
||||
return []
|
||||
rows = _as_list(
|
||||
_data(
|
||||
await self._request(
|
||||
"POST",
|
||||
"/api/v1/knowledge-search",
|
||||
json={"query": query, "knowledge_base_ids": ids},
|
||||
)
|
||||
)
|
||||
)
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
normalized.append(
|
||||
{
|
||||
"chunk_id": str(row.get("id") or row.get("chunk_id") or ""),
|
||||
"content": str(row.get("content") or ""),
|
||||
"knowledge_id": str(row.get("knowledge_id") or ""),
|
||||
"knowledge_base_id": str(row.get("knowledge_base_id") or ""),
|
||||
"title": str(row.get("knowledge_title") or row.get("title") or row.get("knowledge_filename") or ""),
|
||||
"filename": str(row.get("knowledge_filename") or ""),
|
||||
"score": float(row.get("score") or 0),
|
||||
"chunk_index": int(row.get("chunk_index") or 0),
|
||||
"metadata": row.get("metadata") if isinstance(row.get("metadata"), dict) else {},
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
async def get_chunk(self, chunk_id: str) -> dict[str, Any]:
|
||||
value = _data(await self._request("GET", f"/api/v1/chunks/by-id/{quote(chunk_id, safe='')}"))
|
||||
if not isinstance(value, dict):
|
||||
raise WeKnoraError("WeKnora returned an invalid chunk")
|
||||
return dict(value)
|
||||
|
||||
async def get_chunk_context(self, chunk_id: str, *, expected_knowledge_base_id: str, radius: int = 2) -> list[dict[str, Any]]:
|
||||
center = await self.get_chunk(chunk_id)
|
||||
if str(center.get("knowledge_base_id") or "") != expected_knowledge_base_id:
|
||||
raise WeKnoraError("Chunk does not belong to the authorized knowledge base", status_code=404)
|
||||
|
||||
before: list[dict[str, Any]] = []
|
||||
cursor = center
|
||||
for _ in range(max(0, min(radius, 4))):
|
||||
previous_id = str(cursor.get("pre_chunk_id") or "")
|
||||
if not previous_id:
|
||||
break
|
||||
previous = await self.get_chunk(previous_id)
|
||||
if str(previous.get("knowledge_base_id") or "") != expected_knowledge_base_id:
|
||||
break
|
||||
before.append(previous)
|
||||
cursor = previous
|
||||
|
||||
after: list[dict[str, Any]] = []
|
||||
cursor = center
|
||||
for _ in range(max(0, min(radius, 4))):
|
||||
next_id = str(cursor.get("next_chunk_id") or "")
|
||||
if not next_id:
|
||||
break
|
||||
following = await self.get_chunk(next_id)
|
||||
if str(following.get("knowledge_base_id") or "") != expected_knowledge_base_id:
|
||||
break
|
||||
after.append(following)
|
||||
cursor = following
|
||||
return [*reversed(before), center, *after]
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
"""Runtime composition shared by Gateway routes and the read-only agent tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.config.llmwiki_config import ResolvedLlmWikiRuntime, resolve_llmwiki_runtime
|
||||
from deerflow.config.system_settings import load_system_settings
|
||||
from deerflow.integrations.weknora.client import WeKnoraClient
|
||||
from deerflow.persistence.llmwiki import LlmWikiStore
|
||||
|
||||
_default_store: LlmWikiStore | None = None
|
||||
|
||||
|
||||
def register_llmwiki_store(store: LlmWikiStore | None) -> None:
|
||||
global _default_store
|
||||
_default_store = store
|
||||
|
||||
|
||||
def get_llmwiki_store() -> LlmWikiStore:
|
||||
if _default_store is None:
|
||||
raise RuntimeError("LLMWiki store is not initialized")
|
||||
return _default_store
|
||||
|
||||
|
||||
def get_resolved_llmwiki_runtime(config: AppConfig | None = None) -> ResolvedLlmWikiRuntime:
|
||||
current = config or get_app_config()
|
||||
return resolve_llmwiki_runtime(current.llmwiki, load_system_settings().llmwiki)
|
||||
|
||||
|
||||
def build_weknora_client(
|
||||
runtime: ResolvedLlmWikiRuntime,
|
||||
*,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
) -> WeKnoraClient:
|
||||
if not runtime.weknora_enabled:
|
||||
raise RuntimeError("WeKnora mode is not enabled")
|
||||
return WeKnoraClient(
|
||||
base_url=runtime.api_base_url,
|
||||
http_client=http_client,
|
||||
)
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
"""DeerFlow-owned LLMWiki authorization metadata."""
|
||||
|
||||
from deerflow.persistence.llmwiki.base import LlmWikiScope, LlmWikiStore
|
||||
from deerflow.persistence.llmwiki.memory import MemoryLlmWikiStore
|
||||
from deerflow.persistence.llmwiki.model import LlmWikiConversationDepositRow, LlmWikiKnowledgeBaseRow
|
||||
from deerflow.persistence.llmwiki.sql import LlmWikiRepository
|
||||
|
||||
__all__ = [
|
||||
"LlmWikiKnowledgeBaseRow",
|
||||
"LlmWikiConversationDepositRow",
|
||||
"LlmWikiRepository",
|
||||
"LlmWikiScope",
|
||||
"LlmWikiStore",
|
||||
"MemoryLlmWikiStore",
|
||||
"make_llmwiki_store",
|
||||
]
|
||||
|
||||
|
||||
def make_llmwiki_store(session_factory):
|
||||
if session_factory is None:
|
||||
return MemoryLlmWikiStore()
|
||||
return LlmWikiRepository(session_factory)
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
"""Persistence contract for DeerFlow-owned LLMWiki authorization metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Literal
|
||||
|
||||
LlmWikiScope = Literal["personal", "public", "all"]
|
||||
|
||||
|
||||
class LlmWikiStore(ABC):
|
||||
@abstractmethod
|
||||
async def create_mapping(self, *, weknora_id: str, owner_user_id: str, name: str, description: str, kb_type: str) -> dict[str, Any]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def list_visible(self, user_id: str, *, scope: LlmWikiScope = "all", is_admin: bool = False) -> list[dict[str, Any]]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def get_authorized(self, mapping_id: str, user_id: str, *, write: bool, is_admin: bool) -> dict[str, Any] | None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def get_by_weknora_id(self, weknora_id: str) -> dict[str, Any] | None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def update_mapping(self, mapping_id: str, *, name: str | None = None, description: str | None = None) -> dict[str, Any] | None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def adopt_conversation_deposit_mapping(
|
||||
self,
|
||||
mapping_id: str,
|
||||
*,
|
||||
name: str,
|
||||
description: str,
|
||||
) -> dict[str, Any] | None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def delete_mapping(self, mapping_id: str) -> bool: ...
|
||||
|
||||
@abstractmethod
|
||||
async def request_publish(self, mapping_id: str, actor_user_id: str, *, is_admin: bool) -> dict[str, Any] | None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def review_publish(self, mapping_id: str, reviewer_user_id: str, *, approved: bool) -> dict[str, Any] | None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def unpublish(self, mapping_id: str, actor_user_id: str, *, is_admin: bool) -> dict[str, Any] | None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def list_publications(self, *, status: str | None = None) -> list[dict[str, Any]]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def get_conversation_deposit_mapping(self) -> dict[str, Any] | None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def get_conversation_deposit(
|
||||
self,
|
||||
*,
|
||||
thread_id: str,
|
||||
assistant_message_id: str,
|
||||
) -> dict[str, Any] | None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def upsert_conversation_deposit(
|
||||
self,
|
||||
*,
|
||||
thread_id: str,
|
||||
human_message_id: str | None,
|
||||
assistant_message_id: str,
|
||||
knowledge_base_mapping_id: str,
|
||||
weknora_knowledge_id: str | None,
|
||||
title: str,
|
||||
created_by_user_id: str | None,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def delete_conversation_deposits(
|
||||
self,
|
||||
*,
|
||||
thread_id: str,
|
||||
message_ids: list[str] | None = None,
|
||||
) -> list[dict[str, Any]]: ...
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
"""In-memory LLMWiki metadata store for tests and DB-less deployments."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from deerflow.persistence.llmwiki.base import LlmWikiScope, LlmWikiStore
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
class MemoryLlmWikiStore(LlmWikiStore):
|
||||
def __init__(self) -> None:
|
||||
self._rows: dict[str, dict[str, Any]] = {}
|
||||
self._conversation_deposits: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def create_mapping(self, *, weknora_id: str, owner_user_id: str, name: str, description: str, kb_type: str) -> dict[str, Any]:
|
||||
if any(row["weknora_id"] == weknora_id for row in self._rows.values()):
|
||||
raise ValueError("WeKnora knowledge base is already mapped")
|
||||
now = _now()
|
||||
row = {
|
||||
"id": str(uuid4()),
|
||||
"weknora_id": weknora_id,
|
||||
"owner_user_id": owner_user_id,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"kb_type": kb_type,
|
||||
"publication_status": "private",
|
||||
"published_by": None,
|
||||
"reviewed_by": None,
|
||||
"published_at": None,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
self._rows[row["id"]] = row
|
||||
return dict(row)
|
||||
|
||||
async def list_visible(self, user_id: str, *, scope: LlmWikiScope = "all", is_admin: bool = False) -> list[dict[str, Any]]:
|
||||
rows = list(self._rows.values())
|
||||
if scope == "personal":
|
||||
rows = rows if is_admin else [row for row in rows if row["owner_user_id"] == user_id]
|
||||
elif scope == "public":
|
||||
rows = [row for row in rows if row["publication_status"] == "published"]
|
||||
else:
|
||||
rows = rows if is_admin else [row for row in rows if row["owner_user_id"] == user_id or row["publication_status"] == "published"]
|
||||
return [dict(row) for row in sorted(rows, key=lambda row: row["created_at"], reverse=True)]
|
||||
|
||||
async def get_authorized(self, mapping_id: str, user_id: str, *, write: bool, is_admin: bool) -> dict[str, Any] | None:
|
||||
row = self._rows.get(mapping_id)
|
||||
if row is None:
|
||||
return None
|
||||
if is_admin or row["owner_user_id"] == user_id:
|
||||
return dict(row)
|
||||
if not write and row["publication_status"] == "published":
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
async def get_by_weknora_id(self, weknora_id: str) -> dict[str, Any] | None:
|
||||
for row in self._rows.values():
|
||||
if row["weknora_id"] == weknora_id:
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
async def update_mapping(self, mapping_id: str, *, name: str | None = None, description: str | None = None) -> dict[str, Any] | None:
|
||||
row = self._rows.get(mapping_id)
|
||||
if row is None:
|
||||
return None
|
||||
if name is not None:
|
||||
row["name"] = name
|
||||
if description is not None:
|
||||
row["description"] = description
|
||||
row["updated_at"] = _now()
|
||||
return dict(row)
|
||||
|
||||
async def adopt_conversation_deposit_mapping(
|
||||
self,
|
||||
mapping_id: str,
|
||||
*,
|
||||
name: str,
|
||||
description: str,
|
||||
) -> dict[str, Any] | None:
|
||||
row = self._rows.get(mapping_id)
|
||||
if row is None:
|
||||
return None
|
||||
now = _now()
|
||||
row.update(
|
||||
{
|
||||
"owner_user_id": "system",
|
||||
"name": name,
|
||||
"description": description,
|
||||
"publication_status": "published",
|
||||
"published_by": "system",
|
||||
"reviewed_by": None,
|
||||
"published_at": row.get("published_at") or now,
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
async def delete_mapping(self, mapping_id: str) -> bool:
|
||||
return self._rows.pop(mapping_id, None) is not None
|
||||
|
||||
async def request_publish(self, mapping_id: str, actor_user_id: str, *, is_admin: bool) -> dict[str, Any] | None:
|
||||
row = self._rows.get(mapping_id)
|
||||
if row is None or (not is_admin and row["owner_user_id"] != actor_user_id):
|
||||
return None
|
||||
row["publication_status"] = "published"
|
||||
row["published_by"] = actor_user_id
|
||||
row["reviewed_by"] = None
|
||||
row["published_at"] = _now()
|
||||
row["updated_at"] = _now()
|
||||
return dict(row)
|
||||
|
||||
async def review_publish(self, mapping_id: str, reviewer_user_id: str, *, approved: bool) -> dict[str, Any] | None:
|
||||
row = self._rows.get(mapping_id)
|
||||
if row is None or row["publication_status"] != "pending":
|
||||
return None
|
||||
row["publication_status"] = "published" if approved else "rejected"
|
||||
row["reviewed_by"] = reviewer_user_id
|
||||
row["published_at"] = _now() if approved else None
|
||||
row["updated_at"] = _now()
|
||||
return dict(row)
|
||||
|
||||
async def unpublish(self, mapping_id: str, actor_user_id: str, *, is_admin: bool) -> dict[str, Any] | None:
|
||||
row = self._rows.get(mapping_id)
|
||||
if row is None or (not is_admin and row["owner_user_id"] != actor_user_id):
|
||||
return None
|
||||
row["publication_status"] = "private"
|
||||
row["reviewed_by"] = actor_user_id if is_admin else row.get("reviewed_by")
|
||||
row["published_at"] = None
|
||||
row["updated_at"] = _now()
|
||||
return dict(row)
|
||||
|
||||
async def list_publications(self, *, status: str | None = None) -> list[dict[str, Any]]:
|
||||
rows = [row for row in self._rows.values() if row["publication_status"] != "private"]
|
||||
if status:
|
||||
rows = [row for row in rows if row["publication_status"] == status]
|
||||
return [dict(row) for row in sorted(rows, key=lambda row: row["updated_at"], reverse=True)]
|
||||
|
||||
async def get_conversation_deposit_mapping(self) -> dict[str, Any] | None:
|
||||
for row in self._rows.values():
|
||||
if (
|
||||
row.get("owner_user_id") == "system"
|
||||
and row.get("name") == "对话沉淀"
|
||||
and row.get("publication_status") == "published"
|
||||
):
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
async def get_conversation_deposit(
|
||||
self,
|
||||
*,
|
||||
thread_id: str,
|
||||
assistant_message_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
key = f"{thread_id}:{assistant_message_id}"
|
||||
row = self._conversation_deposits.get(key)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def upsert_conversation_deposit(
|
||||
self,
|
||||
*,
|
||||
thread_id: str,
|
||||
human_message_id: str | None,
|
||||
assistant_message_id: str,
|
||||
knowledge_base_mapping_id: str,
|
||||
weknora_knowledge_id: str | None,
|
||||
title: str,
|
||||
created_by_user_id: str | None,
|
||||
) -> dict[str, Any]:
|
||||
key = f"{thread_id}:{assistant_message_id}"
|
||||
now = _now()
|
||||
row = self._conversation_deposits.get(key)
|
||||
if row is None:
|
||||
row = {
|
||||
"id": str(uuid4()),
|
||||
"thread_id": thread_id,
|
||||
"human_message_id": human_message_id,
|
||||
"assistant_message_id": assistant_message_id,
|
||||
"knowledge_base_mapping_id": knowledge_base_mapping_id,
|
||||
"weknora_knowledge_id": weknora_knowledge_id,
|
||||
"title": title,
|
||||
"created_by_user_id": created_by_user_id,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
self._conversation_deposits[key] = row
|
||||
else:
|
||||
row.update(
|
||||
{
|
||||
"human_message_id": human_message_id,
|
||||
"knowledge_base_mapping_id": knowledge_base_mapping_id,
|
||||
"weknora_knowledge_id": weknora_knowledge_id,
|
||||
"title": title,
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
async def delete_conversation_deposits(
|
||||
self,
|
||||
*,
|
||||
thread_id: str,
|
||||
message_ids: list[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
ids = set(message_ids or [])
|
||||
deleted: list[dict[str, Any]] = []
|
||||
for key, row in list(self._conversation_deposits.items()):
|
||||
if row.get("thread_id") != thread_id:
|
||||
continue
|
||||
if ids and row.get("human_message_id") not in ids and row.get("assistant_message_id") not in ids:
|
||||
continue
|
||||
deleted.append(dict(row))
|
||||
self._conversation_deposits.pop(key, None)
|
||||
return deleted
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
"""ORM mapping between DeerFlow ownership and WeKnora knowledge bases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime
|
||||
|
||||
|
||||
class LlmWikiKnowledgeBaseRow(Base):
|
||||
__tablename__ = "llmwiki_knowledge_bases"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
weknora_id: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
owner_user_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), default="")
|
||||
description: Mapped[str] = mapped_column(String(2048), default="")
|
||||
kb_type: Mapped[str] = mapped_column(String(32), default="document")
|
||||
publication_status: Mapped[str] = mapped_column(String(16), default="private", index=True)
|
||||
published_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
reviewed_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
published_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {column.name: getattr(self, column.name) for column in self.__table__.columns}
|
||||
|
||||
|
||||
class LlmWikiConversationDepositRow(Base):
|
||||
"""Mapping from one DeerFlow answer turn to its WeKnora manual document.
|
||||
|
||||
The actual content lives in WeKnora. DeerFlow keeps only the stable ids it
|
||||
needs to rewrite/delete the document when the user regenerates or removes a
|
||||
conversation turn.
|
||||
"""
|
||||
|
||||
__tablename__ = "llmwiki_conversation_deposits"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"thread_id",
|
||||
"assistant_message_id",
|
||||
name="uq_llmwiki_conversation_deposit_answer",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
thread_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||
human_message_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
assistant_message_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||
knowledge_base_mapping_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
weknora_knowledge_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
title: Mapped[str] = mapped_column(String(255), default="")
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {column.name: getattr(self, column.name) for column in self.__table__.columns}
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
"""SQLAlchemy LLMWiki metadata repository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import delete, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.llmwiki.base import LlmWikiScope, LlmWikiStore
|
||||
from deerflow.persistence.llmwiki.model import LlmWikiConversationDepositRow, LlmWikiKnowledgeBaseRow
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _to_dict(row: LlmWikiKnowledgeBaseRow | LlmWikiConversationDepositRow) -> dict[str, Any]:
|
||||
data = row.to_dict()
|
||||
for key in ("published_at", "created_at", "updated_at"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, datetime):
|
||||
data[key] = value.isoformat()
|
||||
return data
|
||||
|
||||
|
||||
class LlmWikiRepository(LlmWikiStore):
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def create_mapping(self, *, weknora_id: str, owner_user_id: str, name: str, description: str, kb_type: str) -> dict[str, Any]:
|
||||
row = LlmWikiKnowledgeBaseRow(
|
||||
id=str(uuid4()),
|
||||
weknora_id=weknora_id,
|
||||
owner_user_id=owner_user_id,
|
||||
name=name,
|
||||
description=description,
|
||||
kb_type=kb_type,
|
||||
publication_status="private",
|
||||
created_at=_now(),
|
||||
updated_at=_now(),
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return _to_dict(row)
|
||||
|
||||
async def list_visible(self, user_id: str, *, scope: LlmWikiScope = "all", is_admin: bool = False) -> list[dict[str, Any]]:
|
||||
stmt = select(LlmWikiKnowledgeBaseRow)
|
||||
if scope == "personal" and not is_admin:
|
||||
stmt = stmt.where(LlmWikiKnowledgeBaseRow.owner_user_id == user_id)
|
||||
elif scope == "public":
|
||||
stmt = stmt.where(LlmWikiKnowledgeBaseRow.publication_status == "published")
|
||||
elif scope == "all" and not is_admin:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
LlmWikiKnowledgeBaseRow.owner_user_id == user_id,
|
||||
LlmWikiKnowledgeBaseRow.publication_status == "published",
|
||||
)
|
||||
)
|
||||
stmt = stmt.order_by(LlmWikiKnowledgeBaseRow.created_at.desc())
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def get_authorized(self, mapping_id: str, user_id: str, *, write: bool, is_admin: bool) -> dict[str, Any] | None:
|
||||
stmt = select(LlmWikiKnowledgeBaseRow).where(LlmWikiKnowledgeBaseRow.id == mapping_id)
|
||||
if not is_admin:
|
||||
condition = LlmWikiKnowledgeBaseRow.owner_user_id == user_id
|
||||
if not write:
|
||||
condition = or_(condition, LlmWikiKnowledgeBaseRow.publication_status == "published")
|
||||
stmt = stmt.where(condition)
|
||||
async with self._sf() as session:
|
||||
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||
return _to_dict(row) if row else None
|
||||
|
||||
async def get_by_weknora_id(self, weknora_id: str) -> dict[str, Any] | None:
|
||||
stmt = select(LlmWikiKnowledgeBaseRow).where(LlmWikiKnowledgeBaseRow.weknora_id == weknora_id)
|
||||
async with self._sf() as session:
|
||||
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||
return _to_dict(row) if row else None
|
||||
|
||||
async def update_mapping(self, mapping_id: str, *, name: str | None = None, description: str | None = None) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(LlmWikiKnowledgeBaseRow, mapping_id)
|
||||
if row is None:
|
||||
return None
|
||||
if name is not None:
|
||||
row.name = name
|
||||
if description is not None:
|
||||
row.description = description
|
||||
row.updated_at = _now()
|
||||
await session.commit()
|
||||
return _to_dict(row)
|
||||
|
||||
async def adopt_conversation_deposit_mapping(
|
||||
self,
|
||||
mapping_id: str,
|
||||
*,
|
||||
name: str,
|
||||
description: str,
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(LlmWikiKnowledgeBaseRow, mapping_id)
|
||||
if row is None:
|
||||
return None
|
||||
now = _now()
|
||||
row.owner_user_id = "system"
|
||||
row.name = name
|
||||
row.description = description
|
||||
row.publication_status = "published"
|
||||
row.published_by = "system"
|
||||
row.reviewed_by = None
|
||||
row.published_at = row.published_at or now
|
||||
row.updated_at = now
|
||||
await session.commit()
|
||||
return _to_dict(row)
|
||||
|
||||
async def delete_mapping(self, mapping_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(LlmWikiKnowledgeBaseRow, mapping_id)
|
||||
if row is None:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def request_publish(self, mapping_id: str, actor_user_id: str, *, is_admin: bool) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(LlmWikiKnowledgeBaseRow, mapping_id)
|
||||
if row is None or (not is_admin and row.owner_user_id != actor_user_id):
|
||||
return None
|
||||
row.publication_status = "published"
|
||||
row.published_by = actor_user_id
|
||||
row.reviewed_by = None
|
||||
row.published_at = _now()
|
||||
row.updated_at = _now()
|
||||
await session.commit()
|
||||
return _to_dict(row)
|
||||
|
||||
async def review_publish(self, mapping_id: str, reviewer_user_id: str, *, approved: bool) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(LlmWikiKnowledgeBaseRow, mapping_id)
|
||||
if row is None or row.publication_status != "pending":
|
||||
return None
|
||||
row.publication_status = "published" if approved else "rejected"
|
||||
row.reviewed_by = reviewer_user_id
|
||||
row.published_at = _now() if approved else None
|
||||
row.updated_at = _now()
|
||||
await session.commit()
|
||||
return _to_dict(row)
|
||||
|
||||
async def unpublish(self, mapping_id: str, actor_user_id: str, *, is_admin: bool) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(LlmWikiKnowledgeBaseRow, mapping_id)
|
||||
if row is None or (not is_admin and row.owner_user_id != actor_user_id):
|
||||
return None
|
||||
row.publication_status = "private"
|
||||
if is_admin:
|
||||
row.reviewed_by = actor_user_id
|
||||
row.published_at = None
|
||||
row.updated_at = _now()
|
||||
await session.commit()
|
||||
return _to_dict(row)
|
||||
|
||||
async def list_publications(self, *, status: str | None = None) -> list[dict[str, Any]]:
|
||||
stmt = select(LlmWikiKnowledgeBaseRow).where(LlmWikiKnowledgeBaseRow.publication_status != "private")
|
||||
if status:
|
||||
stmt = stmt.where(LlmWikiKnowledgeBaseRow.publication_status == status)
|
||||
stmt = stmt.order_by(LlmWikiKnowledgeBaseRow.updated_at.desc())
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def get_conversation_deposit_mapping(self) -> dict[str, Any] | None:
|
||||
stmt = (
|
||||
select(LlmWikiKnowledgeBaseRow)
|
||||
.where(
|
||||
LlmWikiKnowledgeBaseRow.owner_user_id == "system",
|
||||
LlmWikiKnowledgeBaseRow.name == "对话沉淀",
|
||||
LlmWikiKnowledgeBaseRow.publication_status == "published",
|
||||
)
|
||||
.order_by(LlmWikiKnowledgeBaseRow.created_at.asc())
|
||||
.limit(1)
|
||||
)
|
||||
async with self._sf() as session:
|
||||
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||
return _to_dict(row) if row else None
|
||||
|
||||
async def get_conversation_deposit(
|
||||
self,
|
||||
*,
|
||||
thread_id: str,
|
||||
assistant_message_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
stmt = select(LlmWikiConversationDepositRow).where(
|
||||
LlmWikiConversationDepositRow.thread_id == thread_id,
|
||||
LlmWikiConversationDepositRow.assistant_message_id == assistant_message_id,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||
return _to_dict(row) if row else None
|
||||
|
||||
async def upsert_conversation_deposit(
|
||||
self,
|
||||
*,
|
||||
thread_id: str,
|
||||
human_message_id: str | None,
|
||||
assistant_message_id: str,
|
||||
knowledge_base_mapping_id: str,
|
||||
weknora_knowledge_id: str | None,
|
||||
title: str,
|
||||
created_by_user_id: str | None,
|
||||
) -> dict[str, Any]:
|
||||
now = _now()
|
||||
async with self._sf() as session:
|
||||
stmt = select(LlmWikiConversationDepositRow).where(
|
||||
LlmWikiConversationDepositRow.thread_id == thread_id,
|
||||
LlmWikiConversationDepositRow.assistant_message_id == assistant_message_id,
|
||||
)
|
||||
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
row = LlmWikiConversationDepositRow(
|
||||
id=str(uuid4()),
|
||||
thread_id=thread_id,
|
||||
human_message_id=human_message_id,
|
||||
assistant_message_id=assistant_message_id,
|
||||
knowledge_base_mapping_id=knowledge_base_mapping_id,
|
||||
weknora_knowledge_id=weknora_knowledge_id,
|
||||
title=title,
|
||||
created_by_user_id=created_by_user_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(row)
|
||||
else:
|
||||
row.human_message_id = human_message_id
|
||||
row.knowledge_base_mapping_id = knowledge_base_mapping_id
|
||||
row.weknora_knowledge_id = weknora_knowledge_id
|
||||
row.title = title
|
||||
row.updated_at = now
|
||||
await session.commit()
|
||||
return _to_dict(row)
|
||||
|
||||
async def delete_conversation_deposits(
|
||||
self,
|
||||
*,
|
||||
thread_id: str,
|
||||
message_ids: list[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
conditions = [LlmWikiConversationDepositRow.thread_id == thread_id]
|
||||
if message_ids:
|
||||
conditions.append(
|
||||
or_(
|
||||
LlmWikiConversationDepositRow.human_message_id.in_(message_ids),
|
||||
LlmWikiConversationDepositRow.assistant_message_id.in_(message_ids),
|
||||
)
|
||||
)
|
||||
stmt = select(LlmWikiConversationDepositRow).where(*conditions)
|
||||
async with self._sf() as session:
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
deleted = [_to_dict(row) for row in rows]
|
||||
if rows:
|
||||
await session.execute(delete(LlmWikiConversationDepositRow).where(LlmWikiConversationDepositRow.id.in_([row.id for row in rows])))
|
||||
await session.commit()
|
||||
return deleted
|
||||
+3
@@ -42,6 +42,7 @@ from deerflow.persistence.embed_sessions.model import EmbedSessionRow
|
||||
from deerflow.persistence.feedback.model import FeedbackRow
|
||||
from deerflow.persistence.light_apps.model import LightAppRow
|
||||
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
||||
from deerflow.persistence.llmwiki.model import LlmWikiConversationDepositRow, LlmWikiKnowledgeBaseRow
|
||||
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
|
||||
from deerflow.persistence.menu_overrides.model import MenuOverrideRow
|
||||
from deerflow.persistence.models.run_event import RunEventRow
|
||||
@@ -109,6 +110,8 @@ __all__ = [
|
||||
"KnowledgeTagRow",
|
||||
"LightAppRow",
|
||||
"LlmCallMetricRow",
|
||||
"LlmWikiKnowledgeBaseRow",
|
||||
"LlmWikiConversationDepositRow",
|
||||
"MenuOverrideRow",
|
||||
"NotificationRow",
|
||||
"ParallelAgentTaskRow",
|
||||
|
||||
@@ -13,7 +13,6 @@ from deerflow.config.extensions_config import (
|
||||
get_extensions_config,
|
||||
)
|
||||
|
||||
|
||||
_SKILL_PATH_RE = re.compile(r"[\\/]skills[\\/](?:public|custom)[\\/]([^\\/]+)(?:[\\/]|$)")
|
||||
|
||||
|
||||
@@ -27,7 +26,11 @@ def build_reference_batches(messages: list[Any]) -> list[dict[str, Any]]:
|
||||
if _message_type(messages[cursor]) == "human":
|
||||
end = cursor
|
||||
break
|
||||
sources = _collect_sources(messages[index + 1 : end])
|
||||
preloaded_sources = _collect_preloaded_sources(message, start_index=1)
|
||||
sources = [
|
||||
*preloaded_sources,
|
||||
*_collect_sources(messages[index + 1 : end], start_index=len(preloaded_sources) + 1),
|
||||
]
|
||||
if sources:
|
||||
anchor = _find_reference_batch_anchor(messages, index + 1, end)
|
||||
message_id = _message_id(anchor) or _message_id(message) or str(index)
|
||||
@@ -46,7 +49,11 @@ def build_latest_reference_batch(messages: list[Any]) -> dict[str, Any] | None:
|
||||
human_index = _find_latest_human_index(messages)
|
||||
if human_index is None:
|
||||
return None
|
||||
sources = _collect_sources(messages[human_index + 1 :])
|
||||
preloaded_sources = _collect_preloaded_sources(messages[human_index], start_index=1)
|
||||
sources = [
|
||||
*preloaded_sources,
|
||||
*_collect_sources(messages[human_index + 1 :], start_index=len(preloaded_sources) + 1),
|
||||
]
|
||||
if not sources:
|
||||
return None
|
||||
assistant = _find_reference_batch_anchor(messages, human_index + 1, len(messages))
|
||||
@@ -365,7 +372,7 @@ def _source_debug_item(source: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]:
|
||||
def _collect_sources(messages: list[Any], *, start_index: int = 1) -> list[dict[str, Any]]:
|
||||
sources: list[dict[str, Any]] = []
|
||||
pending_tool_contexts: list[dict[str, Any]] = []
|
||||
tool_contexts_by_id: dict[str, dict[str, Any]] = {}
|
||||
@@ -393,7 +400,7 @@ def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]:
|
||||
)
|
||||
if configured_sources is not None:
|
||||
for normalized in configured_sources:
|
||||
normalized["index"] = len(sources) + 1
|
||||
normalized["index"] = start_index + len(sources)
|
||||
sources.append(normalized)
|
||||
continue
|
||||
for raw_item in _first_list(payload):
|
||||
@@ -405,11 +412,37 @@ def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]:
|
||||
)
|
||||
if not normalized:
|
||||
continue
|
||||
normalized["index"] = len(sources) + 1
|
||||
normalized["index"] = start_index + len(sources)
|
||||
sources.append(normalized)
|
||||
return sources
|
||||
|
||||
|
||||
def _collect_preloaded_sources(message: Any, *, start_index: int = 1) -> list[dict[str, Any]]:
|
||||
extra = _message_additional_kwargs(message)
|
||||
raw_sources = extra.get("llmwiki_reference_sources")
|
||||
if not isinstance(raw_sources, list):
|
||||
return []
|
||||
sources: list[dict[str, Any]] = []
|
||||
for raw in raw_sources:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
source = dict(raw)
|
||||
index = start_index + len(sources)
|
||||
source["index"] = index
|
||||
source.setdefault("skillName", "llmwiki_search")
|
||||
source.setdefault("mode", "citation")
|
||||
source.setdefault("type", "llmwiki")
|
||||
title = str(source.get("title") or "").strip()
|
||||
if not title:
|
||||
source["title"] = f"LLMWiki 片段 {index}"
|
||||
snippet = str(source.get("snippet") or source.get("content") or "").strip()
|
||||
source["snippet"] = snippet[:360]
|
||||
if "raw" not in source or not isinstance(source.get("raw"), dict):
|
||||
source["raw"] = {key: value for key, value in source.items() if key != "raw"}
|
||||
sources.append(source)
|
||||
return sources
|
||||
|
||||
|
||||
def _collect_configured_sources(
|
||||
payload: Any,
|
||||
fallback_index: int,
|
||||
@@ -486,6 +519,14 @@ def _message_content(message: Any) -> Any:
|
||||
return message.get("content") if isinstance(message, dict) else getattr(message, "content", None)
|
||||
|
||||
|
||||
def _message_additional_kwargs(message: Any) -> dict[str, Any]:
|
||||
if isinstance(message, dict):
|
||||
value = message.get("additional_kwargs") or message.get("additionalKwargs")
|
||||
else:
|
||||
value = getattr(message, "additional_kwargs", None)
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _message_name(message: Any) -> str:
|
||||
if isinstance(message, dict):
|
||||
return str(message.get("name") or (message.get("additional_kwargs") or {}).get("name") or "tool")
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
"""Authorized read-only WeKnora retrieval for cmzs Q&A."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from langchain.tools import ToolRuntime, tool
|
||||
from langgraph.typing import ContextT
|
||||
|
||||
from deerflow.agents.thread_state import ThreadState
|
||||
from deerflow.config.agents_config import load_agent_config
|
||||
from deerflow.integrations.weknora.client import WeKnoraError
|
||||
from deerflow.integrations.weknora.runtime import build_weknora_client, get_llmwiki_store, get_resolved_llmwiki_runtime
|
||||
from deerflow.runtime.user_context import get_current_user, get_effective_user_id
|
||||
|
||||
|
||||
_CONVERSATION_DEPOSIT_NAME = "对话沉淀"
|
||||
_CONVERSATION_DEPOSIT_OWNER_USER_ID = "system"
|
||||
_LEGACY_BRAND_PATTERN = re.compile(r"deer[\s_-]*flow", re.IGNORECASE)
|
||||
|
||||
|
||||
def _is_conversation_deposit_mapping(row: Mapping[str, Any]) -> bool:
|
||||
return (
|
||||
str(row.get("owner_user_id") or "") == _CONVERSATION_DEPOSIT_OWNER_USER_ID
|
||||
and str(row.get("name") or "").strip() == _CONVERSATION_DEPOSIT_NAME
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_conversation_deposit_value(value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
return _LEGACY_BRAND_PATTERN.sub("cmzs", value)
|
||||
if isinstance(value, dict):
|
||||
return {key: _sanitize_conversation_deposit_value(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_sanitize_conversation_deposit_value(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(_sanitize_conversation_deposit_value(item) for item in value)
|
||||
return value
|
||||
|
||||
|
||||
def selected_knowledge_base_ids(context: Mapping[str, Any] | None) -> list[str] | None:
|
||||
"""Read the conversation selection; ``None`` means legacy/no selection key."""
|
||||
|
||||
if not context or "llmwiki_knowledge_base_ids" not in context:
|
||||
return None
|
||||
raw = context.get("llmwiki_knowledge_base_ids")
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return list(dict.fromkeys(str(item).strip() for item in raw if str(item).strip()))[:100]
|
||||
|
||||
|
||||
def _agent_allowed_ids(context: Mapping[str, Any] | None) -> list[str] | None:
|
||||
agent_id = str((context or {}).get("agent_id") or "").strip()
|
||||
if not agent_id or agent_id in {"default", "lead_agent"}:
|
||||
return None
|
||||
try:
|
||||
config = load_agent_config(agent_id)
|
||||
except (FileNotFoundError, ValueError):
|
||||
return []
|
||||
return list(config.llmwiki_knowledge_base_ids or []) if config else []
|
||||
|
||||
|
||||
@tool("llmwiki_search", parse_docstring=True)
|
||||
async def llmwiki_search_tool(
|
||||
query: str,
|
||||
runtime: ToolRuntime[ContextT, ThreadState],
|
||||
) -> str:
|
||||
"""Search the LLMWiki knowledge selected for this conversation.
|
||||
|
||||
Use this tool when an answer can benefit from the user's managed knowledge
|
||||
bases or the shared public LLMWiki. It only retrieves passages; it cannot
|
||||
upload, edit, delete, publish or change permissions.
|
||||
|
||||
Args:
|
||||
query: Focused natural-language retrieval query.
|
||||
"""
|
||||
|
||||
resolved = get_resolved_llmwiki_runtime()
|
||||
if not resolved.weknora_enabled:
|
||||
return "LLMWiki retrieval is unavailable because WeKnora mode is not enabled."
|
||||
|
||||
store = get_llmwiki_store()
|
||||
user_id = get_effective_user_id()
|
||||
is_admin = getattr(get_current_user(), "system_role", None) == "admin"
|
||||
context = runtime.context if runtime.context else {}
|
||||
selected_ids = selected_knowledge_base_ids(context)
|
||||
allowed_ids = _agent_allowed_ids(context)
|
||||
if selected_ids is None:
|
||||
selected_ids = allowed_ids
|
||||
elif allowed_ids is not None and not set(selected_ids).issubset(set(allowed_ids)):
|
||||
return "The conversation requested knowledge outside this agent's configured LLMWiki scope."
|
||||
|
||||
if selected_ids == []:
|
||||
return "No LLMWiki knowledge base is selected for this conversation."
|
||||
if selected_ids is not None:
|
||||
rows = []
|
||||
for mapping_id in selected_ids:
|
||||
row = await store.get_authorized(
|
||||
mapping_id,
|
||||
user_id,
|
||||
write=False,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
if row is None:
|
||||
return f"Knowledge base {mapping_id!r} is not available to the current user."
|
||||
rows.append(row)
|
||||
else:
|
||||
rows = await store.list_visible(user_id, scope="all", is_admin=False)
|
||||
if not rows:
|
||||
return "No authorized LLMWiki knowledge bases are available."
|
||||
|
||||
try:
|
||||
results = await build_weknora_client(resolved).search(
|
||||
query.strip(),
|
||||
[str(row["weknora_id"]) for row in rows],
|
||||
)
|
||||
except (RuntimeError, WeKnoraError) as exc:
|
||||
return f"LLMWiki retrieval failed: {exc}"
|
||||
|
||||
local_by_remote = {str(row["weknora_id"]): row for row in rows}
|
||||
citations = []
|
||||
for result in results:
|
||||
mapping = local_by_remote.get(str(result.get("knowledge_base_id") or ""))
|
||||
if mapping is None:
|
||||
continue
|
||||
visible_result = (
|
||||
_sanitize_conversation_deposit_value(result)
|
||||
if _is_conversation_deposit_mapping(mapping)
|
||||
else result
|
||||
)
|
||||
params = urlencode(
|
||||
{
|
||||
"knowledge_base_id": mapping["id"],
|
||||
"chunk_id": visible_result.get("chunk_id") or "",
|
||||
}
|
||||
)
|
||||
citations.append(
|
||||
{
|
||||
"title": visible_result.get("title") or visible_result.get("filename") or mapping.get("name") or "LLMWiki source",
|
||||
"content": visible_result.get("content") or "",
|
||||
"score": visible_result.get("score") or 0,
|
||||
"knowledge_base_id": mapping["id"],
|
||||
"knowledge_base_name": mapping.get("name") or "",
|
||||
"source": mapping.get("name") or "LLMWiki",
|
||||
"knowledge_id": visible_result.get("knowledge_id") or "",
|
||||
"chunk_id": visible_result.get("chunk_id") or "",
|
||||
"url": f"/weknora-source-preview?{params}",
|
||||
}
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"query": query,
|
||||
"selection": [row["id"] for row in rows],
|
||||
"knowledge_base_count": len(rows),
|
||||
"results": citations,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -75,7 +75,11 @@ def get_available_tools(
|
||||
List of available tools.
|
||||
"""
|
||||
config = app_config or get_app_config()
|
||||
tool_configs = [tool for tool in config.tools if groups is None or tool.group in groups]
|
||||
selected_tool_configs = [tool for tool in config.tools if groups is None or tool.group in groups]
|
||||
disabled_tool_names = [tool.name for tool in selected_tool_configs if not tool.enabled]
|
||||
if disabled_tool_names:
|
||||
logger.info("Skipping disabled configured tool(s): %s", disabled_tool_names)
|
||||
tool_configs = [tool for tool in selected_tool_configs if tool.enabled]
|
||||
|
||||
browser_context_config = getattr(config, "browser_context", None)
|
||||
if not getattr(browser_context_config, "enabled", False):
|
||||
@@ -154,9 +158,21 @@ def get_available_tools(
|
||||
# read_peer_delivery —— 圆桌席位「按需读取前序席位完整交付」的只读工具(从 run context
|
||||
# 取网关注入的全文,非圆桌场景为空 → 工具自述「无」)。始终提供(只读、无副作用);圆桌
|
||||
# 总控/报告角色由 run policy 显式排除,避免干扰它们。
|
||||
from deerflow.tools.builtins.roundtable_peers_tool import read_peer_delivery_tool
|
||||
|
||||
builtin_tools.append(read_peer_delivery_tool)
|
||||
from deerflow.tools.builtins.roundtable_peers_tool import read_peer_delivery_tool
|
||||
|
||||
builtin_tools.append(read_peer_delivery_tool)
|
||||
|
||||
# Conditional LLMWiki mode: the tool is absent when no effective WeKnora
|
||||
# address is configured, keeping legacy deployments behavior-identical.
|
||||
try:
|
||||
from deerflow.integrations.weknora.runtime import get_resolved_llmwiki_runtime
|
||||
|
||||
if get_resolved_llmwiki_runtime(config).weknora_enabled:
|
||||
from deerflow.tools.builtins.llmwiki_search_tool import llmwiki_search_tool
|
||||
|
||||
builtin_tools.append(llmwiki_search_tool)
|
||||
except Exception: # noqa: BLE001 - configuration/store failures must not block agent creation
|
||||
logger.debug("Failed to evaluate the conditional LLMWiki search tool", exc_info=True)
|
||||
|
||||
# skill_manage 能创建/修改/删除技能,属于"自我进化"写能力,按 skill_evolution.enabled
|
||||
# 门控。该开关用户可在 per-user 配置里覆盖,所以按"生效配置(含 per-user 覆盖)"判断 ——
|
||||
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from deerflow.community.configurable_search.tools import (
|
||||
ConfigurableSearchSettings,
|
||||
execute_search,
|
||||
)
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.config.tool_config import ToolConfig
|
||||
from deerflow.tools.tools import get_available_tools
|
||||
|
||||
|
||||
def _settings(**overrides: object) -> ConfigurableSearchSettings:
|
||||
values = {
|
||||
"enabled": True,
|
||||
"endpoint": "https://search.example.test/searchName",
|
||||
"payload": {"query": "configured value", "xxx": "xxxx"},
|
||||
"verify_ssl": False,
|
||||
"timeout": 30.0,
|
||||
"max_results": 5,
|
||||
"result_url_template": "https://search.example.test/mindId/xxx?recUuid={recUuid}",
|
||||
}
|
||||
values.update(overrides)
|
||||
return ConfigurableSearchSettings(**values) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configurable_search_overrides_query_and_normalizes_nullable_results() -> None:
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
assert request.method == "POST"
|
||||
assert json.loads(request.content) == {"query": "测试检索词", "xxx": "xxxx"}
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"results": [
|
||||
{
|
||||
"recUuid": "record/123",
|
||||
"content": "检索内容",
|
||||
"title": "标题",
|
||||
"url": "https://original.example.test/one",
|
||||
},
|
||||
{
|
||||
"recUuid": None,
|
||||
"content": None,
|
||||
"title": None,
|
||||
"url": "https://original.example.test/two",
|
||||
},
|
||||
"invalid row",
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
settings = _settings()
|
||||
result = json.loads(
|
||||
await execute_search(
|
||||
" 测试检索词 ",
|
||||
settings,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
)
|
||||
|
||||
assert len(requests) == 1
|
||||
assert settings.payload == {"query": "configured value", "xxx": "xxxx"}
|
||||
assert result == {
|
||||
"query": "测试检索词",
|
||||
"total_results": 2,
|
||||
"results": [
|
||||
{
|
||||
"recUuid": "record/123",
|
||||
"content": "检索内容",
|
||||
"title": "标题",
|
||||
"url": "https://search.example.test/mindId/xxx?recUuid=record%2F123",
|
||||
},
|
||||
{
|
||||
"recUuid": "",
|
||||
"content": "",
|
||||
"title": "",
|
||||
"url": "https://original.example.test/two",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configurable_search_handles_empty_results() -> None:
|
||||
async def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"results": []})
|
||||
|
||||
result = json.loads(
|
||||
await execute_search(
|
||||
"nothing",
|
||||
_settings(),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
)
|
||||
|
||||
assert result == {"query": "nothing", "total_results": 0, "results": []}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("response_factory", "expected_error"),
|
||||
[
|
||||
(lambda request: httpx.Response(503, request=request), "Search API returned HTTP 503"),
|
||||
(
|
||||
lambda request: httpx.Response(200, content=b"not-json", request=request),
|
||||
"Search API request failed: JSONDecodeError",
|
||||
),
|
||||
(
|
||||
lambda request: httpx.Response(200, json={"results": None}, request=request),
|
||||
"search API response field 'results' must be an array",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_configurable_search_returns_safe_errors(
|
||||
response_factory,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
return response_factory(request)
|
||||
|
||||
result = json.loads(
|
||||
await execute_search(
|
||||
"query",
|
||||
_settings(),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
)
|
||||
|
||||
assert result["results"] == []
|
||||
assert result["total_results"] == 0
|
||||
assert result["error"] == expected_error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configurable_search_handles_timeout() -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ReadTimeout("timed out", request=request)
|
||||
|
||||
result = json.loads(
|
||||
await execute_search(
|
||||
"query",
|
||||
_settings(),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
)
|
||||
|
||||
assert result["error"] == "Search API request timed out"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configurable_search_passes_verify_ssl_to_httpx(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeAsyncClient:
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
captured.update(kwargs)
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
return None
|
||||
|
||||
async def post(self, url: str, *, json: dict[str, object]) -> httpx.Response:
|
||||
request = httpx.Request("POST", url, json=json)
|
||||
return httpx.Response(200, json={"results": []}, request=request)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deerflow.community.configurable_search.tools.httpx.AsyncClient",
|
||||
FakeAsyncClient,
|
||||
)
|
||||
|
||||
await execute_search("query", _settings(verify_ssl=False))
|
||||
|
||||
assert captured["verify"] is False
|
||||
assert captured["timeout"] == 30.0
|
||||
|
||||
|
||||
def test_disabled_configured_tool_is_not_registered() -> None:
|
||||
config = AppConfig(
|
||||
sandbox={"use": "deerflow.sandbox.local:LocalSandboxProvider"},
|
||||
tools=[
|
||||
ToolConfig(
|
||||
name="web_search",
|
||||
group="web",
|
||||
enabled=False,
|
||||
use="deerflow.community.configurable_search.tools:web_search_tool",
|
||||
endpoint="https://search.example.test/searchName",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
available = get_available_tools(
|
||||
groups=["web"],
|
||||
include_mcp=False,
|
||||
app_config=config,
|
||||
)
|
||||
|
||||
assert "web_search" not in {item.name for item in available}
|
||||
|
||||
config.tools[0].enabled = True
|
||||
enabled_tools = get_available_tools(
|
||||
groups=["web"],
|
||||
include_mcp=False,
|
||||
app_config=config,
|
||||
)
|
||||
|
||||
assert "web_search" in {item.name for item in enabled_tools}
|
||||
|
||||
|
||||
def test_config_validation_requires_rec_uuid_url_placeholder() -> None:
|
||||
config = ToolConfig(
|
||||
name="web_search",
|
||||
group="web",
|
||||
use="deerflow.community.configurable_search.tools:web_search_tool",
|
||||
endpoint="https://search.example.test/searchName",
|
||||
result_url_template="https://search.example.test/mindId/xxx",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="must contain"):
|
||||
ConfigurableSearchSettings.from_tool_config(config)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Regression coverage for the unauthenticated Gateway health probes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.gateway import auth_middleware
|
||||
from app.gateway.app import app as gateway_app
|
||||
from app.gateway.auth_middleware import PUBLIC_HEALTH_PATHS, AuthMiddleware
|
||||
|
||||
|
||||
def _health_only_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
|
||||
async def health_check() -> dict[str, str]:
|
||||
return {"status": "healthy", "service": "deer-flow-gateway"}
|
||||
|
||||
for path in PUBLIC_HEALTH_PATHS:
|
||||
app.add_api_route(path, health_check, methods=["GET", "HEAD"])
|
||||
app.add_middleware(AuthMiddleware)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", sorted(PUBLIC_HEALTH_PATHS))
|
||||
@pytest.mark.parametrize("method", ["GET", "HEAD"])
|
||||
def test_health_probe_is_public_when_auth_is_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
path: str,
|
||||
method: str,
|
||||
) -> None:
|
||||
monkeypatch.setattr(auth_middleware, "is_auth_disabled", lambda: False)
|
||||
|
||||
with TestClient(_health_only_app()) as client:
|
||||
client.cookies.set("access_token", "invalid-token")
|
||||
response = client.request(
|
||||
method,
|
||||
path,
|
||||
headers={"Authorization": "Bearer invalid-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
if method == "GET":
|
||||
assert response.json() == {"status": "healthy", "service": "deer-flow-gateway"}
|
||||
else:
|
||||
assert response.content == b""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", sorted(PUBLIC_HEALTH_PATHS))
|
||||
def test_health_probe_allows_a_trailing_slash(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
path: str,
|
||||
) -> None:
|
||||
monkeypatch.setattr(auth_middleware, "is_auth_disabled", lambda: False)
|
||||
|
||||
with TestClient(_health_only_app(), follow_redirects=False) as client:
|
||||
response = client.get(f"{path}/")
|
||||
|
||||
# FastAPI redirects to the canonical route; importantly, auth must not turn
|
||||
# the request into a 401 before routing can perform that redirect.
|
||||
assert response.status_code == 307
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", sorted(PUBLIC_HEALTH_PATHS))
|
||||
def test_health_probe_is_public_behind_a_reverse_proxy_prefix(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
path: str,
|
||||
) -> None:
|
||||
monkeypatch.setattr(auth_middleware, "is_auth_disabled", lambda: False)
|
||||
|
||||
with TestClient(_health_only_app(), root_path="/deerflow") as client:
|
||||
response = client.get(f"/deerflow{path}")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_gateway_registers_every_public_health_path_for_get_and_head() -> None:
|
||||
methods_by_path = {
|
||||
route.path: route.methods
|
||||
for route in gateway_app.routes
|
||||
if hasattr(route, "methods")
|
||||
}
|
||||
|
||||
for path in PUBLIC_HEALTH_PATHS:
|
||||
assert {"GET", "HEAD"} <= methods_by_path[path]
|
||||
|
||||
|
||||
def test_non_health_route_still_requires_auth(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(auth_middleware, "is_auth_disabled", lambda: False)
|
||||
app = _health_only_app()
|
||||
|
||||
@app.get("/private")
|
||||
async def private() -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/private")
|
||||
|
||||
assert response.status_code == 401
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,9 @@
|
||||
# Direct runtime packages layered into the prebuilt offline backend image.
|
||||
# Keep these three pins synchronized with start_backend.sh.
|
||||
# Keep these four pins synchronized with start_backend.sh.
|
||||
croniter==6.2.2
|
||||
hindsight-client==0.6.2
|
||||
redis==5.2.1
|
||||
aiomysql==0.3.2
|
||||
|
||||
# Fully pinned dependency closure for CPython 3.12 on Linux. Keeping the
|
||||
# transitive packages here makes a newly generated wheelhouse reproducible.
|
||||
@@ -25,3 +26,4 @@ typing-extensions==4.16.0
|
||||
typing-inspection==0.4.2
|
||||
urllib3==2.7.0
|
||||
yarl==1.24.5
|
||||
PyMySQL==1.2.0
|
||||
@@ -59,6 +59,8 @@ echo " Python/ABI: ${TARGET_PYTHON_VERSION}/${TARGET_ABI}"
|
||||
require_wheel 'croniter-6.2.2-*.whl'
|
||||
require_wheel 'hindsight_client-0.6.2-*.whl'
|
||||
require_wheel 'redis-5.2.1-*.whl'
|
||||
require_wheel 'aiomysql-0.3.2-*.whl'
|
||||
require_wheel 'pymysql-1.2.0-*.whl'
|
||||
|
||||
# Resolve the same requirements a second time using only wheelhouse. This
|
||||
# catches a missing transitive dependency before the bundle reaches offline.
|
||||
|
||||
@@ -130,6 +130,12 @@ if [ "${HINDSIGHT_INSTALL_DEPS:-1}" != "0" ]; then
|
||||
uv pip install --python "${PYTHON_BIN}" --no-index --find-links /app/wheelhouse ${HINDSIGHT_PIP_PACKAGES:-hindsight-client==0.6.2}
|
||||
}
|
||||
fi
|
||||
if [ "${AIOMYSQL_INSTALL_DEPS:-1}" != "0" ]; then
|
||||
uv run --no-sync python -c "import aiomysql" >/dev/null 2>&1 || {
|
||||
PYTHON_BIN="$(uv run --no-sync python -c "import sys; print(sys.executable)")"
|
||||
uv pip install --python "${PYTHON_BIN}" --no-index --find-links /app/wheelhouse ${AIOMYSQL_PIP_PACKAGES:-aiomysql==0.3.2}
|
||||
}
|
||||
fi
|
||||
uv run --no-sync python -c "import redis" >/dev/null 2>&1 || uv pip install --python "$(uv run --no-sync python -c "import sys; print(sys.executable)")" --no-index --find-links /app/wheelhouse ${REDIS_PIP_PACKAGES:-redis==5.2.1}
|
||||
PYTHONPATH=. uv run --no-sync python -m uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --workers ${GATEWAY_WORKERS:-2}'
|
||||
|
||||
|
||||
Reference in new issue
Block a user