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 ` +
${escapeHtml(item.tip)}
+ +${escapeHtml(`${model.provider || "openai_compat"} · ${model.model_name || "未命名模型"}`)}
+ +${escapeHtml(`${item.provider || "未标记厂商"} · ${(safeArray(item.model).join(" / ")) || "未配置模型"}`)}
+ +${escapeHtml(`${existing.provider || "未标记厂商"} · ${(safeArray(existing.model).join(" / ")) || "未配置模型"}`)}