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"/);
+});