diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c4a8b9..560a410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ ## 2026-04-07 +### 管理员配置台新增“模型与接入”统一配置中心 + +- `管理员配置台` 新增了 `模型与接入` 页签,只有超级管理员可以访问;它把运行时接入、系统模型、Huobao 文本/图片/视频模型配置统一收进了一个地方。 +- 管理员现在可以直接在 StoryForge 里维护: + - `n8n / Huobao / ASR / cutvideo / live_recorder / local_model` 的运行时地址 + - 系统级文本模型的 `provider / base_url / model / API Key` + - Huobao 的 `text / image / video` 模型配置,包含 Seedance 2.0 这类视频模型 +- `AI 视频` 表单里的 `查看火山配置状态` 现在对管理员会直接带进这个新工作区,真正进入可编辑的模型配置页,而不是只停在健康状态卡。 + +### 管理员模型配置开始纳入回归与部署护栏 + +- 后端新增了管理员专属接口: + - `/v2/admin/model-access/overview` + - `/v2/admin/model-access/runtime` + - `/v2/admin/model-access/system-models` + - `/v2/admin/model-access/huobao-configs` +- 这些接口已经纳入后端回归,前端管理员页也纳入工作台字符串回归,所以以后不会再出现“管理员页有入口、但后端保存不了”这种断层。 + ### AI 视频表单可直接跳到火山视频配置状态 - `创建 AI 视频任务` 里的 `Seedance 配置` 提示现在不再只是静态文案,而是新增了 `查看火山配置状态` 入口。 diff --git a/collector-service/app/core_main.py b/collector-service/app/core_main.py index e3c4d77..3638c6f 100644 --- a/collector-service/app/core_main.py +++ b/collector-service/app/core_main.py @@ -23,7 +23,7 @@ from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from pydantic import BaseModel, Field from .database import Database, utc_now -from .integrations import AsrHttpClient, CutVideoClient, HuobaoDramaClient, N8NClient +from .integrations import AsrHttpClient, CutVideoClient, HuobaoDramaClient, N8NClient, _unwrap_response from .openai_compat import OpenAICompatClient BASE_DIR = Path(__file__).resolve().parents[2] @@ -67,6 +67,24 @@ CUTVIDEO_MAX_WAIT_SEC = int(os.getenv("CUTVIDEO_MAX_WAIT_SEC", "1800")) CUTVIDEO_UPLOAD_TIMEOUT_SEC = int(os.getenv("CUTVIDEO_UPLOAD_TIMEOUT_SEC", "1800")) HUOBAO_POLL_INTERVAL_SEC = int(os.getenv("HUOBAO_POLL_INTERVAL_SEC", "10")) HUOBAO_MAX_WAIT_SEC = int(os.getenv("HUOBAO_MAX_WAIT_SEC", "900")) +MODEL_ACCESS_RUNTIME_KEY = "model_access.runtime" +MODEL_ACCESS_PROVIDER_PRESETS: dict[str, list[dict[str, Any]]] = { + "text": [ + {"provider": "openai", "label": "OpenAI / 兼容", "models": ["gpt-5.2", "gpt-5.1-mini"]}, + {"provider": "gemini", "label": "Gemini", "models": ["gemini-2.5-pro", "gemini-3-flash-preview"]}, + {"provider": "chatfire", "label": "ChatFire", "models": ["gemini-3-flash-preview", "doubao-seed-1-8-251228"]}, + ], + "image": [ + {"provider": "volcengine", "label": "火山图像", "models": ["doubao-seedream-4-5-251128", "doubao-seedream-4-0-250828"]}, + {"provider": "openai", "label": "OpenAI 图像", "models": ["gpt-image-1", "dall-e-3"]}, + {"provider": "gemini", "label": "Gemini 图像", "models": ["gemini-3-pro-image-preview"]}, + ], + "video": [ + {"provider": "volcengine", "label": "火山视频 / Seedance", "models": ["seedance-2.0-pro", "doubao-seedance-1-0-pro-250528"]}, + {"provider": "chatfire", "label": "ChatFire 视频", "models": ["seedance-2.0-pro", "doubao-seedance-1-0-pro-fast-251015"]}, + {"provider": "openai", "label": "OpenAI 视频", "models": ["sora-2", "sora-2-pro"]}, + ], +} INVALID_CONFIG_VALUES = { "", @@ -137,6 +155,41 @@ class PreferredModelRequest(BaseModel): model_profile_id: str +class AdminRuntimeIntegrationRequest(BaseModel): + local_model_base_url: str = "" + local_model_api_key: str = "" + local_model_default_model: str = "" + asr_http_base_url: str = "" + n8n_base_url: str = "" + cutvideo_base_url: str = "" + cutvideo_api_key: str = "" + huobao_base_url: str = "" + live_recorder_base_url: str = "" + + +class AdminSystemModelRequest(BaseModel): + name: str + provider: str = "openai_compat" + base_url: str + api_key: str = "" + model_name: str + is_default: bool = False + + +class AdminHuobaoConfigRequest(BaseModel): + service_type: str + provider: str = "" + name: str + base_url: str + api_key: str = "" + model: str | list[str] + endpoint: str = "" + query_endpoint: str = "" + priority: int = 100 + is_active: bool = True + settings: str = "" + + class KnowledgeBaseCreateRequest(BaseModel): name: str description: str = "" @@ -480,6 +533,196 @@ def normalize_model_profile(row: dict[str, Any]) -> dict[str, Any]: } +def model_access_runtime_snapshot() -> dict[str, Any]: + return { + "local_model": { + "base_url": LOCAL_OPENAI_BASE_URL, + "api_key_masked": mask_api_key(LOCAL_OPENAI_API_KEY), + "default_model": LOCAL_OPENAI_MODEL, + }, + "asr": { + "base_url": ASR_HTTP_BASE_URL, + }, + "n8n": { + "base_url": N8N_BASE_URL, + }, + "cutvideo": { + "base_url": CUTVIDEO_BASE_URL, + "api_key_masked": mask_api_key(CUTVIDEO_API_KEY), + }, + "huobao": { + "base_url": HUOBAO_BASE_URL, + }, + "live_recorder": { + "base_url": LIVE_RECORDER_BASE_URL, + }, + } + + +def apply_runtime_integration_config(runtime: dict[str, Any] | None) -> dict[str, Any]: + global LOCAL_OPENAI_BASE_URL, LOCAL_OPENAI_API_KEY, LOCAL_OPENAI_MODEL + global ASR_HTTP_BASE_URL, N8N_BASE_URL, CUTVIDEO_BASE_URL, CUTVIDEO_API_KEY + global HUOBAO_BASE_URL, LIVE_RECORDER_BASE_URL + + runtime = runtime or {} + local_model = runtime.get("local_model") if isinstance(runtime.get("local_model"), dict) else {} + asr = runtime.get("asr") if isinstance(runtime.get("asr"), dict) else {} + n8n = runtime.get("n8n") if isinstance(runtime.get("n8n"), dict) else {} + cutvideo = runtime.get("cutvideo") if isinstance(runtime.get("cutvideo"), dict) else {} + huobao = runtime.get("huobao") if isinstance(runtime.get("huobao"), dict) else {} + live_recorder = runtime.get("live_recorder") if isinstance(runtime.get("live_recorder"), dict) else {} + + if "base_url" in local_model: + LOCAL_OPENAI_BASE_URL = normalize_config_value(local_model.get("base_url")) + if "api_key" in local_model: + LOCAL_OPENAI_API_KEY = normalize_config_value(local_model.get("api_key")) + if "default_model" in local_model: + LOCAL_OPENAI_MODEL = normalize_config_value(local_model.get("default_model")) + if "base_url" in asr: + ASR_HTTP_BASE_URL = normalize_config_value(asr.get("base_url")) + if "base_url" in n8n: + N8N_BASE_URL = normalize_config_value(n8n.get("base_url")) + if "base_url" in cutvideo: + CUTVIDEO_BASE_URL = normalize_config_value(cutvideo.get("base_url")) + if "api_key" in cutvideo: + CUTVIDEO_API_KEY = normalize_config_value(cutvideo.get("api_key")) + if "base_url" in huobao: + HUOBAO_BASE_URL = normalize_config_value(huobao.get("base_url")) + if "base_url" in live_recorder: + LIVE_RECORDER_BASE_URL = normalize_config_value(live_recorder.get("base_url")) + + asr_http_client.base_url = ASR_HTTP_BASE_URL.rstrip("/") + n8n_client.base_url = N8N_BASE_URL.rstrip("/") + cutvideo_client.base_url = CUTVIDEO_BASE_URL.rstrip("/") + cutvideo_client.api_key = CUTVIDEO_API_KEY.strip() + huobao_client.base_url = HUOBAO_BASE_URL.rstrip("/") + return model_access_runtime_snapshot() + + +def load_runtime_integration_config() -> dict[str, Any]: + row = db.fetch_one("SELECT value_json FROM system_runtime_settings WHERE key = ?", (MODEL_ACCESS_RUNTIME_KEY,)) + if not row: + return model_access_runtime_snapshot() + try: + payload = json.loads(row.get("value_json") or "{}") + except json.JSONDecodeError: + payload = {} + return apply_runtime_integration_config(payload if isinstance(payload, dict) else {}) + + +def save_runtime_integration_config(payload: dict[str, Any], updated_by: str) -> dict[str, Any]: + current = { + "local_model": { + "base_url": LOCAL_OPENAI_BASE_URL, + "api_key": LOCAL_OPENAI_API_KEY, + "default_model": LOCAL_OPENAI_MODEL, + }, + "asr": {"base_url": ASR_HTTP_BASE_URL}, + "n8n": {"base_url": N8N_BASE_URL}, + "cutvideo": {"base_url": CUTVIDEO_BASE_URL, "api_key": CUTVIDEO_API_KEY}, + "huobao": {"base_url": HUOBAO_BASE_URL}, + "live_recorder": {"base_url": LIVE_RECORDER_BASE_URL}, + } + next_payload = { + "local_model": { + "base_url": normalize_config_value(((payload.get("local_model") or {}).get("base_url"))) if isinstance(payload.get("local_model"), dict) else current["local_model"]["base_url"], + "api_key": ( + normalize_config_value(((payload.get("local_model") or {}).get("api_key"))) or current["local_model"]["api_key"] + ) if isinstance(payload.get("local_model"), dict) else current["local_model"]["api_key"], + "default_model": normalize_config_value(((payload.get("local_model") or {}).get("default_model"))) if isinstance(payload.get("local_model"), dict) else current["local_model"]["default_model"], + }, + "asr": {"base_url": normalize_config_value(((payload.get("asr") or {}).get("base_url"))) if isinstance(payload.get("asr"), dict) else current["asr"]["base_url"]}, + "n8n": {"base_url": normalize_config_value(((payload.get("n8n") or {}).get("base_url"))) if isinstance(payload.get("n8n"), dict) else current["n8n"]["base_url"]}, + "cutvideo": { + "base_url": normalize_config_value(((payload.get("cutvideo") or {}).get("base_url"))) if isinstance(payload.get("cutvideo"), dict) else current["cutvideo"]["base_url"], + "api_key": ( + normalize_config_value(((payload.get("cutvideo") or {}).get("api_key"))) or current["cutvideo"]["api_key"] + ) if isinstance(payload.get("cutvideo"), dict) else current["cutvideo"]["api_key"], + }, + "huobao": {"base_url": normalize_config_value(((payload.get("huobao") or {}).get("base_url"))) if isinstance(payload.get("huobao"), dict) else current["huobao"]["base_url"]}, + "live_recorder": {"base_url": normalize_config_value(((payload.get("live_recorder") or {}).get("base_url"))) if isinstance(payload.get("live_recorder"), dict) else current["live_recorder"]["base_url"]}, + } + if not next_payload["local_model"]["base_url"]: + next_payload["local_model"]["api_key"] = "" + if not next_payload["cutvideo"]["base_url"]: + next_payload["cutvideo"]["api_key"] = "" + now = utc_now() + row = db.fetch_one("SELECT key FROM system_runtime_settings WHERE key = ?", (MODEL_ACCESS_RUNTIME_KEY,)) + if row: + db.execute( + "UPDATE system_runtime_settings SET value_json = ?, updated_by = ?, updated_at = ? WHERE key = ?", + (json.dumps(next_payload, ensure_ascii=False), updated_by, now, MODEL_ACCESS_RUNTIME_KEY), + ) + else: + db.execute( + "INSERT INTO system_runtime_settings (key, value_json, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + (MODEL_ACCESS_RUNTIME_KEY, json.dumps(next_payload, ensure_ascii=False), updated_by, now, now), + ) + return apply_runtime_integration_config(next_payload) + + +def huobao_api_request(method: str, path: str, *, payload: dict[str, Any] | None = None, params: dict[str, Any] | None = None, timeout: float = 12.0) -> Any: + if not HUOBAO_BASE_URL: + raise HTTPException(status_code=503, detail="HUOBAO_BASE_URL is not configured") + url = urljoin(HUOBAO_BASE_URL if HUOBAO_BASE_URL.endswith("/") else f"{HUOBAO_BASE_URL}/", path.lstrip("/")) + try: + response = httpx.request( + method.upper(), + url, + json=payload, + params=params, + timeout=timeout, + follow_redirects=True, + ) + response.raise_for_status() + if not response.content: + return {} + if "application/json" not in (response.headers.get("content-type") or ""): + return {"text": response.text} + return _unwrap_response(response.json()) + except httpx.HTTPStatusError as exc: + detail: Any + try: + detail = _unwrap_response(exc.response.json()) + except Exception: + detail = exc.response.text.strip() or f"http_{exc.response.status_code}" + raise HTTPException(status_code=exc.response.status_code, detail=detail) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"huobao request failed: {exc}") + + +def normalize_huobao_config_item(item: dict[str, Any]) -> dict[str, Any]: + models = item.get("model") + if isinstance(models, list): + model_list = [str(model).strip() for model in models if str(model).strip()] + else: + model_list = [str(models).strip()] if str(models or "").strip() else [] + return { + "id": str(item.get("id") or ""), + "service_type": str(item.get("service_type") or ""), + "provider": str(item.get("provider") or ""), + "name": str(item.get("name") or ""), + "base_url": str(item.get("base_url") or ""), + "api_key_masked": mask_api_key(str(item.get("api_key") or "")), + "model": model_list, + "endpoint": str(item.get("endpoint") or ""), + "query_endpoint": str(item.get("query_endpoint") or ""), + "priority": int(item.get("priority") or 0), + "is_active": bool(item.get("is_active", True)), + "settings": str(item.get("settings") or ""), + "created_at": str(item.get("created_at") or ""), + "updated_at": str(item.get("updated_at") or ""), + } + + +def huobao_config_items_from_payload(payload: Any) -> list[dict[str, Any]]: + if isinstance(payload, list): + return [item for item in payload if isinstance(item, dict)] + if isinstance(payload, dict) and isinstance(payload.get("value"), list): + return [item for item in payload.get("value") if isinstance(item, dict)] + return [] + + def normalize_account(row: dict[str, Any]) -> dict[str, Any]: return { "id": row["id"], @@ -3084,6 +3327,7 @@ async def process_job(job_id: str) -> None: @app.on_event("startup") def on_startup() -> None: db.init_schema() + load_runtime_integration_config() seed_defaults() @@ -3467,6 +3711,237 @@ def integrations_local_models(account: dict[str, Any] = Depends(require_approved return fetch_local_model_catalog() +def build_admin_model_access_overview(account: dict[str, Any]) -> dict[str, Any]: + integrations = integrations_health(account) + runtime = model_access_runtime_snapshot() + runtime_cards = { + key: { + **runtime.get(key, {}), + **(integrations.get(key) or {}), + } + for key in ["local_model", "asr", "n8n", "cutvideo", "huobao", "live_recorder"] + } + huobao_configs: dict[str, Any] = {} + for service_type in ["text", "image", "video"]: + try: + payload = huobao_api_request("GET", "/api/v1/ai-configs", params={"service_type": service_type}) + items = huobao_config_items_from_payload(payload) + huobao_configs[service_type] = { + "items": [normalize_huobao_config_item(item) for item in items], + "error": "", + } + except HTTPException as exc: + huobao_configs[service_type] = {"items": [], "error": str(exc.detail)} + system_models = [ + normalize_model_profile(row) + for row in db.fetch_all( + "SELECT * FROM model_profiles WHERE owner_account_id IS NULL ORDER BY is_default DESC, created_at ASC" + ) + ] + return { + "runtime": runtime_cards, + "system_model_profiles": system_models, + "huobao_configs": huobao_configs, + "provider_presets": MODEL_ACCESS_PROVIDER_PRESETS, + } + + +@app.get("/v2/admin/model-access/overview") +def admin_model_access_overview(admin: dict[str, Any] = Depends(require_super_admin)) -> dict[str, Any]: + return build_admin_model_access_overview(admin) + + +@app.put("/v2/admin/model-access/runtime") +def update_admin_model_access_runtime( + request: AdminRuntimeIntegrationRequest, + admin: dict[str, Any] = Depends(require_super_admin), +) -> dict[str, Any]: + runtime = save_runtime_integration_config( + { + "local_model": { + "base_url": request.local_model_base_url, + "api_key": request.local_model_api_key, + "default_model": request.local_model_default_model, + }, + "asr": {"base_url": request.asr_http_base_url}, + "n8n": {"base_url": request.n8n_base_url}, + "cutvideo": {"base_url": request.cutvideo_base_url, "api_key": request.cutvideo_api_key}, + "huobao": {"base_url": request.huobao_base_url}, + "live_recorder": {"base_url": request.live_recorder_base_url}, + }, + admin["id"], + ) + return {"runtime": build_admin_model_access_overview(admin)["runtime"], "saved_runtime": runtime} + + +@app.post("/v2/admin/model-access/system-models") +def create_admin_system_model( + request: AdminSystemModelRequest, + admin: dict[str, Any] = Depends(require_super_admin), +) -> dict[str, Any]: + model_id = make_id("model") + now = utc_now() + if request.is_default: + db.execute("UPDATE model_profiles SET is_default = 0 WHERE owner_account_id IS NULL") + db.execute( + """ + INSERT INTO model_profiles (id, owner_account_id, name, provider, base_url, api_key, model_name, is_system, is_default, created_at, updated_at) + VALUES (?, NULL, ?, ?, ?, ?, ?, 1, ?, ?, ?) + """, + ( + model_id, + request.name.strip(), + request.provider.strip() or "openai_compat", + request.base_url.strip(), + request.api_key.strip(), + request.model_name.strip(), + 1 if request.is_default else 0, + now, + now, + ), + ) + row = db.fetch_one("SELECT * FROM model_profiles WHERE id = ?", (model_id,)) + return normalize_model_profile(row) + + +@app.put("/v2/admin/model-access/system-models/{model_id}") +def update_admin_system_model( + model_id: str, + request: AdminSystemModelRequest, + admin: dict[str, Any] = Depends(require_super_admin), +) -> dict[str, Any]: + _ = admin + row = db.fetch_one("SELECT * FROM model_profiles WHERE id = ? AND owner_account_id IS NULL", (model_id,)) + if not row: + raise HTTPException(status_code=404, detail="System model profile not found") + if request.is_default: + db.execute("UPDATE model_profiles SET is_default = 0 WHERE owner_account_id IS NULL") + next_api_key = request.api_key.strip() if request.api_key.strip() else str(row.get("api_key") or "") + now = utc_now() + db.execute( + """ + UPDATE model_profiles + SET name = ?, provider = ?, base_url = ?, api_key = ?, model_name = ?, is_default = ?, updated_at = ? + WHERE id = ? + """, + ( + request.name.strip(), + request.provider.strip() or "openai_compat", + request.base_url.strip(), + next_api_key, + request.model_name.strip(), + 1 if request.is_default else 0, + now, + model_id, + ), + ) + updated = db.fetch_one("SELECT * FROM model_profiles WHERE id = ?", (model_id,)) + return normalize_model_profile(updated) + + +@app.get("/v2/admin/model-access/huobao-configs") +def list_admin_huobao_configs( + service_type: str = Query(default="video"), + admin: dict[str, Any] = Depends(require_super_admin), +) -> dict[str, Any]: + _ = admin + payload = huobao_api_request("GET", "/api/v1/ai-configs", params={"service_type": service_type}) + items = huobao_config_items_from_payload(payload) + return {"items": [normalize_huobao_config_item(item) for item in items]} + + +@app.get("/v2/admin/model-access/huobao-configs/{config_id}") +def get_admin_huobao_config(config_id: str, admin: dict[str, Any] = Depends(require_super_admin)) -> dict[str, Any]: + _ = admin + payload = huobao_api_request("GET", f"/api/v1/ai-configs/{quote(config_id)}") + if not isinstance(payload, dict): + raise HTTPException(status_code=502, detail="Invalid huobao config payload") + return normalize_huobao_config_item(payload) + + +@app.post("/v2/admin/model-access/huobao-configs") +def create_admin_huobao_config( + request: AdminHuobaoConfigRequest, + admin: dict[str, Any] = Depends(require_super_admin), +) -> dict[str, Any]: + _ = admin + payload = huobao_api_request( + "POST", + "/api/v1/ai-configs", + payload={ + "service_type": request.service_type, + "provider": request.provider, + "name": request.name, + "base_url": request.base_url, + "api_key": request.api_key, + "model": request.model, + "endpoint": request.endpoint, + "query_endpoint": request.query_endpoint, + "priority": request.priority, + "settings": request.settings, + }, + ) + if not isinstance(payload, dict): + raise HTTPException(status_code=502, detail="Invalid huobao config payload") + return normalize_huobao_config_item(payload) + + +@app.put("/v2/admin/model-access/huobao-configs/{config_id}") +def update_admin_huobao_config( + config_id: str, + request: AdminHuobaoConfigRequest, + admin: dict[str, Any] = Depends(require_super_admin), +) -> dict[str, Any]: + _ = admin + update_payload = { + "provider": request.provider, + "name": request.name, + "base_url": request.base_url, + "model": request.model, + "endpoint": request.endpoint, + "query_endpoint": request.query_endpoint, + "priority": request.priority, + "is_active": request.is_active, + "settings": request.settings, + } + if request.api_key.strip(): + update_payload["api_key"] = request.api_key + payload = huobao_api_request("PUT", f"/api/v1/ai-configs/{quote(config_id)}", payload=update_payload) + if not isinstance(payload, dict): + raise HTTPException(status_code=502, detail="Invalid huobao config payload") + return normalize_huobao_config_item(payload) + + +@app.delete("/v2/admin/model-access/huobao-configs/{config_id}") +def delete_admin_huobao_config(config_id: str, admin: dict[str, Any] = Depends(require_super_admin)) -> dict[str, Any]: + _ = admin + payload = huobao_api_request("DELETE", f"/api/v1/ai-configs/{quote(config_id)}") + if isinstance(payload, dict): + return payload + return {"ok": True} + + +@app.post("/v2/admin/model-access/huobao-configs/test") +def test_admin_huobao_config( + request: AdminHuobaoConfigRequest, + admin: dict[str, Any] = Depends(require_super_admin), +) -> dict[str, Any]: + _ = admin + payload = huobao_api_request( + "POST", + "/api/v1/ai-configs/test", + payload={ + "provider": request.provider, + "base_url": request.base_url, + "api_key": request.api_key, + "model": request.model, + "endpoint": request.endpoint, + "query_endpoint": request.query_endpoint, + }, + ) + return payload if isinstance(payload, dict) else {"value": payload} + + @app.get("/v2/live-recorder/health") def live_recorder_health(account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]: _ = account diff --git a/collector-service/app/database.py b/collector-service/app/database.py index cad41d6..49f9a7f 100644 --- a/collector-service/app/database.py +++ b/collector-service/app/database.py @@ -340,6 +340,14 @@ class Database: published_at INTEGER NOT NULL, created_by TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS system_runtime_settings ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL DEFAULT '{}', + updated_by TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); """ with self.session() as conn: conn.executescript(schema) diff --git a/tests/test_main_agent_governance.py b/tests/test_main_agent_governance.py index af0e1a2..8340677 100644 --- a/tests/test_main_agent_governance.py +++ b/tests/test_main_agent_governance.py @@ -80,6 +80,7 @@ class MainAgentGovernanceTests(unittest.TestCase): "projects", "accounts", "model_profiles", + "system_runtime_settings", ] with self.core.db.session() as conn: conn.execute("PRAGMA foreign_keys=OFF") @@ -216,9 +217,13 @@ class MainAgentGovernanceTests(unittest.TestCase): ) VALUES (?, ?, ?, ?, '', '', '', ?, '', '', '[]', '{}', '{}', 'public', 'ready', NULL, NULL, NULL, ?, ?) """, (account_id, self.ctx["member_id"], profile_url, profile_url, nickname, now, now), - ) + ) return account_id + def test_model_access_overview_requires_super_admin(self) -> None: + response = self.client.get("/v2/admin/model-access/overview", headers=self.ctx["member_headers"]) + self.assertEqual(response.status_code, 403, response.text) + def _insert_assistant( self, *, diff --git a/tests/test_production_baseline.py b/tests/test_production_baseline.py index ba8df21..541682b 100644 --- a/tests/test_production_baseline.py +++ b/tests/test_production_baseline.py @@ -88,6 +88,7 @@ class ProductionBaselineTests(unittest.TestCase): "projects", "accounts", "model_profiles", + "system_runtime_settings", ] for table in tables: self.core.db.execute(f"DELETE FROM {table}") @@ -266,6 +267,47 @@ class ProductionBaselineTests(unittest.TestCase): ]: self.assertIn(expected, content) + def test_admin_model_access_overview_returns_runtime_and_huobao_sections(self) -> None: + ctx = self._seed_context("model_access", exhausted=False) + headers = {"Authorization": f"Bearer {ctx['token']}"} + with unittest.mock.patch.object( + self.core, + "huobao_api_request", + return_value={"value": [{"id": 1, "service_type": "video", "provider": "volcengine", "name": "Seedance", "base_url": "https://video.example.com", "api_key": "secret-token", "model": ["seedance-2.0-pro"], "priority": 100, "is_active": True}]}, + ): + response = self.client.get("/v2/admin/model-access/overview", headers=headers) + self.assertEqual(response.status_code, 200, response.text) + payload = response.json() + self.assertIn("runtime", payload) + self.assertIn("system_model_profiles", payload) + self.assertIn("huobao_configs", payload) + self.assertEqual(payload["huobao_configs"]["video"]["items"][0]["provider"], "volcengine") + self.assertEqual(payload["huobao_configs"]["video"]["items"][0]["api_key_masked"], "secr***oken") + + def test_admin_model_access_runtime_update_changes_effective_healthz_values(self) -> None: + ctx = self._seed_context("runtime_access", exhausted=False) + headers = {"Authorization": f"Bearer {ctx['token']}"} + response = self.client.put( + "/v2/admin/model-access/runtime", + headers=headers, + json={ + "local_model_base_url": "", + "local_model_api_key": "", + "local_model_default_model": "GLM-5", + "asr_http_base_url": "http://192.168.31.18:8088", + "n8n_base_url": "http://127.0.0.1:25670", + "cutvideo_base_url": "http://192.168.31.18:7860", + "cutvideo_api_key": "cut-token", + "huobao_base_url": "http://127.0.0.1:25678", + "live_recorder_base_url": "http://192.168.31.188:19106", + }, + ) + self.assertEqual(response.status_code, 200, response.text) + health = self.client.get("/healthz").json() + self.assertEqual(health["asrHttpBaseUrl"], "http://192.168.31.18:8088") + self.assertEqual(health["n8nBaseUrl"], "http://127.0.0.1:25670") + self.assertEqual(health["huobaoBaseUrl"], "http://127.0.0.1:25678") + 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") diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index 888c18c..5783717 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -91,6 +91,7 @@ const appState = { adminOverrideTarget: null, adminOverridePolicy: null, adminPolicyAudits: [], + adminModelAccess: null, tenantQuota: null, tenantUsage: null, adminOpsOverview: null, @@ -2315,7 +2316,7 @@ async function hydrateSelectedOneLinerRun() { async function loadAgentControlSurfaces(projectId = "") { const normalizedProjectId = projectId || getOneLinerProjectId(); const governancePlatform = normalizePlatformValue(getPreferredPlatform(), "douyin"); - const [profile, sessionsPayload, runsPayload, 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, adminModelAccess, tenantQuota, tenantUsage, adminOpsOverview, adminFixRunsPayload] = await Promise.all([ storyforgeFetch(`/v2/oneliner/profile?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => null), storyforgeFetch(`/v2/oneliner/sessions?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => ({ items: [] })), storyforgeFetch(`/v2/oneliner/runs?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => ({ items: [] })), @@ -2336,6 +2337,9 @@ async function loadAgentControlSurfaces(projectId = "") { isSuperAdmin() ? storyforgeFetch("/v2/admin/oneliner/governance/directory").catch(() => ({ items: [] })) : Promise.resolve({ items: [] }), + isSuperAdmin() + ? storyforgeFetch("/v2/admin/model-access/overview").catch(() => null) + : Promise.resolve(null), storyforgeFetch(`/v2/tenant/quota?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => null), storyforgeFetch(`/v2/tenant/usage?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => null), isSuperAdmin() @@ -2365,6 +2369,7 @@ async function loadAgentControlSurfaces(projectId = "") { appState.adminSystemMainPolicy = adminSystemMainPolicy; appState.adminSystemPlatformPolicies = safeArray(adminSystemPlatformPolicies); appState.adminGovernanceDirectory = safeArray(adminGovernanceDirectory?.items || adminGovernanceDirectory); + appState.adminModelAccess = adminModelAccess; if (isSuperAdmin() && appState.adminGovernanceDirectory.length) { const existingTarget = appState.adminOverrideTarget || {}; const hasExistingProjectTarget = Object.prototype.hasOwnProperty.call(existingTarget, "targetProjectId") @@ -5721,6 +5726,159 @@ function renderIntegrationOverviewPanel(options = {}) { `; } +function getAdminModelAccessState() { + return appState.adminModelAccess || { + runtime: {}, + system_model_profiles: [], + huobao_configs: {}, + provider_presets: {} + }; +} + +function focusAdminModelAccessWorkspace(anchorId = "admin-model-access-anchor") { + appState.adminWorkbenchTab = "model_access"; + setScreen("admin-workbench"); + renderAll(); + window.requestAnimationFrame(() => { + (document.getElementById(anchorId) || document.querySelector('[data-screen="admin-workbench"] .panel')) + ?.scrollIntoView({ behavior: "smooth", block: "start" }); + }); +} + +function renderAdminModelRuntimePanel() { + const runtime = getAdminModelAccessState().runtime || {}; + const rows = [ + { key: "huobao", label: "Huobao / 火山模型服务", tip: "文本、图片、视频模型与 Seedance 都从这里接。", action: "open-admin-huobao-ai-config", serviceType: "video" }, + { key: "n8n", label: "n8n 编排", tip: "异步流水线与 webhook 编排。", action: "open-admin-runtime-config" }, + { key: "asr", label: "ASR", tip: "当前是 Windows faster-whisper。", action: "open-admin-runtime-config" }, + { key: "cutvideo", label: "cutvideo", tip: "实拍剪辑与上传链。", action: "open-admin-runtime-config" }, + { key: "live_recorder", label: "live_recorder", tip: "视频录制服务。", action: "open-admin-runtime-config" }, + { key: "local_model", label: "本地模型", tip: "当前已经禁用,默认走公网模型。", action: "open-admin-runtime-config" } + ]; + return ` +
+
+
+

