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:
吉鑫升andClaude Opus 4.8 committed 2026-06-22 18:05:05 +08:00
1 parent 7487395e44
commit 06b169d7a1
5 files changed
+245 -28

No files matched your search

+12 -1
View File
@@ -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