diff --git a/CLAUDE.md b/CLAUDE.md index 56533a1d..d7d49990 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,6 +81,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps) - **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). - **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). +- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. - **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes` - **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE - **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/` diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 88ee59df..6c1db30f 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { Navigate, Route, Routes } from "react-router-dom"; import { ThemeProvider as NextThemesProvider } from "next-themes"; +import { DesensitizeWordsLoader } from "@/components/desensitize-words-loader"; import { FontScaleApplier } from "@/components/font-scale"; import { QueryClientProvider } from "@/components/query-client-provider"; import { AuthSyncWatcher } from "@/core/auth/sync"; @@ -76,6 +77,7 @@ function AppProviders({ children }: { children: React.ReactNode }) { + {children} diff --git a/frontend-web/src/components/desensitize-words-loader.tsx b/frontend-web/src/components/desensitize-words-loader.tsx new file mode 100644 index 00000000..b58b20ca --- /dev/null +++ b/frontend-web/src/components/desensitize-words-loader.tsx @@ -0,0 +1,27 @@ +import { useEffect } from "react"; + +import { replaceDesensitizeMap } from "@/lib/desensitize"; +import { useSensitiveWords, wordsToMap } from "@/strategy-components/api/sensitive-words"; + +/** + * 启动时把后台「敏感词管理」配置加载进运行时脱敏映射表。 + * + * - 后台返回**非空**时:用其中「启用」的词条整体替换运行时映射 + * ({@link replaceDesensitizeMap}),后台即唯一真源,被删除的词不再生效。 + * - 后台为空(全新部署还没配置)或拉取失败:什么都不做,保持使用 + * `src/lib/desensitize.ts` 内置的种子映射。 + * + * 渲染 `null`;挂在 `AppProviders` 里(QueryClientProvider 之内)。与「敏感词管理」 + * 页共享同一份 React Query 缓存,保存后会自动刷新并回灌运行时映射。 + */ +export function DesensitizeWordsLoader() { + const { data } = useSensitiveWords(); + + useEffect(() => { + if (data && data.length > 0) { + replaceDesensitizeMap(wordsToMap(data)); + } + }, [data]); + + return null; +} diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index 6d77b6fa..33b79a6b 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -9,6 +9,7 @@ import { FolderOpen, ListTree, Settings, + ShieldAlert, User, Wrench, } from "lucide-react"; @@ -22,7 +23,12 @@ import { import { NotificationDialog } from "@/components/workspace/notification-dialog"; import { SettingsDialog } from "@/components/workspace/settings"; import { useTheme } from "@/contexts/ThemeContext"; -import { getStoredAuth } from "@/core/auth"; +import { getAccountDisplayName, getStoredAuth } from "@/core/auth"; + +/** 敏感词管理仅对用户名为 lqq 的账号可见(用户名 = 设置→个人信息页展示名)。 */ +const SENSITIVE_WORDS_USERNAME = "lqq"; +const canManageSensitiveWords = () => + getAccountDisplayName().toLowerCase() === SENSITIVE_WORDS_USERNAME; import { isWujieEnv } from "@/core/embed/wujie"; import { useI18n } from "@/core/i18n/hooks"; import { useUnreadCount } from "@/core/notifications"; @@ -207,6 +213,14 @@ function SecondaryNavSidebar() { 菜单管理 )} + {canManageSensitiveWords() && ( + navigate("/page/strategy/admin/sensitive-words")} + > + + 敏感词管理 + + )} ) : null} @@ -479,6 +493,12 @@ function LegacyAccordionSidebar({ 菜单管理 )} + {canManageSensitiveWords() && ( + navigate("/page/strategy/admin/sensitive-words")}> + + 敏感词管理 + + )} ) : ( diff --git a/frontend-web/src/lib/desensitize.ts b/frontend-web/src/lib/desensitize.ts index a6e6bd0f..1dd7ecc8 100644 --- a/frontend-web/src/lib/desensitize.ts +++ b/frontend-web/src/lib/desensitize.ts @@ -1,9 +1,16 @@ /** - * 脱敏词映射表 + * 脱敏词「种子」映射表(前端内置)。 * key: 原始词(支持正则特殊字符,会被转义) * value: 脱敏后替换词 + * + * 这份常量是**部署种子**:新机器首次启动、后台还没有任何敏感词时,运行时直接用它; + * 管理员也可在「敏感词管理」页一键「导入种子词」把它写入后台。线上以后台配置为准 + * (见 {@link replaceDesensitizeMap}),后台为空时回退到这份种子。 + * + * 注意:请勿在运行时修改本对象——它要保持与「出厂种子」一致。运行时可变映射见 + * {@link DESENSITIZE_MAP}。 */ -export const DESENSITIZE_MAP: Record = { +export const SEED_DESENSITIZE_MAP: Readonly> = Object.freeze({ 方案: "FA", 设计: "SJ", 任务: "RW", @@ -44,8 +51,15 @@ export const DESENSITIZE_MAP: Record = { 军: "J", 美军: "MJ", 台军: "TJ", +}); -}; +/** + * 运行时实际生效的脱敏词映射表(可变)。 + * + * 初始为 {@link SEED_DESENSITIZE_MAP} 的浅拷贝(保证后台数据加载前 `desensitize` + * 立即可用);应用启动后由 {@link replaceDesensitizeMap} 用后台配置整体替换。 + */ +export const DESENSITIZE_MAP: Record = { ...SEED_DESENSITIZE_MAP }; /** * 将 DESENSITIZE_MAP 编译为单个正则,避免多次遍历字符串。 @@ -107,3 +121,18 @@ export function addDesensitizeEntries(entries: Record): void { Object.assign(DESENSITIZE_MAP, entries); _regex = buildRegex(DESENSITIZE_MAP); } + +/** + * 用后台配置**整体替换**运行时脱敏词映射表,替换后自动重编译正则。 + * + * 与 {@link addDesensitizeEntries} 不同,本函数会先清空现有词条,因此后台里被 + * 删除的词不会因为还残留在种子里而继续生效。应用启动时拉取 + * `/api/sensitive-words` 后调用;若后台为空则不调用,保持使用种子。 + * + * @param entries 后台返回的「启用」词条,格式同 {@link DESENSITIZE_MAP} + */ +export function replaceDesensitizeMap(entries: Record): void { + for (const key of Object.keys(DESENSITIZE_MAP)) delete DESENSITIZE_MAP[key]; + Object.assign(DESENSITIZE_MAP, entries); + _regex = buildRegex(DESENSITIZE_MAP); +} diff --git a/frontend-web/src/pages/SensitiveWordsPage.tsx b/frontend-web/src/pages/SensitiveWordsPage.tsx new file mode 100644 index 00000000..a64042be --- /dev/null +++ b/frontend-web/src/pages/SensitiveWordsPage.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Navigate } from "react-router-dom"; +import { Download, Info, Plus, Save, Search, ShieldAlert, Sparkles, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Switch } from "@/components/ui/switch"; +import { getAccountDisplayName } from "@/core/auth"; +import { SEED_DESENSITIZE_MAP } from "@/lib/desensitize"; +import { + type SensitiveWord, + useSensitiveWordActions, + useSensitiveWords, +} from "@/strategy-components/api/sensitive-words"; + +interface EditRow { + /** 稳定的行 key(编辑原始词时不丢焦点) */ + key: number; + word: string; + replacement: string; + enabled: boolean; +} + +let _seq = 1; +const nextKey = () => _seq++; + +function toRows(words: SensitiveWord[]): EditRow[] { + return words.map((w) => ({ key: nextKey(), word: w.word, replacement: w.replacement, enabled: w.enabled })); +} + +/** 归一化用于「脏」判断与重复检测(按原始词去空白后比较)。 */ +function serialize(rows: { word: string; replacement: string; enabled: boolean }[]): string { + return JSON.stringify( + rows + .map((r) => ({ w: r.word.trim(), r: r.replacement, e: r.enabled })) + .filter((r) => r.w !== "") + .sort((a, b) => a.w.localeCompare(b.w)), + ); +} + +export default function SensitiveWordsPage() { + // 仅用户名为 lqq 的账号可访问(用户名 = 设置→个人信息页展示名)。 + const canManage = getAccountDisplayName().toLowerCase() === "lqq"; + const { data: serverWords = [], isLoading } = useSensitiveWords(); + const { save } = useSensitiveWordActions(); + + const [rows, setRows] = useState([]); + const [loadedKey, setLoadedKey] = useState(null); + const [query, setQuery] = useState(""); + + // 服务端数据首次/变化时回填工作副本(以序列化值判定,避免覆盖未保存编辑)。 + const sourceKey = useMemo(() => serialize(serverWords), [serverWords]); + useEffect(() => { + if (sourceKey !== loadedKey) { + setRows(toRows(serverWords)); + setLoadedKey(sourceKey); + } + }, [sourceKey, loadedKey, serverWords]); + + const dirty = useMemo(() => serialize(rows) !== serialize(serverWords), [rows, serverWords]); + + const q = query.trim().toLowerCase(); + const visibleRows = useMemo( + () => + !q + ? rows + : rows.filter( + (r) => r.word.toLowerCase().includes(q) || r.replacement.toLowerCase().includes(q), + ), + [rows, q], + ); + + const enabledCount = rows.filter((r) => r.enabled && r.word.trim()).length; + + // ── 行操作 ── + const patchRow = (key: number, patch: Partial) => + setRows((prev) => prev.map((r) => (r.key === key ? { ...r, ...patch } : r))); + const removeRow = (key: number) => setRows((prev) => prev.filter((r) => r.key !== key)); + const addRow = () => setRows((prev) => [{ key: nextKey(), word: "", replacement: "", enabled: true }, ...prev]); + + /** 用前端内置种子补齐当前没有的词(已存在的词不动)。 */ + const importSeed = () => { + const have = new Set(rows.map((r) => r.word.trim()).filter(Boolean)); + const added: EditRow[] = []; + for (const [word, replacement] of Object.entries(SEED_DESENSITIZE_MAP)) { + if (!have.has(word)) added.push({ key: nextKey(), word, replacement, enabled: true }); + } + if (added.length === 0) { + toast.info("种子词已全部存在,无需导入"); + return; + } + setRows((prev) => [...added, ...prev]); + toast.success(`已导入 ${added.length} 个种子词(保存后生效)`); + }; + + const handleSave = async () => { + // 校验:去空白、丢弃空原始词、检测重复。 + const cleaned = rows + .map((r) => ({ ...r, word: r.word.trim() })) + .filter((r) => r.word !== ""); + const seen = new Set(); + for (const r of cleaned) { + if (seen.has(r.word)) { + toast.error(`原始词「${r.word}」重复,请合并后再保存`); + return; + } + seen.add(r.word); + } + const payload: SensitiveWord[] = cleaned.map((r, i) => ({ + word: r.word, + replacement: r.replacement, + enabled: r.enabled, + sortOrder: i, + })); + try { + await save.mutateAsync(payload); + toast.success("敏感词已保存"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "保存失败"); + } + }; + + if (!canManage) return ; + + return ( +
+
+ {/* 顶部信息卡 */} +
+
+ +
+
+
+
+ + + 系统集成版 + +

+ 敏感词管理 + + {rows.length} 词 / {enabledCount} 启用 + +

+
+ +
+ + +
+
+ +

+ + + 配置展示层 脱敏词映射(原始词 → 替换词)。保存后全站脱敏即时生效; + 停用 的词会保留但不参与替换。前端内置的种子词仅用于全新机器首次部署, + 可用「导入种子词」一键写入后台。 + +

+
+
+ + {/* 搜索 + 新增栏 */} +
+
+ + setQuery(e.target.value)} + className="w-full rounded-xl border border-slate-200 bg-slate-50/50 py-2 pl-10 pr-4 text-sm transition-all hover:border-slate-300 focus:border-red-500 focus:outline-none focus:ring-4 focus:ring-red-500/5" + /> +
+ +
+ + {/* 列表 */} +
+
+ 原始词 + 替换词 + 启用 + +
+ + {isLoading ? ( +
加载中…
+ ) : visibleRows.length === 0 ? ( +
+ {rows.length === 0 ? "暂无敏感词,点击「新增」或「导入种子词」开始配置" : "没有匹配的词"} +
+ ) : ( +
    + {visibleRows.map((row) => ( +
  • + patchRow(row.key, { word: e.target.value })} + className="w-full rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm transition-all focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/10" + /> + patchRow(row.key, { replacement: e.target.value })} + className="w-full rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm transition-all focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/10" + /> +
    + patchRow(row.key, { enabled: v })} + /> +
    + +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 2aa89a01..15fa53a3 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -2,7 +2,7 @@ import { Navigate, Route, Routes, useParams } from "react-router-dom"; import { Toaster } from "sonner"; import { SidebarProvider } from "@/components/ui/sidebar"; -import { getStoredAuth } from "@/core/auth"; +import { getAccountDisplayName, getStoredAuth } from "@/core/auth"; import { RouteBaseProvider } from "@/core/route-base"; import GeneralQA from "@/strategy-components/components/chat-qa/GeneralQA"; import SentimentAnalyzes from "@/strategy-components/components/task-components/SentimentAnalyzes"; @@ -32,6 +32,7 @@ import LightAppIframeView from "./LightAppIframeView"; import LightAppListPage from "./LightAppListPage"; import LightAppManagementPage from "./LightAppManagementPage"; import MenuManagementPage from "./MenuManagementPage"; +import SensitiveWordsPage from "./SensitiveWordsPage"; import LightAppNewNotePage from "./LightAppNewNotePage"; import LightAppNoteListPage from "./LightAppNoteListPage"; import LightAppNoteQAPage from "./LightAppNoteQAPage"; @@ -321,6 +322,16 @@ export default function StrategyRoutes() { ) } /> + + ) : ( + + ) + } + /> `${getBackendBaseURL()}/api/sensitive-words`; + +/** 一条敏感词配置(前端视图模型)。 */ +export interface SensitiveWord { + /** 原始词 */ + word: string; + /** 脱敏替换词 */ + replacement: string; + /** 是否启用(停用的词不参与运行时脱敏,但保留以便随时恢复) */ + enabled: boolean; + /** 展示/匹配排序 */ + sortOrder: number; + updatedBy?: string | null; + updatedAt?: string | null; +} + +interface ServerWord { + word: string; + replacement: string; + enabled: boolean; + sort_order: number; + updated_by?: string | null; + updated_at?: string | null; +} + +function toWord(s: ServerWord): SensitiveWord { + return { + word: s.word, + replacement: s.replacement ?? "", + enabled: s.enabled ?? true, + sortOrder: s.sort_order ?? 0, + updatedBy: s.updated_by ?? null, + updatedAt: s.updated_at ?? null, + }; +} + +function toBody(w: SensitiveWord): Pick { + return { + word: w.word, + replacement: w.replacement ?? "", + enabled: w.enabled, + sort_order: w.sortOrder, + }; +} + +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 listSensitiveWords(): Promise { + const res = await apiFetch(BASE(), { method: "GET" }); + const data = await readJson<{ words: ServerWord[] }>(res, "list sensitive words"); + return (data.words ?? []).map(toWord); +} + +export async function replaceSensitiveWords(words: SensitiveWord[]): Promise { + const res = await apiFetch(BASE(), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ words: words.map(toBody) }), + }); + const data = await readJson<{ words: ServerWord[] }>(res, "save sensitive words"); + return (data.words ?? []).map(toWord); +} + +/** 把「启用」的词条折叠成运行时脱敏所需的 `原始词 → 替换词` 映射。 */ +export function wordsToMap(words: SensitiveWord[]): Record { + const map: Record = {}; + for (const w of words) { + if (w.enabled && w.word) map[w.word] = w.replacement ?? ""; + } + return map; +} + +// ── React Query hooks(管理页 + 启动加载共用缓存) ────────────────────────── + +export const SENSITIVE_WORDS_KEY = "sensitive-words"; + +export function useSensitiveWords() { + return useQuery({ + queryKey: [SENSITIVE_WORDS_KEY], + queryFn: listSensitiveWords, + refetchOnWindowFocus: false, + staleTime: 60_000, + }); +} + +export function useSensitiveWordActions() { + const queryClient = useQueryClient(); + return { + save: useMutation({ + mutationFn: replaceSensitiveWords, + onSuccess: () => queryClient.invalidateQueries({ queryKey: [SENSITIVE_WORDS_KEY] }), + }), + }; +} diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index f9c5cc9f..e4ec951f 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -253,6 +253,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA | **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). | | **Light Apps** (`/api/light-apps`) | Light-app registry (????? / ????), **global/shared** (no user scoping; `created_by`/`updated_by` audit-only). Two kinds: `window_open` (launched in a new tab from the ???? card grid) and `iframe` (embedded under a parent sidebar menu via `mount_location` and dynamically injected into the sidebar). Both carry `route_params` ? a list of `{key, paramType: login\|theme, source, customValue, themeValue}` descriptors the **frontend** resolves at launch time (login params read from `localStorage.userInfo`; theme params use a literal value) and appends to `url` as query string. `GET /` - list (filters `app_type`/`mount_location`/`enabled_only`/`search`); `GET /{app_id}` - read one (both open to any authenticated user); `POST /`, `PUT /{app_id}`, `DELETE /{app_id}` - **admin only**; iframe apps must carry a `mount_location` (400 otherwise); the unique `name` collides as HTTP 409. Backed by `deerflow.persistence.light_apps` (table `light_apps`, auto-created by `create_all`); store wired in `deps.py` as `app.state.light_app_store`. Tests: `tests/test_light_apps.py`. | | **Menu Overrides** (`/api/menu-overrides`) | Sidebar-menu overrides (菜单管理), **global/shared** (no user scoping; `updated_by` audit-only). The static menu tree (icons/routes/`adminOnly`) lives in the **frontend** code (`core/page-layout/sidebar-menu.ts`); iframe light-apps are injected at render time. This router persists a per-node **overlay** the frontend merges onto that tree: `parent_id` (re-parent, moves the whole subtree; NULL = top level), `sort_order` (reorder among siblings), `label` (rename; NULL keeps code label), `disabled` (停用 — hides the node **and its subtree**). A node with no row renders exactly as code defines it, so new code menus / newly-registered iframe apps appear automatically and are immediately configurable. `GET /` - list all overrides (open to any authenticated user); `PUT /` - **admin only**, atomically **replaces the whole set** (`{overrides: [...]}`); rejects re-parenting that would exceed **3 levels** or form a cycle (`_validate_depth`, HTTP 400). Backed by `deerflow.persistence.menu_overrides` (table `menu_overrides`, auto-created by `create_all`); store wired in `deps.py` as `app.state.menu_override_store`. Frontend: merge logic `core/page-layout/menu-overrides.ts` (`applyMenuOverrides` in `use-sidebar-menu.ts`), client `strategy-components/api/menu-overrides.ts`, admin page `pages/MenuManagementPage.tsx` (@dnd-kit drag-reorder/re-parent + 「移动到」parent selector + ↑↓ arrows + 改名/停用), entry in the bottom-left settings dropdown (`components/page-sidebar.tsx`, admin-only). Tests: `tests/test_menu_overrides.py`, `frontend-web/src/core/page-layout/menu-overrides.test.ts`. | +| **Sensitive Words** (`/api/sensitive-words`) | Sensitive-word / desensitize map (敏感词管理 / 脱敏词), **global/shared** (no user scoping; `updated_by` audit-only). Each row maps an original `word` → `replacement`, with `enabled` (停用 parks a word without deleting) + `sort_order`. The **frontend** ships a seed map in code (`src/lib/desensitize.ts` `SEED_DESENSITIZE_MAP`) used as the default on a fresh deployment; once an admin saves, this table is the source of truth and the frontend loads it at startup (`DesensitizeWordsLoader` → `replaceDesensitizeMap`, **enabled** rows only). `GET /` - list all words (open to any authenticated user — every page desensitizes); `PUT /` - **admin OR the `lqq` account** (`_require_manager`: `system_role == "admin"` or email `@`-prefix `== "lqq"`), atomically **replaces the whole set** (`{words: [...]}`, de-duped by `word`, last wins). Backed by `deerflow.persistence.sensitive_words` (table `sensitive_words`, PK `word` `String(191)`, auto-created by `create_all`); store wired in `deps.py` as `app.state.sensitive_word_store`. Frontend: client `strategy-components/api/sensitive-words.ts`, page `pages/SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, entry in the bottom-left settings dropdown — **gated on username `lqq`, not admin role**, present in both sidebar modes; 「导入种子词」 merges missing seed entries). Tests: `tests/test_sensitive_words.py`. | | **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Defaults to `background=true`**: the slow per-thread extraction loop is deferred to a FastAPI background task and the endpoint returns `{queued:true, processed:N}` immediately (each thread's transcript is truncated to `max_input_chars` before the LLM call). Running the loop inline for a large batch outlives the nginx gateway timeout (504), which the frontend's `parseResponse` surfaces as a generic "Request failed"; `background=false` keeps the blocking path (full per-thread `created/skipped/failed` tally) for small batches/scripts. Shared loop: `_run_batch_ingest`. **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` ? `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "???????". The "??????" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector?keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[?]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). `GET/POST /folders` + `PUT/DELETE /folders/{id}` - user-managed directories (notes carry a `folder` path; `GET /notes?folder=` filters a dir + sub-dirs); `GET/POST /extract-templates` + `GET/PUT/DELETE /extract-templates/{id}` - editable extraction-prompt templates (thread/document ingest accept `template_id`; capture/PUT-note accept `folder`). Backed by the `deerflow.knowledge` module (see below). | Proxied through nginx: `/api/langgraph/*` ? LangGraph, all other `/api/*` ? Gateway. diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index b31de5ee..34603cd1 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -63,6 +63,7 @@ from app.gateway.routers import ( roundtable_jobs, runs, scheduled_tasks, + sensitive_words, skills, suggestions, system_settings, @@ -1343,6 +1344,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an # Sidebar-menu overrides (菜单管理) at /api/menu-overrides app.include_router(menu_overrides.router) + # Sensitive-word map (敏感词管理 / 脱敏词) at /api/sensitive-words + app.include_router(sensitive_words.router) + # Standalone parallel multi-agent panel: fan-out streaming + saved tasks app.include_router(parallel_agents.router) app.include_router(parallel_agent_tasks.router) diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py index 008bbea5..babc3932 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -243,6 +243,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: app.state.menu_override_store = make_menu_override_store(sf) + from deerflow.persistence.sensitive_words import make_sensitive_word_store + + app.state.sensitive_word_store = make_sensitive_word_store(sf) + from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf) diff --git a/offline-backend-20260512/backend/app/gateway/routers/sensitive_words.py b/offline-backend-20260512/backend/app/gateway/routers/sensitive_words.py new file mode 100644 index 00000000..3d58c9f5 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/sensitive_words.py @@ -0,0 +1,106 @@ +"""Sensitive-word API (脱敏词 / 敏感词管理). + +The frontend ships a seed desensitize map in code (``src/lib/desensitize.ts``); +it is the default on a fresh deployment and the source admins can import from. +This router persists a **global / shared** word map that the frontend loads at +startup and uses to build its desensitize regex. Each entry maps an original +``word`` to its ``replacement``; ``enabled`` parks a word without deleting it. + +Reads are open to any authenticated user (every page desensitizes); the whole +map is replaced atomically and only by an admin. + +Routes (prefix ``/api/sensitive-words``): + GET "" list all words + PUT "" replace the entire word set (admin) +""" + +from __future__ import annotations + +from datetime import datetime + +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.sensitive_words import SensitiveWordStore + +router = APIRouter(prefix="/api/sensitive-words", tags=["sensitive-words"]) + +# 敏感词管理对应的指定账号:用户名(邮箱 @ 前缀)为该值的用户可写,admin 也可写。 +# 与前端 `getAccountDisplayName() === "lqq"` 的可见性门保持一致。 +_MANAGER_USERNAME = "lqq" + + +def _username_of(user) -> str: + email = getattr(user, "email", None) or "" + return email.split("@")[0].strip().lower() + + +# ── schemas ────────────────────────────────────────────────────────────── + + +class SensitiveWord(BaseModel): + word: str = Field(..., min_length=1, max_length=191) + replacement: str = Field(default="", max_length=191) + enabled: bool = True + sort_order: int = 0 + + +class SensitiveWordResponse(SensitiveWord): + updated_by: str | None = None + updated_at: datetime | str | None = None + + +class SensitiveWordListResponse(BaseModel): + words: list[SensitiveWordResponse] + + +class SensitiveWordReplaceRequest(BaseModel): + words: list[SensitiveWord] = Field(default_factory=list) + + +# ── helpers ────────────────────────────────────────────────────────────── + + +def _get_store(request: Request) -> SensitiveWordStore: + store = getattr(request.app.state, "sensitive_word_store", None) + if store is None: + raise HTTPException(status_code=503, detail="Sensitive word store not available") + return store + + +async def _require_manager(request: Request) -> None: + """Allow the designated manager username (``lqq``) or an admin. + + Lets the call through when auth is disabled (no user resolved). + """ + user = await get_optional_user_from_request(request) + if user is None: + return + if getattr(user, "system_role", None) == "admin": + return + if _username_of(user) == _MANAGER_USERNAME: + return + raise HTTPException(status_code=403, detail="敏感词管理仅限指定账号") + + +# ── routes ─────────────────────────────────────────────────────────────── + + +@router.get("", response_model=SensitiveWordListResponse) +async def list_sensitive_words(request: Request) -> SensitiveWordListResponse: + store = _get_store(request) + rows = await store.list_words() + return SensitiveWordListResponse(words=[SensitiveWordResponse(**r) for r in rows]) + + +@router.put("", response_model=SensitiveWordListResponse) +async def replace_sensitive_words(request: Request, body: SensitiveWordReplaceRequest) -> SensitiveWordListResponse: + await _require_manager(request) + store = _get_store(request) + user_id = await get_current_user(request) + rows = await store.replace_all( + [w.model_dump() for w in body.words], + updated_by=user_id, + ) + return SensitiveWordListResponse(words=[SensitiveWordResponse(**r) for r in 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 aaa66560..849fcb4b 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 @@ -55,6 +55,7 @@ from deerflow.persistence.roundtable_drafts.model import ( ) from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow from deerflow.persistence.run.model import RunRow +from deerflow.persistence.sensitive_words.model import SensitiveWordRow from deerflow.persistence.scheduled_tasks.model import ( ScheduledTaskDeliveryProfileRow, ScheduledTaskRow, @@ -112,6 +113,7 @@ __all__ = [ "ScheduledTaskRunRow", "ScheduledTaskSubscriptionRow", "SchedulerThreadRow", + "SensitiveWordRow", "SkillRow", "SkillDisplayAdapterRow", "SkillFavoriteRow", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/sensitive_words/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/sensitive_words/__init__.py new file mode 100644 index 00000000..0f6b820c --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/sensitive_words/__init__.py @@ -0,0 +1,22 @@ +"""Sensitive-word (脱敏词 / 敏感词管理) persistence.""" + +from __future__ import annotations + +from deerflow.persistence.sensitive_words.base import SensitiveWordStore +from deerflow.persistence.sensitive_words.memory import MemorySensitiveWordStore +from deerflow.persistence.sensitive_words.model import SensitiveWordRow +from deerflow.persistence.sensitive_words.sql import SensitiveWordRepository + +__all__ = [ + "MemorySensitiveWordStore", + "SensitiveWordRepository", + "SensitiveWordRow", + "SensitiveWordStore", + "make_sensitive_word_store", +] + + +def make_sensitive_word_store(session_factory): + if session_factory is None: + return MemorySensitiveWordStore() + return SensitiveWordRepository(session_factory) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/sensitive_words/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/sensitive_words/base.py new file mode 100644 index 00000000..774f5bd0 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/sensitive_words/base.py @@ -0,0 +1,33 @@ +"""Abstract sensitive-word (脱敏词) persistence API. + +Global / shared store: no ``user_id`` scoping; every account desensitizes with +the same word map. Reads are open to any caller (the frontend needs the map to +render desensitized text); the router gates writes behind admin. ``updated_by`` +is audit-only. + +The whole word map is treated as one document: the management page computes the +full desired set and replaces every row atomically via :meth:`replace_all`, so +the stored set never drifts out of sync with what the admin sees. This mirrors +the menu-override store. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + + +class SensitiveWordStore(ABC): + @abstractmethod + async def list_words(self) -> list[dict[str, Any]]: + """Return every stored word row, ordered by ``sort_order``.""" + 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 word set with ``rows``. + + Each row is ``{word, replacement, enabled, sort_order}``. Returns the + persisted rows. + """ + raise NotImplementedError diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/sensitive_words/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/sensitive_words/memory.py new file mode 100644 index 00000000..7dbe3fdb --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/sensitive_words/memory.py @@ -0,0 +1,36 @@ +"""In-memory sensitive-word store (used when no DB is configured / tests).""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from deerflow.persistence.sensitive_words.base import SensitiveWordStore + + +def _normalize(data: dict[str, Any], updated_by: str | None) -> dict[str, Any]: + return { + "word": str(data["word"]), + "replacement": str(data.get("replacement") or ""), + "enabled": bool(data.get("enabled", True)), + "sort_order": int(data.get("sort_order", 0)), + "updated_by": updated_by, + "updated_at": datetime.now(UTC).isoformat(), + } + + +class MemorySensitiveWordStore(SensitiveWordStore): + def __init__(self) -> None: + self._rows: dict[str, dict[str, Any]] = {} + + async def list_words(self) -> list[dict[str, Any]]: + return [dict(r) for r in sorted(self._rows.values(), key=lambda r: (r.get("sort_order", 0), r.get("word", "")))] + + 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: + word = r.get("word") + if word in (None, ""): + continue + self._rows[str(word)] = _normalize(r, updated_by) + return await self.list_words() diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/sensitive_words/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/sensitive_words/model.py new file mode 100644 index 00000000..2dcc54c7 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/sensitive_words/model.py @@ -0,0 +1,38 @@ +"""ORM model for sensitive words (脱敏词 / 敏感词管理). + +Global / shared — there is no ``user_id`` scoping; every account desensitizes +with the same map. The frontend ships a seed map in code (used as the default on +a fresh deployment); once an admin saves here, this table is the source of truth. + +Each row maps an original term (``word``, the primary key) to its desensitized +replacement (``replacement``). ``enabled`` lets an admin park a word without +deleting it; ``sort_order`` controls display order in the management page. The +desensitize regex is rebuilt from the **enabled** rows. ``updated_by`` is +audit-only. + +``word`` is the primary key at ``String(191)`` so the utf8mb4 index stays within +the 767-byte legacy limit (191 × 4 = 764) — sensitive terms are never that long. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import Boolean, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from deerflow.persistence.base import Base +from deerflow.persistence.types import BeijingDateTime + + +class SensitiveWordRow(Base): + __tablename__ = "sensitive_words" + + word: Mapped[str] = mapped_column(String(191), primary_key=True) + replacement: Mapped[str] = mapped_column(String(191), default="") + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + 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/sensitive_words/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/sensitive_words/sql.py new file mode 100644 index 00000000..975a2ead --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/sensitive_words/sql.py @@ -0,0 +1,57 @@ +"""SQLAlchemy-backed sensitive-word store (global / shared).""" + +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.sensitive_words.base import SensitiveWordStore +from deerflow.persistence.sensitive_words.model import SensitiveWordRow + + +def _to_dict(row: SensitiveWordRow) -> 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) -> SensitiveWordRow: + return SensitiveWordRow( + word=str(data["word"]), + replacement=str(data.get("replacement") or ""), + enabled=bool(data.get("enabled", True)), + sort_order=int(data.get("sort_order", 0)), + updated_by=updated_by, + ) + + +class SensitiveWordRepository(SensitiveWordStore): + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._sf = session_factory + + async def list_words(self) -> list[dict[str, Any]]: + stmt = select(SensitiveWordRow).order_by(SensitiveWordRow.sort_order.asc(), SensitiveWordRow.word.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]]: + # De-dupe by word (last wins) so a malformed payload can't violate the PK. + by_word: dict[str, dict[str, Any]] = {} + for r in rows: + word = r.get("word") + if word not in (None, ""): + by_word[str(word)] = r + async with self._sf() as session: + await session.execute(delete(SensitiveWordRow)) + new_rows = [_row_from(r, updated_by) for r in by_word.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]