diff --git a/CHANGELOG.md b/CHANGELOG.md index be222f4..01e4c1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,22 @@ ### NAS 联调发布 - 最新 Web 已重新发布到 fnOS NAS: + +### OneLiner 主配置版本化 + +- `OneLiner 主配置` 现在和策略治理层一样,已经支持版本历史、回滚和审计,不再是直接裸改。 +- 后端新增了 `GET /v2/oneliner/profile/versions`、`GET /v2/oneliner/profile/audits`、`POST /v2/oneliner/profile/rollback`,并让 `GET/PUT /v2/oneliner/profile` 直接返回当前版本、历史数量和最近审计。 +- 前端 `配置 OneLiner` 弹层补了当前版本摘要和变更原因,`Agent` 工作台也新增了 `看配置历史` 与 `历史与回滚` 入口。 +- 回滚会生成新的版本快照并保留审计链,不会直接覆盖旧记录。 + +### OneLiner 配置流回归 + +- 新增主配置版本历史和回滚测试,覆盖: + - 初始化版本种子 + - 连续更新后的历史版本 + - 回滚生成新版本 + - 审计记录包含更新与回滚动作 +- 前端工作台测试也新增了 `OneLiner 主配置历史` 的回滚与审计入口校验。 - Web: `http://192.168.31.188:19192/` - Collector: `http://192.168.31.188:19193/healthz` - 主 Agent 配置业务流的这轮修复已经同步到 Gitea,后续可以直接基于当前分支继续收剩余真实能力细节。 diff --git a/collector-service/app/oneliner_features.py b/collector-service/app/oneliner_features.py index 55a7aae..1c2d401 100644 --- a/collector-service/app/oneliner_features.py +++ b/collector-service/app/oneliner_features.py @@ -17,6 +17,13 @@ class OneLinerProfileRequest(BaseModel): notes: str = "" default_platform: str = "" config: dict[str, Any] = Field(default_factory=dict) + reason: str = "" + + +class OneLinerProfileRollbackRequest(BaseModel): + project_id: str = "" + version_id: str + reason: str = "" class OneLinerSessionCreateRequest(BaseModel): @@ -416,6 +423,43 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: FOREIGN KEY(assistant_id) REFERENCES assistants(id) ON DELETE SET NULL ); + CREATE TABLE IF NOT EXISTS oneliner_profile_versions ( + id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL, + user_id TEXT NOT NULL, + project_id TEXT NOT NULL DEFAULT '', + assistant_id TEXT NOT NULL DEFAULT '', + version_no INTEGER NOT NULL DEFAULT 1, + display_name TEXT NOT NULL DEFAULT 'OneLiner', + long_term_goal TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + default_platform TEXT NOT NULL DEFAULT '', + config_json TEXT NOT NULL DEFAULT '{}', + summary TEXT NOT NULL DEFAULT '', + reason TEXT NOT NULL DEFAULT '', + source_type TEXT NOT NULL DEFAULT 'user', + rollback_from_version_id TEXT NOT NULL DEFAULT '', + actor_user_id TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + UNIQUE(profile_id, version_no), + FOREIGN KEY(profile_id) REFERENCES oneliner_profiles(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE, + FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE SET NULL + ); + + CREATE TABLE IF NOT EXISTS oneliner_profile_audit_logs ( + id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL, + version_id TEXT NOT NULL DEFAULT '', + actor_user_id TEXT NOT NULL DEFAULT '', + action_key TEXT NOT NULL DEFAULT '', + summary TEXT NOT NULL DEFAULT '', + details_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + FOREIGN KEY(profile_id) REFERENCES oneliner_profiles(id) ON DELETE CASCADE, + FOREIGN KEY(version_id) REFERENCES oneliner_profile_versions(id) ON DELETE SET NULL + ); + CREATE TABLE IF NOT EXISTS oneliner_sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, @@ -795,6 +839,231 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: (account["id"], project_id), ) + def _summarize_oneliner_profile(row: dict[str, Any] | None) -> str: + def _limit(text: str, limit: int) -> str: + value = str(text or "").strip() + if len(value) <= limit: + return value + return f"{value[: max(limit - 1, 0)].rstrip()}…" + + data = row or {} + assistant_id = str(data.get("assistant_id") or "").strip() + assistant_row = legacy.db.fetch_one("SELECT * FROM assistants WHERE id = ?", (assistant_id,)) if assistant_id else None + assistant_name = (assistant_row or {}).get("name", "").strip() + parts = [ + f"默认平台 {legacy.platform_label(data.get('default_platform', '') or 'douyin')}", + "已绑定执行 Agent" if assistant_id else "暂未绑定执行 Agent", + ] + if assistant_name: + parts.append(f"执行 Agent 为 {assistant_name}") + long_term_goal = str(data.get("long_term_goal") or "").strip() + if long_term_goal: + parts.append(_limit(long_term_goal, 36)) + notes = str(data.get("notes") or "").strip() + if notes: + parts.append(f"备注 {_limit(notes, 32)}") + return ",".join(parts[:4]) + + def _oneliner_profile_version_payload(row: dict[str, Any] | None) -> dict[str, Any] | None: + if not row: + return None + assistant = None + if row.get("assistant_id"): + assistant_row = legacy.db.fetch_one("SELECT * FROM assistants WHERE id = ?", (row["assistant_id"],)) + if assistant_row: + assistant = legacy.assistant_payload(assistant_row) + return { + "id": row["id"], + "profile_id": row.get("profile_id", ""), + "user_id": row.get("user_id", ""), + "project_id": row.get("project_id", ""), + "assistant_id": row.get("assistant_id", ""), + "version_no": int(row.get("version_no") or 0), + "display_name": row.get("display_name", "OneLiner"), + "long_term_goal": row.get("long_term_goal", ""), + "notes": row.get("notes", ""), + "default_platform": row.get("default_platform", ""), + "config": _parse_json(row.get("config_json"), {}), + "summary": row.get("summary", ""), + "reason": row.get("reason", ""), + "source_type": row.get("source_type", ""), + "rollback_from_version_id": row.get("rollback_from_version_id", ""), + "actor_user_id": row.get("actor_user_id", ""), + "assistant": assistant, + "created_at": row.get("created_at", ""), + } + + def _oneliner_profile_audit_payload(row: dict[str, Any] | None) -> dict[str, Any] | None: + if not row: + return None + version_row = legacy.db.fetch_one("SELECT * FROM oneliner_profile_versions WHERE id = ?", (row.get("version_id", ""),)) if row.get("version_id") else None + return { + "id": row["id"], + "profile_id": row.get("profile_id", ""), + "version_id": row.get("version_id", ""), + "actor_user_id": row.get("actor_user_id", ""), + "action_key": row.get("action_key", ""), + "summary": row.get("summary", ""), + "details": _parse_json(row.get("details_json"), {}), + "version": _oneliner_profile_version_payload(version_row), + "created_at": row.get("created_at", ""), + } + + def _list_oneliner_profile_versions(profile_row: dict[str, Any] | None) -> list[dict[str, Any]]: + if not profile_row: + return [] + rows = legacy.db.fetch_all( + """ + SELECT * FROM oneliner_profile_versions + WHERE profile_id = ? + ORDER BY version_no DESC, created_at DESC + """, + (profile_row["id"],), + ) + return [_oneliner_profile_version_payload(row) for row in rows if row] + + def _list_oneliner_profile_audits(profile_row: dict[str, Any] | None, *, limit: int = 12) -> list[dict[str, Any]]: + if not profile_row: + return [] + rows = legacy.db.fetch_all( + """ + SELECT * FROM oneliner_profile_audit_logs + WHERE profile_id = ? + ORDER BY created_at DESC + LIMIT ? + """, + (profile_row["id"], limit), + ) + return [_oneliner_profile_audit_payload(row) for row in rows if row] + + def _current_oneliner_profile_version_row(profile_row: dict[str, Any] | None) -> dict[str, Any] | None: + if not profile_row: + return None + return legacy.db.fetch_one( + """ + SELECT * FROM oneliner_profile_versions + WHERE profile_id = ? + ORDER BY version_no DESC, created_at DESC + LIMIT 1 + """, + (profile_row["id"],), + ) + + def _log_oneliner_profile_audit( + *, + profile_id: str, + version_id: str, + actor_user_id: str, + action_key: str, + summary: str, + details: dict[str, Any] | None = None, + ) -> dict[str, Any]: + audit_id = make_id("oneliner_profile_audit") + legacy.db.execute( + """ + INSERT INTO oneliner_profile_audit_logs ( + id, profile_id, version_id, actor_user_id, action_key, summary, details_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + audit_id, + profile_id, + version_id, + actor_user_id, + action_key, + summary, + _dump(details or {}), + now(), + ), + ) + row = legacy.db.fetch_one("SELECT * FROM oneliner_profile_audit_logs WHERE id = ?", (audit_id,)) + assert row is not None + return _oneliner_profile_audit_payload(row) + + def _create_oneliner_profile_version( + profile_row: dict[str, Any], + *, + actor_user_id: str, + source_type: str, + reason: str, + rollback_from_version_id: str = "", + ) -> dict[str, Any]: + current = legacy.db.fetch_one( + "SELECT COALESCE(MAX(version_no), 0) AS max_version FROM oneliner_profile_versions WHERE profile_id = ?", + (profile_row["id"],), + ) + version_no = int((current or {}).get("max_version") or 0) + 1 + version_id = make_id("oneliner_profile_ver") + summary = _summarize_oneliner_profile(profile_row) + legacy.db.execute( + """ + INSERT INTO oneliner_profile_versions ( + id, profile_id, user_id, project_id, assistant_id, version_no, + display_name, long_term_goal, notes, default_platform, config_json, + summary, reason, source_type, rollback_from_version_id, actor_user_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + version_id, + profile_row["id"], + profile_row.get("user_id", ""), + profile_row.get("project_id", ""), + profile_row.get("assistant_id", ""), + version_no, + profile_row.get("display_name", "OneLiner"), + profile_row.get("long_term_goal", ""), + profile_row.get("notes", ""), + profile_row.get("default_platform", ""), + profile_row.get("config_json", "{}"), + summary, + reason.strip(), + source_type, + rollback_from_version_id, + actor_user_id, + now(), + ), + ) + row = legacy.db.fetch_one("SELECT * FROM oneliner_profile_versions WHERE id = ?", (version_id,)) + assert row is not None + return _oneliner_profile_version_payload(row) + + def _ensure_oneliner_profile_version_seed(profile_row: dict[str, Any], *, actor_user_id: str = "") -> dict[str, Any]: + current_version = _current_oneliner_profile_version_row(profile_row) + if current_version: + payload = _oneliner_profile_version_payload(current_version) + assert payload is not None + return payload + payload = _create_oneliner_profile_version( + profile_row, + actor_user_id=actor_user_id or profile_row.get("user_id", ""), + source_type="system_seed", + reason="初始化 OneLiner 主配置", + ) + _log_oneliner_profile_audit( + profile_id=profile_row["id"], + version_id=payload["id"], + actor_user_id=actor_user_id or profile_row.get("user_id", ""), + action_key="seed-oneliner-profile", + summary="初始化 OneLiner 主配置版本", + details={"project_id": profile_row.get("project_id", "")}, + ) + return payload + + def _oneliner_profile_bundle(row: dict[str, Any], *, account: dict[str, Any] | None = None) -> dict[str, Any]: + _ensure_oneliner_profile_version_seed(row, actor_user_id=(account or {}).get("id", "")) + payload = _profile_payload(row, account=account) + current_version = _oneliner_profile_version_payload(_current_oneliner_profile_version_row(row)) + versions = _list_oneliner_profile_versions(row) + audits = _list_oneliner_profile_audits(row) + payload.update( + { + "current_version": current_version, + "versions": {"items": versions, "count": len(versions)}, + "audits": {"items": audits, "count": len(audits)}, + } + ) + return payload + def _profile_payload(row: dict[str, Any], *, account: dict[str, Any] | None = None) -> dict[str, Any]: assistant = None if row.get("assistant_id"): @@ -5048,7 +5317,7 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: ) -> dict[str, Any]: project = _resolve_project(account, project_id or None) row = _ensure_oneliner_profile(account, project["id"]) - return _profile_payload(row, account=account) + return _oneliner_profile_bundle(row, account=account) @app.put("/v2/oneliner/profile") def put_oneliner_profile( @@ -5059,25 +5328,128 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: assistant = _resolve_assistant(account, request.assistant_id or None, project["id"]) existing = _ensure_oneliner_profile(account, project["id"]) timestamp = now() - legacy.db.execute( - """ + update_sql = """ UPDATE oneliner_profiles SET assistant_id = ?, display_name = ?, long_term_goal = ?, notes = ?, default_platform = ?, config_json = ?, updated_at = ? WHERE id = ? - """, - ( - (assistant or {}).get("id", ""), - request.display_name.strip() or "OneLiner", - request.long_term_goal.strip(), - request.notes.strip(), - _safe_platform(request.default_platform or existing.get("default_platform") or "douyin"), - _dump(request.config), - timestamp, - existing["id"], - ), + """ + update_params = ( + (assistant or {}).get("id", ""), + request.display_name.strip() or "OneLiner", + request.long_term_goal.strip(), + request.notes.strip(), + _safe_platform(request.default_platform or existing.get("default_platform") or "douyin"), + _dump(request.config), + timestamp, + existing["id"], ) + if (assistant or {}).get("id", ""): + legacy.db.execute(update_sql, update_params) + else: + with legacy.db.session() as conn: + conn.execute("PRAGMA foreign_keys=OFF") + conn.execute(update_sql, update_params) + conn.execute("PRAGMA foreign_keys=ON") row = legacy.db.fetch_one("SELECT * FROM oneliner_profiles WHERE id = ?", (existing["id"],)) - return _profile_payload(row, account=account) + assert row is not None + version = _create_oneliner_profile_version( + row, + actor_user_id=account["id"], + source_type="user_update", + reason=request.reason.strip() or "更新 OneLiner 主配置", + ) + _log_oneliner_profile_audit( + profile_id=row["id"], + version_id=version["id"], + actor_user_id=account["id"], + action_key="update-oneliner-profile", + summary=f"已更新 OneLiner「{row.get('display_name', 'OneLiner')}」主配置", + details={"project_id": project["id"], "rollback_to_version_id": "", "assistant_id": row.get("assistant_id", "")}, + ) + return _oneliner_profile_bundle(row, account=account) + + @app.get("/v2/oneliner/profile/versions") + def list_oneliner_profile_versions( + project_id: str | None = Query(default=None), + account: dict[str, Any] = Depends(legacy.require_approved), + ) -> dict[str, Any]: + project = _resolve_project(account, project_id or None) + row = _ensure_oneliner_profile(account, project["id"]) + _ensure_oneliner_profile_version_seed(row, actor_user_id=account["id"]) + items = _list_oneliner_profile_versions(row) + return {"items": items, "count": len(items), "current_version": items[0] if items else None} + + @app.get("/v2/oneliner/profile/audits") + def list_oneliner_profile_audits( + project_id: str | None = Query(default=None), + limit: int = Query(default=12, ge=1, le=50), + account: dict[str, Any] = Depends(legacy.require_approved), + ) -> dict[str, Any]: + project = _resolve_project(account, project_id or None) + row = _ensure_oneliner_profile(account, project["id"]) + _ensure_oneliner_profile_version_seed(row, actor_user_id=account["id"]) + items = _list_oneliner_profile_audits(row, limit=limit) + return {"items": items, "count": len(items)} + + @app.post("/v2/oneliner/profile/rollback") + def rollback_oneliner_profile( + request: OneLinerProfileRollbackRequest, + account: dict[str, Any] = Depends(legacy.require_approved), + ) -> dict[str, Any]: + project = _resolve_project(account, request.project_id or None) + row = _ensure_oneliner_profile(account, project["id"]) + _ensure_oneliner_profile_version_seed(row, actor_user_id=account["id"]) + target_version = legacy.db.fetch_one( + """ + SELECT * FROM oneliner_profile_versions + WHERE id = ? AND profile_id = ? + """, + (request.version_id, row["id"]), + ) + if not target_version: + raise HTTPException(status_code=404, detail="OneLiner profile version not found") + assistant = _resolve_assistant(account, target_version.get("assistant_id", ""), project["id"]) + timestamp = now() + update_sql = """ + UPDATE oneliner_profiles + SET assistant_id = ?, display_name = ?, long_term_goal = ?, notes = ?, default_platform = ?, config_json = ?, updated_at = ? + WHERE id = ? + """ + update_params = ( + (assistant or {}).get("id", ""), + target_version.get("display_name", "OneLiner"), + target_version.get("long_term_goal", ""), + target_version.get("notes", ""), + _safe_platform(target_version.get("default_platform") or row.get("default_platform") or "douyin"), + target_version.get("config_json", "{}"), + timestamp, + row["id"], + ) + if (assistant or {}).get("id", ""): + legacy.db.execute(update_sql, update_params) + else: + with legacy.db.session() as conn: + conn.execute("PRAGMA foreign_keys=OFF") + conn.execute(update_sql, update_params) + conn.execute("PRAGMA foreign_keys=ON") + updated_row = legacy.db.fetch_one("SELECT * FROM oneliner_profiles WHERE id = ?", (row["id"],)) + assert updated_row is not None + version = _create_oneliner_profile_version( + updated_row, + actor_user_id=account["id"], + source_type="user_rollback", + reason=request.reason.strip() or f"回滚到版本 {target_version.get('version_no') or request.version_id}", + rollback_from_version_id=target_version["id"], + ) + _log_oneliner_profile_audit( + profile_id=updated_row["id"], + version_id=version["id"], + actor_user_id=account["id"], + action_key="rollback-oneliner-profile", + summary=f"已回滚 OneLiner 主配置到版本 {target_version.get('version_no') or request.version_id}", + details={"project_id": project["id"], "rollback_to_version_id": target_version["id"]}, + ) + return _oneliner_profile_bundle(updated_row, account=account) @app.get("/v2/oneliner/sessions") def list_oneliner_sessions( diff --git a/tests/test_main_agent_governance.py b/tests/test_main_agent_governance.py index 90579f6..8fee4d3 100644 --- a/tests/test_main_agent_governance.py +++ b/tests/test_main_agent_governance.py @@ -1185,3 +1185,89 @@ class MainAgentGovernanceTests(unittest.TestCase): }, ) self.assertEqual(response.status_code, 403, response.text) + + def test_oneliner_profile_versions_and_rollback_are_available(self) -> None: + initial = self.client.get( + "/v2/oneliner/profile", + headers=self.ctx["member_headers"], + params={"project_id": self.ctx["project_id"]}, + ) + self.assertEqual(initial.status_code, 200, initial.text) + initial_payload = initial.json() + self.assertIn("current_version", initial_payload) + initial_version_id = initial_payload["current_version"]["id"] + + update_one = self.client.put( + "/v2/oneliner/profile", + headers=self.ctx["member_headers"], + json={ + "project_id": self.ctx["project_id"], + "display_name": "增长总控 OneLiner", + "assistant_id": "", + "default_platform": "douyin", + "long_term_goal": "围绕创业内容完成多平台增长", + "notes": "先做抖音与小红书联动", + "config": {"commercial_ready": True}, + "reason": "对齐新的增长目标", + }, + ) + self.assertEqual(update_one.status_code, 200, update_one.text) + update_one_payload = update_one.json() + first_saved_version_id = update_one_payload["current_version"]["id"] + self.assertNotEqual(first_saved_version_id, initial_version_id) + + update_two = self.client.put( + "/v2/oneliner/profile", + headers=self.ctx["member_headers"], + json={ + "project_id": self.ctx["project_id"], + "display_name": "增长总控 OneLiner", + "assistant_id": "", + "default_platform": "xiaohongshu", + "long_term_goal": "先把小红书对标拆解做深", + "notes": "首页动作只保留一条主动作", + "config": {"commercial_ready": True, "tenant_isolation_required": True}, + "reason": "阶段性切到小红书主战场", + }, + ) + self.assertEqual(update_two.status_code, 200, update_two.text) + second_payload = update_two.json() + second_version_id = second_payload["current_version"]["id"] + self.assertNotEqual(second_version_id, first_saved_version_id) + self.assertEqual(second_payload["default_platform"], "xiaohongshu") + + versions_response = self.client.get( + "/v2/oneliner/profile/versions", + headers=self.ctx["member_headers"], + params={"project_id": self.ctx["project_id"]}, + ) + self.assertEqual(versions_response.status_code, 200, versions_response.text) + versions_payload = versions_response.json() + self.assertGreaterEqual(versions_payload["count"], 3) + + rollback_response = self.client.post( + "/v2/oneliner/profile/rollback", + headers=self.ctx["member_headers"], + json={ + "project_id": self.ctx["project_id"], + "version_id": first_saved_version_id, + "reason": "回到抖音主战场配置", + }, + ) + self.assertEqual(rollback_response.status_code, 200, rollback_response.text) + rollback_payload = rollback_response.json() + self.assertEqual(rollback_payload["default_platform"], "douyin") + self.assertEqual(rollback_payload["long_term_goal"], "围绕创业内容完成多平台增长") + self.assertEqual(rollback_payload["current_version"]["rollback_from_version_id"], first_saved_version_id) + + audits_response = self.client.get( + "/v2/oneliner/profile/audits", + headers=self.ctx["member_headers"], + params={"project_id": self.ctx["project_id"]}, + ) + self.assertEqual(audits_response.status_code, 200, audits_response.text) + audits_payload = audits_response.json() + self.assertGreaterEqual(audits_payload["count"], 3) + action_keys = [item["action_key"] for item in audits_payload["items"]] + self.assertIn("update-oneliner-profile", action_keys) + self.assertIn("rollback-oneliner-profile", action_keys) diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index 214174b..60ca00a 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -6201,7 +6201,7 @@ function renderPlaybookScreen() { return screenShell( "Agent", "这里接真实 Agent 列表,当前已经支持切换和编辑 Agent。", - `${button("配置 OneLiner", "open-oneliner-profile")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: playbookHandoffAttrs })} ${button("设主模型", "open-preferred-model")} ${button("新建 Agent", "open-create-assistant")} ${button("去生产", "goto-production", "primary")}`, + `${button("配置 OneLiner", "open-oneliner-profile")} ${button("看配置历史", "open-oneliner-profile-history", "secondary")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: playbookHandoffAttrs })} ${button("设主模型", "open-preferred-model")} ${button("新建 Agent", "open-create-assistant")} ${button("去生产", "goto-production", "primary")}`, ` ${renderMainAgentLandingNotice("playbook")}
@@ -6239,7 +6239,7 @@ function renderPlaybookScreen() { ? `${actionTag("看平台 Agent", "select-page-tab", `data-page-tab-key="playbookDetailTab" data-page-tab-value="platform_agents"`)} ${actionTag("交给主 Agent", "handoff-to-main-agent", playbookHandoffAttrs)}` : activeTab === "models" ? `${actionTag("设主模型", "open-preferred-model")} ${actionTag("回工作区", "select-page-tab", `data-page-tab-key="playbookDetailTab" data-page-tab-value="workspace"`)}` - : `${actionTag("配置 OneLiner", "open-oneliner-profile")} ${actionTag(currentAssistant ? "去生产" : "新建 Agent", currentAssistant ? "goto-production" : "open-create-assistant")} ${actionTag("交给主 Agent", "handoff-to-main-agent", playbookHandoffAttrs)}` + : `${actionTag("配置 OneLiner", "open-oneliner-profile")} ${actionTag("看配置历史", "open-oneliner-profile-history")} ${actionTag(currentAssistant ? "去生产" : "新建 Agent", currentAssistant ? "goto-production" : "open-create-assistant")} ${actionTag("交给主 Agent", "handoff-to-main-agent", playbookHandoffAttrs)}` }
@@ -6272,6 +6272,7 @@ function renderPlaybookScreen() { 会话 ${escapeHtml(formatNumber(safeArray(appState.onelinerSessions).length))} 平台 Agent ${escapeHtml(formatNumber(safeArray(appState.platformAgents).length))} 编辑配置 + 历史与回滚 ${activeAdminOverrideNotice?.title ? ` @@ -8470,11 +8471,13 @@ function openOneLinerProfileAction() { description: "绑定总控主 Agent 的默认平台、长期目标和默认执行 Agent。", submitLabel: "保存配置", fields: [ + { type: "html", label: "当前版本", html: renderPolicyVersionSummary(profile, "你还没有发布过 OneLiner 主配置,当前会沿用默认初始化版本。") }, { name: "assistantId", label: "默认执行 Agent", type: "select", value: profile.assistant_id || getSelectedAssistant()?.id || assistants[0]?.value || "", options: [{ value: "", label: "先不绑定" }, ...assistants] }, { name: "displayName", label: "显示名", value: profile.display_name || "OneLiner", placeholder: "例如:增长总控 OneLiner" }, { name: "defaultPlatform", label: "默认平台", type: "select", value: normalizePlatformValue(profile.default_platform || getPreferredPlatform(), "douyin"), options: getPlatformOptions() }, { name: "longTermGoal", label: "长期目标", type: "textarea", rows: 4, value: profile.long_term_goal || "", placeholder: "例如:围绕创业 IP 做跨平台增长与成交转化" }, - { name: "notes", label: "补充说明", type: "textarea", rows: 4, value: profile.notes || "", placeholder: "例如:前端没产品化的需求先由 OneLiner 承接,不允许直接改核心代码" } + { name: "notes", label: "补充说明", type: "textarea", rows: 4, value: profile.notes || "", placeholder: "例如:前端没产品化的需求先由 OneLiner 承接,不允许直接改核心代码" }, + { name: "reason", label: "变更原因", type: "textarea", rows: 3, value: "", placeholder: "例如:本轮主 Agent 默认改为围绕抖音增长执行" } ], onSubmit: async (values) => { const saved = await storyforgeFetch("/v2/oneliner/profile", { @@ -8486,6 +8489,7 @@ function openOneLinerProfileAction() { default_platform: values.defaultPlatform || "douyin", long_term_goal: values.longTermGoal || "", notes: values.notes || "", + reason: values.reason || "", config: { chat_only_for_unreleased_ui: true, commercial_ready: true, @@ -8495,7 +8499,60 @@ function openOneLinerProfileAction() { }); appState.onelinerProfile = saved; await loadAgentControlSurfaces(project.id); - rememberAction("OneLiner 已保存", `已更新 OneLiner「${saved.display_name || "OneLiner"}」配置。`, "green", saved); + rememberAction("OneLiner 已保存", `已更新 OneLiner「${saved.display_name || "OneLiner"}」配置,当前版本 ${saved.current_version?.version_no || 1}。`, "green", saved); + renderAll(); + } + }); +} + +async function openOneLinerProfileHistoryAction() { + const project = requireSelectedProject(); + const history = await loadPolicyVersions(`/v2/oneliner/profile/versions?project_id=${encodeURIComponent(project.id)}`); + const auditsPayload = await storyforgeFetch(`/v2/oneliner/profile/audits?project_id=${encodeURIComponent(project.id)}`).catch(() => ({ items: [] })); + const audits = safeArray(auditsPayload?.items || auditsPayload); + const selectedVersionId = history.items[0]?.id || ""; + openActionModal({ + title: "OneLiner 主配置历史", + description: "回看主 Agent 核心配置的历史版本、变更原因和回滚记录。", + submitLabel: "回滚到这个版本", + hideSubmit: !selectedVersionId, + fields: [ + { + type: "html", + label: "当前配置状态", + html: renderPolicyVersionSummary(appState.onelinerProfile || {}, "当前项目的 OneLiner 还没有历史版本。") + }, + ...( + selectedVersionId + ? [ + { name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: buildPolicyVersionOptions(history) }, + { name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:回到上一个稳定版本,继续沿用既有执行节奏" } + ] + : [] + ), + { + type: "html", + label: "历史版本", + html: `
${renderPolicyVersionsHtml(history.items, "OneLiner 主配置还没有历史版本。")}
` + }, + { + type: "html", + label: "最近审计", + html: `
${renderPolicyAuditFeed(audits, "还没有 OneLiner 主配置变更记录。")}
` + } + ], + onSubmit: async (values) => { + const saved = await storyforgeFetch("/v2/oneliner/profile/rollback", { + method: "POST", + body: { + project_id: project.id, + version_id: values.versionId, + reason: values.reason || "" + } + }); + appState.onelinerProfile = saved; + await loadAgentControlSurfaces(project.id); + rememberAction("OneLiner 已回滚", `已回滚到版本 ${saved.current_version?.version_no || "指定版本"}。`, "green", saved); renderAll(); } }); @@ -10849,6 +10906,10 @@ document.addEventListener("click", async (event) => { openOneLinerProfileAction(); return; } + if (name === "open-oneliner-profile-history") { + await openOneLinerProfileHistoryAction(); + return; + } if (name === "open-user-global-policy") { openUserGlobalPolicyAction(); return; diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs index 5731e03..873d8bc 100644 --- a/web/storyforge-web-v4/tests/workbench-pages.test.mjs +++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs @@ -593,14 +593,17 @@ test("oneliner runtime remembers completed runs exactly once after hydration", ( }); test("system governance saves refresh control surfaces after persisting", () => { - const profile = extractBetween(APP, "function openOneLinerProfileAction()", "function parsePolicyJsonField(rawValue, label = \"策略 JSON\")"); + const profile = extractBetween(APP, "function openOneLinerProfileAction()", "async function openOneLinerProfileHistoryAction()"); const userGlobal = extractBetween(APP, "function openUserGlobalPolicyAction()", "function openUserPlatformPolicyAction(platform)"); const userPlatform = extractBetween(APP, "function openUserPlatformPolicyAction(platform)", "function openSystemMainPolicyAction()"); const main = extractBetween(APP, "function openSystemMainPolicyAction()", "function openSystemPlatformPolicyAction(platform)"); const platform = extractBetween(APP, "function openSystemPlatformPolicyAction(platform)", "function openPlatformAgentProfileAction(platform)"); + assert.match(profile, /renderPolicyVersionSummary\(profile/); + assert.match(profile, /name: "reason"/); assert.match(profile, /appState\.onelinerProfile = saved;/); assert.match(profile, /await loadAgentControlSurfaces\(project\.id\);/); + assert.match(profile, /saved\.current_version\?\.version_no/); assert.match(userGlobal, /appState\.userGlobalPolicy = saved;/); assert.match(userGlobal, /await loadAgentControlSurfaces\(project\.id\);/); @@ -617,6 +620,19 @@ test("system governance saves refresh control surfaces after persisting", () => assert.match(platform, /await loadAgentControlSurfaces\(projectId\);/); }); +test("oneliner profile history exposes rollback and audit entrypoints", () => { + const profileHistory = extractBetween(APP, "async function openOneLinerProfileHistoryAction()", "function parsePolicyJsonField(rawValue, label = \"策略 JSON\")"); + const playbook = extractBetween(APP, "function renderPlaybookScreen()", "function renderProductionScreen()"); + const actions = extractBetween(APP, "document.addEventListener(\"click\", async (event) => {", "document.addEventListener(\"submit\", async (event) => {"); + + assert.match(profileHistory, /\/v2\/oneliner\/profile\/versions/); + assert.match(profileHistory, /\/v2\/oneliner\/profile\/audits/); + assert.match(profileHistory, /\/v2\/oneliner\/profile\/rollback/); + assert.match(profileHistory, /hideSubmit:\s*!selectedVersionId/); + assert.match(playbook, /open-oneliner-profile-history/); + assert.match(actions, /name === "open-oneliner-profile-history"/); +}); + test("governance UI exposes admin override target picker and history rollback entrypoints", () => { const admin = extractBetween(APP, "function renderAdminGovernanceSummaryPanel()", "function renderPlatformAgentPanel()"); const targetPicker = extractBetween(APP, "async function openAdminOverrideTargetAction()", "function openAdminOverridePolicyAction()");