运行时接入

+
统一管理外部模型服务、编排服务和运行时地址。只有管理员可编辑。
+
+
+ 编辑运行时接入 +
+
+
+ ${rows.map((item) => { + const detail = runtime[item.key] || {}; + const summary = detail.reachable === false ? "离线" : detail.configured === false ? "未配置" : "在线"; + const baseUrl = detail.base_url || detail.url || "未配置"; + const deviceTag = item.key === "asr" && detail.active_device ? `${escapeHtml(`设备 ${detail.active_device}`)}` : ""; + const modelTag = item.key === "asr" && detail.model_name ? `${escapeHtml(detail.model_name)}` : ""; + const deploymentTag = detail.deploymentLabel ? `${escapeHtml(`部署:${detail.deploymentLabel}`)}` : ""; + return ` +
+

${escapeHtml(item.label)}

+

${escapeHtml(item.tip)}

+
+ ${escapeHtml(summary)} + ${deploymentTag} + ${deviceTag} + ${modelTag} + ${escapeHtml(item.key === "huobao" ? "配置模型服务" : "编辑接入")} +
+
${escapeHtml(baseUrl)}
+
+ `; + }).join("")} +
+
+ `; +} + +function renderAdminSystemModelPanel() { + const models = safeArray(getAdminModelAccessState().system_model_profiles); + return ` +
+
+
+

