From 30e37e5ce14f2ff85742f2502dfdf0fea525c87b Mon Sep 17 00:00:00 2001 From: kris Date: Sun, 29 Mar 2026 18:25:39 +0800 Subject: [PATCH] feat: add main agent runtime flow v1 --- collector-service/app/oneliner_features.py | 472 ++++++++++++++++++ scripts/deploy_fnos_storyforge_collector.sh | 8 +- tests/test_main_agent_governance.py | 68 +++ web/storyforge-web-v4/assets/app.js | 282 ++++++++++- web/storyforge-web-v4/assets/styles.css | 6 + .../tests/workbench-pages.test.mjs | 14 + 6 files changed, 845 insertions(+), 5 deletions(-) diff --git a/collector-service/app/oneliner_features.py b/collector-service/app/oneliner_features.py index 3ea6551..9f61389 100644 --- a/collector-service/app/oneliner_features.py +++ b/collector-service/app/oneliner_features.py @@ -158,6 +158,30 @@ class AgentPolicyRollbackRequest(BaseModel): reason: str = "" +class AgentRunCreateRequest(BaseModel): + project_id: str = "" + session_id: str = "" + source_screen: str = "dashboard" + source_action_key: str = "" + title: str = "" + summary: str = "" + intent_key: str = "custom" + platform: str = "" + platform_scope: str = "single_platform" + delivery_mode: str = "hybrid" + scheduling_mode: str = "queued" + plan_request: dict[str, Any] = Field(default_factory=dict) + payload: dict[str, Any] = Field(default_factory=dict) + + +class AgentRunConfirmRequest(BaseModel): + reason: str = "" + + +class AgentRunCancelRequest(BaseModel): + reason: str = "" + + INTENT_ACTIONS: dict[str, list[dict[str, Any]]] = { "create_project": [{"key": "goto-intake", "label": "去我的项目", "kind": "navigate"}], "create_assistant": [{"key": "open-create-assistant", "label": "创建 Agent", "kind": "ui_action"}], @@ -541,6 +565,48 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: created_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS agent_runs ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + project_id TEXT NOT NULL DEFAULT '', + session_id TEXT NOT NULL DEFAULT '', + source_screen TEXT NOT NULL DEFAULT '', + source_action_key TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + summary TEXT NOT NULL DEFAULT '', + intent_key TEXT NOT NULL DEFAULT 'custom', + platform TEXT NOT NULL DEFAULT '', + platform_scope TEXT NOT NULL DEFAULT 'single_platform', + delivery_mode TEXT NOT NULL DEFAULT 'hybrid', + run_status TEXT NOT NULL DEFAULT 'needs_confirmation', + scheduling_mode TEXT NOT NULL DEFAULT 'queued', + active_executor_key TEXT NOT NULL DEFAULT 'main_agent', + plan_json TEXT NOT NULL DEFAULT '{}', + governance_json TEXT NOT NULL DEFAULT '{}', + result_json TEXT NOT NULL DEFAULT '{}', + status_summary TEXT NOT NULL DEFAULT '', + needs_user_input INTEGER NOT NULL DEFAULT 1, + blocked_reason TEXT NOT NULL DEFAULT '', + active_admin_override_notice_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + started_at TEXT NOT NULL DEFAULT '', + finished_at TEXT NOT NULL DEFAULT '', + FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE, + FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE SET NULL, + FOREIGN KEY(session_id) REFERENCES oneliner_sessions(id) ON DELETE SET NULL + ); + + CREATE TABLE IF NOT EXISTS agent_run_events ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL, + event_type TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', + details_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + FOREIGN KEY(run_id) REFERENCES agent_runs(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS admin_ops_incidents ( id TEXT PRIMARY KEY, tenant_user_id TEXT NOT NULL DEFAULT '', @@ -955,6 +1021,61 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: "created_at": row["created_at"], } + def _agent_run_event_payload(row: dict[str, Any]) -> dict[str, Any]: + return { + "id": row["id"], + "run_id": row.get("run_id", ""), + "event_type": row.get("event_type", ""), + "summary": row.get("summary", ""), + "details": _parse_json(row.get("details_json"), {}), + "created_at": row.get("created_at", ""), + } + + def _list_agent_run_events(run_id: str) -> list[dict[str, Any]]: + rows = legacy.db.fetch_all( + """ + SELECT * FROM agent_run_events + WHERE run_id = ? + ORDER BY created_at ASC + """, + (run_id,), + ) + return [_agent_run_event_payload(row) for row in rows] + + def _agent_run_payload(row: dict[str, Any], *, include_events: bool = True) -> dict[str, Any]: + payload = { + "id": row["id"], + "user_id": row.get("user_id", ""), + "project_id": row.get("project_id", ""), + "session_id": row.get("session_id", ""), + "source_screen": row.get("source_screen", ""), + "source_action_key": row.get("source_action_key", ""), + "title": row.get("title", ""), + "summary": row.get("summary", ""), + "intent_key": row.get("intent_key", "custom"), + "platform": row.get("platform", ""), + "platform_label": legacy.platform_label(row.get("platform", "")) if row.get("platform") else "", + "platform_scope": row.get("platform_scope", "single_platform"), + "delivery_mode": row.get("delivery_mode", "hybrid"), + "run_status": row.get("run_status", "needs_confirmation"), + "scheduling_mode": row.get("scheduling_mode", "queued"), + "active_executor_key": row.get("active_executor_key", "main_agent"), + "plan": _parse_json(row.get("plan_json"), {}), + "governance": _parse_json(row.get("governance_json"), {}), + "result": _parse_json(row.get("result_json"), {}), + "status_summary": row.get("status_summary", ""), + "needs_user_input": bool(row.get("needs_user_input")), + "blocked_reason": row.get("blocked_reason", ""), + "active_admin_override_notice": _parse_json(row.get("active_admin_override_notice_json"), {}), + "created_at": row.get("created_at", ""), + "updated_at": row.get("updated_at", ""), + "started_at": row.get("started_at", ""), + "finished_at": row.get("finished_at", ""), + } + if include_events: + payload["events"] = _list_agent_run_events(row["id"]) + return payload + def _incident_payload(row: dict[str, Any]) -> dict[str, Any]: return { "id": row["id"], @@ -2410,6 +2531,157 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: raise HTTPException(status_code=404, detail="OneLiner session not found") return row + def _load_owned_agent_run(run_id: str, account: dict[str, Any]) -> dict[str, Any]: + row = legacy.db.fetch_one( + "SELECT * FROM agent_runs WHERE id = ? AND user_id = ?", + (run_id, account["id"]), + ) + if not row: + raise HTTPException(status_code=404, detail="OneLiner run not found") + return row + + def _normalize_platform_scope(value: str | None) -> str: + normalized = str(value or "").strip().lower() + if normalized == "all_platforms": + return "all_platforms" + return "single_platform" + + def _normalize_delivery_mode(value: str | None) -> str: + normalized = str(value or "").strip().lower() + if normalized in {"ui", "oneliner", "hybrid"}: + return normalized + return "hybrid" + + def _normalize_scheduling_mode(value: str | None) -> str: + normalized = str(value or "").strip().lower() + if normalized == "parallel": + return "parallel" + return "queued" + + def _ensure_run_session( + account: dict[str, Any], + *, + project_id: str, + requested_session_id: str, + title: str, + preferred_platform: str, + ) -> dict[str, Any]: + if requested_session_id: + return _load_owned_session(requested_session_id, account) + latest_row = legacy.db.fetch_one( + """ + SELECT * FROM oneliner_sessions + WHERE user_id = ? AND project_id = ? + ORDER BY updated_at DESC, created_at DESC + LIMIT 1 + """, + (account["id"], project_id), + ) + if latest_row: + return latest_row + profile = _fetch_profile_row(account, project_id) + session_id = make_id("oline") + timestamp = now() + legacy.db.execute( + """ + INSERT INTO oneliner_sessions ( + id, user_id, project_id, profile_id, title, status, preferred_platform, + last_platform, last_intent_key, last_message_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 'active', ?, '', '', ?, ?, ?) + """, + ( + session_id, + account["id"], + project_id, + (profile or {}).get("id"), + title.strip() or "新的 OneLiner 会话", + preferred_platform, + timestamp, + timestamp, + timestamp, + ), + ) + created = legacy.db.fetch_one("SELECT * FROM oneliner_sessions WHERE id = ?", (session_id,)) + assert created is not None + return created + + def _touch_session_for_run(session_id: str, *, platform: str, intent_key: str) -> None: + timestamp = now() + legacy.db.execute( + """ + UPDATE oneliner_sessions + SET last_platform = ?, last_intent_key = ?, last_message_at = ?, updated_at = ? + WHERE id = ? + """, + (platform, intent_key, timestamp, timestamp, session_id), + ) + + def _log_agent_run_event( + run_id: str, + *, + event_type: str, + summary: str, + details: dict[str, Any] | None = None, + ) -> dict[str, Any]: + event_id = make_id("run_evt") + legacy.db.execute( + """ + INSERT INTO agent_run_events (id, run_id, event_type, summary, details_json, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + event_id, + run_id, + event_type, + summary, + _dump(details or {}), + now(), + ), + ) + row = legacy.db.fetch_one("SELECT * FROM agent_run_events WHERE id = ?", (event_id,)) + assert row is not None + return _agent_run_event_payload(row) + + def _agent_run_plan_payload( + request: AgentRunCreateRequest, + *, + governance: dict[str, Any], + platform: str, + platform_scope: str, + ) -> dict[str, Any]: + requested_plan = dict(request.plan_request or {}) + raw_steps = requested_plan.get("steps") or [] + if not isinstance(raw_steps, list): + raw_steps = [raw_steps] + steps = [str(item).strip() for item in raw_steps if str(item).strip()] + if not steps: + steps = ["读取当前项目上下文", "结合治理层生成执行计划", "等待用户确认后执行"] + return { + **requested_plan, + "goal": str(requested_plan.get("goal") or request.title or "主 Agent 任务").strip() or "主 Agent 任务", + "steps": steps, + "intent_key": str(request.intent_key or "custom").strip() or "custom", + "platform": platform, + "platform_scope": platform_scope, + "source_screen": str(request.source_screen or "").strip(), + "source_action_key": str(request.source_action_key or "").strip(), + "summary": str(request.summary or requested_plan.get("summary") or "").strip(), + "requested_delivery_mode": _normalize_delivery_mode(request.delivery_mode), + "active_admin_override_notice": governance.get("active_admin_override_notice") or {}, + } + + def _has_other_active_runs(*, account_id: str, project_id: str, run_id: str) -> bool: + row = legacy.db.fetch_one( + """ + SELECT id FROM agent_runs + WHERE user_id = ? AND project_id = ? AND id != ? AND run_status IN ('queued', 'running', 'blocked') + ORDER BY updated_at DESC + LIMIT 1 + """, + (account_id, project_id, run_id), + ) + return bool(row) + def _deterministic_intent(message: str, platform_hint: str, account: dict[str, Any]) -> dict[str, Any]: text = message.strip() lowered = text.lower() @@ -4207,6 +4479,206 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: **result, } + @app.post("/v2/oneliner/runs") + def create_oneliner_run( + request: AgentRunCreateRequest, + account: dict[str, Any] = Depends(legacy.require_approved), + ) -> dict[str, Any]: + project = _resolve_project(account, request.project_id or None) + normalized_platform = _safe_platform(request.platform, fallback="") if str(request.platform or "").strip() else "" + platform_scope = _normalize_platform_scope(request.platform_scope) + governance = _effective_policy_payload( + subject_account=account, + subject_project_id=project["id"], + platform=normalized_platform, + ) + plan = _agent_run_plan_payload( + request, + governance=governance, + platform=normalized_platform, + platform_scope=platform_scope, + ) + session = _ensure_run_session( + account, + project_id=project["id"], + requested_session_id=request.session_id, + title=request.title.strip() or plan["goal"], + preferred_platform=normalized_platform or "douyin", + ) + _touch_session_for_run(session["id"], platform=normalized_platform, intent_key=plan.get("intent_key", "custom")) + run_id = make_id("oline_run") + timestamp = now() + active_admin_override_notice = governance.get("active_admin_override_notice") or {} + legacy.db.execute( + """ + INSERT INTO agent_runs ( + id, user_id, project_id, session_id, source_screen, source_action_key, title, summary, + intent_key, platform, platform_scope, delivery_mode, run_status, scheduling_mode, + active_executor_key, plan_json, governance_json, result_json, status_summary, + needs_user_input, blocked_reason, active_admin_override_notice_json, + created_at, updated_at, started_at, finished_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'needs_confirmation', ?, 'main_agent', ?, ?, '{}', ?, 1, '', ?, ?, ?, '', '') + """, + ( + run_id, + account["id"], + project["id"], + session["id"], + str(request.source_screen or "").strip(), + str(request.source_action_key or "").strip(), + request.title.strip() or plan["goal"], + request.summary.strip(), + str(request.intent_key or "custom").strip() or "custom", + normalized_platform, + platform_scope, + _normalize_delivery_mode(request.delivery_mode), + _normalize_scheduling_mode(request.scheduling_mode), + _dump(plan), + _dump(governance), + "等待你确认执行计划", + _dump(active_admin_override_notice), + timestamp, + timestamp, + ), + ) + _log_agent_run_event( + run_id, + event_type="run.created", + summary=f"已创建待确认任务:{request.title.strip() or plan['goal']}", + details={ + "run_status": "needs_confirmation", + "source_screen": str(request.source_screen or "").strip(), + "source_action_key": str(request.source_action_key or "").strip(), + "platform": normalized_platform, + "platform_scope": platform_scope, + }, + ) + row = legacy.db.fetch_one("SELECT * FROM agent_runs WHERE id = ?", (run_id,)) + assert row is not None + return _agent_run_payload(row) + + @app.get("/v2/oneliner/runs") + def list_oneliner_runs( + project_id: str | None = Query(default=None), + limit: int = Query(default=20, ge=1, le=100), + account: dict[str, Any] = Depends(legacy.require_approved), + ) -> dict[str, Any]: + project = _resolve_project_for_read(account, project_id or None) if (project_id or "").strip() else _resolve_project_for_read(account, None) + if project: + rows = legacy.db.fetch_all( + """ + SELECT * FROM agent_runs + WHERE user_id = ? AND project_id = ? + ORDER BY created_at DESC + LIMIT ? + """, + (account["id"], project["id"], limit), + ) + else: + rows = legacy.db.fetch_all( + """ + SELECT * FROM agent_runs + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT ? + """, + (account["id"], limit), + ) + items = [_agent_run_payload(row, include_events=False) for row in rows] + return {"items": items, "count": len(items)} + + @app.get("/v2/oneliner/runs/{run_id}") + def get_oneliner_run( + run_id: str, + account: dict[str, Any] = Depends(legacy.require_approved), + ) -> dict[str, Any]: + row = _load_owned_agent_run(run_id, account) + return _agent_run_payload(row) + + @app.get("/v2/oneliner/runs/{run_id}/events") + def list_oneliner_run_events( + run_id: str, + account: dict[str, Any] = Depends(legacy.require_approved), + ) -> dict[str, Any]: + row = _load_owned_agent_run(run_id, account) + items = _list_agent_run_events(row["id"]) + return {"run": _agent_run_payload(row, include_events=False), "items": items, "count": len(items)} + + @app.post("/v2/oneliner/runs/{run_id}/confirm") + def confirm_oneliner_run( + run_id: str, + request: AgentRunConfirmRequest, + account: dict[str, Any] = Depends(legacy.require_approved), + ) -> dict[str, Any]: + row = _load_owned_agent_run(run_id, account) + current_status = str(row.get("run_status") or "needs_confirmation") + if current_status not in {"needs_confirmation", "queued", "running"}: + raise HTTPException(status_code=409, detail="Run can no longer be confirmed") + if current_status == "needs_confirmation": + _log_agent_run_event( + run_id, + event_type="run.confirmed", + summary=request.reason.strip() or "用户已确认执行计划", + details={"reason": request.reason.strip()}, + ) + next_status = "queued" if _has_other_active_runs(account_id=account["id"], project_id=row.get("project_id", ""), run_id=run_id) else "running" + timestamp = now() + started_at = row.get("started_at", "") + event_type = "run.queued" + event_summary = "已进入主 Agent 等待队列" + status_summary = "等待主 Agent 执行" + if next_status == "running": + started_at = timestamp + event_type = "run.started" + event_summary = "主 Agent 已开始执行" + status_summary = "主 Agent 正在执行" + legacy.db.execute( + """ + UPDATE agent_runs + SET run_status = ?, status_summary = ?, needs_user_input = 0, updated_at = ?, started_at = ? + WHERE id = ? + """, + (next_status, status_summary, timestamp, started_at, run_id), + ) + _log_agent_run_event( + run_id, + event_type=event_type, + summary=event_summary, + details={"run_status": next_status}, + ) + updated = legacy.db.fetch_one("SELECT * FROM agent_runs WHERE id = ?", (run_id,)) + assert updated is not None + return _agent_run_payload(updated) + + @app.post("/v2/oneliner/runs/{run_id}/cancel") + def cancel_oneliner_run( + run_id: str, + request: AgentRunCancelRequest, + account: dict[str, Any] = Depends(legacy.require_approved), + ) -> dict[str, Any]: + row = _load_owned_agent_run(run_id, account) + current_status = str(row.get("run_status") or "") + if current_status not in {"needs_confirmation", "queued"}: + raise HTTPException(status_code=409, detail="Run can no longer be cancelled") + timestamp = now() + legacy.db.execute( + """ + UPDATE agent_runs + SET run_status = 'cancelled', status_summary = ?, needs_user_input = 0, updated_at = ?, finished_at = ? + WHERE id = ? + """, + (request.reason.strip() or "任务已取消", timestamp, timestamp, run_id), + ) + _log_agent_run_event( + run_id, + event_type="run.cancelled", + summary=request.reason.strip() or "用户取消了当前任务", + details={"from_status": current_status}, + ) + updated = legacy.db.fetch_one("SELECT * FROM agent_runs WHERE id = ?", (run_id,)) + assert updated is not None + return _agent_run_payload(updated) + @app.get("/v2/oneliner/profile") def get_oneliner_profile( project_id: str | None = Query(default=None), diff --git a/scripts/deploy_fnos_storyforge_collector.sh b/scripts/deploy_fnos_storyforge_collector.sh index dff67a4..095aff3 100755 --- a/scripts/deploy_fnos_storyforge_collector.sh +++ b/scripts/deploy_fnos_storyforge_collector.sh @@ -71,7 +71,13 @@ need_cmd() { need_cmd python3 need_cmd security need_cmd rsync -need_cmd docker + +if [ "$DEPLOY_MODE" = "prebuilt_local" ]; then + if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then + echo "[deploy] local Docker unavailable, fallback to remote_build" >&2 + DEPLOY_MODE="remote_build" + fi +fi shell_quote() { python3 - "$1" <<'PY' diff --git a/tests/test_main_agent_governance.py b/tests/test_main_agent_governance.py index ef4e071..cf97c8e 100644 --- a/tests/test_main_agent_governance.py +++ b/tests/test_main_agent_governance.py @@ -49,6 +49,8 @@ class MainAgentGovernanceTests(unittest.TestCase): def _clear_tables(self) -> None: tables = [ + "agent_run_events", + "agent_runs", "agent_policy_audit_logs", "agent_policy_effectivity", "agent_policy_versions", @@ -180,6 +182,72 @@ class MainAgentGovernanceTests(unittest.TestCase): "member_headers": {"Authorization": f"Bearer {member_token}"}, } + def test_agent_run_creation_snapshots_governance_and_needs_confirmation(self) -> None: + response = self.client.post( + "/v2/oneliner/runs", + headers=self.ctx["member_headers"], + json={ + "project_id": self.ctx["project_id"], + "source_screen": "dashboard", + "source_action_key": "homepage-primary-action", + "title": "跟进重点账号", + "summary": "先由主 Agent 评估优先级", + "intent_key": "track_account", + "platform": "douyin", + "platform_scope": "single_platform", + "plan_request": { + "goal": "跟进重点账号", + "steps": ["读取当前项目上下文", "检查重点账号变化", "决定下一步"], + }, + }, + ) + self.assertEqual(response.status_code, 200, response.text) + payload = response.json() + self.assertEqual(payload["run_status"], "needs_confirmation") + self.assertEqual(payload["source_screen"], "dashboard") + self.assertEqual(payload["platform"], "douyin") + self.assertEqual(payload["platform_scope"], "single_platform") + self.assertEqual(payload["session_id"][:5], "oline") + self.assertEqual(payload["plan"]["goal"], "跟进重点账号") + self.assertEqual(payload["governance"]["project_id"], self.ctx["project_id"]) + self.assertIn("layers", payload["governance"]) + self.assertEqual(payload["events"][0]["event_type"], "run.created") + + def test_agent_run_confirm_transitions_to_queue_or_running_and_logs_events(self) -> None: + create = self.client.post( + "/v2/oneliner/runs", + headers=self.ctx["member_headers"], + json={ + "project_id": self.ctx["project_id"], + "source_screen": "strategy", + "source_action_key": "handoff-to-main-agent", + "title": "调整当前平台策略", + "summary": "让主 Agent 先给执行计划", + "intent_key": "custom", + "platform": "douyin", + "platform_scope": "single_platform", + "plan_request": { + "goal": "调整当前平台策略", + "steps": ["读取当前平台策略", "生成调整建议"], + }, + }, + ) + self.assertEqual(create.status_code, 200, create.text) + run_id = create.json()["id"] + + confirm = self.client.post( + f"/v2/oneliner/runs/{run_id}/confirm", + headers=self.ctx["member_headers"], + json={"reason": "user confirmed"}, + ) + self.assertEqual(confirm.status_code, 200, confirm.text) + payload = confirm.json() + self.assertIn(payload["run_status"], {"queued", "running"}) + event_types = [item["event_type"] for item in payload["events"]] + self.assertIn("run.created", event_types) + self.assertIn("run.confirmed", event_types) + self.assertTrue("run.queued" in event_types or "run.started" in event_types) + def test_effective_policy_merges_system_user_global_and_platform_layers(self) -> None: system_response = self.client.put( "/v2/admin/oneliner/governance/system/main-agent", diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index 7bc4de5..7779efa 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -52,6 +52,8 @@ const appState = { onelinerProfile: null, onelinerSessions: [], selectedOnelinerSessionId: "", + onelinerRuns: [], + selectedOnelinerRunId: "", onelinerMessages: [], onelinerActionRegistry: [], platformAgents: [], @@ -209,6 +211,15 @@ function safeArray(value) { return Array.isArray(value) ? value : []; } +function parseJsonSafe(value, fallback) { + if (typeof value !== "string" || !value.trim()) return fallback; + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + function getRuntimePlatformValues() { return PLATFORM_RUNTIME.getRuntimePlatformValues(); } @@ -918,6 +929,7 @@ function ensureOneLinerUi() {
+
@@ -950,6 +962,97 @@ function renderOneLinerSessionTabs() { `; } +function renderOneLinerRunsHtml() { + const runs = safeArray(appState.onelinerRuns); + const currentRun = getCurrentOneLinerRun(); + if (!runs.length || !currentRun) { + return ` +
+

还没有主 Agent 运行任务

+

你在首页、策略页或 Agent 页点击“交给主 Agent”后,这里会先出现待确认执行卡。

+
+ `; + } + const runEvents = safeArray(currentRun.events).slice(-3); + const planSteps = safeArray(currentRun.plan?.steps).slice(0, 4); + const runStatusLabel = { + needs_confirmation: "待确认", + queued: "排队中", + running: "执行中", + blocked: "已阻塞", + done: "已完成", + failed: "已失败", + cancelled: "已取消" + }[currentRun.run_status] || currentRun.run_status || "运行中"; + const statusTone = currentRun.run_status === "needs_confirmation" + ? "blue" + : currentRun.run_status === "running" + ? "green" + : currentRun.run_status === "queued" + ? "orange" + : currentRun.run_status === "failed" + ? "orange" + : ""; + return ` +
+
+
+

${escapeHtml(currentRun.title || currentRun.plan?.goal || "主 Agent 任务")}

+
${escapeHtml(currentRun.summary || currentRun.status_summary || "主 Agent 会先给你一张确认卡,再继续执行。")}
+
+
+ ${escapeHtml(runStatusLabel)} + ${currentRun.platform_label ? `${escapeHtml(currentRun.platform_label)}` : ""} + ${escapeHtml(onelinerIntentLabel(currentRun.intent_key))} +
+
+ ${currentRun.active_admin_override_notice?.title ? ` +
+

管理员覆盖生效中

+

${escapeHtml(currentRun.active_admin_override_notice.summary || "当前运行会优先遵循管理员覆盖层。")}

+
+ ` : ""} + ${planSteps.length ? ` +
+ ${planSteps.map((step, index) => ` +
+

步骤 ${escapeHtml(formatNumber(index + 1))}

+

${escapeHtml(step)}

+
+ `).join("")} +
+ ` : ""} +
+ ${currentRun.run_status === "needs_confirmation" ? ` + 确认执行 + 取消本轮 + ` : ` + ${escapeHtml(currentRun.status_summary || "主 Agent 正在推进中")} + `} +
+ ${runEvents.length ? ` +
+ ${runEvents.map((item) => ` +
+

${escapeHtml(item.event_type || "run.progress")}

+

${escapeHtml(item.summary || "运行状态已更新。")}

+
+ `).join("")} +
+ ` : ""} +
+ ${runs.length > 1 ? ` +
+ ${runs.slice(0, 6).map((item) => ` + + ${escapeHtml(brief(item.title || item.plan?.goal || "主 Agent 任务", 14))} + + `).join("")} +
+ ` : ""} + `; +} + function renderOneLinerMessagesHtml() { const messages = safeArray(appState.onelinerMessages); if (!messages.length) { @@ -1058,6 +1161,7 @@ function renderOneLinerUi() { ensureOneLinerUi(); const fab = document.querySelector(".oneliner-fab"); const meta = document.querySelector('[data-role="oneliner-meta"]'); + const runs = document.querySelector('[data-role="oneliner-runs"]'); const sessions = document.querySelector('[data-role="oneliner-sessions"]'); const messages = document.querySelector('[data-role="oneliner-messages"]'); const status = document.querySelector('[data-role="oneliner-status"]'); @@ -1067,8 +1171,18 @@ function renderOneLinerUi() { const activeAdminOverrideNotice = effective?.active_admin_override_notice || null; const highlights = summarizePolicyHighlights(effective?.effective_policy || {}, effective?.platform || ""); const layers = safeArray(effective?.layers); + const currentRun = getCurrentOneLinerRun(); + const activeRuns = safeArray(appState.onelinerRuns).filter((item) => !["done", "failed", "cancelled"].includes(item.run_status)); if (fab) { fab.hidden = !appState.session; + const mark = fab.querySelector(".oneliner-fab-mark"); + const text = fab.querySelector(".oneliner-fab-text"); + if (mark) mark.textContent = String(activeRuns.length || 1); + if (text) { + text.textContent = currentRun + ? `OneLiner · ${currentRun.run_status === "needs_confirmation" ? "待确认" : currentRun.run_status === "running" ? "执行中" : currentRun.run_status === "queued" ? "排队中" : "工作中"}` + : "OneLiner"; + } } if (meta) { meta.innerHTML = ` @@ -1096,6 +1210,7 @@ function renderOneLinerUi() { ` : ""} `; } + if (runs) runs.innerHTML = renderOneLinerRunsHtml(); if (sessions) sessions.innerHTML = renderOneLinerSessionTabs(); if (messages) { messages.innerHTML = renderOneLinerMessagesHtml(); @@ -1284,6 +1399,8 @@ async function logoutSession() { appState.onelinerProfile = null; appState.onelinerSessions = []; appState.selectedOnelinerSessionId = ""; + appState.onelinerRuns = []; + appState.selectedOnelinerRunId = ""; appState.onelinerMessages = []; appState.onelinerActionRegistry = []; appState.platformAgents = []; @@ -1336,11 +1453,27 @@ async function loadStorageStatus(projectId = "") { return payload; } +async function hydrateSelectedOneLinerRun() { + const runId = appState.selectedOnelinerRunId || ""; + if (!runId || !backendSupports("/v2/oneliner/runs/{run_id}")) { + return null; + } + const detail = await storyforgeFetch(`/v2/oneliner/runs/${encodeURIComponent(runId)}`).catch(() => null); + if (!detail?.id) return null; + const runs = safeArray(appState.onelinerRuns); + const nextRuns = runs.some((item) => item.id === detail.id) + ? runs.map((item) => (item.id === detail.id ? detail : item)) + : [detail, ...runs]; + appState.onelinerRuns = nextRuns; + return detail; +} + async function loadAgentControlSurfaces(projectId = "") { const normalizedProjectId = projectId || getOneLinerProjectId(); const governancePlatform = normalizePlatformValue(getPreferredPlatform(), "douyin"); const supportsOneLinerProfile = backendSupports("/v2/oneliner/profile"); const supportsOneLinerSessions = backendSupports("/v2/oneliner/sessions"); + const supportsOneLinerRuns = backendSupports("/v2/oneliner/runs"); const supportsActionRegistry = backendSupports("/v2/oneliner/action-registry"); const supportsPlatformAgents = backendSupports("/v2/platform-agents"); const supportsGovernanceEffective = backendSupports("/v2/oneliner/governance/effective"); @@ -1357,13 +1490,16 @@ async function loadAgentControlSurfaces(projectId = "") { const supportsTenantQuota = backendSupports("/v2/tenant/quota"); const supportsTenantUsage = backendSupports("/v2/tenant/usage"); - const [profile, sessionsPayload, actionRegistryPayload, platformAgentsPayload, governanceEffective, userGlobalPolicy, userCurrentPlatformPolicy, userPolicyAuditsPayload, adminSystemMainPolicy, adminSystemPlatformPolicies, adminGovernanceDirectory, tenantQuota, tenantUsage, adminOpsOverview, adminFixRunsPayload] = await Promise.all([ + const [profile, sessionsPayload, runsPayload, actionRegistryPayload, platformAgentsPayload, governanceEffective, userGlobalPolicy, userCurrentPlatformPolicy, userPolicyAuditsPayload, adminSystemMainPolicy, adminSystemPlatformPolicies, adminGovernanceDirectory, tenantQuota, tenantUsage, adminOpsOverview, adminFixRunsPayload] = await Promise.all([ supportsOneLinerProfile ? storyforgeFetch(`/v2/oneliner/profile?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => null) : Promise.resolve(null), supportsOneLinerSessions ? storyforgeFetch(`/v2/oneliner/sessions?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => ({ items: [] })) : Promise.resolve({ items: [] }), + supportsOneLinerRuns + ? storyforgeFetch(`/v2/oneliner/runs?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => ({ items: [] })) + : Promise.resolve({ items: [] }), supportsActionRegistry ? storyforgeFetch(`/v2/oneliner/action-registry?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => ({ items: [] })) : Promise.resolve({ items: [] }), @@ -1409,10 +1545,15 @@ async function loadAgentControlSurfaces(projectId = "") { appState.onelinerProfile = profile; appState.onelinerSessions = safeArray(sessionsPayload?.items || sessionsPayload); + appState.onelinerRuns = safeArray(runsPayload?.items || runsPayload); appState.onelinerActionRegistry = safeArray(actionRegistryPayload?.items || actionRegistryPayload); if (!appState.selectedOnelinerSessionId || !safeArray(appState.onelinerSessions).some((item) => item.id === appState.selectedOnelinerSessionId)) { appState.selectedOnelinerSessionId = safeArray(appState.onelinerSessions)[0]?.id || ""; } + appState.selectedOnelinerRunId = choosePreferredOneLinerRunId(appState.onelinerRuns, appState.selectedOnelinerRunId || ""); + if (appState.selectedOnelinerRunId) { + await hydrateSelectedOneLinerRun(); + } appState.platformAgents = safeArray(platformAgentsPayload?.items || platformAgentsPayload); appState.onelinerGovernanceEffective = governanceEffective; appState.userGlobalPolicy = userGlobalPolicy; @@ -1514,6 +1655,50 @@ async function submitOneLinerMessage(content) { return payload; } +async function createOneLinerRun(runRequest) { + if (!backendSupports("/v2/oneliner/runs")) { + throw new Error("当前后端还没有接入主 Agent 运行层。"); + } + const projectId = getOneLinerProjectId(); + const payload = await storyforgeFetch("/v2/oneliner/runs", { + method: "POST", + body: { + project_id: projectId, + platform: getPreferredPlatform(), + platform_scope: "single_platform", + delivery_mode: "hybrid", + scheduling_mode: "queued", + ...runRequest + } + }); + await loadAgentControlSurfaces(projectId); + appState.selectedOnelinerRunId = payload?.id || choosePreferredOneLinerRunId(appState.onelinerRuns, ""); + rememberAction("主 Agent 已接单", payload?.title || payload?.plan?.goal || "已创建待确认任务。", "blue", payload); + return payload; +} + +async function confirmOneLinerRun(runId, reason = "") { + const payload = await storyforgeFetch(`/v2/oneliner/runs/${encodeURIComponent(runId)}/confirm`, { + method: "POST", + body: { reason } + }); + await loadAgentControlSurfaces(getOneLinerProjectId()); + appState.selectedOnelinerRunId = payload?.id || runId; + rememberAction("主 Agent 已确认执行", payload?.status_summary || "当前任务已进入执行流。", "green", payload); + return payload; +} + +async function cancelOneLinerRun(runId, reason = "") { + const payload = await storyforgeFetch(`/v2/oneliner/runs/${encodeURIComponent(runId)}/cancel`, { + method: "POST", + body: { reason } + }); + await loadAgentControlSurfaces(getOneLinerProjectId()); + appState.selectedOnelinerRunId = choosePreferredOneLinerRunId(appState.onelinerRuns, ""); + rememberAction("主 Agent 任务已取消", payload?.status_summary || "当前任务已取消。", "orange", payload); + return payload; +} + function renderOneLinerExecutionPayloadHtml(payload) { if (!payload || typeof payload !== "object") { return `

没有返回执行结果

当前执行器没有附带额外数据。

`; @@ -2105,6 +2290,20 @@ function getCurrentOneLinerSession() { return sessions.find((item) => item.id === appState.selectedOnelinerSessionId) || sessions[0] || null; } +function choosePreferredOneLinerRunId(items, currentId = "") { + const runs = safeArray(items); + if (currentId && runs.some((item) => item.id === currentId)) { + return currentId; + } + return runs.find((item) => item.run_status === "needs_confirmation")?.id || runs[0]?.id || ""; +} + +function getCurrentOneLinerRun() { + const runs = safeArray(appState.onelinerRuns); + const preferredId = choosePreferredOneLinerRunId(runs, appState.selectedOnelinerRunId || ""); + return runs.find((item) => item.id === preferredId) || null; +} + function onelinerIntentLabel(value) { return ONELINER_INTENT_LABELS[value] || value || "自定义任务"; } @@ -3381,7 +3580,16 @@ function renderGovernanceSummaryCard({ title, subtitle, effective, primaryAction ${resolvedActions.length ? `
${resolvedActions.map((item) => ` - + ${escapeHtml(item.label || "查看")} `).join("")} @@ -5402,7 +5610,18 @@ function renderStrategyScreen() { actions: [ { action: "open-user-global-policy", label: "编辑我的全局策略" }, { action: "open-user-platform-policy", label: "编辑当前平台策略", platform }, - { action: "open-oneliner", label: "交给 OneLiner 调整" } + { + action: "handoff-to-main-agent", + label: "交给主 Agent 调整", + platform, + sourceScreen: "strategy", + sourceActionKey: "governance-summary-handoff", + intentKey: "custom", + title: "调整当前策略", + goal: "调整当前策略", + summary: "先由主 Agent 读取当前治理层,再给一版确认卡。", + planSteps: ["读取当前生效策略", "结合管理员覆盖与个人策略生成方案", "等待用户确认后执行"] + } ] })}
@@ -7515,7 +7734,7 @@ async function openPlatformAgentDetailAction(platform) { 编辑配置 继续补记忆 继续补技能 - 让 OneLiner 调度 + 交给主 Agent 继续 ` @@ -9122,6 +9341,61 @@ document.addEventListener("click", async (event) => { await openAdminOverrideHistoryAction(); return; } + if (name === "handoff-to-main-agent") { + try { + setBusy(true, "正在为主 Agent 创建执行计划..."); + const payload = await createOneLinerRun({ + source_screen: action.dataset.sourceScreen || appState.screen || "dashboard", + source_action_key: action.dataset.sourceActionKey || name, + title: action.dataset.title || action.textContent?.trim() || "交给主 Agent 处理", + summary: action.dataset.summary || "", + intent_key: action.dataset.intentKey || "custom", + platform: action.dataset.platform || getPreferredPlatform(), + platform_scope: action.dataset.platformScope || "single_platform", + plan_request: { + goal: action.dataset.goal || action.dataset.title || action.textContent?.trim() || "交给主 Agent 处理", + steps: parseJsonSafe(action.dataset.planSteps, []), + summary: action.dataset.summary || "" + } + }); + appState.selectedOnelinerRunId = payload?.id || ""; + openOneLinerPanel(); + renderAll(); + } catch (error) { + presentActionFailure(error, "主 Agent 接单失败"); + openOneLinerPanel(); + } finally { + setBusy(false, ""); + } + return; + } + if (name === "confirm-oneliner-run") { + try { + setBusy(true, "正在确认执行计划..."); + await confirmOneLinerRun(action.dataset.runId || "", "user confirmed"); + } catch (error) { + presentActionFailure(error, "主 Agent 确认失败"); + } finally { + setBusy(false, ""); + } + return; + } + if (name === "cancel-oneliner-run") { + try { + setBusy(true, "正在取消当前任务..."); + await cancelOneLinerRun(action.dataset.runId || "", "user cancelled"); + } catch (error) { + presentActionFailure(error, "主 Agent 取消失败"); + } finally { + setBusy(false, ""); + } + return; + } + if (name === "select-oneliner-run") { + appState.selectedOnelinerRunId = action.dataset.runId || ""; + renderAll(); + return; + } if (name === "select-oneliner-session") { appState.selectedOnelinerSessionId = action.dataset.sessionId || ""; await loadOneLinerMessages(appState.selectedOnelinerSessionId); diff --git a/web/storyforge-web-v4/assets/styles.css b/web/storyforge-web-v4/assets/styles.css index 960e1e1..de37786 100644 --- a/web/storyforge-web-v4/assets/styles.css +++ b/web/storyforge-web-v4/assets/styles.css @@ -1130,11 +1130,17 @@ select { } .oneliner-meta, +.oneliner-runs, .oneliner-sessions { display: grid; gap: 8px; } +.oneliner-run-card { + border-color: rgba(79, 143, 238, 0.16); + background: linear-gradient(180deg, rgba(248, 252, 255, 0.98) 0%, rgba(255, 255, 255, 0.98) 100%); +} + .oneliner-messages { min-height: 0; overflow: auto; diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs index d3bcf47..54f56e6 100644 --- a/web/storyforge-web-v4/tests/workbench-pages.test.mjs +++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs @@ -113,6 +113,9 @@ test("oneliner submit failures stay inside the app instead of using a browser al test("agent control surfaces load governance endpoints for user and admin summaries", () => { const source = extractBetween(APP, "async function loadAgentControlSurfaces(projectId = \"\")", "async function loadOneLinerMessages(sessionId)"); assert.match(source, /\/v2\/oneliner\/governance\/effective/); + assert.match(source, /\/v2\/oneliner\/runs/); + assert.match(APP, /function hydrateSelectedOneLinerRun\(\)/); + assert.match(APP, /\/v2\/oneliner\/runs\/\$\{encodeURIComponent\(runId\)\}/); assert.match(source, /\/v2\/oneliner\/governance\/user\/global/); assert.match(source, /\/v2\/oneliner\/governance\/user\/platforms\/\$\{encodeURIComponent\(governancePlatform\)\}/); assert.match(source, /\/v2\/admin\/oneliner\/governance\/system\/main-agent/); @@ -124,15 +127,26 @@ test("agent control surfaces load governance endpoints for user and admin summar assert.match(source, /const targetProjectId = requestedProjectId === ""/); }); +test("oneliner panel includes a dedicated runtime header for agent runs", () => { + const source = extractBetween(APP, "function ensureOneLinerUi()", "function renderOneLinerSessionTabs()"); + const runtime = extractBetween(APP, "function renderOneLinerRunsHtml()", "function renderOneLinerMessagesHtml()"); + assert.match(source, /data-role="oneliner-runs"/); + assert.match(runtime, /confirm-oneliner-run/); + assert.match(runtime, /cancel-oneliner-run/); +}); + test("oneliner meta and action handlers expose governance entry points", () => { const meta = extractBetween(APP, "function renderOneLinerUi()", "function openOneLinerPanel()"); const messages = extractBetween(APP, "function renderOneLinerMessagesHtml()", "function renderOneLinerUi()"); const actions = extractBetween(APP, "document.addEventListener(\"click\", async (event) => {", "document.addEventListener(\"submit\", async (event) => {"); assert.match(meta, /open-user-global-policy/); + assert.match(meta, /renderOneLinerRunsHtml\(\)/); assert.match(meta, /policyScopeTagLabel/); assert.match(messages, /active_admin_override_notice/); assert.match(actions, /name === "open-user-global-policy"/); assert.match(actions, /name === "open-system-main-policy"/); + assert.match(actions, /name === "handoff-to-main-agent"/); + assert.match(actions, /name === "confirm-oneliner-run"/); }); test("system governance saves refresh control surfaces after persisting", () => {