feat: version oneliner profile configuration
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled

This commit is contained in:
kris
2026-03-31 03:32:51 +08:00
parent 1f3631a648
commit c14e573152
5 changed files with 571 additions and 20 deletions

View File

@@ -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后续可以直接基于当前分支继续收剩余真实能力细节。

View File

@@ -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(

View File

@@ -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)

View File

@@ -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")}
<div class="hero-card mobile-secondary-card">
@@ -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)}`
}
</div>
</div>
@@ -6272,6 +6272,7 @@ function renderPlaybookScreen() {
<span class="tag">会话 ${escapeHtml(formatNumber(safeArray(appState.onelinerSessions).length))}</span>
<span class="tag">平台 Agent ${escapeHtml(formatNumber(safeArray(appState.platformAgents).length))}</span>
<span class="tag clickable-tag" data-action="open-oneliner-profile">编辑配置</span>
<span class="tag clickable-tag" data-action="open-oneliner-profile-history">历史与回滚</span>
</div>
</div>
${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: `<div class="list">${renderPolicyVersionsHtml(history.items, "OneLiner 主配置还没有历史版本。")}</div>`
},
{
type: "html",
label: "最近审计",
html: `<div class="list">${renderPolicyAuditFeed(audits, "还没有 OneLiner 主配置变更记录。")}</div>`
}
],
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;

View File

@@ -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()");