feat: 知识库文件夹/模板、技能压缩设置与摘要窗口调优

- 知识库新增文件夹与模板支持(模型/仓储/服务/迁移),前端知识管理组件
- 岗位管理模型与接口完善
- 技能新增压缩设置页与相关存储/工具能力
- 系统设置扩展配置项
- 摘要中间件 keep 窗口改用 token 预算(16000),减少重复压缩

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-16 15:23:54 +08:00
1 parent e6662cf673
commit 5ebbb82d66
54 files changed
+2658 -100

No files matched your search

@@ -0,0 +1,429 @@
import { Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
useExtractTemplateActions,
useExtractTemplates,
useFolderActions,
useKnowledgeFolders,
} from "@/core/knowledge/hooks";
import type {
ExtractTemplateScope,
KnowledgeExtractTemplate,
} from "@/core/knowledge/types";
import { cn } from "@/lib/utils";
const NONE = "__none__";
/** A dropdown of managed directories (+ an "unfiled" option). */
export function FolderSelect({
value,
onChange,
disabled,
triggerClassName,
allowNone = true,
}: {
value: string | null;
onChange: (v: string | null) => void;
disabled?: boolean;
triggerClassName?: string;
allowNone?: boolean;
}) {
const { data: folders = [] } = useKnowledgeFolders();
return (
<Select
value={value ?? NONE}
onValueChange={(v) => onChange(v === NONE ? null : v)}
disabled={disabled}
>
<SelectTrigger className={cn("h-9", triggerClassName)}>
<SelectValue placeholder="选择目录" />
</SelectTrigger>
<SelectContent>
{allowNone ? <SelectItem value={NONE}>(不归类)</SelectItem> : null}
{folders.map((f) => (
<SelectItem key={f.id} value={f.path}>
{f.path}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
/** A dropdown of enabled extraction templates applicable to a scope. */
export function TemplateSelect({
value,
onChange,
scope,
disabled,
triggerClassName,
}: {
value: string | null;
onChange: (v: string | null) => void;
scope: ExtractTemplateScope;
disabled?: boolean;
triggerClassName?: string;
}) {
const { data: templates = [] } = useExtractTemplates(scope, true);
return (
<Select
value={value ?? NONE}
onValueChange={(v) => onChange(v === NONE ? null : v)}
disabled={disabled}
>
<SelectTrigger className={cn("h-9", triggerClassName)}>
<SelectValue placeholder="默认模板" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>默认模板</SelectItem>
{templates.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
{t.is_default ? " · 默认" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
/** Create / rename / delete knowledge directories. */
export function FolderManageDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { data: folders = [], isLoading } = useKnowledgeFolders();
const { create, rename, remove } = useFolderActions();
const [newPath, setNewPath] = useState("");
const handleCreate = async () => {
const path = newPath.trim();
if (!path) {
toast.error("请输入目录路径");
return;
}
try {
await create.mutateAsync(path);
toast.success("目录已创建");
setNewPath("");
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建失败");
}
};
const handleRename = async (folderId: string, current: string) => {
const next = window.prompt("重命名 / 移动目录(输入新的完整路径,斜杠分隔)", current);
if (next === null) return;
try {
await rename.mutateAsync({ folderId, path: next });
toast.success("已更新");
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新失败");
}
};
const handleDelete = async (folderId: string, path: string) => {
if (!window.confirm(`删除目录「${path}」?其中的知识将变为未归类(不会被删除)。`)) return;
try {
await remove.mutateAsync({ folderId });
toast.success("目录已删除");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>目录管理</DialogTitle>
<DialogDescription>
自建知识目录,用斜杠分隔多级(如「投研/行业」)。删除目录只会让其中的知识变为未归类。
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
<Input
placeholder="新目录路径,如 投研/行业"
value={newPath}
onChange={(e) => setNewPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleCreate();
}}
/>
<Button onClick={() => void handleCreate()} disabled={create.isPending}>
<Plus className="size-4" /> 新建
</Button>
</div>
<ScrollArea className="max-h-[50vh]">
{isLoading ? (
<div className="text-muted-foreground p-3 text-sm">加载中...</div>
) : folders.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-sm">
还没有目录,先在上方新建一个。
</div>
) : (
<ul className="flex flex-col gap-1 p-1">
{folders.map((f) => (
<li
key={f.id}
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm dark:border-slate-700"
>
<span className="truncate font-mono text-[13px]">{f.path}</span>
<div className="flex shrink-0 items-center gap-1">
<Button size="sm" variant="ghost" onClick={() => void handleRename(f.id, f.path)}>
重命名
</Button>
<Button
size="sm"
variant="ghost"
className="text-red-600 hover:text-red-700"
onClick={() => void handleDelete(f.id, f.path)}
>
<Trash2 className="size-4" />
</Button>
</div>
</li>
))}
</ul>
)}
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
const SCOPE_LABELS: Record<ExtractTemplateScope, string> = {
thread: "对话",
document: "文档",
both: "通用",
};
const EMPTY_DRAFT = {
name: "",
scope: "both" as ExtractTemplateScope,
description: "",
system_prompt: "",
is_default: false,
enabled: true,
};
/** Create / edit / delete the editable extraction-prompt templates. */
export function ExtractTemplateManageDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { data: templates = [], isLoading } = useExtractTemplates();
const { create, update, remove } = useExtractTemplateActions();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [draft, setDraft] = useState({ ...EMPTY_DRAFT });
const selected = useMemo(
() => templates.find((t) => t.id === selectedId) ?? null,
[templates, selectedId],
);
// Load the selected template into the editable draft (or reset for "new").
useEffect(() => {
if (selected) {
setDraft({
name: selected.name,
scope: selected.scope,
description: selected.description ?? "",
system_prompt: selected.system_prompt,
is_default: selected.is_default,
enabled: selected.enabled,
});
} else {
setDraft({ ...EMPTY_DRAFT });
}
}, [selected]);
const startNew = () => setSelectedId(null);
const handleSave = async () => {
if (!draft.name.trim() || !draft.system_prompt.trim()) {
toast.error("请填写名称与提示词正文");
return;
}
try {
if (selectedId) {
await update.mutateAsync({ templateId: selectedId, data: draft });
toast.success("已保存");
} else {
const created = await create.mutateAsync(draft);
setSelectedId(created.id);
toast.success("模板已创建");
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const handleDelete = async (t: KnowledgeExtractTemplate) => {
if (!window.confirm(`删除模板「${t.name}」?`)) return;
try {
await remove.mutateAsync(t.id);
if (selectedId === t.id) setSelectedId(null);
toast.success("已删除");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>抽取提示词模板</DialogTitle>
<DialogDescription>
编辑驱动「知识沉淀」的提炼提示词,可新建多套抽取逻辑。沉淀对话/导入文件时可选用对应模板;未选则用该类型的默认模板。
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-[240px_minmax(0,1fr)] gap-4">
{/* template list */}
<div className="flex min-h-0 flex-col gap-2">
<Button size="sm" variant={selectedId === null ? "default" : "outline"} onClick={startNew}>
<Plus className="size-4" /> 新建模板
</Button>
<ScrollArea className="max-h-[50vh] rounded-md border dark:border-slate-700">
{isLoading ? (
<div className="text-muted-foreground p-3 text-sm">加载中...</div>
) : (
<ul className="flex flex-col">
{templates.map((t) => (
<li key={t.id}>
<button
type="button"
onClick={() => setSelectedId(t.id)}
className={cn(
"flex w-full items-center justify-between gap-2 border-b px-3 py-2 text-left text-sm last:border-b-0 dark:border-slate-800",
selectedId === t.id ? "bg-slate-100 dark:bg-slate-800" : "hover:bg-slate-50 dark:hover:bg-slate-800/50",
)}
>
<span className="min-w-0">
<span className="block truncate">{t.name}</span>
<span className="text-muted-foreground text-[11px]">
{SCOPE_LABELS[t.scope]}
{t.is_default ? " · 默认" : ""}
{t.enabled ? "" : " · 已停用"}
</span>
</span>
</button>
</li>
))}
</ul>
)}
</ScrollArea>
</div>
{/* editor */}
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Input
placeholder="模板名称"
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
/>
<Select
value={draft.scope}
onValueChange={(v) => setDraft((d) => ({ ...d, scope: v as ExtractTemplateScope }))}
>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="both">通用</SelectItem>
<SelectItem value="thread">对话</SelectItem>
<SelectItem value="document">文档</SelectItem>
</SelectContent>
</Select>
</div>
<Input
placeholder="简介(可选)"
value={draft.description}
onChange={(e) => setDraft((d) => ({ ...d, description: e.target.value }))}
/>
<Textarea
rows={16}
placeholder="提示词正文(system prompt)。这里直接控制提炼粒度——想保留更多信息,就在此放宽要求、要求结构化更详细的输出。"
value={draft.system_prompt}
onChange={(e) => setDraft((d) => ({ ...d, system_prompt: e.target.value }))}
className="font-mono text-xs"
/>
<div className="flex flex-wrap items-center gap-4 text-sm">
<label className="flex items-center gap-1.5">
<input
type="checkbox"
checked={draft.is_default}
onChange={(e) => setDraft((d) => ({ ...d, is_default: e.target.checked }))}
className="size-4"
/>
设为该类型默认
</label>
<label className="flex items-center gap-1.5">
<input
type="checkbox"
checked={draft.enabled}
onChange={(e) => setDraft((d) => ({ ...d, enabled: e.target.checked }))}
className="size-4"
/>
启用
</label>
<div className="ml-auto flex items-center gap-2">
{selected ? (
<Button
variant="ghost"
className="text-red-600 hover:text-red-700"
onClick={() => void handleDelete(selected)}
>
<Trash2 className="size-4" /> 删除
</Button>
) : null}
<Button onClick={() => void handleSave()} disabled={create.isPending || update.isPending}>
{selectedId ? "保存" : "创建"}
</Button>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -22,6 +22,7 @@ import type {
import { Breadcrumb } from "./Breadcrumb";
import { DocumentToolbar } from "./DocumentToolbar";
import { FolderSelect } from "./KnowledgeManage";
import { FloatingToc } from "./FloatingToc";
import { MarkdownViewer } from "./MarkdownViewer";
import { RelatedDocs } from "./RelatedDocs";
@@ -35,6 +36,7 @@ import {
downloadMarkdown,
extractToc,
formatDateTime,
localizeHeadings,
noteBreadcrumbSegments,
splitRelatedSection,
} from "./wikiUtils";
@@ -102,10 +104,14 @@ export function WikiContent({
// Pull the backend-injected `## Related` wikilinks out of the body so they
// render as a structured module; editing still works on the full source.
const { body, related } = useMemo(
// After extraction, localize the English section headings (Context / Finding
// / Reasoning / …) to Chinese for display — `extractToc` and the viewer share
// this transformed text so heading anchors stay consistent.
const { body: rawBody, related } = useMemo(
() => splitRelatedSection(note.content_md),
[note.content_md],
);
const body = useMemo(() => localizeHeadings(rawBody), [rawBody]);
const toc = useMemo(() => extractToc(body), [body]);
// The floating outline only shows h2/h3 — deeper levels are noise.
const outlineItems = useMemo(
@@ -177,6 +183,15 @@ export function WikiContent({
}
};
const handleMoveFolder = async (folder: string | null) => {
try {
await update.mutateAsync({ noteId: note.id, data: { folder } });
toast.success(folder ? `已移动到「${folder}」` : "已移出目录");
} catch (error) {
toast.error(error instanceof Error ? error.message : "移动失败");
}
};
const handleArchive = async () => {
try {
await remove.mutateAsync({ noteId: note.id });
@@ -277,6 +292,15 @@ export function WikiContent({
{note.created_at ? (
<MetaTag>创建:{formatDateTime(note.created_at).slice(0, 10)}</MetaTag>
) : null}
<span className="inline-flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
目录
<FolderSelect
value={note.folder ?? null}
onChange={(v) => void handleMoveFolder(v)}
disabled={update.isPending}
triggerClassName="h-6 w-[10rem] text-xs"
/>
</span>
{note.tags.map((tag) => (
<button
key={tag}
@@ -1,9 +1,11 @@
import {
Copy,
FolderTree,
Menu,
Network,
Plus,
RefreshCw,
SlidersHorizontal,
Upload,
} from "lucide-react";
@@ -48,6 +50,8 @@ export function WikiHeader({
onDedup,
onGraph,
onCreate,
onManageFolders,
onManageTemplates,
}: {
onToggleSidebar: () => void;
onImportFile: () => void;
@@ -55,6 +59,8 @@ export function WikiHeader({
onDedup: () => void;
onGraph: () => void;
onCreate: () => void;
onManageFolders: () => void;
onManageTemplates: () => void;
}) {
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">
@@ -83,6 +89,16 @@ export function WikiHeader({
label="批量导入"
onClick={onBatchImport}
/>
<HeaderButton
icon={<FolderTree className="size-4" />}
label="目录管理"
onClick={onManageFolders}
/>
<HeaderButton
icon={<SlidersHorizontal className="size-4" />}
label="抽取模板"
onClick={onManageTemplates}
/>
<HeaderButton
icon={<Copy className="size-4" />}
label="查重"
@@ -14,4 +14,6 @@ export interface TreeNode {
type: "folder" | "file";
children?: TreeNode[];
item?: KnowledgeNote;
/** Full slash-joined directory path (folder nodes only). */
folderPath?: string;
}
+92 -12
View File
@@ -19,6 +19,73 @@ export const STATUS_LABELS: Record<string, string> = {
const FALLBACK_FOLDER = "未分类";
/**
* Display labels for known English directory segments (the wiki-capture
* categories that historically drove the vault path). Keyed by the lowercased
* segment. User-created folders are arbitrary (usually Chinese) and simply don't
* match, so they pass through unchanged.
*/
const FOLDER_LABELS: Record<string, string> = {
synthesis: "分析综合",
concepts: "概念框架",
concept: "概念框架",
references: "外部来源",
reference: "外部来源",
decision: "决策记录",
journal: "会话纪要",
session: "会话纪要",
};
/** Map a known English directory segment to its Chinese display label (else unchanged). */
export function localizeFolderName(name: string): string {
return FOLDER_LABELS[name.trim().toLowerCase()] ?? name;
}
/**
* Canonical English wiki-capture section headings → Chinese display labels.
* Keyed by the lowercased, symbol-stripped heading text.
*/
const HEADING_LABELS: Record<string, string> = {
context: "背景",
"finding / decision": "结论 / 决策",
"finding/decision": "结论 / 决策",
finding: "结论",
decision: "决策",
reasoning: "推理依据",
implications: "影响 / 后续",
sources: "来源",
related: "相关知识",
merged: "已合并",
};
/**
* Replace the canonical English wiki-capture section headings (`## Context`,
* `## Finding / Decision`, …) with Chinese labels at render time. Operates on
* heading lines only (fenced code blocks are skipped) and matches the heading
* text case-insensitively, so **all historical notes are localized with no data
* migration**. Heading levels are preserved; because `extractToc` and the
* markdown viewer both receive this same transformed text, the slug/anchor ids
* stay consistent.
*/
export function localizeHeadings(markdown: string): string {
let inFence = false;
return markdown
.split("\n")
.map((line) => {
if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence;
return line;
}
if (inFence) return line;
const match = /^(#{1,4})\s+(.+?)\s*$/.exec(line);
if (!match) return line;
const key = match[2].replace(/[#`*_~]/g, "").trim().toLowerCase();
const zh = HEADING_LABELS[key];
return zh ? `${match[1]} ${zh}` : line;
})
.join("\n");
}
/** Stable slug for a heading: keeps CJK characters, collapses whitespace to dashes. */
export function slugify(text: string): string {
return text
@@ -119,16 +186,17 @@ export function splitRelatedSection(markdown: string): {
return { body, related };
}
/** Folder path segments for a note: vault_path directories, else its source-type group. */
/**
* Folder path segments for a note. The user-assigned ``folder`` (a slash-joined
* path) is authoritative; unfiled notes fall back to a source-type group so the
* tree is never empty. (The old behavior keyed off ``vault_path`` — the fixed
* ``projects/<project>/<category>`` scaffold — which the editable directory now
* replaces.)
*/
export function noteFolderSegments(note: KnowledgeNote): string[] {
const rawPath = note.vault_path?.replace(/\\/g, "/").replace(/^\/+/, "");
if (rawPath && rawPath.includes("/")) {
let segments = rawPath.split("/").slice(0, -1).filter(Boolean);
// Vault notes live under projects/<project>/<category>/ — the first two
// levels are constant scaffolding, so hide them from the directory tree.
if (segments[0] === "projects" || segments[0] === "project") {
segments = segments.slice(2);
}
const folder = (note.folder || "").replace(/^\/+|\/+$/g, "").trim();
if (folder) {
const segments = folder.split("/").map((s) => s.trim()).filter(Boolean);
if (segments.length > 0) return segments;
}
return [SOURCE_TYPE_LABELS[note.source_type] ?? FALLBACK_FOLDER];
@@ -136,7 +204,7 @@ export function noteFolderSegments(note: KnowledgeNote): string[] {
/** Breadcrumb segments for a note (folders only; the title is appended by the caller). */
export function noteBreadcrumbSegments(note: KnowledgeNote): string[] {
return ["问答梳理", ...noteFolderSegments(note)];
return ["问答梳理", ...noteFolderSegments(note).map(localizeFolderName)];
}
function sortTree(nodes: TreeNode[]): void {
@@ -153,7 +221,10 @@ function sortTree(nodes: TreeNode[]): void {
* Build the directory tree from the flat note list. Notes with a vault_path
* become nested folders mirroring that path; others are grouped by source type.
*/
export function buildKnowledgeTree(notes: KnowledgeNote[]): TreeNode[] {
export function buildKnowledgeTree(
notes: KnowledgeNote[],
folderPaths: string[] = [],
): TreeNode[] {
const root: TreeNode[] = [];
const folderMap = new Map<string, TreeNode>();
@@ -164,7 +235,9 @@ export function buildKnowledgeTree(notes: KnowledgeNote[]): TreeNode[] {
path = path ? `${path}/${segment}` : segment;
let node = folderMap.get(path);
if (!node) {
node = { id: `folder:${path}`, name: segment, type: "folder", children: [] };
// Display name is localized (known English category → 中文); the id /
// folderPath keep the raw segment so filtering/matching is unaffected.
node = { id: `folder:${path}`, name: localizeFolderName(segment), type: "folder", children: [], folderPath: path };
folderMap.set(path, node);
children.push(node);
}
@@ -173,6 +246,13 @@ export function buildKnowledgeTree(notes: KnowledgeNote[]): TreeNode[] {
return children;
};
// Pre-create managed (possibly empty) directories so a freshly-made folder
// shows up in the tree before any note is filed into it.
for (const path of folderPaths) {
const segments = path.split("/").map((s) => s.trim()).filter(Boolean);
if (segments.length) ensureFolder(segments);
}
for (const note of notes) {
ensureFolder(noteFolderSegments(note)).push({
id: note.id,
@@ -406,7 +406,7 @@ export function PositionManagementPage() {
<div>
<h1 className="text-lg font-semibold">岗位管理</h1>
<p className="text-muted-foreground text-sm">
为岗位配置默认智能体/技能/定时任务/常用提示词/推荐问题的标签,并批量分配给用户。
为岗位配置默认智能体/技能/定时任务/推荐问题的标签,并批量分配给用户。
</p>
</div>
<Button size="sm" onClick={() => { setEditing(null); setDialogOpen(true); }}>
@@ -35,7 +35,7 @@ import { cn } from "@/lib/utils";
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
import RecommendedQuestionsPage from "@/pages/RecommendedQuestionsPage";
import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage";
import { SettingsSection } from "./settings-section";
@@ -457,14 +457,14 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
{isAdmin && (
<>
<Separator />
<RecommendedQuestionsPage embedded />
<Separator />
<PromptPrefixSettingsPage embedded />
<Separator />
<PrivateSquareSettingsPage embedded />
<Separator />
<PublishSquaresSettingsPage embedded />
<Separator />
<SkillCompressionSettingsPage embedded />
</>
)}
</div>
@@ -814,6 +814,15 @@ export function SkillRow({
{t.settings.skills.badgePinned}
</Badge>
)}
{skill.always_on && (
<Badge
variant="outline"
className="border-amber-500/30 text-amber-700 dark:text-amber-300 text-xs"
title="常驻(免压缩):开启技能压缩后,本技能仍全文注入系统提示词并被正常检索"
>
常驻
</Badge>
)}
</div>
);
@@ -26,6 +26,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
@@ -37,12 +38,14 @@ import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import {
useGenerateSkillNameZh,
useSetSkillAlwaysOn,
useSetSkillDetail,
useSetSkillNameZh,
useSkill,
useSkillContent,
useSkillFiles,
} from "@/core/skills/hooks";
import { useSkillCompressionSettings } from "@/core/system-settings/hooks";
import type { Skill } from "@/core/skills/type";
import { reasoningPlugins } from "@/core/streamdown";
import { desensitize } from "@/lib/desensitize";
@@ -105,6 +108,8 @@ export function SkillDetailDialog({
const setDetail = useSetSkillDetail();
const setNameZh = useSetSkillNameZh();
const generateNameZh = useGenerateSkillNameZh();
const setAlwaysOn = useSetSkillAlwaysOn();
const { data: compression } = useSkillCompressionSettings();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
@@ -327,6 +332,15 @@ export function SkillDetailDialog({
{tx.badgePublished}
</Badge>
)}
{skill?.always_on && compression?.enabled && (
<Badge
variant="outline"
className="border-amber-500/30 text-xs text-amber-700 dark:text-amber-300"
title="常驻(免压缩):本技能始终全文注入系统提示词"
>
常驻
</Badge>
)}
</div>
)}
<DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed">
@@ -372,6 +386,31 @@ export function SkillDetailDialog({
</TabsList>
<div className="flex shrink-0 items-center gap-1">
{canManage && compression?.enabled && skill && (
<div
className="mr-1 flex items-center gap-1.5"
title="开启后,即使启用了技能压缩,本技能仍会全文注入系统提示词并被正常检索(免压缩)"
>
<span className="text-muted-foreground text-xs">常驻(免压缩)</span>
<Switch
checked={!!skill.always_on}
disabled={setAlwaysOn.isPending}
onCheckedChange={(next) =>
setAlwaysOn.mutate(
{ skillName: skill.name, alwaysOn: next },
{
onSuccess: () =>
toast.success(next ? "已设为常驻(免压缩)" : "已取消常驻"),
onError: (e) =>
toast.error(
e instanceof Error ? e.message : "更新常驻状态失败",
),
},
)
}
/>
</div>
)}
{selectedTab === "detail" && canManage && !editing && (
<Button
size="sm"
@@ -10,6 +10,7 @@ import {
DatabaseZapIcon,
GaugeIcon,
HistoryIcon,
MessageCircleQuestionIcon,
ScrollTextIcon,
Settings2Icon,
SettingsIcon,
@@ -216,6 +217,18 @@ export function WorkspaceNavMenu() {
岗位管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate(
"/page/workspace/settings/recommended-questions",
)
}
>
<MessageCircleQuestionIcon />
推荐问题配置
</DropdownMenuItem>
)}
{isAdmin && logSystem?.enabled && logSystem.url && (
<DropdownMenuItem
onClick={() =>
+111 -1
View File
@@ -4,8 +4,11 @@ import { getBackendBaseURL } from "@/core/config";
import type {
BatchIngestPayload,
BatchIngestResult,
ExtractTemplateScope,
GraphExportResult,
IngestThreadPayload,
KnowledgeExtractTemplate,
KnowledgeFolder,
KnowledgeListParams,
KnowledgeListResponse,
KnowledgeNote,
@@ -35,6 +38,7 @@ export async function listKnowledgeNotes(params: KnowledgeListParams = {}) {
if (params.entity) qs.set("entity", params.entity);
if (params.source_type) qs.set("source_type", params.source_type);
if (params.status) qs.set("status", params.status);
if (params.folder !== undefined) qs.set("folder", params.folder);
qs.set("limit", String(params.limit ?? 20));
qs.set("offset", String(params.offset ?? 0));
return parseResponse<KnowledgeListResponse>(
@@ -54,6 +58,7 @@ export async function createKnowledgeNote(data: {
summary?: string | null;
tags?: string[];
status?: string;
folder?: string | null;
}) {
const res = await parseResponse<{ note: KnowledgeNote }>(
await apiFetch(`${baseUrl()}/notes`, {
@@ -73,6 +78,7 @@ export async function updateKnowledgeNote(
summary: string | null;
tags: string[];
status: string;
folder: string | null;
}>,
) {
return parseResponse<KnowledgeNote>(
@@ -138,7 +144,14 @@ export interface IngestFileResult {
/** 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 } = {},
opts: {
title?: string;
tags?: string;
status?: string;
model_name?: string | null;
template_id?: string | null;
folder?: string | null;
} = {},
) {
const form = new FormData();
form.append("file", file);
@@ -146,6 +159,8 @@ export async function ingestKnowledgeFile(
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);
if (opts.template_id) form.append("template_id", opts.template_id);
if (opts.folder) form.append("folder", opts.folder);
// No Content-Type header — the browser sets the multipart boundary itself.
return parseResponse<IngestFileResult>(
await apiFetch(`${baseUrl()}/ingest/file`, { method: "POST", body: form }),
@@ -284,3 +299,98 @@ export async function removeGraphBlacklist(entryId: string) {
}),
);
}
// --- folders (user-managed directories) ---
export async function listKnowledgeFolders() {
const res = await parseResponse<{ items: KnowledgeFolder[] }>(
await apiFetch(`${baseUrl()}/folders`),
);
return res.items;
}
export async function createKnowledgeFolder(path: string) {
const res = await parseResponse<{ folder: KnowledgeFolder }>(
await apiFetch(`${baseUrl()}/folders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
}),
);
return res.folder;
}
export async function renameKnowledgeFolder(folderId: string, path: string) {
const res = await parseResponse<{ folder: KnowledgeFolder }>(
await apiFetch(`${baseUrl()}/folders/${encodeURIComponent(folderId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
}),
);
return res.folder;
}
export async function deleteKnowledgeFolder(folderId: string, reassignTo?: string) {
const qs = reassignTo ? `?reassign_to=${encodeURIComponent(reassignTo)}` : "";
return parseResponse<{ ok: boolean }>(
await apiFetch(`${baseUrl()}/folders/${encodeURIComponent(folderId)}${qs}`, {
method: "DELETE",
}),
);
}
// --- extraction prompt templates ---
export async function listExtractTemplates(scope?: ExtractTemplateScope, enabledOnly = false) {
const qs = new URLSearchParams();
if (scope) qs.set("scope", scope);
if (enabledOnly) qs.set("enabled_only", "true");
const q = qs.toString();
const res = await parseResponse<{ items: KnowledgeExtractTemplate[] }>(
await apiFetch(`${baseUrl()}/extract-templates${q ? `?${q}` : ""}`),
);
return res.items;
}
export type ExtractTemplateInput = {
name: string;
system_prompt: string;
scope?: ExtractTemplateScope;
description?: string | null;
is_default?: boolean;
enabled?: boolean;
};
export async function createExtractTemplate(data: ExtractTemplateInput) {
const res = await parseResponse<{ template: KnowledgeExtractTemplate }>(
await apiFetch(`${baseUrl()}/extract-templates`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
);
return res.template;
}
export async function updateExtractTemplate(
templateId: string,
data: Partial<ExtractTemplateInput>,
) {
const res = await parseResponse<{ template: KnowledgeExtractTemplate }>(
await apiFetch(`${baseUrl()}/extract-templates/${encodeURIComponent(templateId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
);
return res.template;
}
export async function deleteExtractTemplate(templateId: string) {
return parseResponse<{ ok: boolean }>(
await apiFetch(`${baseUrl()}/extract-templates/${encodeURIComponent(templateId)}`, {
method: "DELETE",
}),
);
}
+68 -1
View File
@@ -7,7 +7,11 @@ import {
import {
addGraphBlacklist,
createExtractTemplate,
createKnowledgeFolder,
createKnowledgeNote,
deleteExtractTemplate,
deleteKnowledgeFolder,
deleteKnowledgeNote,
exportKnowledgeGraph,
fetchVaultPage,
@@ -18,14 +22,18 @@ import {
ingestThread,
ingestThreadsBatch,
listDuplicates,
listExtractTemplates,
listKnowledgeFolders,
listKnowledgeNotes,
mergeKnowledgeNotes,
removeGraphBlacklist,
renameKnowledgeFolder,
resolveNoteWikilinks,
searchKnowledge,
updateExtractTemplate,
updateKnowledgeNote,
} from "./api";
import type { KnowledgeListParams } from "./types";
import type { ExtractTemplateScope, KnowledgeListParams } from "./types";
const ROOT_KEY = ["knowledge"] as const;
@@ -172,3 +180,62 @@ export function useKnowledgeDuplicates(enabled: boolean) {
refetchOnWindowFocus: false,
});
}
// --- folders (user-managed directories) ---
export function useKnowledgeFolders() {
return useQuery({
queryKey: [...ROOT_KEY, "folders"],
queryFn: listKnowledgeFolders,
refetchOnWindowFocus: false,
});
}
export function useFolderActions() {
const queryClient = useQueryClient();
// Folder changes can move notes around → invalidate the whole KB tree.
const invalidate = () => queryClient.invalidateQueries({ queryKey: ROOT_KEY });
return {
create: useMutation({ mutationFn: createKnowledgeFolder, onSuccess: invalidate }),
rename: useMutation({
mutationFn: ({ folderId, path }: { folderId: string; path: string }) =>
renameKnowledgeFolder(folderId, path),
onSuccess: invalidate,
}),
remove: useMutation({
mutationFn: ({ folderId, reassignTo }: { folderId: string; reassignTo?: string }) =>
deleteKnowledgeFolder(folderId, reassignTo),
onSuccess: invalidate,
}),
};
}
// --- extraction prompt templates ---
export function useExtractTemplates(scope?: ExtractTemplateScope, enabledOnly = false) {
return useQuery({
queryKey: [...ROOT_KEY, "extract-templates", scope ?? "all", enabledOnly],
queryFn: () => listExtractTemplates(scope, enabledOnly),
refetchOnWindowFocus: false,
});
}
export function useExtractTemplateActions() {
const queryClient = useQueryClient();
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: [...ROOT_KEY, "extract-templates"] });
return {
create: useMutation({ mutationFn: createExtractTemplate, onSuccess: invalidate }),
update: useMutation({
mutationFn: ({
templateId,
data,
}: {
templateId: string;
data: Parameters<typeof updateExtractTemplate>[1];
}) => updateExtractTemplate(templateId, data),
onSuccess: invalidate,
}),
remove: useMutation({ mutationFn: deleteExtractTemplate, onSuccess: invalidate }),
};
}
+37
View File
@@ -31,6 +31,8 @@ export interface KnowledgeNote {
summary?: string | null;
content_md: string;
vault_path?: string | null;
/** User-assigned directory (slash-joined path); null/undefined = unfiled. */
folder?: string | null;
source_type: string;
source_id?: string | null;
status: KnowledgeStatus;
@@ -58,10 +60,37 @@ export interface KnowledgeListParams {
entity?: string;
source_type?: string;
status?: string;
/** Filter to a directory (and its sub-folders); "" = unfiled only. */
folder?: string;
limit?: number;
offset?: number;
}
/** A user-managed knowledge directory. */
export interface KnowledgeFolder {
id: string;
path: string;
name: string;
sort_order?: number;
}
export type ExtractTemplateScope = "thread" | "document" | "both";
/** An editable knowledge-extraction prompt template. */
export interface KnowledgeExtractTemplate {
id: string;
name: string;
description?: string | null;
scope: ExtractTemplateScope;
system_prompt: string;
is_default: boolean;
enabled: boolean;
created_by?: string | null;
updated_by?: string | null;
created_at?: string | null;
updated_at?: string | null;
}
export type WikilinkKind = "note" | "vault" | "missing";
export interface WikilinkResolution {
@@ -105,6 +134,10 @@ export interface IngestThreadPayload {
background?: boolean;
/** Optional model override for knowledge extraction (null = configured default). */
model_name?: string | null;
/** Optional extraction-prompt template id (null = scope default). */
template_id?: string | null;
/** Optional directory to file the result under (slash-joined path). */
folder?: string | null;
}
export interface BatchIngestPayload {
@@ -116,6 +149,10 @@ export interface BatchIngestPayload {
mode?: "summary" | "full";
/** Optional model override for knowledge extraction (null = configured default). */
model_name?: string | null;
/** Optional extraction-prompt template id (null = scope default). */
template_id?: string | null;
/** Optional directory to file results under (slash-joined path). */
folder?: string | null;
}
export interface BatchIngestResult {
+7
View File
@@ -106,6 +106,13 @@ export async function resyncPosition(positionId: string): Promise<number> {
return data.synced;
}
export async function getMyPosition(): Promise<Position | null> {
const res = await apiFetch(`${base()}/me`);
if (!res.ok) throw await parseDetail(res, "Failed to load my position");
const data = (await res.json()) as { position: Position | null };
return data.position ?? null;
}
export async function listPositionUsers(): Promise<PositionUser[]> {
const res = await apiFetch(`${base()}/users`);
if (!res.ok) throw await parseDetail(res, "Failed to list users");
+7
View File
@@ -4,6 +4,7 @@ import {
addMembers,
createPosition,
deletePosition,
getMyPosition,
getTagBindings,
listMembers,
listPositionUsers,
@@ -26,12 +27,18 @@ const positionKeys = {
bindings: (id: string) => ["positions", "bindings", id] as const,
members: (id: string) => ["positions", "members", id] as const,
users: ["positions", "users"] as const,
me: ["positions", "me"] as const,
};
export function usePositions() {
return useQuery({ queryKey: positionKeys.list, queryFn: listPositions });
}
/** The caller's own effective position (explicit, else default). */
export function useMyPosition() {
return useQuery({ queryKey: positionKeys.me, queryFn: getMyPosition });
}
export function usePositionUsers() {
return useQuery({ queryKey: positionKeys.users, queryFn: listPositionUsers });
}
+20
View File
@@ -65,6 +65,26 @@ export async function enableSkill(skillName: string, enabled: boolean) {
return response.json();
}
export async function setSkillAlwaysOn(
skillName: string,
alwaysOn: boolean,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/always-on`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ always_on: alwaysOn }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill 常驻 flag");
}
return response.json() as Promise<Skill>;
}
export async function setSkillDisplay(
skillName: string,
display: SkillDisplayConfig | null,
+19
View File
@@ -18,6 +18,7 @@ import {
installSkillUpload,
reconcileCustomSkills,
saveSkillFile,
setSkillAlwaysOn,
setSkillDetail,
setSkillNameZh,
setSkillDisplay,
@@ -171,6 +172,24 @@ export function useEnableSkill() {
});
}
export function useSetSkillAlwaysOn() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, alwaysOn }: { skillName: string; alwaysOn: boolean }) =>
setSkillAlwaysOn(skillName, alwaysOn),
onSuccess: (data, { skillName }) => {
queryClient.setQueryData(["skill", skillName], data);
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
Array.isArray(current)
? current.map((item) => (item.name === skillName ? { ...item, ...data } : item))
: current,
);
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
},
});
}
export function useValidateSkillUpload() {
return useMutation({
mutationFn: (file: File) => validateSkillUpload(file),
+3
View File
@@ -48,6 +48,9 @@ export interface Skill {
category: string;
license: string;
enabled: boolean;
// 常驻(免压缩) —— 开启技能压缩后,标记此项的技能仍以全文形式注入系统提示词,
// 而非被压缩成索引 + search_skills 检索。
always_on?: boolean;
// Multitenancy fields — owner_user_id is null for built-in / public skills.
// ``builtin`` mirrors that nullness for explicit branching on the UI side.
owner_user_id?: string | null;
@@ -247,6 +247,50 @@ export async function updateCitationDisplaySettings(
return (await response.json()) as CitationDisplaySettings;
}
export interface SkillCompressionSettings {
enabled: boolean;
search_top_n: number;
keep_index_in_prompt: boolean;
}
export interface SkillCompressionUpdate {
enabled?: boolean;
search_top_n?: number;
keep_index_in_prompt?: boolean;
}
export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/skill-compression`,
);
if (!response.ok) {
throw new Error(
`Failed to load skill compression settings: ${response.status}`,
);
}
return (await response.json()) as SkillCompressionSettings;
}
export async function updateSkillCompressionSettings(
body: SkillCompressionUpdate,
): Promise<SkillCompressionSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/skill-compression`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update skill compression" }));
throw new Error(detail.detail ?? "Failed to update skill compression");
}
return (await response.json()) as SkillCompressionSettings;
}
export interface LogSystemSettings {
enabled: boolean;
url: string;
@@ -9,18 +9,21 @@ import {
loadPrivateSquareStatus,
loadPromptPrefixSettings,
loadPublishSquares,
loadSkillCompressionSettings,
updateAppearanceSettings,
updateCitationDisplaySettings,
updateLeaderboardSettings,
updatePrivateSquareSettings,
updatePromptPrefixSettings,
updatePublishSquares,
updateSkillCompressionSettings,
type AppearanceUpdate,
type CitationDisplayUpdate,
type LeaderboardSettingsUpdate,
type PrivateSquareUpdate,
type PromptPrefixUpdate,
type PublishSquaresUpdate,
type SkillCompressionUpdate,
type LayoutMode,
type ViewMode,
} from "./api";
@@ -31,6 +34,7 @@ const PUBLISH_SQUARES_KEY = ["systemSettings", "publishSquares"] as const;
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
export function usePromptPrefixSettings() {
return useQuery({
@@ -186,6 +190,29 @@ export function useUpdateCitationDisplaySettings() {
});
}
/** Skill compression (技能压缩) settings. Readable by any logged-in user — the
* skill detail page reads it to decide whether to show the 常驻(免压缩) toggle. */
export function useSkillCompressionSettings() {
return useQuery({
queryKey: SKILL_COMPRESSION_KEY,
queryFn: () => loadSkillCompressionSettings(),
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useUpdateSkillCompressionSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: SkillCompressionUpdate) =>
updateSkillCompressionSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(SKILL_COMPRESSION_KEY, data);
},
});
}
export function useLeaderboardSettings() {
return useQuery({
queryKey: LEADERBOARD_KEY,
+74 -1
View File
@@ -16,6 +16,12 @@ import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import { EmptyDocumentState } from "@/components/wiki/EmptyDocumentState";
import {
ExtractTemplateManageDialog,
FolderManageDialog,
FolderSelect,
TemplateSelect,
} from "@/components/wiki/KnowledgeManage";
import { KnowledgeSidebar } from "@/components/wiki/KnowledgeSidebar";
import { WikiContent } from "@/components/wiki/WikiContent";
import { WikiHeader } from "@/components/wiki/WikiHeader";
@@ -28,6 +34,7 @@ import { ModelNameSelect } from "@/components/workspace/model-name-select";
import {
useKnowledgeActions,
useKnowledgeDuplicates,
useKnowledgeFolders,
useKnowledgeNote,
useKnowledgeNotes,
useNoteWikilinks,
@@ -70,11 +77,13 @@ function CreateNoteDialog({
const [title, setTitle] = useState("");
const [tags, setTags] = useState("");
const [content, setContent] = useState("");
const [folder, setFolder] = useState<string | null>(null);
const reset = () => {
setTitle("");
setTags("");
setContent("");
setFolder(null);
};
const handleSubmit = async () => {
@@ -91,6 +100,7 @@ function CreateNoteDialog({
.map((t) => t.trim())
.filter(Boolean),
status: "approved",
folder,
});
toast.success("知识已创建");
onOpenChange(false);
@@ -119,6 +129,10 @@ function CreateNoteDialog({
value={tags}
onChange={(e) => setTags(e.target.value)}
/>
<label className="flex items-center justify-between gap-2 text-sm">
目录
<FolderSelect value={folder} onChange={setFolder} triggerClassName="w-[14rem]" />
</label>
<Textarea
placeholder="Markdown 正文"
rows={12}
@@ -152,6 +166,8 @@ function BatchImportDialog({
const [skipExisting, setSkipExisting] = useState(true);
// Optional model override for extraction (null = configured default).
const [model, setModel] = useState<string | null>(null);
const [template, setTemplate] = useState<string | null>(null);
const [folder, setFolder] = useState<string | null>(null);
const handleRun = async () => {
try {
@@ -161,6 +177,8 @@ function BatchImportDialog({
status: "approved",
include_sources: true,
model_name: model,
template_id: template,
folder,
});
const base = `批量导入完成:新增 ${result.created},跳过 ${result.skipped},失败 ${result.failed}`;
if (result.llm_fallback && result.llm_fallback > 0) {
@@ -217,6 +235,25 @@ function BatchImportDialog({
triggerClassName="h-9 w-[11rem]"
/>
</label>
<label className="flex items-center justify-between gap-2 text-sm">
抽取模板
<TemplateSelect
value={template}
onChange={setTemplate}
scope="thread"
disabled={batchIngest.isPending}
triggerClassName="w-[11rem]"
/>
</label>
<label className="flex items-center justify-between gap-2 text-sm">
归入目录
<FolderSelect
value={folder}
onChange={setFolder}
disabled={batchIngest.isPending}
triggerClassName="w-[11rem]"
/>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
@@ -244,12 +281,16 @@ function ImportFileDialog({
const [title, setTitle] = useState("");
const [tags, setTags] = useState("");
const [model, setModel] = useState<string | null>(null);
const [template, setTemplate] = useState<string | null>(null);
const [folder, setFolder] = useState<string | null>(null);
const reset = () => {
setFile(null);
setTitle("");
setTags("");
setModel(null);
setTemplate(null);
setFolder(null);
};
const handleImport = async () => {
@@ -265,6 +306,8 @@ function ImportFileDialog({
tags: tags.trim() || undefined,
status: "approved",
model_name: model,
template_id: template,
folder,
},
});
const count = result.notes?.length ?? 1;
@@ -330,6 +373,25 @@ function ImportFileDialog({
triggerClassName="h-9 w-[11rem]"
/>
</label>
<label className="flex items-center justify-between gap-2 text-sm">
抽取模板
<TemplateSelect
value={template}
onChange={setTemplate}
scope="document"
disabled={ingestFile.isPending}
triggerClassName="w-[11rem]"
/>
</label>
<label className="flex items-center justify-between gap-2 text-sm">
归入目录
<FolderSelect
value={folder}
onChange={setFolder}
disabled={ingestFile.isPending}
triggerClassName="w-[11rem]"
/>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
@@ -478,6 +540,8 @@ export default function KnowledgeBasePage() {
const [batchOpen, setBatchOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [dedupOpen, setDedupOpen] = useState(false);
const [foldersOpen, setFoldersOpen] = useState(false);
const [templatesOpen, setTemplatesOpen] = useState(false);
const [vaultPath, setVaultPath] = useState<string | null>(null);
const params = useMemo(
@@ -491,8 +555,13 @@ export default function KnowledgeBasePage() {
);
const { data, isLoading } = useKnowledgeNotes(params);
const { data: folders = [] } = useKnowledgeFolders();
const notes = data?.items ?? [];
const tree = useMemo(() => buildKnowledgeTree(notes), [notes]);
const folderPaths = useMemo(() => folders.map((f) => f.path), [folders]);
const tree = useMemo(
() => buildKnowledgeTree(notes, folderPaths),
[notes, folderPaths],
);
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
const { data: wikiLinks } = useNoteWikilinks(noteId);
@@ -571,6 +640,8 @@ export default function KnowledgeBasePage() {
onDedup={() => setDedupOpen(true)}
onGraph={() => navigate("/page/workspace/knowledge/graph")}
onCreate={() => setCreateOpen(true)}
onManageFolders={() => setFoldersOpen(true)}
onManageTemplates={() => setTemplatesOpen(true)}
/>
<div
@@ -636,6 +707,8 @@ export default function KnowledgeBasePage() {
<ImportFileDialog open={importOpen} onOpenChange={setImportOpen} />
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
<FolderManageDialog open={foldersOpen} onOpenChange={setFoldersOpen} />
<ExtractTemplateManageDialog open={templatesOpen} onOpenChange={setTemplatesOpen} />
<VaultPageDialog
path={vaultPath}
onOpenChange={(open) => {
@@ -0,0 +1,141 @@
"use client";
import { Loader2Icon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
useSkillCompressionSettings,
useUpdateSkillCompressionSettings,
} from "@/core/system-settings/hooks";
/**
* 管理员「技能压缩」配置页。
*
* 开启后,只有被标记为「常驻(免压缩)」的技能会全文注入系统提示词;其余已启用
* 技能会被压缩(从提示词正文剔除),由智能体在需要时通过 `search_skills` 工具
* 按关键词检索调出。关闭时行为不变 —— 所有已启用技能全文注入。
*/
export default function SkillCompressionSettingsPage({
embedded = false,
}: {
embedded?: boolean;
} = {}) {
const { data, isLoading, error } = useSkillCompressionSettings();
const update = useUpdateSkillCompressionSettings();
const [enabled, setEnabled] = useState(false);
const [keepIndex, setKeepIndex] = useState(true);
const [searchTopN, setSearchTopN] = useState(5);
const initializedRef = useRef(false);
useEffect(() => {
if (!data || initializedRef.current) return;
setEnabled(data.enabled);
setKeepIndex(data.keep_index_in_prompt);
setSearchTopN(data.search_top_n);
initializedRef.current = true;
}, [data]);
const handleSave = async () => {
const topN = Math.max(1, Math.min(Math.round(searchTopN) || 5, 50));
try {
await update.mutateAsync({
enabled,
keep_index_in_prompt: keepIndex,
search_top_n: topN,
});
setSearchTopN(topN);
toast.success("已保存");
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
}
};
return (
<div
className={
embedded
? "flex w-full flex-col gap-6"
: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"
}
>
<div>
<h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>
技能压缩配置
</h1>
<p className="text-muted-foreground mt-1 text-sm">
技能过多会让系统提示词变得臃肿、增加 token 消耗。开启「技能压缩」后,只有被标记为
「常驻(免压缩)」的技能会全文注入系统提示词;其余已启用技能会被压缩,智能体在需要时
通过 <code>search_skills</code> 工具按关键词检索调出。关闭时行为不变 ——
所有已启用技能全文注入。某个技能是否常驻,在该技能的详情页单独开启。
</p>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
</div>
) : error ? (
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
加载失败:{error.message}
</div>
) : (
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">启用技能压缩</div>
<div className="text-muted-foreground mt-0.5 text-xs">
关闭时,所有已启用技能照旧全文注入系统提示词(默认行为)。
</div>
</div>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">保留技能名索引</div>
<div className="text-muted-foreground mt-0.5 text-xs">
开启后,被压缩的技能仍以「仅名字」的紧凑列表出现在提示词里,帮助智能体知道存在
哪些技能;关闭则完全隐藏,只能靠 search_skills 检索发现。
</div>
</div>
<Switch
checked={keepIndex}
disabled={!enabled}
onCheckedChange={setKeepIndex}
/>
</div>
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">检索返回条数</p>
<p className="text-muted-foreground text-xs">
search_skills 工具每次最多返回的匹配技能数量(1–50)。
</p>
<Input
type="number"
min={1}
max={50}
value={searchTopN}
disabled={!enabled}
onChange={(e) => setSearchTopN(Number(e.target.value))}
className="w-28"
/>
</div>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={update.isPending}>
{update.isPending && (
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
)}
保存
</Button>
</div>
</div>
)}
</div>
);
}
@@ -32,6 +32,7 @@ import MemoryPage from './MemoryPage';
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
import SkillCompressionSettingsPage from './SkillCompressionSettingsPage';
import SkillEvolutionPage from './SkillEvolutionPage';
import SkillsPage from './SkillsPage';
import UsersPage from './UsersPage';
@@ -534,6 +535,18 @@ export default function WorkspaceRoutes({
)
}
/>
<Route
path="settings/skill-compression"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<SkillCompressionSettingsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/llm-metrics"
element={
@@ -8,6 +8,7 @@ import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { getStoredAuth } from "@/core/auth";
import { isWujieEnv } from "@/core/embed/wujie";
import { useUnreadCount } from "@/core/notifications";
import { useMyPosition } from "@/core/positions";
import type { MenuItem } from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
import { cn } from "@/lib/utils";
@@ -128,6 +129,8 @@ const Header = ({ collapsed }: HeaderProps) => {
const auth = getStoredAuth();
const isAdmin = auth?.system_role === "admin";
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
const { data: myPosition } = useMyPosition();
const positionName = myPosition?.name?.trim() || "";
// 登录时写入的真实用户名(外部登录存于 localStorage.userInfo.userName);
// 缺省回退到 stored auth 的邮箱前缀。
@@ -277,6 +280,11 @@ const Header = ({ collapsed }: HeaderProps) => {
<p className="text-sm leading-tight font-medium text-white">
{displayName}
</p>
{positionName && (
<p className="text-[11px] leading-tight text-white/70">
{positionName}
</p>
)}
</div>
<ChevronDown
className={cn(
+54 -6
View File
@@ -163,7 +163,7 @@ Lead-agent middlewares are assembled in strict append order across `packages/har
6. **GuardrailMiddleware** - Pre-tool-call authorization via pluggable `GuardrailProvider` protocol (optional, if `guardrails.enabled` in config). Evaluates each tool call and returns error ToolMessage on deny. Three provider options: built-in `AllowlistProvider` (zero deps), OAP policy providers (e.g. `aport-agent-guardrails`), or custom providers. See [docs/GUARDRAILS.md](docs/GUARDRAILS.md) for setup, usage, and how to implement a provider.
7. **SandboxAuditMiddleware** - Audits sandboxed shell/file operations for security logging before tool execution continues
8. **ToolErrorHandlingMiddleware** - Converts tool exceptions into error `ToolMessage`s so the run can continue instead of aborting
9. **SummarizationMiddleware** (`DeerFlowSummarizationMiddleware`) - Context reduction when approaching token limits (optional, if enabled). **Non-destructive**: overrides the stock `before_model` (which persists a `RemoveMessage(REMOVE_ALL_MESSAGES)`) to a no-op and instead compresses **transiently** in `wrap_model_call` ? the model request is replaced with `[summary, *recent]` while the full original conversation stays in state (so the frontend keeps rendering the user's real Q&A). The `summary` message is `hide_from_ui` and never persisted; summaries are cached **per-thread in a process-wide, thread-safe LRU** (`_GLOBAL_SUMMARY_CACHE`, bounded to 512 threads, sticky boundary). The cache is module-level — **not** on the middleware instance — because `make_lead_agent` rebuilds a fresh middleware on every run; an instance cache would be cold each turn and force a blocking summary LLM call on every turn of a long conversation. Keyed by `thread_id` (resolved like `ThreadDataMiddleware`), so a summary computed on one turn is reused on the next, paying for an LLM call only on the first threshold crossing or a genuine re-compaction. **Threshold-first gating**: `_plan_compression` returns passthrough (no compression at all) whenever the *full* current conversation is still under the trigger — the cache/summary substitution only engages once the real context crosses the configured trigger. When the genuine (blocking) summarize path runs, it emits a `{"type":"context_compacting","message":"正在压缩上下文…"}` event on LangGraph's `custom` stream channel (the cheap reuse path stays silent); the chat frontend's `onCustomEvent` renders it as a transient toast. Tests: `tests/test_summarization_middleware.py`
9. **SummarizationMiddleware** (`DeerFlowSummarizationMiddleware`) - Context reduction when approaching token limits (optional, if enabled). **Non-destructive**: overrides the stock `before_model` (which persists a `RemoveMessage(REMOVE_ALL_MESSAGES)`) to a no-op and instead compresses **transiently** in `wrap_model_call` ? the model request is replaced with `[summary, *recent]` while the full original conversation stays in state (so the frontend keeps rendering the user's real Q&A). The `summary` message is `hide_from_ui` and never persisted; summaries are cached **per-thread in a process-wide, thread-safe LRU** (`_GLOBAL_SUMMARY_CACHE`, bounded to 512 threads, sticky boundary). The cache is module-level — **not** on the middleware instance — because `make_lead_agent` rebuilds a fresh middleware on every run; an instance cache would be cold each turn and force a blocking summary LLM call on every turn of a long conversation. Keyed by `thread_id` (resolved like `ThreadDataMiddleware`), so a summary computed on one turn is reused on the next, paying for an LLM call only on the first threshold crossing or a genuine re-compaction. **Threshold-first gating**: `_plan_compression` returns passthrough (no compression at all) whenever the *full* current conversation is still under the trigger — the cache/summary substitution only engages once the real context crosses the configured trigger. **Stickiness headroom** (`_apply_stickiness_headroom`, `_KEEP_HEADROOM_FRACTION=0.75`): after the `keep` policy picks a cutoff, the kept suffix is forced below ~75% of *every* trigger before summarizing. Without it a `keep` in *messages* (e.g. 20) can itself exceed a *token* trigger (e.g. 60000) when recent messages are large (tool outputs / pasted docs), leaving the "compressed" context still over the trigger so it re-summarizes on every follow-up question; the headroom makes the sticky boundary actually stick (re-compaction only after genuinely new content). When the genuine (blocking) summarize path runs, it emits a `{"type":"context_compacting","message":"正在压缩上下文…"}` event on LangGraph's `custom` stream channel (the cheap reuse path stays silent); the chat frontend's `onCustomEvent` renders it as a transient toast. Tests: `tests/test_summarization_middleware.py`
10. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
11. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
12. **TitleMiddleware** - Two-phase auto-title (never shows "untitled"): `before_model` sets an instant question-derived *provisional* title the moment the first user message arrives (no LLM, zero latency), then `after_model` asynchronously generates the final LLM title and replaces it (tracked via the `title_provisional` state flag). Normalizes structured message content before prompting the title model
@@ -175,7 +175,7 @@ Lead-agent middlewares are assembled in strict append order across `packages/har
18. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
8a. **ToolMetricsMiddleware** - Appended immediately after `ToolErrorHandlingMiddleware` in `_build_runtime_middlewares`, so it sits *deepest* in the `wrap_tool_call` chain. Records one `tool_call_metrics` row per tool/skill/MCP invocation ? timing duration and bucketing failures into a coarse `error_category` (rate_limited / ip_blocked / auth_error / timeout / network_error / not_found / empty_result / tool_error). It catches both raised exceptions (before #8 converts them) and *self-handled* errors returned as error `ToolMessage`s (search tools that swallow HTTP 429/403). It also captures the targeted Agent Skill into `tool_call_metrics.skill_name`: the lead agent invokes a skill by `read_file`-ing its `/mnt/skills/<category>/<name>/SKILL.md` path (per the `<skill_system>` prompt block ? there is no dedicated "use skill" tool), so `skill_name` is recovered by scanning every tool call's string arguments for the `skills/{public,custom}/{name}/` path shape (`_SKILL_PATH_RE`), plus the explicit `name` argument of `skill_view`/`skill_manage`/`skill_archive`. `skill_view`-specific success detection handles its swallowed failures (`Skill 'x' not found.` returned as a plain string rather than raised). Regression coverage: `tests/test_tool_metrics_middleware.py`. Writes are best-effort fire-and-forget. **Per-run skill merging**: `SqlToolMetricsStore.record` upserts skill rows by `(run_id, skill_name)` instead of inserting one row per call ? a skill touched by several tool calls / retries inside one run collapses to a single row. Any success in the run wins (a skill that failed then finally succeeded is recorded as one `success`); a skill that only ever failed yields one `error` row whose `error_message` accumulates every attempt's message; durations are summed. Non-skill tool calls, and skill calls with no `run_id`, keep one row each. The read?merge?write is guarded by a per-event-loop `asyncio.Lock`. Surfaced by the admin `GET /api/admin/tool-metrics` router (which has a `kind=skill` filter and a per-skill `/summary` breakdown).
9. **PromptPrefixMiddleware** - Injects the admin-configured prompt prefix into the model request only. The prefix is tagged onto the first human message's `additional_kwargs['prompt_prefix']` by the Gateway (`app.gateway.services`) and prepended to content here at call time, so the persisted/displayed message stays prefix-free
10. **SummarizationMiddleware** (`DeerFlowSummarizationMiddleware`) - Context reduction when approaching token limits (optional, if enabled). **Non-destructive**: overrides the stock `before_model` (which persists a `RemoveMessage(REMOVE_ALL_MESSAGES)`) to a no-op and instead compresses **transiently** in `wrap_model_call` ? the model request is replaced with `[summary, *recent]` while the full original conversation stays in state (so the frontend keeps rendering the user's real Q&A). The `summary` message is `hide_from_ui` and never persisted; summaries are cached **per-thread in a process-wide, thread-safe LRU** (`_GLOBAL_SUMMARY_CACHE`, bounded to 512 threads, sticky boundary). The cache is module-level — **not** on the middleware instance — because `make_lead_agent` rebuilds a fresh middleware on every run; an instance cache would be cold each turn and force a blocking summary LLM call on every turn of a long conversation. Keyed by `thread_id` (resolved like `ThreadDataMiddleware`), so a summary computed on one turn is reused on the next, paying for an LLM call only on the first threshold crossing or a genuine re-compaction. **Threshold-first gating**: `_plan_compression` returns passthrough (no compression at all) whenever the *full* current conversation is still under the trigger — the cache/summary substitution only engages once the real context crosses the configured trigger. When the genuine (blocking) summarize path runs, it emits a `{"type":"context_compacting","message":"正在压缩上下文…"}` event on LangGraph's `custom` stream channel (the cheap reuse path stays silent); the chat frontend's `onCustomEvent` renders it as a transient toast. Tests: `tests/test_summarization_middleware.py`
10. **SummarizationMiddleware** (`DeerFlowSummarizationMiddleware`) - Context reduction when approaching token limits (optional, if enabled). **Non-destructive**: overrides the stock `before_model` (which persists a `RemoveMessage(REMOVE_ALL_MESSAGES)`) to a no-op and instead compresses **transiently** in `wrap_model_call` ? the model request is replaced with `[summary, *recent]` while the full original conversation stays in state (so the frontend keeps rendering the user's real Q&A). The `summary` message is `hide_from_ui` and never persisted; summaries are cached **per-thread in a process-wide, thread-safe LRU** (`_GLOBAL_SUMMARY_CACHE`, bounded to 512 threads, sticky boundary). The cache is module-level — **not** on the middleware instance — because `make_lead_agent` rebuilds a fresh middleware on every run; an instance cache would be cold each turn and force a blocking summary LLM call on every turn of a long conversation. Keyed by `thread_id` (resolved like `ThreadDataMiddleware`), so a summary computed on one turn is reused on the next, paying for an LLM call only on the first threshold crossing or a genuine re-compaction. **Threshold-first gating**: `_plan_compression` returns passthrough (no compression at all) whenever the *full* current conversation is still under the trigger — the cache/summary substitution only engages once the real context crosses the configured trigger. **Stickiness headroom** (`_apply_stickiness_headroom`, `_KEEP_HEADROOM_FRACTION=0.75`): after the `keep` policy picks a cutoff, the kept suffix is forced below ~75% of *every* trigger before summarizing. Without it a `keep` in *messages* (e.g. 20) can itself exceed a *token* trigger (e.g. 60000) when recent messages are large (tool outputs / pasted docs), leaving the "compressed" context still over the trigger so it re-summarizes on every follow-up question; the headroom makes the sticky boundary actually stick (re-compaction only after genuinely new content). When the genuine (blocking) summarize path runs, it emits a `{"type":"context_compacting","message":"正在压缩上下文…"}` event on LangGraph's `custom` stream channel (the cheap reuse path stays silent); the chat frontend's `onCustomEvent` renders it as a transient toast. Tests: `tests/test_summarization_middleware.py`
11. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
12. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
13. **TitleMiddleware** - Two-phase auto-title (never shows "untitled"): `before_model` sets an instant question-derived *provisional* title the moment the first user message arrives (no LLM, zero latency), then `after_model` asynchronously generates the final LLM title and replaces it (tracked via the `title_provisional` state flag). Normalizes structured message content before prompting the title model
@@ -252,7 +252,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **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). |
| **Light Apps** (`/api/light-apps`) | Light-app registry (????? / ????), **global/shared** (no user scoping; `created_by`/`updated_by` audit-only). Two kinds: `window_open` (launched in a new tab from the ???? card grid) and `iframe` (embedded under a parent sidebar menu via `mount_location` and dynamically injected into the sidebar). Both carry `route_params` ? a list of `{key, paramType: login\|theme, source, customValue, themeValue}` descriptors the **frontend** resolves at launch time (login params read from `localStorage.userInfo`; theme params use a literal value) and appends to `url` as query string. `GET /` - list (filters `app_type`/`mount_location`/`enabled_only`/`search`); `GET /{app_id}` - read one (both open to any authenticated user); `POST /`, `PUT /{app_id}`, `DELETE /{app_id}` - **admin only**; iframe apps must carry a `mount_location` (400 otherwise); the unique `name` collides as HTTP 409. Backed by `deerflow.persistence.light_apps` (table `light_apps`, auto-created by `create_all`); store wired in `deps.py` as `app.state.light_app_store`. Tests: `tests/test_light_apps.py`. |
| **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, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `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, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `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). `GET/POST /folders` + `PUT/DELETE /folders/{id}` - user-managed directories (notes carry a `folder` path; `GET /notes?folder=` filters a dir + sub-dirs); `GET/POST /extract-templates` + `GET/PUT/DELETE /extract-templates/{id}` - editable extraction-prompt templates (thread/document ingest accept `template_id`; capture/PUT-note accept `folder`). Backed by the `deerflow.knowledge` module (see below). |
Proxied through nginx: `/api/langgraph/*` ? LangGraph, all other `/api/*` ? Gateway.
@@ -316,6 +316,14 @@ Self-contained product knowledge base. **All knowledge capability lives in this
- **Phase 3 ? governance**: `sensitive.py` redacts secrets/PII before persistence (`sensitive_redaction_enabled`); every update snapshots `knowledge_note_versions` (`GET /notes/{id}/versions`); `GET /duplicates` + `POST /merge` for dedup; background `AutoIngestQueue` (wired in `deps.py`, started/stopped in the gateway lifespan) auto-sediments finished conversations **off the chat path** when `auto_ingest_enabled` ? enqueued from `KnowledgeRagMiddleware.after_agent`, value-judged by min-chars + `skip_existing`, landing as `draft`.
- **Phase 4 ? graph**: LLM extraction also yields entities/relations ? `knowledge_entities`/`knowledge_relations`; `exporter.build_graph` adds entity nodes + note?entity/entity?entity edges (reflected in `graph.html`/`graph.json`); `GET /graph` returns product graph data.
**User directories + editable extraction templates + display polish (knowledge UX overhaul):**
- **Custom directories (`knowledge_notes.folder` + `knowledge_folders`)** ? notes carry a free-form, slash-joined `folder` path (e.g. `投研/行业`) independent of the wiki-capture `category` and of `vault_path`. `knowledge_folders` is the manageable directory list (supports empty folders + rename/move that re-points descendant folders **and** filed notes). Router CRUD under `/api/knowledge/folders` (`GET`/`POST`/`PUT/{id}`/`DELETE/{id}?reassign_to=`); `GET /notes?folder=` filters a directory + its sub-folders (`folder=""` ? unfiled). Capture (`capture_thread`/`capture_file`/`create_manual_note`) and `PUT /notes/{id}` accept `folder` (the note PUT uses `model_fields_set` so an absent key leaves the folder untouched, a present `null` clears it). Frontend `buildKnowledgeTree(notes, folderPaths)` builds the left tree from `folder` (no longer from `vault_path`); folder management dialog + `FolderSelect` in the create/import dialogs and the note detail header.
- **Editable extraction prompts (`knowledge_extract_templates`)** ? the hard-coded distillation prompts are seeded as editable templates on startup (`KnowledgeService.seed_default_templates`, idempotent — `默认 · 对话提炼` / `默认 · 文档提炼`, each `is_default` for its scope). `scope ∈ {thread, document, both}`; capture resolves an explicit `template_id` → the scope default → the built-in constant, and passes it to `llm_extractor.extract_thread_llm_multi` / `extract_document_llm_multi` as `system_prompt` (used verbatim). Router CRUD under `/api/knowledge/extract-templates` (409 on duplicate name). Frontend: extraction-template management dialog + `TemplateSelect` in the batch/file import dialogs. Editing a template is the supported way to fix "提炼太精简" (loosen the prompt to keep more detail) and to add alternate extraction logics.
- **Single source list** ? `extractor._related_section_md` no longer emits a `### Sources` sub-section into the body; the note detail shows sources once via the structured `来源` block (`note.sources`). `## Related` stays pure wikilinks so the frontend extracts it cleanly into the related-docs module.
- **Chinese section headings (display-only)** ? the frontend `localizeHeadings` (wikiUtils) maps `Context/Finding / Decision/Reasoning/Implications/Related/Sources` to 中文 at render time (applied to the body fed to both `extractToc` and the viewer, so anchors stay consistent). No data migration — historical English-heading notes render in Chinese too.
- **Historical-data cleanup** ? `scripts/cleanup_knowledge_related.py` (one-off, idempotent, `--dry-run`/`--note-id`) strips legacy `## Related` structural links (vault scaffold: `index`/`_meta/taxonomy`/`projects/…`, any slash-bearing target) and the old `### Sources` sub-section from existing notes (DB `content_md` + vault files, via `KnowledgeService.update_note`).
- Migration `20260616_02` + idempotent `scripts/sql/column_additions_mysql.sql` cover `knowledge_notes.folder` (+ index); the two new tables are auto-created by `create_all` / `_ensure_orm_columns_sync` on startup. Tests: `tests/test_knowledge.py`.
### Custom Agent Persistence
Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks.
@@ -376,7 +384,11 @@ default gets their grants materialized).
position CRUD; `GET/PUT /{id}/tag-bindings`; `GET/POST/DELETE /{id}/members`
(batch assign/unassign → triggers sync); `POST /{id}/resync`;
`GET /users` (all users + their position); `PUT /users/{user_id}` (set one
user's position). Related changes: `GET /api/recommended-questions` is now
user's position). **`GET /me` is the one non-admin route** — it returns the
caller's own effective position (explicit assignment, else the default position
unassigned users inherit, else `null`); powers the top-bar 岗位 label
(`strategy-components/Header.tsx` user cluster, via `useMyPosition`). Related
changes: `GET /api/recommended-questions` is now
filtered by the caller's position (`?all=1` bypasses for admin management) and
admin can tag questions via `/api/tags/assignments/recommended_question/{id}`;
`GET /api/skills?scope=mine` applies skill visibility. Frontend: `core/positions`
@@ -469,11 +481,47 @@ Operators upgrading an existing DB run the whole file (`USE <db>;` then execute)
- **Location**: `deer-flow/skills/{public,custom}/`
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools)
- **Loading**: `load_skills()` recursively scans `skills/{public,custom}` for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json
- **Loading**: `load_skills()` recursively scans `skills/{public,custom}` for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json. **A directory containing `SKILL.md` is a self-contained leaf** — discovery does not descend into its subdirectories, so a skill *package* that bundles its own `examples/`/`references/` sub-skills (each with a SKILL.md) surfaces as **one** skill, not a swarm of phantom entries.
- **Discovery↔delete consistency**: a skill is keyed for discovery by its frontmatter `name`, but its on-disk directory name may differ (hand-dropped / mis-extracted package). `LocalSkillStorage._resolve_skill_dir(name, category)` resolves the *discovered* directory (canonical `<category>/<name>` fast-path, else scan-by-frontmatter-name); `custom_skill_exists`/`read_custom_skill`/`delete_custom_skill` all go through it, so **anything that gets listed can also be read and deleted** regardless of directory name (the old code assumed `custom/<name>/` and 404'd "Custom skill 'x' not found." on any non-canonical layout). Delete is path-guarded to never escape the `custom/` root. Tests: `tests/test_skill_storage_delete.py`.
- **Injection**: Enabled skills listed in agent system prompt with container paths
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
- **Chinese name (`name_zh`)**: a Chinese display name stored in the **`skills` DB table** (`SkillRow.name_zh`, `String(128)`, migration `20260609_01`) ? never written to the on-disk SKILL.md, so it works for built-in skills too. Threaded through `SkillStore` create/ensure_legacy/update/update_admin exactly like `detail`; surfaced on `SkillResponse.name_zh` from the ownership record in `_skill_to_response`. Authorization mirrors `detail`: custom skill ? owner or admin; built-in/public ? admin only (row materialized on demand via `ensure_legacy`). Endpoints (in `app/gateway/routers/skills.py`): `PUT /api/skills/{name}/name-zh` (set/clear), `POST /api/skills/{name}/name-zh/generate` (LLM generates one), `POST /api/skills/name-zh/generate-missing` (????: **admin-only** bulk-generate for every skill that lacks one, bounded-concurrency `asyncio.gather`). **Optional model selection**: both generate endpoints accept an optional JSON body `{model_name}` ? an explicit name wins, else the curator model setting, else the main model (`_resolve_skill_name_zh_model_name`). **Model-error surfacing**: `_generate_skill_name_zh` raises `SkillNameZhModelError` when the model itself fails (build/timeout/API error) ? distinct from the model returning an unusable name (? `None`). The single endpoint maps that to `502 ?????,?????????:?`; the bulk endpoint fails fast on a model-build error (502) and otherwise carries a representative `model_error` string in `SkillNameZhBulkResponse` so the UI can say "N ?????????". The frontend (`skill-settings-page.tsx` list + `skill-detail-dialog.tsx` editor, plus the agent skill pickers/badges in `NewAgentPage.tsx` + `agent-card.tsx`) shows `name_zh || name`, offers per-skill "AI ??" plus an **admin-only** header "???????" button, and a compact model dropdown (`components/workspace/model-name-select.tsx`, default = configured model) beside each action.
#### Skill Compression (技能压缩)
When the skill catalogue grows large, injecting every enabled skill full-text
into the lead agent's system prompt bloats it and wastes tokens. **Skill
compression** is an admin-gated mode that keeps only *常驻(免压缩)* skills in the
prompt body and surfaces the rest on demand via keyword retrieval.
- **Master switch** — `system_settings.json → skill_compression`
(`SkillCompressionSettings`: `enabled` default **false**, `search_top_n`
default 5, `keep_index_in_prompt` default true). Read/written by
`GET`/`PUT /api/system-settings/skill-compression` (GET open to any user — the
skill detail page reads it; PUT admin-only, refreshes the prompt cache).
- **Per-skill 常驻 flag** — `extensions_config.json → skills.{name}.always_on`
(`SkillStateConfig.always_on`, alongside `enabled`). Surfaced on
`Skill.always_on` (populated in `load_skills`) and `SkillResponse.always_on`;
toggled via `PUT /api/skills/{name}/always-on`. `update_skill` /
`update_skill_display` preserve it when rewriting the config entry.
- **Prompt split** — `get_skills_prompt_section` (`lead_agent/prompt.py`) reads
the compression settings (skipped for restricted custom agents — they already
have a curated allowlist). When **off**, behaviour is unchanged (all enabled
skills full-text). When **on**, `always_on` skills render as full `<skill>`
blocks; the rest are dropped from the body, replaced by a `search_skills`
usage note plus (when `keep_index_in_prompt`) a name-only `<compressed_skills>`
index. The `_get_cached_skills_prompt_section` cache key includes the
`always_on` flag + compression flags so changes invalidate correctly.
- **Retrieval tool** — `search_skills` (`tools/builtins/skill_tools.py`,
`search_skills_impl`) keyword-scores `name + description` over enabled,
visible, non-archived skills (same visibility filter as `skill_list`) and
returns top-N `{name, description, category, location}`. It is registered in
`get_available_tools` **only when compression is enabled** (offline,
deterministic, zero external deps). The agent then `read_file`s the returned
`location`.
Tests: `tests/test_skill_compression.py`.
#### Skill Curator (`packages/harness/deerflow/skills/curator.py`)
Background skill lifecycle manager. The Gateway `lifespan` starts a resident
@@ -640,7 +688,7 @@ Both endpoints are whitelisted in `auth_middleware._PUBLIC_EXACT_PATHS`. The sha
**`extensions_config.json`**:
- `mcpServers` - Map of server name ? config (enabled, type, command, args, env, url, headers, oauth, description)
- `skills` - Map of skill name ? state (enabled)
- `skills` - Map of skill name ? state (enabled, always_on)
Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` methods.
@@ -184,6 +184,14 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.knowledge_service = make_knowledge_service(sf, config.knowledge)
# Seed the built-in extraction prompts as editable templates so operators
# can immediately view/edit them (idempotent; never overwrites edits).
if app.state.knowledge_service is not None:
try:
await app.state.knowledge_service.seed_default_templates()
except Exception:
logger.exception("Failed to seed knowledge extraction templates")
# Background auto-sedimentation queue (phase 3) — drains finished
# conversations off the chat path. Worker only does work when
# ``knowledge.auto_ingest_enabled`` is set; the queue is always wired so
@@ -54,6 +54,8 @@ class IngestThreadRequest(BaseModel):
description="When true, sediment the thread asynchronously in a background task and return immediately.",
)
model_name: str | None = Field(default=None, description="可选:指定用于知识提炼的大模型名称;留空使用默认配置")
template_id: str | None = Field(default=None, description="可选:抽取提示词模板 id;留空使用对话默认模板")
folder: str | None = Field(default=None, description="可选:沉淀结果归入的目录(斜杠分隔路径)")
class BatchIngestRequest(BaseModel):
@@ -64,6 +66,8 @@ class BatchIngestRequest(BaseModel):
skip_existing: bool = True
mode: str = "summary"
model_name: str | None = Field(default=None, description="可选:指定用于知识提炼的大模型名称;留空使用默认配置")
template_id: str | None = Field(default=None, description="可选:抽取提示词模板 id;留空使用对话默认模板")
folder: str | None = Field(default=None, description="可选:沉淀结果归入的目录(斜杠分隔路径)")
class SearchResultItem(BaseModel):
@@ -90,6 +94,7 @@ class CreateNoteRequest(BaseModel):
summary: str | None = None
tags: list[str] = Field(default_factory=list)
status: str = "approved"
folder: str | None = None
class UpdateNoteRequest(BaseModel):
@@ -98,6 +103,34 @@ class UpdateNoteRequest(BaseModel):
summary: str | None = None
tags: list[str] | None = None
status: str | None = None
# Present (even as null) ⇒ (re)assign the note's directory; absent ⇒ leave as-is.
folder: str | None = None
class CreateFolderRequest(BaseModel):
path: str = Field(description="目录全路径,斜杠分隔,如 投研/行业")
class RenameFolderRequest(BaseModel):
path: str = Field(description="新的目录全路径")
class ExtractTemplateRequest(BaseModel):
name: str
system_prompt: str
scope: str = Field(default="both", description="thread | document | both")
description: str | None = None
is_default: bool = False
enabled: bool = True
class UpdateExtractTemplateRequest(BaseModel):
name: str | None = None
system_prompt: str | None = None
scope: str | None = None
description: str | None = None
is_default: bool | None = None
enabled: bool | None = None
class SearchRequest(BaseModel):
@@ -187,6 +220,8 @@ async def _ingest_thread_background(service, checkpointer, thread_id: str, body:
reference_batches=batches,
created_by=created_by,
model_name=body.model_name,
template_id=body.template_id,
folder=body.folder,
)
except EmptyThreadError:
logger.warning("Background knowledge ingest skipped: thread %s has no sediment-able content", sanitize_log_param(thread_id))
@@ -237,6 +272,8 @@ async def ingest_thread(thread_id: str, body: IngestThreadRequest, request: Requ
reference_batches=batches,
created_by=get_effective_user_id(),
model_name=body.model_name,
template_id=body.template_id,
folder=body.folder,
)
except EmptyThreadError:
raise HTTPException(status_code=400, detail="Thread has no sediment-able content")
@@ -285,6 +322,8 @@ async def ingest_threads_batch(body: BatchIngestRequest, request: Request) -> di
created_by=get_effective_user_id(),
skip_existing=body.skip_existing,
model_name=body.model_name,
template_id=body.template_id,
folder=body.folder,
)
if result.get("llm_fallback"):
llm_fallback += 1
@@ -352,6 +391,8 @@ async def ingest_file(
tags: str | None = Form(default=None),
status: str = Form(default="approved"),
model_name: str | None = Form(default=None),
template_id: str | None = Form(default=None),
folder: str | None = Form(default=None),
) -> dict[str, Any]:
"""Import an uploaded file into the knowledge base as wiki note(s).
@@ -371,6 +412,8 @@ async def ingest_file(
status=status,
created_by=get_effective_user_id(),
model_name=(model_name or None),
template_id=(template_id or None),
folder=(folder or None),
)
except EmptyThreadError:
raise HTTPException(status_code=422, detail="文件内容无可沉淀的知识")
@@ -413,6 +456,7 @@ async def create_note(body: CreateNoteRequest, request: Request) -> dict[str, An
summary=body.summary,
tags=body.tags,
status=body.status,
folder=body.folder,
created_by=get_effective_user_id(),
)
return {"note": note}
@@ -427,13 +471,14 @@ async def list_notes(
entity: str | None = None,
source_type: str | None = None,
status: str | None = None,
folder: str | None = None,
limit: int = 20,
offset: int = 0,
) -> dict[str, Any]:
service = get_knowledge_service(request)
limit = max(1, min(limit, 100))
offset = max(0, offset)
return await service.list_notes(keyword=keyword, tag=tag, entity=entity, source_type=source_type, status=status, limit=limit, offset=offset)
return await service.list_notes(keyword=keyword, tag=tag, entity=entity, source_type=source_type, status=status, folder=folder, limit=limit, offset=offset)
@router.get("/notes/{note_id}")
@@ -451,6 +496,9 @@ async def get_note(note_id: str, request: Request) -> dict[str, Any]:
@require_auth
async def update_note(note_id: str, body: UpdateNoteRequest, request: Request) -> dict[str, Any]:
service = get_knowledge_service(request)
# ``folder`` present in the request body (even as null) ⇒ (re)assign the
# directory; absent ⇒ leave it untouched.
folder_provided = "folder" in body.model_fields_set
note = await service.update_note(
note_id,
title=body.title,
@@ -458,6 +506,8 @@ async def update_note(note_id: str, body: UpdateNoteRequest, request: Request) -
summary=body.summary,
tags=body.tags,
status=body.status,
folder=body.folder,
set_folder=folder_provided,
updated_by=get_effective_user_id(),
)
if note is None:
@@ -634,3 +684,119 @@ async def export_file(filename: str, request: Request) -> FileResponse:
raise HTTPException(status_code=404, detail="Export file not found")
media_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
return FileResponse(path, media_type=media_type)
# --------------------------------------------------------------------------- #
# Folders (user-managed directories)
# --------------------------------------------------------------------------- #
@router.get("/folders")
@require_auth
async def list_folders(request: Request) -> dict[str, Any]:
"""List every managed knowledge directory (for the directory tree + picker)."""
service = get_knowledge_service(request)
return {"items": await service.list_folders()}
@router.post("/folders")
@require_auth
async def create_folder(body: CreateFolderRequest, request: Request) -> dict[str, Any]:
"""Create a directory (parents auto-created). Notes are filed into it later."""
service = get_knowledge_service(request)
try:
folder = await service.create_folder(body.path, created_by=get_effective_user_id())
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"folder": folder}
@router.put("/folders/{folder_id}")
@require_auth
async def rename_folder(folder_id: str, body: RenameFolderRequest, request: Request) -> dict[str, Any]:
"""Rename/move a directory; descendants + filed notes follow the new prefix."""
service = get_knowledge_service(request)
try:
folder = await service.rename_folder(folder_id, body.path)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if folder is None:
raise HTTPException(status_code=404, detail="Folder not found")
return {"folder": folder}
@router.delete("/folders/{folder_id}")
@require_auth
async def delete_folder(folder_id: str, request: Request, reassign_to: str | None = None) -> dict[str, Any]:
"""Delete a directory (and sub-dirs); notes inside move to ``reassign_to`` or unfiled."""
service = get_knowledge_service(request)
if not await service.delete_folder(folder_id, reassign_to=reassign_to):
raise HTTPException(status_code=404, detail="Folder not found")
return {"ok": True}
# --------------------------------------------------------------------------- #
# Extraction prompt templates
# --------------------------------------------------------------------------- #
@router.get("/extract-templates")
@require_auth
async def list_extract_templates(request: Request, scope: str | None = None, enabled_only: bool = False) -> dict[str, Any]:
"""List editable extraction-prompt templates (optionally filtered by scope)."""
service = get_knowledge_service(request)
return {"items": await service.list_extract_templates(scope=scope, enabled_only=enabled_only)}
@router.get("/extract-templates/{template_id}")
@require_auth
async def get_extract_template(template_id: str, request: Request) -> dict[str, Any]:
service = get_knowledge_service(request)
tpl = await service.get_extract_template(template_id)
if tpl is None:
raise HTTPException(status_code=404, detail="Template not found")
return {"template": tpl}
@router.post("/extract-templates")
@require_auth
async def create_extract_template(body: ExtractTemplateRequest, request: Request) -> dict[str, Any]:
service = get_knowledge_service(request)
if await service.template_name_taken(body.name):
raise HTTPException(status_code=409, detail="同名模板已存在")
tpl = await service.create_extract_template(
name=body.name,
system_prompt=body.system_prompt,
scope=body.scope,
description=body.description,
is_default=body.is_default,
enabled=body.enabled,
created_by=get_effective_user_id(),
)
return {"template": tpl}
@router.put("/extract-templates/{template_id}")
@require_auth
async def update_extract_template(template_id: str, body: UpdateExtractTemplateRequest, request: Request) -> dict[str, Any]:
service = get_knowledge_service(request)
if body.name is not None and await service.template_name_taken(body.name, exclude_id=template_id):
raise HTTPException(status_code=409, detail="同名模板已存在")
tpl = await service.update_extract_template(
template_id,
name=body.name,
system_prompt=body.system_prompt,
scope=body.scope,
description=body.description,
is_default=body.is_default,
enabled=body.enabled,
updated_by=get_effective_user_id(),
)
if tpl is None:
raise HTTPException(status_code=404, detail="Template not found")
return {"template": tpl}
@router.delete("/extract-templates/{template_id}")
@require_auth
async def delete_extract_template(template_id: str, request: Request) -> dict[str, Any]:
service = get_knowledge_service(request)
if not await service.delete_extract_template(template_id):
raise HTTPException(status_code=404, detail="Template not found")
return {"ok": True}
@@ -110,6 +110,10 @@ class PositionUsersResponse(BaseModel):
users: list[PositionUserResponse]
class MyPositionResponse(BaseModel):
position: PositionResponse | None = None
# ----------------------------------------------------------------- position CRUD
@@ -127,6 +131,28 @@ async def list_position_users(request: Request, store: PositionStore = Depends(g
return PositionUsersResponse(users=[PositionUserResponse(**u) for u in users])
@router.get("/me", response_model=MyPositionResponse, summary="Get My Effective Position")
async def get_my_position(request: Request, store: PositionStore = Depends(get_position_store)) -> MyPositionResponse:
"""The caller's own position — open to any authenticated user (not admin).
Returns the explicitly-assigned position, falling back to the default
position (which unassigned users inherit). ``null`` when neither exists or
the caller is anonymous. Powers the top-bar 岗位 label.
"""
user = await get_optional_user_from_request(request)
if user is None:
return MyPositionResponse(position=None)
record = None
pid = await store.get_user_position_id(str(user.id))
if pid:
record = await store.get_position(pid)
if record is None:
default_id = await store.get_default_position_id()
if default_id:
record = await store.get_position(default_id)
return MyPositionResponse(position=PositionResponse(**record) if record else None)
@router.post("", response_model=PositionResponse, status_code=201, summary="Create Position")
async def create_position(request: Request, body: PositionCreateRequest, store: PositionStore = Depends(get_position_store)) -> PositionResponse:
await _require_admin(request)
@@ -154,6 +154,7 @@ class SkillResponse(BaseModel):
license: str | None = Field(None, description="License information")
category: SkillCategory = Field(..., description="Category of the skill (public or custom)")
enabled: bool = Field(default=True, description="Whether this skill is enabled")
always_on: bool = Field(default=False, description="常驻(免压缩):开启技能压缩后仍全文注入系统提示词")
use_count: int = Field(default=0, description="Number of times the skill has been used")
view_count: int = Field(default=0, description="Number of times the skill has been viewed")
patch_count: int = Field(default=0, description="Number of times the skill has been patched")
@@ -186,6 +187,12 @@ class SkillUpdateRequest(BaseModel):
enabled: bool = Field(..., description="Whether to enable or disable the skill")
class SkillAlwaysOnUpdateRequest(BaseModel):
"""Request model for updating a skill's 常驻(免压缩) flag."""
always_on: bool = Field(..., description="Whether this skill bypasses skill compression (full-text injected)")
class SkillDisplayUpdateRequest(BaseModel):
"""Request model for updating a skill's frontend display adapter."""
@@ -334,6 +341,7 @@ def _skill_to_response(
license=skill.license,
category=skill.category,
enabled=skill.enabled,
always_on=skill.always_on,
use_count=entry.get("use_count", 0),
view_count=entry.get("view_count", 0),
patch_count=entry.get("patch_count", 0),
@@ -2186,6 +2194,7 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App
current_skill_config = extensions_config.skills.get(skill_name)
extensions_config.skills[skill_name] = SkillStateConfig(
enabled=request.enabled,
always_on=current_skill_config.always_on if current_skill_config else skill.always_on,
display=current_skill_config.display if current_skill_config else None,
)
@@ -2214,6 +2223,55 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App
raise HTTPException(status_code=500, detail=f"Failed to update skill: {str(e)}")
@router.put(
"/skills/{skill_name}/always-on",
response_model=SkillResponse,
summary="Update Skill 常驻(免压缩) Flag",
description="Mark a skill as 常驻(免压缩) so it is injected full-text into the system prompt even when skill compression is enabled.",
)
async def update_skill_always_on(skill_name: str, request: SkillAlwaysOnUpdateRequest, config: AppConfig = Depends(get_config)) -> SkillResponse:
try:
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
skill = next((s for s in skills if s.name == skill_name), None)
if skill is None:
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
config_path = ExtensionsConfig.resolve_config_path()
if config_path is None:
config_path = Path.cwd().parent / "extensions_config.json"
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
extensions_config = get_extensions_config()
current_skill_config = extensions_config.skills.get(skill_name)
extensions_config.skills[skill_name] = SkillStateConfig(
enabled=current_skill_config.enabled if current_skill_config else skill.enabled,
always_on=request.always_on,
display=current_skill_config.display if current_skill_config else None,
)
with open(config_path, "w", encoding="utf-8") as f:
json.dump(_dump_extensions_config(extensions_config), f, indent=2)
reload_extensions_config()
await refresh_skills_system_prompt_cache_async()
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
updated_skill = next((s for s in skills if s.name == skill_name), None)
if updated_skill is None:
raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after update")
logger.info(f"Skill '{skill_name}' always_on updated to {request.always_on}")
return _skill_to_response(updated_skill)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update skill always_on {skill_name}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to update skill: {str(e)}")
@router.put(
"/skills/{skill_name}/display",
response_model=SkillResponse,
@@ -2242,6 +2300,7 @@ async def update_skill_display(
current_skill_config = extensions_config.skills.get(skill_name)
extensions_config.skills[skill_name] = SkillStateConfig(
enabled=current_skill_config.enabled if current_skill_config else skill.enabled,
always_on=current_skill_config.always_on if current_skill_config else skill.always_on,
display=request.display,
)
@@ -16,6 +16,7 @@ from deerflow.config.system_settings import (
PromptPrefixSettings,
PublishSquare,
PublishSquaresSettings,
SkillCompressionSettings,
SystemSettings,
list_publish_squares,
load_system_settings,
@@ -113,6 +114,64 @@ async def update_prompt_prefix(body: PromptPrefixUpdateRequest, request: Request
return _to_response(saved.prompt_prefix)
class SkillCompressionResponse(BaseModel):
enabled: bool = Field(..., description="Whether skill compression is active")
search_top_n: int = Field(..., description="Max skills returned by the search_skills tool")
keep_index_in_prompt: bool = Field(..., description="Whether compressed skills keep a compact name index in the prompt")
class SkillCompressionUpdateRequest(BaseModel):
enabled: bool | None = None
search_top_n: int | None = Field(default=None, ge=1, le=50)
keep_index_in_prompt: bool | None = None
def _skill_compression_to_response(settings: SkillCompressionSettings) -> SkillCompressionResponse:
return SkillCompressionResponse(
enabled=settings.enabled,
search_top_n=settings.search_top_n,
keep_index_in_prompt=settings.keep_index_in_prompt,
)
@router.get("/skill-compression", response_model=SkillCompressionResponse)
async def get_skill_compression() -> SkillCompressionResponse:
"""Return the skill-compression configuration (readable by any user).
The frontend reads this to decide whether to surface the per-skill 常驻
(免压缩) toggle on the skill detail page.
"""
return _skill_compression_to_response(load_system_settings().skill_compression)
@router.put("/skill-compression", response_model=SkillCompressionResponse)
async def update_skill_compression(body: SkillCompressionUpdateRequest, request: Request) -> SkillCompressionResponse:
"""Update the skill-compression configuration. Admin only."""
await _require_admin(request)
current = load_system_settings()
fields = body.model_dump(exclude_unset=True)
update: dict[str, object] = {}
if "enabled" in fields and fields["enabled"] is not None:
update["enabled"] = bool(fields["enabled"])
if "keep_index_in_prompt" in fields and fields["keep_index_in_prompt"] is not None:
update["keep_index_in_prompt"] = bool(fields["keep_index_in_prompt"])
if "search_top_n" in fields and fields["search_top_n"] is not None:
update["search_top_n"] = max(1, min(int(fields["search_top_n"]), 50))
compression = current.skill_compression.model_copy(update=update)
saved = save_system_settings(current.model_copy(update={"skill_compression": compression}))
# 压缩开关/参数变化会改变系统提示词的技能注入,清掉提示词缓存让其立即生效。
try:
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
await refresh_skills_system_prompt_cache_async()
except Exception:
logger.debug("Failed to refresh skills prompt cache after compression update", exc_info=True)
return _skill_compression_to_response(saved.skill_compression)
def _leaderboard_to_response(settings: LeaderboardSettings) -> LeaderboardSettingsResponse:
return LeaderboardSettingsResponse(
exclude_user_ids=list(dict.fromkeys([uid for uid in settings.exclude_user_ids if uid])),
@@ -23,6 +23,7 @@ LangChain's stock `SummarizationMiddleware` runs in `before_model` and persists
- The full original messages stay in the checkpoint, so the frontend keeps showing the user's real conversation. The summary message is named `summary` and flagged `hide_from_ui`, and because it only ever lives in the transient request it never reaches the frontend at all.
- The generated summary is cached **per-thread** so we don't pay an extra LLM call on every model invocation. The cached summary is reused (no LLM call) until enough *new* messages accumulate to push the compressed context back over the trigger threshold (a sticky boundary mirroring the stock middleware's post-compaction behavior). The cache is bounded (oldest threads evicted first).
- **Threshold-first**: while the full current conversation is still under the trigger, the request is passed through untouched — no cache lookup, no summary substitution. Compression only ever engages once the real context crosses the configured trigger.
- **Stickiness headroom**: after `keep` picks a cutoff, the kept window is forced below ~75% of *every* trigger before summarizing (`_apply_stickiness_headroom`). This prevents the every-turn re-compaction that happens when `keep` is in *messages* (e.g. 20) but a trigger is in *tokens* (e.g. 60000) and the recent messages are large — without headroom the "compressed" context would still sit over the trigger and re-summarize on every follow-up question.
- When the genuine (blocking) summarize path actually runs an LLM call, the middleware emits a `{"type":"context_compacting","message":"正在压缩上下文…"}` event on LangGraph's `custom` stream channel so the chat UI can show a transient "正在压缩" toast (the cheap cache-reuse path stays silent). Frontend handler: `onCustomEvent` in `frontend-web/src/core/threads/hooks.ts`.
> **Legacy threads:** conversations compacted by an older (destructive) build already lost their original messages and carry a persisted `summary` message. The frontend hides any `name: "summary"` message, so the stray summary bubble no longer shows — but those already-discarded messages cannot be recovered.
@@ -758,51 +758,83 @@ def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig
from deerflow.config.memory_config import get_effective_memory_config
from deerflow.runtime.user_context import get_effective_user_id
user_id = get_effective_user_id()
config = get_effective_memory_config(user_id)
manager = build_memory_manager(
user_id=user_id,
agent_id=agent_name or "default",
config=config,
app_config=app_config,
)
user_id = get_effective_user_id()
config = get_effective_memory_config(user_id)
manager = build_memory_manager(
user_id=user_id,
agent_id=agent_name or "default",
config=config,
app_config=app_config,
)
# 只取静态块 (builtin 的 frozen snapshot + Hindsight 的模式说明)。
# Hindsight 的动态召回需要当前 query,走 MemoryMiddleware.before_model。
context_content = manager.build_system_prompt()
if not context_content.strip():
return ""
return f"{context_content}\n"
context_content = manager.build_system_prompt()
if not context_content.strip():
return ""
return f"{context_content}\n"
except Exception:
logger.exception("Failed to load memory context")
return ""
def _render_skill_block(name: str, description: str, category: str, location: str, display_hint: str) -> str:
return f" <skill>\n <name>{name}</name>\n <description>{description} {_skill_mutability_label(category)}</description>\n <location>{location}</location>{display_hint}\n </skill>"
@lru_cache(maxsize=32)
def _get_cached_skills_prompt_section(
skill_signature: tuple[tuple[str, str, str, str], ...],
skill_signature: tuple[tuple[str, str, str, str, bool], ...],
display_signature: tuple[tuple[str, str, str], ...],
available_skills_key: tuple[str, ...] | None,
container_base_path: str,
skill_evolution_section: str,
compression_enabled: bool,
keep_index: bool,
) -> str:
display_by_skill = {name: (mode, custom_instruction) for name, mode, custom_instruction in display_signature}
filtered = [(name, description, category, location) for name, description, category, location in skill_signature if available_skills_key is None or name in available_skills_key]
filtered = [(name, description, category, location, always_on) for name, description, category, location, always_on in skill_signature if available_skills_key is None or name in available_skills_key]
def _display_hint(name: str) -> str:
display_mode, custom_instruction = display_by_skill.get(name, ("", ""))
if display_mode:
return f"\n <display mode=\"{display_mode}\">{custom_instruction}</display>"
return ""
skills_list = ""
compression_note = ""
if filtered:
items = []
for name, description, category, location in filtered:
display_mode, custom_instruction = display_by_skill.get(name, ("", ""))
display_hint = ""
if display_mode:
display_hint = f"\n <display mode=\"{display_mode}\">{custom_instruction}</display>"
items.append(
f" <skill>\n <name>{name}</name>\n <description>{description} {_skill_mutability_label(category)}</description>\n <location>{location}</location>{display_hint}\n </skill>"
)
skill_items = "\n".join(items)
skills_list = f"<available_skills>\n{skill_items}\n</available_skills>"
if compression_enabled:
# 压缩开启:常驻(always_on)技能全文注入;其余技能从提示词正文剔除,
# 仅保留可选的"名字索引",由 agent 通过 search_skills 工具按需检索。
resident = [s for s in filtered if s[4]]
compressed = [s for s in filtered if not s[4]]
blocks: list[str] = []
for name, description, category, location, _ in resident:
blocks.append(_render_skill_block(name, description, category, location, _display_hint(name)))
if blocks:
skills_list = "<available_skills>\n" + "\n".join(blocks) + "\n</available_skills>"
if compressed:
index_block = ""
if keep_index:
index_items = "\n".join(f" <skill><name>{name}</name></skill>" for name, _, _, _, _ in compressed)
index_block = f"\n<compressed_skills>\n{index_items}\n</compressed_skills>"
compression_note = (
"\n**Additional skills are available but not listed in full above to keep this prompt compact.**\n"
"When a user query might match a skill that is NOT fully described above, call the `search_skills` "
"tool with a short keyword query to discover matching skills (it returns each match's description and "
f"`location`), then `read_file` the returned `location` and follow the skill. There are {len(compressed)} "
"such compressed skill(s)."
f"{index_block}\n"
)
else:
items = [
_render_skill_block(name, description, category, location, _display_hint(name))
for name, description, category, location, _ in filtered
]
skills_list = "<available_skills>\n" + "\n".join(items) + "\n</available_skills>"
# Restricted mode: agent has an explicit skill allowlist.
# We deliberately omit the "Skills are located at: <path>" hint and the
@@ -842,7 +874,7 @@ You have access to skills that provide optimized workflows for specific tasks. E
4. Load referenced resources only when needed during execution
5. Follow the skill's instructions precisely
{display_pattern}
{location_hint}{evolution_block}
{location_hint}{evolution_block}{compression_note}
{skills_list}
</skill_system>"""
@@ -877,13 +909,27 @@ def get_skills_prompt_section(
is_restricted = available_skills is not None
effective_skill_evolution_enabled = skill_evolution_enabled and not is_restricted
# 技能压缩仅作用于默认 lead agent 的全局技能集;自定义 agent 已有显式白名单
# (技能数量本就受控),不参与压缩。
compression_enabled = False
keep_index = True
if not is_restricted:
try:
from deerflow.config.system_settings import get_skill_compression_settings
compression_settings = get_skill_compression_settings()
compression_enabled = compression_settings.enabled
keep_index = compression_settings.keep_index_in_prompt
except Exception:
logger.debug("Failed to read skill compression settings for prompt injection", exc_info=True)
if not skills and not effective_skill_evolution_enabled:
return ""
if available_skills is not None and not any(skill.name in available_skills for skill in skills):
return ""
skill_signature = tuple((skill.name, skill.description, skill.category, skill.get_container_file_path(container_base_path)) for skill in skills)
skill_signature = tuple((skill.name, skill.description, skill.category, skill.get_container_file_path(container_base_path), skill.always_on) for skill in skills)
display_signature: tuple[tuple[str, str, str], ...] = ()
if display_enabled:
try:
@@ -907,7 +953,7 @@ def get_skills_prompt_section(
if not skill_signature and available_key is not None:
return ""
skill_evolution_section = _build_skill_evolution_section(effective_skill_evolution_enabled)
return _get_cached_skills_prompt_section(skill_signature, display_signature, available_key, container_base_path, skill_evolution_section)
return _get_cached_skills_prompt_section(skill_signature, display_signature, available_key, container_base_path, skill_evolution_section, compression_enabled, keep_index)
def get_agent_soul(agent_id: str | None) -> str:
@@ -37,6 +37,15 @@ logger = logging.getLogger(__name__)
# many threads can't grow it without limit. Oldest entries are evicted first.
_SUMMARY_CACHE_MAX_THREADS = 512
# After compaction the preserved window must land *comfortably* below every
# trigger threshold, otherwise the "compressed" context is still over the
# trigger and re-summarizes on the very next turn (the sticky boundary never
# sticks). We force the kept suffix down to this fraction of each trigger so a
# re-compaction only happens once meaningfully *new* content accumulates — not
# on every follow-up question. ``keep`` only sets a *target*; this is the hard
# headroom guarantee on top of it.
_KEEP_HEADROOM_FRACTION = 0.75
@dataclass(frozen=True)
class SummarizationEvent:
@@ -319,6 +328,15 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
if cutoff_index <= 0:
return None
# Force the preserved window below every trigger (with headroom) so the
# post-compaction context doesn't immediately re-trigger. Without this,
# a ``keep`` expressed in *messages* (e.g. 20) can itself blow past a
# *token* trigger (e.g. 60000) when recent messages are large (tool
# outputs, pasted docs) — and then every follow-up question re-summarizes.
cutoff_index = self._apply_stickiness_headroom(messages, cutoff_index)
if cutoff_index <= 0:
return None
messages_to_summarize, preserved = self._partition_with_skill_rescue(messages, cutoff_index)
if not messages_to_summarize:
return None
@@ -368,6 +386,62 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
while len(self._summary_cache) > _SUMMARY_CACHE_MAX_THREADS:
self._summary_cache.popitem(last=False)
def _apply_stickiness_headroom(self, messages: list[AnyMessage], cutoff_index: int) -> int:
"""Advance ``cutoff_index`` (keep fewer recent messages) so the preserved
suffix lands below every trigger threshold with headroom.
Only ever *raises* the cutoff (summarizes more / keeps less); never keeps
more than the caller's ``keep`` policy already chose. This is what makes
the sticky boundary actually stick: after compaction the kept context is
guaranteed under ~``_KEEP_HEADROOM_FRACTION`` of each trigger, so a
re-compaction waits for genuinely new content instead of firing every turn.
"""
n = len(messages)
if n <= 1:
return cutoff_index
for kind, value in getattr(self, "_trigger_conditions", []):
if kind == "messages":
keep_cap = max(1, int(int(value) * _KEEP_HEADROOM_FRACTION))
cutoff_index = max(cutoff_index, n - keep_cap)
elif kind == "tokens":
target = max(1, int(int(value) * _KEEP_HEADROOM_FRACTION))
cutoff_index = max(cutoff_index, self._suffix_token_cutoff(messages, target))
# "fraction" triggers depend on model profile limits; the message/token
# caps above (when present) already bound the window, so skip them here.
# Always keep at least the final message, and re-snap to a safe AI/Tool
# boundary so we never orphan a tool result.
cutoff_index = min(cutoff_index, n - 1)
return self._find_safe_cutoff_point(messages, cutoff_index)
def _suffix_token_cutoff(self, messages: list[AnyMessage], target_tokens: int) -> int:
"""Smallest index ``i`` such that ``token_counter(messages[i:]) <= target_tokens``.
Binary search mirroring the stock token-based cutoff; returns 0 when the
whole list already fits, and never returns the full length (keeps ≥1).
"""
n = len(messages)
if n == 0:
return 0
if self.token_counter(messages) <= target_tokens:
return 0
left, right = 0, n
cutoff = n
for _ in range(n.bit_length() + 1):
if left >= right:
break
mid = (left + right) // 2
if self.token_counter(messages[mid:]) <= target_tokens:
cutoff = mid
right = mid
else:
left = mid + 1
if cutoff == n:
cutoff = left
return min(cutoff, n - 1)
@staticmethod
def _resolve_request_thread_id(request: ModelRequest) -> str | None:
"""Best-effort thread id from the model request's runtime."""
@@ -840,13 +840,13 @@ class DeerFlowClient:
current_config = get_extensions_config()
config_data = {
"mcpServers": mcp_servers,
"skills": {
name: skill.model_dump(by_alias=True, exclude_none=True)
for name, skill in current_config.skills.items()
},
}
config_data = {
"mcpServers": mcp_servers,
"skills": {
name: skill.model_dump(by_alias=True, exclude_none=True)
for name, skill in current_config.skills.items()
},
}
self._atomic_write_json(config_path, config_data)
@@ -906,20 +906,21 @@ class DeerFlowClient:
if config_path is None:
raise FileNotFoundError("Cannot locate extensions_config.json. Set DEER_FLOW_EXTENSIONS_CONFIG_PATH or ensure it exists in the project root.")
extensions_config = get_extensions_config()
current_skill_config = extensions_config.skills.get(name)
extensions_config.skills[name] = SkillStateConfig(
enabled=enabled,
display=current_skill_config.display if current_skill_config else None,
)
config_data = {
"mcpServers": {n: s.model_dump(by_alias=True) for n, s in extensions_config.mcp_servers.items()},
"skills": {
n: sc.model_dump(by_alias=True, exclude_none=True)
for n, sc in extensions_config.skills.items()
},
}
extensions_config = get_extensions_config()
current_skill_config = extensions_config.skills.get(name)
extensions_config.skills[name] = SkillStateConfig(
enabled=enabled,
always_on=current_skill_config.always_on if current_skill_config else False,
display=current_skill_config.display if current_skill_config else None,
)
config_data = {
"mcpServers": {n: s.model_dump(by_alias=True) for n, s in extensions_config.mcp_servers.items()},
"skills": {
n: sc.model_dump(by_alias=True, exclude_none=True)
for n, sc in extensions_config.skills.items()
},
}
self._atomic_write_json(config_path, config_data)
@@ -161,6 +161,10 @@ class SkillStateConfig(BaseModel):
"""Configuration for a single skill's state."""
enabled: bool = Field(default=True, description="Whether this skill is enabled")
always_on: bool = Field(
default=False,
description="常驻(免压缩):开启技能压缩后,本技能仍以全文形式注入系统提示词,而非降级为索引+search_skills 检索",
)
display: SkillDisplayConfig | None = Field(default=None, description="Optional frontend display adapter")
@@ -313,6 +317,21 @@ class ExtensionsConfig(BaseModel):
return skill_category in ("public", "custom")
return skill_config.enabled
def is_skill_always_on(self, skill_name: str) -> bool:
"""Check if a skill is marked 常驻(免压缩).
Args:
skill_name: Name of the skill
Returns:
True if the skill should bypass skill compression, False otherwise
(default for skills without an explicit config entry).
"""
skill_config = self.skills.get(skill_name)
if skill_config is None:
return False
return skill_config.always_on
_extensions_config: ExtensionsConfig | None = None
@@ -170,6 +170,26 @@ class LeaderboardSettings(BaseModel):
model_config = ConfigDict(extra="ignore")
class SkillCompressionSettings(BaseModel):
"""Admin-configurable skill compression (技能压缩).
When ``enabled``, only skills explicitly marked 常驻(免压缩) (``always_on``)
are injected full-text into the lead agent's system prompt. Every other
enabled skill is "compressed": kept out of the prompt body and surfaced to
the agent on demand via the ``search_skills`` keyword-retrieval tool (and a
compact name index when ``keep_index_in_prompt`` is true). When disabled the
behaviour is unchanged — every enabled skill is injected full-text.
"""
enabled: bool = Field(default=False, description="Whether skill compression is active (master switch)")
search_top_n: int = Field(default=5, ge=1, le=50, description="Max skills returned by the search_skills tool")
keep_index_in_prompt: bool = Field(
default=True,
description="Whether compressed skills still appear as a compact name-only index in the prompt",
)
model_config = ConfigDict(extra="ignore")
class SystemSettings(BaseModel):
"""Top-level system settings document."""
@@ -179,6 +199,7 @@ class SystemSettings(BaseModel):
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings)
leaderboard: LeaderboardSettings = Field(default_factory=LeaderboardSettings)
skill_compression: SkillCompressionSettings = Field(default_factory=SkillCompressionSettings)
model_config = ConfigDict(extra="ignore")
@@ -204,6 +225,15 @@ def load_system_settings() -> SystemSettings:
return SystemSettings()
def get_skill_compression_settings() -> SkillCompressionSettings:
"""Convenience accessor for the skill-compression block (safe defaults on error)."""
try:
return load_system_settings().skill_compression
except Exception:
logger.debug("Failed to read skill compression settings; using defaults", exc_info=True)
return SkillCompressionSettings()
def save_system_settings(settings: SystemSettings) -> SystemSettings:
"""Persist the settings document atomically, returning the saved value."""
path = _settings_path()
@@ -120,14 +120,18 @@ def related_links(project_name: str | None) -> list[str]: # noqa: ARG001
_related_links = related_links
def _related_section_md(related_lines: list[str], source_lines: list[str]) -> str:
"""Render the ``## Related`` section, or ``""`` when there is nothing to show."""
if not related_lines and not source_lines:
def _related_section_md(related_lines: list[str], source_lines: list[str] | None = None) -> str: # noqa: ARG001
"""Render the ``## Related`` section, or ``""`` when there is nothing to show.
Sources are intentionally **not** rendered into the body anymore. The note
detail page already shows every source once via the structured ``来源`` block
(``note.sources``); emitting a ``### Sources`` sub-section here duplicated that
list (and broke the frontend's clean ``## Related`` extraction). The
``source_lines`` argument is kept for backward compatibility and ignored.
"""
if not related_lines:
return ""
inner = "\n".join(related_lines)
if source_lines:
sources_block = "### Sources\n\n" + "\n".join(source_lines)
inner = (inner + "\n\n" + sources_block) if inner else sources_block
return f"## Related\n\n{inner}\n"
@@ -283,13 +283,16 @@ async def extract_thread_llm_multi(
max_input_chars: int = 12000,
allow_multi: bool = True,
max_notes: int = 4,
system_prompt: str | None = None,
) -> list[NoteDraft]:
"""Distill a thread into one *or several* cross-linkable knowledge notes.
The LLM may split a conversation that covers multiple independent topics into
several declarative notes. Returns ``[]`` when there is nothing to sediment
or the LLM path fails — callers should fall back to the rule-based extractor.
When ``allow_multi`` is false only the first note is kept.
When ``allow_multi`` is false only the first note is kept. ``system_prompt``
overrides the built-in distillation prompt with an operator-edited template
(used as-is, since the template is expected to be self-contained).
"""
turns = extractor.split_turns(messages, max_messages=max_messages)
if not turns:
@@ -303,8 +306,9 @@ async def extract_thread_llm_multi(
model = create_chat_model(name=model_name or None, thinking_enabled=False)
transcript = _build_transcript(turns, sources, max_chars=max_input_chars)
system_text = (system_prompt or "").strip() or (_SYSTEM_PROMPT + "\n\n" + WIKI_CAPTURE_GUIDANCE)
prompt = [
SystemMessage(content=_SYSTEM_PROMPT + "\n\n" + WIKI_CAPTURE_GUIDANCE),
SystemMessage(content=system_text),
HumanMessage(content=f"以下是一次对话,请按规范提炼为知识 JSON:\n\n{transcript}"),
]
response = await model.ainvoke(prompt)
@@ -375,6 +379,7 @@ async def extract_document_llm_multi(
max_input_chars: int = 12000,
allow_multi: bool = True,
max_notes: int = 4,
system_prompt: str | None = None,
) -> list[NoteDraft]:
"""Distill an imported **document** into one *or several* knowledge notes.
@@ -397,8 +402,9 @@ async def extract_document_llm_multi(
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 ""
system_text = (system_prompt or "").strip() or (_DOC_SYSTEM_PROMPT + "\n\n" + WIKI_CAPTURE_GUIDANCE)
prompt = [
SystemMessage(content=_DOC_SYSTEM_PROMPT + "\n\n" + WIKI_CAPTURE_GUIDANCE),
SystemMessage(content=system_text),
HumanMessage(content=f"以下是一篇文档,请按规范提炼为知识 JSON:\n\n{header}{body}"),
]
response = await model.ainvoke(prompt)
@@ -33,6 +33,7 @@ class KnowledgeNoteRow(Base):
__table_args__ = (
Index("ix_knowledge_notes_vault_path", "vault_path", mysql_length=191),
Index("ix_knowledge_notes_source_id", "source_id", mysql_length=191),
Index("ix_knowledge_notes_folder", "folder", mysql_length=191),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
@@ -40,6 +41,10 @@ class KnowledgeNoteRow(Base):
summary: Mapped[str | None] = mapped_column(Text)
content_md: Mapped[str] = mapped_column(PortableLongText())
vault_path: Mapped[str | None] = mapped_column(String(1024))
# User-assigned directory (slash-joined path, e.g. "投研/行业"). Independent
# of the wiki-capture ``category`` and of ``vault_path``; drives the product
# directory tree. NULL = unfiled (shown under a fallback group).
folder: Mapped[str | None] = mapped_column(String(512))
source_type: Mapped[str] = mapped_column(String(64), index=True)
source_id: Mapped[str | None] = mapped_column(String(256))
status: Mapped[str] = mapped_column(String(32), default="approved", index=True)
@@ -170,3 +175,49 @@ class KnowledgeRelationRow(Base):
relation_type: Mapped[str] = mapped_column(String(128))
weight: Mapped[float] = mapped_column(Float, default=1.0)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
class KnowledgeFolderRow(Base):
"""User-managed knowledge directory (mirrors ``knowledge_folders``).
A folder is identified by its full slash-joined ``path`` (e.g. ``投研/行业``);
nesting is derived by splitting on ``/`` on the client. Folders exist
independently of notes so an operator can create an empty directory and file
notes into it later (``knowledge_notes.folder`` stores the chosen path).
Global/shared like the rest of the KB; ``created_by`` is audit-only.
"""
__tablename__ = "knowledge_folders"
__table_args__ = (Index("uq_knowledge_folders_path", "path", unique=True, mysql_length=191),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
path: Mapped[str] = mapped_column(String(512))
name: Mapped[str] = mapped_column(String(256))
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_by: Mapped[str | None] = mapped_column(String(128))
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
class KnowledgeExtractTemplateRow(Base):
"""Editable knowledge-extraction prompt template (mirrors ``knowledge_extract_templates``).
Lets an operator open up, edit and add multiple distillation prompts instead
of the hard-coded constants. ``scope`` is ``thread`` / ``document`` / ``both``;
at capture time the service resolves the chosen template (or the default for
that scope) and feeds its ``system_prompt`` to the extractor. Global/shared.
"""
__tablename__ = "knowledge_extract_templates"
__table_args__ = (Index("uq_knowledge_extract_templates_name", "name", unique=True, mysql_length=191),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(256))
description: Mapped[str | None] = mapped_column(Text)
scope: Mapped[str] = mapped_column(String(16), default="both", index=True)
system_prompt: Mapped[str] = mapped_column(PortableLongText())
is_default: Mapped[bool] = mapped_column(default=False, index=True)
enabled: Mapped[bool] = mapped_column(default=True)
created_by: Mapped[str | None] = mapped_column(String(128))
updated_by: Mapped[str | None] = mapped_column(String(128))
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), index=True)
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -18,6 +18,8 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.knowledge.models import (
KnowledgeEmbeddingRow,
KnowledgeEntityRow,
KnowledgeExtractTemplateRow,
KnowledgeFolderRow,
KnowledgeGraphBlacklistRow,
KnowledgeNoteRow,
KnowledgeNoteTagRow,
@@ -127,6 +129,7 @@ class KnowledgeRepository:
status: str,
confidence: float = 0.0,
vault_path: str | None = None,
folder: str | None = None,
tags: list[str] | None = None,
created_by: str | None = None,
) -> dict[str, Any]:
@@ -138,6 +141,7 @@ class KnowledgeRepository:
summary=summary,
content_md=content_md,
vault_path=vault_path,
folder=(folder or None),
source_type=source_type,
source_id=source_id,
status=status,
@@ -175,6 +179,8 @@ class KnowledgeRepository:
status: str | None = None,
tags: list[str] | None = None,
vault_path: str | None = None,
folder: str | None = None,
set_folder: bool = False,
updated_by: str | None = None,
) -> dict[str, Any] | None:
async with self._sf() as session:
@@ -191,6 +197,10 @@ class KnowledgeRepository:
row.status = status
if vault_path is not None:
row.vault_path = vault_path
# ``set_folder`` lets the caller assign *or clear* (folder=None) the
# directory; a bare ``folder=None`` without the flag leaves it as-is.
if set_folder:
row.folder = (folder or None)
if updated_by is not None:
row.updated_by = updated_by
row.updated_at = datetime.now(UTC)
@@ -279,6 +289,7 @@ class KnowledgeRepository:
entity: str | None = None,
source_type: str | None = None,
status: str | None = None,
folder: str | None = None,
include_archived: bool = False,
limit: int = 20,
offset: int = 0,
@@ -294,6 +305,12 @@ class KnowledgeRepository:
conditions.append(KnowledgeNoteRow.status != "archived")
if source_type:
conditions.append(KnowledgeNoteRow.source_type == source_type)
if folder is not None:
# Exact folder, plus any nested sub-folders (``folder/…``).
if folder == "":
conditions.append(KnowledgeNoteRow.folder.is_(None))
else:
conditions.append(or_(KnowledgeNoteRow.folder == folder, KnowledgeNoteRow.folder.like(f"{folder}/%")))
if keyword:
like = f"%{keyword}%"
conditions.append(or_(KnowledgeNoteRow.title.like(like), KnowledgeNoteRow.summary.like(like), KnowledgeNoteRow.content_md.like(like)))
@@ -581,3 +598,184 @@ class KnowledgeRepository:
)
)
await session.commit()
# -- folders ----------------------------------------------------------
async def list_folders(self) -> list[dict[str, Any]]:
"""Return every managed directory, ordered by sort_order then path."""
async with self._sf() as session:
stmt = select(KnowledgeFolderRow).order_by(KnowledgeFolderRow.sort_order, KnowledgeFolderRow.path)
return [{"id": r.id, "path": r.path, "name": r.name, "sort_order": r.sort_order} for r in (await session.execute(stmt)).scalars()]
async def create_folder(self, path: str, *, created_by: str | None = None) -> dict[str, Any]:
"""Create a directory by its full slash-joined path (idempotent on path).
Parent directories are auto-created so ``投研/行业/寿险`` materializes all
three levels. Returns the leaf folder row.
"""
segments = [s.strip() for s in (path or "").split("/") if s.strip()]
if not segments:
raise ValueError("folder path is empty")
async with self._sf() as session:
acc: list[str] = []
leaf: KnowledgeFolderRow | None = None
for i, seg in enumerate(segments):
acc.append(seg)
full = "/".join(acc)
row = await self._folder_by_path(session, full)
if row is None:
row = KnowledgeFolderRow(id=_new_id("kbdir"), path=full, name=seg, sort_order=i, created_by=created_by, created_at=datetime.now(UTC))
session.add(row)
await session.flush()
leaf = row
# Capture before commit: the async session expires attributes on
# commit, and re-reading them outside an awaited load would error.
result = {"id": leaf.id, "path": leaf.path, "name": leaf.name, "sort_order": leaf.sort_order}
await session.commit()
return result
@staticmethod
async def _folder_by_path(session: AsyncSession, path: str) -> KnowledgeFolderRow | None:
return (await session.execute(select(KnowledgeFolderRow).where(KnowledgeFolderRow.path == path))).scalars().first()
async def rename_folder(self, folder_id: str, new_path: str) -> dict[str, Any] | None:
"""Rename/move a folder and re-point every descendant folder + note.
Both the folder rows under ``old/…`` and notes whose ``folder`` is ``old``
or ``old/…`` are rewritten to the new prefix, so the move is atomic.
"""
new_path = "/".join(s.strip() for s in (new_path or "").split("/") if s.strip())
if not new_path:
raise ValueError("folder path is empty")
async with self._sf() as session:
row = await session.get(KnowledgeFolderRow, folder_id)
if row is None:
return None
old = row.path
if new_path == old:
return {"id": row.id, "path": row.path, "name": row.name, "sort_order": row.sort_order}
for f in (await session.execute(select(KnowledgeFolderRow).where(or_(KnowledgeFolderRow.path == old, KnowledgeFolderRow.path.like(f"{old}/%"))))).scalars():
f.path = new_path + f.path[len(old):]
f.name = f.path.split("/")[-1]
for n in (await session.execute(select(KnowledgeNoteRow).where(or_(KnowledgeNoteRow.folder == old, KnowledgeNoteRow.folder.like(f"{old}/%"))))).scalars():
n.folder = new_path + (n.folder[len(old):] if n.folder else "")
await session.commit()
refreshed = await session.get(KnowledgeFolderRow, folder_id)
return {"id": refreshed.id, "path": refreshed.path, "name": refreshed.name, "sort_order": refreshed.sort_order}
async def delete_folder(self, folder_id: str, *, reassign_to: str | None = None) -> bool:
"""Delete a folder (and its sub-folders); notes inside move to ``reassign_to`` (or unfiled)."""
async with self._sf() as session:
row = await session.get(KnowledgeFolderRow, folder_id)
if row is None:
return False
old = row.path
target = (reassign_to or "").strip() or None
for n in (await session.execute(select(KnowledgeNoteRow).where(or_(KnowledgeNoteRow.folder == old, KnowledgeNoteRow.folder.like(f"{old}/%"))))).scalars():
n.folder = target
await session.execute(delete(KnowledgeFolderRow).where(or_(KnowledgeFolderRow.path == old, KnowledgeFolderRow.path.like(f"{old}/%"))))
await session.commit()
return True
# -- extraction templates ---------------------------------------------
@staticmethod
def _template_to_dict(row: KnowledgeExtractTemplateRow) -> dict[str, Any]:
d = row.to_dict()
d["created_at"] = _iso(d.get("created_at"))
d["updated_at"] = _iso(d.get("updated_at"))
return d
async def list_templates(self, *, scope: str | None = None, enabled_only: bool = False) -> list[dict[str, Any]]:
async with self._sf() as session:
stmt = select(KnowledgeExtractTemplateRow)
if scope:
stmt = stmt.where(or_(KnowledgeExtractTemplateRow.scope == scope, KnowledgeExtractTemplateRow.scope == "both"))
if enabled_only:
stmt = stmt.where(KnowledgeExtractTemplateRow.enabled.is_(True))
stmt = stmt.order_by(KnowledgeExtractTemplateRow.is_default.desc(), KnowledgeExtractTemplateRow.created_at)
return [self._template_to_dict(r) for r in (await session.execute(stmt)).scalars()]
async def get_template(self, template_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(KnowledgeExtractTemplateRow, template_id)
return self._template_to_dict(row) if row else None
async def get_default_template(self, scope: str) -> dict[str, Any] | None:
"""Return the default enabled template applicable to ``scope`` (thread/document)."""
async with self._sf() as session:
stmt = (
select(KnowledgeExtractTemplateRow)
.where(KnowledgeExtractTemplateRow.enabled.is_(True))
.where(KnowledgeExtractTemplateRow.is_default.is_(True))
.where(or_(KnowledgeExtractTemplateRow.scope == scope, KnowledgeExtractTemplateRow.scope == "both"))
.limit(1)
)
row = (await session.execute(stmt)).scalars().first()
return self._template_to_dict(row) if row else None
async def template_name_taken(self, name: str, *, exclude_id: str | None = None) -> bool:
async with self._sf() as session:
stmt = select(KnowledgeExtractTemplateRow.id).where(func.lower(KnowledgeExtractTemplateRow.name) == (name or "").strip().lower())
if exclude_id:
stmt = stmt.where(KnowledgeExtractTemplateRow.id != exclude_id)
return (await session.execute(stmt)).first() is not None
async def create_template(self, *, name: str, system_prompt: str, scope: str = "both", description: str | None = None, is_default: bool = False, enabled: bool = True, created_by: str | None = None) -> dict[str, Any]:
async with self._sf() as session:
if is_default:
await self._clear_default(session, scope)
row = KnowledgeExtractTemplateRow(
id=_new_id("kbtpl"),
name=name.strip()[:256],
description=description,
scope=scope,
system_prompt=system_prompt,
is_default=is_default,
enabled=enabled,
created_by=created_by,
updated_by=created_by,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(row)
await session.commit()
await session.refresh(row)
return self._template_to_dict(row)
async def update_template(self, template_id: str, *, name: str | None = None, system_prompt: str | None = None, scope: str | None = None, description: str | None = None, is_default: bool | None = None, enabled: bool | None = None, updated_by: str | None = None) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(KnowledgeExtractTemplateRow, template_id)
if row is None:
return None
if name is not None:
row.name = name.strip()[:256]
if system_prompt is not None:
row.system_prompt = system_prompt
if scope is not None:
row.scope = scope
if description is not None:
row.description = description
if enabled is not None:
row.enabled = enabled
if is_default:
await self._clear_default(session, scope or row.scope)
row.is_default = True
elif is_default is False:
row.is_default = False
row.updated_by = updated_by
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._template_to_dict(row)
async def delete_template(self, template_id: str) -> bool:
async with self._sf() as session:
result = await session.execute(delete(KnowledgeExtractTemplateRow).where(KnowledgeExtractTemplateRow.id == template_id))
await session.commit()
return bool(result.rowcount)
@staticmethod
async def _clear_default(session: AsyncSession, scope: str) -> None:
"""Unset is_default on any template that would collide with a new default for ``scope``."""
scopes = {scope, "both"} if scope != "both" else {"thread", "document", "both"}
for row in (await session.execute(select(KnowledgeExtractTemplateRow).where(KnowledgeExtractTemplateRow.is_default.is_(True)).where(KnowledgeExtractTemplateRow.scope.in_(scopes)))).scalars():
row.is_default = False
@@ -48,6 +48,8 @@ class NoteDraft:
source_id: str | None = None
status: str = "approved"
confidence: float = 0.0
# User-assigned directory (slash-joined path); None = unfiled.
folder: str | None = None
tags: list[str] = field(default_factory=list)
key_points: list[str] = field(default_factory=list)
actions: list[str] = field(default_factory=list)
@@ -145,6 +145,7 @@ class KnowledgeService:
source_id=draft.source_id,
status=draft.status,
confidence=draft.confidence,
folder=draft.folder,
tags=draft.tags,
created_by=created_by,
)
@@ -223,15 +224,21 @@ class KnowledgeService:
created_by: str | None = None,
skip_existing: bool = False,
model_name: str | None = None,
template_id: str | None = None,
folder: str | None = None,
) -> dict[str, Any]:
"""Sediment a thread into a knowledge note.
``model_name`` optionally overrides the configured extraction model for
this one capture (falls back to ``config.extract_model_name``).
``template_id`` selects an operator-edited extraction prompt template
(falls back to the ``thread`` default, then the built-in prompt).
``folder`` files the resulting note(s) under a user directory.
Returns ``{"note": <note>, "created": bool}``. Raises
:class:`EmptyThreadError` when there is nothing worth keeping.
"""
extract_prompt = await self._resolve_extract_prompt(template_id, "thread")
# Always-on entry log: proves a capture was triggered for this thread,
# regardless of how it ends (success / skip / fallback). Lets you tell
# "capture never ran" apart from "logs not visible" via one grep.
@@ -276,6 +283,7 @@ class KnowledgeService:
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,
system_prompt=extract_prompt,
)
except llm_extractor.LLMRejectedIngest as exc:
# The model judged this conversation worthless (greeting / meta
@@ -324,6 +332,8 @@ class KnowledgeService:
normalized = normalize_status(effective_status)
for draft in drafts:
draft.status = normalized
if folder:
draft.folder = folder
# Cross-link sibling notes from the same conversation by title, so the
# split pages reference each other (resolved once all are persisted).
if len(drafts) > 1:
@@ -370,6 +380,7 @@ class KnowledgeService:
tags: list[str] | None = None,
status: str = "approved",
summary: str | None = None,
folder: str | None = None,
created_by: str | None = None,
) -> dict[str, Any]:
"""Create a note directly from product-edited Markdown."""
@@ -388,6 +399,7 @@ class KnowledgeService:
source_type="manual",
source_id=None,
status=normalize_status(status),
folder=(folder or None),
tags=base_tags,
confidence=0.5,
related=extractor.related_links(self.project_name),
@@ -431,6 +443,8 @@ class KnowledgeService:
created_by: str | None = None,
model_name: str | None = None,
skip_existing: bool = False,
template_id: str | None = None,
folder: str | None = None,
) -> dict[str, Any]:
"""Import a file (already converted to Markdown/text) into knowledge notes.
@@ -468,6 +482,7 @@ class KnowledgeService:
logger.info("[knowledge] file '%s' already imported (note %s); skipping", filename, existing.get("id"))
return {"note": existing, "created": False, "notes": [existing], "llm_fallback": False}
extract_prompt = await self._resolve_extract_prompt(template_id, "document")
drafts: list[NoteDraft] = []
llm_fallback = False
if self.config.llm_extract_enabled:
@@ -482,6 +497,7 @@ class KnowledgeService:
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,
system_prompt=extract_prompt,
)
except llm_extractor.LLMRejectedIngest as exc:
raise EmptyThreadError(f"File {filename} judged not worth ingesting: {exc}") from exc
@@ -496,6 +512,8 @@ class KnowledgeService:
normalized = normalize_status(effective_status)
for draft in drafts:
draft.status = normalized
if folder:
draft.folder = folder
# Cross-link sibling notes split from the same document (resolved by title
# once all are persisted), mirroring multi-note thread capture.
@@ -526,6 +544,7 @@ class KnowledgeService:
entity: str | None = None,
source_type: str | None = None,
status: str | None = None,
folder: str | None = None,
limit: int = 20,
offset: int = 0,
) -> dict[str, Any]:
@@ -535,6 +554,7 @@ class KnowledgeService:
entity=entity,
source_type=normalize_source_type(source_type) if source_type else None,
status=status,
folder=folder,
limit=limit,
offset=offset,
)
@@ -555,6 +575,8 @@ class KnowledgeService:
tags: list[str] | None = None,
status: str | None = None,
summary: str | None = None,
folder: str | None = None,
set_folder: bool = False,
updated_by: str | None = None,
) -> dict[str, Any] | None:
current = await self.repo.get_note(note_id, with_sources=False)
@@ -576,6 +598,8 @@ class KnowledgeService:
content_md=body,
status=normalize_status(status) if status else None,
tags=tags,
folder=folder,
set_folder=set_folder,
updated_by=updated_by,
)
if note is None:
@@ -604,6 +628,84 @@ class KnowledgeService:
async def list_versions(self, note_id: str) -> list[dict[str, Any]]:
return await self.repo.list_versions(note_id)
# -- folders ----------------------------------------------------------
async def list_folders(self) -> list[dict[str, Any]]:
return await self.repo.list_folders()
async def create_folder(self, path: str, *, created_by: str | None = None) -> dict[str, Any]:
return await self.repo.create_folder(path, created_by=created_by)
async def rename_folder(self, folder_id: str, new_path: str) -> dict[str, Any] | None:
return await self.repo.rename_folder(folder_id, new_path)
async def delete_folder(self, folder_id: str, *, reassign_to: str | None = None) -> bool:
return await self.repo.delete_folder(folder_id, reassign_to=reassign_to)
# -- extraction templates ---------------------------------------------
async def _resolve_extract_prompt(self, template_id: str | None, scope: str) -> str | None:
"""Return the system prompt for a capture, or ``None`` for the built-in default.
Resolution: explicit ``template_id`` → the scope's default template →
``None`` (the extractor then uses its hard-coded prompt). Best-effort: any
lookup failure degrades to the built-in prompt so capture never breaks.
"""
try:
if template_id:
tpl = await self.repo.get_template(template_id)
if tpl and tpl.get("enabled") and (tpl.get("system_prompt") or "").strip():
return tpl["system_prompt"]
default = await self.repo.get_default_template(scope)
if default and (default.get("system_prompt") or "").strip():
return default["system_prompt"]
except Exception:
logger.exception("Failed to resolve knowledge extraction template (id=%s, scope=%s)", template_id, scope)
return None
async def list_extract_templates(self, *, scope: str | None = None, enabled_only: bool = False) -> list[dict[str, Any]]:
return await self.repo.list_templates(scope=scope, enabled_only=enabled_only)
async def get_extract_template(self, template_id: str) -> dict[str, Any] | None:
return await self.repo.get_template(template_id)
async def create_extract_template(self, **kwargs: Any) -> dict[str, Any]:
return await self.repo.create_template(**kwargs)
async def update_extract_template(self, template_id: str, **kwargs: Any) -> dict[str, Any] | None:
return await self.repo.update_template(template_id, **kwargs)
async def delete_extract_template(self, template_id: str) -> bool:
return await self.repo.delete_template(template_id)
async def template_name_taken(self, name: str, *, exclude_id: str | None = None) -> bool:
return await self.repo.template_name_taken(name, exclude_id=exclude_id)
async def seed_default_templates(self, *, created_by: str | None = None) -> int:
"""Seed the two built-in distillation prompts as editable templates (idempotent).
Runs once on startup so an operator immediately sees — and can edit — the
prompts that drive 对话 / 文档 extraction. Existing templates with the same
name are left untouched. Returns the number of templates created.
"""
from deerflow.knowledge import llm_extractor
guidance = llm_extractor.WIKI_CAPTURE_GUIDANCE
seeds = [
("默认 · 对话提炼", "thread", llm_extractor._SYSTEM_PROMPT + "\n\n" + guidance, "对话沉淀的默认提炼提示词(wiki-capture 规范)。"),
("默认 · 文档提炼", "document", llm_extractor._DOC_SYSTEM_PROMPT + "\n\n" + guidance, "文档导入的默认提炼提示词(wiki-capture 规范)。"),
]
created = 0
for name, scope, prompt, desc in seeds:
try:
if await self.repo.template_name_taken(name):
continue
await self.repo.create_template(name=name, system_prompt=prompt, scope=scope, description=desc, is_default=True, enabled=True, created_by=created_by)
created += 1
except Exception:
logger.exception("Failed to seed knowledge extraction template '%s'", name)
if created:
logger.info("[knowledge] seeded %d default extraction template(s)", created)
return created
async def delete_note(self, note_id: str, *, updated_by: str | None = None, hard: bool = False) -> bool:
"""Soft delete (archive) by default; hard delete removes the vault file."""
current = await self.repo.get_note(note_id, with_sources=False)
@@ -0,0 +1,84 @@
"""Knowledge directories + editable extraction templates.
New column ``knowledge_notes.folder``; new tables ``knowledge_folders`` and
``knowledge_extract_templates``.
Note: the running app also auto-adds the column + tables via
``_ensure_orm_columns_sync`` / ``create_all`` on startup; this migration keeps
the alembic history consistent per the repo convention (see backend/CLAUDE.md →
Schema Migrations).
Revision ID: 20260616_02
Revises: 20260616_01
Create Date: 2026-06-16
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects import mysql
revision = "20260616_02"
down_revision = "20260616_01"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
return table_name in inspect(op.get_bind()).get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
return any(col["name"] == column_name for col in inspect(op.get_bind()).get_columns(table_name))
def upgrade() -> None:
if _has_table("knowledge_notes") and not _has_column("knowledge_notes", "folder"):
op.add_column("knowledge_notes", sa.Column("folder", sa.String(512), nullable=True))
op.create_index("ix_knowledge_notes_folder", "knowledge_notes", ["folder"], mysql_length=191)
if not _has_table("knowledge_folders"):
op.create_table(
"knowledge_folders",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("path", sa.String(512), nullable=False),
sa.Column("name", sa.String(256), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_by", sa.String(128), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("uq_knowledge_folders_path", "knowledge_folders", ["path"], unique=True, mysql_length=191)
if not _has_table("knowledge_extract_templates"):
op.create_table(
"knowledge_extract_templates",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("name", sa.String(256), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("scope", sa.String(16), nullable=False, server_default="both"),
sa.Column("system_prompt", sa.Text().with_variant(mysql.LONGTEXT(), "mysql"), nullable=False),
sa.Column("is_default", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("1")),
sa.Column("created_by", sa.String(128), nullable=True),
sa.Column("updated_by", sa.String(128), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_knowledge_extract_templates_scope", "knowledge_extract_templates", ["scope"])
op.create_index("ix_knowledge_extract_templates_is_default", "knowledge_extract_templates", ["is_default"])
op.create_index("ix_knowledge_extract_templates_created_at", "knowledge_extract_templates", ["created_at"])
op.create_index("uq_knowledge_extract_templates_name", "knowledge_extract_templates", ["name"], unique=True, mysql_length=191)
def downgrade() -> None:
if _has_table("knowledge_extract_templates"):
op.drop_table("knowledge_extract_templates")
if _has_table("knowledge_folders"):
op.drop_table("knowledge_folders")
if _has_column("knowledge_notes", "folder"):
op.drop_column("knowledge_notes", "folder")
@@ -28,6 +28,8 @@ from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.knowledge.models import (
KnowledgeEmbeddingRow,
KnowledgeEntityRow,
KnowledgeExtractTemplateRow,
KnowledgeFolderRow,
KnowledgeGraphBlacklistRow,
KnowledgeNoteRow,
KnowledgeNoteTagRow,
@@ -78,6 +80,8 @@ __all__ = [
"BusinessMappingRow",
"KnowledgeEmbeddingRow",
"KnowledgeEntityRow",
"KnowledgeExtractTemplateRow",
"KnowledgeFolderRow",
"KnowledgeGraphBlacklistRow",
"KnowledgeNoteRow",
"KnowledgeNoteTagRow",
@@ -1,8 +1,8 @@
"""ORM models for 岗位 (positions) and their per-resource tag bindings.
A **position** (岗位) is an admin-managed aggregation layer that decides which
agents / skills / scheduled tasks / custom prompts / recommended questions a
user is subscribed to. Each user has at most one position (the link lives on
agents / skills / scheduled tasks / recommended questions a user is subscribed
to. Each user has at most one position (the link lives on
``users.position_id``, a 1:1 relation — see ``UserRow``).
Two tables here:
@@ -26,8 +26,9 @@ from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
# Resource types a position can grant. The first three reuse the existing
# typed-tag namespace (``deerflow.persistence.tags``); ``user_prompt`` and
# ``recommended_question`` are added by the same change (see TAG_TYPES).
# typed-tag namespace (``deerflow.persistence.tags``); ``recommended_question``
# was added alongside (see TAG_TYPES). The removed ``user_prompt`` type is no
# longer accepted — ``get_tag_bindings`` drops any stale rows of that type.
POSITION_RESOURCE_TYPES: tuple[str, ...] = (
"agent",
"skill",
@@ -11,7 +11,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.positions.base import PositionStore
from deerflow.persistence.positions.model import PositionRow, PositionTagBindingRow
from deerflow.persistence.positions.model import POSITION_RESOURCE_TYPES, PositionRow, PositionTagBindingRow
from deerflow.persistence.user.model import UserRow
@@ -134,6 +134,11 @@ class PositionRepository(PositionStore):
)
out: dict[str, list[str]] = {}
for r in rows:
# Drop bindings for resource types no longer supported (e.g. the
# removed ``user_prompt`` type) so stale rows can't poison the
# editor or be echoed back into a 422 on save.
if r.resource_type not in POSITION_RESOURCE_TYPES:
continue
out.setdefault(r.resource_type, []).append(r.tag_id)
return out
@@ -54,11 +54,42 @@ class LocalSkillStorage(SkillStorage):
return self._host_root
def custom_skill_exists(self, name: str) -> bool:
return self.get_custom_skill_file(name).exists()
return self._resolve_skill_dir(name, SkillCategory.CUSTOM) is not None
def public_skill_exists(self, name: str) -> bool:
return self._resolve_skill_dir(name, SkillCategory.PUBLIC) is not None
def _resolve_skill_dir(self, name: str, category: SkillCategory) -> Path | None:
"""Return the on-disk directory of the skill identified by *name*.
Discovery (``_iter_skill_files``/``load_skills``) keys a skill by the
``name`` in its SKILL.md frontmatter, but a hand-dropped or mis-extracted
skill can live at a directory whose name differs from that frontmatter
name. Locating a skill by the canonical ``<category>/<name>`` path alone
would then make it *listable but not deletable/readable*.
Fast path: the canonical ``<category>/<name>`` layout. Fallback: scan the
discovered SKILL.md files and match on the parsed frontmatter ``name`` so
delete/read/editability stay consistent with what discovery surfaces.
Returns ``None`` when no such skill is discoverable.
"""
from deerflow.skills.parser import parse_skill_file
normalized_name = self.validate_skill_name(name)
return (self._host_root / SkillCategory.PUBLIC.value / normalized_name / SKILL_MD_FILE).exists()
canonical = self._host_root / category.value / normalized_name
if (canonical / SKILL_MD_FILE).is_file():
return canonical
for found_category, category_root, md_path in self._iter_skill_files():
if found_category != category:
continue
skill = parse_skill_file(
md_path,
category=found_category,
relative_path=md_path.parent.relative_to(category_root),
)
if skill and skill.name == normalized_name:
return md_path.parent
return None
def _iter_skill_files(self) -> Iterable[tuple[SkillCategory, Path, Path]]:
if not self._host_root.exists():
@@ -71,12 +102,19 @@ class LocalSkillStorage(SkillStorage):
dir_names[:] = sorted(name for name in dir_names if not name.startswith("."))
if SKILL_MD_FILE not in file_names:
continue
# A directory containing SKILL.md *is* a self-contained skill;
# treat it as a leaf and do not descend into its subdirectories.
# This keeps bundled assets that ship their own SKILL.md (e.g.
# ``examples/`` or ``references/`` inside a skill package) from
# being surfaced as separate, undeletable top-level skills.
dir_names[:] = []
yield category, category_path, Path(current_root) / SKILL_MD_FILE
def read_custom_skill(self, name: str) -> str:
if not self.custom_skill_exists(name):
skill_dir = self._resolve_skill_dir(name, SkillCategory.CUSTOM)
if skill_dir is None:
raise FileNotFoundError(f"Custom skill '{name}' not found.")
return (self.get_custom_skill_dir(name) / SKILL_MD_FILE).read_text(encoding="utf-8")
return (skill_dir / SKILL_MD_FILE).read_text(encoding="utf-8")
def write_custom_skill(self, name: str, relative_path: str, content: str) -> None:
target = self.validate_relative_path(relative_path, self.get_custom_skill_dir(name))
@@ -251,7 +289,17 @@ class LocalSkillStorage(SkillStorage):
def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None:
self.validate_skill_name(name)
self.ensure_custom_skill_is_editable(name)
target = self.get_custom_skill_dir(name)
# Delete the skill's *discovered* directory, not the assumed
# ``custom/<name>`` path — a skill placed at a non-canonical or nested
# location must still be removable now that discovery surfaced it.
target = self._resolve_skill_dir(name, SkillCategory.CUSTOM)
if target is None:
raise FileNotFoundError(f"Custom skill '{name}' not found.")
# Safety: never escape the custom root, and never remove the root itself.
custom_root = (self._host_root / SkillCategory.CUSTOM.value).resolve()
resolved_target = target.resolve()
if resolved_target == custom_root or custom_root not in resolved_target.parents:
raise ValueError(f"Refusing to delete skill '{name}': resolved path '{resolved_target}' is outside the custom skills directory.")
if history_meta is not None:
prev_content = self.read_custom_skill(name)
try:
@@ -247,6 +247,7 @@ class SkillStorage(ABC):
extensions_config = ExtensionsConfig.from_file()
for skill in skills:
skill.enabled = extensions_config.is_skill_enabled(skill.name, skill.category)
skill.always_on = extensions_config.is_skill_always_on(skill.name)
except Exception as e:
logger.warning("Failed to load extensions config: %s", e)
@@ -28,6 +28,7 @@ class Skill:
relative_path: Path # Relative path from category root to skill directory
category: SkillCategory # 'public' or 'custom'
enabled: bool = False # Whether this skill is enabled
always_on: bool = False # 常驻(免压缩):开启压缩后仍把本技能全文注入系统提示词
@property
def skill_path(self) -> str:
@@ -176,6 +176,113 @@ def skill_view_tool(name: str, runtime: ToolRuntime) -> str:
return skill_view_impl(name, _resolve_active_agent_id(runtime))
def _resolve_skills_container_base_path() -> str:
"""容器内技能挂载根路径(用于回传 location,供 agent read_file)。"""
try:
from deerflow.config import get_app_config
return get_app_config().skills.container_path
except Exception:
return "/mnt/skills"
def _score_skill(query_terms: list[str], haystack: str) -> int:
"""对单个技能按关键词命中计分(纯关键词、离线、确定性)。"""
score = 0
for term in query_terms:
if term and term in haystack:
score += 1
return score
def search_skills_impl(query: str, agent_id: str | None, top_n: int = 5) -> str:
"""按关键词检索可用技能,返回 top-N 的 name/description/category/location。
与 ``skill_list`` 共享可见性过滤(自定义 agent 走白名单;默认 lead agent 只
暴露未归档且内置或属于当前用户的技能),并只检索**已启用**的技能 —— 它是
"技能压缩"开启后,agent 调出被压缩(非常驻)技能的主要入口。匹配字段为技能
名与描述。
"""
import re
from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.types import SkillCategory
from deerflow.skills.usage import STATE_ARCHIVED, all_entries
query = (query or "").strip()
if not query:
return "search_skills: empty query. Provide a short keyword query describing the task."
terms = [t for t in re.findall(r"\w+", query.lower()) if t]
# 整串也作为一个匹配项,兼顾中文等不被 \w+ 切分出有意义 token 的查询。
whole = query.lower()
if whole and whole not in terms:
terms.append(whole)
allowed = _load_agent_skill_whitelist(agent_id)
owned: set[str] | None = None
if allowed is None:
owned = _load_owned_skill_names(_resolve_effective_user_id())
try:
top_n = max(1, min(int(top_n or 5), 50))
except (TypeError, ValueError):
top_n = 5
try:
storage = get_or_new_skill_storage()
skills = storage.load_skills(enabled_only=True)
usage = all_entries()
base_path = _resolve_skills_container_base_path()
scored: list[tuple[int, dict]] = []
for skill in skills:
entry = usage.get(skill.name, {})
if allowed is not None:
if skill.name not in allowed:
continue
else:
if entry.get("state") == STATE_ARCHIVED:
continue
if skill.category == SkillCategory.CUSTOM and owned is not None and skill.name not in owned:
continue
haystack = f"{skill.name} {skill.description}".lower()
score = _score_skill(terms, haystack)
if score <= 0:
continue
scored.append((
score,
{
"name": skill.name,
"description": skill.description,
"category": skill.category.value if hasattr(skill.category, "value") else str(skill.category),
"location": skill.get_container_file_path(base_path),
},
))
scored.sort(key=lambda x: x[0], reverse=True)
results = [item for _, item in scored[:top_n]]
if not results:
return f"No skills matched '{query}'. Try a broader keyword, or call skill_list to browse everything available."
return json.dumps(results, ensure_ascii=False, indent=2)
except Exception as e:
logger.debug("search_skills failed: %s", e)
return f"Error searching skills: {e}"
@tool("search_skills")
def search_skills_tool(query: str, runtime: ToolRuntime) -> str:
"""Search available skills by keyword and return the best matches.
Use this to discover skills that are not already described in full in your
system prompt. Returns a JSON array of matches, each with `name`,
`description`, `category`, and `location` — then `read_file` the `location`
of the most relevant match and follow its SKILL.md instructions.
Args:
query: A short keyword query describing the task (e.g. 'pdf table extract').
"""
return search_skills_impl(query, _resolve_active_agent_id(runtime))
@tool("skill_list")
def skill_list_tool(runtime: ToolRuntime) -> str:
"""List skills available to this agent, with metadata and usage stats.
@@ -139,6 +139,18 @@ def get_available_tools(
builtin_tools.extend([skill_list_tool, skill_view_tool])
# search_skills —— 仅当管理员开启"技能压缩"时提供。压缩开启后,非常驻技能不再
# 全文注入系统提示词,agent 通过该关键词检索工具按需调出(纯关键词、离线、确定性)。
try:
from deerflow.config.system_settings import get_skill_compression_settings
if get_skill_compression_settings().enabled:
from deerflow.tools.builtins.skill_tools import search_skills_tool
builtin_tools.append(search_skills_tool)
except Exception: # noqa: BLE001 - 读取设置失败不能阻断 agent 构建
logger.debug("Failed to evaluate skill compression setting for search_skills tool", exc_info=True)
# read_peer_delivery —— 圆桌席位「按需读取前序席位完整交付」的只读工具(从 run context
# 取网关注入的全文,非圆桌场景为空 → 工具自述「无」)。始终提供(只读、无副作用);圆桌
# 总控/报告角色由 run policy 显式排除,避免干扰它们。
@@ -0,0 +1,182 @@
"""一次性清洗:修正历史知识笔记里遗留的 `## Related` 结构链接与 `### Sources`。
背景
====
早期沉淀的每条笔记都会在 `## Related` 段自动塞入“结构性” wikilink
(`[[index]]` / `[[_meta/taxonomy]]` / `[[projects/<project>/…]]` / 项目 hub),
并在 `## Related` 下嵌套一个 `### Sources` 子段。现在:
- 真·相关链接只用裸标题 `[[Title]]`(由 service 层按共享标签注入);
- 来源统一由结构化的「来源」区块(`note.sources`)展示,不再写进正文。
但历史笔记的 `content_md`(数据库 + vault 文件)里仍焊死着这些遗留内容,
导致详情页出现噪声链接、以及 sources/来源 重复。本脚本把它们清理掉。
清洗规则(保守,只删明确匹配项)
================================
对每条笔记的正文:
1. 删除 `## Related` 段内的 `### Sources` 子段(标题 + 其下条目);
2. 删除 `## Related` 段内的“结构性”链接条目 —— 即 wikilink 目标包含 `/`、
或等于 `index` / `taxonomy` 之类的脚手架页(真·相关链接是裸标题,予以保留);
3. 若清理后 `## Related` 段已无任何内容条目,则连同 `## Related` 标题一并删除;
4. 折叠多余空行。
裸标题的 `[[Title]]` 相关链接、其它正文内容一律保留(无数据丢失)。
用法
====
::
# 预览(不写盘),打印每条将要发生的改动
PYTHONPATH=. python scripts/cleanup_knowledge_related.py --dry-run
# 执行(写回数据库 + 重写 vault 文件)
PYTHONPATH=. python scripts/cleanup_knowledge_related.py
# 只处理单条
PYTHONPATH=. python scripts/cleanup_knowledge_related.py --note-id kb_xxx
脚本幂等:已经干净的笔记会被跳过。写回走 `KnowledgeService.update_note`,
所以每次改动都会落一份版本快照(可在「编辑历史」回滚)。
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import re
import sys
logger = logging.getLogger("cleanup_knowledge_related")
_RELATED_HEADER_RE = re.compile(r"^##\s+(Related|相关知识|相关文档|相关)\s*$", re.IGNORECASE)
_SOURCES_HEADER_RE = re.compile(r"^###\s+(Sources|来源)\s*$", re.IGNORECASE)
_H12_RE = re.compile(r"^#{1,2}\s+")
_H123_RE = re.compile(r"^#{1,3}\s+")
_BULLET_LINK_RE = re.compile(r"^[-*]\s*\[\[([^\]\n|]+)(?:\|[^\]\n]+)?\]\]\s*$")
def _is_structural_target(target: str) -> bool:
"""A `## Related` wikilink that points at vault scaffolding (not a real note).
Real related cross-links are bare note titles (`[[标题]]`) with no slashes.
The legacy structural links were full vault paths / meta pages.
"""
t = (target or "").strip().strip("/").lower()
if not t:
return True
if "/" in t: # projects/<p>/…, _meta/taxonomy, … — all structural
return True
return t in {"index", "taxonomy", "hot", "log", "_meta"}
def clean_note_body(md: str) -> str:
"""Strip the legacy `### Sources` subsection + structural `## Related` links.
Pure function (no I/O) so it can be unit-tested. Returns the cleaned body;
identical to the input when there is nothing to clean.
"""
lines = md.split("\n")
n = len(lines)
out: list[str] = []
i = 0
in_fence = False
while i < n:
line = lines[i]
if re.match(r"^\s*(```|~~~)", line):
in_fence = not in_fence
out.append(line)
i += 1
continue
if in_fence or not _RELATED_HEADER_RE.match(line.strip()):
out.append(line)
i += 1
continue
# --- inside a `## Related` section -------------------------------
header = line
j = i + 1
kept: list[str] = [] # surviving content lines of this section
while j < n:
cur = lines[j]
stripped = cur.strip()
if _H12_RE.match(cur): # next h1/h2 ends the Related section
break
if _SOURCES_HEADER_RE.match(stripped):
# Drop the `### Sources` header and everything until the next header.
j += 1
while j < n and not _H123_RE.match(lines[j]):
j += 1
continue
m = _BULLET_LINK_RE.match(stripped)
if m and _is_structural_target(m.group(1)):
j += 1 # drop the structural link bullet
continue
kept.append(cur)
j += 1
meaningful = [ln for ln in kept if ln.strip()]
if meaningful:
out.append(header)
out.extend(kept)
# else: drop the now-empty `## Related` section entirely.
i = j
cleaned = "\n".join(out)
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).rstrip() + "\n"
return cleaned
async def _run(dry_run: bool, only_note_id: str | None) -> int:
from deerflow.config import get_app_config
from deerflow.knowledge import make_knowledge_service
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
config = get_app_config()
await init_engine_from_config(config.database)
sf = get_session_factory()
if sf is None:
logger.error("No database session factory — is the database backend configured?")
return 1
service = make_knowledge_service(sf, config.knowledge)
if service is None:
logger.error("Knowledge base disabled (knowledge.enabled=false) or memory backend; nothing to do.")
return 1
notes = await service.repo.list_all(include_archived=True)
if only_note_id:
notes = [n for n in notes if n.get("id") == only_note_id]
scanned = changed = 0
for note in notes:
scanned += 1
body = note.get("content_md") or ""
cleaned = clean_note_body(body)
if cleaned == body:
continue
changed += 1
nid = note.get("id")
title = note.get("title") or nid
if dry_run:
logger.info("[dry-run] would clean %s — %s (%d → %d chars)", nid, title, len(body), len(cleaned))
else:
await service.update_note(nid, content_md=cleaned, updated_by="cleanup_script")
logger.info("cleaned %s — %s", nid, title)
await close_engine()
logger.info("Done. scanned=%d, %s=%d", scanned, "would-change" if dry_run else "changed", changed)
return 0
def main() -> int:
logging.basicConfig(level=logging.INFO, format="%(message)s")
parser = argparse.ArgumentParser(description="Clean legacy `## Related` structural links + `### Sources` from knowledge notes.")
parser.add_argument("--dry-run", action="store_true", help="Preview changes without writing.")
parser.add_argument("--note-id", default=None, help="Only process this note id.")
args = parser.parse_args()
return asyncio.run(_run(args.dry_run, args.note_id))
if __name__ == "__main__":
sys.exit(main())
@@ -94,6 +94,10 @@ CALL _add_column_if_missing('agent_favorites', 'position_id', 'VARCHAR(64) NULL
CALL _add_column_if_missing('scheduled_task_subscriptions', 'origin', "VARCHAR(16) NOT NULL DEFAULT 'user'");
CALL _add_column_if_missing('scheduled_task_subscriptions', 'position_id', 'VARCHAR(64) NULL DEFAULT NULL');
-- knowledge_notes.folder: 用户自建目录(斜杠分隔路径),驱动产品目录树;NULL=未归类。
-- (knowledge_folders / knowledge_extract_templates 为【新表】,由 create_all 自动建,无需在此补列。)
CALL _add_column_if_missing('knowledge_notes', 'folder', 'VARCHAR(512) NULL DEFAULT NULL');
-- ---- 清理临时存储过程 --------------------------------------------------------
DROP PROCEDURE IF EXISTS _add_column_if_missing;
@@ -111,6 +115,20 @@ PREPARE stmt FROM @idx_ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ---- knowledge_notes.folder 索引(幂等) --------------------------------------
SET @idx_exists = (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'knowledge_notes'
AND index_name = 'ix_knowledge_notes_folder'
);
SET @idx_ddl = IF(@idx_exists = 0,
'CREATE INDEX `ix_knowledge_notes_folder` ON `knowledge_notes` (`folder`(191))',
'SELECT ''SKIPPED ix_knowledge_notes_folder (already exists)'' AS result');
PREPARE stmt FROM @idx_ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ---- 执行后自检:确认本批次涉及的列都已存在 ---------------------------------
SELECT table_name, column_name
FROM information_schema.columns
+9 -2
View File
@@ -310,7 +310,14 @@ summarization:
value: 60000
- type: messages
value: 80
# Keep a small *token* budget of recent context (same unit as the token
# trigger, well below it). This is what the model receives after compaction:
# [summary, ~16000 tokens of recent turns]. Small keep = small model context
# (fast, no overflow) AND infrequent re-compaction — the preserved window
# grows from ~16000 up to the 60000 trigger before folding into a new summary,
# which spans many turns. The full conversation still stays in state for the
# page; only what is sent to the model is compressed.
keep:
type: messages
value: 20
type: tokens
value: 16000
trim_tokens_to_summarize: 4000