2 Commits

Author SHA1 Message Date
kris
ea6a855890 feat: surface cutvideo upload capability in health ui 2026-03-23 05:21:48 +08:00
kris
042188f954 feat: restore local model gateway via cliproxy 2026-03-22 18:58:55 +08:00
8 changed files with 275 additions and 12 deletions

View File

@@ -33,3 +33,7 @@ WEBHOOK_URL=http://127.0.0.1:5670/
GENERIC_TIMEZONE=Asia/Shanghai
TZ=Asia/Shanghai
CLIPROXY_IMAGE=storyforge/cli-proxy-api:patched
CLIPROXY_MANAGEMENT_SECRET=storyforge-local-management
CLIPROXY_DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# Optional but recommended for local model gateway recovery.
# DASHSCOPE_API_KEY=

View File

@@ -107,6 +107,14 @@ cp .env.example .env
docker compose up -d --build
```
如果要让本机模型网关 `cli-proxy-api` 自动提供 `GLM-5`,建议在启动前确保本机环境里存在:
```bash
export DASHSCOPE_API_KEY=your_dashscope_key
```
或者把它写进本地 `.env``./scripts/start_business.sh` 会自动生成 `data/cliproxyapi/config.yaml` 并把 `glm-5 -> GLM-5` 映射到本机网关。
如果 `collector` 跑在 Docker 里,建议保留:
```bash

View File

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

View File

@@ -65,6 +65,14 @@ services:
image: ${CLIPROXY_IMAGE:-storyforge/cli-proxy-api:patched}
container_name: storyforge-cliproxyapi
restart: unless-stopped
command:
- ./CLIProxyAPI
- -config
- /CLIProxyAPI/config.yaml
volumes:
- ./data/cliproxyapi/config.yaml:/CLIProxyAPI/config.yaml:ro
- ./data/cliproxyapi/auths:/root/.cli-proxy-api
- ./data/cliproxyapi/logs:/CLIProxyAPI/logs
ports:
- "8317:8317"
- "8085:8085"

View File

@@ -0,0 +1,73 @@
#!/bin/sh
set -eu
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
if [ -f "$ROOT/.env" ]; then
set -a
# shellcheck disable=SC1091
. "$ROOT/.env"
set +a
fi
DATA_DIR="$ROOT/data/cliproxyapi"
CONFIG_PATH="$DATA_DIR/config.yaml"
mkdir -p "$DATA_DIR/auths" "$DATA_DIR/logs"
: "${CLIPROXY_MANAGEMENT_SECRET:=storyforge-local-management}"
: "${CLIPROXY_DASHSCOPE_BASE_URL:=https://dashscope.aliyuncs.com/compatible-mode/v1}"
python3 - <<'PY' "$CONFIG_PATH" "$CLIPROXY_MANAGEMENT_SECRET" "$CLIPROXY_DASHSCOPE_BASE_URL"
import os
import sys
from pathlib import Path
config_path = Path(sys.argv[1])
management_secret = sys.argv[2]
base_url = sys.argv[3]
dashscope_api_key = os.environ.get("DASHSCOPE_API_KEY", "").strip()
lines = [
'host: ""',
'port: 8317',
'tls:',
' enable: false',
' cert: ""',
' key: ""',
'remote-management:',
' allow-remote: false',
f' secret-key: "{management_secret}"',
' disable-control-panel: false',
'auth-dir: "/root/.cli-proxy-api"',
'debug: false',
'logging-to-file: true',
'logs-max-total-size-mb: 200',
'usage-statistics-enabled: true',
'request-retry: 2',
]
if dashscope_api_key:
lines.extend(
[
'openai-compatibility:',
' - name: "dashscope"',
f' base-url: "{base_url}"',
' api-key-entries:',
f' - api-key: "{dashscope_api_key}"',
' models:',
' - name: "glm-5"',
' alias: "GLM-5"',
' - name: "glm-5"',
' alias: "glm-5"',
' - name: "qwen3.5-plus"',
' alias: "qwen3.5-plus"',
]
)
config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
if dashscope_api_key:
print(f"rendered cliproxy config with DashScope upstream -> {config_path}")
else:
print(f"rendered cliproxy config without upstream credentials -> {config_path}")
PY

View File