系统模型

+
给 StoryForge 主流程配置系统级文本模型入口,供分析、策略和主 Agent 默认使用。
+
+
+ 新增系统模型 +
+
+ ${models.length ? ` +
+ ${models.map((model) => ` +
+

${escapeHtml(model.name)}

+

${escapeHtml(`${model.provider || "openai_compat"} · ${model.model_name || "未命名模型"}`)}

+
+ ${model.is_default ? '系统默认' : ""} + ${escapeHtml(model.provider || "openai_compat")} + ${escapeHtml(model.model_name || "")} + 编辑 +
+
${escapeHtml(model.base_url || "未配置地址")}
+
${escapeHtml(`API Key:${model.api_key_masked || "未配置"}`)}
+
+ `).join("")} +
+ ` : renderEmptyState("还没有系统模型", "先新增一条系统模型,管理员就可以直接在这里维护分析与主 Agent 默认模型。")} +
+ `; +} + +function renderAdminHuobaoConfigPanel(serviceType, label, description) { + const bundle = getAdminModelAccessState().huobao_configs?.[serviceType] || { items: [], error: "" }; + const presets = safeArray(getAdminModelAccessState().provider_presets?.[serviceType]); + return ` +
+
+
+

${escapeHtml(label)}

+
${escapeHtml(description)}
+
+
+ 新增配置 +
+
+ ${presets.length ? `
${presets.map((item) => `${escapeHtml(`${item.label}:${safeArray(item.models).join(" / ")}`)}`).join("")}
` : ""} + ${bundle.error ? `
${escapeHtml(`当前读取失败:${bundle.error}`)}
` : ""} + ${safeArray(bundle.items).length ? ` +
+ ${safeArray(bundle.items).map((item) => ` +
+

${escapeHtml(item.name || `${label} 配置`)}

+

${escapeHtml(`${item.provider || "未标记厂商"} · ${(safeArray(item.model).join(" / ")) || "未配置模型"}`)}

+
+ ${escapeHtml(item.is_active === false ? "已停用" : "启用中")} + ${escapeHtml(`优先级 ${item.priority || 0}`)} + 编辑 + 删除 +
+
${escapeHtml(item.base_url || "未配置地址")}
+
${escapeHtml(`API Key:${item.api_key_masked || "未配置"}`)}
+
+ `).join("")} +
+ ` : renderEmptyState(`还没有${label}配置`, `先新增一条${label}配置,当前服务才会真正具备可用模型。`)} +
+ `; +} + +function renderAdminModelAccessPanel() { + return ` +
+ ${renderAdminModelRuntimePanel()} +
${renderAdminSystemModelPanel()}
+
${renderAdminHuobaoConfigPanel("text", "文本模型服务", "大模型文本、策略、文案与分析模型统一在这里维护。")}
+
${renderAdminHuobaoConfigPanel("image", "图片模型服务", "图片生成、封面和素材处理模型统一在这里维护。")}
+
${renderAdminHuobaoConfigPanel("video", "视频模型服务", "视频模型、Seedance 2.0 和火山视频引擎统一在这里维护。")}
+
+ `; +} + function buildMainAgentHandoffAttrs({ sourceScreen = "", sourceActionKey = "", @@ -6260,6 +6418,7 @@ function renderAdminWorkbenchScreen() { } const tabs = [ { value: "integrations", label: "依赖健康" }, + { value: "model_access", label: "模型与接入" }, { value: "storage", label: "存储状态" }, { value: "agents", label: "Agent 治理" }, { value: "governance_audit", label: "覆盖与审计" }, @@ -6281,6 +6440,8 @@ function renderAdminWorkbenchScreen() { ${renderDetailTabs("adminWorkbenchTab", tabs)} ${activeTab === "integrations" ? renderIntegrationOverviewPanel({ showActions: false }) + : activeTab === "model_access" + ? renderAdminModelAccessPanel() : activeTab === "storage" ? renderStorageStatusPanel() : activeTab === "agents" @@ -10368,6 +10529,175 @@ function focusAdminGovernanceAuditWorkspace(anchorId = "admin-override-audit-anc }, 0); } +function ensureAdminModelAccess() { + if (isSuperAdmin()) return true; + rememberAction("需要管理员权限", "模型与接入配置仅超级管理员可访问。", "orange"); + renderAll(); + return false; +} + +function getAdminModelProviderOptions(serviceType) { + const presets = safeArray(getAdminModelAccessState().provider_presets?.[serviceType]); + const options = presets.map((item) => ({ value: item.provider, label: item.label })); + return options.length ? options : [{ value: "openai", label: "OpenAI / 兼容" }]; +} + +function openAdminRuntimeConfigAction() { + if (!ensureAdminModelAccess()) return; + const runtime = getAdminModelAccessState().runtime || {}; + openActionModal({ + title: "编辑运行时接入", + description: "统一维护 StoryForge 依赖的外部模型服务、编排服务和运行时地址。留空表示禁用该接入。", + submitLabel: "保存运行时接入", + fields: [ + { name: "localModelBaseUrl", label: "本地模型 Base URL", value: runtime.local_model?.base_url || "", placeholder: "当前已禁用可留空" }, + { name: "localModelApiKey", label: "本地模型 API Key", value: "", placeholder: "留空则保持当前值或清空未启用项" }, + { name: "localModelDefaultModel", label: "本地模型默认模型", value: runtime.local_model?.default_model || "", placeholder: "例如:GLM-5" }, + { name: "asrHttpBaseUrl", label: "ASR Base URL", value: runtime.asr?.base_url || "", placeholder: "例如:http://192.168.31.18:8088" }, + { name: "n8nBaseUrl", label: "n8n Base URL", value: runtime.n8n?.base_url || "", placeholder: "例如:http://127.0.0.1:25670" }, + { name: "cutvideoBaseUrl", label: "cutvideo Base URL", value: runtime.cutvideo?.base_url || "", placeholder: "例如:http://192.168.31.18:7860" }, + { name: "cutvideoApiKey", label: "cutvideo API Key", value: "", placeholder: "留空则保持当前值" }, + { name: "huobaoBaseUrl", label: "Huobao Base URL", value: runtime.huobao?.base_url || "", placeholder: "例如:http://127.0.0.1:25678" }, + { name: "liveRecorderBaseUrl", label: "live_recorder Base URL", value: runtime.live_recorder?.base_url || "", placeholder: "例如:http://192.168.31.188:19106" } + ], + onSubmit: async (values) => { + await storyforgeFetch("/v2/admin/model-access/runtime", { + method: "PUT", + body: { + local_model_base_url: values.localModelBaseUrl || "", + local_model_api_key: values.localModelApiKey || "", + local_model_default_model: values.localModelDefaultModel || "", + asr_http_base_url: values.asrHttpBaseUrl || "", + n8n_base_url: values.n8nBaseUrl || "", + cutvideo_base_url: values.cutvideoBaseUrl || "", + cutvideo_api_key: values.cutvideoApiKey || "", + huobao_base_url: values.huobaoBaseUrl || "", + live_recorder_base_url: values.liveRecorderBaseUrl || "" + } + }); + await loadAgentControlSurfaces(getOneLinerProjectId()); + rememberAction("运行时接入已保存", "模型与服务运行时地址已经更新。", "green"); + renderAll(); + focusAdminModelAccessWorkspace("admin-model-runtime-anchor"); + } + }); +} + +function openAdminSystemModelAction(modelId = "") { + if (!ensureAdminModelAccess()) return; + const current = safeArray(getAdminModelAccessState().system_model_profiles).find((item) => item.id === modelId) || null; + openActionModal({ + title: current ? "编辑系统模型" : "新增系统模型", + description: "系统模型用于主 Agent、分析链和系统默认模型入口。管理员修改后,用户可在此基础上再做个人偏好。", + submitLabel: current ? "保存系统模型" : "创建系统模型", + fields: [ + { name: "name", label: "名称", value: current?.name || "", placeholder: "例如:系统默认 GPT-5.2" }, + { name: "provider", label: "提供方", value: current?.provider || "openai_compat", placeholder: "例如:openai_compat / gemini / openai" }, + { name: "baseUrl", label: "Base URL", value: current?.base_url || "", placeholder: "例如:https://api.openai.com/v1" }, + { name: "apiKey", label: "API Key", value: "", placeholder: current ? "留空则保持当前 Key" : "输入系统模型 Key" }, + { name: "modelName", label: "模型名", value: current?.model_name || "", placeholder: "例如:gpt-5.2" }, + { name: "isDefault", label: "设为系统默认", type: "checkbox", value: Boolean(current?.is_default) } + ], + onSubmit: async (values) => { + const url = current ? `/v2/admin/model-access/system-models/${encodeURIComponent(current.id)}` : "/v2/admin/model-access/system-models"; + const method = current ? "PUT" : "POST"; + await storyforgeFetch(url, { + method, + body: { + name: values.name || "", + provider: values.provider || "openai_compat", + base_url: values.baseUrl || "", + api_key: values.apiKey || "", + model_name: values.modelName || "", + is_default: Boolean(values.isDefault) + } + }); + await loadAgentControlSurfaces(getOneLinerProjectId()); + rememberAction(current ? "系统模型已更新" : "系统模型已创建", `${values.name || values.modelName || "系统模型"} 已保存。`, "green"); + renderAll(); + focusAdminModelAccessWorkspace("admin-system-models-anchor"); + } + }); +} + +async function openAdminHuobaoConfigAction(serviceType = "video", configId = "") { + if (!ensureAdminModelAccess()) return; + const normalizedServiceType = String(serviceType || "video").trim() || "video"; + const existing = safeArray(getAdminModelAccessState().huobao_configs?.[normalizedServiceType]?.items).find((item) => item.id === configId) || null; + const providerOptions = getAdminModelProviderOptions(normalizedServiceType); + openActionModal({ + title: existing ? "编辑模型接入配置" : "新增模型接入配置", + description: normalizedServiceType === "video" + ? "这里维护视频模型与 Seedance / 火山引擎配置。保存后,AI 视频和主 Agent 会直接读取这里的可用配置。" + : normalizedServiceType === "image" + ? "这里维护图片模型服务配置。" + : "这里维护文本大模型服务配置。", + submitLabel: existing ? "保存配置" : "创建配置", + fields: [ + { name: "provider", label: "提供方", type: "select", value: existing?.provider || providerOptions[0]?.value || "openai", options: providerOptions }, + { name: "name", label: "名称", value: existing?.name || "", placeholder: normalizedServiceType === "video" ? "例如:火山 Seedance 2.0" : "例如:系统默认文本模型" }, + { name: "baseUrl", label: "Base URL", value: existing?.base_url || "", placeholder: "例如:https://ark.cn-beijing.volces.com/api/v3" }, + { name: "apiKey", label: "API Key", value: "", placeholder: existing ? "留空则保持当前 Key" : "输入对应服务的 API Key" }, + { name: "modelCsv", label: "模型列表", value: safeArray(existing?.model).join(", "), placeholder: normalizedServiceType === "video" ? "例如:seedance-2.0-pro" : "多个模型用逗号分隔" }, + { name: "endpoint", label: "调用路径", value: existing?.endpoint || "", placeholder: normalizedServiceType === "video" ? "/chat/completions 或 /videos" : "留空则按服务默认" }, + { name: "queryEndpoint", label: "异步查询路径", value: existing?.query_endpoint || "", placeholder: "视频任务可填写查询路径" }, + { name: "priority", label: "优先级", type: "number", value: existing?.priority || 100, min: 0, max: 999 }, + { name: "isActive", label: "启用配置", type: "checkbox", value: existing ? existing.is_active !== false : true }, + { name: "settings", label: "附加设置 JSON", type: "textarea", rows: 4, value: existing?.settings || "", placeholder: "{\"timeout\": 120}" } + ], + onSubmit: async (values) => { + const payload = { + service_type: normalizedServiceType, + provider: values.provider || "", + name: values.name || "", + base_url: values.baseUrl || "", + api_key: values.apiKey || "", + model: String(values.modelCsv || "").split(",").map((item) => item.trim()).filter(Boolean), + endpoint: values.endpoint || "", + query_endpoint: values.queryEndpoint || "", + priority: Number(values.priority || 0), + is_active: Boolean(values.isActive), + settings: values.settings || "" + }; + const url = existing ? `/v2/admin/model-access/huobao-configs/${encodeURIComponent(existing.id)}` : "/v2/admin/model-access/huobao-configs"; + await storyforgeFetch(url, { + method: existing ? "PUT" : "POST", + body: payload + }); + await loadAgentControlSurfaces(getOneLinerProjectId()); + rememberAction(existing ? "模型接入配置已更新" : "模型接入配置已创建", `${values.name || "模型配置"} 已保存。`, "green"); + renderAll(); + focusAdminModelAccessWorkspace(`admin-model-${normalizedServiceType}-anchor`); + } + }); +} + +function openAdminHuobaoConfigDeleteAction(serviceType = "video", configId = "") { + if (!ensureAdminModelAccess()) return; + const normalizedServiceType = String(serviceType || "video").trim() || "video"; + const existing = safeArray(getAdminModelAccessState().huobao_configs?.[normalizedServiceType]?.items).find((item) => item.id === configId) || null; + if (!existing?.id) { + rememberAction("没有找到模型配置", "当前配置已经不存在或还没刷新到本地。", "orange"); + renderAll(); + return; + } + openActionModal({ + title: "删除模型接入配置", + description: "删除后,这条配置将不再参与该模型服务的实际调度。", + submitLabel: "确认删除", + fields: [ + { type: "html", label: "当前配置", html: `

