@@ -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
(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();
+ 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) =>
+ 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 (
+ <>
+
+
+
+
+
+
+
+
+
+ 仅简介模式生效
+
+
+ 简介菜单管理
+
+ {rows.length} 个项目
+
+
+
+
+
+
+
+
+
+
+ 这里配置的是简介模式工作区左侧菜单:可改显示名称、调整顺序,或迁移到「设置和更多」下拉中;全量模式菜单不受影响。
+
+
+
+
+
+ {isLoading ? (
+
加载中…
+ ) : (
+ grouped.map((group) => (
+
+ {group.label}
+
+ {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 (
+
+
+
+
+
+ updateRow(row.id, { label: e.target.value })}
+ className="bg-white"
+ />
+
+
+
+
+
+
+
+ );
+ })}
+
+
+ ))
+ )}
+
+ >
+ );
+}
+
// ── 编辑弹窗 ─────────────────────────────────────────────────────────────
interface EditMenuDialogProps {
diff --git a/frontend-web/src/strategy-components/api/compact-menu-overrides.ts b/frontend-web/src/strategy-components/api/compact-menu-overrides.ts
new file mode 100644
index 00000000..dc3bd0bc
--- /dev/null
+++ b/frontend-web/src/strategy-components/api/compact-menu-overrides.ts
@@ -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(res: Response, ctx: string): Promise {
+ 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 {
+ 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 {
+ 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] }),
+ }),
+ };
+}
diff --git a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx
index 1cb13253..9f2ae5b4 100644
--- a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx
+++ b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx
@@ -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({
删除
-
diff --git a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace.tsx b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace.tsx
index 07b5b2fd..04a20482 100644
--- a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace.tsx
+++ b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace.tsx
@@ -125,7 +125,7 @@ export function WeKnoraKnowledgeWorkspace() {
-
问答梳理
+ 知识库管理
知识服务
由知识服务负责解析与检索,用户、权限和发布范围由 cmzs 管理。
diff --git a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer.tsx b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer.tsx
index 0a4fa6ab..b88ee2d6 100644
--- a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer.tsx
+++ b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer.tsx
@@ -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({
- |
+ |
#
|
{Array.from({ length: columnCount }, (_, columnIndex) => (
{headerRow?.[columnIndex] ?? ""}
|
@@ -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[] = [];
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 (
@@ -588,7 +651,7 @@ export function WeKnoraSourceDrawer({
{resolvedTitle}
知识库文档详情
-
+
@@ -604,17 +667,24 @@ export function WeKnoraSourceDrawer({
-
+
+ {resolvedDocumentId && (
+
+ )}
+
+
{loading && (
@@ -672,6 +742,24 @@ export function WeKnoraSourceDrawer({
)}
+ {preview.kind === "markdown" && (
+
+
+
+
+ {isWordDocument(document) ? "Word 文档预览" : "Markdown 文档预览"}
+
+
+
+
+ )}
+
{preview.kind === "unsupported" && (
{preview.message}
@@ -695,8 +783,14 @@ export function WeKnoraSourceDrawer({
{wikiContent && (
Wiki 原文
-
- {wikiContent}
+
+
)}
diff --git a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage.tsx b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage.tsx
index 27480776..743d6b2a 100644
--- a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage.tsx
+++ b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage.tsx
@@ -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() {
diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py
index be808faf..b1316762 100644
--- a/offline-backend-20260512/backend/app/gateway/app.py
+++ b/offline-backend-20260512/backend/app/gateway/app.py
@@ -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
diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py
index 69caf1e4..b094f05e 100644
--- a/offline-backend-20260512/backend/app/gateway/deps.py
+++ b/offline-backend-20260512/backend/app/gateway/deps.py
@@ -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)
diff --git a/offline-backend-20260512/backend/app/gateway/routers/compact_menu_overrides.py b/offline-backend-20260512/backend/app/gateway/routers/compact_menu_overrides.py
new file mode 100644
index 00000000..9d6c7409
--- /dev/null
+++ b/offline-backend-20260512/backend/app/gateway/routers/compact_menu_overrides.py
@@ -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])
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/__init__.py
new file mode 100644
index 00000000..da11cc02
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/__init__.py
@@ -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)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/base.py
new file mode 100644
index 00000000..ef630c04
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/base.py
@@ -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
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/memory.py
new file mode 100644
index 00000000..583a1a31
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/memory.py
@@ -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()]
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/model.py
new file mode 100644
index 00000000..f34e5dee
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/model.py
@@ -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)
+ )
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/sql.py
new file mode 100644
index 00000000..58bd5258
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/compact_menu_overrides/sql.py
@@ -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]
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py
index 35369639..175512e3 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py
@@ -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",
diff --git a/scripts/mbws-status-start.ps1 b/scripts/mbws-status-start.ps1
new file mode 100644
index 00000000..2e62b14c
--- /dev/null
+++ b/scripts/mbws-status-start.ps1
@@ -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"
+ }
+ }
+}