feat(knowledge): 支持文件导入沉淀知识,知识图谱可视化随主题切换
- 后端新增 /api/knowledge/ingest/file:上传 md/txt/pdf/word/ppt/excel, 转 Markdown 后走 wiki-capture LLM 蒸馏为陈述式知识笔记,失败回退逐字草稿 - service.capture_file + 文档专用蒸馏提示词,按主题拆分 1~4 页 - 知识图谱导出 HTML 支持 ?theme=light|dark,布局稳定后冻结, 按度数缩放节点,悬停/点击聚焦邻居 - 前端知识库页新增「导入文件」对话框与 ingestKnowledgeFile API - 智能体问答页与简介侧边栏 UI 优化;批量人物蒸馏脚本提取正文逻辑修正 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
17bb3715d1
commit
3845d5090b
14 files changed
+1040
-352
No files matched your search
@@ -44,7 +44,7 @@ except ImportError:
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
# 只改这一行:你的线上服务地址(不带结尾斜杠)
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
BASE_URL = "http://localhost:8001"
|
||||
BASE_URL = "http://47.94.209.59:2026/"
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ DEPTH = "full" # full 完整 / fast 精简
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
OUTPUT_DIR = HERE / "output"
|
||||
PEOPLE_FILE_DEFAULT = HERE / "people.xlsx"
|
||||
PEOPLE_FILE_DEFAULT = HERE / "people.example.csv"
|
||||
|
||||
# 执行时间窗:23:00 ~ 次日 08:00(跨午夜)
|
||||
WIN_START = dtime(23, 0)
|
||||
@@ -260,35 +260,63 @@ class DeerFlowClient:
|
||||
return "timeout"
|
||||
|
||||
def fetch_result_text(self, thread_id: str, run_id: str) -> str | None:
|
||||
"""抓取该 run 的最终 AI 回复文本(由 py 保存为 md)。"""
|
||||
"""抓取该 thread 的最终 AI 回复文本(由 py 保存为 md)。
|
||||
|
||||
注意:本部署的 `/runs/{rid}/messages`、`/messages`、`/events` 端点都返回空
|
||||
(事件库按登录用户过滤,后台 worker 写入时未带 user_id → 读不到)。
|
||||
可靠来源是 LangGraph 检查点状态 `/api/threads/{tid}/state` → values.messages,
|
||||
其中每条消息形如 {"type": "human"|"ai"|"tool", "content": <str | 内容块列表>, ...}。
|
||||
取最后一条有正文的 ai 消息即可。
|
||||
"""
|
||||
try:
|
||||
r = self.s.get(f"{self.base}/api/threads/{thread_id}/runs/{run_id}/messages",
|
||||
headers=self._headers(), params={"limit": 200}, timeout=HTTP_TIMEOUT)
|
||||
if r.status_code == 200:
|
||||
data = r.json().get("data", [])
|
||||
for msg in reversed(data):
|
||||
if msg.get("event_type") == "ai_message" or msg.get("type") == "ai":
|
||||
txt = _extract_text(msg)
|
||||
if txt and txt.strip():
|
||||
return txt
|
||||
r = self.s.get(f"{self.base}/api/threads/{thread_id}/state",
|
||||
headers=self._headers(), timeout=HTTP_TIMEOUT)
|
||||
if r.status_code != 200:
|
||||
print(f" 取回复返回 {r.status_code}:{r.text[:200]}")
|
||||
return None
|
||||
messages = (r.json().get("values") or {}).get("messages") or []
|
||||
# 从后往前找最后一条「有正文」的 AI 回复(跳过只含工具调用的中间消息)
|
||||
for msg in reversed(messages):
|
||||
if not isinstance(msg, dict) or msg.get("type") != "ai":
|
||||
continue
|
||||
txt = _extract_text(msg)
|
||||
if txt and txt.strip():
|
||||
return txt
|
||||
except requests.RequestException as e:
|
||||
print(f" 取回复出错:{e}")
|
||||
return None
|
||||
|
||||
|
||||
def _extract_text(msg: dict) -> str:
|
||||
c = msg.get("content") or msg.get("text") or ""
|
||||
def _extract_text(payload) -> str:
|
||||
"""从一条消息的 content 里提取纯文本。
|
||||
|
||||
payload 可能是:
|
||||
- LangChain 消息的 model_dump 字典(正文在 payload["content"])
|
||||
- 直接的字符串
|
||||
- 内容块列表([{"type":"text","text":"..."}, ...])
|
||||
"""
|
||||
if payload is None:
|
||||
return ""
|
||||
# 若是整条消息的 model_dump,正文在其 content 字段
|
||||
if isinstance(payload, dict):
|
||||
c = payload.get("content", payload.get("text", ""))
|
||||
else:
|
||||
c = payload
|
||||
if isinstance(c, str):
|
||||
return c
|
||||
if isinstance(c, list):
|
||||
parts = []
|
||||
for b in c:
|
||||
if isinstance(b, dict):
|
||||
parts.append(b.get("text") or b.get("content") or "")
|
||||
else:
|
||||
parts.append(str(b))
|
||||
# 只取文本块,跳过 tool_use / thinking 等非文本块
|
||||
if b.get("type") in (None, "text", "text_block"):
|
||||
parts.append(b.get("text") or b.get("content") or "")
|
||||
elif b.get("text"):
|
||||
parts.append(b["text"])
|
||||
elif isinstance(b, str):
|
||||
parts.append(b)
|
||||
return "\n".join(p for p in parts if p)
|
||||
return str(c)
|
||||
return str(c) if c else ""
|
||||
|
||||
|
||||
def _clean_markdown(text: str) -> str:
|
||||
|
||||
@@ -2,35 +2,31 @@
|
||||
|
||||
import {
|
||||
BotIcon,
|
||||
CheckIcon,
|
||||
InfoIcon,
|
||||
MessageCircleQuestionIcon,
|
||||
PanelRightCloseIcon,
|
||||
PanelRightOpenIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
SparklesIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { type Agent } from "@/core/agents";
|
||||
import { useUpdateAgent } from "@/core/agents/hooks";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Collapsible right-hand panel on the agent chat page that surfaces the agent's
|
||||
* profile (name / description / skills) plus a list of frequently-asked
|
||||
* questions ("常用问题"). Clicking a question sends it as a message. The agent's
|
||||
* owner (or an admin) can edit the question list inline.
|
||||
* Collapsible right-hand "智能体简介" panel on the agent chat page. It is a
|
||||
* read-only window onto the agent's profile — avatar, name, slogan/description,
|
||||
* the admin-authored SOUL.md as a long-form blurb, the skill whitelist, and the
|
||||
* admin-configured common questions surfaced as quick-ask entries. Clicking a
|
||||
* question sends it as a message.
|
||||
*
|
||||
* Editing the common questions lives in the "编辑智能体" dialog (agent-card), NOT
|
||||
* here — this panel never offers an edit affordance or an admin hint.
|
||||
*/
|
||||
export function AgentInfoSidebar({
|
||||
agent,
|
||||
@@ -49,20 +45,12 @@ export function AgentInfoSidebar({
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
||||
const { skills } = useSkills();
|
||||
const updateAgent = useUpdateAgent();
|
||||
|
||||
const displayName = desensitize(agent?.name ?? agentName);
|
||||
const description = agent?.description
|
||||
? desensitize(agent.description)
|
||||
: undefined;
|
||||
|
||||
// Owner / admin gate for inline editing of the common-questions list.
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const canEdit = useMemo(() => {
|
||||
if (!agent) return false;
|
||||
if (storedAuth?.system_role === "admin") return true;
|
||||
return agent.user_id != null && agent.user_id === storedAuth?.user_id;
|
||||
}, [agent, storedAuth]);
|
||||
const soul = agent?.soul?.trim() ? desensitize(agent.soul) : undefined;
|
||||
|
||||
// Map a stored skill name to its Chinese display label (falls back to name).
|
||||
const skillLabel = useMemo(() => {
|
||||
@@ -78,45 +66,9 @@ export function AgentInfoSidebar({
|
||||
[agent?.common_questions],
|
||||
);
|
||||
|
||||
// --- Inline editing state ---
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [drafts, setDrafts] = useState<string[]>([]);
|
||||
|
||||
const startEditing = () => {
|
||||
setDrafts(questions.length > 0 ? [...questions] : [""]);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditing(false);
|
||||
setDrafts([]);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!agent) return;
|
||||
const cleaned = drafts.map((q) => q.trim()).filter((q) => q.length > 0);
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
request: { common_questions: cleaned },
|
||||
});
|
||||
toast.success("常用问题已保存");
|
||||
setEditing(false);
|
||||
setDrafts([]);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
// Leaving edit mode whenever the panel collapses keeps the state tidy.
|
||||
useEffect(() => {
|
||||
if (collapsed && editing) cancelEditing();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [collapsed]);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col items-center border-l pt-14">
|
||||
<div className="flex shrink-0 flex-col items-center border-l pt-3">
|
||||
<Tooltip content="智能体简介">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -132,8 +84,9 @@ export function AgentInfoSidebar({
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="bg-card/40 flex w-72 shrink-0 flex-col border-l pt-12">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<aside className="bg-card/40 hidden h-full w-[340px] shrink-0 flex-col border-l lg:flex">
|
||||
{/* Header — same height as the chat header so the two columns line up. */}
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<span className="text-sm font-semibold">智能体简介</span>
|
||||
<Tooltip content="收起">
|
||||
<Button
|
||||
@@ -147,125 +100,66 @@ export function AgentInfoSidebar({
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto p-4">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{/* Identity */}
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<BotIcon className="text-primary h-6 w-6" />
|
||||
<div className="flex flex-col items-center gap-2 px-6 pb-6 pt-7 text-center">
|
||||
<div className="bg-primary/10 ring-primary/15 flex h-16 w-16 items-center justify-center rounded-2xl ring-1">
|
||||
<BotIcon className="text-primary h-8 w-8" />
|
||||
</div>
|
||||
<div className="text-base font-bold" title={displayName}>
|
||||
<div className="text-lg font-bold" title={displayName}>
|
||||
{displayName}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Soul — the agent's long-form personality blurb. */}
|
||||
{soul && (
|
||||
<section className="space-y-3 border-t px-6 py-5">
|
||||
<h4 className="text-foreground text-sm font-semibold">Soul</h4>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{soul}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
{agent?.skills && agent.skills.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
|
||||
<section className="space-y-3 border-t px-6 py-5">
|
||||
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
|
||||
<SparklesIcon className="h-3.5 w-3.5" />
|
||||
技能
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{agent.skills.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setDetailSkillName(name)}
|
||||
title="查看技能详情"
|
||||
className="border-border bg-background hover:bg-accent inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition-colors"
|
||||
className="border-border bg-background hover:bg-accent inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition-colors"
|
||||
>
|
||||
<span className="max-w-[150px] truncate">
|
||||
<span className="max-w-[180px] truncate">
|
||||
{skillLabel(name)}
|
||||
</span>
|
||||
<InfoIcon className="text-muted-foreground h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Common questions */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
|
||||
{/* Common questions — read-only quick-ask entries. */}
|
||||
{questions.length > 0 && (
|
||||
<section className="space-y-3 border-t px-6 py-5">
|
||||
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
|
||||
<MessageCircleQuestionIcon className="h-3.5 w-3.5" />
|
||||
常用问题
|
||||
</div>
|
||||
{canEdit && !editing && (
|
||||
<Tooltip content="编辑常用问题">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={startEditing}
|
||||
aria-label="编辑常用问题"
|
||||
>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="space-y-2">
|
||||
{drafts.map((value, index) => (
|
||||
<div key={index} className="flex items-center gap-1">
|
||||
<Input
|
||||
value={value}
|
||||
placeholder={`问题 ${index + 1}`}
|
||||
onChange={(e) => {
|
||||
const next = [...drafts];
|
||||
next[index] = e.currentTarget.value;
|
||||
setDrafts(next);
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() =>
|
||||
setDrafts(drafts.filter((_, i) => i !== index))
|
||||
}
|
||||
aria-label="删除"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setDrafts([...drafts, ""])}
|
||||
>
|
||||
<PlusIcon className="h-3.5 w-3.5" /> 添加问题
|
||||
</Button>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={updateAgent.isPending}
|
||||
>
|
||||
<CheckIcon className="h-3.5 w-3.5" /> 保存
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={cancelEditing}
|
||||
disabled={updateAgent.isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : questions.length > 0 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
推荐问题
|
||||
</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
{questions.map((question, index) => (
|
||||
<button
|
||||
key={index}
|
||||
@@ -274,20 +168,16 @@ export function AgentInfoSidebar({
|
||||
onClick={() => onAskQuestion(question)}
|
||||
title={question}
|
||||
className={cn(
|
||||
"border-border bg-background hover:bg-accent w-full rounded-md border px-2.5 py-1.5 text-left text-xs transition-colors",
|
||||
"border-border/60 bg-background/60 hover:bg-accent hover:border-border w-full rounded-xl border px-3 py-2.5 text-left text-sm leading-relaxed transition-colors",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{question}
|
||||
{desensitize(question)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{canEdit ? "还没有常用问题,点击上方编辑添加。" : "暂无常用问题。"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SkillDetailDialog
|
||||
|
||||
@@ -41,14 +41,14 @@ export function AgentWelcome({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="bg-primary/10 ring-primary/15 flex h-16 w-16 items-center justify-center rounded-2xl ring-1">
|
||||
<BotIcon className="text-primary h-8 w-8" />
|
||||
<div className="bg-primary/10 ring-primary/15 mb-2 flex h-[72px] w-[72px] items-center justify-center rounded-3xl ring-1">
|
||||
<BotIcon className="text-primary h-9 w-9" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold tracking-tight">
|
||||
<div className="text-3xl font-bold tracking-tight">
|
||||
Hi,我是{displayName}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-muted-foreground max-w-md text-sm leading-relaxed">
|
||||
<p className="text-muted-foreground max-w-xl text-base leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
LockIcon,
|
||||
MessageSquareIcon,
|
||||
PinIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
ShieldIcon,
|
||||
SparklesIcon,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
TagsIcon,
|
||||
Trash2Icon,
|
||||
UserIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
@@ -207,6 +209,12 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
const [editName, setEditName] = useState(agent.name);
|
||||
const [editDescription, setEditDescription] = useState(agent.description ?? "");
|
||||
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
|
||||
// Common questions (常用问题) are authored here in the edit dialog and read
|
||||
// back read-only on the agent chat page. Keep one trailing blank row so there
|
||||
// is always an empty input to type the next question into.
|
||||
const [editCommonQuestions, setEditCommonQuestions] = useState<string[]>(
|
||||
agent.common_questions ?? [],
|
||||
);
|
||||
const [editPublished, setEditPublished] = useState(agent.published);
|
||||
const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private);
|
||||
const [selectedSkills, setSelectedSkills] = useState<string[]>(
|
||||
@@ -250,8 +258,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
setEditPublished(agent.published);
|
||||
setEditIsPrivate(agent.is_private);
|
||||
setSelectedSkills(agent.skills ?? []);
|
||||
setEditCommonQuestions(agent.common_questions ?? []);
|
||||
setSkillSearch("");
|
||||
}, [
|
||||
agent.common_questions,
|
||||
agent.description,
|
||||
agent.is_private,
|
||||
agent.name,
|
||||
@@ -269,6 +279,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
// include `soul`) keep working.
|
||||
if (agentDetail) {
|
||||
setEditSoul(agentDetail.soul ?? "");
|
||||
setEditCommonQuestions(agentDetail.common_questions ?? []);
|
||||
} else {
|
||||
setEditSoul(agent.soul ?? "");
|
||||
}
|
||||
@@ -345,6 +356,9 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
published: editIsPrivate ? false : editPublished,
|
||||
is_private: editIsPrivate,
|
||||
skills: selectedSkills,
|
||||
common_questions: editCommonQuestions
|
||||
.map((q) => q.trim())
|
||||
.filter((q) => q.length > 0),
|
||||
},
|
||||
});
|
||||
toast.success("智能体已更新");
|
||||
@@ -831,7 +845,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑智能体:{desensitize(agent.name)}</DialogTitle>
|
||||
<DialogDescription>
|
||||
可以修改名称、描述、技能白名单、SOUL 内容与发布状态。
|
||||
可以修改名称、描述、技能白名单、SOUL 内容、常用问题与发布状态。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
@@ -935,6 +949,52 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
className="min-h-40 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">常用问题</label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
这些问题会在该智能体的首页与简介栏作为推荐问题展示,点击即可一键提问。
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{editCommonQuestions.map((value, index) => (
|
||||
<div key={index} className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={value}
|
||||
placeholder={`问题 ${index + 1}`}
|
||||
onChange={(e) => {
|
||||
const next = [...editCommonQuestions];
|
||||
next[index] = e.currentTarget.value;
|
||||
setEditCommonQuestions(next);
|
||||
}}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() =>
|
||||
setEditCommonQuestions(
|
||||
editCommonQuestions.filter((_, i) => i !== index),
|
||||
)
|
||||
}
|
||||
aria-label="删除问题"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
setEditCommonQuestions([...editCommonQuestions, ""])
|
||||
}
|
||||
>
|
||||
<PlusIcon className="size-3.5" /> 添加问题
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">公开发布</p>
|
||||
|
||||
@@ -127,6 +127,30 @@ export async function ingestThreadsBatch(payload: BatchIngestPayload = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
export interface IngestFileResult {
|
||||
note: KnowledgeNote;
|
||||
created: boolean;
|
||||
notes: KnowledgeNote[];
|
||||
llm_fallback: boolean;
|
||||
}
|
||||
|
||||
/** Import a file (md/txt/pdf/word/ppt/excel) into the KB as wiki note(s). */
|
||||
export async function ingestKnowledgeFile(
|
||||
file: File,
|
||||
opts: { title?: string; tags?: string; status?: string; model_name?: string | null } = {},
|
||||
) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (opts.title) form.append("title", opts.title);
|
||||
if (opts.tags) form.append("tags", opts.tags);
|
||||
if (opts.status) form.append("status", opts.status);
|
||||
if (opts.model_name) form.append("model_name", opts.model_name);
|
||||
// No Content-Type header — the browser sets the multipart boundary itself.
|
||||
return parseResponse<IngestFileResult>(
|
||||
await apiFetch(`${baseUrl()}/ingest/file`, { method: "POST", body: form }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve `[[wikilink]]` targets to note ids / vault pages. */
|
||||
export async function resolveWikilinks(targets: string[]) {
|
||||
const res = await parseResponse<{ resolved: WikilinkResolutionMap }>(
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
exportKnowledgeGraph,
|
||||
fetchVaultPage,
|
||||
getKnowledgeNote,
|
||||
ingestKnowledgeFile,
|
||||
ingestThread,
|
||||
ingestThreadsBatch,
|
||||
listDuplicates,
|
||||
@@ -106,6 +107,16 @@ export function useKnowledgeActions() {
|
||||
mutationFn: ingestThreadsBatch,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
ingestFile: useMutation({
|
||||
mutationFn: ({
|
||||
file,
|
||||
opts,
|
||||
}: {
|
||||
file: File;
|
||||
opts?: Parameters<typeof ingestKnowledgeFile>[1];
|
||||
}) => ingestKnowledgeFile(file, opts),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
exportGraph: useMutation({ mutationFn: exportKnowledgeGraph }),
|
||||
merge: useMutation({
|
||||
mutationFn: ({ primaryId, duplicateIds }: { primaryId: string; duplicateIds: string[] }) =>
|
||||
|
||||
@@ -13,11 +13,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 {
|
||||
MessageList,
|
||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
|
||||
} from "@/components/workspace/messages";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
@@ -32,7 +28,6 @@ import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function AgentChatPage() {
|
||||
const routeBase = useRouteBase();
|
||||
@@ -163,146 +158,156 @@ export default function AgentChatPage() {
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
const messageListPaddingBottom = showFollowups
|
||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||
: undefined;
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
const isDemoMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
|
||||
|
||||
const status = thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready";
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
className={cn(
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 border-b px-4",
|
||||
isLandingState
|
||||
? "border-transparent bg-transparent"
|
||||
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
|
||||
)}
|
||||
>
|
||||
{/* Agent badge */}
|
||||
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
||||
<BotIcon className="text-primary h-3.5 w-3.5" />
|
||||
<span
|
||||
className="max-w-[200px] truncate text-xs font-medium"
|
||||
title={agentDisplayName}
|
||||
>
|
||||
{agentDisplayName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div className="mr-4 flex items-center">
|
||||
<Tooltip content={t.agents.newChat}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
router.push(`/page/workspace/agents/${agent_id}/chats/new`);
|
||||
}}
|
||||
{/* Root: fixed-height horizontal split. The left main column owns the
|
||||
chat (home / conversation) and the input; the right sidebar is a
|
||||
separate flex sibling, so nothing in the main column can ever bleed
|
||||
over it. */}
|
||||
<div className="flex size-full min-h-0 overflow-hidden">
|
||||
{/* ── Left: main conversation column ───────────────────────────── */}
|
||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{/* Header — in normal flow (not absolute), fixed height. */}
|
||||
<header className="bg-card/90 border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-md">
|
||||
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
||||
<BotIcon className="text-primary h-3.5 w-3.5" />
|
||||
<span
|
||||
className="max-w-[200px] truncate text-xs font-medium"
|
||||
title={agentDisplayName}
|
||||
>
|
||||
<PlusSquare /> {t.agents.newChat}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
/>
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
{agentDisplayName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex size-full justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", !isLandingState && "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
|
||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isLandingState && "-translate-y-[calc(50vh-96px)]",
|
||||
isLandingState
|
||||
? "max-w-(--container-width-md)"
|
||||
: "max-w-(--container-width-lg)",
|
||||
)}
|
||||
>
|
||||
<div className="absolute -top-4 right-0 left-0 z-0">
|
||||
<div className="absolute right-0 bottom-0 left-0">
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={
|
||||
!thread.values.todos || thread.values.todos.length === 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Tooltip content={t.agents.newChat}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/page/workspace/agents/${agent_id}/chats/new`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<PlusSquare /> {t.agents.newChat}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
/>
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="w-full -translate-y-4">
|
||||
{isLandingState ? (
|
||||
/* ── home: centered hero + suggested questions + input ──────
|
||||
Everything lives inside the scrollable main column. No fixed,
|
||||
no absolute, no viewport centering — so the avatar can't touch
|
||||
the header and the input can't cover the sidebar. */
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center overflow-y-auto px-6 pb-10 pt-16 sm:pt-24">
|
||||
<AgentWelcome
|
||||
agent={agent}
|
||||
agentName={agentDisplayName}
|
||||
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
|
||||
disabled={isDemoMode}
|
||||
className="mb-7"
|
||||
/>
|
||||
<div className="w-full max-w-(--container-width-md)">
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread={isLandingState}
|
||||
isNewThread
|
||||
threadId={threadId}
|
||||
assistantId={agent_id}
|
||||
autoFocus={isLandingState}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
autoFocus
|
||||
status={status}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isLandingState && (
|
||||
<AgentWelcome
|
||||
agent={agent}
|
||||
agentName={agentDisplayName}
|
||||
onAskQuestion={(text) =>
|
||||
handleSubmit({ text, files: [] })
|
||||
}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||||
}
|
||||
/>
|
||||
)
|
||||
disabled={isDemoMode}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
{isDemoMode && (
|
||||
<div className="text-muted-foreground/67 mt-3 w-full text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ── conversation (active / history): messages scroll, input
|
||||
docked at the bottom of the main column only. */
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<MessageList
|
||||
className="min-h-0 flex-1"
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={showFollowups ? 96 : 24}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
|
||||
/>
|
||||
|
||||
<div className="from-background pointer-events-none shrink-0 bg-gradient-to-t to-transparent px-4 pb-4 pt-2">
|
||||
<div className="pointer-events-auto mx-auto w-full max-w-(--container-width-lg)">
|
||||
<TodoList
|
||||
className="bg-background/5 mb-2"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={
|
||||
!thread.values.todos ||
|
||||
thread.values.todos.length === 0
|
||||
}
|
||||
/>
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread={false}
|
||||
threadId={threadId}
|
||||
assistantId={agent_id}
|
||||
status={status}
|
||||
context={settings.context}
|
||||
disabled={isDemoMode}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
{isDemoMode && (
|
||||
<div className="text-muted-foreground/67 mt-2 w-full text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* ── Right: read-only agent profile sidebar ───────────────────── */}
|
||||
<AgentInfoSidebar
|
||||
agent={agent}
|
||||
agentName={agentDisplayName}
|
||||
disabled={
|
||||
thread.isLoading ||
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||||
}
|
||||
disabled={thread.isLoading || isDemoMode}
|
||||
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Search,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -323,6 +324,119 @@ function BatchImportDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function ImportFileDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}) {
|
||||
const { ingestFile } = useKnowledgeActions();
|
||||
const navigate = useNavigate();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [model, setModel] = useState<string | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
setFile(null);
|
||||
setTitle("");
|
||||
setTags("");
|
||||
setModel(null);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file) {
|
||||
toast.error("请选择文件");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await ingestFile.mutateAsync({
|
||||
file,
|
||||
opts: {
|
||||
title: title.trim() || undefined,
|
||||
tags: tags.trim() || undefined,
|
||||
status: "approved",
|
||||
model_name: model,
|
||||
},
|
||||
});
|
||||
const count = result.notes?.length ?? 1;
|
||||
if (result.llm_fallback) {
|
||||
// The model produced nothing usable — the file was stored verbatim as a
|
||||
// draft instead of distilled into a wiki note.
|
||||
toast.warning(
|
||||
`已导入 ${count} 条(大模型异常,改用规则抽取并存为草稿)`,
|
||||
);
|
||||
} else {
|
||||
toast.success(`已从文件生成 ${count} 条知识`);
|
||||
}
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
if (result.note?.id) {
|
||||
navigate(`/page/workspace/knowledge/${result.note.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "文件导入失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>导入文件生成 Wiki</DialogTitle>
|
||||
<DialogDescription>
|
||||
上传文档(Markdown / TXT / PDF / Word / PPT / Excel),自动提炼为知识库 wiki 词条并写入 vault。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
选择文件
|
||||
<Input
|
||||
type="file"
|
||||
accept=".md,.markdown,.txt,.pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
{file ? (
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{file.name} · {(file.size / 1024).toFixed(0)} KB
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="标题(可选,留空则由模型生成)"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="标签,逗号分隔(可选)"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
/>
|
||||
<label className="flex items-center justify-between gap-2 text-sm">
|
||||
提炼大模型
|
||||
<ModelNameSelect
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
disabled={ingestFile.isPending}
|
||||
triggerClassName="h-9 w-[11rem]"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => void handleImport()} disabled={ingestFile.isPending || !file}>
|
||||
{ingestFile.isPending ? "导入中..." : "导入并生成"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DedupDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -698,6 +812,7 @@ export default function KnowledgeBasePage() {
|
||||
const [status, setStatus] = useState("all");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [batchOpen, setBatchOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [dedupOpen, setDedupOpen] = useState(false);
|
||||
|
||||
const params = useMemo(
|
||||
@@ -752,6 +867,9 @@ export default function KnowledgeBasePage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||
<Upload className="size-4" /> 导入文件
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setBatchOpen(true)}>
|
||||
<RefreshCw className="size-4" /> 批量导入
|
||||
</Button>
|
||||
@@ -814,6 +932,7 @@ export default function KnowledgeBasePage() {
|
||||
</div>
|
||||
|
||||
<CreateNoteDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
<ImportFileDialog open={importOpen} onOpenChange={setImportOpen} />
|
||||
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
|
||||
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
|
||||
</div>
|
||||
|
||||
@@ -4,13 +4,17 @@ import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { knowledgeExportFileUrl } from "@/core/knowledge/api";
|
||||
import { useKnowledgeActions } from "@/core/knowledge/hooks";
|
||||
|
||||
export default function KnowledgeGraphPage() {
|
||||
const navigate = useNavigate();
|
||||
const { theme } = useTheme();
|
||||
const { exportGraph } = useKnowledgeActions();
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
// Keep the served base URL (without the theme) so a theme toggle can re-skin
|
||||
// the iframe without regenerating the graph on the backend.
|
||||
const [baseSrc, setBaseSrc] = useState<string | null>(null);
|
||||
const [stats, setStats] = useState<{ nodes: number; edges: number } | null>(null);
|
||||
|
||||
const regenerate = useCallback(async () => {
|
||||
@@ -18,7 +22,7 @@ export default function KnowledgeGraphPage() {
|
||||
const result = await exportGraph.mutateAsync();
|
||||
setStats(result.stats);
|
||||
// Cache-bust the iframe so it reflects the freshly generated graph.
|
||||
setSrc(`${knowledgeExportFileUrl("graph.html")}?t=${Date.now()}`);
|
||||
setBaseSrc(`${knowledgeExportFileUrl("graph.html")}?t=${Date.now()}`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "生成图谱失败");
|
||||
}
|
||||
@@ -29,6 +33,10 @@ export default function KnowledgeGraphPage() {
|
||||
void regenerate();
|
||||
}, [regenerate]);
|
||||
|
||||
// The graph HTML lives in an iframe and can't read the app's CSS theme, so we
|
||||
// pass the active day/night mode via a query param; toggling re-renders it.
|
||||
const src = baseSrc ? `${baseSrc}&theme=${theme}` : null;
|
||||
|
||||
return (
|
||||
<div className="flex size-full min-h-0 flex-col">
|
||||
<header className="flex items-center gap-2 border-b p-3">
|
||||
|
||||
@@ -250,7 +250,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|
||||
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
|
||||
| **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` → HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. |
|
||||
| **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). |
|
||||
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` → `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "已改用规则抽取". The "保存到知识库" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector→keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[…]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). Backed by the `deerflow.knowledge` module (see below). |
|
||||
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` → `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "已改用规则抽取". The "保存到知识库" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector→keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[…]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). Backed by the `deerflow.knowledge` module (see below). |
|
||||
|
||||
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
|
||||
|
||||
@@ -290,7 +290,7 @@ Self-contained product knowledge base. **All knowledge capability lives in this
|
||||
- **DB mirror** — tables `knowledge_notes` / `knowledge_sources` / `knowledge_tags` / `knowledge_note_tags` (`knowledge/models.py`, registered in `persistence/models/__init__.py`, auto-created by `Base.metadata.create_all`; portable types `PortableLongText`/`BeijingDateTime`). Powers listing/filter/search/audit. Global/shared — no `user_id` column; `created_by`/`updated_by` are audit-only (resolved via `get_effective_user_id()`).
|
||||
- **Markdown vault** — obsidian-wiki compatible, default `{$DEER_FLOW_HOME}/data/knowledge/obsidian-vault`. `ObsidianWikiAdapter` (`obsidian_adapter.py`) writes wiki-capture frontmatter+body, and after **every** write syncs `index.md` / `log.md` (CAPTURE/INGEST/UPDATE/ARCHIVE/EXPORT) / `hot.md` / `.manifest.json` (source-hash dedup for `skip_existing`). `markdown_vault.py` handles frontmatter build/parse, slugging, wikilink extraction and **path-traversal-proof** resolution.
|
||||
|
||||
**Module layout:** `models.py` (ORM: notes/sources/tags/note_tags + embeddings/versions/entities/relations) · `repository.py` (DB only, no LLM) · `schemas.py` (NoteDraft/SourceDraft DTOs) · `skill_templates.py` · `markdown_vault.py` · `manifest.py` · `extractor.py` (rule-based fallback) · `llm_extractor.py` (**default**: distills declarative knowledge per wiki-capture, structured JSON incl. entities/relations; falls back to `extractor.py`) · `sources.py` · `exporter.py` (`graph.json`/`graph.graphml`/`cypher.txt` + self-contained vanilla-JS `graph.html`; entity nodes + relation edges) · `chunking.py` + `embeddings.py` (OpenAI-compatible embedding client, keyword fallback) + `search.py` (keyword/vector/hybrid) · `sensitive.py` (secret/PII redaction) · `dedup.py` (Jaccard + hash dedup) · `auto_ingest.py` (background queue/worker) · `obsidian_adapter.py` · `service.py` (`KnowledgeService` orchestrator — the only thing the router touches). Tests: `tests/test_knowledge.py` (20 cases). Frontend: `frontend-web/src/core/knowledge/*` + `pages/KnowledgeBasePage.tsx` / `KnowledgeGraphPage.tsx` + chat `knowledge-save-trigger.tsx` / `knowledge-rag-toggle.tsx`; spec at `frontend-web/docs/product-knowledge-base-dev.md`.
|
||||
**Module layout:** `models.py` (ORM: notes/sources/tags/note_tags + embeddings/versions/entities/relations) · `repository.py` (DB only, no LLM) · `schemas.py` (NoteDraft/SourceDraft DTOs) · `skill_templates.py` · `markdown_vault.py` · `manifest.py` · `extractor.py` (rule-based fallback) · `llm_extractor.py` (**default**: distills declarative knowledge per wiki-capture, structured JSON incl. entities/relations; falls back to `extractor.py`) · `sources.py` · `exporter.py` (`graph.json`/`graph.graphml`/`cypher.txt` + self-contained vanilla-JS `graph.html`; entity nodes + relation edges. **`graph.html` is theme-aware** — it reads `?theme=light|dark` from its own URL since the embedding iframe can't inherit the app's CSS; `KnowledgeGraphPage.tsx` appends the active day/night mode and re-skins on toggle. The layout cools down and **freezes** so dense graphs stay clickable, node radius scales with degree, and hovering/clicking a node focuses it + its direct neighbours while dimming the rest + a detail panel — taming heavy node/edge clutter) · `chunking.py` + `embeddings.py` (OpenAI-compatible embedding client, keyword fallback) + `search.py` (keyword/vector/hybrid) · `sensitive.py` (secret/PII redaction) · `dedup.py` (Jaccard + hash dedup) · `auto_ingest.py` (background queue/worker) · `obsidian_adapter.py` · `service.py` (`KnowledgeService` orchestrator — the only thing the router touches; capture entry points `capture_thread` / `capture_search` / `capture_file` / `create_manual_note`, all converging on `_persist_draft`). Tests: `tests/test_knowledge.py` (20 cases). Frontend: `frontend-web/src/core/knowledge/*` + `pages/KnowledgeBasePage.tsx` / `KnowledgeGraphPage.tsx` + chat `knowledge-save-trigger.tsx` / `knowledge-rag-toggle.tsx`; spec at `frontend-web/docs/product-knowledge-base-dev.md`.
|
||||
|
||||
**Wikilinks & multi-note capture** (`knowledge.{related_notes_enabled,related_notes_limit,multi_note_enabled}`, all default-on; `project_name` defaults to `zncm`):
|
||||
- **Navigable `[[wikilinks]]`** — `service.resolve_wikilink(s)` maps a `[[target]]` to a knowledge note (by id → `vault_path` → title) or a vault meta page (`read_vault_page`, path-traversal-proof), else `missing`. `markdown_vault.normalize_wikilink_target` canonicalizes targets (drops `|alias`, slashes, `.md`). The frontend `MarkdownRenderer` renders non-numeric `[[...]]` as clickable links from the resolver map (numeric `[[123]]` stay citation badges); note links navigate, vault links open a `VaultPageDialog`, missing links are greyed.
|
||||
|
||||
@@ -13,9 +13,12 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
|
||||
from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -26,6 +29,12 @@ from deerflow.knowledge.service import EmptyThreadError
|
||||
from deerflow.runtime import serialize_channel_values
|
||||
from deerflow.runtime.references import build_reference_batches
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
from deerflow.utils.file_conversion import CONVERTIBLE_EXTENSIONS, convert_file_to_markdown
|
||||
|
||||
# Plain-text/markdown files are read directly; binary documents are converted.
|
||||
_TEXT_EXTENSIONS = {".md", ".markdown", ".txt", ".text", ""}
|
||||
# Bound the in-memory read so a knowledge import can't OOM the gateway.
|
||||
_MAX_IMPORT_BYTES = 25 * 1024 * 1024
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/knowledge", tags=["knowledge"])
|
||||
@@ -299,6 +308,80 @@ async def ingest_threads_batch(body: BatchIngestRequest, request: Request) -> di
|
||||
return {"ok": True, "processed": len(items), "created": created, "skipped": skipped, "failed": failed, "llm_fallback": llm_fallback, "items": items}
|
||||
|
||||
|
||||
async def _extract_upload_text(file: UploadFile) -> tuple[str, str]:
|
||||
"""Read an uploaded file and return ``(filename, markdown_text)``.
|
||||
|
||||
Plain-text/markdown is decoded directly; PDF/Office documents are converted
|
||||
to Markdown via the shared converter. Raises ``HTTPException`` on empty /
|
||||
oversized / unsupported / unparseable files.
|
||||
"""
|
||||
name = file.filename or "document"
|
||||
ext = Path(name).suffix.lower()
|
||||
raw = await file.read()
|
||||
if not raw:
|
||||
raise HTTPException(status_code=400, detail="文件为空")
|
||||
if len(raw) > _MAX_IMPORT_BYTES:
|
||||
raise HTTPException(status_code=413, detail=f"文件过大,最大支持 {_MAX_IMPORT_BYTES // (1024 * 1024)}MB")
|
||||
|
||||
if ext in _TEXT_EXTENSIONS:
|
||||
text = raw.decode("utf-8", errors="ignore")
|
||||
elif ext in CONVERTIBLE_EXTENSIONS:
|
||||
# Convert in an isolated temp dir; a fixed safe name avoids path issues
|
||||
# while preserving the extension so the converter detects the type.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp) / f"import{ext}"
|
||||
tmp_path.write_bytes(raw)
|
||||
md_path = await convert_file_to_markdown(tmp_path)
|
||||
if md_path is None:
|
||||
raise HTTPException(status_code=422, detail=f"无法解析文件:{name}")
|
||||
text = md_path.read_text(encoding="utf-8", errors="ignore")
|
||||
else:
|
||||
raise HTTPException(status_code=415, detail=f"不支持的文件类型:{ext or '未知'}(支持 md/txt/pdf/word/ppt/excel)")
|
||||
|
||||
if not text.strip():
|
||||
raise HTTPException(status_code=422, detail="文件内容为空或无法提取文本")
|
||||
return name, text
|
||||
|
||||
|
||||
@router.post("/ingest/file")
|
||||
@require_auth
|
||||
async def ingest_file(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
title: str | None = Form(default=None),
|
||||
tags: str | None = Form(default=None),
|
||||
status: str = Form(default="approved"),
|
||||
model_name: str | None = Form(default=None),
|
||||
) -> dict[str, Any]:
|
||||
"""Import an uploaded file into the knowledge base as wiki note(s).
|
||||
|
||||
Converts the file to Markdown, distills it via the wiki-capture LLM pipeline
|
||||
(same as thread capture), and writes the resulting declarative note(s) into
|
||||
the shared vault. ``tags`` is a comma/space-separated string.
|
||||
"""
|
||||
service = get_knowledge_service(request)
|
||||
name, text = await _extract_upload_text(file)
|
||||
tag_list = [t for t in re.split(r"[,,\s]+", tags or "") if t]
|
||||
try:
|
||||
result = await service.capture_file(
|
||||
content_md=text,
|
||||
filename=name,
|
||||
title=(title or None),
|
||||
tags=tag_list,
|
||||
status=status,
|
||||
created_by=get_effective_user_id(),
|
||||
model_name=(model_name or None),
|
||||
)
|
||||
except EmptyThreadError:
|
||||
raise HTTPException(status_code=422, detail="文件内容无可沉淀的知识")
|
||||
return {
|
||||
"note": result["note"],
|
||||
"created": result["created"],
|
||||
"notes": result.get("notes", [result["note"]]),
|
||||
"llm_fallback": result.get("llm_fallback", False),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/ingest/search")
|
||||
@require_auth
|
||||
async def ingest_search(body: IngestSearchRequest, request: Request) -> dict[str, Any]:
|
||||
|
||||
@@ -135,7 +135,15 @@ def to_cypher(graph: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
def to_html(graph: dict[str, Any]) -> str:
|
||||
"""Self-contained force-directed graph (canvas + vanilla JS, no CDN)."""
|
||||
"""Self-contained, theme-aware force-directed graph (canvas + vanilla JS, no CDN).
|
||||
|
||||
The page reads ``?theme=light|dark`` from its own URL so the embedding iframe
|
||||
can stay in sync with the app's day/night mode (an iframe cannot inherit the
|
||||
parent's CSS). The layout settles and then **freezes** so a dense graph stops
|
||||
drifting and stays clickable; node size scales with degree; and hovering or
|
||||
clicking a node focuses it + its direct neighbours while dimming everything
|
||||
else, which cuts through heavy node/edge clutter.
|
||||
"""
|
||||
data_json = json.dumps(graph, ensure_ascii=False)
|
||||
return _HTML_TEMPLATE.replace("__GRAPH_DATA__", data_json)
|
||||
|
||||
@@ -178,50 +186,246 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Knowledge Graph</title>
|
||||
<style>
|
||||
html,body{margin:0;height:100%;background:#0b1020;color:#e7ecff;font-family:system-ui,Segoe UI,Arial,sans-serif;overflow:hidden}
|
||||
#hud{position:fixed;top:10px;left:12px;font-size:12px;opacity:.8;z-index:2}
|
||||
#tip{position:fixed;padding:4px 8px;background:#111a36;border:1px solid #2a3a66;border-radius:4px;font-size:12px;pointer-events:none;display:none;z-index:3}
|
||||
canvas{display:block}
|
||||
#empty{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;opacity:.6}
|
||||
:root{
|
||||
--bg:#0b1020; --fg:#e7ecff; --sub:rgba(231,236,255,.78);
|
||||
--panel:rgba(17,26,54,.92); --panel-border:#2a3a66;
|
||||
}
|
||||
html.light{
|
||||
--bg:#f6f8fc; --fg:#1f2433; --sub:rgba(31,36,51,.72);
|
||||
--panel:rgba(255,255,255,.94); --panel-border:#d7deec;
|
||||
}
|
||||
html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);font-family:system-ui,Segoe UI,Arial,sans-serif;overflow:hidden}
|
||||
canvas{display:block;cursor:grab}
|
||||
canvas.grabbing{cursor:grabbing}
|
||||
.card{position:fixed;background:var(--panel);border:1px solid var(--panel-border);border-radius:8px;font-size:12px;z-index:3;backdrop-filter:blur(6px)}
|
||||
#hud{top:10px;left:12px;padding:6px 10px;color:var(--sub);z-index:2}
|
||||
#legend{top:10px;right:12px;padding:8px 10px;display:flex;flex-direction:column;gap:6px}
|
||||
#legend .row{display:flex;align-items:center;gap:6px;color:var(--sub)}
|
||||
#legend .dot{width:10px;height:10px;border-radius:50%}
|
||||
#tip{padding:4px 8px;pointer-events:none;display:none}
|
||||
#panel{left:12px;bottom:12px;max-width:300px;padding:12px 14px;display:none}
|
||||
#panel h3{margin:0 0 6px;font-size:14px;line-height:1.3;word-break:break-word}
|
||||
#panel .meta{color:var(--sub);margin-bottom:8px}
|
||||
#panel .nb{display:flex;flex-wrap:wrap;gap:5px;max-height:120px;overflow:auto}
|
||||
#panel .nb span{padding:2px 7px;border:1px solid var(--panel-border);border-radius:999px;line-height:1.4;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
#panel .close{position:absolute;top:8px;right:10px;cursor:pointer;color:var(--sub);font-size:14px}
|
||||
#reset{bottom:12px;right:12px;padding:7px 12px;cursor:pointer;color:var(--fg);user-select:none}
|
||||
#reset:hover{filter:brightness(1.1)}
|
||||
#empty{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;opacity:.55}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="hud"></div>
|
||||
<div id="tip"></div>
|
||||
<div id="hud" class="card"></div>
|
||||
<div id="legend" class="card">
|
||||
<div class="row"><span class="dot" id="lg-note"></span>知识笔记</div>
|
||||
<div class="row"><span class="dot" id="lg-entity"></span>实体</div>
|
||||
</div>
|
||||
<div id="tip" class="card"></div>
|
||||
<div id="panel" class="card"><span class="close" id="panel-close">×</span><h3></h3><div class="meta"></div><div class="nb"></div></div>
|
||||
<div id="reset" class="card">重置视图</div>
|
||||
<canvas id="c"></canvas>
|
||||
<script>
|
||||
const GRAPH = __GRAPH_DATA__;
|
||||
// Theme is supplied by the embedding iframe (it can't read the parent's CSS).
|
||||
const THEME = (new URLSearchParams(location.search).get('theme') === 'light') ? 'light' : 'dark';
|
||||
const PAL = {
|
||||
dark: {note:'#5b8cff', entity:'#ffb454', label:'rgba(231,236,255,.92)', edge:[140,165,255], halo:'rgba(11,16,32,.55)'},
|
||||
light: {note:'#2f6df6', entity:'#d97706', label:'rgba(31,36,51,.92)', edge:[70,110,200], halo:'rgba(255,255,255,.65)'},
|
||||
}[THEME];
|
||||
document.documentElement.classList.toggle('light', THEME === 'light');
|
||||
document.getElementById('lg-note').style.background = PAL.note;
|
||||
document.getElementById('lg-entity').style.background = PAL.entity;
|
||||
|
||||
const canvas = document.getElementById('c'), ctx = canvas.getContext('2d');
|
||||
const hud = document.getElementById('hud'), tip = document.getElementById('tip');
|
||||
let W=0,H=0;
|
||||
function resize(){W=canvas.width=innerWidth;H=canvas.height=innerHeight;}
|
||||
addEventListener('resize',resize);resize();
|
||||
const panel = document.getElementById('panel');
|
||||
let W=0,H=0,DPR=Math.min(window.devicePixelRatio||1,2);
|
||||
function resize(){W=innerWidth;H=innerHeight;canvas.width=W*DPR;canvas.height=H*DPR;canvas.style.width=W+'px';canvas.style.height=H+'px';}
|
||||
addEventListener('resize',()=>{resize();bump();});resize();
|
||||
|
||||
hud.textContent = GRAPH.nodes.length + ' 个知识节点 · ' + GRAPH.edges.length + ' 条关系';
|
||||
if(!GRAPH.nodes.length){document.body.insertAdjacentHTML('beforeend','<div id=\"empty\">暂无知识图谱数据</div>');}
|
||||
const idx={};GRAPH.nodes.forEach((n,i)=>{idx[n.id]=n;n.x=W/2+Math.cos(i)*200*Math.random()+(Math.random()-.5)*200;n.y=H/2+Math.sin(i)*200*Math.random()+(Math.random()-.5)*200;n.vx=0;n.vy=0;});
|
||||
const edges=GRAPH.edges.map(e=>({s:idx[e.source],t:idx[e.target],w:e.w||e.weight||1})).filter(e=>e.s&&e.t);
|
||||
let cam={x:0,y:0,k:1},drag=null,pan=null;
|
||||
function step(){
|
||||
for(const n of GRAPH.nodes){for(const m of GRAPH.nodes){if(n===m)continue;let dx=n.x-m.x,dy=n.y-m.y,d2=dx*dx+dy*dy+0.01,f=1200/d2;n.vx+=dx*f*0.0006;n.vy+=dy*f*0.0006;}}
|
||||
for(const e of edges){let dx=e.t.x-e.s.x,dy=e.t.y-e.s.y,d=Math.sqrt(dx*dx+dy*dy)||1,f=(d-110)*0.01;e.s.vx+=dx/d*f;e.s.vy+=dy/d*f;e.t.vx-=dx/d*f;e.t.vy-=dy/d*f;}
|
||||
for(const n of GRAPH.nodes){n.vx+=(W/2-n.x)*0.0008;n.vy+=(H/2-n.y)*0.0008;n.x+=n.vx*=0.85;n.y+=n.vy*=0.85;}
|
||||
if(!GRAPH.nodes.length){document.body.insertAdjacentHTML('beforeend','<div id="empty">暂无知识图谱数据</div>');}
|
||||
|
||||
// --- index, adjacency, degree, node sizing -------------------------------
|
||||
const idx={}, adj={};
|
||||
GRAPH.nodes.forEach(n=>{idx[n.id]=n;adj[n.id]=new Set();});
|
||||
const edges=[];
|
||||
for(const e of GRAPH.edges){
|
||||
const s=idx[e.source], t=idx[e.target];
|
||||
if(!s||!t||s===t) continue;
|
||||
edges.push({s,t,w:e.weight||e.w||1,label:e.label||''});
|
||||
adj[e.source].add(e.target); adj[e.target].add(e.source);
|
||||
}
|
||||
function draw(){
|
||||
ctx.setTransform(cam.k,0,0,cam.k,cam.x,cam.y);ctx.clearRect(-cam.x/cam.k,-cam.y/cam.k,W/cam.k,H/cam.k);
|
||||
ctx.strokeStyle='rgba(120,150,255,.25)';
|
||||
for(const e of edges){ctx.lineWidth=e.w*0.6;ctx.beginPath();ctx.moveTo(e.s.x,e.s.y);ctx.lineTo(e.t.x,e.t.y);ctx.stroke();}
|
||||
for(const n of GRAPH.nodes){const r=6;ctx.fillStyle='#5b8cff';ctx.beginPath();ctx.arc(n.x,n.y,r,0,7);ctx.fill();
|
||||
ctx.fillStyle='rgba(231,236,255,.85)';ctx.font='11px system-ui';ctx.fillText((n.label||'').slice(0,18),n.x+9,n.y+3);}
|
||||
const N=GRAPH.nodes.length;
|
||||
GRAPH.nodes.forEach((n,i)=>{
|
||||
n.deg=adj[n.id].size;
|
||||
n.r=4.5+Math.min(11,Math.sqrt(n.deg)*2.4); // hubs are visibly bigger → easier to hit
|
||||
const a=i*2.399963, rad=18*Math.sqrt(i); // phyllotaxis spread = no initial overlap
|
||||
n.x=Math.cos(a)*rad; n.y=Math.sin(a)*rad; n.vx=0; n.vy=0;
|
||||
});
|
||||
// "Hub" nodes keep their label even when zoomed out; the rest declutter.
|
||||
const degs=GRAPH.nodes.map(n=>n.deg).sort((a,b)=>b-a);
|
||||
const HUB=Math.max(3,(degs[Math.floor(N*0.08)]||3));
|
||||
|
||||
// --- force layout that cools down and FREEZES (frozen graph = clickable) --
|
||||
const REP=9000, LINK=92, CENTER=0.0016;
|
||||
let alpha=1, drag=null;
|
||||
function physics(){
|
||||
for(let i=0;i<N;i++){
|
||||
const n=GRAPH.nodes[i];
|
||||
for(let j=i+1;j<N;j++){
|
||||
const m=GRAPH.nodes[j];
|
||||
let dx=n.x-m.x,dy=n.y-m.y,d2=dx*dx+dy*dy+0.01,f=REP/d2;
|
||||
let fx=dx*f,fy=dy*f; n.vx+=fx;n.vy+=fy;m.vx-=fx;m.vy-=fy;
|
||||
}
|
||||
}
|
||||
for(const e of edges){
|
||||
let dx=e.t.x-e.s.x,dy=e.t.y-e.s.y,d=Math.hypot(dx,dy)||1,f=(d-LINK)*0.02;
|
||||
e.s.vx+=dx/d*f;e.s.vy+=dy/d*f;e.t.vx-=dx/d*f;e.t.vy-=dy/d*f;
|
||||
}
|
||||
for(const n of GRAPH.nodes){
|
||||
if(n===drag){n.vx=n.vy=0;continue;}
|
||||
n.vx-=n.x*CENTER; n.vy-=n.y*CENTER;
|
||||
n.x+=(n.vx*=0.82)*alpha; n.y+=(n.vy*=0.82)*alpha;
|
||||
}
|
||||
alpha*=0.985;
|
||||
}
|
||||
function loop(){step();draw();requestAnimationFrame(loop);}loop();
|
||||
|
||||
// --- camera / fit-to-view -------------------------------------------------
|
||||
let cam={x:0,y:0,k:1};
|
||||
function toWorld(x,y){return {x:(x-cam.x)/cam.k,y:(y-cam.y)/cam.k};}
|
||||
canvas.addEventListener('mousedown',ev=>{const p=toWorld(ev.clientX,ev.clientY);let hit=null;
|
||||
for(const n of GRAPH.nodes){if((n.x-p.x)**2+(n.y-p.y)**2<100){hit=n;break;}}if(hit)drag=hit;else pan={x:ev.clientX,y:ev.clientY,cx:cam.x,cy:cam.y};});
|
||||
addEventListener('mousemove',ev=>{const p=toWorld(ev.clientX,ev.clientY);if(drag){drag.x=p.x;drag.y=p.y;drag.vx=drag.vy=0;}else if(pan){cam.x=pan.cx+ev.clientX-pan.x;cam.y=pan.cy+ev.clientY-pan.y;}
|
||||
let hov=null;for(const n of GRAPH.nodes){if((n.x-p.x)**2+(n.y-p.y)**2<100){hov=n;break;}}
|
||||
if(hov){tip.style.display='block';tip.style.left=(ev.clientX+12)+'px';tip.style.top=(ev.clientY+12)+'px';tip.textContent=hov.label;}else{tip.style.display='none';}});
|
||||
addEventListener('mouseup',()=>{drag=null;pan=null;});
|
||||
canvas.addEventListener('wheel',ev=>{ev.preventDefault();const s=ev.deltaY<0?1.1:0.9;cam.k*=s;cam.x=ev.clientX-(ev.clientX-cam.x)*s;cam.y=ev.clientY-(ev.clientY-cam.y)*s;},{passive:false});
|
||||
function fitView(){
|
||||
if(!N){return;}
|
||||
let a=1e9,b=1e9,c=-1e9,d=-1e9;
|
||||
for(const n of GRAPH.nodes){a=Math.min(a,n.x);b=Math.min(b,n.y);c=Math.max(c,n.x);d=Math.max(d,n.y);}
|
||||
const gw=Math.max(c-a,1),gh=Math.max(d-b,1),pad=90;
|
||||
cam.k=Math.max(0.12,Math.min(2.2,Math.min((W-pad)/gw,(H-pad)/gh)));
|
||||
cam.x=W/2-((a+c)/2)*cam.k; cam.y=H/2-((b+d)/2)*cam.k;
|
||||
}
|
||||
let fitted=false;
|
||||
function bump(){alpha=Math.max(alpha,0.35);} // re-warm the sim on interaction
|
||||
|
||||
// --- focus / hover state --------------------------------------------------
|
||||
let hoverNode=null, focusNode=null;
|
||||
function active(){return focusNode||hoverNode;}
|
||||
function neighbors(n){return n?adj[n.id]:null;}
|
||||
|
||||
function draw(){
|
||||
ctx.setTransform(DPR,0,0,DPR,0,0);
|
||||
ctx.clearRect(0,0,W,H);
|
||||
ctx.translate(cam.x,cam.y); ctx.scale(cam.k,cam.k);
|
||||
const act=active(), nb=neighbors(act), zoomed=cam.k>1.35;
|
||||
const [er,eg,eb]=PAL.edge;
|
||||
|
||||
// edges: dim by default; when a node is focused, only its edges stay bright.
|
||||
for(const e of edges){
|
||||
const lit = act && (e.s===act||e.t===act);
|
||||
const op = act ? (lit?0.9:0.05) : Math.min(0.5,0.12+e.w*0.05);
|
||||
ctx.strokeStyle='rgba('+er+','+eg+','+eb+','+op+')';
|
||||
ctx.lineWidth=(lit?1.6:0.8)*Math.max(0.6,e.w*0.55)/Math.sqrt(cam.k);
|
||||
ctx.beginPath();ctx.moveTo(e.s.x,e.s.y);ctx.lineTo(e.t.x,e.t.y);ctx.stroke();
|
||||
}
|
||||
// nodes
|
||||
for(const n of GRAPH.nodes){
|
||||
const dim = act && n!==act && !(nb&&nb.has(n.id));
|
||||
ctx.globalAlpha = dim?0.22:1;
|
||||
ctx.fillStyle = n.type==='entity'?PAL.entity:PAL.note;
|
||||
ctx.beginPath();ctx.arc(n.x,n.y,n.r,0,6.2832);ctx.fill();
|
||||
if(n===act){ctx.lineWidth=2/cam.k;ctx.strokeStyle=PAL.label;ctx.stroke();}
|
||||
ctx.globalAlpha=1;
|
||||
}
|
||||
// labels: only for focus/neighbors, hubs, or when zoomed in — kills clutter.
|
||||
ctx.textBaseline='middle';
|
||||
for(const n of GRAPH.nodes){
|
||||
const show = act ? (n===act||(nb&&nb.has(n.id))) : (zoomed||n.deg>=HUB);
|
||||
if(!show) continue;
|
||||
const txt=(n.label||'').slice(0,20);
|
||||
if(!txt) continue;
|
||||
ctx.font=(n===act?'bold ':'')+(11/cam.k)+'px system-ui';
|
||||
const tx=n.x+n.r+4/cam.k, ty=n.y;
|
||||
ctx.lineWidth=3/cam.k; ctx.strokeStyle=PAL.halo;
|
||||
ctx.strokeText(txt,tx,ty);
|
||||
ctx.fillStyle=PAL.label; ctx.fillText(txt,tx,ty);
|
||||
}
|
||||
}
|
||||
|
||||
function loop(){
|
||||
if(alpha>0.02 || drag){physics();} else if(!fitted){fitView();fitted=true;}
|
||||
draw();
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
// Pre-settle a few iterations so the first paint is already roughly laid out.
|
||||
for(let i=0;i<60;i++)physics();
|
||||
fitView();
|
||||
loop();
|
||||
|
||||
// --- picking & interaction ------------------------------------------------
|
||||
function pick(p){
|
||||
let best=null,bd=1e9;
|
||||
for(const n of GRAPH.nodes){
|
||||
const dx=n.x-p.x,dy=n.y-p.y,d=dx*dx+dy*dy, hit=(n.r+8/cam.k);
|
||||
if(d<hit*hit && d<bd){bd=d;best=n;}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
let pan=null, downXY=null, moved=false;
|
||||
canvas.addEventListener('mousedown',ev=>{
|
||||
const p=toWorld(ev.clientX,ev.clientY), hit=pick(p);
|
||||
downXY={x:ev.clientX,y:ev.clientY,hit}; moved=false;
|
||||
if(hit){drag=hit;} else {pan={x:ev.clientX,y:ev.clientY,cx:cam.x,cy:cam.y};}
|
||||
canvas.classList.add('grabbing');
|
||||
});
|
||||
addEventListener('mousemove',ev=>{
|
||||
const p=toWorld(ev.clientX,ev.clientY);
|
||||
if(downXY && (Math.abs(ev.clientX-downXY.x)>3||Math.abs(ev.clientY-downXY.y)>3)) moved=true;
|
||||
if(drag){drag.x=p.x;drag.y=p.y;drag.vx=drag.vy=0;bump();}
|
||||
else if(pan){cam.x=pan.cx+ev.clientX-pan.x;cam.y=pan.cy+ev.clientY-pan.y;}
|
||||
hoverNode=drag||pan?hoverNode:pick(p);
|
||||
if(hoverNode && !drag && !pan){
|
||||
tip.style.display='block';
|
||||
tip.style.left=Math.min(ev.clientX+14,W-220)+'px';
|
||||
tip.style.top=(ev.clientY+14)+'px';
|
||||
tip.textContent=hoverNode.label||'';
|
||||
} else { tip.style.display='none'; }
|
||||
});
|
||||
addEventListener('mouseup',ev=>{
|
||||
if(downXY && !moved){ // a click, not a drag → toggle focus / detail panel
|
||||
focusNode = (downXY.hit && downXY.hit!==focusNode) ? downXY.hit : null;
|
||||
renderPanel();
|
||||
}
|
||||
drag=null;pan=null;downXY=null;canvas.classList.remove('grabbing');
|
||||
});
|
||||
canvas.addEventListener('wheel',ev=>{
|
||||
ev.preventDefault();
|
||||
const s=ev.deltaY<0?1.12:0.89;
|
||||
const nk=Math.max(0.08,Math.min(6,cam.k*s)), r=nk/cam.k;
|
||||
cam.x=ev.clientX-(ev.clientX-cam.x)*r; cam.y=ev.clientY-(ev.clientY-cam.y)*r; cam.k=nk;
|
||||
},{passive:false});
|
||||
|
||||
// --- detail panel ---------------------------------------------------------
|
||||
const TYPE_LABEL={note:'知识笔记',entity:'实体'};
|
||||
function renderPanel(){
|
||||
if(!focusNode){panel.style.display='none';return;}
|
||||
const n=focusNode;
|
||||
panel.querySelector('h3').textContent=n.label||n.id;
|
||||
const cat=n.category?' · '+n.category:'';
|
||||
panel.querySelector('.meta').textContent=(TYPE_LABEL[n.type]||n.type||'节点')+cat+' · '+n.deg+' 条关系';
|
||||
const nb=panel.querySelector('.nb'); nb.innerHTML='';
|
||||
let count=0;
|
||||
for(const id of adj[n.id]){
|
||||
if(count++>=24)break;
|
||||
const m=idx[id]; if(!m)continue;
|
||||
const s=document.createElement('span'); s.textContent=m.label||id;
|
||||
s.title=m.label||id;
|
||||
s.style.cursor='pointer';
|
||||
s.onclick=()=>{focusNode=m;renderPanel();};
|
||||
nb.appendChild(s);
|
||||
}
|
||||
if(!adj[n.id].size){const s=document.createElement('span');s.textContent='(无关联)';s.style.opacity=.6;nb.appendChild(s);}
|
||||
panel.style.display='block';
|
||||
}
|
||||
document.getElementById('panel-close').onclick=()=>{focusNode=null;renderPanel();};
|
||||
document.getElementById('reset').onclick=()=>{focusNode=null;renderPanel();fitView();};
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
+146
-13
@@ -84,6 +84,46 @@ references(外部来源汇总)、decision(架构/设计决策)、journal
|
||||
}"""
|
||||
|
||||
|
||||
_DOC_SYSTEM_PROMPT = """\
|
||||
你是知识库整理专家,遵循 obsidian-wiki 的 wiki-capture 规范,把一篇**文档**提炼成\
|
||||
**陈述式知识(declarative knowledge)**,而不是照抄原文。
|
||||
|
||||
要求:
|
||||
- 只保留高价值内容:核心结论、关键事实、定义、流程、数据、决策与其依据。
|
||||
- 丢弃:目录、页眉页脚、版权声明、重复的样板文字、无意义的排版噪声。
|
||||
- 用自己的话重组为结构化知识,不要逐段复制原文。
|
||||
- 推断内容用 ^[inferred] 标记,不确定/冲突用 ^[ambiguous] 标记。
|
||||
- 分类 category 取值之一:synthesis(分析/方案/结论)、concepts(概念/框架)、\
|
||||
references(外部来源汇总)、decision(架构/设计决策)、journal(纪要)。
|
||||
- 用中文输出。
|
||||
|
||||
一篇文档可能只讲一个主题,也可能覆盖多个相互独立的主题。请把它拆成 1~4 页知识:
|
||||
- 默认只产出 **1 页**;只有当文档明确包含**多个彼此独立、可各自成文**的主题时才拆成多页。
|
||||
- 每一页聚焦单一主题,标题与 summary 必须彼此区分。
|
||||
- 文档没有可沉淀的知识时返回空的 notes 数组。
|
||||
|
||||
只输出一个 JSON 对象,不要任何额外文字或 Markdown 代码块,结构与对话提炼一致:
|
||||
{
|
||||
"should_ingest": true,
|
||||
"reason": "为什么值得/不值得沉淀",
|
||||
"notes": [
|
||||
{
|
||||
"title": "简洁的知识标题(不超过 40 字)",
|
||||
"summary": "1-2 句话概括这页知识",
|
||||
"category": "synthesis",
|
||||
"context": "文档背景或适用场景,1-3 句",
|
||||
"findings": ["结论/要点(陈述式,可含 ^[inferred])", "..."],
|
||||
"reasoning": "为什么成立、取舍、不确定性",
|
||||
"implications": ["后续建议/风险/下一步", "..."],
|
||||
"tags": ["标签1", "标签2"],
|
||||
"entities": [{"name": "实体名", "type": "technology|concept|person|org|product|other"}],
|
||||
"relations": [{"from": "实体A", "to": "实体B", "type": "uses|relates_to|part_of|depends_on"}],
|
||||
"confidence": 0.0
|
||||
}
|
||||
]
|
||||
}"""
|
||||
|
||||
|
||||
def _build_transcript(turns: list[dict[str, str]], sources: list[SourceDraft], *, max_chars: int) -> str:
|
||||
parts: list[str] = []
|
||||
for i, t in enumerate(turns, 1):
|
||||
@@ -171,21 +211,27 @@ def _notes_from_data(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
def _build_note_draft(
|
||||
note_data: dict[str, Any],
|
||||
*,
|
||||
thread_id: str,
|
||||
thread_title: str | None,
|
||||
turns: list[dict[str, str]],
|
||||
source_type: str,
|
||||
source_id: str | None,
|
||||
source_key: str,
|
||||
fallback_title: str,
|
||||
title_override: str | None,
|
||||
tags: list[str] | None,
|
||||
project_name: str | None,
|
||||
sources: list[SourceDraft],
|
||||
source_key_suffix: str = "",
|
||||
) -> NoteDraft:
|
||||
"""Render one note dict into a :class:`NoteDraft`."""
|
||||
"""Render one note dict into a :class:`NoteDraft`.
|
||||
|
||||
Source-agnostic: the caller supplies ``source_type`` / ``source_id`` /
|
||||
``source_key`` and a ``fallback_title`` (used when neither the user override
|
||||
nor the LLM produced a usable title), so both thread and document capture
|
||||
share one renderer.
|
||||
"""
|
||||
category = str(note_data.get("category") or "synthesis").strip().lower()
|
||||
if category not in _VALID_CATEGORIES:
|
||||
category = "synthesis"
|
||||
|
||||
final_title = (title_override or "").strip() or str(note_data.get("title") or "").strip() or extractor._auto_title(thread_title, turns)
|
||||
final_title = (title_override or "").strip() or str(note_data.get("title") or "").strip() or fallback_title
|
||||
summary = str(note_data.get("summary") or "").strip()
|
||||
related = extractor.related_links(project_name)
|
||||
body = _render_body(note_data, final_title, related, sources)
|
||||
@@ -210,15 +256,15 @@ def _build_note_draft(
|
||||
summary=summary or final_title,
|
||||
content_md=body,
|
||||
category=category,
|
||||
source_type="thread",
|
||||
source_id=thread_id,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
tags=base_tags,
|
||||
confidence=max(0.0, min(confidence, 1.0)),
|
||||
related=related,
|
||||
sources=sources,
|
||||
entities=[e for e in entities if isinstance(e, dict) and e.get("name")],
|
||||
relations=[r for r in relations if isinstance(r, dict) and r.get("from") and r.get("to")],
|
||||
source_key=f"thread:{thread_id}{source_key_suffix}",
|
||||
source_key=source_key,
|
||||
)
|
||||
|
||||
|
||||
@@ -293,6 +339,7 @@ async def extract_thread_llm_multi(
|
||||
else:
|
||||
note_dicts = note_dicts[: max(1, max_notes)]
|
||||
|
||||
fallback_title = extractor._auto_title(thread_title, turns)
|
||||
drafts: list[NoteDraft] = []
|
||||
for i, nd in enumerate(note_dicts):
|
||||
# The user-supplied title override only applies to a single-note result;
|
||||
@@ -302,14 +349,100 @@ async def extract_thread_llm_multi(
|
||||
drafts.append(
|
||||
_build_note_draft(
|
||||
nd,
|
||||
thread_id=thread_id,
|
||||
thread_title=thread_title,
|
||||
turns=turns,
|
||||
source_type="thread",
|
||||
source_id=thread_id,
|
||||
source_key=f"thread:{thread_id}{suffix}",
|
||||
fallback_title=fallback_title,
|
||||
title_override=override,
|
||||
tags=tags,
|
||||
project_name=project_name,
|
||||
sources=sources,
|
||||
)
|
||||
)
|
||||
return drafts
|
||||
|
||||
|
||||
async def extract_document_llm_multi(
|
||||
text: str,
|
||||
*,
|
||||
doc_id: str,
|
||||
doc_title: str | None = None,
|
||||
title: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
project_name: str | None = "zncm",
|
||||
sources: list[SourceDraft] | None = None,
|
||||
model_name: str | None = None,
|
||||
max_input_chars: int = 12000,
|
||||
allow_multi: bool = True,
|
||||
max_notes: int = 4,
|
||||
) -> list[NoteDraft]:
|
||||
"""Distill an imported **document** into one *or several* knowledge notes.
|
||||
|
||||
Mirrors :func:`extract_thread_llm_multi` but takes raw document text (already
|
||||
converted to Markdown/plain text) instead of a conversation. Returns ``[]``
|
||||
when the LLM path fails (callers fall back to a verbatim note); raises
|
||||
:class:`LLMRejectedIngest` when the model deliberately judges the document
|
||||
worthless. Resulting notes carry ``source_type="file"``.
|
||||
"""
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
sources = sources or []
|
||||
|
||||
try:
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
|
||||
from deerflow.models import create_chat_model
|
||||
|
||||
model = create_chat_model(name=model_name or None, thinking_enabled=False)
|
||||
body = text if len(text) <= max_input_chars else text[:max_input_chars] + "\n…(已截断)"
|
||||
header = f"文档标题:{doc_title}\n\n" if doc_title else ""
|
||||
prompt = [
|
||||
SystemMessage(content=_DOC_SYSTEM_PROMPT + "\n\n" + WIKI_CAPTURE_GUIDANCE),
|
||||
HumanMessage(content=f"以下是一篇文档,请按规范提炼为知识 JSON:\n\n{header}{body}"),
|
||||
]
|
||||
response = await model.ainvoke(prompt)
|
||||
raw = response.content if isinstance(response.content, str) else str(response.content)
|
||||
data = _parse_json(raw)
|
||||
except Exception:
|
||||
logger.exception("[knowledge] document %s — LLM extraction call failed; using rule-based fallback", doc_id)
|
||||
return []
|
||||
|
||||
if not data:
|
||||
logger.warning("[knowledge] document %s — LLM returned no parsable JSON; using rule-based fallback", doc_id)
|
||||
return []
|
||||
|
||||
note_dicts = _notes_from_data(data)
|
||||
if not note_dicts:
|
||||
reason = str(data.get("reason") or "")[:200]
|
||||
if _is_falsey(data.get("should_ingest")):
|
||||
logger.warning("[knowledge] document %s SKIPPED — model judged not worth ingesting: %s", doc_id, reason)
|
||||
raise LLMRejectedIngest(reason or "no knowledge value")
|
||||
logger.warning(
|
||||
"[knowledge] document %s — LLM produced no notes (should_ingest=%s, reason=%r); using rule-based fallback",
|
||||
doc_id,
|
||||
data.get("should_ingest"),
|
||||
reason,
|
||||
)
|
||||
return []
|
||||
note_dicts = note_dicts[:1] if not allow_multi else note_dicts[: max(1, max_notes)]
|
||||
|
||||
fallback_title = (doc_title or title or "文档知识").strip() or "文档知识"
|
||||
drafts: list[NoteDraft] = []
|
||||
for i, nd in enumerate(note_dicts):
|
||||
override = title if (len(note_dicts) == 1) else None
|
||||
suffix = "" if len(note_dicts) == 1 else f"#{i}"
|
||||
drafts.append(
|
||||
_build_note_draft(
|
||||
nd,
|
||||
source_type="file",
|
||||
source_id=doc_id,
|
||||
source_key=f"file:{doc_id}{suffix}",
|
||||
fallback_title=fallback_title,
|
||||
title_override=override,
|
||||
tags=tags,
|
||||
project_name=project_name,
|
||||
sources=sources,
|
||||
source_key_suffix=suffix,
|
||||
)
|
||||
)
|
||||
return drafts
|
||||
|
||||
@@ -394,6 +394,129 @@ class KnowledgeService:
|
||||
)
|
||||
return await self._persist_draft(draft, created_by=created_by)
|
||||
|
||||
def _verbatim_file_draft(self, body: str, filename: str, *, title: str | None, tags: list[str] | None, doc_id: str) -> NoteDraft:
|
||||
"""Build a verbatim fallback note from a file when LLM distillation fails.
|
||||
|
||||
Lands as a ``draft`` (like the thread fallback) since it is the raw
|
||||
document, not distilled knowledge.
|
||||
"""
|
||||
base_tags = list(tags or [])
|
||||
for default in (self.project_name, "knowledge-base"):
|
||||
if default and default not in base_tags:
|
||||
base_tags.append(default)
|
||||
name = (title or Path(filename).stem or "文档知识").strip() or "文档知识"
|
||||
summary = next((ln.strip() for ln in body.splitlines() if ln.strip()), name)[:280]
|
||||
return NoteDraft(
|
||||
title=name,
|
||||
summary=summary,
|
||||
content_md=body,
|
||||
category="references",
|
||||
source_type="file",
|
||||
source_id=doc_id,
|
||||
status="draft",
|
||||
tags=base_tags,
|
||||
confidence=0.4,
|
||||
related=extractor.related_links(self.project_name),
|
||||
source_key=f"file:{doc_id}",
|
||||
)
|
||||
|
||||
async def capture_file(
|
||||
self,
|
||||
*,
|
||||
content_md: str,
|
||||
filename: str,
|
||||
title: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
status: str = "approved",
|
||||
created_by: str | None = None,
|
||||
model_name: str | None = None,
|
||||
skip_existing: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Import a file (already converted to Markdown/text) into knowledge notes.
|
||||
|
||||
Runs the same wiki-capture LLM distillation as thread capture, so an
|
||||
uploaded document becomes one or more declarative wiki notes in the vault.
|
||||
Falls back to a verbatim ``draft`` note when the LLM is disabled or returns
|
||||
nothing usable. Returns ``{"note", "created", "notes", "llm_fallback"}``;
|
||||
raises :class:`EmptyThreadError` when there is nothing to keep.
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
text = (content_md or "").strip()
|
||||
if not text:
|
||||
raise EmptyThreadError(f"File {filename} is empty")
|
||||
# Strip any frontmatter the document carries; the vault writer adds its own.
|
||||
_, parsed = MarkdownVault.parse(text)
|
||||
body = (parsed or text).strip()
|
||||
if not body:
|
||||
raise EmptyThreadError(f"File {filename} has no body content")
|
||||
# Stable id from name + size so re-importing the same file can be skipped.
|
||||
doc_id = hashlib.sha1(f"{filename}:{len(body)}".encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
logger.info(
|
||||
"[knowledge] importing file '%s' (chars=%d, doc_id=%s, llm_extract=%s, skip_existing=%s)",
|
||||
filename,
|
||||
len(body),
|
||||
doc_id,
|
||||
self.config.llm_extract_enabled,
|
||||
skip_existing,
|
||||
)
|
||||
|
||||
if skip_existing:
|
||||
existing = await self.repo.find_by_source("file", doc_id)
|
||||
if existing:
|
||||
logger.info("[knowledge] file '%s' already imported (note %s); skipping", filename, existing.get("id"))
|
||||
return {"note": existing, "created": False, "notes": [existing], "llm_fallback": False}
|
||||
|
||||
drafts: list[NoteDraft] = []
|
||||
llm_fallback = False
|
||||
if self.config.llm_extract_enabled:
|
||||
try:
|
||||
drafts = await llm_extractor.extract_document_llm_multi(
|
||||
body,
|
||||
doc_id=doc_id,
|
||||
doc_title=title or Path(filename).stem,
|
||||
title=title,
|
||||
tags=tags,
|
||||
project_name=self.project_name,
|
||||
model_name=model_name or self.config.extract_model_name,
|
||||
max_input_chars=self.config.extract_max_input_chars,
|
||||
allow_multi=self.config.multi_note_enabled,
|
||||
)
|
||||
except llm_extractor.LLMRejectedIngest as exc:
|
||||
raise EmptyThreadError(f"File {filename} judged not worth ingesting: {exc}") from exc
|
||||
if not drafts:
|
||||
llm_fallback = True
|
||||
if not drafts:
|
||||
drafts = [self._verbatim_file_draft(body, filename, title=title, tags=tags, doc_id=doc_id)]
|
||||
|
||||
effective_status = status
|
||||
if llm_fallback and normalize_status(status) == "approved":
|
||||
effective_status = "draft"
|
||||
normalized = normalize_status(effective_status)
|
||||
for draft in drafts:
|
||||
draft.status = normalized
|
||||
|
||||
# Cross-link sibling notes split from the same document (resolved by title
|
||||
# once all are persisted), mirroring multi-note thread capture.
|
||||
if len(drafts) > 1:
|
||||
for i, draft in enumerate(drafts):
|
||||
siblings = [f"[[{o.title}]]" for j, o in enumerate(drafts) if j != i and o.title]
|
||||
if siblings:
|
||||
draft.content_md = _inject_related_notes(draft.content_md, siblings)
|
||||
draft.related = list(dict.fromkeys([*siblings, *draft.related]))
|
||||
|
||||
notes = [await self._persist_draft(draft, created_by=created_by) for draft in drafts]
|
||||
logger.info(
|
||||
"[knowledge] file '%s' imported → %d note(s) [%s], status=%s, llm_fallback=%s",
|
||||
filename,
|
||||
len(notes),
|
||||
", ".join(n.get("title", "") for n in notes),
|
||||
normalized,
|
||||
llm_fallback,
|
||||
)
|
||||
return {"note": notes[0], "created": True, "notes": notes, "llm_fallback": llm_fallback}
|
||||
|
||||
# -- CRUD -------------------------------------------------------------
|
||||
async def list_notes(
|
||||
self,
|
||||
|
||||
Reference in new issue
Block a user