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 { Breadcrumb } from "./Breadcrumb";
import { DocumentToolbar } from "./DocumentToolbar"; import { DocumentToolbar } from "./DocumentToolbar";
import { FolderSelect } from "./KnowledgeManage";
import { FloatingToc } from "./FloatingToc"; import { FloatingToc } from "./FloatingToc";
import { MarkdownViewer } from "./MarkdownViewer"; import { MarkdownViewer } from "./MarkdownViewer";
import { RelatedDocs } from "./RelatedDocs"; import { RelatedDocs } from "./RelatedDocs";
@@ -35,6 +36,7 @@ import {
downloadMarkdown, downloadMarkdown,
extractToc, extractToc,
formatDateTime, formatDateTime,
localizeHeadings,
noteBreadcrumbSegments, noteBreadcrumbSegments,
splitRelatedSection, splitRelatedSection,
} from "./wikiUtils"; } from "./wikiUtils";
@@ -102,10 +104,14 @@ export function WikiContent({
// Pull the backend-injected `## Related` wikilinks out of the body so they // Pull the backend-injected `## Related` wikilinks out of the body so they
// render as a structured module; editing still works on the full source. // 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), () => splitRelatedSection(note.content_md),
[note.content_md], [note.content_md],
); );
const body = useMemo(() => localizeHeadings(rawBody), [rawBody]);
const toc = useMemo(() => extractToc(body), [body]); const toc = useMemo(() => extractToc(body), [body]);
// The floating outline only shows h2/h3 — deeper levels are noise. // The floating outline only shows h2/h3 — deeper levels are noise.
const outlineItems = useMemo( 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 () => { const handleArchive = async () => {
try { try {
await remove.mutateAsync({ noteId: note.id }); await remove.mutateAsync({ noteId: note.id });
@@ -277,6 +292,15 @@ export function WikiContent({
{note.created_at ? ( {note.created_at ? (
<MetaTag>创建:{formatDateTime(note.created_at).slice(0, 10)}</MetaTag> <MetaTag>创建:{formatDateTime(note.created_at).slice(0, 10)}</MetaTag>
) : null} ) : 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) => ( {note.tags.map((tag) => (
<button <button
key={tag} key={tag}
@@ -1,9 +1,11 @@
import { import {
Copy, Copy,
FolderTree,
Menu, Menu,
Network, Network,
Plus, Plus,
RefreshCw, RefreshCw,
SlidersHorizontal,
Upload, Upload,
} from "lucide-react"; } from "lucide-react";
@@ -48,6 +50,8 @@ export function WikiHeader({
onDedup, onDedup,
onGraph, onGraph,
onCreate, onCreate,
onManageFolders,
onManageTemplates,
}: { }: {
onToggleSidebar: () => void; onToggleSidebar: () => void;
onImportFile: () => void; onImportFile: () => void;
@@ -55,6 +59,8 @@ export function WikiHeader({
onDedup: () => void; onDedup: () => void;
onGraph: () => void; onGraph: () => void;
onCreate: () => void; onCreate: () => void;
onManageFolders: () => void;
onManageTemplates: () => void;
}) { }) {
return ( 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"> <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="批量导入" label="批量导入"
onClick={onBatchImport} onClick={onBatchImport}
/> />
<HeaderButton
icon={<FolderTree className="size-4" />}
label="目录管理"
onClick={onManageFolders}
/>
<HeaderButton
icon={<SlidersHorizontal className="size-4" />}
label="抽取模板"
onClick={onManageTemplates}
/>
<HeaderButton <HeaderButton
icon={<Copy className="size-4" />} icon={<Copy className="size-4" />}
label="查重" label="查重"
@@ -14,4 +14,6 @@ export interface TreeNode {
type: "folder" | "file"; type: "folder" | "file";
children?: TreeNode[]; children?: TreeNode[];
item?: KnowledgeNote; 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 = "未分类"; 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. */ /** Stable slug for a heading: keeps CJK characters, collapses whitespace to dashes. */
export function slugify(text: string): string { export function slugify(text: string): string {
return text return text
@@ -119,16 +186,17 @@ export function splitRelatedSection(markdown: string): {
return { body, related }; 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[] { export function noteFolderSegments(note: KnowledgeNote): string[] {
const rawPath = note.vault_path?.replace(/\\/g, "/").replace(/^\/+/, ""); const folder = (note.folder || "").replace(/^\/+|\/+$/g, "").trim();
if (rawPath && rawPath.includes("/")) { if (folder) {
let segments = rawPath.split("/").slice(0, -1).filter(Boolean); const segments = folder.split("/").map((s) => s.trim()).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);
}
if (segments.length > 0) return segments; if (segments.length > 0) return segments;
} }
return [SOURCE_TYPE_LABELS[note.source_type] ?? FALLBACK_FOLDER]; 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). */ /** Breadcrumb segments for a note (folders only; the title is appended by the caller). */
export function noteBreadcrumbSegments(note: KnowledgeNote): string[] { export function noteBreadcrumbSegments(note: KnowledgeNote): string[] {
return ["问答梳理", ...noteFolderSegments(note)]; return ["问答梳理", ...noteFolderSegments(note).map(localizeFolderName)];
} }
function sortTree(nodes: TreeNode[]): void { 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 * 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. * 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 root: TreeNode[] = [];
const folderMap = new Map<string, TreeNode>(); const folderMap = new Map<string, TreeNode>();
@@ -164,7 +235,9 @@ export function buildKnowledgeTree(notes: KnowledgeNote[]): TreeNode[] {
path = path ? `${path}/${segment}` : segment; path = path ? `${path}/${segment}` : segment;
let node = folderMap.get(path); let node = folderMap.get(path);
if (!node) { 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); folderMap.set(path, node);
children.push(node); children.push(node);
} }
@@ -173,6 +246,13 @@ export function buildKnowledgeTree(notes: KnowledgeNote[]): TreeNode[] {
return children; 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) { for (const note of notes) {
ensureFolder(noteFolderSegments(note)).push({ ensureFolder(noteFolderSegments(note)).push({
id: note.id, id: note.id,
@@ -406,7 +406,7 @@ export function PositionManagementPage() {
<div> <div>
<h1 className="text-lg font-semibold">岗位管理</h1> <h1 className="text-lg font-semibold">岗位管理</h1>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
为岗位配置默认智能体/技能/定时任务/常用提示词/推荐问题的标签,并批量分配给用户。 为岗位配置默认智能体/技能/定时任务/推荐问题的标签,并批量分配给用户。
</p> </p>
</div> </div>
<Button size="sm" onClick={() => { setEditing(null); setDialogOpen(true); }}> <Button size="sm" onClick={() => { setEditing(null); setDialogOpen(true); }}>
@@ -35,7 +35,7 @@ import { cn } from "@/lib/utils";
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage"; import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage"; import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage"; import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
import RecommendedQuestionsPage from "@/pages/RecommendedQuestionsPage"; import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage";
import { SettingsSection } from "./settings-section"; import { SettingsSection } from "./settings-section";
@@ -457,14 +457,14 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
{isAdmin && ( {isAdmin && (
<> <>
<Separator />
<RecommendedQuestionsPage embedded />
<Separator /> <Separator />
<PromptPrefixSettingsPage embedded /> <PromptPrefixSettingsPage embedded />
<Separator /> <Separator />
<PrivateSquareSettingsPage embedded /> <PrivateSquareSettingsPage embedded />
<Separator /> <Separator />
<PublishSquaresSettingsPage embedded /> <PublishSquaresSettingsPage embedded />
<Separator />
<SkillCompressionSettingsPage embedded />
</> </>
)} )}
</div> </div>
@@ -814,6 +814,15 @@ export function SkillRow({
{t.settings.skills.badgePinned} {t.settings.skills.badgePinned}
</Badge> </Badge>
)} )}
{skill.always_on && (
<Badge
variant="outline"
className="border-amber-500/30 text-amber-700 dark:text-amber-300 text-xs"
title="常驻(免压缩):开启技能压缩后,本技能仍全文注入系统提示词并被正常检索"
>
常驻
</Badge>
)}
</div> </div>
); );
@@ -26,6 +26,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
@@ -37,12 +38,14 @@ import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
useGenerateSkillNameZh, useGenerateSkillNameZh,
useSetSkillAlwaysOn,
useSetSkillDetail, useSetSkillDetail,
useSetSkillNameZh, useSetSkillNameZh,
useSkill, useSkill,
useSkillContent, useSkillContent,
useSkillFiles, useSkillFiles,
} from "@/core/skills/hooks"; } from "@/core/skills/hooks";
import { useSkillCompressionSettings } from "@/core/system-settings/hooks";
import type { Skill } from "@/core/skills/type"; import type { Skill } from "@/core/skills/type";
import { reasoningPlugins } from "@/core/streamdown"; import { reasoningPlugins } from "@/core/streamdown";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
@@ -105,6 +108,8 @@ export function SkillDetailDialog({
const setDetail = useSetSkillDetail(); const setDetail = useSetSkillDetail();
const setNameZh = useSetSkillNameZh(); const setNameZh = useSetSkillNameZh();
const generateNameZh = useGenerateSkillNameZh(); const generateNameZh = useGenerateSkillNameZh();
const setAlwaysOn = useSetSkillAlwaysOn();
const { data: compression } = useSkillCompressionSettings();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
@@ -327,6 +332,15 @@ export function SkillDetailDialog({
{tx.badgePublished} {tx.badgePublished}
</Badge> </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> </div>
)} )}
<DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed"> <DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed">
@@ -372,6 +386,31 @@ export function SkillDetailDialog({
</TabsList> </TabsList>
<div className="flex shrink-0 items-center gap-1"> <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 && ( {selectedTab === "detail" && canManage && !editing && (
<Button <Button
size="sm" size="sm"
@@ -10,6 +10,7 @@ import {
DatabaseZapIcon, DatabaseZapIcon,
GaugeIcon, GaugeIcon,
HistoryIcon, HistoryIcon,
MessageCircleQuestionIcon,
ScrollTextIcon, ScrollTextIcon,
Settings2Icon, Settings2Icon,
SettingsIcon, SettingsIcon,
@@ -216,6 +217,18 @@ export function WorkspaceNavMenu() {
岗位管理 岗位管理
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate(
"/page/workspace/settings/recommended-questions",
)
}
>
<MessageCircleQuestionIcon />
推荐问题配置
</DropdownMenuItem>
)}
{isAdmin && logSystem?.enabled && logSystem.url && ( {isAdmin && logSystem?.enabled && logSystem.url && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
+111 -1
View File
@@ -4,8 +4,11 @@ import { getBackendBaseURL } from "@/core/config";
import type { import type {
BatchIngestPayload, BatchIngestPayload,
BatchIngestResult, BatchIngestResult,
ExtractTemplateScope,
GraphExportResult, GraphExportResult,
IngestThreadPayload, IngestThreadPayload,
KnowledgeExtractTemplate,
KnowledgeFolder,
KnowledgeListParams, KnowledgeListParams,
KnowledgeListResponse, KnowledgeListResponse,
KnowledgeNote, KnowledgeNote,
@@ -35,6 +38,7 @@ export async function listKnowledgeNotes(params: KnowledgeListParams = {}) {
if (params.entity) qs.set("entity", params.entity); if (params.entity) qs.set("entity", params.entity);
if (params.source_type) qs.set("source_type", params.source_type); if (params.source_type) qs.set("source_type", params.source_type);
if (params.status) qs.set("status", params.status); 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("limit", String(params.limit ?? 20));
qs.set("offset", String(params.offset ?? 0)); qs.set("offset", String(params.offset ?? 0));
return parseResponse<KnowledgeListResponse>( return parseResponse<KnowledgeListResponse>(
@@ -54,6 +58,7 @@ export async function createKnowledgeNote(data: {
summary?: string | null; summary?: string | null;
tags?: string[]; tags?: string[];
status?: string; status?: string;
folder?: string | null;
}) { }) {
const res = await parseResponse<{ note: KnowledgeNote }>( const res = await parseResponse<{ note: KnowledgeNote }>(
await apiFetch(`${baseUrl()}/notes`, { await apiFetch(`${baseUrl()}/notes`, {
@@ -73,6 +78,7 @@ export async function updateKnowledgeNote(
summary: string | null; summary: string | null;
tags: string[]; tags: string[];
status: string; status: string;
folder: string | null;
}>, }>,
) { ) {
return parseResponse<KnowledgeNote>( 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). */ /** Import a file (md/txt/pdf/word/ppt/excel) into the KB as wiki note(s). */
export async function ingestKnowledgeFile( export async function ingestKnowledgeFile(
file: File, 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(); const form = new FormData();
form.append("file", file); form.append("file", file);
@@ -146,6 +159,8 @@ export async function ingestKnowledgeFile(
if (opts.tags) form.append("tags", opts.tags); if (opts.tags) form.append("tags", opts.tags);
if (opts.status) form.append("status", opts.status); if (opts.status) form.append("status", opts.status);
if (opts.model_name) form.append("model_name", opts.model_name); 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. // No Content-Type header — the browser sets the multipart boundary itself.
return parseResponse<IngestFileResult>( return parseResponse<IngestFileResult>(
await apiFetch(`${baseUrl()}/ingest/file`, { method: "POST", body: form }), 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 { import {
addGraphBlacklist, addGraphBlacklist,
createExtractTemplate,
createKnowledgeFolder,
createKnowledgeNote, createKnowledgeNote,
deleteExtractTemplate,
deleteKnowledgeFolder,
deleteKnowledgeNote, deleteKnowledgeNote,
exportKnowledgeGraph, exportKnowledgeGraph,
fetchVaultPage, fetchVaultPage,
@@ -18,14 +22,18 @@ import {
ingestThread, ingestThread,
ingestThreadsBatch, ingestThreadsBatch,
listDuplicates, listDuplicates,
listExtractTemplates,
listKnowledgeFolders,
listKnowledgeNotes, listKnowledgeNotes,
mergeKnowledgeNotes, mergeKnowledgeNotes,
removeGraphBlacklist, removeGraphBlacklist,
renameKnowledgeFolder,
resolveNoteWikilinks, resolveNoteWikilinks,
searchKnowledge, searchKnowledge,
updateExtractTemplate,
updateKnowledgeNote, updateKnowledgeNote,
} from "./api"; } from "./api";
import type { KnowledgeListParams } from "./types"; import type { ExtractTemplateScope, KnowledgeListParams } from "./types";
const ROOT_KEY = ["knowledge"] as const; const ROOT_KEY = ["knowledge"] as const;
@@ -172,3 +180,62 @@ export function useKnowledgeDuplicates(enabled: boolean) {
refetchOnWindowFocus: false, 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; summary?: string | null;
content_md: string; content_md: string;
vault_path?: string | null; vault_path?: string | null;
/** User-assigned directory (slash-joined path); null/undefined = unfiled. */
folder?: string | null;
source_type: string; source_type: string;
source_id?: string | null; source_id?: string | null;
status: KnowledgeStatus; status: KnowledgeStatus;
@@ -58,10 +60,37 @@ export interface KnowledgeListParams {
entity?: string; entity?: string;
source_type?: string; source_type?: string;
status?: string; status?: string;
/** Filter to a directory (and its sub-folders); "" = unfiled only. */
folder?: string;
limit?: number; limit?: number;
offset?: 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 type WikilinkKind = "note" | "vault" | "missing";
export interface WikilinkResolution { export interface WikilinkResolution {
@@ -105,6 +134,10 @@ export interface IngestThreadPayload {
background?: boolean; background?: boolean;
/** Optional model override for knowledge extraction (null = configured default). */ /** Optional model override for knowledge extraction (null = configured default). */
model_name?: string | null; 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 { export interface BatchIngestPayload {
@@ -116,6 +149,10 @@ export interface BatchIngestPayload {
mode?: "summary" | "full"; mode?: "summary" | "full";
/** Optional model override for knowledge extraction (null = configured default). */ /** Optional model override for knowledge extraction (null = configured default). */
model_name?: string | null; 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 { export interface BatchIngestResult {
+7
View File
@@ -106,6 +106,13 @@ export async function resyncPosition(positionId: string): Promise<number> {
return data.synced; 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[]> { export async function listPositionUsers(): Promise<PositionUser[]> {
const res = await apiFetch(`${base()}/users`); const res = await apiFetch(`${base()}/users`);
if (!res.ok) throw await parseDetail(res, "Failed to list users"); if (!res.ok) throw await parseDetail(res, "Failed to list users");
+7
View File
@@ -4,6 +4,7 @@ import {
addMembers, addMembers,
createPosition, createPosition,
deletePosition, deletePosition,
getMyPosition,
getTagBindings, getTagBindings,
listMembers, listMembers,
listPositionUsers, listPositionUsers,
@@ -26,12 +27,18 @@ const positionKeys = {
bindings: (id: string) => ["positions", "bindings", id] as const, bindings: (id: string) => ["positions", "bindings", id] as const,
members: (id: string) => ["positions", "members", id] as const, members: (id: string) => ["positions", "members", id] as const,
users: ["positions", "users"] as const, users: ["positions", "users"] as const,
me: ["positions", "me"] as const,
}; };
export function usePositions() { export function usePositions() {
return useQuery({ queryKey: positionKeys.list, queryFn: listPositions }); 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() { export function usePositionUsers() {
return useQuery({ queryKey: positionKeys.users, queryFn: listPositionUsers }); 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(); 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( export async function setSkillDisplay(
skillName: string, skillName: string,
display: SkillDisplayConfig | null, display: SkillDisplayConfig | null,
+19
View File
@@ -18,6 +18,7 @@ import {
installSkillUpload, installSkillUpload,
reconcileCustomSkills, reconcileCustomSkills,
saveSkillFile, saveSkillFile,
setSkillAlwaysOn,
setSkillDetail, setSkillDetail,
setSkillNameZh, setSkillNameZh,
setSkillDisplay, 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() { export function useValidateSkillUpload() {
return useMutation({ return useMutation({
mutationFn: (file: File) => validateSkillUpload(file), mutationFn: (file: File) => validateSkillUpload(file),
+3
View File
@@ -48,6 +48,9 @@ export interface Skill {
category: string; category: string;
license: string; license: string;
enabled: boolean; enabled: boolean;
// 常驻(免压缩) —— 开启技能压缩后,标记此项的技能仍以全文形式注入系统提示词,
// 而非被压缩成索引 + search_skills 检索。
always_on?: boolean;
// Multitenancy fields — owner_user_id is null for built-in / public skills. // Multitenancy fields — owner_user_id is null for built-in / public skills.
// ``builtin`` mirrors that nullness for explicit branching on the UI side. // ``builtin`` mirrors that nullness for explicit branching on the UI side.
owner_user_id?: string | null; owner_user_id?: string | null;
@@ -247,6 +247,50 @@ export async function updateCitationDisplaySettings(
return (await response.json()) as CitationDisplaySettings; 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 { export interface LogSystemSettings {
enabled: boolean; enabled: boolean;
url: string; url: string;
@@ -9,18 +9,21 @@ import {
loadPrivateSquareStatus, loadPrivateSquareStatus,
loadPromptPrefixSettings, loadPromptPrefixSettings,
loadPublishSquares, loadPublishSquares,
loadSkillCompressionSettings,
updateAppearanceSettings, updateAppearanceSettings,
updateCitationDisplaySettings, updateCitationDisplaySettings,
updateLeaderboardSettings, updateLeaderboardSettings,
updatePrivateSquareSettings, updatePrivateSquareSettings,
updatePromptPrefixSettings, updatePromptPrefixSettings,
updatePublishSquares, updatePublishSquares,
updateSkillCompressionSettings,
type AppearanceUpdate, type AppearanceUpdate,
type CitationDisplayUpdate, type CitationDisplayUpdate,
type LeaderboardSettingsUpdate, type LeaderboardSettingsUpdate,
type PrivateSquareUpdate, type PrivateSquareUpdate,
type PromptPrefixUpdate, type PromptPrefixUpdate,
type PublishSquaresUpdate, type PublishSquaresUpdate,
type SkillCompressionUpdate,
type LayoutMode, type LayoutMode,
type ViewMode, type ViewMode,
} from "./api"; } from "./api";
@@ -31,6 +34,7 @@ const PUBLISH_SQUARES_KEY = ["systemSettings", "publishSquares"] as const;
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const; const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const; const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const; const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
export function usePromptPrefixSettings() { export function usePromptPrefixSettings() {
return useQuery({ 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() { export function useLeaderboardSettings() {
return useQuery({ return useQuery({
queryKey: LEADERBOARD_KEY, 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 { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { EmptyDocumentState } from "@/components/wiki/EmptyDocumentState"; import { EmptyDocumentState } from "@/components/wiki/EmptyDocumentState";
import {
ExtractTemplateManageDialog,
FolderManageDialog,
FolderSelect,
TemplateSelect,
} from "@/components/wiki/KnowledgeManage";
import { KnowledgeSidebar } from "@/components/wiki/KnowledgeSidebar"; import { KnowledgeSidebar } from "@/components/wiki/KnowledgeSidebar";
import { WikiContent } from "@/components/wiki/WikiContent"; import { WikiContent } from "@/components/wiki/WikiContent";
import { WikiHeader } from "@/components/wiki/WikiHeader"; import { WikiHeader } from "@/components/wiki/WikiHeader";
@@ -28,6 +34,7 @@ import { ModelNameSelect } from "@/components/workspace/model-name-select";
import { import {
useKnowledgeActions, useKnowledgeActions,
useKnowledgeDuplicates, useKnowledgeDuplicates,
useKnowledgeFolders,
useKnowledgeNote, useKnowledgeNote,
useKnowledgeNotes, useKnowledgeNotes,
useNoteWikilinks, useNoteWikilinks,
@@ -70,11 +77,13 @@ function CreateNoteDialog({
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [tags, setTags] = useState(""); const [tags, setTags] = useState("");
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [folder, setFolder] = useState<string | null>(null);
const reset = () => { const reset = () => {
setTitle(""); setTitle("");
setTags(""); setTags("");
setContent(""); setContent("");
setFolder(null);
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -91,6 +100,7 @@ function CreateNoteDialog({
.map((t) => t.trim()) .map((t) => t.trim())
.filter(Boolean), .filter(Boolean),
status: "approved", status: "approved",
folder,
}); });
toast.success("知识已创建"); toast.success("知识已创建");
onOpenChange(false); onOpenChange(false);
@@ -119,6 +129,10 @@ function CreateNoteDialog({
value={tags} value={tags}
onChange={(e) => setTags(e.target.value)} 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 <Textarea
placeholder="Markdown 正文" placeholder="Markdown 正文"
rows={12} rows={12}
@@ -152,6 +166,8 @@ function BatchImportDialog({
const [skipExisting, setSkipExisting] = useState(true); const [skipExisting, setSkipExisting] = useState(true);
// Optional model override for extraction (null = configured default). // Optional model override for extraction (null = configured default).
const [model, setModel] = useState<string | null>(null); const [model, setModel] = useState<string | null>(null);
const [template, setTemplate] = useState<string | null>(null);
const [folder, setFolder] = useState<string | null>(null);
const handleRun = async () => { const handleRun = async () => {
try { try {
@@ -161,6 +177,8 @@ function BatchImportDialog({
status: "approved", status: "approved",
include_sources: true, include_sources: true,
model_name: model, model_name: model,
template_id: template,
folder,
}); });
const base = `批量导入完成:新增 ${result.created},跳过 ${result.skipped},失败 ${result.failed}`; const base = `批量导入完成:新增 ${result.created},跳过 ${result.skipped},失败 ${result.failed}`;
if (result.llm_fallback && result.llm_fallback > 0) { if (result.llm_fallback && result.llm_fallback > 0) {
@@ -217,6 +235,25 @@ function BatchImportDialog({
triggerClassName="h-9 w-[11rem]" triggerClassName="h-9 w-[11rem]"
/> />
</label> </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> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
@@ -244,12 +281,16 @@ function ImportFileDialog({
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [tags, setTags] = useState(""); const [tags, setTags] = useState("");
const [model, setModel] = useState<string | null>(null); const [model, setModel] = useState<string | null>(null);
const [template, setTemplate] = useState<string | null>(null);
const [folder, setFolder] = useState<string | null>(null);
const reset = () => { const reset = () => {
setFile(null); setFile(null);
setTitle(""); setTitle("");
setTags(""); setTags("");
setModel(null); setModel(null);
setTemplate(null);
setFolder(null);
}; };
const handleImport = async () => { const handleImport = async () => {
@@ -265,6 +306,8 @@ function ImportFileDialog({
tags: tags.trim() || undefined, tags: tags.trim() || undefined,
status: "approved", status: "approved",
model_name: model, model_name: model,
template_id: template,
folder,
}, },
}); });
const count = result.notes?.length ?? 1; const count = result.notes?.length ?? 1;
@@ -330,6 +373,25 @@ function ImportFileDialog({
triggerClassName="h-9 w-[11rem]" triggerClassName="h-9 w-[11rem]"
/> />
</label> </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> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
@@ -478,6 +540,8 @@ export default function KnowledgeBasePage() {
const [batchOpen, setBatchOpen] = useState(false); const [batchOpen, setBatchOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [dedupOpen, setDedupOpen] = 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 [vaultPath, setVaultPath] = useState<string | null>(null);
const params = useMemo( const params = useMemo(
@@ -491,8 +555,13 @@ export default function KnowledgeBasePage() {
); );
const { data, isLoading } = useKnowledgeNotes(params); const { data, isLoading } = useKnowledgeNotes(params);
const { data: folders = [] } = useKnowledgeFolders();
const notes = data?.items ?? []; 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: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
const { data: wikiLinks } = useNoteWikilinks(noteId); const { data: wikiLinks } = useNoteWikilinks(noteId);
@@ -571,6 +640,8 @@ export default function KnowledgeBasePage() {
onDedup={() => setDedupOpen(true)} onDedup={() => setDedupOpen(true)}
onGraph={() => navigate("/page/workspace/knowledge/graph")} onGraph={() => navigate("/page/workspace/knowledge/graph")}
onCreate={() => setCreateOpen(true)} onCreate={() => setCreateOpen(true)}
onManageFolders={() => setFoldersOpen(true)}
onManageTemplates={() => setTemplatesOpen(true)}
/> />
<div <div
@@ -636,6 +707,8 @@ export default function KnowledgeBasePage() {
<ImportFileDialog open={importOpen} onOpenChange={setImportOpen} /> <ImportFileDialog open={importOpen} onOpenChange={setImportOpen} />
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} /> <BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} /> <DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
<FolderManageDialog open={foldersOpen} onOpenChange={setFoldersOpen} />
<ExtractTemplateManageDialog open={templatesOpen} onOpenChange={setTemplatesOpen} />
<VaultPageDialog <VaultPageDialog
path={vaultPath} path={vaultPath}
onOpenChange={(open) => { 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 PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage'; import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage'; import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
import SkillCompressionSettingsPage from './SkillCompressionSettingsPage';
import SkillEvolutionPage from './SkillEvolutionPage'; import SkillEvolutionPage from './SkillEvolutionPage';
import SkillsPage from './SkillsPage'; import SkillsPage from './SkillsPage';
import UsersPage from './UsersPage'; 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 <Route
path="settings/llm-metrics" path="settings/llm-metrics"
element={ element={
@@ -8,6 +8,7 @@ import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { isWujieEnv } from "@/core/embed/wujie"; import { isWujieEnv } from "@/core/embed/wujie";
import { useUnreadCount } from "@/core/notifications"; import { useUnreadCount } from "@/core/notifications";
import { useMyPosition } from "@/core/positions";
import type { MenuItem } from "@/core/page-layout/sidebar-menu"; import type { MenuItem } from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu"; import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -128,6 +129,8 @@ const Header = ({ collapsed }: HeaderProps) => {
const auth = getStoredAuth(); const auth = getStoredAuth();
const isAdmin = auth?.system_role === "admin"; const isAdmin = auth?.system_role === "admin";
const visibleMenuItems = useSidebarMenuItems(!!isAdmin); const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
const { data: myPosition } = useMyPosition();
const positionName = myPosition?.name?.trim() || "";
// 登录时写入的真实用户名(外部登录存于 localStorage.userInfo.userName); // 登录时写入的真实用户名(外部登录存于 localStorage.userInfo.userName);
// 缺省回退到 stored auth 的邮箱前缀。 // 缺省回退到 stored auth 的邮箱前缀。
@@ -277,6 +280,11 @@ const Header = ({ collapsed }: HeaderProps) => {
<p className="text-sm leading-tight font-medium text-white"> <p className="text-sm leading-tight font-medium text-white">
{displayName} {displayName}
</p> </p>
{positionName && (
<p className="text-[11px] leading-tight text-white/70">
{positionName}
</p>
)}
</div> </div>
<ChevronDown <ChevronDown
className={cn( 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. 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 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 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) 10. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
11. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional) 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 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) 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). 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 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) 11. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
12. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional) 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 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. | | **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). | | **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`. | | **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. 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 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. - **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 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. 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` position CRUD; `GET/PUT /{id}/tag-bindings`; `GET/POST/DELETE /{id}/members`
(batch assign/unassign → triggers sync); `POST /{id}/resync`; (batch assign/unassign → triggers sync); `POST /{id}/resync`;
`GET /users` (all users + their position); `PUT /users/{user_id}` (set one `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 filtered by the caller's position (`?all=1` bypasses for admin management) and
admin can tag questions via `/api/tags/assignments/recommended_question/{id}`; admin can tag questions via `/api/tags/assignments/recommended_question/{id}`;
`GET /api/skills?scope=mine` applies skill visibility. Frontend: `core/positions` `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}/` - **Location**: `deer-flow/skills/{public,custom}/`
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools) - **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 - **Injection**: Enabled skills listed in agent system prompt with container paths
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory - **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. - **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`) #### Skill Curator (`packages/harness/deerflow/skills/curator.py`)
Background skill lifecycle manager. The Gateway `lifespan` starts a resident 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`**: **`extensions_config.json`**:
- `mcpServers` - Map of server name ? config (enabled, type, command, args, env, url, headers, oauth, description) - `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. 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) 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 # Background auto-sedimentation queue (phase 3) — drains finished
# conversations off the chat path. Worker only does work when # conversations off the chat path. Worker only does work when
# ``knowledge.auto_ingest_enabled`` is set; the queue is always wired so # ``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.", description="When true, sediment the thread asynchronously in a background task and return immediately.",
) )
model_name: str | None = Field(default=None, description="可选:指定用于知识提炼的大模型名称;留空使用默认配置") 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): class BatchIngestRequest(BaseModel):
@@ -64,6 +66,8 @@ class BatchIngestRequest(BaseModel):
skip_existing: bool = True skip_existing: bool = True
mode: str = "summary" mode: str = "summary"
model_name: str | None = Field(default=None, description="可选:指定用于知识提炼的大模型名称;留空使用默认配置") 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): class SearchResultItem(BaseModel):
@@ -90,6 +94,7 @@ class CreateNoteRequest(BaseModel):
summary: str | None = None summary: str | None = None
tags: list[str] = Field(default_factory=list) tags: list[str] = Field(default_factory=list)
status: str = "approved" status: str = "approved"
folder: str | None = None
class UpdateNoteRequest(BaseModel): class UpdateNoteRequest(BaseModel):
@@ -98,6 +103,34 @@ class UpdateNoteRequest(BaseModel):
summary: str | None = None summary: str | None = None
tags: list[str] | None = None tags: list[str] | None = None
status: 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): class SearchRequest(BaseModel):
@@ -187,6 +220,8 @@ async def _ingest_thread_background(service, checkpointer, thread_id: str, body:
reference_batches=batches, reference_batches=batches,
created_by=created_by, created_by=created_by,
model_name=body.model_name, model_name=body.model_name,
template_id=body.template_id,
folder=body.folder,
) )
except EmptyThreadError: except EmptyThreadError:
logger.warning("Background knowledge ingest skipped: thread %s has no sediment-able content", sanitize_log_param(thread_id)) 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, reference_batches=batches,
created_by=get_effective_user_id(), created_by=get_effective_user_id(),
model_name=body.model_name, model_name=body.model_name,
template_id=body.template_id,
folder=body.folder,
) )
except EmptyThreadError: except EmptyThreadError:
raise HTTPException(status_code=400, detail="Thread has no sediment-able content") 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(), created_by=get_effective_user_id(),
skip_existing=body.skip_existing, skip_existing=body.skip_existing,
model_name=body.model_name, model_name=body.model_name,
template_id=body.template_id,
folder=body.folder,
) )
if result.get("llm_fallback"): if result.get("llm_fallback"):
llm_fallback += 1 llm_fallback += 1
@@ -352,6 +391,8 @@ async def ingest_file(
tags: str | None = Form(default=None), tags: str | None = Form(default=None),
status: str = Form(default="approved"), status: str = Form(default="approved"),
model_name: str | None = Form(default=None), model_name: str | None = Form(default=None),
template_id: str | None = Form(default=None),
folder: str | None = Form(default=None),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Import an uploaded file into the knowledge base as wiki note(s). """Import an uploaded file into the knowledge base as wiki note(s).
@@ -371,6 +412,8 @@ async def ingest_file(
status=status, status=status,
created_by=get_effective_user_id(), created_by=get_effective_user_id(),
model_name=(model_name or None), model_name=(model_name or None),
template_id=(template_id or None),
folder=(folder or None),
) )
except EmptyThreadError: except EmptyThreadError:
raise HTTPException(status_code=422, detail="文件内容无可沉淀的知识") raise HTTPException(status_code=422, detail="文件内容无可沉淀的知识")
@@ -413,6 +456,7 @@ async def create_note(body: CreateNoteRequest, request: Request) -> dict[str, An
summary=body.summary, summary=body.summary,
tags=body.tags, tags=body.tags,
status=body.status, status=body.status,
folder=body.folder,
created_by=get_effective_user_id(), created_by=get_effective_user_id(),
) )
return {"note": note} return {"note": note}
@@ -427,13 +471,14 @@ async def list_notes(
entity: str | None = None, entity: str | None = None,
source_type: str | None = None, source_type: str | None = None,
status: str | None = None, status: str | None = None,
folder: str | None = None,
limit: int = 20, limit: int = 20,
offset: int = 0, offset: int = 0,
) -> dict[str, Any]: ) -> dict[str, Any]:
service = get_knowledge_service(request) service = get_knowledge_service(request)
limit = max(1, min(limit, 100)) limit = max(1, min(limit, 100))
offset = max(0, offset) 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}") @router.get("/notes/{note_id}")
@@ -451,6 +496,9 @@ async def get_note(note_id: str, request: Request) -> dict[str, Any]:
@require_auth @require_auth
async def update_note(note_id: str, body: UpdateNoteRequest, request: Request) -> dict[str, Any]: async def update_note(note_id: str, body: UpdateNoteRequest, request: Request) -> dict[str, Any]:
service = get_knowledge_service(request) 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 = await service.update_note(
note_id, note_id,
title=body.title, title=body.title,
@@ -458,6 +506,8 @@ async def update_note(note_id: str, body: UpdateNoteRequest, request: Request) -
summary=body.summary, summary=body.summary,
tags=body.tags, tags=body.tags,
status=body.status, status=body.status,
folder=body.folder,
set_folder=folder_provided,
updated_by=get_effective_user_id(), updated_by=get_effective_user_id(),
) )
if note is None: 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") raise HTTPException(status_code=404, detail="Export file not found")
media_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" media_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
return FileResponse(path, media_type=media_type) 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] users: list[PositionUserResponse]
class MyPositionResponse(BaseModel):
position: PositionResponse | None = None
# ----------------------------------------------------------------- position CRUD # ----------------------------------------------------------------- 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]) 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") @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: async def create_position(request: Request, body: PositionCreateRequest, store: PositionStore = Depends(get_position_store)) -> PositionResponse:
await _require_admin(request) await _require_admin(request)
@@ -154,6 +154,7 @@ class SkillResponse(BaseModel):
license: str | None = Field(None, description="License information") license: str | None = Field(None, description="License information")
category: SkillCategory = Field(..., description="Category of the skill (public or custom)") category: SkillCategory = Field(..., description="Category of the skill (public or custom)")
enabled: bool = Field(default=True, description="Whether this skill is enabled") 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") 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") 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") 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") 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): class SkillDisplayUpdateRequest(BaseModel):
"""Request model for updating a skill's frontend display adapter.""" """Request model for updating a skill's frontend display adapter."""
@@ -334,6 +341,7 @@ def _skill_to_response(
license=skill.license, license=skill.license,
category=skill.category, category=skill.category,
enabled=skill.enabled, enabled=skill.enabled,
always_on=skill.always_on,
use_count=entry.get("use_count", 0), use_count=entry.get("use_count", 0),
view_count=entry.get("view_count", 0), view_count=entry.get("view_count", 0),
patch_count=entry.get("patch_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) current_skill_config = extensions_config.skills.get(skill_name)
extensions_config.skills[skill_name] = SkillStateConfig( extensions_config.skills[skill_name] = SkillStateConfig(
enabled=request.enabled, 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, 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)}") 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( @router.put(
"/skills/{skill_name}/display", "/skills/{skill_name}/display",
response_model=SkillResponse, response_model=SkillResponse,
@@ -2242,6 +2300,7 @@ async def update_skill_display(
current_skill_config = extensions_config.skills.get(skill_name) current_skill_config = extensions_config.skills.get(skill_name)
extensions_config.skills[skill_name] = SkillStateConfig( extensions_config.skills[skill_name] = SkillStateConfig(
enabled=current_skill_config.enabled if current_skill_config else skill.enabled, 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, display=request.display,
) )
@@ -16,6 +16,7 @@ from deerflow.config.system_settings import (
PromptPrefixSettings, PromptPrefixSettings,
PublishSquare, PublishSquare,
PublishSquaresSettings, PublishSquaresSettings,
SkillCompressionSettings,
SystemSettings, SystemSettings,
list_publish_squares, list_publish_squares,
load_system_settings, load_system_settings,
@@ -113,6 +114,64 @@ async def update_prompt_prefix(body: PromptPrefixUpdateRequest, request: Request
return _to_response(saved.prompt_prefix) 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: def _leaderboard_to_response(settings: LeaderboardSettings) -> LeaderboardSettingsResponse:
return LeaderboardSettingsResponse( return LeaderboardSettingsResponse(
exclude_user_ids=list(dict.fromkeys([uid for uid in settings.exclude_user_ids if uid])), 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 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). - 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. - **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`. - 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. > **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.config.memory_config import get_effective_memory_config
from deerflow.runtime.user_context import get_effective_user_id from deerflow.runtime.user_context import get_effective_user_id
user_id = get_effective_user_id() user_id = get_effective_user_id()
config = get_effective_memory_config(user_id) config = get_effective_memory_config(user_id)
manager = build_memory_manager( manager = build_memory_manager(
user_id=user_id, user_id=user_id,
agent_id=agent_name or "default", agent_id=agent_name or "default",
config=config, config=config,
app_config=app_config, app_config=app_config,
) )
# 只取静态块 (builtin 的 frozen snapshot + Hindsight 的模式说明)。 # 只取静态块 (builtin 的 frozen snapshot + Hindsight 的模式说明)。
# Hindsight 的动态召回需要当前 query,走 MemoryMiddleware.before_model。 # Hindsight 的动态召回需要当前 query,走 MemoryMiddleware.before_model。
context_content = manager.build_system_prompt() context_content = manager.build_system_prompt()
if not context_content.strip(): if not context_content.strip():
return "" return ""
return f"{context_content}\n" return f"{context_content}\n"
except Exception: except Exception:
logger.exception("Failed to load memory context") logger.exception("Failed to load memory context")
return "" 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) @lru_cache(maxsize=32)
def _get_cached_skills_prompt_section( 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], ...], display_signature: tuple[tuple[str, str, str], ...],
available_skills_key: tuple[str, ...] | None, available_skills_key: tuple[str, ...] | None,
container_base_path: str, container_base_path: str,
skill_evolution_section: str, skill_evolution_section: str,
compression_enabled: bool,
keep_index: bool,
) -> str: ) -> str:
display_by_skill = {name: (mode, custom_instruction) for name, mode, custom_instruction in display_signature} 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 = "" skills_list = ""
compression_note = ""
if filtered: if filtered:
items = [] if compression_enabled:
for name, description, category, location in filtered: # 压缩开启:常驻(always_on)技能全文注入;其余技能从提示词正文剔除,
display_mode, custom_instruction = display_by_skill.get(name, ("", "")) # 仅保留可选的"名字索引",由 agent 通过 search_skills 工具按需检索。
display_hint = "" resident = [s for s in filtered if s[4]]
if display_mode: compressed = [s for s in filtered if not s[4]]
display_hint = f"\n <display mode=\"{display_mode}\">{custom_instruction}</display>" blocks: list[str] = []
items.append( for name, description, category, location, _ in resident:
f" <skill>\n <name>{name}</name>\n <description>{description} {_skill_mutability_label(category)}</description>\n <location>{location}</location>{display_hint}\n </skill>" blocks.append(_render_skill_block(name, description, category, location, _display_hint(name)))
) if blocks:
skill_items = "\n".join(items) skills_list = "<available_skills>\n" + "\n".join(blocks) + "\n</available_skills>"
skills_list = f"<available_skills>\n{skill_items}\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. # Restricted mode: agent has an explicit skill allowlist.
# We deliberately omit the "Skills are located at: <path>" hint and the # 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 4. Load referenced resources only when needed during execution
5. Follow the skill's instructions precisely 5. Follow the skill's instructions precisely
{display_pattern} {display_pattern}
{location_hint}{evolution_block} {location_hint}{evolution_block}{compression_note}
{skills_list} {skills_list}
</skill_system>""" </skill_system>"""
@@ -877,13 +909,27 @@ def get_skills_prompt_section(
is_restricted = available_skills is not None is_restricted = available_skills is not None
effective_skill_evolution_enabled = skill_evolution_enabled and not is_restricted 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: if not skills and not effective_skill_evolution_enabled:
return "" return ""
if available_skills is not None and not any(skill.name in available_skills for skill in skills): if available_skills is not None and not any(skill.name in available_skills for skill in skills):
return "" 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], ...] = () display_signature: tuple[tuple[str, str, str], ...] = ()
if display_enabled: if display_enabled:
try: try:
@@ -907,7 +953,7 @@ def get_skills_prompt_section(
if not skill_signature and available_key is not None: if not skill_signature and available_key is not None:
return "" return ""
skill_evolution_section = _build_skill_evolution_section(effective_skill_evolution_enabled) 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: 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. # many threads can't grow it without limit. Oldest entries are evicted first.
_SUMMARY_CACHE_MAX_THREADS = 512 _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) @dataclass(frozen=True)
class SummarizationEvent: class SummarizationEvent:
@@ -319,6 +328,15 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
if cutoff_index <= 0: if cutoff_index <= 0:
return None 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) messages_to_summarize, preserved = self._partition_with_skill_rescue(messages, cutoff_index)
if not messages_to_summarize: if not messages_to_summarize:
return None return None
@@ -368,6 +386,62 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
while len(self._summary_cache) > _SUMMARY_CACHE_MAX_THREADS: while len(self._summary_cache) > _SUMMARY_CACHE_MAX_THREADS:
self._summary_cache.popitem(last=False) 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 @staticmethod
def _resolve_request_thread_id(request: ModelRequest) -> str | None: def _resolve_request_thread_id(request: ModelRequest) -> str | None:
"""Best-effort thread id from the model request's runtime.""" """Best-effort thread id from the model request's runtime."""
@@ -840,13 +840,13 @@ class DeerFlowClient:
current_config = get_extensions_config() current_config = get_extensions_config()
config_data = { config_data = {
"mcpServers": mcp_servers, "mcpServers": mcp_servers,
"skills": { "skills": {
name: skill.model_dump(by_alias=True, exclude_none=True) name: skill.model_dump(by_alias=True, exclude_none=True)
for name, skill in current_config.skills.items() for name, skill in current_config.skills.items()
}, },
} }
self._atomic_write_json(config_path, config_data) self._atomic_write_json(config_path, config_data)
@@ -906,20 +906,21 @@ class DeerFlowClient:
if config_path is None: 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.") 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() extensions_config = get_extensions_config()
current_skill_config = extensions_config.skills.get(name) current_skill_config = extensions_config.skills.get(name)
extensions_config.skills[name] = SkillStateConfig( extensions_config.skills[name] = SkillStateConfig(
enabled=enabled, enabled=enabled,
display=current_skill_config.display if current_skill_config else None, 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()}, config_data = {
"skills": { "mcpServers": {n: s.model_dump(by_alias=True) for n, s in extensions_config.mcp_servers.items()},
n: sc.model_dump(by_alias=True, exclude_none=True) "skills": {
for n, sc in extensions_config.skills.items() 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) self._atomic_write_json(config_path, config_data)
@@ -161,6 +161,10 @@ class SkillStateConfig(BaseModel):
"""Configuration for a single skill's state.""" """Configuration for a single skill's state."""
enabled: bool = Field(default=True, description="Whether this skill is enabled") 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") 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_category in ("public", "custom")
return skill_config.enabled 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 _extensions_config: ExtensionsConfig | None = None
@@ -170,6 +170,26 @@ class LeaderboardSettings(BaseModel):
model_config = ConfigDict(extra="ignore") 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): class SystemSettings(BaseModel):
"""Top-level system settings document.""" """Top-level system settings document."""
@@ -179,6 +199,7 @@ class SystemSettings(BaseModel):
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings) appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings) citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings)
leaderboard: LeaderboardSettings = Field(default_factory=LeaderboardSettings) leaderboard: LeaderboardSettings = Field(default_factory=LeaderboardSettings)
skill_compression: SkillCompressionSettings = Field(default_factory=SkillCompressionSettings)
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
@@ -204,6 +225,15 @@ def load_system_settings() -> SystemSettings:
return 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: def save_system_settings(settings: SystemSettings) -> SystemSettings:
"""Persist the settings document atomically, returning the saved value.""" """Persist the settings document atomically, returning the saved value."""
path = _settings_path() path = _settings_path()
@@ -120,14 +120,18 @@ def related_links(project_name: str | None) -> list[str]: # noqa: ARG001
_related_links = related_links _related_links = related_links
def _related_section_md(related_lines: list[str], source_lines: list[str]) -> str: 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.""" """Render the ``## Related`` section, or ``""`` when there is nothing to show.
if not related_lines and not source_lines:
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 "" return ""
inner = "\n".join(related_lines) 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" return f"## Related\n\n{inner}\n"
@@ -283,13 +283,16 @@ async def extract_thread_llm_multi(
max_input_chars: int = 12000, max_input_chars: int = 12000,
allow_multi: bool = True, allow_multi: bool = True,
max_notes: int = 4, max_notes: int = 4,
system_prompt: str | None = None,
) -> list[NoteDraft]: ) -> list[NoteDraft]:
"""Distill a thread into one *or several* cross-linkable knowledge notes. """Distill a thread into one *or several* cross-linkable knowledge notes.
The LLM may split a conversation that covers multiple independent topics into The LLM may split a conversation that covers multiple independent topics into
several declarative notes. Returns ``[]`` when there is nothing to sediment several declarative notes. Returns ``[]`` when there is nothing to sediment
or the LLM path fails — callers should fall back to the rule-based extractor. 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) turns = extractor.split_turns(messages, max_messages=max_messages)
if not turns: 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) model = create_chat_model(name=model_name or None, thinking_enabled=False)
transcript = _build_transcript(turns, sources, max_chars=max_input_chars) 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 = [ prompt = [
SystemMessage(content=_SYSTEM_PROMPT + "\n\n" + WIKI_CAPTURE_GUIDANCE), SystemMessage(content=system_text),
HumanMessage(content=f"以下是一次对话,请按规范提炼为知识 JSON:\n\n{transcript}"), HumanMessage(content=f"以下是一次对话,请按规范提炼为知识 JSON:\n\n{transcript}"),
] ]
response = await model.ainvoke(prompt) response = await model.ainvoke(prompt)
@@ -375,6 +379,7 @@ async def extract_document_llm_multi(
max_input_chars: int = 12000, max_input_chars: int = 12000,
allow_multi: bool = True, allow_multi: bool = True,
max_notes: int = 4, max_notes: int = 4,
system_prompt: str | None = None,
) -> list[NoteDraft]: ) -> list[NoteDraft]:
"""Distill an imported **document** into one *or several* knowledge notes. """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) 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…(已截断)" body = text if len(text) <= max_input_chars else text[:max_input_chars] + "\n…(已截断)"
header = f"文档标题:{doc_title}\n\n" if doc_title else "" 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 = [ prompt = [
SystemMessage(content=_DOC_SYSTEM_PROMPT + "\n\n" + WIKI_CAPTURE_GUIDANCE), SystemMessage(content=system_text),
HumanMessage(content=f"以下是一篇文档,请按规范提炼为知识 JSON:\n\n{header}{body}"), HumanMessage(content=f"以下是一篇文档,请按规范提炼为知识 JSON:\n\n{header}{body}"),
] ]
response = await model.ainvoke(prompt) response = await model.ainvoke(prompt)
@@ -33,6 +33,7 @@ class KnowledgeNoteRow(Base):
__table_args__ = ( __table_args__ = (
Index("ix_knowledge_notes_vault_path", "vault_path", mysql_length=191), 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_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) id: Mapped[str] = mapped_column(String(64), primary_key=True)
@@ -40,6 +41,10 @@ class KnowledgeNoteRow(Base):
summary: Mapped[str | None] = mapped_column(Text) summary: Mapped[str | None] = mapped_column(Text)
content_md: Mapped[str] = mapped_column(PortableLongText()) content_md: Mapped[str] = mapped_column(PortableLongText())
vault_path: Mapped[str | None] = mapped_column(String(1024)) 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_type: Mapped[str] = mapped_column(String(64), index=True)
source_id: Mapped[str | None] = mapped_column(String(256)) source_id: Mapped[str | None] = mapped_column(String(256))
status: Mapped[str] = mapped_column(String(32), default="approved", index=True) 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)) relation_type: Mapped[str] = mapped_column(String(128))
weight: Mapped[float] = mapped_column(Float, default=1.0) weight: Mapped[float] = mapped_column(Float, default=1.0)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC)) 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 ( from deerflow.knowledge.models import (
KnowledgeEmbeddingRow, KnowledgeEmbeddingRow,
KnowledgeEntityRow, KnowledgeEntityRow,
KnowledgeExtractTemplateRow,
KnowledgeFolderRow,
KnowledgeGraphBlacklistRow, KnowledgeGraphBlacklistRow,
KnowledgeNoteRow, KnowledgeNoteRow,
KnowledgeNoteTagRow, KnowledgeNoteTagRow,
@@ -127,6 +129,7 @@ class KnowledgeRepository:
status: str, status: str,
confidence: float = 0.0, confidence: float = 0.0,
vault_path: str | None = None, vault_path: str | None = None,
folder: str | None = None,
tags: list[str] | None = None, tags: list[str] | None = None,
created_by: str | None = None, created_by: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -138,6 +141,7 @@ class KnowledgeRepository:
summary=summary, summary=summary,
content_md=content_md, content_md=content_md,
vault_path=vault_path, vault_path=vault_path,
folder=(folder or None),
source_type=source_type, source_type=source_type,
source_id=source_id, source_id=source_id,
status=status, status=status,
@@ -175,6 +179,8 @@ class KnowledgeRepository:
status: str | None = None, status: str | None = None,
tags: list[str] | None = None, tags: list[str] | None = None,
vault_path: str | None = None, vault_path: str | None = None,
folder: str | None = None,
set_folder: bool = False,
updated_by: str | None = None, updated_by: str | None = None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
async with self._sf() as session: async with self._sf() as session:
@@ -191,6 +197,10 @@ class KnowledgeRepository:
row.status = status row.status = status
if vault_path is not None: if vault_path is not None:
row.vault_path = vault_path 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: if updated_by is not None:
row.updated_by = updated_by row.updated_by = updated_by
row.updated_at = datetime.now(UTC) row.updated_at = datetime.now(UTC)
@@ -279,6 +289,7 @@ class KnowledgeRepository:
entity: str | None = None, entity: str | None = None,
source_type: str | None = None, source_type: str | None = None,
status: str | None = None, status: str | None = None,
folder: str | None = None,
include_archived: bool = False, include_archived: bool = False,
limit: int = 20, limit: int = 20,
offset: int = 0, offset: int = 0,
@@ -294,6 +305,12 @@ class KnowledgeRepository:
conditions.append(KnowledgeNoteRow.status != "archived") conditions.append(KnowledgeNoteRow.status != "archived")
if source_type: if source_type:
conditions.append(KnowledgeNoteRow.source_type == 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: if keyword:
like = f"%{keyword}%" like = f"%{keyword}%"
conditions.append(or_(KnowledgeNoteRow.title.like(like), KnowledgeNoteRow.summary.like(like), KnowledgeNoteRow.content_md.like(like))) 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() 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 source_id: str | None = None
status: str = "approved" status: str = "approved"
confidence: float = 0.0 confidence: float = 0.0
# User-assigned directory (slash-joined path); None = unfiled.
folder: str | None = None
tags: list[str] = field(default_factory=list) tags: list[str] = field(default_factory=list)
key_points: list[str] = field(default_factory=list) key_points: list[str] = field(default_factory=list)
actions: list[str] = field(default_factory=list) actions: list[str] = field(default_factory=list)
@@ -145,6 +145,7 @@ class KnowledgeService:
source_id=draft.source_id, source_id=draft.source_id,
status=draft.status, status=draft.status,
confidence=draft.confidence, confidence=draft.confidence,
folder=draft.folder,
tags=draft.tags, tags=draft.tags,
created_by=created_by, created_by=created_by,
) )
@@ -223,15 +224,21 @@ class KnowledgeService:
created_by: str | None = None, created_by: str | None = None,
skip_existing: bool = False, skip_existing: bool = False,
model_name: str | None = None, model_name: str | None = None,
template_id: str | None = None,
folder: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Sediment a thread into a knowledge note. """Sediment a thread into a knowledge note.
``model_name`` optionally overrides the configured extraction model for ``model_name`` optionally overrides the configured extraction model for
this one capture (falls back to ``config.extract_model_name``). 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 Returns ``{"note": <note>, "created": bool}``. Raises
:class:`EmptyThreadError` when there is nothing worth keeping. :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, # Always-on entry log: proves a capture was triggered for this thread,
# regardless of how it ends (success / skip / fallback). Lets you tell # regardless of how it ends (success / skip / fallback). Lets you tell
# "capture never ran" apart from "logs not visible" via one grep. # "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, model_name=model_name or self.config.extract_model_name,
max_input_chars=self.config.extract_max_input_chars, max_input_chars=self.config.extract_max_input_chars,
allow_multi=self.config.multi_note_enabled, allow_multi=self.config.multi_note_enabled,
system_prompt=extract_prompt,
) )
except llm_extractor.LLMRejectedIngest as exc: except llm_extractor.LLMRejectedIngest as exc:
# The model judged this conversation worthless (greeting / meta # The model judged this conversation worthless (greeting / meta
@@ -324,6 +332,8 @@ class KnowledgeService:
normalized = normalize_status(effective_status) normalized = normalize_status(effective_status)
for draft in drafts: for draft in drafts:
draft.status = normalized draft.status = normalized
if folder:
draft.folder = folder
# Cross-link sibling notes from the same conversation by title, so the # Cross-link sibling notes from the same conversation by title, so the
# split pages reference each other (resolved once all are persisted). # split pages reference each other (resolved once all are persisted).
if len(drafts) > 1: if len(drafts) > 1:
@@ -370,6 +380,7 @@ class KnowledgeService:
tags: list[str] | None = None, tags: list[str] | None = None,
status: str = "approved", status: str = "approved",
summary: str | None = None, summary: str | None = None,
folder: str | None = None,
created_by: str | None = None, created_by: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Create a note directly from product-edited Markdown.""" """Create a note directly from product-edited Markdown."""
@@ -388,6 +399,7 @@ class KnowledgeService:
source_type="manual", source_type="manual",
source_id=None, source_id=None,
status=normalize_status(status), status=normalize_status(status),
folder=(folder or None),
tags=base_tags, tags=base_tags,
confidence=0.5, confidence=0.5,
related=extractor.related_links(self.project_name), related=extractor.related_links(self.project_name),
@@ -431,6 +443,8 @@ class KnowledgeService:
created_by: str | None = None, created_by: str | None = None,
model_name: str | None = None, model_name: str | None = None,
skip_existing: bool = False, skip_existing: bool = False,
template_id: str | None = None,
folder: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Import a file (already converted to Markdown/text) into knowledge notes. """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")) logger.info("[knowledge] file '%s' already imported (note %s); skipping", filename, existing.get("id"))
return {"note": existing, "created": False, "notes": [existing], "llm_fallback": False} return {"note": existing, "created": False, "notes": [existing], "llm_fallback": False}
extract_prompt = await self._resolve_extract_prompt(template_id, "document")
drafts: list[NoteDraft] = [] drafts: list[NoteDraft] = []
llm_fallback = False llm_fallback = False
if self.config.llm_extract_enabled: if self.config.llm_extract_enabled:
@@ -482,6 +497,7 @@ class KnowledgeService:
model_name=model_name or self.config.extract_model_name, model_name=model_name or self.config.extract_model_name,
max_input_chars=self.config.extract_max_input_chars, max_input_chars=self.config.extract_max_input_chars,
allow_multi=self.config.multi_note_enabled, allow_multi=self.config.multi_note_enabled,
system_prompt=extract_prompt,
) )
except llm_extractor.LLMRejectedIngest as exc: except llm_extractor.LLMRejectedIngest as exc:
raise EmptyThreadError(f"File {filename} judged not worth ingesting: {exc}") from exc raise EmptyThreadError(f"File {filename} judged not worth ingesting: {exc}") from exc
@@ -496,6 +512,8 @@ class KnowledgeService:
normalized = normalize_status(effective_status) normalized = normalize_status(effective_status)
for draft in drafts: for draft in drafts:
draft.status = normalized draft.status = normalized
if folder:
draft.folder = folder
# Cross-link sibling notes split from the same document (resolved by title # Cross-link sibling notes split from the same document (resolved by title
# once all are persisted), mirroring multi-note thread capture. # once all are persisted), mirroring multi-note thread capture.
@@ -526,6 +544,7 @@ class KnowledgeService:
entity: str | None = None, entity: str | None = None,
source_type: str | None = None, source_type: str | None = None,
status: str | None = None, status: str | None = None,
folder: str | None = None,
limit: int = 20, limit: int = 20,
offset: int = 0, offset: int = 0,
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -535,6 +554,7 @@ class KnowledgeService:
entity=entity, entity=entity,
source_type=normalize_source_type(source_type) if source_type else None, source_type=normalize_source_type(source_type) if source_type else None,
status=status, status=status,
folder=folder,
limit=limit, limit=limit,
offset=offset, offset=offset,
) )
@@ -555,6 +575,8 @@ class KnowledgeService:
tags: list[str] | None = None, tags: list[str] | None = None,
status: str | None = None, status: str | None = None,
summary: str | None = None, summary: str | None = None,
folder: str | None = None,
set_folder: bool = False,
updated_by: str | None = None, updated_by: str | None = None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
current = await self.repo.get_note(note_id, with_sources=False) current = await self.repo.get_note(note_id, with_sources=False)
@@ -576,6 +598,8 @@ class KnowledgeService:
content_md=body, content_md=body,
status=normalize_status(status) if status else None, status=normalize_status(status) if status else None,
tags=tags, tags=tags,
folder=folder,
set_folder=set_folder,
updated_by=updated_by, updated_by=updated_by,
) )
if note is None: if note is None:
@@ -604,6 +628,84 @@ class KnowledgeService:
async def list_versions(self, note_id: str) -> list[dict[str, Any]]: async def list_versions(self, note_id: str) -> list[dict[str, Any]]:
return await self.repo.list_versions(note_id) 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: 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.""" """Soft delete (archive) by default; hard delete removes the vault file."""
current = await self.repo.get_note(note_id, with_sources=False) 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 ( from deerflow.knowledge.models import (
KnowledgeEmbeddingRow, KnowledgeEmbeddingRow,
KnowledgeEntityRow, KnowledgeEntityRow,
KnowledgeExtractTemplateRow,
KnowledgeFolderRow,
KnowledgeGraphBlacklistRow, KnowledgeGraphBlacklistRow,
KnowledgeNoteRow, KnowledgeNoteRow,
KnowledgeNoteTagRow, KnowledgeNoteTagRow,
@@ -78,6 +80,8 @@ __all__ = [
"BusinessMappingRow", "BusinessMappingRow",
"KnowledgeEmbeddingRow", "KnowledgeEmbeddingRow",
"KnowledgeEntityRow", "KnowledgeEntityRow",
"KnowledgeExtractTemplateRow",
"KnowledgeFolderRow",
"KnowledgeGraphBlacklistRow", "KnowledgeGraphBlacklistRow",
"KnowledgeNoteRow", "KnowledgeNoteRow",
"KnowledgeNoteTagRow", "KnowledgeNoteTagRow",
@@ -1,8 +1,8 @@
"""ORM models for 岗位 (positions) and their per-resource tag bindings. """ORM models for 岗位 (positions) and their per-resource tag bindings.
A **position** (岗位) is an admin-managed aggregation layer that decides which A **position** (岗位) is an admin-managed aggregation layer that decides which
agents / skills / scheduled tasks / custom prompts / recommended questions a agents / skills / scheduled tasks / recommended questions a user is subscribed
user is subscribed to. Each user has at most one position (the link lives on to. Each user has at most one position (the link lives on
``users.position_id``, a 1:1 relation — see ``UserRow``). ``users.position_id``, a 1:1 relation — see ``UserRow``).
Two tables here: Two tables here:
@@ -26,8 +26,9 @@ from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime from deerflow.persistence.types import BeijingDateTime
# Resource types a position can grant. The first three reuse the existing # Resource types a position can grant. The first three reuse the existing
# typed-tag namespace (``deerflow.persistence.tags``); ``user_prompt`` and # typed-tag namespace (``deerflow.persistence.tags``); ``recommended_question``
# ``recommended_question`` are added by the same change (see TAG_TYPES). # 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, ...] = ( POSITION_RESOURCE_TYPES: tuple[str, ...] = (
"agent", "agent",
"skill", "skill",
@@ -11,7 +11,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.positions.base import PositionStore 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 from deerflow.persistence.user.model import UserRow
@@ -134,6 +134,11 @@ class PositionRepository(PositionStore):
) )
out: dict[str, list[str]] = {} out: dict[str, list[str]] = {}
for r in rows: 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) out.setdefault(r.resource_type, []).append(r.tag_id)
return out return out
@@ -54,11 +54,42 @@ class LocalSkillStorage(SkillStorage):
return self._host_root return self._host_root
def custom_skill_exists(self, name: str) -> bool: 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: 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) 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]]: def _iter_skill_files(self) -> Iterable[tuple[SkillCategory, Path, Path]]:
if not self._host_root.exists(): 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(".")) dir_names[:] = sorted(name for name in dir_names if not name.startswith("."))
if SKILL_MD_FILE not in file_names: if SKILL_MD_FILE not in file_names:
continue 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 yield category, category_path, Path(current_root) / SKILL_MD_FILE
def read_custom_skill(self, name: str) -> str: 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.") 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: 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)) 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: def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None:
self.validate_skill_name(name) self.validate_skill_name(name)
self.ensure_custom_skill_is_editable(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: if history_meta is not None:
prev_content = self.read_custom_skill(name) prev_content = self.read_custom_skill(name)
try: try:
@@ -247,6 +247,7 @@ class SkillStorage(ABC):
extensions_config = ExtensionsConfig.from_file() extensions_config = ExtensionsConfig.from_file()
for skill in skills: for skill in skills:
skill.enabled = extensions_config.is_skill_enabled(skill.name, skill.category) 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: except Exception as e:
logger.warning("Failed to load extensions config: %s", 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 relative_path: Path # Relative path from category root to skill directory
category: SkillCategory # 'public' or 'custom' category: SkillCategory # 'public' or 'custom'
enabled: bool = False # Whether this skill is enabled enabled: bool = False # Whether this skill is enabled
always_on: bool = False # 常驻(免压缩):开启压缩后仍把本技能全文注入系统提示词
@property @property
def skill_path(self) -> str: 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)) 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") @tool("skill_list")
def skill_list_tool(runtime: ToolRuntime) -> str: def skill_list_tool(runtime: ToolRuntime) -> str:
"""List skills available to this agent, with metadata and usage stats. """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]) 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 # read_peer_delivery —— 圆桌席位「按需读取前序席位完整交付」的只读工具(从 run context
# 取网关注入的全文,非圆桌场景为空 → 工具自述「无」)。始终提供(只读、无副作用);圆桌 # 取网关注入的全文,非圆桌场景为空 → 工具自述「无」)。始终提供(只读、无副作用);圆桌
# 总控/报告角色由 run policy 显式排除,避免干扰它们。 # 总控/报告角色由 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', 'origin', "VARCHAR(16) NOT NULL DEFAULT 'user'");
CALL _add_column_if_missing('scheduled_task_subscriptions', 'position_id', 'VARCHAR(64) NULL DEFAULT NULL'); 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; DROP PROCEDURE IF EXISTS _add_column_if_missing;
@@ -111,6 +115,20 @@ PREPARE stmt FROM @idx_ddl;
EXECUTE stmt; EXECUTE stmt;
DEALLOCATE PREPARE 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 SELECT table_name, column_name
FROM information_schema.columns FROM information_schema.columns
+9 -2
View File
@@ -310,7 +310,14 @@ summarization:
value: 60000 value: 60000
- type: messages - type: messages
value: 80 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: keep:
type: messages type: tokens
value: 20 value: 16000
trim_tokens_to_summarize: 4000 trim_tokens_to_summarize: 4000