feat: add admin model access center
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled

This commit is contained in:
kris
2026-04-07 19:03:46 +08:00
parent 07680dce4f
commit 9e4f32077e
7 changed files with 938 additions and 5 deletions

View File

@@ -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 配置` 提示现在不再只是静态文案,而是新增了 `查看火山配置状态` 入口。

View File

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

View File

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

View File

@@ -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")
@@ -219,6 +220,10 @@ class MainAgentGovernanceTests(unittest.TestCase):
)
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,
*,

View File

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

View File

@@ -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 `
<div class="panel pad" id="admin-model-runtime-anchor">
<div class="panel-head">
<div>
<h3>运行时接入</h3>
<div class="panel-subtitle">统一管理外部模型服务、编排服务和运行时地址。只有管理员可编辑。</div>
</div>
<div class="task-meta">
<span class="tag clickable-tag" data-action="open-admin-runtime-config">编辑运行时接入</span>
</div>
</div>
<div class="layout-grid grid-2">
${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 ? `<span class="tag">${escapeHtml(`设备 ${detail.active_device}`)}</span>` : "";
const modelTag = item.key === "asr" && detail.model_name ? `<span class="tag">${escapeHtml(detail.model_name)}</span>` : "";
const deploymentTag = detail.deploymentLabel ? `<span class="tag">${escapeHtml(`部署:${detail.deploymentLabel}`)}</span>` : "";
return `
<div class="task-item compact" id="admin-model-runtime-${escapeHtml(item.key)}-anchor">
<h4>${escapeHtml(item.label)}</h4>
<p>${escapeHtml(item.tip)}</p>
<div class="task-meta">
<span class="tag ${detail.reachable === false ? "orange" : detail.configured === false ? "" : "green"}">${escapeHtml(summary)}</span>
${deploymentTag}
${deviceTag}
${modelTag}
<span class="tag clickable-tag" data-action="${escapeHtml(item.action)}"${item.serviceType ? ` data-service-type="${escapeHtml(item.serviceType)}"` : ""}>${escapeHtml(item.key === "huobao" ? "配置模型服务" : "编辑接入")}</span>
</div>
<div class="integration-url">${escapeHtml(baseUrl)}</div>
</div>
`;
}).join("")}
</div>
</div>
`;
}
function renderAdminSystemModelPanel() {
const models = safeArray(getAdminModelAccessState().system_model_profiles);
return `
<div class="panel pad" id="admin-system-models-anchor">
<div class="panel-head">
<div>
<h3>系统模型</h3>
<div class="panel-subtitle"> StoryForge 主流程配置系统级文本模型入口供分析策略和主 Agent 默认使用</div>
</div>
<div class="task-meta">
<span class="tag clickable-tag" data-action="open-admin-system-model">新增系统模型</span>
</div>
</div>
${models.length ? `
<div class="layout-grid grid-2">
${models.map((model) => `
<div class="task-item compact" id="admin-system-model-${escapeHtml(model.id)}">
<h4>${escapeHtml(model.name)}</h4>
<p>${escapeHtml(`${model.provider || "openai_compat"} · ${model.model_name || "未命名模型"}`)}</p>
<div class="task-meta">
${model.is_default ? '<span class="tag green">系统默认</span>' : ""}
<span class="tag">${escapeHtml(model.provider || "openai_compat")}</span>
<span class="tag">${escapeHtml(model.model_name || "")}</span>
<span class="tag clickable-tag" data-action="open-admin-system-model" data-model-id="${escapeHtml(model.id)}">编辑</span>
</div>
<div class="integration-url">${escapeHtml(model.base_url || "未配置地址")}</div>
<div class="integration-note">${escapeHtml(`API Key${model.api_key_masked || "未配置"}`)}</div>
</div>
`).join("")}
</div>
` : renderEmptyState("还没有系统模型", "先新增一条系统模型,管理员就可以直接在这里维护分析与主 Agent 默认模型。")}
</div>
`;
}
function renderAdminHuobaoConfigPanel(serviceType, label, description) {
const bundle = getAdminModelAccessState().huobao_configs?.[serviceType] || { items: [], error: "" };
const presets = safeArray(getAdminModelAccessState().provider_presets?.[serviceType]);
return `
<div class="panel pad" id="admin-model-${escapeHtml(serviceType)}-anchor">
<div class="panel-head">
<div>
<h3>${escapeHtml(label)}</h3>
<div class="panel-subtitle">${escapeHtml(description)}</div>
</div>
<div class="task-meta">
<span class="tag clickable-tag" data-action="open-admin-huobao-ai-config" data-service-type="${escapeHtml(serviceType)}">新增配置</span>
</div>
</div>
${presets.length ? `<div class="task-meta" style="margin-bottom:10px;">${presets.map((item) => `<span class="tag">${escapeHtml(`${item.label}${safeArray(item.models).join(" / ")}`)}</span>`).join("")}</div>` : ""}
${bundle.error ? `<div class="integration-note">${escapeHtml(`当前读取失败:${bundle.error}`)}</div>` : ""}
${safeArray(bundle.items).length ? `
<div class="layout-grid grid-2">
${safeArray(bundle.items).map((item) => `
<div class="task-item compact" id="admin-huobao-${escapeHtml(serviceType)}-${escapeHtml(item.id)}">
<h4>${escapeHtml(item.name || `${label} 配置`)}</h4>
<p>${escapeHtml(`${item.provider || "未标记厂商"} · ${(safeArray(item.model).join(" / ")) || "未配置模型"}`)}</p>
<div class="task-meta">
<span class="tag ${item.is_active === false ? "orange" : "green"}">${escapeHtml(item.is_active === false ? "已停用" : "启用中")}</span>
<span class="tag">${escapeHtml(`优先级 ${item.priority || 0}`)}</span>
<span class="tag clickable-tag" data-action="open-admin-huobao-ai-config" data-service-type="${escapeHtml(serviceType)}" data-config-id="${escapeHtml(item.id)}">编辑</span>
<span class="tag clickable-tag" data-action="delete-admin-huobao-ai-config" data-service-type="${escapeHtml(serviceType)}" data-config-id="${escapeHtml(item.id)}">删除</span>
</div>
<div class="integration-url">${escapeHtml(item.base_url || "未配置地址")}</div>
<div class="integration-note">${escapeHtml(`API Key${item.api_key_masked || "未配置"}`)}</div>
</div>
`).join("")}
</div>
` : renderEmptyState(`还没有${label}配置`, `先新增一条${label}配置,当前服务才会真正具备可用模型。`)}
</div>
`;
}
function renderAdminModelAccessPanel() {
return `
<div id="admin-model-access-anchor">
${renderAdminModelRuntimePanel()}
<div style="margin-top:18px;">${renderAdminSystemModelPanel()}</div>
<div style="margin-top:18px;">${renderAdminHuobaoConfigPanel("text", "文本模型服务", "大模型文本、策略、文案与分析模型统一在这里维护。")}</div>
<div style="margin-top:18px;">${renderAdminHuobaoConfigPanel("image", "图片模型服务", "图片生成、封面和素材处理模型统一在这里维护。")}</div>
<div style="margin-top:18px;">${renderAdminHuobaoConfigPanel("video", "视频模型服务", "视频模型、Seedance 2.0 和火山视频引擎统一在这里维护。")}</div>
</div>
`;
}
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: `<div class="task-item compact"><h4>${escapeHtml(existing.name || "模型配置")}</h4><p>${escapeHtml(`${existing.provider || "未标记厂商"} · ${(safeArray(existing.model).join(" / ")) || "未配置模型"}`)}</p></div>` }
],
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") {
<div class="task-meta">
<span class="tag ${huobao.videoConfigReady ? "green" : "orange"}">${escapeHtml(configStatus)}</span>
${huobao.deploymentLabel ? `<span class="tag">${escapeHtml(`部署:${huobao.deploymentLabel}`)}</span>` : ""}
${isSuperAdmin() ? '<span class="tag clickable-tag" data-action="open-admin-huobao-ai-config" data-service-type="video">打开视频引擎配置</span>' : ""}
<span class="tag clickable-tag" data-action="focus-huobao-video-config">查看火山配置状态</span>
</div>
</div>
@@ -12195,6 +12526,7 @@ function renderAiVideoProviderMemoryHtml(projectId = "", preferences = {}) {
<div class="task-meta">
<span class="tag blue">${escapeHtml(providerLabel)}</span>
${normalizedModel ? `<span class="tag">${escapeHtml(normalizedModel)}</span>` : ""}
${isSuperAdmin() ? '<span class="tag clickable-tag" data-action="open-admin-huobao-ai-config" data-service-type="video">打开视频引擎配置</span>' : ""}
<span class="tag clickable-tag" data-action="focus-huobao-video-config">查看火山配置状态</span>
</div>
</div>
@@ -12959,8 +13291,33 @@ document.addEventListener("click", async (event) => {
return;
}
if (name === "focus-huobao-video-config") {
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") {

View File

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