From ea6a855890409d877cf27cd720490696e19bf320 Mon Sep 17 00:00:00 2001 From: kris Date: Mon, 23 Mar 2026 05:21:48 +0800 Subject: [PATCH] feat: surface cutvideo upload capability in health ui --- collector-service/app/main.py | 71 +++++++++++++++++- web/storyforge-web-v4/assets/app.js | 111 +++++++++++++++++++++++++--- 2 files changed, 171 insertions(+), 11 deletions(-) diff --git a/collector-service/app/main.py b/collector-service/app/main.py index 660fe49..6664b32 100644 --- a/collector-service/app/main.py +++ b/collector-service/app/main.py @@ -1459,6 +1459,58 @@ def probe_http(url: str, path: str = "", timeout: float = 3.0) -> dict[str, Any] return tcp +def local_model_public_base_url() -> str: + if not LOCAL_OPENAI_BASE_URL: + return "" + parsed = urlparse(LOCAL_OPENAI_BASE_URL) + scheme = parsed.scheme or "http" + host = parsed.hostname or "127.0.0.1" + if host in {"host.docker.internal", "localhost"}: + host = "127.0.0.1" + port = parsed.port + root = f"{scheme}://{host}" + if port: + root = f"{root}:{port}" + return root + + +def fetch_local_model_catalog(timeout: float = 8.0) -> dict[str, Any]: + detail = probe_http(LOCAL_OPENAI_BASE_URL, "/models", timeout=timeout) + public_base_url = local_model_public_base_url() + management_url = f"{public_base_url}/management.html" if public_base_url else "" + payload = { + "configured": detail.get("configured", False), + "reachable": detail.get("reachable", False), + "base_url": LOCAL_OPENAI_BASE_URL, + "public_base_url": public_base_url, + "management_url": management_url, + "default_model": LOCAL_OPENAI_MODEL, + "models": [], + "status_code": detail.get("status_code", 0), + "error": detail.get("error", ""), + "url": detail.get("url", ""), + } + if not detail.get("configured") or not detail.get("reachable"): + return payload + try: + response = httpx.get(urljoin(LOCAL_OPENAI_BASE_URL if LOCAL_OPENAI_BASE_URL.endswith("/") else f"{LOCAL_OPENAI_BASE_URL}/", "models"), timeout=timeout) + response.raise_for_status() + data = response.json() + payload["models"] = [ + { + "id": item.get("id", ""), + "owned_by": item.get("owned_by", ""), + "created": item.get("created", 0), + } + for item in (data.get("data") or []) + if isinstance(item, dict) + ] + except Exception as exc: # pragma: no cover - operational probe + payload["reachable"] = False + payload["error"] = str(exc) + return payload + + @app.on_event("startup") def on_startup() -> None: db.init_schema() @@ -1483,6 +1535,13 @@ def healthz() -> dict[str, Any]: @app.get("/v2/integrations/health") def integrations_health(account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]: _ = account + cutvideo_bootstrap = probe_http(CUTVIDEO_BASE_URL, "/api/bootstrap", timeout=5.0) + cutvideo_uploads = probe_http(CUTVIDEO_BASE_URL, "/api/uploads", timeout=5.0) + cutvideo_supports_uploads = bool( + cutvideo_uploads.get("configured") + and cutvideo_uploads.get("reachable") + and int(cutvideo_uploads.get("status_code") or 0) != 404 + ) return { "local_model": { "base_url": LOCAL_OPENAI_BASE_URL, @@ -1490,7 +1549,11 @@ def integrations_health(account: dict[str, Any] = Depends(require_approved)) -> }, "cutvideo": { "base_url": CUTVIDEO_BASE_URL, - **probe_http(CUTVIDEO_BASE_URL, "/api/bootstrap"), + **cutvideo_bootstrap, + "supports_uploads": cutvideo_supports_uploads, + "upload_status_code": int(cutvideo_uploads.get("status_code") or 0), + "upload_error": cutvideo_uploads.get("error", ""), + "upload_url": cutvideo_uploads.get("url", ""), }, "huobao": { "base_url": HUOBAO_BASE_URL, @@ -1507,6 +1570,12 @@ def integrations_health(account: dict[str, Any] = Depends(require_approved)) -> } +@app.get("/v2/integrations/local-models") +def integrations_local_models(account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]: + _ = account + return fetch_local_model_catalog() + + def seed_defaults() -> None: if not db.fetch_one("SELECT id FROM model_profiles WHERE is_default = 1 LIMIT 1"): profile_id = make_id("model") diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index 95b507c..aab5987 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -24,6 +24,7 @@ const appState = { trackingDigest: null, reviews: [], integrationHealth: null, + localModelCatalog: null, busy: false, message: "", lastAction: null, @@ -597,13 +598,14 @@ async function bootstrap() { renderAll(); return; } - const [dashboard, contentSources, accounts, trackingAccountsPayload, reviews, integrationHealth] = await Promise.all([ + const [dashboard, contentSources, accounts, trackingAccountsPayload, reviews, integrationHealth, localModelCatalog] = await Promise.all([ storyforgeFetch("/v2/me/dashboard"), storyforgeFetch("/v2/content-sources").catch(() => []), storyforgeFetch("/v2/douyin/accounts").catch(() => []), storyforgeFetch("/v2/douyin/tracking/accounts").catch(() => ({ items: [], cursor_last_seen_at: "" })), storyforgeFetch("/v2/reviews").catch(() => []), - storyforgeFetch("/v2/integrations/health").catch(() => null) + storyforgeFetch("/v2/integrations/health").catch(() => null), + storyforgeFetch("/v2/integrations/local-models").catch(() => null) ]); const trackingCursorLastSeenAt = trackingAccountsPayload?.cursor_last_seen_at || ""; if (trackingCursorLastSeenAt) { @@ -622,6 +624,7 @@ async function bootstrap() { appState.trackingDigest = trackingDigest; appState.reviews = safeArray(reviews); appState.integrationHealth = integrationHealth; + appState.localModelCatalog = localModelCatalog; appState.documents = await loadKnowledgeDocuments(dashboard.knowledge_bases); appState.selectedProjectId = appState.selectedProjectId || dashboard.projects?.[0]?.id || ""; const selectedAssistantExists = safeArray(dashboard.assistants).some((item) => item.id === appState.selectedAssistantId); @@ -731,6 +734,14 @@ function getModelOptions() { return safeArray(appState.dashboard?.model_profiles).map((model) => ({ value: model.id, label: model.name })); } +function getCurrentModelProfile() { + const models = safeArray(appState.dashboard?.model_profiles); + const currentId = appState.me?.preferred_analysis_model_id + || models.find((item) => item.is_default)?.id + || ""; + return models.find((item) => item.id === currentId) || models.find((item) => item.is_default) || models[0] || null; +} + function getCompletedJobOptions() { return safeArray(appState.dashboard?.recent_jobs) .filter((item) => item.status === "completed") @@ -880,7 +891,11 @@ function getIntegrationDetail(key) { statusCode: Number(raw?.status_code || 0), error: String(raw?.error || ""), url: String(raw?.url || raw?.base_url || ""), - baseUrl: String(raw?.base_url || "") + baseUrl: String(raw?.base_url || ""), + supportsUploads: raw?.supports_uploads !== undefined ? Boolean(raw?.supports_uploads) : true, + uploadStatusCode: Number(raw?.upload_status_code || 0), + uploadError: String(raw?.upload_error || ""), + uploadUrl: String(raw?.upload_url || "") }; } @@ -888,6 +903,9 @@ function getIntegrationStatus(detail) { if (!detail.available) { return { tone: "blue", summary: "未拉取" }; } + if (detail.key === "cutvideo" && detail.reachable && !detail.supportsUploads) { + return { tone: "orange", summary: "缺上传能力" }; + } if (detail.reachable) { return { tone: "green", summary: "在线" }; } @@ -901,6 +919,9 @@ function describeIntegrationFailure(key) { const detail = getIntegrationDetail(key); const meta = INTEGRATION_META[key] || { label: key }; if (!detail.available) return `${meta.label}健康状态未拉取`; + if (key === "cutvideo" && detail.reachable && !detail.supportsUploads) { + return `${meta.label}缺少 /api/uploads`; + } if (!detail.configured) return `${meta.label}未配置`; if (detail.statusCode) return `${meta.label}返回 HTTP ${detail.statusCode}`; if (detail.error) return `${meta.label}${brief(detail.error, 42)}`; @@ -914,7 +935,12 @@ function getPipelineGuard(kind) { } const blocked = config.dependencies .map((key) => ({ key, detail: getIntegrationDetail(key), meta: INTEGRATION_META[key] || { label: key } })) - .filter((item) => item.detail.available && !item.detail.reachable); + .filter((item) => { + if (!item.detail.available) return false; + if (!item.detail.reachable) return true; + if (item.key === "cutvideo" && !item.detail.supportsUploads) return true; + return false; + }); if (!blocked.length) { return { enabled: true, reason: "", blocked: [] }; } @@ -926,6 +952,8 @@ function getPipelineGuard(kind) { } function getIntegrationCards() { + const currentModel = getCurrentModelProfile(); + const localCatalog = appState.localModelCatalog || {}; return INTEGRATION_ORDER.map((key) => { const detail = getIntegrationDetail(key); const status = getIntegrationStatus(detail); @@ -933,9 +961,15 @@ function getIntegrationCards() { let note = "尚未获取健康检查数据"; if (detail.available) { if (detail.reachable) { - note = detail.statusCode + if (key === "cutvideo" && !detail.supportsUploads) { + note = detail.uploadStatusCode + ? `主服务在线,但 /api/uploads 返回 HTTP ${detail.uploadStatusCode}` + : (detail.uploadError ? brief(detail.uploadError, 72) : "主服务在线,但缺少上传接口"); + } else { + note = detail.statusCode ? `健康探测返回 HTTP ${detail.statusCode}` : "TCP 探测已通过"; + } } else if (!detail.configured) { note = "后端还没有配置该依赖地址"; } else if (detail.statusCode) { @@ -946,12 +980,31 @@ function getIntegrationCards() { note = "探测失败,请检查服务进程和网络"; } } + let extra = ""; + let actions = ""; + if (key === "local_model") { + const availableModels = safeArray(localCatalog.models).map((item) => item.id).filter(Boolean); + extra = currentModel + ? `当前主模型:${currentModel.name} · ${currentModel.model_name || "-"}` + : `默认模型:${localCatalog.default_model || "GLM-5"}`; + if (availableModels.length) { + extra += ` · 可用:${availableModels.slice(0, 4).join(" / ")}${availableModels.length > 4 ? "…" : ""}`; + } + actions = [ + localCatalog.management_url + ? `打开管理页` + : "", + `设主模型` + ].filter(Boolean).join(""); + } return { key, meta, detail, status, - note + note, + extra, + actions }; }); } @@ -1184,7 +1237,9 @@ function renderIntegrationOverviewPanel(options = {}) { ${item.detail.statusCode ? `HTTP ${escapeHtml(item.detail.statusCode)}` : ""}
${escapeHtml(item.note)}
+ ${item.extra ? `
${escapeHtml(item.extra)}
` : ""}
${escapeHtml(item.detail.url || item.detail.baseUrl || "未提供探测地址")}
+ ${item.actions ? `
${item.actions}
` : ""} `).join("")} @@ -1789,6 +1844,9 @@ function renderPlaybookScreen() { } const assistants = safeArray(appState.dashboard.assistants); const models = safeArray(appState.dashboard.model_profiles); + const currentModel = getCurrentModelProfile(); + const localCatalog = appState.localModelCatalog || {}; + const gatewayModels = safeArray(localCatalog.models).map((item) => item.id).filter(Boolean); return screenShell( "Agent", "这里接真实 Agent 列表,后面再继续补创建和编辑动作。", @@ -1801,6 +1859,25 @@ function renderPlaybookScreen() { ${models.slice(0, 6).map((model) => `${escapeHtml(model.name)}`).join("") || `暂无模型`} +
+
+
+

本机模型网关

+
当前默认分析会优先走本机 cli-proxy-api
+
+
+ ${escapeHtml(localCatalog.reachable ? "在线" : "离线")} + ${localCatalog.management_url ? `打开管理页` : ""} +
+
+
+

${escapeHtml(currentModel?.name || localCatalog.default_model || "GLM-5")}

+

${escapeHtml(currentModel ? `${currentModel.model_name || "-"} · ${currentModel.base_url || "-"}` : (localCatalog.public_base_url || localCatalog.base_url || "尚未读取到网关地址"))}

+
+ ${gatewayModels.slice(0, 6).map((model) => `${escapeHtml(model)}`).join("") || `暂无可见模型`} +
+
+

模型列表

来自真实 model_profiles
@@ -2088,15 +2165,29 @@ async function createProject() { function openPreferredModelAction() { const models = getModelOptions(); - const currentId = appState.me?.preferred_analysis_model_id - || safeArray(appState.dashboard?.model_profiles).find((item) => item.is_default)?.id - || models[0]?.value - || ""; + const currentProfile = getCurrentModelProfile(); + const currentId = currentProfile?.id || models[0]?.value || ""; + const localCatalog = appState.localModelCatalog || {}; + const gatewayModels = safeArray(localCatalog.models).map((item) => item.id).filter(Boolean); openActionModal({ title: "设置分析主模型", description: "后续导入分析、市场调研和风格学习会优先使用这里设置的模型。", submitLabel: "保存模型", fields: [ + { + type: "html", + label: "本机模型网关", + html: ` +
+

${escapeHtml(localCatalog.reachable ? "网关在线" : "网关离线")}

+

${escapeHtml(currentProfile ? `当前主模型:${currentProfile.name} · ${currentProfile.model_name || "-"}` : `默认模型:${localCatalog.default_model || "GLM-5"}`)}

+
+ ${gatewayModels.slice(0, 6).map((model) => `${escapeHtml(model)}`).join("") || `暂未读取到模型目录`} + ${localCatalog.management_url ? `打开管理页` : ""} +
+
+ ` + }, { name: "modelProfileId", label: "主模型", type: "select", value: currentId, options: models } ], onSubmit: async (values) => {