feat: update knowledge workspace and menu configuration

This commit is contained in:
吉鑫升 committed 2026-08-18 15:23:14 +08:00
1 parent 64a5305bbf
commit 8af4fed89d
28 files changed
+1250 -219

No files matched your search

+5
View File
@@ -111,6 +111,11 @@ frontend-web-node_modules-offline-*.tar.gz
/.pnpm-store/
/offline-backend-20260512/backend/backend*.zip
# Local offline bundles and scratch tooling
/*.tar.gz
/offline-backend-20260512/*.zip
/tmp/
.codegraph
codegraph.mdc
@@ -0,0 +1,71 @@
"use client";
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { languages } from "@codemirror/language-data";
import { basicLightInit } from "@uiw/codemirror-theme-basic";
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
import CodeMirror from "@uiw/react-codemirror";
import { useTheme } from "next-themes";
import { useMemo } from "react";
import { cn } from "@/lib/utils";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { reasoningPlugins } from "@/core/streamdown";
const lightTheme = basicLightInit({ settings: { background: "transparent", fontSize: "14px" } });
const darkTheme = monokaiInit({
settings: { background: "transparent", gutterBackground: "transparent", fontSize: "14px" },
});
/** Markdown-aware source editor shared by Wiki create/edit dialogs. */
export function MarkdownCodeEditor({
value,
onChange,
className,
}: {
value: string;
onChange: (value: string) => void;
className?: string;
}) {
const { resolvedTheme } = useTheme();
const extensions = useMemo(
() => [markdown({ base: markdownLanguage, codeLanguages: languages })],
[],
);
return (
<div className={cn("grid min-h-[46vh] overflow-hidden rounded-lg border bg-background lg:grid-cols-2", className)}>
<section className="flex min-h-0 flex-col border-b lg:border-r lg:border-b-0">
<div className="flex h-9 shrink-0 items-center border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground">
Markdown 编辑
</div>
<CodeMirror
value={value}
onChange={onChange}
theme={resolvedTheme === "dark" ? darkTheme : lightTheme}
extensions={extensions}
basicSetup={{
foldGutter: true,
highlightActiveLine: true,
highlightActiveLineGutter: false,
lineNumbers: true,
}}
className="min-h-[42vh] flex-1 overflow-auto font-mono text-sm [&_.cm-editor]:min-h-[42vh] [&_.cm-focused]:outline-none!"
/>
</section>
<section className="flex min-h-0 flex-col bg-muted/10">
<div className="flex h-9 shrink-0 items-center border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground">
实时渲染
</div>
<div className="min-h-[42vh] flex-1 overflow-auto p-4">
<MarkdownContent
content={value || "*暂无正文*"}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
</div>
</section>
</div>
);
}
@@ -77,7 +77,7 @@ export function WikiHeader({
<Menu className="size-5" />
</button>
<div className="text-base font-semibold tracking-tight text-slate-900 dark:text-slate-100">
问答梳理
知识库管理
</div>
</div>
@@ -204,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).map(localizeFolderName)];
return ["知识库管理", ...noteFolderSegments(note).map(localizeFolderName)];
}
function sortTree(nodes: TreeNode[]): void {
@@ -117,7 +117,7 @@ export function SpreadsheetPreview({
{Array.from({ length: columnCount }, (_, columnIndex) => (
<th
key={columnIndex}
className="border-border bg-muted/60 text-foreground sticky top-0 border px-3 py-1.5 text-left font-medium whitespace-pre-wrap"
className="border-border bg-muted/60 text-foreground border px-3 py-1.5 text-left font-semibold whitespace-pre-wrap"
>
{desensitize(headerRow?.[columnIndex] ?? "")}
</th>
@@ -5,6 +5,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useNavigate } from "react-router-dom";
import { useMemo } from "react";
import {
SidebarGroup,
@@ -13,13 +14,18 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
import { WorkspaceNavStudio } from "./workspace-nav-studio";
import { cn } from "@/lib/utils";
import {
applyCompactMenuOverrides,
type CompactMenuItem,
} from "@/core/page-layout/compact-menu-overrides";
import { type MenuItem } from "@/core/page-layout/sidebar-menu";
import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
const NAV_ITEM_CLASS =
@@ -40,6 +46,99 @@ const NEW_CHAT_COMPACT_CLASS =
"hover:bg-[rgb(var(--legacy-primary-rgb)/4%)] hover:text-[var(--primary)] hover:border-[rgb(var(--legacy-primary-rgb)/32%)] " +
"data-[active=true]:bg-card data-[active=true]:text-[var(--primary)] data-[active=true]:shadow-none";
export type WorkspaceCompactNavItem = MenuItem & {
match: (pathname: string) => boolean;
};
export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
{
id: "workspace-new-chat",
label: "新对话",
icon: MessageSquarePlus,
path: "/page/workspace/chats/new",
match: (pathname) => pathname === "/page/workspace/chats/new",
},
{
id: "workspace-chats",
label: "对话",
icon: MessagesSquare,
path: "/page/workspace/chats",
match: (pathname) => pathname === "/page/workspace/chats",
},
{
id: "workspace-agents",
label: "智能体",
icon: BotIcon,
path: "/page/workspace/agents",
match: (pathname) => pathname.startsWith("/page/workspace/agents"),
},
{
id: "workspace-skills",
label: "技能",
icon: SparklesIcon,
path: "/page/workspace/skills",
match: (pathname) => pathname.startsWith("/page/workspace/skills"),
},
{
id: "workspace-memory",
label: "记忆",
icon: BrainIcon,
path: "/page/workspace/memory",
match: (pathname) => pathname.startsWith("/page/workspace/memory"),
},
{
id: "workspace-ai-writing",
label: "AI写作",
icon: PenLine,
path: "/page/workspace/ai-writing",
match: (pathname) => pathname.startsWith("/page/workspace/ai-writing"),
},
{
id: "workspace-deep-research",
label: "深度研究",
icon: Telescope,
path: "/page/workspace/deep-research",
match: (pathname) => pathname.startsWith("/page/workspace/deep-research"),
},
{
id: "workspace-roundtable-planning",
label: "多智能体规划",
icon: Users,
path: "/page/workspace/roundtable/planning",
match: (pathname) => pathname.startsWith("/page/workspace/roundtable/planning"),
},
{
id: "workspace-scheduled-tasks",
label: "定时任务",
icon: ListChecks,
path: "/page/workspace/scheduled/tasks",
match: (pathname) => pathname === "/page/workspace/scheduled/tasks",
},
{
id: "workspace-knowledge",
label: "知识库管理",
icon: BookOpen,
path: "/page/workspace/knowledge",
match: (pathname) => pathname.startsWith("/page/workspace/knowledge"),
},
];
export type EffectiveWorkspaceCompactNavItem = CompactMenuItem & WorkspaceCompactNavItem;
export function useWorkspaceCompactNavItems(): {
sidebarItems: EffectiveWorkspaceCompactNavItem[];
settingsItems: EffectiveWorkspaceCompactNavItem[];
} {
const { data: overrides = [] } = useCompactMenuOverrides();
return useMemo(() => {
const applied = applyCompactMenuOverrides(WORKSPACE_COMPACT_NAV_ITEMS, overrides);
return {
sidebarItems: applied.sidebarItems as EffectiveWorkspaceCompactNavItem[],
settingsItems: applied.settingsItems as EffectiveWorkspaceCompactNavItem[],
};
}, [overrides]);
}
export function WorkspaceNavChatList({
showCollapseTrigger = false,
compact = false,
@@ -50,6 +149,8 @@ export function WorkspaceNavChatList({
const { t } = useI18n();
const pathname = usePathname();
const navigate = useNavigate();
const { sidebarItems } = useWorkspaceCompactNavItems();
const newChatItem = sidebarItems.find((item) => item.id === "workspace-new-chat");
// 「新对话」点击时,如果已经在 /chats/new 路径,直接 navigate 会被 react-router
// 当成同路径 no-op,不更新 location.key,挂在 location.key 上的下游 hook
// (useThreadChat)就感知不到「用户想重开一个对话」,UI 不刷新。
@@ -68,171 +169,72 @@ export function WorkspaceNavChatList({
<>
<SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}>
<SidebarMenu className="gap-1">
<SidebarMenuItem>
{compact ? (
{(showCollapseTrigger || newChatItem) && (
<SidebarMenuItem>
{compact ? (
// 紧凑模式:新对话占满整行白卡样式 + 右侧收起钮(点击收起整列历史栏)。
<div className="flex items-center gap-1">
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(NEW_CHAT_COMPACT_CLASS, "min-w-0 flex-1")}
>
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
<Plus className="stroke-[2.5] text-[var(--primary)]" />
<span>{t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
{showCollapseTrigger && (
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
)}
</div>
) : (
<div className="flex items-center gap-1">
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(
NEW_CHAT_CLASS,
showCollapseTrigger && "min-w-0 flex-1",
)}
>
<Link
href="/page/workspace/chats/new"
onClick={handleNewChatClick}
<div className="flex items-center gap-1">
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(NEW_CHAT_COMPACT_CLASS, "min-w-0 flex-1")}
>
<MessageSquarePlus />
<span>{t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
{showCollapseTrigger && (
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
)}
</div>
)}
</SidebarMenuItem>
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
<Plus className="stroke-[2.5] text-[var(--primary)]" />
<span>{newChatItem?.label ?? t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
{showCollapseTrigger && (
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
)}
</div>
) : (
<div className="flex items-center gap-1">
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(
NEW_CHAT_CLASS,
showCollapseTrigger && "min-w-0 flex-1",
)}
>
<Link
href="/page/workspace/chats/new"
onClick={handleNewChatClick}
>
<MessageSquarePlus />
<span>{newChatItem?.label ?? t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
{showCollapseTrigger && (
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
)}
</div>
)}
</SidebarMenuItem>
)}
{!showCollapseTrigger && (
<>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats"}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/chats">
<MessagesSquare />
<span>{t.sidebar.chats}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/agents")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/agents">
<BotIcon />
<span>{t.sidebar.agents}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/skills")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/skills">
<SparklesIcon />
<span>技能</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/memory")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/memory">
<BrainIcon />
<span>记忆</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/ai-writing")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/ai-writing">
<PenLine />
<span>AI写作</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/deep-research")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/deep-research">
<Telescope />
<span>深度研究</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/roundtable/planning")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/roundtable/planning">
<Users />
<span>多智能体规划</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{/* <SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/roundtable/position")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/roundtable/position">
<BriefcaseBusiness />
<span>岗位会商</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem> */}
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/scheduled/tasks"}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/scheduled/tasks">
<ListChecks />
<span>定时任务</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{sidebarItems
.filter((item) => item.id !== "workspace-new-chat")
.map((item) => {
const Icon = item.icon;
return (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
isActive={item.match(pathname)}
asChild
className={NAV_ITEM_CLASS}
>
<Link href={item.path ?? "/page/workspace/chats/new"}>
<Icon />
<span>{item.label}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
<KnowledgeRagToggle className={NAV_ITEM_CLASS} />
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/knowledge")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/knowledge">
<BookOpen />
<span>问答梳理</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</>
)}
</SidebarMenu>
@@ -46,6 +46,7 @@ import { NotificationDialog } from "./notification-dialog";
import { useNavigate } from "react-router-dom";
import { SettingsDialog } from "./settings";
import { useWorkspaceCompactNavItems } from "./workspace-nav-chat-list";
function NavMenuButtonContent({
isSidebarOpen,
@@ -80,6 +81,7 @@ export function WorkspaceNavMenu() {
const { open: isSidebarOpen } = useSidebar();
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
const { settingsItems } = useWorkspaceCompactNavItems();
useEffect(() => {
setMounted(true);
@@ -129,6 +131,18 @@ export function WorkspaceNavMenu() {
sideOffset={4}
>
<DropdownMenuGroup>
{settingsItems.map((item) => {
const Icon = item.icon;
return (
<DropdownMenuItem
key={item.id}
onClick={() => item.path && navigate(item.path)}
>
<Icon />
{item.label}
</DropdownMenuItem>
);
})}
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
<BellIcon />
{t.notifications.title}
@@ -78,7 +78,9 @@ export async function downloadMarkdownDocxFromBackend(options: {
}),
});
if (response.status === 404) {
// Older/offline gateways mount /api/writing but do not provide export/docx;
// FastAPI then answers 404 or 405. The browser converter is feature-equivalent.
if (response.status === 404 || response.status === 405) {
await downloadMarkdownAsDocx(markdown, options.title?.trim() || "document", options.title);
writeYLog({
optType: OptType.export,
@@ -0,0 +1,72 @@
import type { MenuItem } from "./sidebar-menu";
export type CompactMenuPlacement = "sidebar" | "settings";
export interface CompactMenuOverride {
nodeId: string;
placement: CompactMenuPlacement;
sortOrder: number;
label?: string | null;
}
export interface CompactMenuItem extends MenuItem {
compactPlacement: CompactMenuPlacement;
compactOrder: number;
}
function firstRoutePath(items?: MenuItem[]): string | undefined {
for (const item of items ?? []) {
if (item.path) return item.path;
const nested = firstRoutePath(item.children);
if (nested) return nested;
}
return undefined;
}
function isNavigable(item: MenuItem): boolean {
return Boolean(item.path ?? firstRoutePath(item.children));
}
export function applyCompactMenuOverrides(
items: MenuItem[],
overrides: CompactMenuOverride[],
): { sidebarItems: CompactMenuItem[]; settingsItems: CompactMenuItem[] } {
const byId = new Map(overrides.map((override) => [override.nodeId, override]));
const enhanced = items.filter(isNavigable).map((item, index) => {
const override = byId.get(item.id);
return {
...item,
label: override?.label != null && override.label !== "" ? override.label : item.label,
compactPlacement: override?.placement ?? "sidebar",
compactOrder: override?.sortOrder ?? index,
};
});
const sortItems = (list: CompactMenuItem[]) =>
list.slice().sort((a, b) => a.compactOrder - b.compactOrder || a.id.localeCompare(b.id));
return {
sidebarItems: sortItems(enhanced.filter((item) => item.compactPlacement === "sidebar")),
settingsItems: sortItems(enhanced.filter((item) => item.compactPlacement === "settings")),
};
}
export function diffCompactMenuOverrides(items: CompactMenuEditorItem[]): CompactMenuOverride[] {
return items.flatMap((item, index) => {
const renamed = item.label !== item.defaultLabel;
const moved = item.compactPlacement !== "sidebar";
const reordered = index !== item.defaultIndex || item.compactOrder !== item.defaultIndex;
if (!renamed && !moved && !reordered) return [];
return {
nodeId: item.id,
placement: item.compactPlacement,
sortOrder: index,
label: renamed ? item.label : null,
};
});
}
export interface CompactMenuEditorItem extends CompactMenuItem {
defaultLabel: string;
defaultIndex: number;
}
@@ -250,6 +250,12 @@ export const sidebarMenuItems: MenuItem[] = [
},
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
{
id: "resource-markdown-docx-editor",
label: "md转word",
icon: FileText,
path: "/page/workspace/knowledge/markdown-editor",
},
{
id: "mgmt-tasks",
label: "定时任务",
@@ -313,16 +319,10 @@ export const sidebarMenuItems: MenuItem[] = [
// },
{
id: "resource-knowledge-native",
label: "问答梳理",
label: "知识库管理",
icon: BookOpen,
path: "/page/workspace/knowledge",
},
{
id: "resource-markdown-docx-editor",
label: "Markdown 转 Word",
icon: FileText,
path: "/page/workspace/knowledge/markdown-editor",
},
{
id: "resource-knowledge-base",
label: "知识库资源管理",
@@ -69,6 +69,14 @@ function desensitizeLabels(items: MenuItem[], fn: (text: string) => string): Men
}));
}
function useDesensitizeLabel() {
const { data: words = [] } = useSensitiveWords();
return useMemo(
() => (words.length > 0 ? makeDesensitizer(wordsToMap(words)) : desensitize),
[words],
);
}
/**
* 返回合并了 iframe 轻应用、套用了后端「菜单覆盖层」(改名/停用/排序/层级)、
* 按 admin 角色过滤、并对标签做**展示层脱敏**后的菜单。
@@ -82,11 +90,7 @@ function desensitizeLabels(items: MenuItem[], fn: (text: string) => string): Men
export function useSidebarMenuItems(isAdmin: boolean): MenuItem[] {
const { data: iframeApps = [] } = useLightApps({ appType: "iframe", enabledOnly: true });
const { data: overrides = [] } = useMenuOverrides();
const { data: words = [] } = useSensitiveWords();
const desensitizeLabel = useMemo(
() => (words.length > 0 ? makeDesensitizer(wordsToMap(words)) : desensitize),
[words],
);
const desensitizeLabel = useDesensitizeLabel();
return useMemo(
() =>
desensitizeLabels(
@@ -116,7 +116,7 @@ export default function MarkdownDocxEditorPage() {
<header className="shrink-0 border-b px-6 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h1 className="text-xl font-semibold">Markdown 转 Word</h1>
<h1 className="text-xl font-semibold">md转word</h1>
<p className="mt-1 text-sm text-muted-foreground">
上传 md 文件后在线编辑,确认内容后下载为 Word。
</p>
@@ -41,6 +41,7 @@ import {
Info,
Menu,
Pencil,
Settings,
RotateCcw,
Save,
Search,
@@ -65,6 +66,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { WORKSPACE_COMPACT_NAV_ITEMS } from "@/components/workspace/workspace-nav-chat-list";
import { getStoredAuth } from "@/core/auth";
import {
MAX_MENU_DEPTH,
@@ -76,8 +78,16 @@ import {
type MenuOverride,
type MergedMenuNode,
} from "@/core/page-layout/menu-overrides";
import type {
CompactMenuOverride,
CompactMenuPlacement,
} from "@/core/page-layout/compact-menu-overrides";
import { sidebarMenuItems, type MenuItem } from "@/core/page-layout/sidebar-menu";
import { injectLightApps } from "@/core/page-layout/use-sidebar-menu";
import {
useCompactMenuOverrideActions,
useCompactMenuOverrides,
} from "@/strategy-components/api/compact-menu-overrides";
import { useLightApps } from "@/strategy-components/api/light-apps";
import {
useMenuOverrideActions,
@@ -99,6 +109,19 @@ interface FlatRow {
isLightApp: boolean;
}
interface CompactRow {
id: string;
topId: string;
topIndex: number;
topLabel: string;
label: string;
defaultLabel: string;
defaultIndex: number;
order: number;
placement: CompactMenuPlacement;
source: MenuItem;
}
// ── 扁平行工具 ───────────────────────────────────────────────────────────
/** 按 parentId 分组、DFS 重排,使每个节点的子节点紧跟其后(同级保持相遇顺序)。 */
@@ -190,6 +213,101 @@ function depthByIdOf(rows: FlatRow[]): Map<string, number> {
return cache;
}
function findFirstRoutePath(items?: MenuItem[]): string | null {
if (!items?.length) {
return null;
}
for (const child of items) {
if (child.path) {
return child.path;
}
const nested = findFirstRoutePath(child.children);
if (nested) {
return nested;
}
}
return null;
}
function hasRoute(item: MenuItem): boolean {
return Boolean(item.path ?? findFirstRoutePath(item.children));
}
function compactRowsFromTree(tree: MenuItem[], overrides: CompactMenuOverride[]): CompactRow[] {
const byId = new Map(overrides.map((override) => [override.nodeId, override]));
const rows: CompactRow[] = [];
tree.forEach((top, topIndex) => {
(top.children ?? [])
.filter(hasRoute)
.forEach((item, index) => {
const override = byId.get(item.id);
rows.push({
id: item.id,
topId: top.id,
topIndex,
topLabel: top.label,
label: override?.label != null && override.label !== "" ? override.label : item.label,
defaultLabel: item.label,
defaultIndex: index,
order: override?.sortOrder ?? index,
placement: override?.placement ?? "sidebar",
source: item,
});
});
});
return normalizeCompactRows(rows);
}
function normalizeCompactRows(rows: CompactRow[]): CompactRow[] {
return rows
.slice()
.sort(
(a, b) =>
a.topIndex - b.topIndex ||
Number(a.placement === "settings") - Number(b.placement === "settings") ||
a.order - b.order ||
a.id.localeCompare(b.id),
);
}
function compactOverridesFromRows(rows: CompactRow[]): CompactMenuOverride[] {
const grouped = new Map<string, CompactRow[]>();
for (const row of rows) {
const key = `${row.topId}:${row.placement}`;
const list = grouped.get(key) ?? [];
list.push(row);
grouped.set(key, list);
}
const orderById = new Map<string, number>();
for (const list of grouped.values()) {
list.forEach((row, index) => orderById.set(row.id, index));
}
return rows.flatMap((row) => {
const sortOrder = orderById.get(row.id) ?? row.defaultIndex;
const renamed = row.label !== row.defaultLabel;
const moved = row.placement !== "sidebar";
const reordered = row.placement === "sidebar" && sortOrder !== row.defaultIndex;
if (!renamed && !moved && !reordered) return [];
return {
nodeId: row.id,
placement: row.placement,
sortOrder,
label: renamed ? row.label : null,
};
});
}
function sameOverrides(a: CompactMenuOverride[], b: CompactMenuOverride[]): boolean {
const norm = (list: CompactMenuOverride[]) =>
JSON.stringify(
list
.map((o) => ({ ...o, label: o.label ?? null }))
.sort((x, y) => x.nodeId.localeCompare(y.nodeId)),
);
return norm(a) === norm(b);
}
// ── 可排序行 ─────────────────────────────────────────────────────────────
interface SortableRowProps {
@@ -377,12 +495,24 @@ function SortableRow({
export default function MenuManagementPage() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const [mode, setMode] = useState<"full" | "compact">("compact");
// 与侧边栏一致:只取「启用的 iframe 轻应用」作为合并基树,避免管理树与实际渲染脱节。
const { data: iframeApps = [] } = useLightApps({ appType: "iframe", enabledOnly: true });
const { data: overrides = [], isLoading } = useMenuOverrides();
const { save } = useMenuOverrideActions();
const baseTree = useMemo(() => injectLightApps(sidebarMenuItems, iframeApps), [iframeApps]);
const compactWorkspaceTree = useMemo<MenuItem[]>(
() => [
{
id: "workspace-compact-nav",
label: "简介模式菜单",
icon: Settings,
children: WORKSPACE_COMPACT_NAV_ITEMS,
},
],
[],
);
// 初始扁平行(套用已保存覆盖层,含停用项)。
const initialRows = useMemo(
@@ -627,9 +757,44 @@ export default function MenuManagementPage() {
if (!isAdmin) return <Navigate to="/page/workspace/chats/new" replace />;
const modeTabs = (
<div className="mb-6 inline-flex rounded-2xl border border-slate-200 bg-white p-1 text-sm font-semibold shadow-sm">
<button
type="button"
onClick={() => setMode("full")}
className={`rounded-xl px-4 py-2 transition-all ${
mode === "full" ? "bg-red-600 text-white shadow-sm" : "text-slate-500 hover:text-slate-800"
}`}
>
全量模式
</button>
<button
type="button"
onClick={() => setMode("compact")}
className={`rounded-xl px-4 py-2 transition-all ${
mode === "compact" ? "bg-red-600 text-white shadow-sm" : "text-slate-500 hover:text-slate-800"
}`}
>
简介模式
</button>
</div>
);
if (mode === "compact") {
return (
<div className="h-full overflow-auto bg-slate-50/60 px-4 py-6 sm:px-6 lg:px-8">
<div className="mx-auto max-w-4xl">
{modeTabs}
<CompactMenuManagementPanel baseTree={compactWorkspaceTree} />
</div>
</div>
);
}
return (
<div className="h-full overflow-auto bg-slate-50/60 px-4 py-6 sm:px-6 lg:px-8">
<div className="mx-auto max-w-4xl">
{modeTabs}
{/* 顶部信息卡 */}
<div className="relative mb-6 overflow-hidden rounded-3xl border border-slate-200/60 bg-white p-6 shadow-sm md:p-8">
<div className="pointer-events-none absolute right-0 top-0 select-none p-8 opacity-[0.03]">
@@ -809,6 +974,189 @@ export default function MenuManagementPage() {
);
}
function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
const { data: overrides = [], isLoading } = useCompactMenuOverrides();
const { save } = useCompactMenuOverrideActions();
const initialRows = useMemo(() => compactRowsFromTree(baseTree, overrides), [baseTree, overrides]);
const [rows, setRows] = useState<CompactRow[]>(initialRows);
const [loadedKey, setLoadedKey] = useState("");
const sourceKey = useMemo(
() =>
JSON.stringify({
tree: baseTree.flatMap((top) =>
(top.children ?? []).map((child) => [top.id, child.id, child.label]),
),
overrides,
}),
[baseTree, overrides],
);
useEffect(() => {
if (sourceKey !== loadedKey) {
setRows(initialRows);
setLoadedKey(sourceKey);
}
}, [sourceKey, loadedKey, initialRows]);
const currentOverrides = useMemo(() => compactOverridesFromRows(rows), [rows]);
const dirty = useMemo(() => !sameOverrides(currentOverrides, overrides), [currentOverrides, overrides]);
const grouped = useMemo(() => {
const groups = new Map<string, { label: string; rows: CompactRow[] }>();
for (const row of rows) {
const group = groups.get(row.topId) ?? { label: row.topLabel, rows: [] };
group.rows.push(row);
groups.set(row.topId, group);
}
return Array.from(groups.entries()).map(([id, group]) => ({ id, ...group }));
}, [rows]);
const updateRow = (id: string, patch: Partial<CompactRow>) =>
setRows((prev) => prev.map((row) => (row.id === id ? { ...row, ...patch } : row)));
const moveRow = (row: CompactRow, direction: -1 | 1) => {
setRows((prev) => {
const sameLane = prev.filter((item) => item.topId === row.topId && item.placement === row.placement);
const laneIndex = sameLane.findIndex((item) => item.id === row.id);
const swapWith = sameLane[laneIndex + direction];
if (!swapWith) return prev;
const next = prev.slice();
const a = next.findIndex((item) => item.id === row.id);
const b = next.findIndex((item) => item.id === swapWith.id);
[next[a], next[b]] = [next[b], next[a]];
return next;
});
};
const handleSave = async () => {
try {
await save.mutateAsync(currentOverrides);
toast.success("简介菜单配置已保存");
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存失败");
}
};
const handleReset = () => {
if (!confirm("确定要重置简介模式菜单为默认布局吗?保存后将清除简介模式自定义配置。")) return;
setRows(compactRowsFromTree(baseTree, []));
};
return (
<>
<div className="relative mb-6 overflow-hidden rounded-3xl border border-slate-200/60 bg-white p-6 shadow-sm md:p-8">
<div className="pointer-events-none absolute right-0 top-0 select-none p-8 opacity-[0.03]">
<Settings className="size-44 text-slate-900" />
</div>
<div className="relative">
<div className="mb-4 flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div>
<span className="mb-3 inline-flex select-none items-center gap-1.5 rounded-full border border-red-100 bg-red-50 px-3 py-1 text-xs font-semibold text-red-600">
<Sparkles className="size-3" />
仅简介模式生效
</span>
<h1 className="flex items-center gap-3 text-2xl font-bold tracking-tight text-slate-800 md:text-3xl">
简介菜单管理
<span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-0.5 font-mono text-xs font-medium text-slate-500">
{rows.length} 个项目
</span>
</h1>
</div>
<div className="flex items-center gap-2.5">
<button
type="button"
onClick={handleReset}
className="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-3.5 py-2 text-sm font-semibold text-slate-600 shadow-sm transition-all hover:border-slate-300 hover:bg-slate-50"
>
<RotateCcw className="size-[15px]" />
重置全部
</button>
<button
type="button"
onClick={handleSave}
disabled={!dirty || save.isPending}
className="inline-flex items-center gap-2 rounded-xl border border-red-600 bg-red-600 px-5 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Save className="size-[15px]" />
{save.isPending ? "保存中…" : "保存修改"}
</button>
</div>
</div>
<p className="flex max-w-2xl items-start gap-2.5 rounded-2xl border border-slate-200/50 bg-slate-50/70 p-3.5 text-sm leading-relaxed text-slate-600">
<Info className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span>这里配置的是简介模式工作区左侧菜单:可改显示名称、调整顺序,或迁移到「设置和更多」下拉中;全量模式菜单不受影响。</span>
</p>
</div>
</div>
<div className="space-y-5">
{isLoading ? (
<p className="rounded-3xl border border-slate-200/60 bg-white py-20 text-center text-sm text-slate-400">加载中…</p>
) : (
grouped.map((group) => (
<section key={group.id} className="rounded-3xl border border-slate-200/60 bg-white p-4 shadow-sm md:p-5">
<h2 className="mb-4 text-base font-bold text-slate-800">{group.label}</h2>
<div className="flex flex-col gap-3">
{group.rows.map((row) => {
const lane = group.rows.filter((item) => item.placement === row.placement);
const laneIndex = lane.findIndex((item) => item.id === row.id);
const Icon = row.source.icon;
return (
<div key={row.id} className="grid gap-3 rounded-2xl border border-slate-200/70 bg-slate-50/50 p-3 md:grid-cols-[1fr_160px_auto] md:items-center">
<div className="flex min-w-0 items-center gap-3">
<span className="shrink-0 rounded-xl bg-red-50 p-2.5 text-red-600">
<Icon className="size-[18px]" />
</span>
<Input
value={row.label}
onChange={(e) => updateRow(row.id, { label: e.target.value })}
className="bg-white"
/>
</div>
<Select
value={row.placement}
onValueChange={(value) => updateRow(row.id, { placement: value as CompactMenuPlacement })}
>
<SelectTrigger className="bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sidebar">左侧菜单</SelectItem>
<SelectItem value="settings">设置和更多</SelectItem>
</SelectContent>
</Select>
<div className="flex justify-end gap-1.5">
<button
type="button"
onClick={() => moveRow(row, -1)}
disabled={laneIndex <= 0}
className="rounded-lg border border-slate-200 bg-white p-2 text-slate-500 transition-colors hover:text-slate-800 disabled:opacity-30"
title="上移"
>
<ChevronUp className="size-4" />
</button>
<button
type="button"
onClick={() => moveRow(row, 1)}
disabled={laneIndex < 0 || laneIndex >= lane.length - 1}
className="rounded-lg border border-slate-200 bg-white p-2 text-slate-500 transition-colors hover:text-slate-800 disabled:opacity-30"
title="下移"
>
<ChevronDown className="size-4" />
</button>
</div>
</div>
);
})}
</div>
</section>
))
)}
</div>
</>
);
}
// ── 编辑弹窗 ─────────────────────────────────────────────────────────────
interface EditMenuDialogProps {
@@ -0,0 +1,92 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
import type { CompactMenuOverride } from "@/core/page-layout/compact-menu-overrides";
const BASE = () => `${getBackendBaseURL()}/api/compact-menu-overrides`;
interface ServerCompactOverride {
node_id: string;
placement: "sidebar" | "settings";
sort_order: number;
label: string | null;
}
function toOverride(s: ServerCompactOverride): CompactMenuOverride {
return {
nodeId: s.node_id,
placement: s.placement === "settings" ? "settings" : "sidebar",
sortOrder: s.sort_order ?? 0,
label: s.label ?? null,
};
}
function toBody(o: CompactMenuOverride): ServerCompactOverride {
return {
node_id: o.nodeId,
placement: o.placement,
sort_order: o.sortOrder,
label: o.label ?? null,
};
}
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const data = await res.json().catch(() => null as unknown);
const detail =
data && typeof data === "object" && "detail" in data
? String((data as { detail: unknown }).detail)
: `${ctx} failed (${res.status})`;
throw new Error(detail);
}
return (await res.json()) as T;
}
export async function listCompactMenuOverrides(): Promise<CompactMenuOverride[]> {
const res = await apiFetch(BASE(), { method: "GET" });
const data = await readJson<{ overrides: ServerCompactOverride[] }>(res, "list compact menu overrides");
return (data.overrides ?? []).map(toOverride);
}
export async function replaceCompactMenuOverrides(
overrides: CompactMenuOverride[],
): Promise<CompactMenuOverride[]> {
const res = await apiFetch(BASE(), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ overrides: overrides.map(toBody) }),
});
const data = await readJson<{ overrides: ServerCompactOverride[] }>(res, "save compact menu overrides");
const saved = (data.overrides ?? []).map(toOverride);
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-简介-菜单管理-保存",
url: "/api/compact-menu-overrides",
reqParam: { overrides },
returnParam: saved,
});
return saved;
}
const COMPACT_MENU_OVERRIDES_KEY = "compact-menu-overrides";
export function useCompactMenuOverrides() {
return useQuery({
queryKey: [COMPACT_MENU_OVERRIDES_KEY],
queryFn: listCompactMenuOverrides,
refetchOnWindowFocus: false,
staleTime: 60_000,
});
}
export function useCompactMenuOverrideActions() {
const queryClient = useQueryClient();
return {
save: useMutation({
mutationFn: replaceCompactMenuOverrides,
onSuccess: () => queryClient.invalidateQueries({ queryKey: [COMPACT_MENU_OVERRIDES_KEY] }),
}),
};
}
@@ -12,6 +12,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownCodeEditor } from "@/components/wiki/MarkdownCodeEditor";
import { getBackendBaseURL } from "@/core/config";
import {
createLlmWikiWeKnoraEmbedSession,
@@ -435,7 +436,7 @@ function CurrentWikiArticleActions({
删除
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="flex max-h-[90vh] w-[calc(100vw-2rem)] max-w-4xl flex-col overflow-hidden">
<DialogContent className="flex max-h-[94vh] w-[calc(100vw-2rem)] max-w-7xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>编辑此文章</DialogTitle>
</DialogHeader>
@@ -457,10 +458,10 @@ function CurrentWikiArticleActions({
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">正文</label>
<Textarea
<MarkdownCodeEditor
value={draft.content}
onChange={(event) => setDraft((prev) => ({ ...prev, content: event.target.value }))}
className="min-h-[48vh] resize-y font-mono text-sm"
onChange={(content) => setDraft((prev) => ({ ...prev, content }))}
className="[&_.cm-editor]:min-h-[48vh]"
/>
</div>
</div>
@@ -125,7 +125,7 @@ export function WeKnoraKnowledgeWorkspace() {
<div>
<div className="flex items-center gap-2">
<Database className="size-5 text-primary" />
<h1 className="text-xl font-semibold">问答梳理</h1>
<h1 className="text-xl font-semibold">知识库管理</h1>
<Badge variant="secondary">知识服务</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">由知识服务负责解析与检索,用户、权限和发布范围由 cmzs 管理。</p>
@@ -1,5 +1,6 @@
import { FileText, Loader2, Maximize2, Minimize2 } from "lucide-react";
import { Download, FileText, Loader2, Maximize2, Minimize2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -13,7 +14,9 @@ import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import { SpreadsheetPreview } from "@/components/workspace/artifacts/spreadsheet-preview";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { cn } from "@/lib/utils";
import { reasoningPlugins } from "@/core/streamdown";
import {
getLlmWikiDocument,
getLlmWikiDocumentPreview,
@@ -44,10 +47,12 @@ type PreviewState =
| { kind: "iframe"; url: string }
| { kind: "xlsx"; data: ArrayBuffer }
| { kind: "table"; rows: string[][]; highlightedRows: number[]; truncated: boolean; source: "csv" | "chunks" }
| { kind: "markdown"; text: string }
| { kind: "text"; text: string }
| { kind: "unsupported"; message: string };
type CachedSourceDetail = {
documentId: string;
document: LlmWikiDocument | null;
wikiPage: LlmWikiWikiPage | null;
preview: PreviewState;
@@ -104,6 +109,10 @@ function cleanText(value?: string | null) {
return (value || "").replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
}
function stripFrontmatter(value: string) {
return value.replace(/^\s*---\s*\n[\s\S]*?\n---\s*(?:\n|$)/, "").trim();
}
function fileExtension(document?: LlmWikiDocument | null) {
const name = [document?.file_name, document?.title, document?.file_type]
.filter(Boolean)
@@ -165,6 +174,20 @@ function isTextPreview(blob: Blob, document?: LlmWikiDocument | null) {
);
}
function isMarkdownDocument(document?: LlmWikiDocument | null) {
return ["md", "markdown"].includes(fileExtension(document));
}
function isWordDocument(document?: LlmWikiDocument | null) {
return ["doc", "docx"].includes(fileExtension(document));
}
function markdownFromChunks(chunks: LlmWikiChunk[]) {
return stripFrontmatter(
chunks.map((chunk) => cleanText(chunk.content)).filter(Boolean).join("\n\n"),
);
}
function extractTerms(value: string, limit = 8) {
return Array.from(
new Set(
@@ -240,6 +263,15 @@ async function buildPreviewState(
const ext = fileExtension(document);
const chunkTable = tableFromChunks(chunks, focusChunkId, quote);
// Browsers cannot render doc/docx blobs directly. WeKnora already parsed the
// document into Markdown-like chunks, so use those for a faithful readable preview.
if (isWordDocument(document)) {
const text = markdownFromChunks(chunks);
return text
? { kind: "markdown", text }
: { kind: "unsupported", message: "Word 文档已载入,但暂未取得可展示的解析正文。" };
}
if (data.byteLength === 0 && chunkTable) {
return chunkTable;
}
@@ -274,7 +306,9 @@ async function buildPreviewState(
: text;
return { kind: "table", ...parseCsv(delimiterNormalized), highlightedRows: [], source: "csv" };
}
return { kind: "text", text };
return isMarkdownDocument(document)
? { kind: "markdown", text }
: { kind: "text", text };
}
return { kind: "iframe", url: URL.createObjectURL(blob) };
@@ -367,13 +401,13 @@ function DataTablePreview({
<table className="w-max min-w-full border-separate border-spacing-0 overflow-hidden rounded-lg border bg-background text-sm shadow-sm">
<thead>
<tr>
<th className="sticky left-0 top-0 z-20 w-12 border-b border-r bg-slate-100 px-2 py-2.5 text-center text-xs font-medium text-muted-foreground">
<th className="w-12 border-b border-r bg-slate-100 px-2 py-2.5 text-center text-xs font-medium text-muted-foreground dark:bg-slate-800">
#
</th>
{Array.from({ length: columnCount }, (_, columnIndex) => (
<th
key={columnIndex}
className="sticky top-0 z-10 border-b border-r bg-slate-100 px-3 py-2.5 text-left font-semibold text-foreground whitespace-nowrap"
className="border-b border-r bg-slate-100 px-3 py-2.5 text-left font-semibold text-foreground whitespace-nowrap dark:bg-slate-800"
>
{headerRow?.[columnIndex] ?? ""}
</th>
@@ -433,6 +467,8 @@ export function WeKnoraSourceDrawer({
const [previewError, setPreviewError] = useState("");
const [previewLoading, setPreviewLoading] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [downloading, setDownloading] = useState(false);
const [resolvedDocumentId, setResolvedDocumentId] = useState("");
const targetKey = useMemo(() => {
if (!target) return "";
@@ -455,6 +491,7 @@ export function WeKnoraSourceDrawer({
setPreviewLoading(false);
setPreview({ kind: "none" });
setFullscreen(false);
setResolvedDocumentId("");
return;
}
@@ -462,6 +499,7 @@ export function WeKnoraSourceDrawer({
const cached = sourceDetailCache.get(targetKey);
if (cached) {
setDocument(cached.document);
setResolvedDocumentId(cached.documentId);
setWikiPage(cached.wikiPage);
setPreview(cached.preview);
setPreviewError(cached.previewError);
@@ -480,6 +518,7 @@ export function WeKnoraSourceDrawer({
void (async () => {
const detailTarget = await resolveTargetDetail(target);
if (!cancelled) setResolvedDocumentId(detailTarget.documentId || "");
const tasks: Promise<void>[] = [];
let loadedDocument: LlmWikiDocument | null = null;
let loadedWikiPage: LlmWikiWikiPage | null = null;
@@ -516,7 +555,10 @@ export function WeKnoraSourceDrawer({
setLoading(false);
setPreviewLoading(true);
}
const previewTask = getLlmWikiDocumentPreview(detailTarget.knowledgeBaseId, detailTarget.documentId)
const previewBlobPromise = isWordDocument(loadedDocument)
? Promise.resolve(new Blob())
: getLlmWikiDocumentPreview(detailTarget.knowledgeBaseId, detailTarget.documentId);
const previewTask = previewBlobPromise
.then((blob) => {
return buildPreviewState(
blob,
@@ -547,6 +589,7 @@ export function WeKnoraSourceDrawer({
}
await Promise.all(tasks);
sourceDetailCache.set(targetKey, {
documentId: detailTarget.documentId || "",
document: loadedDocument,
wikiPage: loadedWikiPage,
preview: loadedPreview,
@@ -574,7 +617,27 @@ export function WeKnoraSourceDrawer({
title ||
"文档详情";
const summary = cleanText(wikiPage?.summary || document?.description);
const wikiContent = cleanText(wikiPage?.content);
const wikiContent = stripFrontmatter(cleanText(wikiPage?.content));
const downloadOriginal = async () => {
if (!target || !resolvedDocumentId) return;
setDownloading(true);
try {
const blob = await getLlmWikiDocumentPreview(target.knowledgeBaseId, resolvedDocumentId);
const url = URL.createObjectURL(blob);
const anchor = window.document.createElement("a");
anchor.href = url;
anchor.download = document?.file_name || document?.title || resolvedTitle || "document";
window.document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载原文失败");
} finally {
setDownloading(false);
}
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
@@ -588,7 +651,7 @@ export function WeKnoraSourceDrawer({
<SheetTitle className="sr-only">{resolvedTitle}</SheetTitle>
<SheetDescription className="sr-only">知识库文档详情</SheetDescription>
<div className="relative flex h-full min-h-0 flex-col bg-background">
<div className="shrink-0 border-b px-5 py-4 pr-24">
<div className="shrink-0 border-b px-5 py-4 pr-64">
<div className="flex min-w-0 items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-700">
<FileText className="size-5" />
@@ -604,17 +667,24 @@ export function WeKnoraSourceDrawer({
</div>
</div>
</div>
<Button
type="button"
size="icon-sm"
variant="ghost"
className="absolute top-4 right-12"
onClick={() => setFullscreen((value) => !value)}
aria-label={fullscreen ? "退出全屏" : "全屏打开参考文献源文件"}
title={fullscreen ? "退出全屏" : "全屏打开参考文献源文件"}
>
{fullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</Button>
<div className="absolute top-4 right-12 flex items-center gap-2">
{resolvedDocumentId && (
<Button type="button" size="sm" variant="outline" onClick={() => void downloadOriginal()} disabled={downloading}>
{downloading ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
下载原文
</Button>
)}
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setFullscreen((value) => !value)}
aria-label={fullscreen ? "退出放大查看" : "单独放大查看文档"}
>
{fullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
{fullscreen ? "退出放大" : "放大查看"}
</Button>
</div>
</div>
{loading && (
@@ -672,6 +742,24 @@ export function WeKnoraSourceDrawer({
</section>
)}
{preview.kind === "markdown" && (
<section className="border-b bg-slate-100/70 px-4 py-6 dark:bg-slate-950/50 sm:px-8">
<article className="mx-auto max-w-4xl rounded-xl border bg-background px-6 py-7 shadow-sm sm:px-10">
<div className="mb-6 flex items-center gap-2 border-b pb-4 text-sm font-medium text-muted-foreground">
<FileText className="size-4" />
{isWordDocument(document) ? "Word 文档预览" : "Markdown 文档预览"}
</div>
<MarkdownContent
className="max-w-none"
content={preview.text}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
</article>
</section>
)}
{preview.kind === "unsupported" && (
<section className="border-b px-5 py-4 text-sm text-muted-foreground">
{preview.message}
@@ -695,8 +783,14 @@ export function WeKnoraSourceDrawer({
{wikiContent && (
<section className="border-b px-5 py-4">
<div className="mb-2 text-sm font-medium text-foreground">Wiki 原文</div>
<div className="prose prose-sm max-w-none whitespace-pre-wrap text-foreground">
{wikiContent}
<div className="rounded-lg border bg-background p-5">
<MarkdownContent
className="max-w-none"
content={wikiContent}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
</div>
</section>
)}
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownCodeEditor } from "@/components/wiki/MarkdownCodeEditor";
import {
type LlmWikiWikiPage,
useLlmWikiActions,
@@ -747,10 +748,9 @@ export function WeKnoraWikiManagementPage() {
</label>
<label className="block space-y-1.5">
<span className="text-sm font-medium">正文</span>
<Textarea
<MarkdownCodeEditor
value={draft.content}
onChange={(event) => setDraft((prev) => ({ ...prev, content: event.target.value }))}
className="min-h-[46vh] resize-y font-mono text-sm"
onChange={(content) => setDraft((prev) => ({ ...prev, content }))}
/>
</label>
</div>
@@ -35,9 +35,10 @@ from app.gateway.routers import (
auth,
browser_context,
business_mapping,
channels,
checkpoint_migration,
feedback,
channels,
checkpoint_migration,
compact_menu_overrides,
feedback,
dashboard_sessions,
deep_research,
html_page_favorites,
@@ -1520,10 +1521,13 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Light-app registry (?????) at /api/light-apps
app.include_router(light_apps.router)
# Sidebar-menu overrides (菜单管理) at /api/menu-overrides
app.include_router(menu_overrides.router)
# Sensitive-word map (敏感词管理 / 脱敏词) at /api/sensitive-words
# Sidebar-menu overrides (菜单管理) at /api/menu-overrides
app.include_router(menu_overrides.router)
# Compact/sidebar-summary menu overrides at /api/compact-menu-overrides
app.include_router(compact_menu_overrides.router)
# Sensitive-word map (敏感词管理 / 脱敏词) at /api/sensitive-words
app.include_router(sensitive_words.router)
# Skill URL/IP batch replace (技能地址管理) at /api/skill-addresses
@@ -281,11 +281,15 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.light_app_store = make_light_app_store(sf)
from deerflow.persistence.menu_overrides import make_menu_override_store
app.state.menu_override_store = make_menu_override_store(sf)
from deerflow.persistence.sensitive_words import make_sensitive_word_store
from deerflow.persistence.menu_overrides import make_menu_override_store
app.state.menu_override_store = make_menu_override_store(sf)
from deerflow.persistence.compact_menu_overrides import make_compact_menu_override_store
app.state.compact_menu_override_store = make_compact_menu_override_store(sf)
from deerflow.persistence.sensitive_words import make_sensitive_word_store
app.state.sensitive_word_store = make_sensitive_word_store(sf)
@@ -0,0 +1,76 @@
"""Compact/sidebar-summary menu override API.
Routes (prefix ``/api/compact-menu-overrides``):
GET "" list all compact-menu overrides
PUT "" replace the entire override set (admin)
"""
from __future__ import annotations
from datetime import datetime
from typing import Literal
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user, get_optional_user_from_request
from deerflow.persistence.compact_menu_overrides import CompactMenuOverrideStore
router = APIRouter(prefix="/api/compact-menu-overrides", tags=["compact-menu-overrides"])
class CompactMenuOverride(BaseModel):
node_id: str = Field(..., min_length=1, max_length=128)
placement: Literal["sidebar", "settings"] = "sidebar"
sort_order: int = 0
label: str | None = Field(default=None, max_length=255)
class CompactMenuOverrideResponse(CompactMenuOverride):
updated_by: str | None = None
updated_at: datetime | str | None = None
class CompactMenuOverrideListResponse(BaseModel):
overrides: list[CompactMenuOverrideResponse]
class CompactMenuOverrideReplaceRequest(BaseModel):
overrides: list[CompactMenuOverride] = Field(default_factory=list)
def _get_store(request: Request) -> CompactMenuOverrideStore:
store = getattr(request.app.state, "compact_menu_override_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Compact menu override store not available")
return store
async def _require_admin(request: Request) -> None:
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="简介菜单管理仅限管理员")
@router.get("", response_model=CompactMenuOverrideListResponse)
async def list_compact_menu_overrides(request: Request) -> CompactMenuOverrideListResponse:
store = _get_store(request)
rows = await store.list_overrides()
return CompactMenuOverrideListResponse(overrides=[CompactMenuOverrideResponse(**r) for r in rows])
@router.put("", response_model=CompactMenuOverrideListResponse)
async def replace_compact_menu_overrides(
request: Request,
body: CompactMenuOverrideReplaceRequest,
) -> CompactMenuOverrideListResponse:
await _require_admin(request)
store = _get_store(request)
user_id = await get_current_user(request)
rows = await store.replace_all(
[o.model_dump() for o in body.overrides],
updated_by=user_id,
)
return CompactMenuOverrideListResponse(overrides=[CompactMenuOverrideResponse(**r) for r in rows])
@@ -0,0 +1,22 @@
"""Compact/sidebar-summary menu override persistence."""
from __future__ import annotations
from deerflow.persistence.compact_menu_overrides.base import CompactMenuOverrideStore
from deerflow.persistence.compact_menu_overrides.memory import MemoryCompactMenuOverrideStore
from deerflow.persistence.compact_menu_overrides.model import CompactMenuOverrideRow
from deerflow.persistence.compact_menu_overrides.sql import CompactMenuOverrideRepository
__all__ = [
"CompactMenuOverrideRepository",
"CompactMenuOverrideRow",
"CompactMenuOverrideStore",
"MemoryCompactMenuOverrideStore",
"make_compact_menu_override_store",
]
def make_compact_menu_override_store(session_factory):
if session_factory is None:
return MemoryCompactMenuOverrideStore()
return CompactMenuOverrideRepository(session_factory)
@@ -0,0 +1,18 @@
"""Abstract compact-sidebar menu override persistence API."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class CompactMenuOverrideStore(ABC):
@abstractmethod
async def list_overrides(self) -> list[dict[str, Any]]:
"""Return every stored compact-menu override row."""
raise NotImplementedError
@abstractmethod
async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]:
"""Atomically replace the entire compact-menu override set."""
raise NotImplementedError
@@ -0,0 +1,37 @@
"""In-memory compact-menu override store."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.compact_menu_overrides.base import CompactMenuOverrideStore
def _normalize(data: dict[str, Any], updated_by: str | None) -> dict[str, Any]:
placement = str(data.get("placement") or "sidebar")
return {
"node_id": str(data["node_id"]),
"placement": placement if placement in {"sidebar", "settings"} else "sidebar",
"sort_order": int(data.get("sort_order", 0)),
"label": (data.get("label") or None),
"updated_by": updated_by,
"updated_at": datetime.now(UTC).isoformat(),
}
class MemoryCompactMenuOverrideStore(CompactMenuOverrideStore):
def __init__(self) -> None:
self._rows: dict[str, dict[str, Any]] = {}
async def list_overrides(self) -> list[dict[str, Any]]:
return [dict(r) for r in sorted(self._rows.values(), key=lambda r: r.get("sort_order", 0))]
async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]:
self._rows = {}
for r in rows:
node_id = r.get("node_id")
if not node_id:
continue
self._rows[str(node_id)] = _normalize(r, updated_by)
return [dict(r) for r in self._rows.values()]
@@ -0,0 +1,28 @@
"""ORM model for compact/sidebar-summary menu overrides.
This table only controls the compact left menu presentation. The full sidebar
menu layout continues to use ``menu_overrides`` and is unaffected.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class CompactMenuOverrideRow(Base):
__tablename__ = "compact_menu_overrides"
node_id: Mapped[str] = mapped_column(String(128), primary_key=True)
placement: Mapped[str] = mapped_column(String(32), default="sidebar")
sort_order: Mapped[int] = mapped_column(Integer, default=0)
label: Mapped[str | None] = mapped_column(String(255))
updated_by: Mapped[str | None] = mapped_column(String(64))
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
)
@@ -0,0 +1,57 @@
"""SQLAlchemy-backed compact-menu override store."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.compact_menu_overrides.base import CompactMenuOverrideStore
from deerflow.persistence.compact_menu_overrides.model import CompactMenuOverrideRow
def _to_dict(row: CompactMenuOverrideRow) -> dict[str, Any]:
data = row.to_dict()
value = data.get("updated_at")
if isinstance(value, datetime):
data["updated_at"] = value.isoformat()
return data
def _row_from(data: dict[str, Any], updated_by: str | None) -> CompactMenuOverrideRow:
placement = str(data.get("placement") or "sidebar")
return CompactMenuOverrideRow(
node_id=str(data["node_id"]),
placement=placement if placement in {"sidebar", "settings"} else "sidebar",
sort_order=int(data.get("sort_order", 0)),
label=(data.get("label") or None),
updated_by=updated_by,
)
class CompactMenuOverrideRepository(CompactMenuOverrideStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def list_overrides(self) -> list[dict[str, Any]]:
stmt = select(CompactMenuOverrideRow).order_by(CompactMenuOverrideRow.sort_order.asc())
async with self._sf() as session:
result = await session.execute(stmt)
return [_to_dict(row) for row in result.scalars()]
async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]:
by_id: dict[str, dict[str, Any]] = {}
for r in rows:
node_id = r.get("node_id")
if node_id:
by_id[str(node_id)] = r
async with self._sf() as session:
await session.execute(delete(CompactMenuOverrideRow))
new_rows = [_row_from(r, updated_by) for r in by_id.values()]
session.add_all(new_rows)
await session.commit()
for row in new_rows:
await session.refresh(row)
return [_to_dict(row) for row in new_rows]
@@ -36,6 +36,7 @@ from deerflow.persistence.agents.model import (
AgentSkillRow,
)
from deerflow.persistence.business_mapping.model import BusinessMappingRow
from deerflow.persistence.compact_menu_overrides.model import CompactMenuOverrideRow
from deerflow.persistence.concurrency.model import ConcurrencySampleRow
from deerflow.persistence.dashboard_sessions.model import DashboardSessionRow
from deerflow.persistence.deep_research_events.model import DeepResearchEventRow
@@ -102,6 +103,7 @@ __all__ = [
"AgentSkillRow",
"AdminLeaderboardDailyStatRow",
"BusinessMappingRow",
"CompactMenuOverrideRow",
"ConcurrencySampleRow",
"DashboardSessionRow",
"DocumentRewriteVersionRow",
+78
View File
@@ -0,0 +1,78 @@
param(
[ValidateSet("status", "start", "open")]
[string]$Action = "status"
)
$InstallRoot = "C:\Program Files (x86)\Intax\mbwsmdmclient"
$RelatedServices = @(
"mbwsmdmclient",
"itxcltserver",
"zfmgclient",
"zfnetchkclient",
"ZFICONTROL",
"ZFFTSSERVER"
)
function Show-MbwsStatus {
Write-Host "MBWS related directory:" -ForegroundColor Cyan
Write-Host " $InstallRoot"
Write-Host ""
Write-Host "MBWS related services:" -ForegroundColor Cyan
foreach ($name in $RelatedServices) {
$svc = Get-Service -Name $name -ErrorAction SilentlyContinue
if ($null -eq $svc) {
Write-Host (" {0,-16} not found" -f $name) -ForegroundColor DarkGray
continue
}
Write-Host (" {0,-16} {1}" -f $svc.Name, $svc.Status)
}
Write-Host ""
Write-Host "MBWS related processes:" -ForegroundColor Cyan
Get-Process -ErrorAction SilentlyContinue |
Where-Object {
$_.Path -like "C:\Program Files (x86)\Intax\*" -or
$_.ProcessName -match "itx|mbws|zf"
} |
Select-Object ProcessName, Id, Path |
Format-Table -AutoSize
}
function Start-MbwsServices {
foreach ($name in $RelatedServices) {
$svc = Get-Service -Name $name -ErrorAction SilentlyContinue
if ($null -eq $svc) {
Write-Host "Service not found: $name" -ForegroundColor DarkGray
continue
}
if ($svc.Status -eq "Running") {
Write-Host "Already running: $name" -ForegroundColor Green
continue
}
Write-Host "Starting: $name" -ForegroundColor Yellow
Start-Service -Name $name -ErrorAction Continue
}
Write-Host ""
Show-MbwsStatus
}
switch ($Action) {
"status" {
Show-MbwsStatus
}
"start" {
Start-MbwsServices
}
"open" {
if (Test-Path -LiteralPath $InstallRoot) {
explorer $InstallRoot
} else {
Write-Error "Directory does not exist: $InstallRoot"
}
}
}