${escapeHtml(existing.name || "模型配置")}

${escapeHtml(`${existing.provider || "未标记厂商"} · ${(safeArray(existing.model).join(" / ")) || "未配置模型"}`)}

` } + ], + onSubmit: async () => { + await storyforgeFetch(`/v2/admin/model-access/huobao-configs/${encodeURIComponent(existing.id)}`, { method: "DELETE" }); + await loadAgentControlSurfaces(getOneLinerProjectId()); + rememberAction("模型接入配置已删除", `${existing.name || "模型配置"} 已移除。`, "green"); + renderAll(); + focusAdminModelAccessWorkspace(`admin-model-${normalizedServiceType}-anchor`); + } + }); +} + function openUserGlobalPolicyAction() { const project = requireSelectedProject(); const bundle = appState.userGlobalPolicy || {}; @@ -12165,6 +12495,7 @@ function renderAiVideoProviderHintHtml(provider = "doubao") {
${escapeHtml(configStatus)} ${huobao.deploymentLabel ? `${escapeHtml(`部署:${huobao.deploymentLabel}`)}` : ""} + ${isSuperAdmin() ? '打开视频引擎配置' : ""} 查看火山配置状态
@@ -12195,6 +12526,7 @@ function renderAiVideoProviderMemoryHtml(projectId = "", preferences = {}) {
${escapeHtml(providerLabel)} ${normalizedModel ? `${escapeHtml(normalizedModel)}` : ""} + ${isSuperAdmin() ? '打开视频引擎配置' : ""} 查看火山配置状态
@@ -12959,8 +13291,33 @@ document.addEventListener("click", async (event) => { return; } if (name === "focus-huobao-video-config") { - captureMainAgentLandingContext(action, "goto-automation"); - focusAutomationHealthWorkspace("integration-huobao-anchor"); + if (isSuperAdmin()) { + captureMainAgentLandingContext(action, "goto-admin-workbench"); + focusAdminModelAccessWorkspace("admin-model-video-anchor"); + } else { + captureMainAgentLandingContext(action, "goto-automation"); + focusAutomationHealthWorkspace("integration-huobao-anchor"); + } + return; + } + if (name === "focus-admin-model-access") { + focusAdminModelAccessWorkspace(action.dataset.anchorId || "admin-model-access-anchor"); + return; + } + if (name === "open-admin-runtime-config") { + openAdminRuntimeConfigAction(); + return; + } + if (name === "open-admin-system-model") { + openAdminSystemModelAction(action.dataset.modelId || ""); + return; + } + if (name === "open-admin-huobao-ai-config") { + await openAdminHuobaoConfigAction(action.dataset.serviceType || "video", action.dataset.configId || ""); + return; + } + if (name === "delete-admin-huobao-ai-config") { + openAdminHuobaoConfigDeleteAction(action.dataset.serviceType || "video", action.dataset.configId || ""); return; } if (name === "goto-strategy") { diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs index 5f9c1c7..e480783 100644 --- a/web/storyforge-web-v4/tests/workbench-pages.test.mjs +++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs @@ -256,12 +256,32 @@ test("discovery, production, and admin screens use page tabs for heavy content", assert.match(production, /renderDetailTabs\("productionDetailTab"/); assert.match(production, /value: "recorder", label: "视频录制"/); assert.match(admin, /renderDetailTabs\("adminWorkbenchTab"/); + assert.match(admin, /value: "model_access", label: "模型与接入"/); + assert.match(admin, /renderAdminModelAccessPanel\(/); assert.match(admin, /renderAdminGovernanceSummaryPanel\(/); assert.match(admin, /覆盖与审计/); assert.match(strategy, /renderDetailTabs\("strategyDetailTab"/); assert.match(strategy, /renderPolicyAuditFeed\(/); }); +test("admin workbench exposes a dedicated model access workspace and actions", () => { + const admin = extractBetween(APP, "function renderAdminModelRuntimePanel()", "function renderDashboardScreen()"); + const loadControls = extractBetween(APP, "async function loadAgentControlSurfaces(", "async function loadOneLinerMessages("); + const clickActions = extractBetween(APP, "document.addEventListener(\"click\", async (event) => {", "document.addEventListener(\"submit\", async (event) => {"); + assert.match(APP, /adminModelAccess:\s*null/); + assert.match(APP, /function focusAdminModelAccessWorkspace\(anchorId = "admin-model-access-anchor"\)/); + assert.match(loadControls, /\/v2\/admin\/model-access\/overview/); + assert.match(admin, /运行时接入/); + assert.match(admin, /系统模型/); + assert.match(admin, /视频模型服务/); + assert.match(admin, /open-admin-runtime-config/); + assert.match(admin, /open-admin-system-model/); + assert.match(admin, /open-admin-huobao-ai-config/); + assert.match(clickActions, /name === "open-admin-runtime-config"/); + assert.match(clickActions, /name === "open-admin-system-model"/); + assert.match(clickActions, /name === "open-admin-huobao-ai-config"/); +}); + test("governance and quota panels use real empty-state language instead of backend-sync placeholders", () => { const actionRegistry = extractBetween(APP, "function renderOneLinerActionRegistryPanel()", "function renderTenantQuotaPanel()"); const tenantQuota = extractBetween(APP, "function renderTenantQuotaPanel()", "function policyScopeTagLabel("); @@ -414,6 +434,14 @@ test("job detail and follow-up flows use direct generate-copy execution and pers assert.match(clickActions, /name === "job-to-generate-copy"[\s\S]*runDirectWorkbenchAction\("generate-copy"/); }); +test("ai video provider hint links super admins into the huobao video config workspace", () => { + const hintSource = extractBetween(APP, "function renderAiVideoProviderHintHtml(provider = \"doubao\") {", "function renderAiVideoProviderMemoryHtml("); + const clickActions = extractBetween(APP, "document.addEventListener(\"click\", async (event) => {", "document.addEventListener(\"submit\", async (event) => {"); + assert.match(hintSource, /打开视频引擎配置/); + assert.match(hintSource, /focus-huobao-video-config/); + assert.match(clickActions, /name === "focus-huobao-video-config"[\s\S]*focusAdminModelAccessWorkspace\("admin-model-video-anchor"\)/); +}); + test("pipeline follow-up tags route source-job actions through direct execute handlers", () => { const pipelineGuards = extractBetween(APP, "const PIPELINE_GUARDS = {", "const ONELINER_INTENT_LABELS = {"); const clickActions = extractBetween(APP, "document.addEventListener(\"click\", async (event) => {", "document.addEventListener(\"submit\", async (event) => {");