feat: add admin model access center
This commit is contained in:
18
CHANGELOG.md
18
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 配置` 提示现在不再只是静态文案,而是新增了 `查看火山配置状态` 入口。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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") {
|
||||
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") {
|
||||
|
||||
@@ -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) => {");
|
||||
|
||||
Reference in New Issue
Block a user