From 6b480fdbe97d608fe42b9fa5eb2c73e0f9dd016f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Mon, 22 Jun 2026 17:17:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=8A=80=E8=83=BD?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=E7=AE=A1=E7=90=86=E4=B8=8E=E5=BC=80=E6=94=BE?= =?UTF-8?q?=E9=97=AE=E7=AD=94=E6=8E=A5=E5=8F=A3=EF=BC=8C=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=B7=A5=E4=BD=9C=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增「技能地址管理」页面与 /api/skill-addresses 路由(扫描/预览/批量替换技能内地址) - 完善 /api/open/chat 开放问答接口与模型工厂、网关配置 - 调整任务工作区、侧边栏与路由 - 将本地 0622/ 临时目录与 __pycache__/ 加入忽略,不提交 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 6 + CLAUDE.md | 1 + frontend-web/src/components/page-sidebar.tsx | 15 + frontend-web/src/pages/SkillAddressesPage.tsx | 522 +++++++++++++++ frontend-web/src/pages/StrategyRoutes.tsx | 11 + .../api/skill-addresses.ts | 272 ++++++++ .../task-components/TaskFxWorkspace.tsx | 5 +- offline-backend-20260512/backend/CLAUDE.md | 41 +- .../backend/app/gateway/app.py | 4 + .../backend/app/gateway/routers/open_chat.py | 133 +++- .../app/gateway/routers/skill_addresses.py | 631 ++++++++++++++++++ offline-backend-20260512/backend/config.yaml | 6 + .../harness/deerflow/models/factory.py | 8 +- offline-backend-20260512/config.yaml | 6 + test_open_chat_api.py | 128 +++- 15 files changed, 1749 insertions(+), 40 deletions(-) create mode 100644 frontend-web/src/pages/SkillAddressesPage.tsx create mode 100644 frontend-web/src/strategy-components/api/skill-addresses.ts create mode 100644 offline-backend-20260512/backend/app/gateway/routers/skill_addresses.py diff --git a/.gitignore b/.gitignore index de6dbc5f..0b5f0dfe 100644 --- a/.gitignore +++ b/.gitignore @@ -104,3 +104,9 @@ docs/backups # uv lockfile (not tracked per project preference) offline-backend-20260512/backend/uv.lock + +# Local-only scratch directory (not committed) +0622/ + +# Python bytecode cache +__pycache__/ diff --git a/CLAUDE.md b/CLAUDE.md index f2dd1701..16f0ef3b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **轻应用中心 (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. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. +- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。页面 `pages/SkillAddressesPage.tsx`(route `/page/strategy/admin/skill-addresses`,入口「技能地址管理」在 `components/page-sidebar.tsx` 左下角设置下拉,full+collapsed 两种模式):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**撤销上次**(回滚)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。 - **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. - **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 diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index d01d6d6e..92b4eaa8 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -9,6 +9,7 @@ import { FolderOpen, ListTree, MousePointerClick, + Replace, Settings, ShieldAlert, User, @@ -222,6 +223,14 @@ function SecondaryNavSidebar() { 按钮管理 )} + {isAdmin && ( + navigate("/page/strategy/admin/skill-addresses")} + > + + 技能地址管理 + + )} {canManageSensitiveWords() && ( navigate("/page/strategy/admin/sensitive-words")} @@ -508,6 +517,12 @@ function LegacyAccordionSidebar({ 按钮管理 )} + {isAdmin && ( + navigate("/page/strategy/admin/skill-addresses")}> + + 技能地址管理 + + )} {canManageSensitiveWords() && ( navigate("/page/strategy/admin/sensitive-words")}> diff --git a/frontend-web/src/pages/SkillAddressesPage.tsx b/frontend-web/src/pages/SkillAddressesPage.tsx new file mode 100644 index 00000000..b1cc1642 --- /dev/null +++ b/frontend-web/src/pages/SkillAddressesPage.tsx @@ -0,0 +1,522 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Navigate } from "react-router-dom"; +import { + AlertTriangle, + ChevronDown, + ChevronRight, + Eye, + FileCode, + Globe, + Info, + Loader2, + Network, + Replace, + ScanLine, + Search, + Undo2, + Wand2, +} from "lucide-react"; +import { toast } from "sonner"; + +import { getStoredAuth } from "@/core/auth"; +import { + type AddressGroup, + type AddressKind, + type ApplyProgress, + type PreviewResult, + type Replacement, + type ScanProgress, + type ScanResult, + applyReplacementsStream, + previewReplacements, + rollbackBackup, + scanAddressesStream, + useSkillAddressBackups, +} from "@/strategy-components/api/skill-addresses"; + +const ALL_KINDS: AddressKind[] = ["url", "ip"]; + +function ProgressBar({ done, total, label }: { done: number; total: number; label: string }) { + const pct = total > 0 ? Math.round((done / total) * 100) : 0; + return ( +
+
+ + + {label} + + + {done} / {total}({pct}%) + +
+
+
+
+
+ ); +} + +function KindBadge({ kind }: { kind: AddressKind }) { + const isUrl = kind === "url"; + return ( + + {isUrl ? : } + {isUrl ? "URL" : "IP"} + + ); +} + +export default function SkillAddressesPage() { + const isAdmin = getStoredAuth()?.system_role === "admin"; + + const [kinds, setKinds] = useState(ALL_KINDS); + const [scan, setScan] = useState(null); + const [scanning, setScanning] = useState(false); + const [scanProg, setScanProg] = useState(null); + + const [edits, setEdits] = useState>({}); + const [query, setQuery] = useState(""); + const [expanded, setExpanded] = useState>(new Set()); + + const [preview, setPreview] = useState(null); + const [applying, setApplying] = useState(false); + const [applyProg, setApplyProg] = useState(null); + const [lastBackupId, setLastBackupId] = useState(null); + + const [segFind, setSegFind] = useState(""); + const [segRepl, setSegRepl] = useState(""); + + const { data: backups = [], refetch: refetchBackups } = useSkillAddressBackups(); + + const runScan = useCallback( + async (useKinds: AddressKind[]) => { + setScanning(true); + setScanProg({ done: 0, total: 0 }); + setPreview(null); + try { + const result = await scanAddressesStream({ kinds: useKinds, onProgress: setScanProg }); + setScan(result); + } catch (err) { + toast.error(err instanceof Error ? err.message : "扫描失败"); + } finally { + setScanning(false); + setScanProg(null); + } + }, + [], + ); + + // 首次进入自动扫描。 + useEffect(() => { + if (isAdmin) void runScan(ALL_KINDS); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAdmin]); + + const groups = scan?.groups ?? []; + + const toggleKind = (k: AddressKind) => { + const next = kinds.includes(k) ? kinds.filter((x) => x !== k) : [...kinds, k]; + if (next.length === 0) return; // 至少保留一种 + setKinds(next); + void runScan(next); + }; + + const setEdit = (addr: string, val: string) => setEdits((p) => ({ ...p, [addr]: val })); + + const toggleExpand = (addr: string) => + setExpanded((p) => { + const n = new Set(p); + if (n.has(addr)) n.delete(addr); + else n.add(addr); + return n; + }); + + /** 片段批量套用:把所有地址里出现的「查找片段」整体替换为「替换片段」,填入各行的「替换为」。 */ + const applySegment = () => { + const find = segFind.trim(); + if (!find) { + toast.error("请先填写要查找的片段(如 uat.4.cn)"); + return; + } + let n = 0; + setEdits((prev) => { + const next = { ...prev }; + for (const g of groups) { + if (g.address.includes(find)) { + const replaced = g.address.split(find).join(segRepl); + if (replaced !== g.address) { + next[g.address] = replaced; + n++; + } + } + } + return next; + }); + if (n === 0) toast.info(`没有地址包含片段「${find}」`); + else toast.success(`已为 ${n} 个地址套用替换(请预览后应用)`); + }; + + const buildReplacements = useCallback((): Replacement[] => { + const out: Replacement[] = []; + for (const g of groups) { + const to = (edits[g.address] ?? "").trim(); + if (to && to !== g.address) out.push({ from: g.address, to, kind: g.kind }); + } + return out; + }, [groups, edits]); + + const pendingCount = useMemo(() => buildReplacements().length, [buildReplacements]); + + const runPreview = async () => { + const repls = buildReplacements(); + if (repls.length === 0) { + toast.error("还没有填写任何「替换为」"); + return; + } + try { + const result = await previewReplacements(repls); + setPreview(result); + if (result.total === 0) toast.info("没有任何文件会被改动(请检查填写的替换值)"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "预览失败"); + } + }; + + const runApply = async () => { + if (!preview) return; + const repls = buildReplacements(); + if (repls.length === 0) return; + setApplying(true); + setApplyProg({ done: 0, totalFiles: 0 }); + try { + const result = await applyReplacementsStream(repls, { + expectedTotal: preview.expectedTotal, + backup: true, + onProgress: setApplyProg, + }); + toast.success(`已修改 ${result.changedFiles.length} 个文件,共替换 ${result.replacedTotal} 处`); + setLastBackupId(result.backupId); + setEdits({}); + setPreview(null); + await refetchBackups(); + await runScan(kinds); + } catch (err) { + toast.error(err instanceof Error ? err.message : "应用失败"); + } finally { + setApplying(false); + setApplyProg(null); + } + }; + + const runRollback = async () => { + const id = lastBackupId ?? backups[0]?.backupId; + if (!id) { + toast.error("没有可撤销的备份"); + return; + } + try { + const { restored } = await rollbackBackup(id); + toast.success(`已从备份恢复 ${restored} 个文件`); + setLastBackupId(null); + await refetchBackups(); + await runScan(kinds); + } catch (err) { + toast.error(err instanceof Error ? err.message : "撤销失败"); + } + }; + + const q = query.trim().toLowerCase(); + const visible = useMemo( + () => (!q ? groups : groups.filter((g) => g.address.toLowerCase().includes(q) || g.occurrences.some((o) => o.skill.toLowerCase().includes(q)))), + [groups, q], + ); + + const totalOccurrences = useMemo(() => groups.reduce((s, g) => s + g.count, 0), [groups]); + const canRollback = !!(lastBackupId || backups.length > 0); + + if (!isAdmin) return ; + + return ( +
+
+ {/* 顶部信息卡 */} +
+
+ +
+
+
+
+ + + 技能维护工具 + +

+ 技能地址管理 + + {groups.length} 地址 / {totalOccurrences} 处 + {scan ? ` / ${scan.scannedSkills} 技能` : ""} + +

+
+ +
+ + +
+
+ +

+ + + 扫描运行中的技能(skills/public 与{" "} + skills/custom 下所有 .md / .py),按唯一地址聚合其全部出现位置(跨 .md 与 .py)。填写「替换为」后预览命中再应用;应用前会自动磁盘备份,可一键撤销。替换为精确整串匹配,不会误伤子串。 + +

+
+
+ + {/* 进度条 */} + {scanning && scanProg && ( +
+ +
+ )} + {applying && applyProg && ( +
+ +
+ )} + + {/* 工具栏:类型过滤 + 搜索 + 片段批量套用 */} +
+
+
+ {ALL_KINDS.map((k) => ( + + ))} +
+
+ + 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-blue-500 focus:outline-none focus:ring-4 focus:ring-blue-500/5" + /> +
+
+ + {/* 片段批量套用 */} +
+ + + 片段批量套用 + + setSegFind(e.target.value)} + className="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-3 py-1.5 font-mono text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/10" + /> + → + setSegRepl(e.target.value)} + className="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-3 py-1.5 font-mono text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/10" + /> + +
+
+ + {/* 预览面板 */} + {preview && ( +
+
+
+ 预览:将改动 {preview.total} 处 + ({preview.items.filter((i) => i.count > 0).length} 个地址命中) +
+ +
+ {preview.warnings.length > 0 && ( +
+ {preview.warnings.map((w, i) => ( +
+ + {w} +
+ ))} +
+ )} +
    + {preview.items.map((it) => ( +
  • +
    + + {it.from} + → + {it.to} + {it.count} 处 +
    +
  • + ))} +
+
+ )} + + {/* 地址列表 */} +
+
+ 地址({visible.length}) + {pendingCount > 0 && ( + + )} +
+ + {scanning && !scan ? ( +
扫描中…
+ ) : visible.length === 0 ? ( +
+ {groups.length === 0 ? "未发现任何 URL / IP 地址" : "没有匹配的地址"} +
+ ) : ( +
    + {visible.map((g) => ( + toggleExpand(g.address)} + onChange={(v) => setEdit(g.address, v)} + /> + ))} +
+ )} +
+
+
+ ); +} + +function GroupRow({ + group, + value, + expanded, + onToggle, + onChange, +}: { + group: AddressGroup; + value: string; + expanded: boolean; + onToggle: () => void; + onChange: (v: string) => void; +}) { + const dirty = value.trim() !== "" && value.trim() !== group.address; + return ( +
  • +
    +
    + + + + {group.address} + + {group.count} 处 +
    + onChange(e.target.value)} + className="w-full rounded-lg border border-slate-200 bg-white px-3 py-1.5 font-mono text-sm transition-all focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/10 lg:w-96" + /> +
    + + {expanded && ( +
      + {group.occurrences.map((o, i) => ( +
    • + +
      + {o.skill} + · + + {o.file}:{o.line} + +
      + {o.snippet} +
      +
      +
    • + ))} +
    + )} +
  • + ); +} diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 0bab56b0..44b0883d 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -40,6 +40,7 @@ import LightAppListPage from "./LightAppListPage"; import LightAppManagementPage from "./LightAppManagementPage"; import MenuManagementPage from "./MenuManagementPage"; import SensitiveWordsPage from "./SensitiveWordsPage"; +import SkillAddressesPage from "./SkillAddressesPage"; import TaskButtonsPage from "./TaskButtonsPage"; import LightAppNewNotePage from "./LightAppNewNotePage"; import LightAppNoteListPage from "./LightAppNoteListPage"; @@ -393,6 +394,16 @@ export default function StrategyRoutes() { ) } /> + + ) : ( + + ) + } + /> `${getBackendBaseURL()}/api/skill-addresses`; + +export type AddressKind = "url" | "ip"; + +export interface AddressOccurrence { + skill: string; + category: string; // "public" | "custom" + file: string; // 相对技能根的 POSIX 路径 + line: number; + col: number; + snippet: string; +} + +export interface AddressGroup { + address: string; + kind: AddressKind; + count: number; + occurrences: AddressOccurrence[]; +} + +export interface ScanResult { + groups: AddressGroup[]; + scannedFiles: number; + scannedSkills: number; + kinds: string[]; + scannedAt: string; +} + +export interface Replacement { + from: string; + to: string; + kind?: AddressKind; +} + +export interface PreviewFileHit { + file: string; + line: number; + before: string; + after: string; +} + +export interface PreviewItem { + from: string; + to: string; + kind: AddressKind; + count: number; + files: PreviewFileHit[]; +} + +export interface PreviewResult { + items: PreviewItem[]; + total: number; + warnings: string[]; + expectedTotal: number; +} + +export interface ChangedFile { + file: string; + count: number; +} + +export interface ApplyResult { + changedFiles: ChangedFile[]; + replacedTotal: number; + backupId: string | null; + warnings: string[]; +} + +export interface BackupEntry { + backupId: string; + files: number; + createdAt: string; +} + +/** 扫描进度(逐技能)。 */ +export interface ScanProgress { + done: number; + total: number; + skill?: string; +} + +/** 应用进度(逐文件)。 */ +export interface ApplyProgress { + done: number; + totalFiles: number; + file?: string; +} + +// ── 错误处理 ─────────────────────────────────────────────────────────────── + +async function ensureOk(res: Response, ctx: string): Promise { + if (res.ok) return; + 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} 失败 (${res.status})`; + throw new Error(detail); +} + +async function readJson(res: Response, ctx: string): Promise { + await ensureOk(res, ctx); + return (await res.json()) as T; +} + +// ── 序列化辅助 ───────────────────────────────────────────────────────────── + +function toScanResult(d: any): ScanResult { + return { + groups: (d.groups ?? []) as AddressGroup[], + scannedFiles: d.scanned_files ?? 0, + scannedSkills: d.scanned_skills ?? 0, + kinds: d.kinds ?? [], + scannedAt: d.scanned_at ?? "", + }; +} + +function toPreviewResult(d: any): PreviewResult { + return { + items: (d.items ?? []).map((i: any) => ({ + from: i.from, + to: i.to, + kind: i.kind, + count: i.count ?? 0, + files: i.files ?? [], + })), + total: d.total ?? 0, + warnings: d.warnings ?? [], + expectedTotal: d.expected_total ?? 0, + }; +} + +function toApplyResult(d: any): ApplyResult { + return { + changedFiles: (d.changed_files ?? []).map((c: any) => ({ file: c.file, count: c.count })), + replacedTotal: d.replaced_total ?? 0, + backupId: d.backup_id ?? null, + warnings: d.warnings ?? [], + }; +} + +// ── NDJSON 流式读取 ──────────────────────────────────────────────────────── + +/** 逐行解析 NDJSON 流,对每个事件对象调用 onEvent。 */ +async function readNdjson(res: Response, ctx: string, onEvent: (ev: any) => void): Promise { + await ensureOk(res, ctx); + if (!res.body) throw new Error(`${ctx}:响应无数据流`); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + const flush = (chunk: string) => { + buf += chunk; + let idx: number; + while ((idx = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, idx).trim(); + buf = buf.slice(idx + 1); + if (line) onEvent(JSON.parse(line)); + } + }; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + flush(decoder.decode(value, { stream: true })); + } + const tail = (buf + decoder.decode()).trim(); + if (tail) onEvent(JSON.parse(tail)); +} + +// ── 端点 ─────────────────────────────────────────────────────────────────── + +/** 一次性扫描(兜底,无进度)。 */ +export async function scanAddresses(kinds?: AddressKind[], skill?: string): Promise { + const params = new URLSearchParams(); + if (kinds && kinds.length) params.set("kinds", kinds.join(",")); + if (skill) params.set("skill", skill); + const qs = params.toString(); + const res = await apiFetch(`${BASE()}/scan${qs ? `?${qs}` : ""}`, { method: "GET" }); + return toScanResult(await readJson(res, "扫描")); +} + +/** 流式扫描:逐技能上报进度,返回最终聚合结果。 */ +export async function scanAddressesStream( + opts: { kinds?: AddressKind[]; skill?: string; onProgress?: (p: ScanProgress) => void } = {}, +): Promise { + const params = new URLSearchParams(); + if (opts.kinds && opts.kinds.length) params.set("kinds", opts.kinds.join(",")); + if (opts.skill) params.set("skill", opts.skill); + const qs = params.toString(); + const res = await apiFetch(`${BASE()}/scan/stream${qs ? `?${qs}` : ""}`, { method: "GET" }); + let result: ScanResult | null = null; + await readNdjson(res, "扫描", (ev) => { + if (ev.type === "start") opts.onProgress?.({ done: 0, total: ev.total ?? 0 }); + else if (ev.type === "progress") opts.onProgress?.({ done: ev.done, total: ev.total, skill: ev.skill }); + else if (ev.type === "result") result = toScanResult(ev); + }); + if (!result) throw new Error("扫描未返回结果"); + return result; +} + +/** 干跑预览。 */ +export async function previewReplacements(replacements: Replacement[]): Promise { + const res = await apiFetch(`${BASE()}/preview`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ replacements }), + }); + return toPreviewResult(await readJson(res, "预览")); +} + +/** 流式应用:逐文件上报进度,返回最终结果。`expectedTotal` 做乐观锁。 */ +export async function applyReplacementsStream( + replacements: Replacement[], + opts: { expectedTotal?: number; backup?: boolean; onProgress?: (p: ApplyProgress) => void } = {}, +): Promise { + const body = { + replacements, + expected_total: opts.expectedTotal ?? null, + backup: opts.backup ?? true, + }; + const res = await apiFetch(`${BASE()}/apply/stream`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + let result: ApplyResult | null = null; + await readNdjson(res, "应用", (ev) => { + if (ev.type === "start") opts.onProgress?.({ done: 0, totalFiles: ev.total_files ?? 0 }); + else if (ev.type === "progress") opts.onProgress?.({ done: ev.done, totalFiles: ev.total_files, file: ev.file }); + else if (ev.type === "result") result = toApplyResult(ev); + }); + if (!result) throw new Error("应用未返回结果"); + return result; +} + +export async function listBackups(): Promise { + const res = await apiFetch(`${BASE()}/backups`, { method: "GET" }); + const data = await readJson<{ backups: any[] }>(res, "读取备份"); + return (data.backups ?? []).map((b) => ({ backupId: b.backup_id, files: b.files ?? 0, createdAt: b.created_at ?? "" })); +} + +export async function rollbackBackup(backupId: string): Promise<{ restored: number }> { + const res = await apiFetch(`${BASE()}/rollback/${encodeURIComponent(backupId)}`, { method: "POST" }); + const data = await readJson<{ restored_files: string[] }>(res, "撤销"); + return { restored: (data.restored_files ?? []).length }; +} + +// ── React Query ──────────────────────────────────────────────────────────── + +export const SKILL_ADDRESS_BACKUPS_KEY = "skill-address-backups"; + +export function useSkillAddressBackups() { + return useQuery({ + queryKey: [SKILL_ADDRESS_BACKUPS_KEY], + queryFn: listBackups, + refetchOnWindowFocus: false, + staleTime: 30_000, + }); +} diff --git a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx index 2a3fb8c6..f21ba0b1 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx @@ -343,7 +343,10 @@ function RwfxSqReportGate({ taskId }: { taskId: string }) { 暂不