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 { DocumentToolbar } from "./DocumentToolbar";
|
||||
import { FolderSelect } from "./KnowledgeManage";
|
||||
import { FloatingToc } from "./FloatingToc";
|
||||
import { MarkdownViewer } from "./MarkdownViewer";
|
||||
import { RelatedDocs } from "./RelatedDocs";
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
downloadMarkdown,
|
||||
extractToc,
|
||||
formatDateTime,
|
||||
localizeHeadings,
|
||||
noteBreadcrumbSegments,
|
||||
splitRelatedSection,
|
||||
} from "./wikiUtils";
|
||||
@@ -102,10 +104,14 @@ export function WikiContent({
|
||||
|
||||
// Pull the backend-injected `## Related` wikilinks out of the body so they
|
||||
// render as a structured module; editing still works on the full source.
|
||||
const { body, related } = useMemo(
|
||||
// After extraction, localize the English section headings (Context / Finding
|
||||
// / Reasoning / …) to Chinese for display — `extractToc` and the viewer share
|
||||
// this transformed text so heading anchors stay consistent.
|
||||
const { body: rawBody, related } = useMemo(
|
||||
() => splitRelatedSection(note.content_md),
|
||||
[note.content_md],
|
||||
);
|
||||
const body = useMemo(() => localizeHeadings(rawBody), [rawBody]);
|
||||
const toc = useMemo(() => extractToc(body), [body]);
|
||||
// The floating outline only shows h2/h3 — deeper levels are noise.
|
||||
const outlineItems = useMemo(
|
||||
@@ -177,6 +183,15 @@ export function WikiContent({
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveFolder = async (folder: string | null) => {
|
||||
try {
|
||||
await update.mutateAsync({ noteId: note.id, data: { folder } });
|
||||
toast.success(folder ? `已移动到「${folder}」` : "已移出目录");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "移动失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async () => {
|
||||
try {
|
||||
await remove.mutateAsync({ noteId: note.id });
|
||||
@@ -277,6 +292,15 @@ export function WikiContent({
|
||||
{note.created_at ? (
|
||||
<MetaTag>创建:{formatDateTime(note.created_at).slice(0, 10)}</MetaTag>
|
||||
) : null}
|
||||
<span className="inline-flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
目录
|
||||
<FolderSelect
|
||||
value={note.folder ?? null}
|
||||
onChange={(v) => void handleMoveFolder(v)}
|
||||
disabled={update.isPending}
|
||||
triggerClassName="h-6 w-[10rem] text-xs"
|
||||
/>
|
||||
</span>
|
||||
{note.tags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
Copy,
|
||||
FolderTree,
|
||||
Menu,
|
||||
Network,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -48,6 +50,8 @@ export function WikiHeader({
|
||||
onDedup,
|
||||
onGraph,
|
||||
onCreate,
|
||||
onManageFolders,
|
||||
onManageTemplates,
|
||||
}: {
|
||||
onToggleSidebar: () => void;
|
||||
onImportFile: () => void;
|
||||
@@ -55,6 +59,8 @@ export function WikiHeader({
|
||||
onDedup: () => void;
|
||||
onGraph: () => void;
|
||||
onCreate: () => void;
|
||||
onManageFolders: () => void;
|
||||
onManageTemplates: () => void;
|
||||
}) {
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900">
|
||||
@@ -83,6 +89,16 @@ export function WikiHeader({
|
||||
label="批量导入"
|
||||
onClick={onBatchImport}
|
||||
/>
|
||||
<HeaderButton
|
||||
icon={<FolderTree className="size-4" />}
|
||||
label="目录管理"
|
||||
onClick={onManageFolders}
|
||||
/>
|
||||
<HeaderButton
|
||||
icon={<SlidersHorizontal className="size-4" />}
|
||||
label="抽取模板"
|
||||
onClick={onManageTemplates}
|
||||
/>
|
||||
<HeaderButton
|
||||
icon={<Copy className="size-4" />}
|
||||
label="查重"
|
||||
|
||||
@@ -14,4 +14,6 @@ export interface TreeNode {
|
||||
type: "folder" | "file";
|
||||
children?: TreeNode[];
|
||||
item?: KnowledgeNote;
|
||||
/** Full slash-joined directory path (folder nodes only). */
|
||||
folderPath?: string;
|
||||
}
|
||||
@@ -19,6 +19,73 @@ export const STATUS_LABELS: Record<string, string> = {
|
||||
|
||||
const FALLBACK_FOLDER = "未分类";
|
||||
|
||||
/**
|
||||
* Display labels for known English directory segments (the wiki-capture
|
||||
* categories that historically drove the vault path). Keyed by the lowercased
|
||||
* segment. User-created folders are arbitrary (usually Chinese) and simply don't
|
||||
* match, so they pass through unchanged.
|
||||
*/
|
||||
const FOLDER_LABELS: Record<string, string> = {
|
||||
synthesis: "分析综合",
|
||||
concepts: "概念框架",
|
||||
concept: "概念框架",
|
||||
references: "外部来源",
|
||||
reference: "外部来源",
|
||||
decision: "决策记录",
|
||||
journal: "会话纪要",
|
||||
session: "会话纪要",
|
||||
};
|
||||
|
||||
/** Map a known English directory segment to its Chinese display label (else unchanged). */
|
||||
export function localizeFolderName(name: string): string {
|
||||
return FOLDER_LABELS[name.trim().toLowerCase()] ?? name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical English wiki-capture section headings → Chinese display labels.
|
||||
* Keyed by the lowercased, symbol-stripped heading text.
|
||||
*/
|
||||
const HEADING_LABELS: Record<string, string> = {
|
||||
context: "背景",
|
||||
"finding / decision": "结论 / 决策",
|
||||
"finding/decision": "结论 / 决策",
|
||||
finding: "结论",
|
||||
decision: "决策",
|
||||
reasoning: "推理依据",
|
||||
implications: "影响 / 后续",
|
||||
sources: "来源",
|
||||
related: "相关知识",
|
||||
merged: "已合并",
|
||||
};
|
||||
|
||||
/**
|
||||
* Replace the canonical English wiki-capture section headings (`## Context`,
|
||||
* `## Finding / Decision`, …) with Chinese labels at render time. Operates on
|
||||
* heading lines only (fenced code blocks are skipped) and matches the heading
|
||||
* text case-insensitively, so **all historical notes are localized with no data
|
||||
* migration**. Heading levels are preserved; because `extractToc` and the
|
||||
* markdown viewer both receive this same transformed text, the slug/anchor ids
|
||||
* stay consistent.
|
||||
*/
|
||||
export function localizeHeadings(markdown: string): string {
|
||||
let inFence = false;
|
||||
return markdown
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
if (/^\s*(```|~~~)/.test(line)) {
|
||||
inFence = !inFence;
|
||||
return line;
|
||||
}
|
||||
if (inFence) return line;
|
||||
const match = /^(#{1,4})\s+(.+?)\s*$/.exec(line);
|
||||
if (!match) return line;
|
||||
const key = match[2].replace(/[#`*_~]/g, "").trim().toLowerCase();
|
||||
const zh = HEADING_LABELS[key];
|
||||
return zh ? `${match[1]} ${zh}` : line;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Stable slug for a heading: keeps CJK characters, collapses whitespace to dashes. */
|
||||
export function slugify(text: string): string {
|
||||
return text
|
||||
@@ -119,16 +186,17 @@ export function splitRelatedSection(markdown: string): {
|
||||
return { body, related };
|
||||
}
|
||||
|
||||
/** Folder path segments for a note: vault_path directories, else its source-type group. */
|
||||
/**
|
||||
* Folder path segments for a note. The user-assigned ``folder`` (a slash-joined
|
||||
* path) is authoritative; unfiled notes fall back to a source-type group so the
|
||||
* tree is never empty. (The old behavior keyed off ``vault_path`` — the fixed
|
||||
* ``projects/<project>/<category>`` scaffold — which the editable directory now
|
||||
* replaces.)
|
||||
*/
|
||||
export function noteFolderSegments(note: KnowledgeNote): string[] {
|
||||
const rawPath = note.vault_path?.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
if (rawPath && rawPath.includes("/")) {
|
||||
let segments = rawPath.split("/").slice(0, -1).filter(Boolean);
|
||||
// Vault notes live under projects/<project>/<category>/ — the first two
|
||||
// levels are constant scaffolding, so hide them from the directory tree.
|
||||
if (segments[0] === "projects" || segments[0] === "project") {
|
||||
segments = segments.slice(2);
|
||||
}
|
||||
const folder = (note.folder || "").replace(/^\/+|\/+$/g, "").trim();
|
||||
if (folder) {
|
||||
const segments = folder.split("/").map((s) => s.trim()).filter(Boolean);
|
||||
if (segments.length > 0) return segments;
|
||||
}
|
||||
return [SOURCE_TYPE_LABELS[note.source_type] ?? FALLBACK_FOLDER];
|
||||
@@ -136,7 +204,7 @@ export function noteFolderSegments(note: KnowledgeNote): string[] {
|
||||
|
||||
/** Breadcrumb segments for a note (folders only; the title is appended by the caller). */
|
||||
export function noteBreadcrumbSegments(note: KnowledgeNote): string[] {
|
||||
return ["问答梳理", ...noteFolderSegments(note)];
|
||||
return ["问答梳理", ...noteFolderSegments(note).map(localizeFolderName)];
|
||||
}
|
||||
|
||||
function sortTree(nodes: TreeNode[]): void {
|
||||
@@ -153,7 +221,10 @@ function sortTree(nodes: TreeNode[]): void {
|
||||
* Build the directory tree from the flat note list. Notes with a vault_path
|
||||
* become nested folders mirroring that path; others are grouped by source type.
|
||||
*/
|
||||
export function buildKnowledgeTree(notes: KnowledgeNote[]): TreeNode[] {
|
||||
export function buildKnowledgeTree(
|
||||
notes: KnowledgeNote[],
|
||||
folderPaths: string[] = [],
|
||||
): TreeNode[] {
|
||||
const root: TreeNode[] = [];
|
||||
const folderMap = new Map<string, TreeNode>();
|
||||
|
||||
@@ -164,7 +235,9 @@ export function buildKnowledgeTree(notes: KnowledgeNote[]): TreeNode[] {
|
||||
path = path ? `${path}/${segment}` : segment;
|
||||
let node = folderMap.get(path);
|
||||
if (!node) {
|
||||
node = { id: `folder:${path}`, name: segment, type: "folder", children: [] };
|
||||
// Display name is localized (known English category → 中文); the id /
|
||||
// folderPath keep the raw segment so filtering/matching is unaffected.
|
||||
node = { id: `folder:${path}`, name: localizeFolderName(segment), type: "folder", children: [], folderPath: path };
|
||||
folderMap.set(path, node);
|
||||
children.push(node);
|
||||
}
|
||||
@@ -173,6 +246,13 @@ export function buildKnowledgeTree(notes: KnowledgeNote[]): TreeNode[] {
|
||||
return children;
|
||||
};
|
||||
|
||||
// Pre-create managed (possibly empty) directories so a freshly-made folder
|
||||
// shows up in the tree before any note is filed into it.
|
||||
for (const path of folderPaths) {
|
||||
const segments = path.split("/").map((s) => s.trim()).filter(Boolean);
|
||||
if (segments.length) ensureFolder(segments);
|
||||
}
|
||||
|
||||
for (const note of notes) {
|
||||
ensureFolder(noteFolderSegments(note)).push({
|
||||
id: note.id,
|
||||
|
||||
@@ -406,7 +406,7 @@ export function PositionManagementPage() {
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">岗位管理</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
为岗位配置默认智能体/技能/定时任务/常用提示词/推荐问题的标签,并批量分配给用户。
|
||||
为岗位配置默认智能体/技能/定时任务/推荐问题的标签,并批量分配给用户。
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => { setEditing(null); setDialogOpen(true); }}>
|
||||
|
||||
@@ -35,7 +35,7 @@ import { cn } from "@/lib/utils";
|
||||
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
|
||||
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
|
||||
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
|
||||
import RecommendedQuestionsPage from "@/pages/RecommendedQuestionsPage";
|
||||
import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
@@ -457,14 +457,14 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
<RecommendedQuestionsPage embedded />
|
||||
<Separator />
|
||||
<PromptPrefixSettingsPage embedded />
|
||||
<Separator />
|
||||
<PrivateSquareSettingsPage embedded />
|
||||
<Separator />
|
||||
<PublishSquaresSettingsPage embedded />
|
||||
<Separator />
|
||||
<SkillCompressionSettingsPage embedded />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -814,6 +814,15 @@ export function SkillRow({
|
||||
{t.settings.skills.badgePinned}
|
||||
</Badge>
|
||||
)}
|
||||
{skill.always_on && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/30 text-amber-700 dark:text-amber-300 text-xs"
|
||||
title="常驻(免压缩):开启技能压缩后,本技能仍全文注入系统提示词并被正常检索"
|
||||
>
|
||||
常驻
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
@@ -37,12 +38,14 @@ import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
useGenerateSkillNameZh,
|
||||
useSetSkillAlwaysOn,
|
||||
useSetSkillDetail,
|
||||
useSetSkillNameZh,
|
||||
useSkill,
|
||||
useSkillContent,
|
||||
useSkillFiles,
|
||||
} from "@/core/skills/hooks";
|
||||
import { useSkillCompressionSettings } from "@/core/system-settings/hooks";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
@@ -105,6 +108,8 @@ export function SkillDetailDialog({
|
||||
const setDetail = useSetSkillDetail();
|
||||
const setNameZh = useSetSkillNameZh();
|
||||
const generateNameZh = useGenerateSkillNameZh();
|
||||
const setAlwaysOn = useSetSkillAlwaysOn();
|
||||
const { data: compression } = useSkillCompressionSettings();
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
@@ -327,6 +332,15 @@ export function SkillDetailDialog({
|
||||
{tx.badgePublished}
|
||||
</Badge>
|
||||
)}
|
||||
{skill?.always_on && compression?.enabled && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/30 text-xs text-amber-700 dark:text-amber-300"
|
||||
title="常驻(免压缩):本技能始终全文注入系统提示词"
|
||||
>
|
||||
常驻
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed">
|
||||
@@ -372,6 +386,31 @@ export function SkillDetailDialog({
|
||||
</TabsList>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{canManage && compression?.enabled && skill && (
|
||||
<div
|
||||
className="mr-1 flex items-center gap-1.5"
|
||||
title="开启后,即使启用了技能压缩,本技能仍会全文注入系统提示词并被正常检索(免压缩)"
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">常驻(免压缩)</span>
|
||||
<Switch
|
||||
checked={!!skill.always_on}
|
||||
disabled={setAlwaysOn.isPending}
|
||||
onCheckedChange={(next) =>
|
||||
setAlwaysOn.mutate(
|
||||
{ skillName: skill.name, alwaysOn: next },
|
||||
{
|
||||
onSuccess: () =>
|
||||
toast.success(next ? "已设为常驻(免压缩)" : "已取消常驻"),
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "更新常驻状态失败",
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selectedTab === "detail" && canManage && !editing && (
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DatabaseZapIcon,
|
||||
GaugeIcon,
|
||||
HistoryIcon,
|
||||
MessageCircleQuestionIcon,
|
||||
ScrollTextIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
@@ -216,6 +217,18 @@ export function WorkspaceNavMenu() {
|
||||
岗位管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate(
|
||||
"/page/workspace/settings/recommended-questions",
|
||||
)
|
||||
}
|
||||
>
|
||||
<MessageCircleQuestionIcon />
|
||||
推荐问题配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && logSystem?.enabled && logSystem.url && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
|
||||
@@ -4,8 +4,11 @@ import { getBackendBaseURL } from "@/core/config";
|
||||
import type {
|
||||
BatchIngestPayload,
|
||||
BatchIngestResult,
|
||||
ExtractTemplateScope,
|
||||
GraphExportResult,
|
||||
IngestThreadPayload,
|
||||
KnowledgeExtractTemplate,
|
||||
KnowledgeFolder,
|
||||
KnowledgeListParams,
|
||||
KnowledgeListResponse,
|
||||
KnowledgeNote,
|
||||
@@ -35,6 +38,7 @@ export async function listKnowledgeNotes(params: KnowledgeListParams = {}) {
|
||||
if (params.entity) qs.set("entity", params.entity);
|
||||
if (params.source_type) qs.set("source_type", params.source_type);
|
||||
if (params.status) qs.set("status", params.status);
|
||||
if (params.folder !== undefined) qs.set("folder", params.folder);
|
||||
qs.set("limit", String(params.limit ?? 20));
|
||||
qs.set("offset", String(params.offset ?? 0));
|
||||
return parseResponse<KnowledgeListResponse>(
|
||||
@@ -54,6 +58,7 @@ export async function createKnowledgeNote(data: {
|
||||
summary?: string | null;
|
||||
tags?: string[];
|
||||
status?: string;
|
||||
folder?: string | null;
|
||||
}) {
|
||||
const res = await parseResponse<{ note: KnowledgeNote }>(
|
||||
await apiFetch(`${baseUrl()}/notes`, {
|
||||
@@ -73,6 +78,7 @@ export async function updateKnowledgeNote(
|
||||
summary: string | null;
|
||||
tags: string[];
|
||||
status: string;
|
||||
folder: string | null;
|
||||
}>,
|
||||
) {
|
||||
return parseResponse<KnowledgeNote>(
|
||||
@@ -138,7 +144,14 @@ export interface IngestFileResult {
|
||||
/** Import a file (md/txt/pdf/word/ppt/excel) into the KB as wiki note(s). */
|
||||
export async function ingestKnowledgeFile(
|
||||
file: File,
|
||||
opts: { title?: string; tags?: string; status?: string; model_name?: string | null } = {},
|
||||
opts: {
|
||||
title?: string;
|
||||
tags?: string;
|
||||
status?: string;
|
||||
model_name?: string | null;
|
||||
template_id?: string | null;
|
||||
folder?: string | null;
|
||||
} = {},
|
||||
) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
@@ -146,6 +159,8 @@ export async function ingestKnowledgeFile(
|
||||
if (opts.tags) form.append("tags", opts.tags);
|
||||
if (opts.status) form.append("status", opts.status);
|
||||
if (opts.model_name) form.append("model_name", opts.model_name);
|
||||
if (opts.template_id) form.append("template_id", opts.template_id);
|
||||
if (opts.folder) form.append("folder", opts.folder);
|
||||
// No Content-Type header — the browser sets the multipart boundary itself.
|
||||
return parseResponse<IngestFileResult>(
|
||||
await apiFetch(`${baseUrl()}/ingest/file`, { method: "POST", body: form }),
|
||||
@@ -284,3 +299,98 @@ export async function removeGraphBlacklist(entryId: string) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// --- folders (user-managed directories) ---
|
||||
|
||||
export async function listKnowledgeFolders() {
|
||||
const res = await parseResponse<{ items: KnowledgeFolder[] }>(
|
||||
await apiFetch(`${baseUrl()}/folders`),
|
||||
);
|
||||
return res.items;
|
||||
}
|
||||
|
||||
export async function createKnowledgeFolder(path: string) {
|
||||
const res = await parseResponse<{ folder: KnowledgeFolder }>(
|
||||
await apiFetch(`${baseUrl()}/folders`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
}),
|
||||
);
|
||||
return res.folder;
|
||||
}
|
||||
|
||||
export async function renameKnowledgeFolder(folderId: string, path: string) {
|
||||
const res = await parseResponse<{ folder: KnowledgeFolder }>(
|
||||
await apiFetch(`${baseUrl()}/folders/${encodeURIComponent(folderId)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
}),
|
||||
);
|
||||
return res.folder;
|
||||
}
|
||||
|
||||
export async function deleteKnowledgeFolder(folderId: string, reassignTo?: string) {
|
||||
const qs = reassignTo ? `?reassign_to=${encodeURIComponent(reassignTo)}` : "";
|
||||
return parseResponse<{ ok: boolean }>(
|
||||
await apiFetch(`${baseUrl()}/folders/${encodeURIComponent(folderId)}${qs}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// --- extraction prompt templates ---
|
||||
|
||||
export async function listExtractTemplates(scope?: ExtractTemplateScope, enabledOnly = false) {
|
||||
const qs = new URLSearchParams();
|
||||
if (scope) qs.set("scope", scope);
|
||||
if (enabledOnly) qs.set("enabled_only", "true");
|
||||
const q = qs.toString();
|
||||
const res = await parseResponse<{ items: KnowledgeExtractTemplate[] }>(
|
||||
await apiFetch(`${baseUrl()}/extract-templates${q ? `?${q}` : ""}`),
|
||||
);
|
||||
return res.items;
|
||||
}
|
||||
|
||||
export type ExtractTemplateInput = {
|
||||
name: string;
|
||||
system_prompt: string;
|
||||
scope?: ExtractTemplateScope;
|
||||
description?: string | null;
|
||||
is_default?: boolean;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export async function createExtractTemplate(data: ExtractTemplateInput) {
|
||||
const res = await parseResponse<{ template: KnowledgeExtractTemplate }>(
|
||||
await apiFetch(`${baseUrl()}/extract-templates`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
);
|
||||
return res.template;
|
||||
}
|
||||
|
||||
export async function updateExtractTemplate(
|
||||
templateId: string,
|
||||
data: Partial<ExtractTemplateInput>,
|
||||
) {
|
||||
const res = await parseResponse<{ template: KnowledgeExtractTemplate }>(
|
||||
await apiFetch(`${baseUrl()}/extract-templates/${encodeURIComponent(templateId)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
);
|
||||
return res.template;
|
||||
}
|
||||
|
||||
export async function deleteExtractTemplate(templateId: string) {
|
||||
return parseResponse<{ ok: boolean }>(
|
||||
await apiFetch(`${baseUrl()}/extract-templates/${encodeURIComponent(templateId)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
|
||||
import {
|
||||
addGraphBlacklist,
|
||||
createExtractTemplate,
|
||||
createKnowledgeFolder,
|
||||
createKnowledgeNote,
|
||||
deleteExtractTemplate,
|
||||
deleteKnowledgeFolder,
|
||||
deleteKnowledgeNote,
|
||||
exportKnowledgeGraph,
|
||||
fetchVaultPage,
|
||||
@@ -18,14 +22,18 @@ import {
|
||||
ingestThread,
|
||||
ingestThreadsBatch,
|
||||
listDuplicates,
|
||||
listExtractTemplates,
|
||||
listKnowledgeFolders,
|
||||
listKnowledgeNotes,
|
||||
mergeKnowledgeNotes,
|
||||
removeGraphBlacklist,
|
||||
renameKnowledgeFolder,
|
||||
resolveNoteWikilinks,
|
||||
searchKnowledge,
|
||||
updateExtractTemplate,
|
||||
updateKnowledgeNote,
|
||||
} from "./api";
|
||||
import type { KnowledgeListParams } from "./types";
|
||||
import type { ExtractTemplateScope, KnowledgeListParams } from "./types";
|
||||
|
||||
const ROOT_KEY = ["knowledge"] as const;
|
||||
|
||||
@@ -172,3 +180,62 @@ export function useKnowledgeDuplicates(enabled: boolean) {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
// --- folders (user-managed directories) ---
|
||||
|
||||
export function useKnowledgeFolders() {
|
||||
return useQuery({
|
||||
queryKey: [...ROOT_KEY, "folders"],
|
||||
queryFn: listKnowledgeFolders,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useFolderActions() {
|
||||
const queryClient = useQueryClient();
|
||||
// Folder changes can move notes around → invalidate the whole KB tree.
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ROOT_KEY });
|
||||
return {
|
||||
create: useMutation({ mutationFn: createKnowledgeFolder, onSuccess: invalidate }),
|
||||
rename: useMutation({
|
||||
mutationFn: ({ folderId, path }: { folderId: string; path: string }) =>
|
||||
renameKnowledgeFolder(folderId, path),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
remove: useMutation({
|
||||
mutationFn: ({ folderId, reassignTo }: { folderId: string; reassignTo?: string }) =>
|
||||
deleteKnowledgeFolder(folderId, reassignTo),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// --- extraction prompt templates ---
|
||||
|
||||
export function useExtractTemplates(scope?: ExtractTemplateScope, enabledOnly = false) {
|
||||
return useQuery({
|
||||
queryKey: [...ROOT_KEY, "extract-templates", scope ?? "all", enabledOnly],
|
||||
queryFn: () => listExtractTemplates(scope, enabledOnly),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useExtractTemplateActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: [...ROOT_KEY, "extract-templates"] });
|
||||
return {
|
||||
create: useMutation({ mutationFn: createExtractTemplate, onSuccess: invalidate }),
|
||||
update: useMutation({
|
||||
mutationFn: ({
|
||||
templateId,
|
||||
data,
|
||||
}: {
|
||||
templateId: string;
|
||||
data: Parameters<typeof updateExtractTemplate>[1];
|
||||
}) => updateExtractTemplate(templateId, data),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
remove: useMutation({ mutationFn: deleteExtractTemplate, onSuccess: invalidate }),
|
||||
};
|
||||
}
|
||||
@@ -31,6 +31,8 @@ export interface KnowledgeNote {
|
||||
summary?: string | null;
|
||||
content_md: string;
|
||||
vault_path?: string | null;
|
||||
/** User-assigned directory (slash-joined path); null/undefined = unfiled. */
|
||||
folder?: string | null;
|
||||
source_type: string;
|
||||
source_id?: string | null;
|
||||
status: KnowledgeStatus;
|
||||
@@ -58,10 +60,37 @@ export interface KnowledgeListParams {
|
||||
entity?: string;
|
||||
source_type?: string;
|
||||
status?: string;
|
||||
/** Filter to a directory (and its sub-folders); "" = unfiled only. */
|
||||
folder?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/** A user-managed knowledge directory. */
|
||||
export interface KnowledgeFolder {
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export type ExtractTemplateScope = "thread" | "document" | "both";
|
||||
|
||||
/** An editable knowledge-extraction prompt template. */
|
||||
export interface KnowledgeExtractTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
scope: ExtractTemplateScope;
|
||||
system_prompt: string;
|
||||
is_default: boolean;
|
||||
enabled: boolean;
|
||||
created_by?: string | null;
|
||||
updated_by?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export type WikilinkKind = "note" | "vault" | "missing";
|
||||
|
||||
export interface WikilinkResolution {
|
||||
@@ -105,6 +134,10 @@ export interface IngestThreadPayload {
|
||||
background?: boolean;
|
||||
/** Optional model override for knowledge extraction (null = configured default). */
|
||||
model_name?: string | null;
|
||||
/** Optional extraction-prompt template id (null = scope default). */
|
||||
template_id?: string | null;
|
||||
/** Optional directory to file the result under (slash-joined path). */
|
||||
folder?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchIngestPayload {
|
||||
@@ -116,6 +149,10 @@ export interface BatchIngestPayload {
|
||||
mode?: "summary" | "full";
|
||||
/** Optional model override for knowledge extraction (null = configured default). */
|
||||
model_name?: string | null;
|
||||
/** Optional extraction-prompt template id (null = scope default). */
|
||||
template_id?: string | null;
|
||||
/** Optional directory to file results under (slash-joined path). */
|
||||
folder?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchIngestResult {
|
||||
|
||||
@@ -106,6 +106,13 @@ export async function resyncPosition(positionId: string): Promise<number> {
|
||||
return data.synced;
|
||||
}
|
||||
|
||||
export async function getMyPosition(): Promise<Position | null> {
|
||||
const res = await apiFetch(`${base()}/me`);
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to load my position");
|
||||
const data = (await res.json()) as { position: Position | null };
|
||||
return data.position ?? null;
|
||||
}
|
||||
|
||||
export async function listPositionUsers(): Promise<PositionUser[]> {
|
||||
const res = await apiFetch(`${base()}/users`);
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to list users");
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
addMembers,
|
||||
createPosition,
|
||||
deletePosition,
|
||||
getMyPosition,
|
||||
getTagBindings,
|
||||
listMembers,
|
||||
listPositionUsers,
|
||||
@@ -26,12 +27,18 @@ const positionKeys = {
|
||||
bindings: (id: string) => ["positions", "bindings", id] as const,
|
||||
members: (id: string) => ["positions", "members", id] as const,
|
||||
users: ["positions", "users"] as const,
|
||||
me: ["positions", "me"] as const,
|
||||
};
|
||||
|
||||
export function usePositions() {
|
||||
return useQuery({ queryKey: positionKeys.list, queryFn: listPositions });
|
||||
}
|
||||
|
||||
/** The caller's own effective position (explicit, else default). */
|
||||
export function useMyPosition() {
|
||||
return useQuery({ queryKey: positionKeys.me, queryFn: getMyPosition });
|
||||
}
|
||||
|
||||
export function usePositionUsers() {
|
||||
return useQuery({ queryKey: positionKeys.users, queryFn: listPositionUsers });
|
||||
}
|
||||
|
||||
@@ -65,6 +65,26 @@ export async function enableSkill(skillName: string, enabled: boolean) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function setSkillAlwaysOn(
|
||||
skillName: string,
|
||||
alwaysOn: boolean,
|
||||
): Promise<Skill> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/always-on`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ always_on: alwaysOn }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to update skill 常驻 flag");
|
||||
}
|
||||
return response.json() as Promise<Skill>;
|
||||
}
|
||||
|
||||
export async function setSkillDisplay(
|
||||
skillName: string,
|
||||
display: SkillDisplayConfig | null,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
installSkillUpload,
|
||||
reconcileCustomSkills,
|
||||
saveSkillFile,
|
||||
setSkillAlwaysOn,
|
||||
setSkillDetail,
|
||||
setSkillNameZh,
|
||||
setSkillDisplay,
|
||||
@@ -171,6 +172,24 @@ export function useEnableSkill() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSkillAlwaysOn() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ skillName, alwaysOn }: { skillName: string; alwaysOn: boolean }) =>
|
||||
setSkillAlwaysOn(skillName, alwaysOn),
|
||||
onSuccess: (data, { skillName }) => {
|
||||
queryClient.setQueryData(["skill", skillName], data);
|
||||
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
||||
Array.isArray(current)
|
||||
? current.map((item) => (item.name === skillName ? { ...item, ...data } : item))
|
||||
: current,
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useValidateSkillUpload() {
|
||||
return useMutation({
|
||||
mutationFn: (file: File) => validateSkillUpload(file),
|
||||
|
||||
@@ -48,6 +48,9 @@ export interface Skill {
|
||||
category: string;
|
||||
license: string;
|
||||
enabled: boolean;
|
||||
// 常驻(免压缩) —— 开启技能压缩后,标记此项的技能仍以全文形式注入系统提示词,
|
||||
// 而非被压缩成索引 + search_skills 检索。
|
||||
always_on?: boolean;
|
||||
// Multitenancy fields — owner_user_id is null for built-in / public skills.
|
||||
// ``builtin`` mirrors that nullness for explicit branching on the UI side.
|
||||
owner_user_id?: string | null;
|
||||
|
||||
@@ -247,6 +247,50 @@ export async function updateCitationDisplaySettings(
|
||||
return (await response.json()) as CitationDisplaySettings;
|
||||
}
|
||||
|
||||
export interface SkillCompressionSettings {
|
||||
enabled: boolean;
|
||||
search_top_n: number;
|
||||
keep_index_in_prompt: boolean;
|
||||
}
|
||||
|
||||
export interface SkillCompressionUpdate {
|
||||
enabled?: boolean;
|
||||
search_top_n?: number;
|
||||
keep_index_in_prompt?: boolean;
|
||||
}
|
||||
|
||||
export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/skill-compression`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to load skill compression settings: ${response.status}`,
|
||||
);
|
||||
}
|
||||
return (await response.json()) as SkillCompressionSettings;
|
||||
}
|
||||
|
||||
export async function updateSkillCompressionSettings(
|
||||
body: SkillCompressionUpdate,
|
||||
): Promise<SkillCompressionSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/skill-compression`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to update skill compression" }));
|
||||
throw new Error(detail.detail ?? "Failed to update skill compression");
|
||||
}
|
||||
return (await response.json()) as SkillCompressionSettings;
|
||||
}
|
||||
|
||||
export interface LogSystemSettings {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
|
||||
@@ -9,18 +9,21 @@ import {
|
||||
loadPrivateSquareStatus,
|
||||
loadPromptPrefixSettings,
|
||||
loadPublishSquares,
|
||||
loadSkillCompressionSettings,
|
||||
updateAppearanceSettings,
|
||||
updateCitationDisplaySettings,
|
||||
updateLeaderboardSettings,
|
||||
updatePrivateSquareSettings,
|
||||
updatePromptPrefixSettings,
|
||||
updatePublishSquares,
|
||||
updateSkillCompressionSettings,
|
||||
type AppearanceUpdate,
|
||||
type CitationDisplayUpdate,
|
||||
type LeaderboardSettingsUpdate,
|
||||
type PrivateSquareUpdate,
|
||||
type PromptPrefixUpdate,
|
||||
type PublishSquaresUpdate,
|
||||
type SkillCompressionUpdate,
|
||||
type LayoutMode,
|
||||
type ViewMode,
|
||||
} from "./api";
|
||||
@@ -31,6 +34,7 @@ const PUBLISH_SQUARES_KEY = ["systemSettings", "publishSquares"] as const;
|
||||
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
|
||||
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
|
||||
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
|
||||
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
|
||||
|
||||
export function usePromptPrefixSettings() {
|
||||
return useQuery({
|
||||
@@ -186,6 +190,29 @@ export function useUpdateCitationDisplaySettings() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Skill compression (技能压缩) settings. Readable by any logged-in user — the
|
||||
* skill detail page reads it to decide whether to show the 常驻(免压缩) toggle. */
|
||||
export function useSkillCompressionSettings() {
|
||||
return useQuery({
|
||||
queryKey: SKILL_COMPRESSION_KEY,
|
||||
queryFn: () => loadSkillCompressionSettings(),
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSkillCompressionSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: SkillCompressionUpdate) =>
|
||||
updateSkillCompressionSettings(body),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(SKILL_COMPRESSION_KEY, data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useLeaderboardSettings() {
|
||||
return useQuery({
|
||||
queryKey: LEADERBOARD_KEY,
|
||||
|
||||
@@ -16,6 +16,12 @@ import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { EmptyDocumentState } from "@/components/wiki/EmptyDocumentState";
|
||||
import {
|
||||
ExtractTemplateManageDialog,
|
||||
FolderManageDialog,
|
||||
FolderSelect,
|
||||
TemplateSelect,
|
||||
} from "@/components/wiki/KnowledgeManage";
|
||||
import { KnowledgeSidebar } from "@/components/wiki/KnowledgeSidebar";
|
||||
import { WikiContent } from "@/components/wiki/WikiContent";
|
||||
import { WikiHeader } from "@/components/wiki/WikiHeader";
|
||||
@@ -28,6 +34,7 @@ import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||
import {
|
||||
useKnowledgeActions,
|
||||
useKnowledgeDuplicates,
|
||||
useKnowledgeFolders,
|
||||
useKnowledgeNote,
|
||||
useKnowledgeNotes,
|
||||
useNoteWikilinks,
|
||||
@@ -70,11 +77,13 @@ function CreateNoteDialog({
|
||||
const [title, setTitle] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [folder, setFolder] = useState<string | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
setTitle("");
|
||||
setTags("");
|
||||
setContent("");
|
||||
setFolder(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -91,6 +100,7 @@ function CreateNoteDialog({
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
status: "approved",
|
||||
folder,
|
||||
});
|
||||
toast.success("知识已创建");
|
||||
onOpenChange(false);
|
||||
@@ -119,6 +129,10 @@ function CreateNoteDialog({
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
/>
|
||||
<label className="flex items-center justify-between gap-2 text-sm">
|
||||
目录
|
||||
<FolderSelect value={folder} onChange={setFolder} triggerClassName="w-[14rem]" />
|
||||
</label>
|
||||
<Textarea
|
||||
placeholder="Markdown 正文"
|
||||
rows={12}
|
||||
@@ -152,6 +166,8 @@ function BatchImportDialog({
|
||||
const [skipExisting, setSkipExisting] = useState(true);
|
||||
// Optional model override for extraction (null = configured default).
|
||||
const [model, setModel] = useState<string | null>(null);
|
||||
const [template, setTemplate] = useState<string | null>(null);
|
||||
const [folder, setFolder] = useState<string | null>(null);
|
||||
|
||||
const handleRun = async () => {
|
||||
try {
|
||||
@@ -161,6 +177,8 @@ function BatchImportDialog({
|
||||
status: "approved",
|
||||
include_sources: true,
|
||||
model_name: model,
|
||||
template_id: template,
|
||||
folder,
|
||||
});
|
||||
const base = `批量导入完成:新增 ${result.created},跳过 ${result.skipped},失败 ${result.failed}`;
|
||||
if (result.llm_fallback && result.llm_fallback > 0) {
|
||||
@@ -217,6 +235,25 @@ function BatchImportDialog({
|
||||
triggerClassName="h-9 w-[11rem]"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-2 text-sm">
|
||||
抽取模板
|
||||
<TemplateSelect
|
||||
value={template}
|
||||
onChange={setTemplate}
|
||||
scope="thread"
|
||||
disabled={batchIngest.isPending}
|
||||
triggerClassName="w-[11rem]"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-2 text-sm">
|
||||
归入目录
|
||||
<FolderSelect
|
||||
value={folder}
|
||||
onChange={setFolder}
|
||||
disabled={batchIngest.isPending}
|
||||
triggerClassName="w-[11rem]"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
@@ -244,12 +281,16 @@ function ImportFileDialog({
|
||||
const [title, setTitle] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [model, setModel] = useState<string | null>(null);
|
||||
const [template, setTemplate] = useState<string | null>(null);
|
||||
const [folder, setFolder] = useState<string | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
setFile(null);
|
||||
setTitle("");
|
||||
setTags("");
|
||||
setModel(null);
|
||||
setTemplate(null);
|
||||
setFolder(null);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
@@ -265,6 +306,8 @@ function ImportFileDialog({
|
||||
tags: tags.trim() || undefined,
|
||||
status: "approved",
|
||||
model_name: model,
|
||||
template_id: template,
|
||||
folder,
|
||||
},
|
||||
});
|
||||
const count = result.notes?.length ?? 1;
|
||||
@@ -330,6 +373,25 @@ function ImportFileDialog({
|
||||
triggerClassName="h-9 w-[11rem]"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-2 text-sm">
|
||||
抽取模板
|
||||
<TemplateSelect
|
||||
value={template}
|
||||
onChange={setTemplate}
|
||||
scope="document"
|
||||
disabled={ingestFile.isPending}
|
||||
triggerClassName="w-[11rem]"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-2 text-sm">
|
||||
归入目录
|
||||
<FolderSelect
|
||||
value={folder}
|
||||
onChange={setFolder}
|
||||
disabled={ingestFile.isPending}
|
||||
triggerClassName="w-[11rem]"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
@@ -478,6 +540,8 @@ export default function KnowledgeBasePage() {
|
||||
const [batchOpen, setBatchOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [dedupOpen, setDedupOpen] = useState(false);
|
||||
const [foldersOpen, setFoldersOpen] = useState(false);
|
||||
const [templatesOpen, setTemplatesOpen] = useState(false);
|
||||
const [vaultPath, setVaultPath] = useState<string | null>(null);
|
||||
|
||||
const params = useMemo(
|
||||
@@ -491,8 +555,13 @@ export default function KnowledgeBasePage() {
|
||||
);
|
||||
|
||||
const { data, isLoading } = useKnowledgeNotes(params);
|
||||
const { data: folders = [] } = useKnowledgeFolders();
|
||||
const notes = data?.items ?? [];
|
||||
const tree = useMemo(() => buildKnowledgeTree(notes), [notes]);
|
||||
const folderPaths = useMemo(() => folders.map((f) => f.path), [folders]);
|
||||
const tree = useMemo(
|
||||
() => buildKnowledgeTree(notes, folderPaths),
|
||||
[notes, folderPaths],
|
||||
);
|
||||
|
||||
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
|
||||
const { data: wikiLinks } = useNoteWikilinks(noteId);
|
||||
@@ -571,6 +640,8 @@ export default function KnowledgeBasePage() {
|
||||
onDedup={() => setDedupOpen(true)}
|
||||
onGraph={() => navigate("/page/workspace/knowledge/graph")}
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
onManageFolders={() => setFoldersOpen(true)}
|
||||
onManageTemplates={() => setTemplatesOpen(true)}
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -636,6 +707,8 @@ export default function KnowledgeBasePage() {
|
||||
<ImportFileDialog open={importOpen} onOpenChange={setImportOpen} />
|
||||
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
|
||||
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
|
||||
<FolderManageDialog open={foldersOpen} onOpenChange={setFoldersOpen} />
|
||||
<ExtractTemplateManageDialog open={templatesOpen} onOpenChange={setTemplatesOpen} />
|
||||
<VaultPageDialog
|
||||
path={vaultPath}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useSkillCompressionSettings,
|
||||
useUpdateSkillCompressionSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
|
||||
/**
|
||||
* 管理员「技能压缩」配置页。
|
||||
*
|
||||
* 开启后,只有被标记为「常驻(免压缩)」的技能会全文注入系统提示词;其余已启用
|
||||
* 技能会被压缩(从提示词正文剔除),由智能体在需要时通过 `search_skills` 工具
|
||||
* 按关键词检索调出。关闭时行为不变 —— 所有已启用技能全文注入。
|
||||
*/
|
||||
export default function SkillCompressionSettingsPage({
|
||||
embedded = false,
|
||||
}: {
|
||||
embedded?: boolean;
|
||||
} = {}) {
|
||||
const { data, isLoading, error } = useSkillCompressionSettings();
|
||||
const update = useUpdateSkillCompressionSettings();
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [keepIndex, setKeepIndex] = useState(true);
|
||||
const [searchTopN, setSearchTopN] = useState(5);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || initializedRef.current) return;
|
||||
setEnabled(data.enabled);
|
||||
setKeepIndex(data.keep_index_in_prompt);
|
||||
setSearchTopN(data.search_top_n);
|
||||
initializedRef.current = true;
|
||||
}, [data]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const topN = Math.max(1, Math.min(Math.round(searchTopN) || 5, 50));
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
enabled,
|
||||
keep_index_in_prompt: keepIndex,
|
||||
search_top_n: topN,
|
||||
});
|
||||
setSearchTopN(topN);
|
||||
toast.success("已保存");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
embedded
|
||||
? "flex w-full flex-col gap-6"
|
||||
: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>
|
||||
技能压缩配置
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
技能过多会让系统提示词变得臃肿、增加 token 消耗。开启「技能压缩」后,只有被标记为
|
||||
「常驻(免压缩)」的技能会全文注入系统提示词;其余已启用技能会被压缩,智能体在需要时
|
||||
通过 <code>search_skills</code> 工具按关键词检索调出。关闭时行为不变 ——
|
||||
所有已启用技能全文注入。某个技能是否常驻,在该技能的详情页单独开启。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
|
||||
加载失败:{error.message}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">启用技能压缩</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||
关闭时,所有已启用技能照旧全文注入系统提示词(默认行为)。
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">保留技能名索引</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||
开启后,被压缩的技能仍以「仅名字」的紧凑列表出现在提示词里,帮助智能体知道存在
|
||||
哪些技能;关闭则完全隐藏,只能靠 search_skills 检索发现。
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={keepIndex}
|
||||
disabled={!enabled}
|
||||
onCheckedChange={setKeepIndex}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium">检索返回条数</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
search_skills 工具每次最多返回的匹配技能数量(1–50)。
|
||||
</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={searchTopN}
|
||||
disabled={!enabled}
|
||||
onChange={(e) => setSearchTopN(Number(e.target.value))}
|
||||
className="w-28"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} disabled={update.isPending}>
|
||||
{update.isPending && (
|
||||
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import MemoryPage from './MemoryPage';
|
||||
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
|
||||
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
|
||||
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
|
||||
import SkillCompressionSettingsPage from './SkillCompressionSettingsPage';
|
||||
import SkillEvolutionPage from './SkillEvolutionPage';
|
||||
import SkillsPage from './SkillsPage';
|
||||
import UsersPage from './UsersPage';
|
||||
@@ -534,6 +535,18 @@ export default function WorkspaceRoutes({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/skill-compression"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<SkillCompressionSettingsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/llm-metrics"
|
||||
element={
|
||||
|
||||
@@ -8,6 +8,7 @@ import { NotificationDialog } from "@/components/workspace/notification-dialog";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { isWujieEnv } from "@/core/embed/wujie";
|
||||
import { useUnreadCount } from "@/core/notifications";
|
||||
import { useMyPosition } from "@/core/positions";
|
||||
import type { MenuItem } from "@/core/page-layout/sidebar-menu";
|
||||
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -128,6 +129,8 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
const auth = getStoredAuth();
|
||||
const isAdmin = auth?.system_role === "admin";
|
||||
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
|
||||
const { data: myPosition } = useMyPosition();
|
||||
const positionName = myPosition?.name?.trim() || "";
|
||||
|
||||
// 登录时写入的真实用户名(外部登录存于 localStorage.userInfo.userName);
|
||||
// 缺省回退到 stored auth 的邮箱前缀。
|
||||
@@ -277,6 +280,11 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
<p className="text-sm leading-tight font-medium text-white">
|
||||
{displayName}
|
||||
</p>
|
||||
{positionName && (
|
||||
<p className="text-[11px] leading-tight text-white/70">
|
||||
{positionName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
|
||||
Reference in new issue
Block a user