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={() =>
|
||||
|
||||
Reference in new issue
Block a user