Merge branch 'master-zl' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl
# Conflicts: # frontend-web/src/components/workspace/skill-display-sources.tsx
This commit is contained in:
commit
0f667261be
28 files changed
+1250
-219
No files matched your search
@@ -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] }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
+5
-4
@@ -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>
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+114
-20
@@ -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>
|
||||
)}
|
||||
|
||||
+3
-3
@@ -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])
|
||||
+22
@@ -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)
|
||||
+18
@@ -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
|
||||
+37
@@ -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()]
|
||||
+28
@@ -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)
|
||||
)
|
||||
+57
@@ -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]
|
||||
+2
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user