@@ -5,7 +5,14 @@ ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
COMPOSE_FILE="$ROOT/docker-compose.yml"
cd "$ROOT"
docker compose -f "$COMPOSE_FILE" up -d --build collector n8n
"$ROOT/scripts/render_cliproxy_config.sh"
OWNER="$(docker inspect storyforge-cliproxyapi --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}' 2>/dev/null || true)"
if [ -n "$OWNER" ] && [ "$OWNER" != "$ROOT" ]; then
docker rm -f storyforge-cliproxyapi >/dev/null 2>&1 || true
fi
docker compose -f "$COMPOSE_FILE" up -d --build collector n8n cli-proxy-api
python3 - <<'PY'
import time
@@ -14,6 +21,7 @@ import urllib.request
checks = [
("collector", "http://127.0.0.1:8081/healthz"),
("n8n", "http://127.0.0.1:5670/healthz"),
("cli-proxy-api", "http://127.0.0.1:8317/v1/models"),
]
deadline = time.time() + 45
@@ -37,3 +45,4 @@ PY
echo "business started"
echo "collector: http://127.0.0.1:8081/healthz"
echo "n8n: http://127.0.0.1:5670/healthz"
echo "cli-proxy-api: http://127.0.0.1:8317/v1/models"

View File

@@ -13,6 +13,7 @@ import urllib.request
for name, url in [
("collector", "http://127.0.0.1:8081/healthz"),
("n8n", "http://127.0.0.1:5670/healthz"),
("cli-proxy-api", "http://127.0.0.1:8317/v1/models"),
]:
try:
with urllib.request.urlopen(url, timeout=5) as resp:

View File

@@ -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
? `<a class="tag blue" href="${escapeHtml(localCatalog.management_url)}" target="_blank" rel="noreferrer">打开管理页</a>`
: "",
`<span class="tag clickable-tag" data-action="open-preferred-model">设主模型</span>`
].filter(Boolean).join("");
}
return {
key,
meta,
detail,
status,
note
note,
extra,
actions
};
});
}
@@ -1184,7 +1237,9 @@ function renderIntegrationOverviewPanel(options = {}) {
${item.detail.statusCode ? `<span class="tag">HTTP ${escapeHtml(item.detail.statusCode)}</span>` : ""}
</div>
<div class="integration-note">${escapeHtml(item.note)}</div>
${item.extra ? `<div class="integration-note">${escapeHtml(item.extra)}</div>` : ""}
<div class="integration-url">${escapeHtml(item.detail.url || item.detail.baseUrl || "未提供探测地址")}</div>
${item.actions ? `<div class="task-meta integration-highlights" style="margin-top:12px;">${item.actions}</div>` : ""}
</div>
`).join("")}
</div>
@@ -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) => `<span class="chip ${model.is_default ? "active" : ""}">${escapeHtml(model.name)}</span>`).join("") || `<span class="chip active">暂无模型</span>`}
</div>
</div>
<div class="panel pad" style="margin-top:18px;">
<div class="panel-head">
<div>
<h3>本机模型网关</h3>
<div class="panel-subtitle">当前默认分析会优先走本机 cli-proxy-api</div>
</div>
<div class="task-meta">
<span class="tag ${escapeHtml(localCatalog.reachable ? "green" : "red")}">${escapeHtml(localCatalog.reachable ? "在线" : "离线")}</span>
${localCatalog.management_url ? `<a class="tag blue" href="${escapeHtml(localCatalog.management_url)}" target="_blank" rel="noreferrer">打开管理页</a>` : ""}
</div>
</div>
<div class="task-item compact">
<h4>${escapeHtml(currentModel?.name || localCatalog.default_model || "GLM-5")}</h4>
<p>${escapeHtml(currentModel ? `${currentModel.model_name || "-"} · ${currentModel.base_url || "-"}` : (localCatalog.public_base_url || localCatalog.base_url || "尚未读取到网关地址"))}</p>
<div class="task-meta">
${gatewayModels.slice(0, 6).map((model) => `<span class="tag">${escapeHtml(model)}</span>`).join("") || `<span class="tag red">暂无可见模型</span>`}
</div>
</div>
</div>
<div class="layout-grid grid-split" style="margin-top:18px;">
<div class="panel pad">
<div class="panel-head"><div><h3>模型列表</h3><div class="panel-subtitle"> model_profiles</div></div></div>
@@ -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: `
<div class="task-item compact">
<h4>${escapeHtml(localCatalog.reachable ? "网关在线" : "网关离线")}</h4>
<p>${escapeHtml(currentProfile ? `当前主模型:${currentProfile.name} · ${currentProfile.model_name || "-"}` : `默认模型:${localCatalog.default_model || "GLM-5"}`)}</p>
<div class="task-meta">
${gatewayModels.slice(0, 6).map((model) => `<span class="tag">${escapeHtml(model)}</span>`).join("") || `<span class="tag red">暂未读取到模型目录</span>`}
${localCatalog.management_url ? `<a class="tag blue" href="${escapeHtml(localCatalog.management_url)}" target="_blank" rel="noreferrer">打开管理页</a>` : ""}
</div>
</div>
`
},
{ name: "modelProfileId", label: "主模型", type: "select", value: currentId, options: models }
],
onSubmit: async (values) => {