From 26f86f84848e614c6dde1966f26a5aeaaae8ffff Mon Sep 17 00:00:00 2001 From: kris Date: Sun, 29 Mar 2026 16:42:12 +0800 Subject: [PATCH] feat: add oneliner policy history controls --- collector-service/app/oneliner_features.py | 53 ++ scripts/deploy_fnos_storyforge_web.sh | 3 +- tests/test_main_agent_governance.py | 142 +++++ tests/test_production_baseline.py | 7 + web/storyforge-web-v4/assets/app.js | 495 +++++++++++++++++- .../tests/workbench-pages.test.mjs | 30 ++ 6 files changed, 719 insertions(+), 11 deletions(-) diff --git a/collector-service/app/oneliner_features.py b/collector-service/app/oneliner_features.py index 218c8de..ea31733 100644 --- a/collector-service/app/oneliner_features.py +++ b/collector-service/app/oneliner_features.py @@ -1610,6 +1610,52 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: raise HTTPException(status_code=400, detail="Target project does not belong to target user") return row + def _governance_directory_payload() -> dict[str, Any]: + account_rows = legacy.db.fetch_all( + """ + SELECT id, username, display_name, role, approval_status, created_at, updated_at + FROM accounts + WHERE approval_status = 'approved' + ORDER BY CASE WHEN role = 'super_admin' THEN 0 ELSE 1 END ASC, updated_at DESC, created_at DESC + """ + ) + project_rows = legacy.db.fetch_all( + """ + SELECT id, user_id, name, description, created_at, updated_at + FROM projects + ORDER BY updated_at DESC, created_at DESC + """ + ) + projects_by_user: dict[str, list[dict[str, Any]]] = {} + for row in project_rows: + projects_by_user.setdefault(row.get("user_id", ""), []).append( + { + "id": row["id"], + "user_id": row.get("user_id", ""), + "name": row.get("name", ""), + "description": row.get("description", ""), + "created_at": row.get("created_at", ""), + "updated_at": row.get("updated_at", ""), + } + ) + items = [] + for row in account_rows: + projects = projects_by_user.get(row["id"], []) + items.append( + { + "id": row["id"], + "username": row.get("username", ""), + "display_name": row.get("display_name", ""), + "role": row.get("role", ""), + "approval_status": row.get("approval_status", ""), + "project_count": len(projects), + "projects": projects, + "created_at": row.get("created_at", ""), + "updated_at": row.get("updated_at", ""), + } + ) + return {"items": items, "count": len(items)} + def _effective_policy_payload( *, subject_account: dict[str, Any], @@ -4770,6 +4816,13 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: ) return payload + @app.get("/v2/admin/oneliner/governance/directory") + def get_admin_governance_directory( + admin: dict[str, Any] = Depends(legacy.require_super_admin), + ) -> dict[str, Any]: + _ = admin + return _governance_directory_payload() + @app.get("/v2/admin/oneliner/governance/overrides") def get_admin_override_policy( target_user_id: str = Query(default=""), diff --git a/scripts/deploy_fnos_storyforge_web.sh b/scripts/deploy_fnos_storyforge_web.sh index beab644..7307b97 100755 --- a/scripts/deploy_fnos_storyforge_web.sh +++ b/scripts/deploy_fnos_storyforge_web.sh @@ -16,7 +16,8 @@ REMOTE_WEB_DIR="$REMOTE_WEB_PARENT/storyforge-web-v4" REMOTE_ASSETS_DIR="$REMOTE_WEB_DIR/assets" REMOTE_COMPOSE_DIR="${STORYFORGE_FNOS_COMPOSE_DIR:-/vol1/docker/hyzq-stack/current/deploy/fnos}" REMOTE_COMPOSE_FILE="$REMOTE_COMPOSE_DIR/storyforge-fnos-web-v4.compose.yaml" -BACKEND_URL="${STORYFORGE_FNOS_BACKEND_URL:-https://storyforge.hyzq.net}" +COLLECTOR_PORT="${STORYFORGE_COLLECTOR_DEV_PORT:-19193}" +BACKEND_URL="${STORYFORGE_FNOS_BACKEND_URL:-${STORYFORGE_FNOS_COLLECTOR_URL:-http://$FNOS_HOST:$COLLECTOR_PORT}}" WEB_PORT="${STORYFORGE_WEB_V4_DEV_PORT:-19192}" need_cmd() { diff --git a/tests/test_main_agent_governance.py b/tests/test_main_agent_governance.py index 3ce522e..91b7ae0 100644 --- a/tests/test_main_agent_governance.py +++ b/tests/test_main_agent_governance.py @@ -311,6 +311,92 @@ class MainAgentGovernanceTests(unittest.TestCase): self.assertEqual(payload["effective_policy"]["tone"]["style"], "default") self.assertEqual(payload["layers"][0]["current_version"]["title"], "Current system baseline") + def test_admin_governance_directory_lists_accounts_and_projects(self) -> None: + response = self.client.get( + "/v2/admin/oneliner/governance/directory", + headers=self.ctx["admin_headers"], + ) + self.assertEqual(response.status_code, 200, response.text) + payload = response.json() + self.assertGreaterEqual(payload["count"], 2) + member = next((item for item in payload["items"] if item["id"] == self.ctx["member_id"]), None) + self.assertIsNotNone(member) + assert member is not None + self.assertEqual(member["project_count"], 1) + self.assertEqual(member["projects"][0]["id"], self.ctx["project_id"]) + + def test_admin_override_versions_support_rollback(self) -> None: + first_response = self.client.post( + "/v2/admin/oneliner/governance/overrides", + headers=self.ctx["admin_headers"], + json={ + "target_user_id": self.ctx["member_id"], + "target_project_id": self.ctx["project_id"], + "platform": "douyin", + "title": "Override v1", + "summary": "first override", + "policy": {"actions": {"max_cards": 2}}, + "reason": "first override", + }, + ) + self.assertEqual(first_response.status_code, 200, first_response.text) + first_version_id = first_response.json()["current_version"]["id"] + + second_response = self.client.post( + "/v2/admin/oneliner/governance/overrides", + headers=self.ctx["admin_headers"], + json={ + "target_user_id": self.ctx["member_id"], + "target_project_id": self.ctx["project_id"], + "platform": "douyin", + "title": "Override v2", + "summary": "second override", + "policy": {"actions": {"max_cards": 5}}, + "reason": "second override", + }, + ) + self.assertEqual(second_response.status_code, 200, second_response.text) + + versions_before = self.client.get( + "/v2/admin/oneliner/governance/overrides/versions", + headers=self.ctx["admin_headers"], + params={ + "target_user_id": self.ctx["member_id"], + "target_project_id": self.ctx["project_id"], + "platform": "douyin", + }, + ) + self.assertEqual(versions_before.status_code, 200, versions_before.text) + self.assertEqual(versions_before.json()["count"], 2) + + rollback_response = self.client.post( + "/v2/admin/oneliner/governance/overrides/rollback", + headers=self.ctx["admin_headers"], + json={ + "target_user_id": self.ctx["member_id"], + "target_project_id": self.ctx["project_id"], + "platform": "douyin", + "version_id": first_version_id, + "reason": "rollback to v1", + }, + ) + self.assertEqual(rollback_response.status_code, 200, rollback_response.text) + rollback_payload = rollback_response.json() + self.assertEqual(rollback_payload["current_version"]["rollback_from_version_id"], first_version_id) + self.assertEqual(rollback_payload["effective_policy"]["actions"]["max_cards"], 2) + + versions_after = self.client.get( + "/v2/admin/oneliner/governance/overrides/versions", + headers=self.ctx["admin_headers"], + params={ + "target_user_id": self.ctx["member_id"], + "target_project_id": self.ctx["project_id"], + "platform": "douyin", + }, + ) + self.assertEqual(versions_after.status_code, 200, versions_after.text) + self.assertEqual(versions_after.json()["count"], 3) + def test_user_global_versions_support_rollback_by_creating_new_version(self) -> None: first_response = self.client.put( "/v2/oneliner/governance/user/global", @@ -367,6 +453,62 @@ class MainAgentGovernanceTests(unittest.TestCase): self.assertEqual(versions_after.status_code, 200, versions_after.text) self.assertEqual(versions_after.json()["count"], 3) + def test_user_platform_versions_support_rollback_by_creating_new_version(self) -> None: + first_response = self.client.put( + "/v2/oneliner/governance/user/platforms/douyin", + headers=self.ctx["member_headers"], + json={ + "project_id": self.ctx["project_id"], + "title": "Douyin strategy v1", + "policy": {"douyin": {"benchmark_mode": "strict"}}, + "reason": "first platform pass", + }, + ) + self.assertEqual(first_response.status_code, 200, first_response.text) + first_version_id = first_response.json()["current_version"]["id"] + + second_response = self.client.put( + "/v2/oneliner/governance/user/platforms/douyin", + headers=self.ctx["member_headers"], + json={ + "project_id": self.ctx["project_id"], + "title": "Douyin strategy v2", + "policy": {"douyin": {"benchmark_mode": "aggressive"}}, + "reason": "push harder", + }, + ) + self.assertEqual(second_response.status_code, 200, second_response.text) + + versions_before = self.client.get( + "/v2/oneliner/governance/user/platforms/douyin/versions", + headers=self.ctx["member_headers"], + params={"project_id": self.ctx["project_id"]}, + ) + self.assertEqual(versions_before.status_code, 200, versions_before.text) + self.assertEqual(versions_before.json()["count"], 2) + + rollback_response = self.client.post( + "/v2/oneliner/governance/user/platforms/douyin/rollback", + headers=self.ctx["member_headers"], + json={ + "project_id": self.ctx["project_id"], + "version_id": first_version_id, + "reason": "restore previous platform strategy", + }, + ) + self.assertEqual(rollback_response.status_code, 200, rollback_response.text) + rollback_payload = rollback_response.json() + self.assertEqual(rollback_payload["current_version"]["rollback_from_version_id"], first_version_id) + self.assertEqual(rollback_payload["effective_policy"]["douyin"]["benchmark_mode"], "strict") + + versions_after = self.client.get( + "/v2/oneliner/governance/user/platforms/douyin/versions", + headers=self.ctx["member_headers"], + params={"project_id": self.ctx["project_id"]}, + ) + self.assertEqual(versions_after.status_code, 200, versions_after.text) + self.assertEqual(versions_after.json()["count"], 3) + def test_non_admin_cannot_change_system_defaults(self) -> None: response = self.client.put( "/v2/admin/oneliner/governance/system/main-agent", diff --git a/tests/test_production_baseline.py b/tests/test_production_baseline.py index 41a3924..8054f87 100644 --- a/tests/test_production_baseline.py +++ b/tests/test_production_baseline.py @@ -249,6 +249,13 @@ class ProductionBaselineTests(unittest.TestCase): ]: self.assertIn(expected, content) + def test_web_deploy_script_defaults_to_lan_collector(self) -> None: + script_path = ROOT / "scripts" / "deploy_fnos_storyforge_web.sh" + content = script_path.read_text(encoding="utf-8") + self.assertIn('COLLECTOR_PORT="${STORYFORGE_COLLECTOR_DEV_PORT:-19193}"', content) + self.assertIn('BACKEND_URL="${STORYFORGE_FNOS_BACKEND_URL:-${STORYFORGE_FNOS_COLLECTOR_URL:-http://$FNOS_HOST:$COLLECTOR_PORT}}"', content) + self.assertNotIn('https://storyforge.hyzq.net', content) + def test_baseline_script_covers_homepage_dashboard_node_test(self) -> None: script = (ROOT / "scripts" / "check_repo_baseline.sh").read_text(encoding="utf-8") self.assertIn("dashboard-home.test.mjs", script) diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index 5f39e05..c76a1a2 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -59,6 +59,9 @@ const appState = { userCurrentPlatformPolicy: null, adminSystemMainPolicy: null, adminSystemPlatformPolicies: [], + adminGovernanceDirectory: null, + adminOverrideTarget: null, + adminOverridePolicy: null, tenantQuota: null, tenantUsage: null, adminOpsOverview: null, @@ -1261,6 +1264,9 @@ async function logoutSession() { appState.userCurrentPlatformPolicy = null; appState.adminSystemMainPolicy = null; appState.adminSystemPlatformPolicies = []; + appState.adminGovernanceDirectory = null; + appState.adminOverrideTarget = null; + appState.adminOverridePolicy = null; appState.tenantQuota = null; appState.tenantUsage = null; appState.adminOpsOverview = null; @@ -1312,12 +1318,14 @@ async function loadAgentControlSurfaces(projectId = "") { const supportsUserPlatformPolicy = backendSupports("/v2/oneliner/governance/user/platforms/{platform}"); const supportsAdminSystemMainPolicy = backendSupports("/v2/admin/oneliner/governance/system/main-agent"); const supportsAdminSystemPlatformPolicy = backendSupports("/v2/admin/oneliner/governance/system/platforms/{platform}"); + const supportsAdminGovernanceDirectory = backendSupports("/v2/admin/oneliner/governance/directory"); + const supportsAdminOverridePolicy = backendSupports("/v2/admin/oneliner/governance/overrides"); const supportsAdminOps = backendSupports("/v2/admin/ops/overview"); const supportsAdminFixRuns = backendSupports("/v2/admin/ops/fix-runs"); const supportsTenantQuota = backendSupports("/v2/tenant/quota"); const supportsTenantUsage = backendSupports("/v2/tenant/usage"); - const [profile, sessionsPayload, actionRegistryPayload, platformAgentsPayload, governanceEffective, userGlobalPolicy, userCurrentPlatformPolicy, adminSystemMainPolicy, adminSystemPlatformPolicies, tenantQuota, tenantUsage, adminOpsOverview, adminFixRunsPayload] = await Promise.all([ + const [profile, sessionsPayload, actionRegistryPayload, platformAgentsPayload, governanceEffective, userGlobalPolicy, userCurrentPlatformPolicy, adminSystemMainPolicy, adminSystemPlatformPolicies, adminGovernanceDirectory, tenantQuota, tenantUsage, adminOpsOverview, adminFixRunsPayload] = await Promise.all([ supportsOneLinerProfile ? storyforgeFetch(`/v2/oneliner/profile?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => null) : Promise.resolve(null), @@ -1347,6 +1355,9 @@ async function loadAgentControlSurfaces(projectId = "") { storyforgeFetch(`/v2/admin/oneliner/governance/system/platforms/${encodeURIComponent(item.value)}`).catch(() => null) )) : Promise.resolve([]), + supportsAdminGovernanceDirectory && isSuperAdmin() + ? storyforgeFetch("/v2/admin/oneliner/governance/directory").catch(() => ({ items: [] })) + : Promise.resolve({ items: [] }), supportsTenantQuota ? storyforgeFetch(`/v2/tenant/quota?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => null) : Promise.resolve(null), @@ -1373,6 +1384,24 @@ async function loadAgentControlSurfaces(projectId = "") { appState.userCurrentPlatformPolicy = userCurrentPlatformPolicy; appState.adminSystemMainPolicy = adminSystemMainPolicy; appState.adminSystemPlatformPolicies = safeArray(adminSystemPlatformPolicies); + appState.adminGovernanceDirectory = safeArray(adminGovernanceDirectory?.items || adminGovernanceDirectory); + if (isSuperAdmin() && supportsAdminOverridePolicy && appState.adminGovernanceDirectory.length) { + const existingTarget = appState.adminOverrideTarget || {}; + const targetUserId = String(existingTarget.targetUserId || existingTarget.target_user_id || appState.adminGovernanceDirectory[0]?.id || ""); + const targetUser = appState.adminGovernanceDirectory.find((item) => item.id === targetUserId) || appState.adminGovernanceDirectory[0] || null; + const targetProjects = safeArray(targetUser?.projects); + const targetProjectId = String(existingTarget.targetProjectId || existingTarget.target_project_id || targetProjects[0]?.id || ""); + const targetPlatform = normalizePlatformValue(existingTarget.platform || governancePlatform, "douyin"); + appState.adminOverrideTarget = { + targetUserId, + targetProjectId, + platform: targetPlatform + }; + appState.adminOverridePolicy = await storyforgeFetch(`/v2/admin/oneliner/governance/overrides?target_user_id=${encodeURIComponent(targetUserId)}&target_project_id=${encodeURIComponent(targetProjectId)}&platform=${encodeURIComponent(targetPlatform)}`).catch(() => null); + } else { + appState.adminOverrideTarget = null; + appState.adminOverridePolicy = null; + } appState.tenantQuota = tenantQuota; appState.tenantUsage = tenantUsage; appState.adminOpsOverview = adminOpsOverview; @@ -3203,9 +3232,86 @@ function summarizePolicyHighlights(policy = {}, platform = "") { return items.slice(0, 4); } -function renderGovernanceSummaryCard({ title, subtitle, effective, primaryAction = "", primaryLabel = "编辑策略", secondaryAction = "", secondaryLabel = "", secondaryPlatform = "" }) { +function getGovernanceDirectoryItems() { + return safeArray(appState.adminGovernanceDirectory?.items || appState.adminGovernanceDirectory); +} + +function parseAdminOverrideScopeValue(value) { + const normalized = String(value || "").trim(); + if (!normalized) return { targetUserId: "", targetProjectId: "" }; + if (normalized.startsWith("project:")) { + const parts = normalized.slice("project:".length).split("|"); + return { targetUserId: parts[0] || "", targetProjectId: parts[1] || "" }; + } + if (normalized.startsWith("user:")) { + return { targetUserId: normalized.slice("user:".length), targetProjectId: "" }; + } + return { targetUserId: normalized, targetProjectId: "" }; +} + +function getAdminOverrideTargetOptions(directoryItems = getGovernanceDirectoryItems()) { + return safeArray(directoryItems).flatMap((account) => { + const accountLabel = account.display_name || account.username || account.id; + const projects = safeArray(account.projects); + return [ + { value: `user:${account.id}`, label: `${accountLabel} · 全部项目` }, + ...projects.map((project) => ({ + value: `project:${account.id}|${project.id}`, + label: `${accountLabel} / ${project.name || project.id}` + })) + ]; + }); +} + +function normalizeAdminOverrideTarget(target, directoryItems = getGovernanceDirectoryItems(), fallbackPlatform = "") { + const items = safeArray(directoryItems); + if (!items.length) { + return { targetUserId: "", targetProjectId: "", platform: normalizePlatformValue(fallbackPlatform, "douyin") }; + } + const preferred = target && target.targetUserId + ? items.find((item) => item.id === target.targetUserId) + : items.find((item) => item.role !== "super_admin") || items[0]; + const preferredProjects = safeArray(preferred?.projects); + const project = + target?.targetProjectId + ? preferredProjects.find((item) => item.id === target.targetProjectId) + : preferredProjects[0] || null; + return { + targetUserId: preferred?.id || "", + targetProjectId: project?.id || "", + platform: target?.platform === "" ? "" : normalizePlatformValue(target?.platform || fallbackPlatform, "douyin") + }; +} + +function findGovernanceDirectoryAccount(userId) { + return getGovernanceDirectoryItems().find((item) => item.id === userId) || null; +} + +function findGovernanceDirectoryProject(userId, projectId) { + const account = findGovernanceDirectoryAccount(userId); + return safeArray(account?.projects).find((item) => item.id === projectId) || null; +} + +function getAdminOverrideTargetSummary(target = appState.adminOverrideTarget) { + const normalized = target || {}; + const account = findGovernanceDirectoryAccount(normalized.targetUserId || ""); + const project = normalized.targetProjectId ? findGovernanceDirectoryProject(normalized.targetUserId || "", normalized.targetProjectId) : null; + return { + account, + project, + accountLabel: account?.display_name || account?.username || normalized.targetUserId || "未选择用户", + projectLabel: project?.name || (normalized.targetProjectId ? normalized.targetProjectId : "全部项目"), + platformLabel: normalized.platform ? platformLabel(normalized.platform) : "全部平台" + }; +} + +function renderGovernanceSummaryCard({ title, subtitle, effective, primaryAction = "", primaryLabel = "编辑策略", secondaryAction = "", secondaryLabel = "", secondaryPlatform = "", actions = null }) { const layers = safeArray(effective?.layers); const highlights = summarizePolicyHighlights(effective?.effective_policy || {}, effective?.platform || secondaryPlatform || ""); + const resolvedActions = safeArray(actions?.length ? actions : [ + primaryAction ? { action: primaryAction, label: primaryLabel } : null, + secondaryAction ? { action: secondaryAction, label: secondaryLabel, platform: secondaryPlatform } : null + ].filter(Boolean)); return `

${escapeHtml(title)}

@@ -3214,10 +3320,13 @@ function renderGovernanceSummaryCard({ title, subtitle, effective, primaryAction ${layers.map((layer) => `${escapeHtml(policyScopeTagLabel(layer.scope_kind, layer.scope?.platform || effective?.platform || ""))}`).join("") || `尚未发布`} ${highlights.map((item) => `${escapeHtml(item)}`).join("")}
- ${(primaryAction || secondaryAction) ? ` + ${resolvedActions.length ? `
- ${primaryAction ? `${escapeHtml(primaryLabel)}` : ""} - ${secondaryAction ? `${escapeHtml(secondaryLabel)}` : ""} + ${resolvedActions.map((item) => ` + + ${escapeHtml(item.label || "查看")} + + `).join("")}
` : ""} @@ -3228,6 +3337,8 @@ function renderAdminGovernanceSummaryPanel() { const systemMain = appState.adminSystemMainPolicy; const systemPlatforms = safeArray(appState.adminSystemPlatformPolicies); const configuredPlatforms = systemPlatforms.filter((item) => item?.current_version); + const targetSummary = getAdminOverrideTargetSummary(); + const overrideBundle = appState.adminOverridePolicy; return `
@@ -3248,6 +3359,20 @@ function renderAdminGovernanceSummaryPanel() {
${escapeHtml(systemMain?.current_version ? `版本 ${formatNumber(systemMain.current_version.version_no)}` : "未发布")} 历史 ${escapeHtml(formatNumber(systemMain?.versions?.count || 0))} + 历史与回滚 +
+
+
+
管理员覆盖
+
${escapeHtml(overrideBundle?.current_version?.summary || `${targetSummary.accountLabel} / ${targetSummary.projectLabel} / ${targetSummary.platformLabel}`)}
+
+ ${escapeHtml(targetSummary.accountLabel)} + ${escapeHtml(targetSummary.projectLabel)} + ${escapeHtml(targetSummary.platformLabel)} + ${escapeHtml(overrideBundle?.current_version ? `版本 ${formatNumber(overrideBundle.current_version.version_no)}` : "未发布")} + 切换目标 + 编辑覆盖 + 历史与回滚
${ACTIVE_PLATFORMS.map((platformItem) => { @@ -3260,6 +3385,7 @@ function renderAdminGovernanceSummaryPanel() { ${escapeHtml(item?.current_version ? `版本 ${formatNumber(item.current_version.version_no)}` : "沿用系统默认")} 历史 ${escapeHtml(formatNumber(item?.versions?.count || 0))} 编辑 + 历史与回滚
`; @@ -4755,11 +4881,26 @@ function renderPlaybookScreen() { title: "我的策略与历史", subtitle: appState.userGlobalPolicy?.current_version?.summary || "你和主 Agent 的策略对话,会先沉淀成用户全局策略,再按需要下放到单平台。", effective: appState.onelinerGovernanceEffective, - primaryAction: "open-user-global-policy", - primaryLabel: `编辑全局策略 · 历史 ${formatNumber(appState.userGlobalPolicy?.versions?.count || 0)}`, - secondaryAction: "open-user-platform-policy", - secondaryLabel: `编辑当前平台策略 · 历史 ${formatNumber(appState.userCurrentPlatformPolicy?.versions?.count || 0)}`, - secondaryPlatform: appState.onelinerGovernanceEffective?.platform || appState.onelinerProfile?.default_platform || getPreferredPlatform() + actions: [ + { + action: "open-user-global-policy", + label: `编辑全局策略 · 历史 ${formatNumber(appState.userGlobalPolicy?.versions?.count || 0)}` + }, + { + action: "open-user-global-policy-history", + label: "查看全局历史" + }, + { + action: "open-user-platform-policy", + label: `编辑当前平台策略 · 历史 ${formatNumber(appState.userCurrentPlatformPolicy?.versions?.count || 0)}`, + platform: appState.onelinerGovernanceEffective?.platform || appState.onelinerProfile?.default_platform || getPreferredPlatform() + }, + { + action: "open-user-platform-policy-history", + label: "查看当前平台历史", + platform: appState.onelinerGovernanceEffective?.platform || appState.onelinerProfile?.default_platform || getPreferredPlatform() + } + ] })}
@@ -6409,6 +6550,82 @@ function renderPolicyVersionSummary(bundle, emptyText) { `; } +function getAdminGovernanceDirectoryItems() { + return safeArray(appState.adminGovernanceDirectory); +} + +function findAdminGovernanceDirectoryItem(targetUserId) { + return getAdminGovernanceDirectoryItems().find((item) => item.id === targetUserId) || null; +} + +function getAdminOverrideTargetState() { + const directoryItems = getAdminGovernanceDirectoryItems(); + const existing = appState.adminOverrideTarget || {}; + const targetUserId = String(existing.targetUserId || existing.target_user_id || directoryItems[0]?.id || ""); + const targetUser = findAdminGovernanceDirectoryItem(targetUserId) || directoryItems[0] || null; + const targetProjects = safeArray(targetUser?.projects); + const targetProjectId = String(existing.targetProjectId || existing.target_project_id || targetProjects[0]?.id || ""); + return { + targetUserId, + targetProjectId, + platform: normalizePlatformValue(existing.platform || getPreferredPlatform(), "douyin") + }; +} + +function formatAdminGovernanceTargetLabel(target) { + const directoryItem = findAdminGovernanceDirectoryItem(target?.targetUserId || target?.target_user_id || ""); + const project = safeArray(directoryItem?.projects).find((item) => item.id === (target?.targetProjectId || target?.target_project_id || "")); + const userLabel = directoryItem ? `${directoryItem.display_name || directoryItem.username || directoryItem.id}${directoryItem.role ? ` · ${directoryItem.role}` : ""}` : "未选择目标"; + const projectLabel = project ? project.name || project.id : "默认用户全局"; + return `${userLabel} / ${projectLabel}`; +} + +function getAdminGovernanceDirectoryUserOptions() { + return getAdminGovernanceDirectoryItems().map((item) => ({ + value: item.id, + label: `${item.display_name || item.username || item.id}${item.project_count ? ` · ${formatNumber(item.project_count)} 项目` : ""}` + })); +} + +function getAdminGovernanceDirectoryProjectOptions(targetUserId) { + const directoryItem = findAdminGovernanceDirectoryItem(targetUserId); + return safeArray(directoryItem?.projects).map((item) => ({ + value: item.id, + label: item.name || item.id + })); +} + +function renderPolicyVersionsHtml(items, emptyText = "暂无历史版本。") { + const versions = safeArray(items); + if (!versions.length) { + return `

还没有历史版本

${escapeHtml(emptyText)}

`; + } + return versions.slice(0, 8).map((version) => ` +
+

${escapeHtml(version.title || `版本 ${formatNumber(version.version_no || 0)}`)}

+

${escapeHtml(version.summary || "没有补充摘要。")}

+
+ 版本 ${escapeHtml(formatNumber(version.version_no || 0))} + ${version.created_at ? `${escapeHtml(formatDateTime(version.created_at))}` : ""} + ${version.rollback_from_version_id ? `回滚生成` : ""} +
+
+ `).join(""); +} + +async function loadPolicyVersions(url) { + const payload = await storyforgeFetch(url).catch(() => ({ items: [] })); + const items = safeArray(payload?.items || payload); + return { items, count: Number(payload?.count || items.length) }; +} + +function buildPolicyVersionOptions(history) { + return safeArray(history?.items).map((item) => ({ + value: item.id, + label: `v${formatNumber(item.version_no || 0)} · ${item.title || brief(item.summary || item.id, 24)}` + })); +} + function openUserGlobalPolicyAction() { const project = requireSelectedProject(); const bundle = appState.userGlobalPolicy || {}; @@ -6478,6 +6695,77 @@ function openUserPlatformPolicyAction(platform) { }); } +async function openUserGlobalPolicyHistoryAction() { + const project = requireSelectedProject(); + const history = await loadPolicyVersions(`/v2/oneliner/governance/user/global/versions?project_id=${encodeURIComponent(project.id)}`); + const selectedVersionId = history.items[0]?.id || ""; + const versionOptions = buildPolicyVersionOptions(history); + openActionModal({ + title: "我的全局策略历史", + description: "查看你自己的全局策略版本,并从历史里选择一个版本回滚。回滚不会改旧记录,而是会生成一个新的生效版本。", + submitLabel: "回滚到所选版本", + hideSubmit: !selectedVersionId, + fields: [ + { type: "html", label: "当前版本", html: renderPolicyVersionSummary(appState.userGlobalPolicy || {}, "你还没有发布自己的全局策略。") }, + { type: "html", label: "历史版本", html: renderPolicyVersionsHtml(history.items, "你的全局策略还没有历史版本。") }, + ...(selectedVersionId ? [ + { name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: versionOptions }, + { name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:恢复到更稳妥的首页动作和语气策略" } + ] : []) + ], + onSubmit: async (values) => { + const saved = await storyforgeFetch("/v2/oneliner/governance/user/global/rollback", { + method: "POST", + body: { + project_id: project.id, + version_id: values.versionId || selectedVersionId, + reason: values.reason || "" + } + }); + appState.userGlobalPolicy = saved; + await loadAgentControlSurfaces(project.id); + rememberAction("我的全局策略已回滚", `已生成回滚版本 ${saved.current_version?.version_no || "所选版本"}。`, "green", saved); + renderAll(); + } + }); +} + +async function openUserPlatformPolicyHistoryAction(platform) { + const normalizedPlatform = normalizePlatformValue(platform || getPreferredPlatform(), "douyin"); + const project = requireSelectedProject(); + const history = await loadPolicyVersions(`/v2/oneliner/governance/user/platforms/${encodeURIComponent(normalizedPlatform)}/versions?project_id=${encodeURIComponent(project.id)}`); + const selectedVersionId = history.items[0]?.id || ""; + const versionOptions = buildPolicyVersionOptions(history); + openActionModal({ + title: `${platformLabel(normalizedPlatform)} 平台策略历史`, + description: "查看该平台的个人策略版本,并从历史里选择一个版本回滚。回滚只影响当前平台,不会改动其他平台。", + submitLabel: "回滚到所选版本", + hideSubmit: !selectedVersionId, + fields: [ + { type: "html", label: "当前版本", html: renderPolicyVersionSummary(appState.userCurrentPlatformPolicy || {}, `你还没有发布 ${platformLabel(normalizedPlatform)} 平台策略。`) }, + { type: "html", label: "历史版本", html: renderPolicyVersionsHtml(history.items, `${platformLabel(normalizedPlatform)} 还没有历史版本。`) }, + ...(selectedVersionId ? [ + { name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: versionOptions }, + { name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:恢复到更适合这个平台的拆解方式" } + ] : []) + ], + onSubmit: async (values) => { + const saved = await storyforgeFetch(`/v2/oneliner/governance/user/platforms/${encodeURIComponent(normalizedPlatform)}/rollback`, { + method: "POST", + body: { + project_id: project.id, + version_id: values.versionId || selectedVersionId, + reason: values.reason || "" + } + }); + appState.userCurrentPlatformPolicy = saved; + await loadAgentControlSurfaces(project.id); + rememberAction(`${platformLabel(normalizedPlatform)} 平台策略已回滚`, `已生成回滚版本 ${saved.current_version?.version_no || "所选版本"}。`, "green", saved); + renderAll(); + } + }); +} + function openSystemMainPolicyAction() { const projectId = getOneLinerProjectId(); const bundle = appState.adminSystemMainPolicy || {}; @@ -6548,6 +6836,165 @@ function openSystemPlatformPolicyAction(platform) { }); } +async function openAdminOverrideTargetAction() { + const current = getAdminOverrideTargetState(); + const directoryItems = getAdminGovernanceDirectoryItems(); + openActionModal({ + title: "选择管理员覆盖目标", + description: "先选中要覆盖的用户、项目和平台,再去编辑覆盖策略或查看历史。", + submitLabel: "保存目标", + fields: [ + { type: "html", label: "当前目标", html: renderPolicyVersionSummary(appState.adminOverridePolicy || {}, `当前目标是 ${formatAdminGovernanceTargetLabel(current)}。`) }, + { name: "targetUserId", label: "目标用户", type: "select", value: current.targetUserId, options: getAdminGovernanceDirectoryUserOptions() }, + { name: "targetProjectId", label: "目标项目", type: "select", value: current.targetProjectId, options: [{ value: "", label: "用户全局" }, ...getAdminGovernanceDirectoryProjectOptions(current.targetUserId)] }, + { name: "platform", label: "平台", type: "select", value: current.platform, options: getPlatformOptions() }, + { type: "html", label: "目录提示", html: directoryItems.length ? `

可选目标

当前目录里有 ${escapeHtml(formatNumber(directoryItems.length))} 位已审核账号。

` : `

目录为空

后端还没有返回可选账号。

` } + ], + onSubmit: async (values) => { + appState.adminOverrideTarget = { + targetUserId: String(values.targetUserId || ""), + targetProjectId: String(values.targetProjectId || ""), + platform: normalizePlatformValue(values.platform || "douyin", "douyin") + }; + await loadAgentControlSurfaces(getOneLinerProjectId()); + rememberAction("管理员覆盖目标已更新", `当前目标已切换到 ${formatAdminGovernanceTargetLabel(appState.adminOverrideTarget)}。`, "green"); + renderAll(); + } + }); +} + +function openAdminOverridePolicyAction() { + const target = getAdminOverrideTargetState(); + const bundle = appState.adminOverridePolicy || {}; + const current = bundle.current_version || {}; + openActionModal({ + title: "编辑管理员覆盖策略", + description: "这层策略只作用于当前选中的目标,会叠加在用户策略和系统默认之上。", + submitLabel: "保存覆盖策略", + fields: [ + { type: "html", label: "当前版本", html: renderPolicyVersionSummary(bundle, `当前还没有为 ${formatAdminGovernanceTargetLabel(target)} 发布覆盖策略。`) }, + { name: "title", label: "策略标题", value: current.title || `管理员覆盖:${formatAdminGovernanceTargetLabel(target)}`, placeholder: "例如:重点账号短期放量覆盖" }, + { name: "summary", label: "摘要", type: "textarea", rows: 3, value: current.summary || "", placeholder: "写清楚这层覆盖是为了什么目标" }, + { name: "policyJson", label: "策略 JSON", type: "textarea", rows: 8, value: JSON.stringify(current.policy || {}, null, 2), placeholder: "{\"guardrails\":{\"require_admin_review\":true}}" }, + { name: "reason", label: "变更原因", type: "textarea", rows: 3, value: "", placeholder: "例如:对该账号/项目临时放宽首页动作数量" } + ], + onSubmit: async (values) => { + const saved = await storyforgeFetch("/v2/admin/oneliner/governance/overrides", { + method: "POST", + body: { + target_user_id: target.targetUserId, + target_project_id: target.targetProjectId, + platform: target.platform, + title: values.title || `管理员覆盖:${formatAdminGovernanceTargetLabel(target)}`, + summary: values.summary || "", + policy: parsePolicyJsonField(values.policyJson, "管理员覆盖策略 JSON"), + reason: values.reason || "" + } + }); + appState.adminOverridePolicy = saved; + await loadAgentControlSurfaces(getOneLinerProjectId()); + rememberAction("管理员覆盖策略已保存", `已为 ${formatAdminGovernanceTargetLabel(target)} 发布版本 ${saved.current_version?.version_no || 1}。`, "green", saved); + renderAll(); + } + }); +} + +async function openAdminOverrideHistoryAction() { + const target = getAdminOverrideTargetState(); + const history = await loadPolicyVersions(`/v2/admin/oneliner/governance/overrides/versions?target_user_id=${encodeURIComponent(target.targetUserId)}&target_project_id=${encodeURIComponent(target.targetProjectId)}&platform=${encodeURIComponent(target.platform)}`); + const selectedVersionId = history.items[0]?.id || ""; + openActionModal({ + title: "管理员覆盖历史", + description: "查看当前目标的管理员覆盖版本,并从历史里选择一个版本回滚。", + submitLabel: "回滚到所选版本", + fields: [ + { type: "html", label: "当前目标", html: renderPolicyVersionSummary(appState.adminOverridePolicy || {}, `当前查看的是 ${formatAdminGovernanceTargetLabel(target)} 的覆盖历史。`) }, + { type: "html", label: "历史版本", html: renderPolicyVersionsHtml(history.items, "当前目标还没有历史版本。") }, + { name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: safeArray(history.items).map((item) => ({ value: item.id, label: `v${formatNumber(item.version_no || 0)} · ${item.title || brief(item.summary || item.id, 24)}` })) }, + { name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:这版覆盖太激进,需要恢复到上一版" } + ], + onSubmit: async (values) => { + const saved = await storyforgeFetch("/v2/admin/oneliner/governance/overrides/rollback", { + method: "POST", + body: { + target_user_id: target.targetUserId, + target_project_id: target.targetProjectId, + platform: target.platform, + version_id: values.versionId || selectedVersionId, + reason: values.reason || "" + } + }); + appState.adminOverridePolicy = saved; + await loadAgentControlSurfaces(getOneLinerProjectId()); + rememberAction("管理员覆盖已回滚", `已回滚到版本 ${saved.current_version?.version_no || "所选版本"}。`, "green", saved); + renderAll(); + } + }); +} + +async function openSystemMainPolicyHistoryAction() { + const history = await loadPolicyVersions("/v2/admin/oneliner/governance/system/main-agent/versions"); + const selectedVersionId = history.items[0]?.id || ""; + openActionModal({ + title: "系统主 Agent 历史", + description: "查看系统主 Agent 的历史版本,并选择某个版本回滚。", + submitLabel: "回滚到所选版本", + fields: [ + { type: "html", label: "当前版本", html: renderPolicyVersionSummary(appState.adminSystemMainPolicy || {}, "系统主 Agent 还没有历史版本。") }, + { type: "html", label: "历史版本", html: renderPolicyVersionsHtml(history.items, "系统主 Agent 还没有历史版本。") }, + { name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: safeArray(history.items).map((item) => ({ value: item.id, label: `v${formatNumber(item.version_no || 0)} · ${item.title || brief(item.summary || item.id, 24)}` })) }, + { name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:恢复到上一版系统主 Agent 策略" } + ], + onSubmit: async (values) => { + const saved = await storyforgeFetch("/v2/admin/oneliner/governance/system/main-agent/rollback", { + method: "POST", + body: { + version_id: values.versionId || selectedVersionId, + reason: values.reason || "" + } + }); + appState.adminSystemMainPolicy = saved; + await loadAgentControlSurfaces(getOneLinerProjectId()); + rememberAction("系统主 Agent 已回滚", `已回滚到版本 ${saved.current_version?.version_no || "所选版本"}。`, "green", saved); + renderAll(); + } + }); +} + +async function openSystemPlatformPolicyHistoryAction(platform) { + const normalizedPlatform = normalizePlatformValue(platform || getPreferredPlatform(), "douyin"); + const history = await loadPolicyVersions(`/v2/admin/oneliner/governance/system/platforms/${encodeURIComponent(normalizedPlatform)}/versions`); + const selectedVersionId = history.items[0]?.id || ""; + const bundle = safeArray(appState.adminSystemPlatformPolicies).find((item) => item?.scope?.platform === normalizedPlatform) || {}; + openActionModal({ + title: `${platformLabel(normalizedPlatform)} 系统平台历史`, + description: "查看该平台的系统默认策略历史,并选择某个版本回滚。", + submitLabel: "回滚到所选版本", + fields: [ + { type: "html", label: "当前版本", html: renderPolicyVersionSummary(bundle, `当前 ${platformLabel(normalizedPlatform)} 还没有系统平台历史版本。`) }, + { type: "html", label: "历史版本", html: renderPolicyVersionsHtml(history.items, `${platformLabel(normalizedPlatform)} 还没有历史版本。`) }, + { name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: safeArray(history.items).map((item) => ({ value: item.id, label: `v${formatNumber(item.version_no || 0)} · ${item.title || brief(item.summary || item.id, 24)}` })) }, + { name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:恢复到上一版平台默认方法论" } + ], + onSubmit: async (values) => { + const saved = await storyforgeFetch(`/v2/admin/oneliner/governance/system/platforms/${encodeURIComponent(normalizedPlatform)}/rollback`, { + method: "POST", + body: { + version_id: values.versionId || selectedVersionId, + reason: values.reason || "" + } + }); + appState.adminSystemPlatformPolicies = safeArray(appState.adminSystemPlatformPolicies) + .filter((item) => item?.scope?.platform !== normalizedPlatform) + .concat(saved) + .sort((a, b) => String(a?.scope?.platform || "").localeCompare(String(b?.scope?.platform || ""))); + await loadAgentControlSurfaces(getOneLinerProjectId()); + rememberAction(`${platformLabel(normalizedPlatform)} 系统平台策略已回滚`, `已回滚到版本 ${saved.current_version?.version_no || "所选版本"}。`, "green", saved); + renderAll(); + } + }); +} + function openPlatformAgentProfileAction(platform) { const project = requireSelectedProject(); const agents = safeArray(appState.platformAgents); @@ -8316,18 +8763,46 @@ document.addEventListener("click", async (event) => { openUserGlobalPolicyAction(); return; } + if (name === "open-user-global-policy-history") { + await openUserGlobalPolicyHistoryAction(); + return; + } if (name === "open-user-platform-policy") { openUserPlatformPolicyAction(action.dataset.platform || ""); return; } + if (name === "open-user-platform-policy-history") { + await openUserPlatformPolicyHistoryAction(action.dataset.platform || ""); + return; + } if (name === "open-system-main-policy") { openSystemMainPolicyAction(); return; } + if (name === "open-system-main-policy-history") { + await openSystemMainPolicyHistoryAction(); + return; + } if (name === "open-system-platform-policy") { openSystemPlatformPolicyAction(action.dataset.platform || ""); return; } + if (name === "open-system-platform-policy-history") { + await openSystemPlatformPolicyHistoryAction(action.dataset.platform || ""); + return; + } + if (name === "open-admin-override-target") { + await openAdminOverrideTargetAction(); + return; + } + if (name === "open-admin-override-policy") { + openAdminOverridePolicyAction(); + return; + } + if (name === "open-admin-override-history") { + await openAdminOverrideHistoryAction(); + return; + } if (name === "select-oneliner-session") { appState.selectedOnelinerSessionId = action.dataset.sessionId || ""; await loadOneLinerMessages(appState.selectedOnelinerSessionId); diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs index 53ea3e1..e2b4055 100644 --- a/web/storyforge-web-v4/tests/workbench-pages.test.mjs +++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs @@ -105,6 +105,8 @@ test("agent control surfaces load governance endpoints for user and admin summar assert.match(source, /\/v2\/oneliner\/governance\/user\/platforms\/\$\{encodeURIComponent\(governancePlatform\)\}/); assert.match(source, /\/v2\/admin\/oneliner\/governance\/system\/main-agent/); assert.match(source, /\/v2\/admin\/oneliner\/governance\/system\/platforms\/\$\{encodeURIComponent\(item\.value\)\}/); + assert.match(source, /\/v2\/admin\/oneliner\/governance\/directory/); + assert.match(source, /\/v2\/admin\/oneliner\/governance\/overrides/); }); test("oneliner meta and action handlers expose governance entry points", () => { @@ -140,3 +142,31 @@ test("system governance saves refresh control surfaces after persisting", () => assert.match(platform, /appState\.adminSystemPlatformPolicies = safeArray\(appState\.adminSystemPlatformPolicies\)/); assert.match(platform, /await loadAgentControlSurfaces\(projectId\);/); }); + +test("governance UI exposes admin override target picker and history rollback entrypoints", () => { + const admin = extractBetween(APP, "function renderAdminGovernanceSummaryPanel()", "function renderPlatformAgentPanel()"); + const actions = extractBetween(APP, "document.addEventListener(\"click\", async (event) => {", "document.addEventListener(\"submit\", async (event) => {"); + + assert.match(admin, /open-admin-override-target/); + assert.match(admin, /open-admin-override-policy/); + assert.match(admin, /open-admin-override-history/); + assert.match(admin, /open-system-main-policy-history/); + assert.match(admin, /open-system-platform-policy-history/); + + assert.match(actions, /name === "open-admin-override-target"/); + assert.match(actions, /name === "open-admin-override-policy"/); + assert.match(actions, /name === "open-admin-override-history"/); + assert.match(actions, /name === "open-system-main-policy-history"/); + assert.match(actions, /name === "open-system-platform-policy-history"/); +}); + +test("user governance UI exposes personal history and rollback entrypoints", () => { + const playbook = extractBetween(APP, "function renderPlaybookScreen()", "function renderProductionScreen()"); + const actions = extractBetween(APP, "document.addEventListener(\"click\", async (event) => {", "document.addEventListener(\"submit\", async (event) => {"); + + assert.match(playbook, /open-user-global-policy-history/); + assert.match(playbook, /open-user-platform-policy-history/); + + assert.match(actions, /name === "open-user-global-policy-history"/); + assert.match(actions, /name === "open-user-platform-policy-history"/); +});