feat(skill-addresses): 新增技能地址修改记录,支持单次记录回退
后端:
- 每次「应用」写一份 {id}.manifest.json(记录本次 from→to 替换明细/文件数/处数),
放在备份目录的同级,避免被回退误恢复或计入文件数
- GET /backups 返回每条记录的 replaced_total + replacements;POST /rollback/{id} 回退单条
前端:
- 把「撤销上次」升级为「修改记录」面板:每条显示时间/改了哪些地址/文件数,
带「回退此次」(二次确认);应用后自动展开
- BackupEntry 类型扩展 replacedTotal + replacements
同步更新前后端 CLAUDE.md。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7487395e44
commit
06b169d7a1
5 files changed
+245
-28
No files matched your search
@@ -82,7 +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-<appId>`). 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 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 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/`。
|
||||
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。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/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `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
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Eye,
|
||||
FileCode,
|
||||
Globe,
|
||||
History,
|
||||
Info,
|
||||
Loader2,
|
||||
Network,
|
||||
@@ -90,6 +91,11 @@ export function SkillAddressesPanel() {
|
||||
const [applyProg, setApplyProg] = useState<ApplyProgress | null>(null);
|
||||
const [lastBackupId, setLastBackupId] = useState<string | null>(null);
|
||||
|
||||
// 修改记录(每次「应用」生成一条可单独回退的记录)。
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [confirmRollbackId, setConfirmRollbackId] = useState<string | null>(null);
|
||||
const [rollingBack, setRollingBack] = useState(false);
|
||||
|
||||
const [segFind, setSegFind] = useState("");
|
||||
const [segRepl, setSegRepl] = useState("");
|
||||
|
||||
@@ -205,6 +211,7 @@ export function SkillAddressesPanel() {
|
||||
setLastBackupId(result.backupId);
|
||||
setEdits({});
|
||||
setPreview(null);
|
||||
setShowHistory(true); // 应用后自动展开修改记录,便于查看/回退本次
|
||||
await refetchBackups();
|
||||
await runScan(kinds);
|
||||
} catch (err) {
|
||||
@@ -215,23 +222,29 @@ export function SkillAddressesPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const runRollback = async () => {
|
||||
const id = lastBackupId ?? backups[0]?.backupId;
|
||||
if (!id) {
|
||||
toast.error("没有可撤销的备份");
|
||||
return;
|
||||
}
|
||||
/** 回退单次修改记录:从该次备份恢复文件(不影响其他记录)。 */
|
||||
const rollbackOne = async (backupId: string) => {
|
||||
setRollingBack(true);
|
||||
try {
|
||||
const { restored } = await rollbackBackup(id);
|
||||
toast.success(`已从备份恢复 ${restored} 个文件`);
|
||||
setLastBackupId(null);
|
||||
const { restored } = await rollbackBackup(backupId);
|
||||
toast.success(`已从该记录回退 ${restored} 个文件`);
|
||||
setConfirmRollbackId(null);
|
||||
if (backupId === lastBackupId) setLastBackupId(null);
|
||||
await refetchBackups();
|
||||
await runScan(kinds);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "撤销失败");
|
||||
toast.error(err instanceof Error ? err.message : "回退失败");
|
||||
} finally {
|
||||
setRollingBack(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fmtTime = (iso: string) => {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
};
|
||||
|
||||
// 多关键词检索:空格分隔,每个关键词都要命中(地址或技能名任一包含)才显示。
|
||||
// 这样「uat 4.cn」能同时收窄到既含 uat 又含 4.cn 的地址,应对地址过多的场景。
|
||||
const tokens = useMemo(
|
||||
@@ -247,7 +260,6 @@ export function SkillAddressesPanel() {
|
||||
}, [groups, tokens]);
|
||||
|
||||
const totalOccurrences = useMemo(() => groups.reduce((s, g) => s + g.count, 0), [groups]);
|
||||
const canRollback = !!(lastBackupId || backups.length > 0);
|
||||
|
||||
if (!isAdmin) return <Navigate to="/page/workspace/chats/new" replace />;
|
||||
|
||||
@@ -278,13 +290,16 @@ export function SkillAddressesPanel() {
|
||||
<div className="flex items-center gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={runRollback}
|
||||
disabled={!canRollback || scanning || applying}
|
||||
className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-slate-200 bg-white px-3.5 py-2 text-sm font-semibold text-slate-600 shadow-sm transition-all hover:border-amber-300 hover:bg-amber-50 hover:text-amber-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="从最近一次的磁盘备份恢复"
|
||||
onClick={() => setShowHistory((v) => !v)}
|
||||
className={`inline-flex cursor-pointer items-center gap-2 rounded-xl border px-3.5 py-2 text-sm font-semibold shadow-sm transition-all ${
|
||||
showHistory
|
||||
? "border-amber-300 bg-amber-50 text-amber-700"
|
||||
: "border-slate-200 bg-white text-slate-600 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-700"
|
||||
}`}
|
||||
title="查看每次修改记录,可针对单次回退"
|
||||
>
|
||||
<Undo2 className="size-[15px]" />
|
||||
撤销上次
|
||||
<History className="size-[15px]" />
|
||||
修改记录{backups.length > 0 ? `(${backups.length})` : ""}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -302,7 +317,7 @@ export function SkillAddressesPanel() {
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-slate-400" />
|
||||
<span>
|
||||
扫描运行中的技能(<code className="rounded bg-slate-100 px-1">skills/public</code> 与{" "}
|
||||
<code className="rounded bg-slate-100 px-1">skills/custom</code> 下所有 .md / .py),按<strong>唯一地址</strong>聚合其全部出现位置(跨 .md 与 .py)。填写「替换为」后<strong>预览</strong>命中再<strong>应用</strong>;应用前会自动磁盘备份,可一键<strong>撤销</strong>。替换为精确整串匹配,不会误伤子串。
|
||||
<code className="rounded bg-slate-100 px-1">skills/custom</code> 下所有 .md / .py),按<strong>唯一地址</strong>聚合其全部出现位置(跨 .md 与 .py)。填写「替换为」后<strong>预览</strong>命中再<strong>应用</strong>;每次应用都会自动磁盘备份并生成一条<strong>修改记录</strong>,可在「修改记录」里针对<strong>单次记录回退</strong>。替换为精确整串匹配,不会误伤子串。
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -320,6 +335,101 @@ export function SkillAddressesPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 修改记录:每次应用一条,可针对单次回退 */}
|
||||
{showHistory && (
|
||||
<div className="mb-6 overflow-hidden rounded-2xl border border-amber-200/70 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-amber-100 bg-amber-50/50 px-4 py-3 text-sm font-semibold text-slate-700">
|
||||
<span className="flex items-center gap-2">
|
||||
<History className="size-4 text-amber-600" />
|
||||
修改记录({backups.length})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetchBackups()}
|
||||
className="rounded-lg border border-slate-200 bg-white px-2.5 py-1 text-xs font-medium text-slate-500 transition-colors hover:border-slate-300 hover:text-slate-700"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
{backups.length === 0 ? (
|
||||
<div className="px-4 py-10 text-center text-sm text-slate-400">
|
||||
暂无修改记录。应用一次替换后,这里会生成一条可回退的记录。
|
||||
</div>
|
||||
) : (
|
||||
<ul className="max-h-96 divide-y divide-slate-100 overflow-auto">
|
||||
{backups.map((b) => (
|
||||
<li key={b.backupId} className="px-4 py-3">
|
||||
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
{fmtTime(b.createdAt)}
|
||||
{b.backupId === lastBackupId && (
|
||||
<span className="rounded-md bg-amber-100 px-1.5 py-0.5 text-[11px] font-semibold text-amber-700">最近</span>
|
||||
)}
|
||||
<span className="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-500">
|
||||
{b.files} 文件 / {b.replacedTotal} 处
|
||||
</span>
|
||||
</div>
|
||||
{b.replacements.length > 0 ? (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{b.replacements.slice(0, 4).map((r, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 rounded-md bg-slate-50 px-1.5 py-0.5 font-mono text-[11px] text-slate-500">
|
||||
<span className="line-through">{r.from}</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span className="font-medium text-blue-700">{r.to}</span>
|
||||
</span>
|
||||
))}
|
||||
{b.replacements.length > 4 && (
|
||||
<span className="text-[11px] text-slate-400">等 {b.replacements.length} 项</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 font-mono text-xs text-slate-400">{b.backupId}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{confirmRollbackId === b.backupId ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => rollbackOne(b.backupId)}
|
||||
disabled={rollingBack || scanning || applying}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-amber-500 bg-amber-500 px-3 py-1.5 text-xs font-semibold text-white transition-all hover:bg-amber-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{rollingBack ? <Loader2 className="size-3.5 animate-spin" /> : <Undo2 className="size-3.5" />}
|
||||
确认回退
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmRollbackId(null)}
|
||||
disabled={rollingBack}
|
||||
className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-500 transition-colors hover:border-slate-300 hover:text-slate-700"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmRollbackId(b.backupId)}
|
||||
disabled={scanning || applying}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition-all hover:border-amber-300 hover:bg-amber-50 hover:text-amber-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="把文件恢复到这次修改之前"
|
||||
>
|
||||
<Undo2 className="size-3.5" />
|
||||
回退此次
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 工具栏:类型过滤 + 搜索 + 片段批量套用 */}
|
||||
<div className="mb-6 space-y-4 rounded-2xl border border-slate-200/60 bg-white p-4 shadow-sm md:p-5">
|
||||
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||
|
||||
@@ -80,10 +80,18 @@ export interface ApplyResult {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface BackupReplacement {
|
||||
from: string;
|
||||
to: string;
|
||||
kind: AddressKind;
|
||||
}
|
||||
|
||||
export interface BackupEntry {
|
||||
backupId: string;
|
||||
files: number;
|
||||
createdAt: string;
|
||||
replacedTotal: number;
|
||||
replacements: BackupReplacement[];
|
||||
}
|
||||
|
||||
/** 扫描进度(逐技能)。 */
|
||||
@@ -249,7 +257,13 @@ export async function applyReplacementsStream(
|
||||
export async function listBackups(): Promise<BackupEntry[]> {
|
||||
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 ?? "" }));
|
||||
return (data.backups ?? []).map((b) => ({
|
||||
backupId: b.backup_id,
|
||||
files: b.files ?? 0,
|
||||
createdAt: b.created_at ?? "",
|
||||
replacedTotal: b.replaced_total ?? 0,
|
||||
replacements: (b.replacements ?? []).map((r: any) => ({ from: r.from, to: r.to, kind: r.kind })),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function rollbackBackup(backupId: string): Promise<{ restored: number }> {
|
||||
|
||||
@@ -556,6 +556,14 @@ snippet) and replaced together.
|
||||
`{skills_root}/.address-edits/{timestamp}/` (the `.`-prefixed dir is excluded
|
||||
from scans), then `refresh_skills_system_prompt_cache_async()` so edits take
|
||||
effect immediately. Registered in `app.py` after `sensitive_words.router`.
|
||||
**Per-edit history:** each apply also writes a sibling manifest
|
||||
`{skills_root}/.address-edits/{timestamp}.manifest.json` (best-effort) capturing
|
||||
`{created_at, replaced_total, replacements:[{from,to,kind}], changed_files}`; the
|
||||
sibling-not-child placement keeps it out of both the restore set and the file
|
||||
count. `GET /backups` returns each record enriched with `replaced_total` +
|
||||
`replacements` (from the manifest; falls back to dir mtime when absent), and
|
||||
`POST /rollback/{backup_id}` restores a single record — so the UI shows a full
|
||||
modification history and rolls back any one entry independently.
|
||||
- **Frontend** — the panel is the reusable `SkillAddressesPanel` (exported from
|
||||
`pages/SkillAddressesPage.tsx`; its default export keeps the standalone route
|
||||
`/page/strategy/admin/skill-addresses` as a direct-link fallback). It is
|
||||
@@ -567,7 +575,10 @@ snippet) and replaced together.
|
||||
auto-scan on open with a **progress bar** (扫描逐技能 / 应用逐文件 X/N), URL/IP
|
||||
type filter, search, a「片段批量套用」helper (e.g. all `uat.4.cn` → `prod.4.cn`),
|
||||
per-address「替换为」input with expandable occurrences, inline 预览 panel, 应用,
|
||||
and 撤销上次 (rollback). Client + NDJSON stream parsing in
|
||||
**multi-keyword search** (space-separated, all-must-hit), and a **修改记录**
|
||||
(modification history) panel — each apply is one record (time / which addresses
|
||||
changed from→to / file & replacement counts) with a per-record 回退此次
|
||||
(single-record rollback, with inline confirm). Client + NDJSON stream parsing in
|
||||
`strategy-components/api/skill-addresses.ts`. Scope is the running skills only;
|
||||
the `deploy/minimal/skills/` copy is not touched (sync separately).
|
||||
|
||||
|
||||
@@ -145,10 +145,20 @@ class ApplyResponse(BaseModel):
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
class ReplacementSummary(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
from_: str = Field(serialization_alias="from")
|
||||
to: str
|
||||
kind: str
|
||||
|
||||
|
||||
class BackupEntry(BaseModel):
|
||||
backup_id: str
|
||||
files: int
|
||||
created_at: str
|
||||
replaced_total: int = 0
|
||||
replacements: list[ReplacementSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BackupListResponse(BaseModel):
|
||||
@@ -292,6 +302,51 @@ def _backup_file(root: Path, backup_id: str, path: Path, rel: str) -> None:
|
||||
shutil.copy2(path, dest)
|
||||
|
||||
|
||||
_MANIFEST_SUFFIX = ".manifest.json"
|
||||
|
||||
|
||||
def _manifest_path(root: Path, backup_id: str) -> Path:
|
||||
"""Manifest lives as a *sibling* of the backup dir (``{id}.manifest.json``),
|
||||
not inside it — so it is neither restored back into the skills tree on
|
||||
rollback nor counted as a backed-up file."""
|
||||
|
||||
return root / _BACKUP_DIRNAME / f"{backup_id}{_MANIFEST_SUFFIX}"
|
||||
|
||||
|
||||
def _write_manifest(
|
||||
root: Path,
|
||||
backup_id: str,
|
||||
norm: list[tuple[str, str, str]],
|
||||
changed: list["ChangedFile"],
|
||||
replaced_total: int,
|
||||
created_at: str,
|
||||
) -> None:
|
||||
"""Record what this edit changed so the UI can show a meaningful, per-edit
|
||||
history (原地址 → 新地址, 文件数, 处数) and roll a single record back."""
|
||||
|
||||
data = {
|
||||
"created_at": created_at,
|
||||
"replaced_total": replaced_total,
|
||||
"replacements": [{"from": frm, "to": to, "kind": kind} for frm, to, kind in norm],
|
||||
"changed_files": [{"file": c.file, "count": c.count} for c in changed],
|
||||
}
|
||||
try: # best-effort: a metadata write failure must not fail the apply itself
|
||||
path = _manifest_path(root, backup_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, ensure_ascii=False)
|
||||
except OSError:
|
||||
logger.warning("Failed to write skill-address backup manifest for %s", backup_id, exc_info=True)
|
||||
|
||||
|
||||
def _read_manifest(root: Path, backup_id: str) -> dict | None:
|
||||
try:
|
||||
with open(_manifest_path(root, backup_id), "r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
# ── scan extraction shared by one-shot + stream ───────────────────────────────
|
||||
|
||||
|
||||
@@ -475,7 +530,7 @@ def _new_backup_id() -> str:
|
||||
async def apply_replacements_route(request: Request, body: ApplyRequest) -> ApplyResponse:
|
||||
await _require_admin(request)
|
||||
root = _skills_root()
|
||||
mapping, _norm, warnings = _build_mapping(body.replacements)
|
||||
mapping, norm, warnings = _build_mapping(body.replacements)
|
||||
if not mapping:
|
||||
return ApplyResponse(changed_files=[], replaced_total=0, backup_id=None, warnings=warnings)
|
||||
|
||||
@@ -501,6 +556,9 @@ async def apply_replacements_route(request: Request, body: ApplyRequest) -> Appl
|
||||
changed.append(ChangedFile(file=rel, count=cnt))
|
||||
replaced_total += cnt
|
||||
|
||||
if backup_id:
|
||||
await asyncio.to_thread(_write_manifest, root, backup_id, norm, changed, replaced_total, datetime.now(UTC).isoformat())
|
||||
|
||||
await _refresh_cache_safe()
|
||||
logger.info("Skill address apply: %d files, %d replacements, backup=%s", len(changed), replaced_total, backup_id)
|
||||
return ApplyResponse(changed_files=changed, replaced_total=replaced_total, backup_id=backup_id, warnings=warnings)
|
||||
@@ -518,7 +576,7 @@ async def apply_replacements_stream(request: Request, body: ApplyRequest) -> Str
|
||||
|
||||
await _require_admin(request)
|
||||
root = _skills_root()
|
||||
mapping, _norm, warnings = _build_mapping(body.replacements)
|
||||
mapping, norm, warnings = _build_mapping(body.replacements)
|
||||
|
||||
plan: list[tuple[Path, str, int, str]] = []
|
||||
current_total = 0
|
||||
@@ -554,6 +612,9 @@ async def apply_replacements_stream(request: Request, body: ApplyRequest) -> Str
|
||||
done += 1
|
||||
yield _ndjson({"type": "progress", "done": done, "total_files": len(plan), "file": rel, "count": cnt})
|
||||
|
||||
if backup_id:
|
||||
await asyncio.to_thread(_write_manifest, root, backup_id, norm, changed, replaced_total, datetime.now(UTC).isoformat())
|
||||
|
||||
await _refresh_cache_safe()
|
||||
logger.info("Skill address apply(stream): %d files, %d replacements, backup=%s", len(changed), replaced_total, backup_id)
|
||||
result = ApplyResponse(changed_files=changed, replaced_total=replaced_total, backup_id=backup_id, warnings=warnings)
|
||||
@@ -573,11 +634,32 @@ def _list_backups(root: Path) -> list[BackupEntry]:
|
||||
if not d.is_dir():
|
||||
continue
|
||||
file_count = sum(1 for p in d.rglob("*") if p.is_file())
|
||||
try:
|
||||
created = datetime.fromtimestamp(d.stat().st_mtime, UTC).isoformat()
|
||||
except OSError:
|
||||
created = ""
|
||||
entries.append(BackupEntry(backup_id=d.name, files=file_count, created_at=created))
|
||||
manifest = _read_manifest(root, d.name)
|
||||
created = ""
|
||||
replaced_total = 0
|
||||
replacements: list[ReplacementSummary] = []
|
||||
if manifest:
|
||||
created = str(manifest.get("created_at") or "")
|
||||
replaced_total = int(manifest.get("replaced_total") or 0)
|
||||
replacements = [
|
||||
ReplacementSummary(from_=str(r.get("from", "")), to=str(r.get("to", "")), kind=str(r.get("kind", "")))
|
||||
for r in manifest.get("replacements", [])
|
||||
if isinstance(r, dict)
|
||||
]
|
||||
if not created:
|
||||
try:
|
||||
created = datetime.fromtimestamp(d.stat().st_mtime, UTC).isoformat()
|
||||
except OSError:
|
||||
created = ""
|
||||
entries.append(
|
||||
BackupEntry(
|
||||
backup_id=d.name,
|
||||
files=file_count,
|
||||
created_at=created,
|
||||
replaced_total=replaced_total,
|
||||
replacements=replacements,
|
||||
)
|
||||
)
|
||||
entries.sort(key=lambda e: e.backup_id, reverse=True)
|
||||
return entries
|
||||
|
||||
|
||||
Reference in new issue
Block a user