feat: 知识库文件夹/模板、技能压缩设置与摘要窗口调优
- 知识库新增文件夹与模板支持(模型/仓储/服务/迁移),前端知识管理组件 - 岗位管理模型与接口完善 - 技能新增压缩设置页与相关存储/工具能力 - 系统设置扩展配置项 - 摘要中间件 keep 窗口改用 token 预算(16000),减少重复压缩 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
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;
|
||||||
}
|
}
|
||||||
@@ -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={() =>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+77
-31
@@ -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:
|
||||||
|
|||||||
+74
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+19
@@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+9
-3
@@ -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)
|
||||||
|
|||||||
+84
@@ -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")
|
||||||
+4
@@ -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",
|
||||||
|
|||||||
+5
-4
@@ -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",
|
||||||
|
|||||||
+6
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
+53
-5
@@ -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:
|
||||||
|
|||||||
+1
@@ -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:
|
||||||
|
|||||||
+107
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user