diff --git a/.gitignore b/.gitignore
index 2b236e2..d91bff0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,3 +31,8 @@ output/
# macOS / editors
.idea/
.vscode/
+
+# Local agent/browser scratch state
+.playwright-cli/
+.superpowers/
+.tmp-previews*/
diff --git a/collector-service/app/core_main.py b/collector-service/app/core_main.py
index 3638c6f..e43ff6c 100644
--- a/collector-service/app/core_main.py
+++ b/collector-service/app/core_main.py
@@ -723,6 +723,48 @@ def huobao_config_items_from_payload(payload: Any) -> list[dict[str, Any]]:
return []
+def _normalize_huobao_model_values(models: Any) -> list[str]:
+ if isinstance(models, list):
+ raw_models = models
+ elif isinstance(models, tuple):
+ raw_models = list(models)
+ else:
+ raw_models = [models]
+ normalized: list[str] = []
+ for model in raw_models:
+ value = str(model or "").strip()
+ if value:
+ normalized.append(value.lower())
+ return normalized
+
+
+def validate_ai_video_huobao_availability(video_provider: str, video_model: str) -> list[dict[str, Any]]:
+ try:
+ payload = huobao_api_request("GET", "/api/v1/ai-configs", params={"service_type": "video"})
+ except HTTPException as exc:
+ if exc.status_code in {502, 503}:
+ raise HTTPException(
+ status_code=503,
+ detail="AI 视频暂时不可用:Huobao 视频配置未就绪,请先在管理后台完成视频配置。",
+ ) from exc
+ raise
+
+ items = [normalize_huobao_config_item(item) for item in huobao_config_items_from_payload(payload)]
+ active_items = [item for item in items if bool(item.get("is_active"))]
+ if not active_items:
+ raise HTTPException(status_code=409, detail="AI 视频暂时不可用:请先在 Huobao 启用至少一条视频配置。")
+
+ normalized_provider, normalized_model = normalize_ai_video_provider(video_provider, video_model)
+ if normalized_provider == "seedance2":
+ target_model = (normalized_model or "seedance-2.0-pro").strip().lower()
+ if not any(target_model in _normalize_huobao_model_values(item.get("model")) for item in active_items):
+ raise HTTPException(
+ status_code=409,
+ detail=f"AI 视频暂时不可用:Huobao 启用中的视频配置未包含所选 Seedance 模型 {normalized_model or 'seedance-2.0-pro'}。",
+ )
+ return active_items
+
+
def normalize_account(row: dict[str, Any]) -> dict[str, Any]:
return {
"id": row["id"],
@@ -3323,12 +3365,8 @@ async def process_job(job_id: str) -> None:
except Exception as exc:
update_job_state(job_id, status="failed", error=str(exc))
-
-@app.on_event("startup")
-def on_startup() -> None:
- db.init_schema()
- load_runtime_integration_config()
- seed_defaults()
+db.init_schema()
+load_runtime_integration_config()
def collect_readiness() -> dict[str, Any]:
@@ -3878,6 +3916,7 @@ def create_admin_huobao_config(
"endpoint": request.endpoint,
"query_endpoint": request.query_endpoint,
"priority": request.priority,
+ "is_active": request.is_active,
"settings": request.settings,
},
)
@@ -4246,6 +4285,9 @@ def seed_defaults() -> None:
print(f"StoryForge bootstrap: created super_admin account '{bootstrap_username}'.")
+seed_defaults()
+
+
@app.post("/v2/auth/register")
def register(request: RegisterAccountRequest) -> dict[str, Any]:
username = request.username.strip()
@@ -5080,8 +5122,9 @@ async def create_ai_video_job(request: AiVideoJobRequest, account: dict[str, Any
kb = resolve_target_kb(account["id"], request.knowledge_base_id or source_kb_id or None, project["id"], username=account["username"])
assistant = resolve_target_assistant(account["id"], request.assistant_id or None, project["id"])
video_provider, video_model = normalize_ai_video_provider(request.video_provider, request.video_model)
- dispatch_provider, dispatch_model = resolve_ai_video_dispatch(video_provider, video_model)
_enforce_tenant_quota(account, project_id=project["id"], usage_category="ai_video")
+ validate_ai_video_huobao_availability(video_provider, video_model)
+ dispatch_provider, dispatch_model = resolve_ai_video_dispatch(video_provider, video_model)
source = create_content_source(
account_id=account["id"],
project_id=project["id"],
diff --git a/collector-service/app/domestic_platform_features.py b/collector-service/app/domestic_platform_features.py
index c197500..54f4e34 100644
--- a/collector-service/app/domestic_platform_features.py
+++ b/collector-service/app/domestic_platform_features.py
@@ -1,11 +1,15 @@
from __future__ import annotations
import json
+import re
+from datetime import datetime, timezone
from typing import Any
from fastapi import Body, Depends, HTTPException, Query
from pydantic import BaseModel, Field
+from .douyin_features import _extract_json_blobs_from_html, _fetch_html
+
class PlatformAnalysisRequest(BaseModel):
model_profile_ids: list[str] = Field(default_factory=list)
@@ -54,6 +58,24 @@ class PlatformTrackingCursorRequest(BaseModel):
last_seen_at: str
+class PlatformManualPageCapture(BaseModel):
+ url: str = ""
+ title: str = ""
+ payload: dict[str, Any] = Field(default_factory=dict)
+
+
+class PlatformAccountSyncRequest(BaseModel):
+ project_id: str = ""
+ profile_url: str = ""
+ handle: str = ""
+ title: str = ""
+ session_cookie: str = ""
+ creator_center_urls: list[str] = Field(default_factory=list)
+ manual_profile_payload: dict[str, Any] | None = None
+ manual_creator_pages: list[PlatformManualPageCapture] = Field(default_factory=list)
+ discovery_note: str = ""
+
+
def register_domestic_platform_routes(app: Any, legacy: Any, *, platform: str, label: str) -> None:
table_prefix = platform
@@ -179,13 +201,18 @@ def register_domestic_platform_routes(app: Any, legacy: Any, *, platform: str, l
"""
with legacy.db.session() as conn:
conn.executescript(schema)
+ existing_columns = {row["name"] for row in conn.execute(f"PRAGMA table_info({table_prefix}_analysis_reports)").fetchall()}
+ if "model_profile_ids_json" not in existing_columns:
+ conn.execute(
+ f"ALTER TABLE {table_prefix}_analysis_reports ADD COLUMN model_profile_ids_json TEXT NOT NULL DEFAULT '[]'"
+ )
+ if "linked_account_ids_json" not in existing_columns:
+ conn.execute(
+ f"ALTER TABLE {table_prefix}_analysis_reports ADD COLUMN linked_account_ids_json TEXT NOT NULL DEFAULT '[]'"
+ )
ensure_schema()
- @app.on_event("startup")
- def _startup_platform_schema() -> None:
- ensure_schema()
-
def _content_source_rows(user_id: str, platform_value: str, kind: str = "") -> list[dict[str, Any]]:
rows = legacy.db.fetch_all(
"SELECT * FROM content_sources WHERE user_id = ? AND platform = ? ORDER BY updated_at DESC, created_at DESC",
@@ -201,6 +228,834 @@ def register_domestic_platform_routes(app: Any, legacy: Any, *, platform: str, l
def _source_metadata(row: dict[str, Any]) -> dict[str, Any]:
return _content_source_payload(row).get("metadata", {})
+ def _first_non_empty(*values: Any) -> str:
+ for value in values:
+ if value is None:
+ continue
+ if isinstance(value, str):
+ stripped = value.strip()
+ if stripped:
+ return stripped
+ elif value not in ("", [], {}, ()):
+ return str(value)
+ return ""
+
+ def _dedupe_strings(values: list[str]) -> list[str]:
+ result: list[str] = []
+ seen: set[str] = set()
+ for value in values:
+ item = str(value or "").strip()
+ if not item:
+ continue
+ key = item.lower()
+ if key in seen:
+ continue
+ seen.add(key)
+ result.append(item)
+ return result
+
+ def _compact_text(value: Any, limit: int = 500) -> str:
+ text = str(value or "").strip()
+ if len(text) <= limit:
+ return text
+ return f"{text[: limit - 1]}…"
+
+ def _normalize_id_list(values: list[str] | None) -> list[str]:
+ return _dedupe_strings([str(value or "").strip() for value in values or [] if str(value or "").strip()])
+
+ def _parse_count(value: Any) -> float:
+ if value is None:
+ return 0.0
+ if isinstance(value, (int, float)):
+ return float(value)
+ text = str(value).strip().lower().replace(",", "")
+ if not text:
+ return 0.0
+ multiplier = 1.0
+ if text.endswith("w") or text.endswith("万"):
+ multiplier = 10_000.0
+ text = text[:-1]
+ elif text.endswith("亿"):
+ multiplier = 100_000_000.0
+ text = text[:-1]
+ match = re.search(r"-?\d+(?:\.\d+)?", text)
+ if not match:
+ return 0.0
+ try:
+ return float(match.group()) * multiplier
+ except ValueError:
+ return 0.0
+
+ def _extract_hashtags(*texts: str) -> list[str]:
+ tags: list[str] = []
+ for text in texts:
+ if not text:
+ continue
+ tags.extend(match.group(1) for match in re.finditer(r"#([\w\u4e00-\u9fff]+)", text))
+ return _dedupe_strings(tags)
+
+ def _extract_keywords(*texts: str) -> list[str]:
+ candidates: list[str] = []
+ for text in texts:
+ if not text:
+ continue
+ candidates.extend(_extract_hashtags(text))
+ candidates.extend(re.findall(r"[\u4e00-\u9fff]{2,8}", text))
+ candidates.extend(re.findall(r"[A-Za-z][A-Za-z0-9_]{2,20}", text))
+ stop_words = {
+ "视频",
+ "作品",
+ "账号",
+ "内容",
+ "发布",
+ "更多",
+ "关注",
+ "用户",
+ "creator",
+ "profile",
+ platform,
+ label,
+ }
+ return _dedupe_strings([item for item in candidates if item.lower() not in stop_words])
+
+ def _normalize_timestamp(value: Any) -> str:
+ if value in (None, ""):
+ return ""
+ if isinstance(value, (int, float)):
+ timestamp = float(value)
+ if timestamp > 1_000_000_000_000:
+ timestamp /= 1000.0
+ if timestamp <= 0:
+ return ""
+ return datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat()
+ text = str(value or "").strip()
+ if not text:
+ return ""
+ if re.fullmatch(r"\d{10,13}", text):
+ return _normalize_timestamp(float(text))
+ return text
+
+ def _flatten_json(value: Any, prefix: str = "") -> list[tuple[str, str, str]]:
+ rows: list[tuple[str, str, str]] = []
+ if isinstance(value, dict):
+ for key, child in value.items():
+ next_prefix = f"{prefix}.{key}" if prefix else str(key)
+ rows.extend(_flatten_json(child, next_prefix))
+ elif isinstance(value, list):
+ for index, child in enumerate(value):
+ next_prefix = f"{prefix}[{index}]"
+ rows.extend(_flatten_json(child, next_prefix))
+ else:
+ rows.append((prefix or "$", type(value).__name__, _compact_text(value, 2000)))
+ return rows
+
+ def _walk_json(value: Any) -> list[dict[str, Any]]:
+ items: list[dict[str, Any]] = []
+ if isinstance(value, dict):
+ items.append(value)
+ for child in value.values():
+ items.extend(_walk_json(child))
+ elif isinstance(value, list):
+ for child in value:
+ items.extend(_walk_json(child))
+ return items
+
+ def _default_creator_center_urls() -> list[str]:
+ return {
+ "kuaishou": [
+ "https://creator.kuaishou.com/creator/home",
+ "https://creator.kuaishou.com/creator/content/works",
+ "https://creator.kuaishou.com/creator/data/overview",
+ ],
+ "xiaohongshu": [
+ "https://creator.xiaohongshu.com/publish/publish",
+ ],
+ "bilibili": [
+ "https://member.bilibili.com/platform/home",
+ ],
+ "wechat_video": [
+ "https://channels.weixin.qq.com/platform",
+ ],
+ }.get(platform, [])
+
+ def _profile_candidate_score(candidate: dict[str, Any]) -> int:
+ score = 0
+ interesting_keys = {
+ "nickname",
+ "name",
+ "user_name",
+ "author_name",
+ "handle",
+ "uid",
+ "user_id",
+ "kwaiId",
+ "kwai_id",
+ "bio",
+ "description",
+ "signature",
+ "follower_count",
+ "fans_count",
+ "fans",
+ "avatar_url",
+ "head_url",
+ }
+ score += sum(1 for key in interesting_keys if key in candidate)
+ if "profile" in candidate and isinstance(candidate["profile"], dict):
+ score += 2
+ return score
+
+ def _extract_profile_candidates(payload: Any) -> list[dict[str, Any]]:
+ candidates: list[dict[str, Any]] = []
+ for item in _walk_json(payload):
+ if _profile_candidate_score(item) >= 2:
+ candidates.append(item)
+ profile_value = item.get("profile")
+ if isinstance(profile_value, dict) and _profile_candidate_score(profile_value) >= 2:
+ candidates.append(profile_value)
+ return candidates
+
+ def _normalize_profile_candidate(
+ candidate: dict[str, Any],
+ *,
+ fallback_url: str = "",
+ fallback_title: str = "",
+ fallback_handle: str = "",
+ ) -> dict[str, Any]:
+ avatar = (
+ candidate.get("avatar_url")
+ or candidate.get("avatar")
+ or candidate.get("head_url")
+ or candidate.get("headurl")
+ or candidate.get("user_avatar")
+ or candidate.get("profile_image_url")
+ )
+ if isinstance(avatar, dict):
+ avatar = _first_non_empty(
+ avatar.get("url_list", [""])[0] if isinstance(avatar.get("url_list"), list) else "",
+ avatar.get("url"),
+ avatar.get("src"),
+ )
+ nickname = _first_non_empty(
+ candidate.get("nickname"),
+ candidate.get("name"),
+ candidate.get("user_name"),
+ candidate.get("author_name"),
+ candidate.get("display_name"),
+ fallback_title,
+ fallback_handle,
+ )
+ signature = _first_non_empty(
+ candidate.get("signature"),
+ candidate.get("bio"),
+ candidate.get("description"),
+ candidate.get("desc"),
+ candidate.get("intro"),
+ candidate.get("introduction"),
+ )
+ explicit_tags = candidate.get("tags") or candidate.get("content_tags") or candidate.get("keywords") or []
+ if not isinstance(explicit_tags, list):
+ explicit_tags = [explicit_tags]
+ handle = _first_non_empty(
+ candidate.get("handle"),
+ candidate.get("user_id"),
+ candidate.get("uid"),
+ candidate.get("kwai_id"),
+ candidate.get("kwaiId"),
+ candidate.get("short_id"),
+ fallback_handle,
+ )
+ return {
+ "nickname": nickname,
+ "signature": signature,
+ "avatar_url": _first_non_empty(avatar),
+ "profile_url": _first_non_empty(candidate.get("profile_url"), candidate.get("share_url"), fallback_url),
+ "handle": handle,
+ "stats": {
+ "followers": _parse_count(
+ candidate.get("follower_count")
+ or candidate.get("fans_count")
+ or candidate.get("fans")
+ or candidate.get("followers")
+ ),
+ "likes": _parse_count(
+ candidate.get("like_count")
+ or candidate.get("liked_count")
+ or candidate.get("total_like")
+ ),
+ "plays": _parse_count(
+ candidate.get("play_count")
+ or candidate.get("view_count")
+ or candidate.get("views")
+ ),
+ "videos": _parse_count(
+ candidate.get("video_count")
+ or candidate.get("works_count")
+ or candidate.get("published_count")
+ or candidate.get("aweme_count")
+ ),
+ },
+ "tags": _dedupe_strings(
+ [str(item) for item in explicit_tags if isinstance(item, (str, int, float))]
+ + _extract_hashtags(signature, nickname)
+ ),
+ "keywords": _extract_keywords(nickname, signature, handle),
+ }
+
+ def _video_candidate_score(candidate: dict[str, Any]) -> int:
+ score = 0
+ score += 2 if _first_non_empty(candidate.get("title"), candidate.get("name"), candidate.get("caption")) else 0
+ score += 2 if _first_non_empty(candidate.get("share_url"), candidate.get("video_url"), candidate.get("play_url"), candidate.get("url")) else 0
+ score += 2 if _first_non_empty(candidate.get("video_id"), candidate.get("aweme_id"), candidate.get("item_id"), candidate.get("note_id"), candidate.get("works_id")) else 0
+ score += 1 if _first_non_empty(candidate.get("description"), candidate.get("desc"), candidate.get("summary"), candidate.get("text")) else 0
+ score += 1 if _first_non_empty(candidate.get("cover_url"), candidate.get("published_at"), candidate.get("publish_time"), candidate.get("create_time")) else 0
+ score += 1 if _first_non_empty(candidate.get("duration_sec"), candidate.get("duration"), candidate.get("play_count"), candidate.get("view_count")) else 0
+ stats_value = candidate.get("stats") or candidate.get("statistics")
+ score += 1 if isinstance(stats_value, dict) else 0
+ return score
+
+ def _extract_video_candidates(payload: Any) -> list[dict[str, Any]]:
+ candidates: list[dict[str, Any]] = []
+ for item in _walk_json(payload):
+ if _video_candidate_score(item) >= 4:
+ candidates.append(item)
+ return candidates
+
+ def _video_metric_bundle(candidate: dict[str, Any]) -> dict[str, float]:
+ stats_source = candidate.get("stats") if isinstance(candidate.get("stats"), dict) else {}
+ if not stats_source and isinstance(candidate.get("statistics"), dict):
+ stats_source = candidate.get("statistics")
+ return {
+ "play": _parse_count(candidate.get("play_count") or candidate.get("view_count") or stats_source.get("play_count") or stats_source.get("view_count")),
+ "like": _parse_count(candidate.get("like_count") or candidate.get("digg_count") or stats_source.get("like_count") or stats_source.get("digg_count")),
+ "comment": _parse_count(candidate.get("comment_count") or stats_source.get("comment_count")),
+ "share": _parse_count(candidate.get("share_count") or stats_source.get("share_count")),
+ }
+
+ def _heuristic_video_performance_score(stats: dict[str, Any]) -> float:
+ play = _parse_count(stats.get("play") or stats.get("play_count"))
+ like = _parse_count(stats.get("like") or stats.get("like_count"))
+ comment = _parse_count(stats.get("comment") or stats.get("comment_count"))
+ share = _parse_count(stats.get("share") or stats.get("share_count"))
+ if play <= 0 and like <= 0 and comment <= 0 and share <= 0:
+ return 0.0
+ score = (play / 5000.0) + (like / 100.0) + (comment / 20.0) + (share / 10.0)
+ return round(min(100.0, score), 1)
+
+ def _normalize_video_candidate(candidate: dict[str, Any]) -> dict[str, Any]:
+ cover = candidate.get("cover_url") or candidate.get("cover") or candidate.get("poster") or candidate.get("image") or candidate.get("thumbnail")
+ if isinstance(cover, dict):
+ cover = _first_non_empty(
+ cover.get("url"),
+ cover.get("src"),
+ cover.get("url_list", [""])[0] if isinstance(cover.get("url_list"), list) and cover.get("url_list") else "",
+ )
+ tags = candidate.get("tags") or candidate.get("content_tags") or candidate.get("keywords") or []
+ if not isinstance(tags, list):
+ tags = [tags]
+ stats = _video_metric_bundle(candidate)
+ title = _first_non_empty(candidate.get("title"), candidate.get("name"), candidate.get("caption"), candidate.get("desc"))
+ description = _first_non_empty(candidate.get("description"), candidate.get("desc"), candidate.get("summary"), candidate.get("text"), title)
+ return {
+ "external_id": _first_non_empty(candidate.get("video_id"), candidate.get("aweme_id"), candidate.get("item_id"), candidate.get("note_id"), candidate.get("works_id")),
+ "title": title or "未命名作品",
+ "description": description,
+ "share_url": _first_non_empty(candidate.get("share_url"), candidate.get("video_url"), candidate.get("play_url"), candidate.get("url")),
+ "cover_url": _first_non_empty(cover),
+ "duration_sec": float(candidate.get("duration_sec") or candidate.get("duration") or 0),
+ "published_at": _normalize_timestamp(candidate.get("published_at") or candidate.get("publish_time") or candidate.get("create_time")),
+ "tags": _dedupe_strings([str(item) for item in tags if isinstance(item, (str, int, float))]),
+ "stats": stats,
+ "performance_score": _heuristic_video_performance_score(stats),
+ "raw": candidate,
+ }
+
+ def _extract_videos(payloads: list[Any]) -> list[dict[str, Any]]:
+ videos: list[dict[str, Any]] = []
+ seen: set[str] = set()
+ for payload in payloads:
+ for candidate in _extract_video_candidates(payload):
+ normalized = _normalize_video_candidate(candidate)
+ dedupe_key = normalized["share_url"] or normalized["external_id"] or normalized["title"]
+ if not dedupe_key or dedupe_key in seen:
+ continue
+ seen.add(dedupe_key)
+ videos.append(normalized)
+ videos.sort(
+ key=lambda item: (float(item.get("performance_score") or 0), str(item.get("published_at") or "")),
+ reverse=True,
+ )
+ return videos
+
+ def _pick_best_profile(
+ payloads: list[Any],
+ *,
+ fallback_url: str = "",
+ fallback_title: str = "",
+ fallback_handle: str = "",
+ ) -> dict[str, Any]:
+ candidates: list[dict[str, Any]] = []
+ for payload in payloads:
+ candidates.extend(_extract_profile_candidates(payload))
+ if not candidates and payloads and isinstance(payloads[0], dict):
+ candidates = [payloads[0]]
+ best = _normalize_profile_candidate(
+ {},
+ fallback_url=fallback_url,
+ fallback_title=fallback_title,
+ fallback_handle=fallback_handle,
+ )
+ best_score = -1
+ for candidate in candidates:
+ normalized = _normalize_profile_candidate(
+ candidate,
+ fallback_url=fallback_url,
+ fallback_title=fallback_title,
+ fallback_handle=fallback_handle,
+ )
+ score = 0
+ score += 3 if normalized["nickname"] else 0
+ score += 2 if normalized["signature"] else 0
+ score += 2 if normalized["handle"] else 0
+ score += 1 if normalized["stats"]["followers"] else 0
+ score += 1 if normalized["avatar_url"] else 0
+ if score > best_score:
+ best = normalized
+ best_score = score
+ return best
+
+ def _fields_payload(payload: Any) -> tuple[list[dict[str, Any]], int]:
+ flattened = _flatten_json(payload)
+ return (
+ [
+ {
+ "field_path": field_path,
+ "field_type": field_type,
+ "field_value_text": field_value_text,
+ }
+ for field_path, field_type, field_value_text in flattened[:300]
+ ],
+ len(flattened),
+ )
+
+ def _snapshot_summary_from_payload(payload: Any) -> dict[str, Any]:
+ fields, field_count = _fields_payload(payload)
+ summary: dict[str, Any] = {}
+ for item in fields[:8]:
+ summary[item["field_path"]] = item["field_value_text"]
+ if field_count and "field_count" not in summary:
+ summary["field_count"] = field_count
+ return summary
+
+ def _build_snapshot(snapshot_type: str, source_url: str, payload: Any) -> dict[str, Any]:
+ fields, field_count = _fields_payload(payload)
+ collected_at = now()
+ return {
+ "id": make_id(f"{platform}_snapshot"),
+ "snapshot_type": snapshot_type,
+ "source_url": source_url,
+ "field_count": field_count,
+ "collected_at": collected_at,
+ "summary": _snapshot_summary_from_payload(payload),
+ "raw_payload": payload,
+ "fields": fields,
+ }
+
+ def _creator_center_state(metadata: dict[str, Any]) -> dict[str, Any]:
+ state = metadata.get("creator_center")
+ return state if isinstance(state, dict) else {}
+
+ def _list_snapshots(account_row: dict[str, Any]) -> list[dict[str, Any]]:
+ metadata = _source_metadata(account_row)
+ snapshots = _creator_center_state(metadata).get("snapshots") or []
+ if not isinstance(snapshots, list):
+ return []
+ normalized = [item for item in snapshots if isinstance(item, dict)]
+ normalized.sort(key=lambda item: str(item.get("collected_at") or ""), reverse=True)
+ return normalized
+
+ def _snapshot_brief(snapshot: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "id": snapshot.get("id", ""),
+ "snapshot_type": snapshot.get("snapshot_type", ""),
+ "source_url": snapshot.get("source_url", ""),
+ "field_count": snapshot.get("field_count", 0),
+ "collected_at": snapshot.get("collected_at", ""),
+ "summary": snapshot.get("summary") or {},
+ }
+
+ def _latest_snapshot(account_row: dict[str, Any], snapshot_type: str) -> dict[str, Any] | None:
+ return next((item for item in _list_snapshots(account_row) if item.get("snapshot_type") == snapshot_type), None)
+
+ def _snapshot_detail(account_row: dict[str, Any], snapshot_id: str) -> dict[str, Any]:
+ snapshot = next((item for item in _list_snapshots(account_row) if item.get("id") == snapshot_id), None)
+ if not snapshot:
+ raise HTTPException(status_code=404, detail="Snapshot not found")
+ return {
+ "id": snapshot.get("id", ""),
+ "snapshot_type": snapshot.get("snapshot_type", ""),
+ "source_url": snapshot.get("source_url", ""),
+ "field_count": snapshot.get("field_count", 0),
+ "collected_at": snapshot.get("collected_at", ""),
+ "summary": snapshot.get("summary") or {},
+ "raw_payload": snapshot.get("raw_payload") or {},
+ "fields": snapshot.get("fields") or [],
+ }
+
+ async def _collect_public_profile(
+ source_url: str,
+ manual_payload: dict[str, Any] | None,
+ *,
+ fallback_title: str = "",
+ fallback_handle: str = "",
+ ) -> dict[str, Any]:
+ payloads: list[Any] = []
+ errors: list[str] = []
+ resolved_url = source_url.strip()
+ if manual_payload:
+ payloads.append(manual_payload)
+ elif resolved_url:
+ try:
+ final_url, html = await _fetch_html(resolved_url)
+ resolved_url = final_url
+ blobs = _extract_json_blobs_from_html(html)
+ payloads.extend([item["payload"] for item in blobs if isinstance(item.get("payload"), (dict, list))])
+ except Exception as exc:
+ errors.append(f"public_profile_fetch_failed: {exc}")
+ profile = _pick_best_profile(
+ payloads,
+ fallback_url=resolved_url,
+ fallback_title=fallback_title,
+ fallback_handle=fallback_handle,
+ )
+ raw_payload: Any = {}
+ if payloads:
+ raw_payload = payloads[0] if len(payloads) == 1 else {"items": payloads[:8]}
+ elif resolved_url:
+ raw_payload = {
+ "profile_url": resolved_url,
+ "title": fallback_title,
+ "handle": fallback_handle,
+ }
+ return {
+ "profile": profile,
+ "payload": raw_payload,
+ "errors": errors,
+ "source_url": resolved_url,
+ }
+
+ async def _collect_creator_center_pages(
+ urls: list[str],
+ cookie: str,
+ manual_pages: list[PlatformManualPageCapture],
+ *,
+ fallback_title: str = "",
+ fallback_handle: str = "",
+ ) -> dict[str, Any]:
+ pages: list[dict[str, Any]] = []
+ errors: list[str] = []
+ payloads: list[Any] = []
+
+ for page in manual_pages:
+ payload = page.payload if isinstance(page.payload, dict) else {}
+ pages.append({"url": page.url, "title": page.title, "payload": payload})
+ payloads.append(payload)
+
+ candidate_urls = _dedupe_strings(urls or _default_creator_center_urls())
+ if cookie.strip():
+ for url in candidate_urls:
+ try:
+ final_url, html = await _fetch_html(url, cookie=cookie)
+ blobs = _extract_json_blobs_from_html(html)
+ extracted_payloads = [item["payload"] for item in blobs if isinstance(item.get("payload"), (dict, list))]
+ payload = extracted_payloads[0] if len(extracted_payloads) == 1 else {"items": extracted_payloads[:8]}
+ if not extracted_payloads and html.strip():
+ payload = {"html_excerpt": _compact_text(html, 1600)}
+ pages.append({"url": final_url, "title": "", "payload": payload})
+ if extracted_payloads:
+ payloads.extend(extracted_payloads)
+ elif payload:
+ payloads.append(payload)
+ except Exception as exc:
+ errors.append(f"creator_center_fetch_failed[{url}]: {exc}")
+
+ profile = _pick_best_profile(
+ payloads,
+ fallback_title=fallback_title,
+ fallback_handle=fallback_handle,
+ )
+ aggregated_payload = {
+ "pages": pages,
+ "page_count": len(pages),
+ } if pages else {}
+ return {
+ "profile": profile,
+ "payload": aggregated_payload,
+ "pages": pages,
+ "errors": errors,
+ }
+
+ def _resolve_project_id(requested_project_id: str, owner_id: str, existing: dict[str, Any] | None) -> str:
+ if requested_project_id.strip():
+ project_row = legacy.db.fetch_one(
+ "SELECT * FROM projects WHERE id = ? AND user_id = ? LIMIT 1",
+ (requested_project_id.strip(), owner_id),
+ )
+ if not project_row:
+ raise HTTPException(status_code=400, detail="归属项目不存在或不属于当前账号")
+ return project_row["id"]
+ if existing and str(existing.get("project_id") or "").strip():
+ return str(existing.get("project_id") or "")
+ project_row = legacy.db.fetch_one(
+ "SELECT * FROM projects WHERE user_id = ? ORDER BY updated_at DESC LIMIT 1",
+ (owner_id,),
+ )
+ if project_row:
+ return project_row["id"]
+ raise HTTPException(status_code=400, detail="请先创建或选择归属项目")
+
+ def _find_existing_account(user_id: str, profile_url: str, handle: str) -> dict[str, Any] | None:
+ rows = _content_source_rows(user_id, platform, "creator_account")
+ normalized_url = profile_url.strip()
+ normalized_handle = handle.strip().lower()
+ if normalized_url:
+ for row in rows:
+ if str(row.get("source_url") or "").strip() == normalized_url:
+ return row
+ if normalized_handle:
+ for row in rows:
+ if str(row.get("handle") or "").strip().lower() == normalized_handle:
+ return row
+ return None
+
+ def _upsert_account_source(
+ owner: dict[str, Any],
+ sync_request: PlatformAccountSyncRequest,
+ public_data: dict[str, Any],
+ creator_data: dict[str, Any],
+ ) -> dict[str, Any]:
+ existing = _find_existing_account(owner["id"], sync_request.profile_url, sync_request.handle)
+ project_id = _resolve_project_id(sync_request.project_id, owner["id"], existing)
+ existing_metadata = _source_metadata(existing) if existing else {}
+ existing_state = _creator_center_state(existing_metadata)
+ existing_snapshots = existing_state.get("snapshots") or []
+ existing_snapshots = [item for item in existing_snapshots if isinstance(item, dict)]
+ public_profile = public_data.get("profile") or {}
+ creator_profile = creator_data.get("profile") or {}
+ resolved_profile = {
+ "nickname": _first_non_empty(public_profile.get("nickname"), creator_profile.get("nickname"), sync_request.title, sync_request.handle),
+ "signature": _first_non_empty(public_profile.get("signature"), creator_profile.get("signature"), existing_metadata.get("bio"), existing_metadata.get("description")),
+ "avatar_url": _first_non_empty(public_profile.get("avatar_url"), creator_profile.get("avatar_url"), existing_metadata.get("avatar_url")),
+ "profile_url": _first_non_empty(public_data.get("source_url"), sync_request.profile_url, public_profile.get("profile_url"), creator_profile.get("profile_url"), existing.get("source_url") if existing else ""),
+ "handle": _first_non_empty(sync_request.handle, public_profile.get("handle"), creator_profile.get("handle"), existing.get("handle") if existing else ""),
+ "stats": {
+ "followers": public_profile.get("stats", {}).get("followers") or creator_profile.get("stats", {}).get("followers") or 0,
+ "likes": public_profile.get("stats", {}).get("likes") or creator_profile.get("stats", {}).get("likes") or 0,
+ "plays": public_profile.get("stats", {}).get("plays") or creator_profile.get("stats", {}).get("plays") or 0,
+ "videos": public_profile.get("stats", {}).get("videos") or creator_profile.get("stats", {}).get("videos") or 0,
+ },
+ "tags": _dedupe_strings(
+ (existing_metadata.get("tags") or [])
+ + (public_profile.get("tags") or [])
+ + (creator_profile.get("tags") or [])
+ + _extract_keywords(
+ public_profile.get("nickname") or "",
+ creator_profile.get("nickname") or "",
+ public_profile.get("signature") or "",
+ creator_profile.get("signature") or "",
+ )
+ ),
+ "keywords": _dedupe_strings(
+ (existing_metadata.get("keywords") or [])
+ + (public_profile.get("keywords") or [])
+ + (creator_profile.get("keywords") or [])
+ + _extract_keywords(
+ public_profile.get("nickname") or "",
+ creator_profile.get("nickname") or "",
+ public_profile.get("signature") or "",
+ creator_profile.get("signature") or "",
+ )
+ ),
+ }
+ new_snapshots: list[dict[str, Any]] = []
+ if public_data.get("payload") not in ({}, None, "") or resolved_profile["profile_url"]:
+ public_payload = public_data.get("payload") or {
+ "profile_url": resolved_profile["profile_url"],
+ "nickname": resolved_profile["nickname"],
+ "handle": resolved_profile["handle"],
+ "signature": resolved_profile["signature"],
+ "avatar_url": resolved_profile["avatar_url"],
+ "stats": resolved_profile["stats"],
+ }
+ new_snapshots.append(_build_snapshot("public_profile", resolved_profile["profile_url"], public_payload))
+ if creator_data.get("payload"):
+ creator_url = creator_data.get("pages", [{}])[0].get("url", "") if creator_data.get("pages") else ""
+ new_snapshots.append(_build_snapshot("creator_center", creator_url, creator_data["payload"]))
+ merged_snapshots = [*new_snapshots, *existing_snapshots][:12]
+ latest_public_snapshot = next((item for item in merged_snapshots if item.get("snapshot_type") == "public_profile"), None)
+ latest_creator_snapshot = next((item for item in merged_snapshots if item.get("snapshot_type") == "creator_center"), None)
+ errors = [*public_data.get("errors", []), *creator_data.get("errors", [])]
+ metadata = {
+ **existing_metadata,
+ "bio": resolved_profile["signature"],
+ "description": resolved_profile["signature"],
+ "avatar_url": resolved_profile["avatar_url"],
+ "tags": resolved_profile["tags"],
+ "keywords": resolved_profile["keywords"],
+ "profile_stats": resolved_profile["stats"],
+ "last_sync_error": ";".join([str(item) for item in errors if str(item).strip()]),
+ "source_mode": "creator_center" if latest_creator_snapshot else "public",
+ "last_public_sync_at": latest_public_snapshot.get("collected_at", "") if latest_public_snapshot else existing_metadata.get("last_public_sync_at", ""),
+ "last_creator_sync_at": latest_creator_snapshot.get("collected_at", "") if latest_creator_snapshot else existing_metadata.get("last_creator_sync_at", ""),
+ "discovery_note": sync_request.discovery_note or existing_metadata.get("discovery_note", ""),
+ "creator_center": {
+ "snapshots": merged_snapshots,
+ "sync_errors": errors,
+ "creator_center_urls": _dedupe_strings(sync_request.creator_center_urls or _default_creator_center_urls()),
+ "last_synced_at": now(),
+ },
+ }
+ account_id = existing["id"] if existing else make_id(f"{platform}_acct")
+ created_at = existing["created_at"] if existing else now()
+ title = _first_non_empty(sync_request.title, resolved_profile["nickname"], existing.get("title") if existing else "", resolved_profile["handle"], label)
+ handle = _first_non_empty(sync_request.handle, resolved_profile["handle"], existing.get("handle") if existing else "")
+ source_url = resolved_profile["profile_url"]
+ if existing:
+ legacy.db.execute(
+ """
+ UPDATE content_sources
+ SET project_id = ?, handle = ?, source_url = ?, title = ?, metadata_json = ?, updated_at = ?
+ WHERE id = ?
+ """,
+ (
+ project_id,
+ handle,
+ source_url,
+ title,
+ _safe_json_dumps(metadata),
+ now(),
+ account_id,
+ ),
+ )
+ else:
+ legacy.db.execute(
+ """
+ INSERT INTO content_sources (
+ id, user_id, project_id, source_kind, platform, handle, source_url, title, local_path,
+ metadata_json, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ account_id,
+ owner["id"],
+ project_id,
+ "creator_account",
+ platform,
+ handle,
+ source_url,
+ title,
+ "",
+ _safe_json_dumps(metadata),
+ created_at,
+ now(),
+ ),
+ )
+ row = legacy.db.fetch_one("SELECT * FROM content_sources WHERE id = ?", (account_id,))
+ assert row is not None
+ return row
+
+ def _upsert_account_videos(account_row: dict[str, Any], videos: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ if not videos:
+ return []
+ project_id = account_row.get("project_id", "")
+ source_account_url = str(account_row.get("source_url") or "").strip()
+ existing_rows = _linked_video_sources(account_row)
+ existing_by_url: dict[str, dict[str, Any]] = {}
+ existing_by_external_id: dict[str, dict[str, Any]] = {}
+ for row in existing_rows:
+ metadata = _source_metadata(row)
+ source_url = str(row.get("source_url") or "").strip()
+ external_id = str(metadata.get("external_id") or "").strip()
+ if source_url:
+ existing_by_url[source_url] = row
+ if external_id:
+ existing_by_external_id[external_id] = row
+
+ saved_rows: list[dict[str, Any]] = []
+ for index, video in enumerate(videos, start=1):
+ share_url = str(video.get("share_url") or "").strip()
+ external_id = str(video.get("external_id") or "").strip()
+ if not share_url and not external_id:
+ continue
+ metadata = {
+ "summary": video.get("description") or "",
+ "description": video.get("description") or "",
+ "cover_url": video.get("cover_url") or "",
+ "published_at": video.get("published_at") or "",
+ "tags": video.get("tags") or [],
+ "content_type": "video",
+ "duration_sec": float(video.get("duration_sec") or 0),
+ "external_id": external_id,
+ "origin_content_source_id": account_row["id"],
+ "source_account_url": source_account_url,
+ "stats": video.get("stats") or {},
+ "performance_score": float(video.get("performance_score") or 0),
+ "raw_payload": video.get("raw") or {},
+ }
+ existing = (existing_by_url.get(share_url) if share_url else None) or (existing_by_external_id.get(external_id) if external_id else None)
+ if existing:
+ merged_metadata = {
+ **_source_metadata(existing),
+ **metadata,
+ }
+ legacy.db.execute(
+ """
+ UPDATE content_sources
+ SET source_url = ?, title = ?, metadata_json = ?, updated_at = ?
+ WHERE id = ?
+ """,
+ (
+ share_url or str(existing.get("source_url") or ""),
+ video.get("title") or existing.get("title") or f"{label} 作品 {index}",
+ _safe_json_dumps(merged_metadata),
+ now(),
+ existing["id"],
+ ),
+ )
+ row = legacy.db.fetch_one("SELECT * FROM content_sources WHERE id = ?", (existing["id"],))
+ else:
+ row_id = make_id(f"{platform}_video")
+ created_at = now()
+ legacy.db.execute(
+ """
+ INSERT INTO content_sources (
+ id, user_id, project_id, source_kind, platform, handle, source_url, title, local_path,
+ metadata_json, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ row_id,
+ account_row["user_id"],
+ project_id,
+ "video_link",
+ platform,
+ account_row.get("handle", ""),
+ share_url,
+ video.get("title") or f"{label} 作品 {index}",
+ "",
+ _safe_json_dumps(metadata),
+ created_at,
+ created_at,
+ ),
+ )
+ row = legacy.db.fetch_one("SELECT * FROM content_sources WHERE id = ?", (row_id,))
+ if row:
+ saved_rows.append(row)
+ if share_url:
+ existing_by_url[share_url] = row
+ if external_id:
+ existing_by_external_id[external_id] = row
+ return saved_rows
+
def _require_account(account_id: str, user_id: str) -> dict[str, Any]:
row = legacy.db.fetch_one(
"SELECT * FROM content_sources WHERE id = ? AND user_id = ? AND source_kind = 'creator_account' AND platform = ?",
@@ -274,9 +1129,19 @@ def register_domestic_platform_routes(app: Any, legacy: Any, *, platform: str, l
metadata = payload.get("metadata", {})
latest_job = _latest_job_for_source(source_row["id"])
metrics = _extract_metrics(latest_job)
+ metadata_stats = metadata.get("stats") if isinstance(metadata.get("stats"), dict) else {}
tags = metadata.get("tags") or []
if not isinstance(tags, list):
tags = []
+ resolved_stats = {
+ "play": metrics.get("play_count") or metrics.get("play") or metadata_stats.get("play_count") or metadata_stats.get("play") or 0,
+ "like": metrics.get("like_count") or metrics.get("like") or metadata_stats.get("like_count") or metadata_stats.get("like") or 0,
+ "comment": metrics.get("comment_count") or metrics.get("comment") or metadata_stats.get("comment_count") or metadata_stats.get("comment") or 0,
+ "share": metrics.get("share_count") or metrics.get("share") or metadata_stats.get("share_count") or metadata_stats.get("share") or 0,
+ }
+ performance_score = _extract_performance_score(latest_job)
+ if performance_score <= 0:
+ performance_score = _parse_count(metadata.get("performance_score")) or _heuristic_video_performance_score(resolved_stats)
return {
"id": source_row["id"],
"aweme_id": str(metadata.get("external_id") or source_row["id"]),
@@ -288,14 +1153,9 @@ def register_domestic_platform_routes(app: Any, legacy: Any, *, platform: str, l
"published_at": metadata.get("published_at") or source_row.get("created_at"),
"tags": tags,
"content_type": metadata.get("content_type") or "video",
- "stats": {
- "play": metrics.get("play_count") or metrics.get("play") or 0,
- "like": metrics.get("like_count") or metrics.get("like") or 0,
- "comment": metrics.get("comment_count") or metrics.get("comment") or 0,
- "share": metrics.get("share_count") or metrics.get("share") or 0,
- },
+ "stats": resolved_stats,
"score": {
- "performance_score": _extract_performance_score(latest_job),
+ "performance_score": performance_score,
},
"source": payload,
"latest_job_id": (latest_job or {}).get("id", ""),
@@ -321,6 +1181,7 @@ def register_domestic_platform_routes(app: Any, legacy: Any, *, platform: str, l
"avatar_url": metadata.get("avatar_url") or "",
"tags": tags,
"keywords": metadata.get("keywords") or [],
+ "profile_stats": metadata.get("profile_stats") or {},
"sync_status": "ready" if payload.get("metadata", {}).get("last_sync_error", "") == "" else "partial",
"video_summary": {
"count": len(videos),
@@ -396,10 +1257,12 @@ def register_domestic_platform_routes(app: Any, legacy: Any, *, platform: str, l
f"SELECT * FROM {table_prefix}_similarity_searches WHERE source_account_id = ? ORDER BY created_at DESC LIMIT 5",
(account_row["id"],),
)
+ latest_public_snapshot = _latest_snapshot(account_row, "public_profile")
+ latest_creator_snapshot = _latest_snapshot(account_row, "creator_center")
return {
"account": _account_payload(account_row),
- "latest_public_snapshot": None,
- "latest_creator_snapshot": None,
+ "latest_public_snapshot": _snapshot_brief(latest_public_snapshot) if latest_public_snapshot else None,
+ "latest_creator_snapshot": _snapshot_brief(latest_creator_snapshot) if latest_creator_snapshot else None,
"recent_reports": [_report_payload(row) for row in reports],
"linked_accounts": [_relation_payload(row) for row in relations],
"recent_similarity_searches": [
@@ -428,6 +1291,149 @@ def register_domestic_platform_routes(app: Any, legacy: Any, *, platform: str, l
parsed = legacy.parse_json_object(output)
return output, parsed if isinstance(parsed, dict) else {}
+ def _resolve_model_profiles(user_id: str, requested_ids: list[str] | None) -> list[dict[str, Any]]:
+ normalized_ids = _normalize_id_list(requested_ids)
+ profiles: list[dict[str, Any]] = []
+ seen: set[str] = set()
+ for profile_id in normalized_ids:
+ try:
+ profile = legacy.model_profile_for_account(user_id, profile_id)
+ except Exception:
+ continue
+ resolved_id = str(profile.get("id") or "").strip()
+ if not resolved_id or resolved_id in seen:
+ continue
+ seen.add(resolved_id)
+ profiles.append(profile)
+ if profiles:
+ return profiles
+ fallback = legacy.model_profile_for_account(user_id, None)
+ fallback_id = str(fallback.get("id") or "").strip()
+ return [fallback] if fallback_id else []
+
+ def _linked_account_context(
+ account_row: dict[str, Any],
+ requested_linked_account_ids: list[str] | None,
+ *,
+ include_linked_accounts: bool,
+ limit: int = 5,
+ ) -> list[dict[str, Any]]:
+ normalized_requested = set(_normalize_id_list(requested_linked_account_ids))
+ relations = legacy.db.fetch_all(
+ f"SELECT * FROM {table_prefix}_account_relations WHERE source_account_id = ? ORDER BY created_at DESC",
+ (account_row["id"],),
+ )
+ items: list[dict[str, Any]] = []
+ for relation in relations:
+ target_account_id = str(relation.get("target_account_id") or "").strip()
+ if normalized_requested and target_account_id and target_account_id not in normalized_requested:
+ continue
+ if not include_linked_accounts and not (normalized_requested and target_account_id in normalized_requested):
+ continue
+ target_account = _require_account(target_account_id, account_row["user_id"]) if target_account_id else None
+ items.append(
+ {
+ "relation_id": relation["id"],
+ "relation_type": relation.get("relation_type", "benchmark"),
+ "note": relation.get("note", ""),
+ "target_account_id": target_account_id,
+ "target_profile_url": relation.get("target_profile_url", ""),
+ "target_nickname": _account_payload(target_account).get("nickname", "") if target_account else "",
+ "account": _account_payload(target_account) if target_account else None,
+ }
+ )
+ return items[:limit]
+
+ def _recent_similarity_candidates_context(account_row: dict[str, Any], limit: int = 6) -> list[dict[str, Any]]:
+ candidate_rows = legacy.db.fetch_all(
+ f"""
+ SELECT candidates.*
+ FROM {table_prefix}_similarity_candidates AS candidates
+ JOIN {table_prefix}_similarity_searches AS searches
+ ON searches.id = candidates.search_id
+ WHERE searches.source_account_id = ?
+ ORDER BY searches.created_at DESC, candidates.rank_index ASC
+ LIMIT ?
+ """,
+ (account_row["id"], limit),
+ )
+ items: list[dict[str, Any]] = []
+ for row in candidate_rows:
+ payload = _parse_json(row.get("raw_output_json") or "{}", {})
+ payload.setdefault("candidate_account_id", row.get("candidate_account_id", "") or "")
+ payload.setdefault("candidate_profile_url", row.get("candidate_profile_url", ""))
+ payload.setdefault("candidate_nickname", payload.get("candidate_nickname", ""))
+ payload.setdefault("rationale_text", row.get("rationale_text", ""))
+ payload.setdefault("heuristic_score", row.get("heuristic_score", 0))
+ payload.setdefault("agent_score", row.get("agent_score", 0))
+ items.append(payload)
+ return items
+
+ async def _analyze_top_videos_for_account(
+ account_row: dict[str, Any],
+ user_id: str,
+ *,
+ model_profile_id: str = "",
+ top_video_count: int = 5,
+ min_score: float = 0,
+ temperature: float = 0.25,
+ ) -> list[dict[str, Any]]:
+ videos = [_video_payload(row) for row in _linked_video_sources(account_row)]
+ ranked = [
+ video for video in sorted(
+ videos,
+ key=lambda item: (item["score"]["performance_score"], item.get("published_at") or ""),
+ reverse=True,
+ )
+ if float(video["score"]["performance_score"] or 0) >= float(min_score or 0)
+ ][: max(1, min(top_video_count, 12))]
+ results: list[dict[str, Any]] = []
+ for video in ranked:
+ prompt = (
+ f"请拆解这条{label}作品为什么值得关注,输出 summary、borrow_points、risks。"
+ f"\n\n输入:\n{json.dumps(video, ensure_ascii=False, indent=2)}"
+ )
+ output, parsed = await _call_reasoning_model(
+ user_id,
+ prompt,
+ system_prompt="你是短视频内容拆解助手。尽量输出 JSON,字段包括 summary、borrow_points、risks。",
+ model_profile_id=model_profile_id,
+ temperature=temperature,
+ )
+ summary_text = str(parsed.get("summary") or parsed.get("headline_summary") or output)[:240]
+ results.append(
+ {
+ "id": make_id(f"{platform}_va"),
+ "video_id": video["id"],
+ "video_title": video["title"],
+ "status": "ok",
+ "summary_text": summary_text,
+ "parsed_json": parsed,
+ "performance_score": video["score"]["performance_score"],
+ "created_at": now(),
+ }
+ )
+ return results
+
+ def _similarity_tokens(payload: dict[str, Any], extra_text: str = "") -> set[str]:
+ return {
+ token.lower()
+ for token in _extract_keywords(
+ payload.get("nickname") or "",
+ payload.get("signature") or "",
+ " ".join(payload.get("tags") or []),
+ " ".join(payload.get("keywords") or []),
+ extra_text,
+ )
+ if token
+ }
+
+ def _candidate_title_from_url(url: str) -> str:
+ cleaned = str(url or "").strip().rstrip("/")
+ tail = cleaned.rsplit("/", 1)[-1] if cleaned else ""
+ tail = tail.split("?", 1)[0].replace("-", " ").replace("_", " ").strip()
+ return tail or cleaned or "候选账号"
+
async def _create_sync_job_for_account(account_row: dict[str, Any], assistant_id: str = "") -> dict[str, Any]:
project_id = account_row.get("project_id") or ""
if not project_id:
@@ -552,6 +1558,52 @@ def register_domestic_platform_routes(app: Any, legacy: Any, *, platform: str, l
"cursor_last_seen_at": (cursor or {}).get("last_seen_at", ""),
}
+ @app.post(f"/v2/{platform}/accounts/sync")
+ async def sync_platform_account(
+ request: PlatformAccountSyncRequest,
+ account: dict[str, Any] = Depends(legacy.require_approved),
+ ) -> dict[str, Any]:
+ if (
+ not request.profile_url.strip()
+ and not request.manual_profile_payload
+ and not request.manual_creator_pages
+ ):
+ raise HTTPException(
+ status_code=400,
+ detail="profile_url、manual_profile_payload 或 manual_creator_pages 至少需要传一个",
+ )
+ public_data = await _collect_public_profile(
+ request.profile_url,
+ request.manual_profile_payload,
+ fallback_title=request.title,
+ fallback_handle=request.handle,
+ )
+ creator_data = await _collect_creator_center_pages(
+ request.creator_center_urls,
+ request.session_cookie,
+ request.manual_creator_pages,
+ fallback_title=request.title,
+ fallback_handle=request.handle,
+ )
+ if (
+ not public_data["profile"].get("nickname")
+ and not public_data["profile"].get("profile_url")
+ and not creator_data["pages"]
+ ):
+ raise HTTPException(status_code=400, detail=f"No {label} profile or creator-center data could be extracted")
+ account_row = _upsert_account_source(account, request, public_data, creator_data)
+ _upsert_account_videos(
+ account_row,
+ _extract_videos([
+ public_data.get("payload") or {},
+ creator_data.get("payload") or {},
+ ]),
+ )
+ account_row = _require_account(account_row["id"], account["id"])
+ workspace = _workspace_payload(account_row)
+ workspace["sync_errors"] = [*public_data["errors"], *creator_data["errors"]]
+ return workspace
+
@app.get(f"/v2/{platform}/accounts")
def list_platform_accounts(account: dict[str, Any] = Depends(legacy.require_approved)) -> list[dict[str, Any]]:
return [_account_payload(row) for row in _content_source_rows(account["id"], platform, "creator_account")]
@@ -568,13 +1620,25 @@ def register_domestic_platform_routes(app: Any, legacy: Any, *, platform: str, l
@app.get(f"/v2/{platform}/accounts/{{account_id}}/snapshots")
def list_platform_snapshots(account_id: str, account: dict[str, Any] = Depends(legacy.require_approved)) -> list[dict[str, Any]]:
- _require_account(account_id, account["id"])
- return []
+ account_row = _require_account(account_id, account["id"])
+ return [_snapshot_brief(item) for item in _list_snapshots(account_row)]
+
+ @app.get(f"/v2/{platform}/accounts/{{account_id}}/snapshots/{{snapshot_id}}")
+ def get_platform_snapshot_detail(
+ account_id: str,
+ snapshot_id: str,
+ account: dict[str, Any] = Depends(legacy.require_approved),
+ ) -> dict[str, Any]:
+ account_row = _require_account(account_id, account["id"])
+ return _snapshot_detail(account_row, snapshot_id)
@app.get(f"/v2/{platform}/accounts/{{account_id}}/creator-fields")
def get_platform_creator_fields(account_id: str, account: dict[str, Any] = Depends(legacy.require_approved)) -> dict[str, Any]:
- _require_account(account_id, account["id"])
- raise HTTPException(status_code=404, detail="No creator-center snapshot found")
+ account_row = _require_account(account_id, account["id"])
+ latest_creator_snapshot = _latest_snapshot(account_row, "creator_center")
+ if not latest_creator_snapshot:
+ raise HTTPException(status_code=404, detail="No creator-center snapshot found")
+ return _snapshot_detail(account_row, str(latest_creator_snapshot.get("id") or ""))
@app.get(f"/v2/{platform}/accounts/{{account_id}}/videos")
def list_platform_account_videos(
@@ -604,49 +1668,115 @@ def register_domestic_platform_routes(app: Any, legacy: Any, *, platform: str, l
) -> dict[str, Any]:
account_row = _require_account(account_id, account["id"])
workspace = _workspace_payload(account_row)
+ latest_public_snapshot = _latest_snapshot(account_row, "public_profile")
+ latest_creator_snapshot = _latest_snapshot(account_row, "creator_center")
+ profiles = _resolve_model_profiles(account["id"], request.model_profile_ids)
+ selected_model_profile_ids = [str(profile.get("id") or "").strip() for profile in profiles if str(profile.get("id") or "").strip()]
+ requested_linked_account_ids = _normalize_id_list(request.linked_account_ids)
+ linked_accounts = _linked_account_context(
+ account_row,
+ requested_linked_account_ids,
+ include_linked_accounts=bool(request.include_linked_accounts),
+ limit=6,
+ )
+ recent_similarity_candidates = (
+ _recent_similarity_candidates_context(account_row, limit=6)
+ if request.include_recent_similar_candidates
+ else []
+ )
context = {
"account": workspace["account"],
"top_videos": workspace["account"]["video_summary"]["videos"][: max(1, min(request.max_videos, 8))],
- "linked_accounts": workspace["linked_accounts"][:5],
+ "linked_accounts": linked_accounts,
+ "recent_similar_candidates": recent_similarity_candidates,
+ "creator_center": {
+ "snapshot_count": len(_list_snapshots(account_row)),
+ "latest_public_snapshot": _snapshot_brief(latest_public_snapshot) if latest_public_snapshot else None,
+ "latest_creator_snapshot": _snapshot_detail(account_row, str(latest_creator_snapshot.get("id") or "")) if latest_creator_snapshot else None,
+ },
+ "request_options": {
+ "include_linked_accounts": bool(request.include_linked_accounts),
+ "include_recent_similar_candidates": bool(request.include_recent_similar_candidates),
+ "auto_analyze_top_videos": bool(request.auto_analyze_top_videos),
+ "top_video_analysis_count": int(request.top_video_analysis_count),
+ "max_videos": int(request.max_videos),
+ },
+ "requested_model_profile_ids": _normalize_id_list(request.model_profile_ids),
+ "selected_model_profile_ids": selected_model_profile_ids,
"extra_focus": request.extra_focus,
}
prompt = (
f"请从新媒体商业化运营视角,分析这个{label}账号,输出执行摘要、可借鉴点、风险提醒和下一步动作。"
f"\n\n输入:\n{json.dumps(context, ensure_ascii=False, indent=2)}"
)
- output, parsed = await _call_reasoning_model(
- account["id"],
- prompt,
- system_prompt="你是新媒体账号分析顾问。尽量输出 JSON,字段包括 executive_summary、borrow_points、risks、next_actions。",
- temperature=request.temperature,
- )
report_id = make_id(f"{platform}_report")
legacy.db.execute(
- f"INSERT INTO {table_prefix}_analysis_reports (id, user_id, account_source_id, focus_text, prompt_text, context_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
+ f"""INSERT INTO {table_prefix}_analysis_reports
+ (id, user_id, account_source_id, focus_text, model_profile_ids_json, linked_account_ids_json, prompt_text, context_json, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
report_id,
account["id"],
account_row["id"],
request.extra_focus or "",
+ _safe_json_dumps(selected_model_profile_ids),
+ _safe_json_dumps([item.get("target_account_id", "") for item in linked_accounts if item.get("target_account_id")]),
prompt,
_safe_json_dumps(context),
now(),
),
)
- suggestion_id = make_id(f"{platform}_suggestion")
- profile = legacy.model_profile_for_account(account["id"], None)
- legacy.db.execute(
- f"INSERT INTO {table_prefix}_analysis_suggestions (id, report_id, model_profile_id, model_label, status, suggestion_text, parsed_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
- (
- suggestion_id,
- report_id,
- profile["id"],
- f"{profile.get('name', '')} · {profile.get('model_name', '')}".strip(" ·"),
- "ok",
- output[:4000],
- _safe_json_dumps(parsed),
- now(),
- ),
+
+ suggestions: list[dict[str, Any]] = []
+ for profile in profiles:
+ output, parsed = await _call_reasoning_model(
+ account["id"],
+ prompt,
+ system_prompt="你是新媒体账号分析顾问。尽量输出 JSON,字段包括 executive_summary、borrow_points、risks、next_actions。",
+ model_profile_id=str(profile.get("id") or ""),
+ temperature=request.temperature,
+ )
+ suggestion_id = make_id(f"{platform}_suggestion")
+ model_label = f"{profile.get('name', '')} · {profile.get('model_name', '')}".strip(" ·")
+ legacy.db.execute(
+ f"INSERT INTO {table_prefix}_analysis_suggestions (id, report_id, model_profile_id, model_label, status, suggestion_text, parsed_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ (
+ suggestion_id,
+ report_id,
+ profile["id"],
+ model_label,
+ "ok",
+ output[:4000],
+ _safe_json_dumps(parsed),
+ now(),
+ ),
+ )
+ suggestions.append(
+ {
+ "id": suggestion_id,
+ "status": "ok",
+ "model_profile_id": profile["id"],
+ "model_label": model_label,
+ "suggestion_text": output[:4000],
+ "parsed_json": parsed,
+ "created_at": now(),
+ }
+ )
+
+ top_video_min_score = 45.0
+ if not any(float(item.get("score", {}).get("performance_score") or 0) >= top_video_min_score for item in context["top_videos"]):
+ top_video_min_score = 0.0
+ top_video_analyses = (
+ await _analyze_top_videos_for_account(
+ account_row,
+ account["id"],
+ model_profile_id=selected_model_profile_ids[0] if selected_model_profile_ids else "",
+ top_video_count=request.top_video_analysis_count,
+ min_score=top_video_min_score,
+ temperature=min(max(request.temperature, 0.15), 0.4),
+ )
+ if request.auto_analyze_top_videos and profiles
+ else []
)
report_row = legacy.db.fetch_one(
f"SELECT * FROM {table_prefix}_analysis_reports WHERE id = ?",
@@ -657,9 +1787,9 @@ def register_domestic_platform_routes(app: Any, legacy: Any, *, platform: str, l
"report_id": report_id,
"account_id": account_row["id"],
"created_at": now(),
- "suggestions": report_payload["suggestions"],
+ "suggestions": report_payload["suggestions"] or suggestions,
"context": context,
- "top_video_analyses": [],
+ "top_video_analyses": top_video_analyses,
}
@app.post(f"/v2/{platform}/accounts/{{account_id}}/videos/analyze-top")
@@ -669,37 +1799,14 @@ def register_domestic_platform_routes(app: Any, legacy: Any, *, platform: str, l
account: dict[str, Any] = Depends(legacy.require_approved),
) -> dict[str, Any]:
account_row = _require_account(account_id, account["id"])
- videos = [_video_payload(row) for row in _linked_video_sources(account_row)]
- ranked = [
- video for video in sorted(videos, key=lambda item: item["score"]["performance_score"], reverse=True)
- if float(video["score"]["performance_score"] or 0) >= float(request.min_score or 0)
- ][: request.top_video_count]
- results: list[dict[str, Any]] = []
- for video in ranked:
- prompt = (
- f"请拆解这条{label}作品为什么值得关注,输出 summary、borrow_points、risks。"
- f"\n\n输入:\n{json.dumps(video, ensure_ascii=False, indent=2)}"
- )
- output, parsed = await _call_reasoning_model(
- account["id"],
- prompt,
- system_prompt="你是短视频内容拆解助手。尽量输出 JSON,字段包括 summary、borrow_points、risks。",
- model_profile_id=request.model_profile_id,
- temperature=request.temperature,
- )
- summary_text = str(parsed.get("summary") or parsed.get("headline_summary") or output)[:240]
- results.append(
- {
- "id": make_id(f"{platform}_va"),
- "video_id": video["id"],
- "video_title": video["title"],
- "status": "ok",
- "summary_text": summary_text,
- "parsed_json": parsed,
- "performance_score": video["score"]["performance_score"],
- "created_at": now(),
- }
- )
+ results = await _analyze_top_videos_for_account(
+ account_row,
+ account["id"],
+ model_profile_id=request.model_profile_id,
+ top_video_count=request.top_video_count,
+ min_score=request.min_score,
+ temperature=request.temperature,
+ )
return {
"account_id": account_row["id"],
"analyzed_count": len(results),
@@ -713,18 +1820,73 @@ def register_domestic_platform_routes(app: Any, legacy: Any, *, platform: str, l
) -> dict[str, Any]:
account_row = _require_account(request.source_account_id, account["id"])
source_payload = _account_payload(account_row)
+ ranked_candidates: list[dict[str, Any]] = []
+ seen_keys: set[str] = set()
+ source_tokens = _similarity_tokens(source_payload)
+ extra_requirement_tokens = {
+ token.lower()
+ for token in _extract_keywords(request.extra_requirements or "")
+ if token
+ }
+
+ def _append_candidate(item: dict[str, Any]) -> None:
+ candidate_account_id = str(item.get("candidate_account_id") or "").strip()
+ candidate_profile_url = str(item.get("candidate_profile_url") or "").strip()
+ dedupe_key = candidate_account_id or candidate_profile_url.lower()
+ if not dedupe_key or dedupe_key in seen_keys:
+ return
+ seen_keys.add(dedupe_key)
+ ranked_candidates.append(item)
+
+ if request.seed_linked_accounts:
+ for linked in _linked_account_context(account_row, [], include_linked_accounts=True, limit=max(4, request.max_candidates)):
+ linked_account = linked.get("account") or {}
+ candidate_tokens = _similarity_tokens(linked_account)
+ source_overlap = len(source_tokens.intersection(candidate_tokens))
+ requirement_overlap = len(extra_requirement_tokens.intersection(candidate_tokens))
+ heuristic = 65 + source_overlap * 6 + requirement_overlap * 8
+ _append_candidate(
+ {
+ "candidate_account_id": linked.get("target_account_id", ""),
+ "candidate_profile_url": linked.get("target_profile_url") or linked_account.get("profile_url", ""),
+ "candidate_nickname": linked_account.get("nickname", "") or linked.get("target_nickname", ""),
+ "heuristic_score": float(heuristic),
+ "agent_score": float(heuristic),
+ "rationale_text": "来自已建立的对标关系,优先纳入相似账号候选池。",
+ "dimensions_json": {"source": "linked_account", "source_overlap": source_overlap, "requirement_overlap": requirement_overlap},
+ }
+ )
+
+ for url in _normalize_id_list(request.candidate_urls):
+ title = _candidate_title_from_url(url)
+ candidate_tokens = _similarity_tokens({"nickname": title, "signature": "", "tags": [], "keywords": []})
+ source_overlap = len(source_tokens.intersection(candidate_tokens))
+ requirement_overlap = len(extra_requirement_tokens.intersection(candidate_tokens))
+ heuristic = 52 + source_overlap * 4 + requirement_overlap * 6
+ _append_candidate(
+ {
+ "candidate_account_id": "",
+ "candidate_profile_url": url,
+ "candidate_nickname": title,
+ "heuristic_score": float(heuristic),
+ "agent_score": float(heuristic),
+ "rationale_text": "来自手动提供的主页链接,已纳入相似账号候选池。",
+ "dimensions_json": {"source": "manual_url", "source_overlap": source_overlap, "requirement_overlap": requirement_overlap, "search_public_pages": bool(request.search_public_pages)},
+ }
+ )
+
candidates = [
row for row in _content_source_rows(account["id"], platform, "creator_account")
if row["id"] != account_row["id"]
- ][: max(5, request.max_candidates)]
- ranked_candidates: list[dict[str, Any]] = []
- source_tags = set(source_payload.get("tags") or [])
+ ][: max(5, request.max_candidates * 2)]
for index, row in enumerate(candidates, start=1):
payload = _account_payload(row)
- overlap = len(source_tags.intersection(set(payload.get("tags") or [])))
- heuristic = overlap * 10 + max(0, 50 - index)
- rationale = f"与源账号同平台,标签重合 {overlap},适合作为{label}对标候选。"
- ranked_candidates.append(
+ candidate_tokens = _similarity_tokens(payload)
+ source_overlap = len(source_tokens.intersection(candidate_tokens))
+ requirement_overlap = len(extra_requirement_tokens.intersection(candidate_tokens))
+ heuristic = source_overlap * 10 + requirement_overlap * 8 + max(0, 50 - index)
+ rationale = f"与源账号同平台,标签/关键词重合 {source_overlap},适合作为{label}对标候选。"
+ _append_candidate(
{
"candidate_account_id": row["id"],
"candidate_profile_url": payload.get("profile_url", ""),
@@ -732,7 +1894,7 @@ def register_domestic_platform_routes(app: Any, legacy: Any, *, platform: str, l
"heuristic_score": float(heuristic),
"agent_score": float(heuristic),
"rationale_text": rationale,
- "dimensions_json": {"tag_overlap": overlap},
+ "dimensions_json": {"source": "local_account", "source_overlap": source_overlap, "requirement_overlap": requirement_overlap},
}
)
ranked_candidates.sort(key=lambda item: item["agent_score"], reverse=True)
@@ -745,7 +1907,15 @@ def register_domestic_platform_routes(app: Any, legacy: Any, *, platform: str, l
account["id"],
account_row["id"],
request.extra_requirements or "",
- _safe_json_dumps({"source_account": source_payload}),
+ _safe_json_dumps(
+ {
+ "source_account": source_payload,
+ "candidate_urls": _normalize_id_list(request.candidate_urls),
+ "seed_linked_accounts": bool(request.seed_linked_accounts),
+ "search_public_pages": bool(request.search_public_pages),
+ "model_profile_id": request.model_profile_id or "",
+ }
+ ),
now(),
),
)
@@ -795,6 +1965,7 @@ def register_domestic_platform_routes(app: Any, legacy: Any, *, platform: str, l
"id": search_row["id"],
"search_id": search_row["id"],
"source_account_id": search_row["source_account_id"],
+ "context": _parse_json(search_row.get("context_json") or "{}", {}),
"candidates": candidates,
"created_at": search_row["created_at"],
}
diff --git a/collector-service/app/douyin_features.py b/collector-service/app/douyin_features.py
index 729dcca..20b6948 100644
--- a/collector-service/app/douyin_features.py
+++ b/collector-service/app/douyin_features.py
@@ -462,7 +462,8 @@ async def _fetch_html(url: str, cookie: str = "") -> tuple[str, str]:
}
if cookie.strip():
headers["Cookie"] = cookie.strip()
- async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT, follow_redirects=True) as client:
+ # Keep Douyin public fetches deterministic and avoid inheriting local desktop proxy settings.
+ async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT, follow_redirects=True, trust_env=False) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
return str(response.url), response.text
@@ -792,10 +793,6 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
ensure_schema()
- @app.on_event("startup")
- def _startup_douyin_schema() -> None:
- ensure_schema()
-
def _require_owned_account(account_id: str, user_id: str) -> dict[str, Any]:
row = legacy.db.fetch_one(
"SELECT * FROM douyin_accounts WHERE id = ? AND user_id = ?",
diff --git a/collector-service/app/oneliner_features.py b/collector-service/app/oneliner_features.py
index 9492e3c..83ce426 100644
--- a/collector-service/app/oneliner_features.py
+++ b/collector-service/app/oneliner_features.py
@@ -1071,10 +1071,6 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None:
ensure_schema()
- @app.on_event("startup")
- def _startup_oneliner_schema() -> None:
- ensure_schema()
-
def _resolve_project(account: dict[str, Any], project_id: str | None) -> dict[str, Any]:
return legacy.resolve_target_project(account["id"], project_id or None, username=account["username"])
diff --git a/concepts/studio-workbench/README.md b/concepts/studio-workbench/README.md
index 76468fe..d83e33b 100644
--- a/concepts/studio-workbench/README.md
+++ b/concepts/studio-workbench/README.md
@@ -2,6 +2,8 @@
Direction: `Castmagic x content ops studio`
+Preview prototype: [index.html](index.html)
+
This version optimizes for teams that want to turn one material source into many structured outputs.
## Product thesis
diff --git a/deploy/storyforge-fnos-cliproxy.compose.yaml b/deploy/storyforge-fnos-cliproxy.compose.yaml
new file mode 100644
index 0000000..98d4865
--- /dev/null
+++ b/deploy/storyforge-fnos-cliproxy.compose.yaml
@@ -0,0 +1,16 @@
+services:
+ storyforge-cliproxyapi:
+ image: ${STORYFORGE_CLIPROXY_IMAGE:-eceasy/cli-proxy-api:latest}
+ container_name: storyforge-cliproxyapi
+ restart: unless-stopped
+ command:
+ - ./CLIProxyAPI
+ - -config
+ - /CLIProxyAPI/config.yaml
+ ports:
+ - "${STORYFORGE_CLIPROXY_PORT:-8317}:8317"
+ - "${STORYFORGE_CLIPROXY_MANAGEMENT_PORT:-18085}:8085"
+ volumes:
+ - "${STORYFORGE_CLIPROXY_STATE_ROOT:-/vol1/docker/hyzq-stack/shared/storyforge-cliproxyapi}/config.yaml:/CLIProxyAPI/config.yaml:ro"
+ - "${STORYFORGE_CLIPROXY_STATE_ROOT:-/vol1/docker/hyzq-stack/shared/storyforge-cliproxyapi}/auths:/root/.cli-proxy-api"
+ - "${STORYFORGE_CLIPROXY_STATE_ROOT:-/vol1/docker/hyzq-stack/shared/storyforge-cliproxyapi}/logs:/CLIProxyAPI/logs"
diff --git a/deploy/storyforge-fnos-huobao.compose.yaml b/deploy/storyforge-fnos-huobao.compose.yaml
new file mode 100644
index 0000000..4517391
--- /dev/null
+++ b/deploy/storyforge-fnos-huobao.compose.yaml
@@ -0,0 +1,25 @@
+services:
+ storyforge-huobao:
+ image: ${STORYFORGE_HUOBAO_IMAGE:-storyforge-huobao:fnos}
+ build:
+ context: ../../storyforge/huobao-drama-source
+ dockerfile: Dockerfile
+ args:
+ DOCKER_REGISTRY: ${STORYFORGE_HUOBAO_DOCKER_REGISTRY:-docker.m.daocloud.io/library/}
+ NPM_REGISTRY: ${STORYFORGE_HUOBAO_NPM_REGISTRY:-https://registry.npmmirror.com}
+ GO_PROXY: ${STORYFORGE_HUOBAO_GO_PROXY:-https://goproxy.cn,direct}
+ ALPINE_MIRROR: ${STORYFORGE_HUOBAO_ALPINE_MIRROR:-mirrors.aliyun.com}
+ container_name: storyforge-huobao
+ restart: unless-stopped
+ ports:
+ - "${STORYFORGE_HUOBAO_PORT:-5678}:5678"
+ environment:
+ TZ: ${TZ:-Asia/Shanghai}
+ volumes:
+ - "${STORYFORGE_HUOBAO_STATE_ROOT:-/vol1/docker/hyzq-stack/shared/storyforge-huobao}/data:/app/data"
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5678/health"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 20s
diff --git a/deploy/storyforge-fnos-n8n.compose.yaml b/deploy/storyforge-fnos-n8n.compose.yaml
new file mode 100644
index 0000000..e14ff8e
--- /dev/null
+++ b/deploy/storyforge-fnos-n8n.compose.yaml
@@ -0,0 +1,21 @@
+services:
+ storyforge-n8n:
+ image: ${STORYFORGE_N8N_IMAGE:-docker.m.daocloud.io/n8nio/n8n:latest}
+ container_name: storyforge-n8n
+ restart: unless-stopped
+ ports:
+ - "${STORYFORGE_N8N_PORT:-5670}:5678"
+ environment:
+ N8N_HOST: ${N8N_HOST:-0.0.0.0}
+ N8N_PORT: 5678
+ N8N_PROTOCOL: ${N8N_PROTOCOL:-http}
+ WEBHOOK_URL: ${WEBHOOK_URL:-http://192.168.31.188:5670/}
+ STORYFORGE_INTERNAL_BASE_URL: ${STORYFORGE_INTERNAL_BASE_URL:-http://192.168.31.188:19193}
+ STORYFORGE_ORCHESTRATOR_SECRET: ${ORCHESTRATOR_SHARED_SECRET:-storyforge-local-secret}
+ GENERIC_TIMEZONE: ${GENERIC_TIMEZONE:-Asia/Shanghai}
+ TZ: ${TZ:-Asia/Shanghai}
+ N8N_SECURE_COOKIE: ${N8N_SECURE_COOKIE:-false}
+ N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: ${N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS:-false}
+ volumes:
+ - "${STORYFORGE_N8N_STATE_ROOT:-/vol1/docker/hyzq-stack/shared/storyforge-n8n}/storage:/home/node/.n8n"
+ - "${STORYFORGE_N8N_WORKFLOW_ROOT:-/vol1/docker/hyzq-stack/current/storyforge/n8n}:/workspace/n8n:ro"
diff --git a/docs/NEXT_THREAD_HANDOFF_2026-05-02.md b/docs/NEXT_THREAD_HANDOFF_2026-05-02.md
new file mode 100644
index 0000000..64c0131
--- /dev/null
+++ b/docs/NEXT_THREAD_HANDOFF_2026-05-02.md
@@ -0,0 +1,125 @@
+# StoryForge Next Thread Handoff - 2026-05-02
+
+## Gitea
+
+- Repository: https://git.hyzq.site/krisolo/storyforge
+- Current branch: `codex/storyforge-live-orchestrator-sync-20260323`
+- Public workbench: https://storyforge.hyzq.net/
+- Public health endpoint: https://storyforge.hyzq.net/healthz
+
+## Project Goal
+
+StoryForge is being shaped into a multi-platform new-media operating workbench: project-first workspace, benchmark discovery, creator-center account analysis, production queue, live recording, AI video generation, review, and a OneLiner main Agent layer that can route unfinished flows into platform Agents.
+
+## Current Progress
+
+- The public web workbench is deployed at `storyforge.hyzq.net` and can auto-login with the configured web auto-session.
+- The UI has been returned to the preferred current design direction and refined for mobile/workbench use. The dashboard keeps the `1 main + 2 secondary` action model.
+- OneLiner now opens immediately. Context hydration happens inside the OneLiner panel instead of leaving the global header stuck on `正在打开 OneLiner`.
+- Discovery/creator-center flows now support Douyin and Kuaishou style creator-center sync, account analysis, top-video analysis, similar-account state isolation, and selected-account cache cleanup.
+- Production Center exposes intake entry points for creator-center sync, import homepage/video/text, upload video, AI video, real-cut, and live-recorder maintenance.
+- Admin Model Access centralizes language model, ASR, image, image-to-image, video, Huobao, Seedance, and runtime integration configuration behind super-admin access.
+- Seedance 2.0 is routed through Huobao/Volcengine style video config. AI video creation preflights Huobao video config before dispatch.
+- Public deployment scripts and fnOS/NAS deployment scripts are present for web, collector, live-recorder, cutvideo tunnel, n8n, Huobao, and CLI proxy.
+
+## Architecture Snapshot
+
+- Frontend: static vanilla JS app under `web/storyforge-web-v4`, with runtime config, API client, session store, platform runtime, and large workbench renderer in `assets/app.js`.
+- Backend: FastAPI collector under `collector-service/app`, with `core_main.py` as the main app surface and feature modules for Douyin, domestic platforms, OneLiner, integrations, and database access.
+- Data: server-side SQLite under `/home/ubuntu/storyforge/data/collector/storyforge.db` in production.
+- Public server: `https://storyforge.hyzq.net` proxies the static web and collector API.
+- fnOS/NAS: local storage and optional service workloads live under `/vol1/docker/hyzq-stack/...` on the fnOS host.
+- Windows ASR target: intended Windows host is `192.168.31.18`, using faster-whisper with GPU-capable auto mode and mixed Chinese/English recognition.
+
+## Current Public Runtime Status
+
+Fresh checks on 2026-05-02:
+
+- `GET https://storyforge.hyzq.net/healthz`: OK.
+- `POST https://storyforge.hyzq.net/v2/auth/auto-session`: OK, returns the `kris` super-admin session.
+- `cutvideo`: configured and reachable at the server-local route.
+- `n8n`: configured and reachable at the server-local route.
+- `Huobao`: configured and reachable, but video config count is `0`; Seedance/AI video still needs an enabled Huobao video config.
+- `local_model`: intentionally not configured because the project decision is to use public/cloud models rather than local models.
+- `ASR`: configured as Windows deployment, but public collector currently reports `Connection refused` on `http://127.0.0.1:28088/health`.
+- `live_recorder`: configured as NAS deployment, but public collector currently reports connection reset on `http://127.0.0.1:19106/api/healthz`.
+
+## Important Files For The Next Thread
+
+- `web/storyforge-web-v4/assets/app.js`: primary workbench UI, OneLiner runtime, admin model config, discovery, production, and mobile interaction logic.
+- `web/storyforge-web-v4/assets/storyforge-platform-runtime.js`: platform route contract for Douyin/Kuaishou/Xiaohongshu/Bilibili/Video Account style workbenches.
+- `web/storyforge-web-v4/tests/workbench-pages.test.mjs`: frontend contract tests; most UI workflow guarantees live here.
+- `collector-service/app/core_main.py`: collector API, auth, integrations, runtime config, live recorder proxy, Huobao model access, AI video job creation.
+- `collector-service/app/domestic_platform_features.py`: domestic-platform creator-center sync, analysis, relations, video persistence, and top-video followups.
+- `collector-service/app/douyin_features.py`: Douyin-specific account and public fetch behavior.
+- `collector-service/app/oneliner_features.py`: OneLiner main Agent, governance, run lifecycle, execution cards, and platform Agent routing.
+- `tests/test_platform_contracts.py`: backend route contracts for platform sync/analysis flows.
+- `tests/test_production_baseline.py`: production, model access, AI video, and integration baseline tests.
+- `docs/superpowers/specs/*` and `docs/superpowers/plans/*`: design and implementation plans used during this build phase.
+- `docs/FNOS_LAN_DELIVERY_RUNBOOK_2026-03-27.md`: fnOS/NAS deployment guide.
+- `docs/WINDOWS_CUTVIDEO_OPERATIONS_2026-03-27.md`: Windows cutvideo operating notes.
+- `deploy/STORYFORGE_PUBLIC_GATEWAY.md`: public gateway deployment notes.
+
+## Recent Change Highlights
+
+- OneLiner opening behavior:
+ - Added `onelinerHydrating` and `onelinerHydrationMessage`.
+ - `open-oneliner` opens the panel first, renders immediately, then hydrates control surfaces and messages.
+ - Loading text is panel-local (`正在同步 OneLiner 上下文...`) and clears after hydration.
+
+- Creator-center and benchmark discovery:
+ - Kuaishou/Douyin creator-center sync can persist snapshots and creator works into video sources.
+ - Account analysis carries model profile, linked-account, recent-similar, creator-center, and top-video context.
+ - Similar-account search results are isolated by selected account to avoid stale/cross-account state.
+
+- AI video and Seedance:
+ - AI video form exposes provider/model controls and points admins to Huobao video config.
+ - Backend validates that Huobao has active video config before AI video dispatch.
+ - Seedance 2.0 uses the Huobao/Volcengine config path, not a local model path.
+
+- Runtime governance and admin config:
+ - Admin Model Access covers runtime config, system model config, Huobao AI config, quota, policy, and integration status.
+ - Local model is left blank by design; public/cloud model configuration is the intended path.
+
+- Deployment:
+ - Added fnOS compose/deploy scripts for CLI proxy, Huobao, and n8n.
+ - LAN stack deployment now includes cutvideo tunnel, live recorder, CLI proxy, n8n, Huobao, collector, web, and smoke checks.
+
+## Verification Commands
+
+Run from repository root:
+
+```bash
+node --test web/storyforge-web-v4/tests/workbench-pages.test.mjs
+python3 -m unittest tests.test_platform_contracts
+python3 -m unittest tests.test_production_baseline
+curl -fsS https://storyforge.hyzq.net/healthz
+```
+
+Useful public deploy commands:
+
+```bash
+STORYFORGE_PUBLIC_SYNC_COLLECTOR=0 ./scripts/deploy_public_storyforge.sh
+STORYFORGE_PUBLIC_SYNC_COLLECTOR=1 ./scripts/deploy_public_storyforge.sh
+```
+
+Useful fnOS/NAS deploy commands:
+
+```bash
+SKIP_SMOKE=1 ./scripts/deploy_fnos_storyforge_lan_stack.sh
+./scripts/deploy_fnos_storyforge_cliproxy.sh
+./scripts/deploy_fnos_storyforge_n8n.sh
+./scripts/deploy_fnos_storyforge_huobao.sh
+```
+
+## Known Follow-Up Work
+
+- Restore ASR reachability from the public collector to the Windows ASR host. The intended host is `192.168.31.18`; check whether the server-side runtime config should point at the relay/tunnel URL rather than `127.0.0.1:28088`.
+- Restore live-recorder health from the public collector to the NAS service. The current public probe reports connection reset.
+- Configure at least one active Huobao video model config for Seedance 2.0 before expecting AI video jobs to dispatch successfully.
+- The public deploy smoke can fail if ASR/live-recorder are offline even when the web and collector deploy succeeded; check the individual health results before assuming the deploy itself failed.
+- Keep secrets out of Git: API keys, cookies, creator-center login cookies, and Gitea credentials must stay in runtime config, Keychain, or server-side storage.
+
+## Handoff Recommendation
+
+For the next thread, start by pulling this branch from Gitea, reading this document, then running the verification commands above. After that, focus first on the three runtime gaps: ASR, live-recorder, and Huobao Seedance video config. Once those are green, test the real creator-center account flow and AI video creation from the public site.
diff --git a/docs/superpowers/plans/2026-03-28-homepage-workbench-redesign.md b/docs/superpowers/plans/2026-03-28-homepage-workbench-redesign.md
new file mode 100644
index 0000000..4e8312c
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-28-homepage-workbench-redesign.md
@@ -0,0 +1,692 @@
+# Homepage Workbench Redesign Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Rebuild the StoryForge homepage into the approved human-first `v6` structure while preserving the current visual language, reducing text density, surfacing `1 主 2 次` actions first, and moving system governance entry points into an explicit admin workbench flow.
+
+**Architecture:** Keep the existing static-script frontend architecture, but pull homepage-specific rendering into a dedicated browser module so the dashboard layout can be tested without dragging the entire `app.js` file into every change. The existing `renderDashboardScreen()` function becomes an orchestrator: it gathers runtime data, delegates HTML generation to a dedicated homepage renderer, and wires click handlers through the existing global action system and quick-action modal.
+
+**Tech Stack:** Vanilla browser JS (IIFE modules on `window`), HTML string rendering, CSS in `assets/styles.css`, Python baseline tests, Node built-in test runner for homepage markup contracts.
+
+---
+
+### Task 1: Extract Homepage Rendering Into a Dedicated Module
+
+**Files:**
+- Create: `web/storyforge-web-v4/assets/storyforge-dashboard-home.js`
+- Create: `web/storyforge-web-v4/tests/dashboard-home.test.mjs`
+- Modify: `web/storyforge-web-v4/index.html`
+- Modify: `web/storyforge-web-v4/assets/app.js`
+
+- [ ] **Step 1: Write the failing homepage renderer test**
+
+Create `web/storyforge-web-v4/tests/dashboard-home.test.mjs`:
+
+```js
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import vm from "node:vm";
+
+const ROOT = path.resolve(process.cwd(), "web/storyforge-web-v4");
+
+function loadHomepageModule() {
+ const source = fs.readFileSync(path.join(ROOT, "assets/storyforge-dashboard-home.js"), "utf8");
+ const context = {
+ window: {},
+ console,
+ escapeHtml: (value) => String(value ?? ""),
+ formatNumber: (value) => String(value ?? 0),
+ safeArray: (value) => Array.isArray(value) ? value : [],
+ button: (label, action, tone = "secondary") =>
+ ``
+ };
+ vm.createContext(context);
+ vm.runInContext(source, context);
+ return context.window.StoryForgeDashboardHome;
+}
+
+test("homepage v6 puts actions before overview and uses 1-primary-2-secondary structure", () => {
+ const mod = loadHomepageModule();
+ const html = mod.renderDashboardHome({
+ title: "项目总台",
+ workspaceLabel: "Kris",
+ currentProjectName: "品牌增长实验室",
+ summaryTabs: [
+ { key: "project_progress", label: "项目进度", value: "3 / 5", hint: "2 项可继续推进", active: true },
+ { key: "focus_accounts", label: "重点账号 / 对标", value: "2 个", hint: "1 个缺高分分析", active: false },
+ { key: "production_jobs", label: "生产任务", value: "4 条", hint: "1 条待确认", active: false }
+ ],
+ primaryAction: {
+ title: "先补抖音重点对标的高分作品分析",
+ reason: "最近有新作品,但还没形成高分样本。",
+ badges: ["最优先", "预计 10 分钟判断", "关联:重点账号"]
+ },
+ secondaryActions: [
+ { title: "确认一个待执行的生产计划", reason: "素材和结论都在,只差最后确认。" },
+ { title: "更新重点账号的跟踪摘要", reason: "有新动态,但不值得占据大块首页空间。" }
+ ],
+ overviewDetail: {
+ title: "当前阶段",
+ body: "这里只展示当前 tab 的核心状态。"
+ }
+ });
+
+ assert.ok(html.includes("今天先做什么"));
+ assert.ok(html.includes("项目概览"));
+ assert.ok(html.indexOf("今天先做什么") < html.indexOf("项目概览"));
+ assert.match(html, /先补抖音重点对标的高分作品分析/);
+ assert.match(html, /确认一个待执行的生产计划/);
+ assert.match(html, /更新重点账号的跟踪摘要/);
+});
+```
+
+- [ ] **Step 2: Run the new test and verify it fails**
+
+Run:
+
+```bash
+cd /Users/kris/code/StoryForge-gitea
+node --test web/storyforge-web-v4/tests/dashboard-home.test.mjs
+```
+
+Expected: FAIL with `ENOENT` for `storyforge-dashboard-home.js`.
+
+- [ ] **Step 3: Create the dedicated homepage renderer module**
+
+Create `web/storyforge-web-v4/assets/storyforge-dashboard-home.js`:
+
+```js
+(function () {
+ function defaultEscapeHtml(value) {
+ return String(value ?? "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """);
+ }
+
+ function renderTags(items, escapeHtml) {
+ return (items || []).map((item) => `${escapeHtml(item)}`).join("");
+ }
+
+ function renderSecondaryAction(item, index, escapeHtml) {
+ return `
+
+
${index + 2}
+
+
${escapeHtml(item.title)}
+
${escapeHtml(item.reason)}
+
+
+
+
+
+
+ `;
+ }
+
+ function renderDashboardHome(model, helpers = {}) {
+ const escapeHtml = helpers.escapeHtml || defaultEscapeHtml;
+ return `
+
+
+
+
+ 当前工作区${escapeHtml(model.workspaceLabel)}
+
+
+
+
+ ${model.contextLinks.map((item) => `
+
+ `).join("")}
+
+
+
+
+
+
+
${escapeHtml(model.actionSourceLabel)}
+
+
+
+
+
${escapeHtml(model.primaryAction.title)}
+
${escapeHtml(model.primaryAction.reason)}
+
${renderTags(model.primaryAction.badges, escapeHtml)}
+
+
+
+
+
+
+
+
+
+ ${model.secondaryActions.map((item, index) => renderSecondaryAction(item, index, escapeHtml)).join("")}
+
+
+
+
+
+
+
项目概览
+
按需展开,不抢首页第一优先级。
+
+
${escapeHtml(model.activeTabLabel)}
+
+
+ ${model.summaryTabs.map((item) => `
+
+ `).join("")}
+
+
${model.overviewBodyHtml}
+
+
+ `;
+ }
+
+ window.StoryForgeDashboardHome = {
+ renderDashboardHome
+ };
+})();
+```
+
+- [ ] **Step 4: Wire the new module into the page**
+
+Modify `web/storyforge-web-v4/index.html`:
+
+```html
+
+
+```
+
+Modify `web/storyforge-web-v4/assets/app.js` near `renderDashboardScreen()`:
+
+```js
+const dashboardHomeRenderer = window.StoryForgeDashboardHome;
+
+function renderDashboardScreen() {
+ // existing auth/loading guards stay in place
+ const homeModel = buildDashboardHomeModel();
+ return screenShell(
+ "项目总台",
+ "先做最能推进当前项目的事。",
+ `${button("新建项目", "create-project")} ${button("导入主页", "open-import-homepage")} ${button("创建 Agent", "open-create-assistant", "primary")}`,
+ dashboardHomeRenderer.renderDashboardHome(homeModel, { escapeHtml })
+ );
+}
+```
+
+- [ ] **Step 5: Re-run the renderer test and syntax checks**
+
+Run:
+
+```bash
+cd /Users/kris/code/StoryForge-gitea
+node --test web/storyforge-web-v4/tests/dashboard-home.test.mjs
+node --check web/storyforge-web-v4/assets/storyforge-dashboard-home.js
+node --check web/storyforge-web-v4/assets/app.js
+```
+
+Expected: all PASS with the Node test showing `ok 1`.
+
+- [ ] **Step 6: Commit the extraction**
+
+Run:
+
+```bash
+cd /Users/kris/code/StoryForge-gitea
+git add web/storyforge-web-v4/assets/storyforge-dashboard-home.js web/storyforge-web-v4/tests/dashboard-home.test.mjs web/storyforge-web-v4/index.html web/storyforge-web-v4/assets/app.js
+git commit -m "feat: extract homepage dashboard renderer"
+```
+
+### Task 2: Implement Human-First Dashboard Data Model and 1-Primary-2-Secondary Actions
+
+**Files:**
+- Modify: `web/storyforge-web-v4/assets/storyforge-dashboard-home.js`
+- Modify: `web/storyforge-web-v4/assets/app.js`
+- Modify: `web/storyforge-web-v4/tests/dashboard-home.test.mjs`
+
+- [ ] **Step 1: Add failing tests for homepage model generation**
+
+Append to `web/storyforge-web-v4/tests/dashboard-home.test.mjs`:
+
+```js
+test("homepage model builds one primary action, two secondary actions, and a rule fallback label", () => {
+ const mod = loadHomepageModule();
+ assert.equal(typeof mod.createDashboardHomeModel, "function");
+
+ const model = mod.createDashboardHomeModel({
+ workspaceLabel: "Kris",
+ currentProjectName: "品牌增长实验室",
+ trackedAccountsCount: 2,
+ assistantCount: 1,
+ jobCount: 4,
+ actionSourceLabel: "规则推荐",
+ dashboardOverviewTab: "project_progress"
+ });
+
+ assert.equal(model.actionSourceLabel, "规则推荐");
+ assert.equal(model.secondaryActions.length, 2);
+ assert.match(model.primaryAction.title, /高分作品分析|继续补高分对标/);
+});
+```
+
+- [ ] **Step 2: Run the targeted Node tests**
+
+Run:
+
+```bash
+cd /Users/kris/code/StoryForge-gitea
+node --test web/storyforge-web-v4/tests/dashboard-home.test.mjs
+```
+
+Expected: FAIL because the renderer does not yet expose the full `contextLinks` / `actionSourceLabel` model consistently.
+
+- [ ] **Step 3: Add a reusable homepage model builder in `storyforge-dashboard-home.js`**
+
+Modify `web/storyforge-web-v4/assets/storyforge-dashboard-home.js`:
+
+```js
+ function createDashboardHomeModel(raw) {
+ const trackedAccountsCount = Number(raw.trackedAccountsCount || 0);
+ const assistantCount = Number(raw.assistantCount || 0);
+ const jobCount = Number(raw.jobCount || 0);
+
+ const actions = [];
+ if (trackedAccountsCount > 0) {
+ actions.push({
+ title: "先补抖音重点对标的高分作品分析",
+ reason: "最近有新作品,但还没形成高分样本。",
+ badges: ["最优先", "预计 10 分钟判断", "关联:重点账号"],
+ goAction: "goto-discovery",
+ goLabel: "去找对标",
+ agentLabel: "交给主 Agent"
+ });
+ }
+ if (jobCount > 0) {
+ actions.push({
+ title: "确认一个待执行的生产计划",
+ reason: "素材和结论都在,只差最后确认。",
+ goAction: "goto-production",
+ goLabel: "去处理"
+ });
+ }
+ actions.push({
+ title: "更新重点账号的跟踪摘要",
+ reason: "有新动态,但不值得占据大块首页空间。",
+ goAction: "goto-tracking",
+ goLabel: "去处理"
+ });
+ while (actions.length < 3) {
+ actions.push({
+ title: "继续补高分对标并安排生产",
+ reason: "当前项目没有更多高优先动作时,保持主流程推进。",
+ goAction: "goto-production",
+ goLabel: "去处理"
+ });
+ }
+
+ return {
+ workspaceLabel: raw.workspaceLabel,
+ currentProjectName: raw.currentProjectName,
+ actionSourceLabel: raw.actionSourceLabel,
+ contextLinks: [
+ { label: "账号", value: String(trackedAccountsCount), action: "goto-owned" },
+ { label: "任务", value: String(jobCount), action: "goto-production" },
+ { label: "Agent", value: String(assistantCount), action: "goto-playbook" }
+ ],
+ primaryAction: actions[0],
+ secondaryActions: actions.slice(1, 3)
+ };
+ }
+
+ window.StoryForgeDashboardHome = {
+ createDashboardHomeModel,
+ renderDashboardHome
+ };
+```
+
+- [ ] **Step 4: Add dashboard-specific state and wire the model builder from `app.js`**
+
+Modify `web/storyforge-web-v4/assets/app.js` state setup:
+
+```js
+const appState = {
+ // existing fields...
+ dashboardOverviewTab: "project_progress",
+ dashboardActionReason: null
+};
+```
+
+Build the raw dashboard inputs in `web/storyforge-web-v4/assets/app.js`:
+
+```js
+function getDashboardActionSourceLabel() {
+ return appState.onelinerProfile ? "主 Agent 优先推荐" : "规则推荐";
+}
+
+function buildDashboardHomeModel() {
+ const project = getSelectedProject();
+ const stats = project ? getProjectStats(project.id) : { assistants: [], jobs: [], sources: [], knowledgeBases: [] };
+ const trackedAccounts = getTrackingAccounts();
+ const baseModel = window.StoryForgeDashboardHome.createDashboardHomeModel({
+ workspaceLabel: appState.me?.display_name || appState.me?.username || "当前工作区",
+ currentProjectName: project?.name || "还没有项目",
+ trackedAccountsCount: trackedAccounts.length || appState.accounts.length,
+ assistantCount: stats.assistants.length,
+ jobCount: stats.jobs.length,
+ actionSourceLabel: getDashboardActionSourceLabel(),
+ dashboardOverviewTab: appState.dashboardOverviewTab
+ });
+ return {
+ ...baseModel,
+ summaryTabs: buildDashboardOverviewTabs(project, stats),
+ activeTabLabel: dashboardTabLabel(appState.dashboardOverviewTab),
+ overviewBodyHtml: renderDashboardOverviewBody(appState.dashboardOverviewTab, { project, stats, trackedAccounts })
+ };
+}
+```
+
+- [ ] **Step 5: Re-run tests and syntax checks**
+
+Run:
+
+```bash
+cd /Users/kris/code/StoryForge-gitea
+node --test web/storyforge-web-v4/tests/dashboard-home.test.mjs
+node --check web/storyforge-web-v4/assets/storyforge-dashboard-home.js
+node --check web/storyforge-web-v4/assets/app.js
+```
+
+Expected: PASS with no missing-field errors.
+
+- [ ] **Step 6: Commit the action hierarchy work**
+
+Run:
+
+```bash
+cd /Users/kris/code/StoryForge-gitea
+git add web/storyforge-web-v4/assets/storyforge-dashboard-home.js web/storyforge-web-v4/assets/app.js web/storyforge-web-v4/tests/dashboard-home.test.mjs
+git commit -m "feat: redesign dashboard actions for human-first flow"
+```
+
+### Task 3: Implement Overview Tabs, Project Switcher, and Admin Workbench Entry
+
+**Files:**
+- Modify: `web/storyforge-web-v4/index.html`
+- Modify: `web/storyforge-web-v4/assets/app.js`
+- Modify: `web/storyforge-web-v4/assets/storyforge-dashboard-home.js`
+- Modify: `web/storyforge-web-v4/tests/dashboard-home.test.mjs`
+
+- [ ] **Step 1: Add failing tests for overview tab buttons and admin entry**
+
+Append to `web/storyforge-web-v4/tests/dashboard-home.test.mjs`:
+
+```js
+test("homepage overview uses tab buttons and does not render legacy repeated sections", () => {
+ const mod = loadHomepageModule();
+ const html = mod.renderDashboardHome({
+ workspaceLabel: "Kris",
+ currentProjectName: "品牌增长实验室",
+ contextLinks: [],
+ actionSourceLabel: "主 Agent 优先推荐",
+ primaryAction: { title: "A", reason: "B", badges: [], goAction: "x", goLabel: "去处理", agentLabel: "交给主 Agent" },
+ secondaryActions: [],
+ summaryTabs: [
+ { key: "project_progress", label: "项目进度", value: "3 / 5", hint: "2 项可继续推进", active: true }
+ ],
+ activeTabLabel: "项目进度",
+ overviewBodyHtml: ""
+ });
+
+ assert.ok(html.includes('data-action="select-dashboard-tab"'));
+ assert.ok(!html.includes("当前项目推进详情"));
+ assert.ok(!html.includes("重点账号 / 对标右栏保留"));
+});
+```
+
+- [ ] **Step 2: Run the Node test and verify the new assertions fail**
+
+Run:
+
+```bash
+cd /Users/kris/code/StoryForge-gitea
+node --test web/storyforge-web-v4/tests/dashboard-home.test.mjs
+```
+
+Expected: FAIL because the overview renderer and admin entry are not complete yet.
+
+- [ ] **Step 3: Implement overview-tab state and project switcher reuse**
+
+Modify `web/storyforge-web-v4/assets/app.js`:
+
+```js
+function dashboardTabLabel(value) {
+ return ({
+ project_progress: "项目进度",
+ focus_accounts: "重点账号 / 对标",
+ production_jobs: "生产任务"
+ })[value] || "项目进度";
+}
+
+function buildDashboardOverviewTabs(project, stats) {
+ return [
+ { key: "project_progress", label: "项目进度", value: "3 / 5", hint: "2 项可继续推进", active: appState.dashboardOverviewTab === "project_progress" },
+ { key: "focus_accounts", label: "重点账号 / 对标", value: formatNumber(getTrackingAccounts().length), hint: "重点对象", active: appState.dashboardOverviewTab === "focus_accounts" },
+ { key: "production_jobs", label: "生产任务", value: formatNumber(stats.jobs.length), hint: "当前项目任务", active: appState.dashboardOverviewTab === "production_jobs" }
+ ];
+}
+
+function openDashboardProjectSwitcher() {
+ openActionModal({
+ title: "切换当前项目",
+ description: "首页上下文与动作区会随当前项目一起切换。",
+ submitLabel: "切换项目",
+ fields: [
+ { name: "projectId", label: "当前项目", type: "select", value: getSelectedProject()?.id || "", options: getProjectOptions() }
+ ],
+ onSubmit: async (payload) => {
+ appState.selectedProjectId = payload.projectId;
+ await loadAgentControlSurfaces(appState.selectedProjectId || "");
+ renderAll();
+ }
+ });
+}
+```
+
+Add click handling in `web/storyforge-web-v4/assets/app.js`:
+
+```js
+if (name === "select-dashboard-tab") {
+ appState.dashboardOverviewTab = action.dataset.dashboardTab || "project_progress";
+ renderAll();
+ return;
+}
+if (name === "open-dashboard-project-switcher") {
+ openDashboardProjectSwitcher();
+ return;
+}
+if (name === "goto-owned") {
+ setScreen("owned");
+ return;
+}
+if (name === "goto-tracking") {
+ setScreen("tracking");
+ return;
+}
+if (name === "goto-playbook") {
+ setScreen("playbook");
+ return;
+}
+```
+
+- [ ] **Step 4: Add the explicit admin workbench entry and screen**
+
+Modify `web/storyforge-web-v4/index.html` sidebar:
+
+```html
+
+```
+
+Modify `web/storyforge-web-v4/assets/app.js`:
+
+```js
+function syncRoleGatedNav() {
+ document.querySelectorAll("[data-role-gate]").forEach((element) => {
+ const gate = element.getAttribute("data-role-gate");
+ const visible = gate === "super_admin" ? isSuperAdmin() : true;
+ element.classList.toggle("hidden", !visible);
+ });
+}
+
+function renderAdminWorkbenchScreen() {
+ if (!isSuperAdmin()) {
+ return screenShell("管理员配置台", "仅超级管理员可见。", "", renderEmptyState("无权限", "请使用超级管理员账号访问。"));
+ }
+ return screenShell(
+ "管理员配置台",
+ "系统级依赖、存储、平台 Agent 与策略治理。",
+ "",
+ `
+ ${renderIntegrationOverviewPanel()}
+ ${renderStorageStatusPanel()}
+ ${renderPlatformAgentPanel()}
+ ${renderAdminOpsOverviewPanel()}
+ ${renderAdminFixRunsPanel()}
+ `
+ );
+}
+```
+
+Call `syncRoleGatedNav()` inside `renderAll()` after session/role state has updated.
+
+- [ ] **Step 5: Re-run targeted tests and syntax checks**
+
+Run:
+
+```bash
+cd /Users/kris/code/StoryForge-gitea
+node --test web/storyforge-web-v4/tests/dashboard-home.test.mjs
+node --check web/storyforge-web-v4/assets/storyforge-dashboard-home.js
+node --check web/storyforge-web-v4/assets/app.js
+```
+
+Expected: PASS, and homepage markup no longer contains legacy repeated panels.
+
+- [ ] **Step 6: Commit the overview/admin interaction work**
+
+Run:
+
+```bash
+cd /Users/kris/code/StoryForge-gitea
+git add web/storyforge-web-v4/index.html web/storyforge-web-v4/assets/app.js web/storyforge-web-v4/assets/storyforge-dashboard-home.js web/storyforge-web-v4/tests/dashboard-home.test.mjs
+git commit -m "feat: add dashboard tab flow and admin workbench entry"
+```
+
+### Task 4: Add Styles, Docs, and Regression Coverage
+
+**Files:**
+- Modify: `web/storyforge-web-v4/assets/styles.css`
+- Modify: `web/storyforge-web-v4/README.md`
+- Modify: `scripts/check_repo_baseline.sh`
+- Modify: `tests/test_production_baseline.py`
+
+- [ ] **Step 1: Add a failing baseline regression test for the homepage redesign wiring**
+
+Append to `tests/test_production_baseline.py`:
+
+```python
+ def test_baseline_script_covers_homepage_dashboard_node_test(self) -> None:
+ script = (ROOT / "scripts" / "check_repo_baseline.sh").read_text(encoding="utf-8")
+ self.assertIn("dashboard-home.test.mjs", script)
+```
+
+- [ ] **Step 2: Run the Python regression test and verify the current branch fails**
+
+Run:
+
+```bash
+cd /Users/kris/code/StoryForge-gitea
+python3 -m unittest tests.test_production_baseline.ProductionBaselineTests.test_baseline_script_covers_homepage_dashboard_node_test -v
+```
+
+Expected: FAIL before `scripts/check_repo_baseline.sh` is updated to run the homepage Node test.
+
+- [ ] **Step 3: Add the new CSS and update docs/baseline script**
+
+Modify `web/storyforge-web-v4/assets/styles.css` with homepage-specific classes:
+
+```css
+.dashboard-context-row { display:flex; justify-content:space-between; gap:16px; flex-wrap:wrap; }
+.dashboard-context-chip { display:flex; align-items:center; gap:8px; border:1px solid var(--line); border-radius:14px; padding:10px 12px; background:var(--panel-soft); }
+.dashboard-priority-panel { display:grid; gap:12px; }
+.dashboard-action-primary { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:16px; align-items:center; }
+.dashboard-action-secondary-list { display:grid; gap:10px; }
+.dashboard-overview-tabs { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
+.dashboard-overview-tab.is-active { border-color: var(--accent); background: var(--accent-soft); }
+```
+
+Modify `web/storyforge-web-v4/README.md`:
+
+```md
+- 首页已切到“人类决策优先”结构:
+ - 先显示当前项目与今日动作
+ - 再显示项目概览 tab
+ - 管理员配置台通过独立导航进入
+```
+
+Modify `scripts/check_repo_baseline.sh`:
+
+```sh
+echo "[5/5] validate homepage dashboard tests"
+node --test web/storyforge-web-v4/tests/dashboard-home.test.mjs
+```
+
+- [ ] **Step 4: Run the full redesign verification**
+
+Run:
+
+```bash
+cd /Users/kris/code/StoryForge-gitea
+python3 -m unittest tests.test_platform_contracts tests.test_production_baseline -v
+node --test web/storyforge-web-v4/tests/dashboard-home.test.mjs
+node --check web/storyforge-web-v4/assets/storyforge-dashboard-home.js
+node --check web/storyforge-web-v4/assets/app.js
+bash scripts/check_repo_baseline.sh
+git diff --check
+```
+
+Expected:
+
+- Python tests PASS
+- Node homepage test PASS
+- `baseline checks passed`
+- `git diff --check` returns no output
+
+- [ ] **Step 5: Commit the styling and regression coverage**
+
+Run:
+
+```bash
+cd /Users/kris/code/StoryForge-gitea
+git add web/storyforge-web-v4/assets/styles.css web/storyforge-web-v4/README.md scripts/check_repo_baseline.sh tests/test_production_baseline.py
+git commit -m "test: cover homepage dashboard redesign"
+```
diff --git a/scripts/deploy_fnos_storyforge_cliproxy.sh b/scripts/deploy_fnos_storyforge_cliproxy.sh
new file mode 100755
index 0000000..2995c9e
--- /dev/null
+++ b/scripts/deploy_fnos_storyforge_cliproxy.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
+
+export CODEX_HOME="${CODEX_HOME:-$HOME/.codex}"
+export FNOS_SKILL="${FNOS_SKILL:-$CODEX_HOME/skills/fnos-hyzq-deploy}"
+export FNOS_SSH="${FNOS_SSH:-$FNOS_SKILL/scripts/fnos_ssh.sh}"
+export FNOS_SCP="${FNOS_SCP:-$FNOS_SKILL/scripts/fnos_scp.sh}"
+
+FNOS_HOST="${FNOS_HOST:-192.168.31.188}"
+REMOTE_ROOT="${STORYFORGE_FNOS_REMOTE_ROOT:-/vol1/docker/hyzq-stack/current/storyforge}"
+REMOTE_COMPOSE_DIR="${STORYFORGE_FNOS_COMPOSE_DIR:-/vol1/docker/hyzq-stack/current/deploy/fnos}"
+REMOTE_STATE_ROOT="${STORYFORGE_CLIPROXY_STATE_ROOT:-/vol1/docker/hyzq-stack/shared/storyforge-cliproxyapi}"
+
+resolve_fnos_password() {
+ if [ -n "${FNOS_PASSWORD:-}" ]; then
+ printf '%s' "$FNOS_PASSWORD"
+ return 0
+ fi
+ security find-internet-password -s "$FNOS_HOST" -a "${FNOS_USER:-krisolo}" -w
+}
+
+need_cmd() {
+ command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1" >&2; exit 1; }
+}
+
+need_cmd python3
+need_cmd security
+need_cmd sshpass
+
+run_remote() {
+ local remote_cmd="$1"
+ sshpass -p "$FNOS_PASSWORD_VALUE" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "${FNOS_USER:-krisolo}@${FNOS_HOST}" "$remote_cmd"
+}
+
+FNOS_PASSWORD_VALUE="$(resolve_fnos_password)"
+TMPDIR_DEPLOY="$(mktemp -d)"
+trap 'rm -rf "$TMPDIR_DEPLOY"' EXIT
+
+mkdir -p "$TMPDIR_DEPLOY/cliproxyapi/auths" "$TMPDIR_DEPLOY/cliproxyapi/logs"
+cp "$ROOT/data/cliproxyapi/config.yaml" "$TMPDIR_DEPLOY/cliproxyapi/config.yaml"
+rsync -a "$ROOT/data/cliproxyapi/auths/" "$TMPDIR_DEPLOY/cliproxyapi/auths/" 2>/dev/null || true
+
+run_remote "mkdir -p '$REMOTE_COMPOSE_DIR' '$REMOTE_ROOT' '$REMOTE_STATE_ROOT/auths' '$REMOTE_STATE_ROOT/logs'"
+"$FNOS_SCP" "$REMOTE_COMPOSE_DIR" "$ROOT/deploy/storyforge-fnos-cliproxy.compose.yaml"
+"$FNOS_SCP" "$REMOTE_STATE_ROOT" "$TMPDIR_DEPLOY/cliproxyapi/config.yaml"
+"$FNOS_SCP" "$REMOTE_STATE_ROOT" "$TMPDIR_DEPLOY/cliproxyapi/auths"
+
+run_remote "printf '%s\n' '$FNOS_PASSWORD_VALUE' | sudo -S -p '' sh -lc 'cd \"$REMOTE_COMPOSE_DIR\" && docker compose -f \"$REMOTE_COMPOSE_DIR/storyforge-fnos-cliproxy.compose.yaml\" up -d --force-recreate storyforge-cliproxyapi'"
+
+curl -fsS --max-time 15 "http://$FNOS_HOST:8317/v1/models" >/dev/null 2>&1 || true
+echo "fnOS cliproxy deployed: http://$FNOS_HOST:8317/v1/models"
diff --git a/scripts/deploy_fnos_storyforge_huobao.sh b/scripts/deploy_fnos_storyforge_huobao.sh
new file mode 100755
index 0000000..25127a0
--- /dev/null
+++ b/scripts/deploy_fnos_storyforge_huobao.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
+
+export CODEX_HOME="${CODEX_HOME:-$HOME/.codex}"
+export FNOS_SKILL="${FNOS_SKILL:-$CODEX_HOME/skills/fnos-hyzq-deploy}"
+export FNOS_SSH="${FNOS_SSH:-$FNOS_SKILL/scripts/fnos_ssh.sh}"
+export FNOS_SCP="${FNOS_SCP:-$FNOS_SKILL/scripts/fnos_scp.sh}"
+
+FNOS_HOST="${FNOS_HOST:-192.168.31.188}"
+REMOTE_ROOT="${STORYFORGE_FNOS_REMOTE_ROOT:-/vol1/docker/hyzq-stack/current/storyforge}"
+REMOTE_COMPOSE_DIR="${STORYFORGE_FNOS_COMPOSE_DIR:-/vol1/docker/hyzq-stack/current/deploy/fnos}"
+REMOTE_STATE_ROOT="${STORYFORGE_HUOBAO_STATE_ROOT:-/vol1/docker/hyzq-stack/shared/storyforge-huobao}"
+LOCAL_SOURCE_ROOT="${STORYFORGE_HUOBAO_SOURCE_ROOT:-/Users/kris/code/huobao-drama-upstream}"
+
+resolve_fnos_password() {
+ if [ -n "${FNOS_PASSWORD:-}" ]; then
+ printf '%s' "$FNOS_PASSWORD"
+ return 0
+ fi
+ security find-internet-password -s "$FNOS_HOST" -a "${FNOS_USER:-krisolo}" -w
+}
+
+need_cmd() {
+ command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1" >&2; exit 1; }
+}
+
+need_cmd rsync
+need_cmd security
+need_cmd sshpass
+
+run_remote() {
+ local remote_cmd="$1"
+ sshpass -p "$FNOS_PASSWORD_VALUE" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "${FNOS_USER:-krisolo}@${FNOS_HOST}" "$remote_cmd"
+}
+
+FNOS_PASSWORD_VALUE="$(resolve_fnos_password)"
+TMPDIR_DEPLOY="$(mktemp -d)"
+trap 'rm -rf "$TMPDIR_DEPLOY"' EXIT
+
+FILTERED_SOURCE="$TMPDIR_DEPLOY/huobao-drama-source"
+mkdir -p "$FILTERED_SOURCE"
+rsync -a \
+ --exclude '.git' \
+ --exclude 'data' \
+ --exclude 'web/node_modules' \
+ --exclude 'web/dist' \
+ --exclude '.DS_Store' \
+ "$LOCAL_SOURCE_ROOT/" "$FILTERED_SOURCE/"
+mkdir -p "$TMPDIR_DEPLOY/state/data"
+if [ -f "$LOCAL_SOURCE_ROOT/data/drama.db" ]; then
+ cp "$LOCAL_SOURCE_ROOT/data/drama.db" "$TMPDIR_DEPLOY/state/data/drama.db"
+fi
+
+run_remote "mkdir -p '$REMOTE_COMPOSE_DIR' '$REMOTE_ROOT' '$REMOTE_STATE_ROOT/data'"
+"$FNOS_SCP" "$REMOTE_COMPOSE_DIR" "$ROOT/deploy/storyforge-fnos-huobao.compose.yaml"
+"$FNOS_SCP" "$REMOTE_ROOT" "$FILTERED_SOURCE"
+"$FNOS_SCP" "$REMOTE_STATE_ROOT" "$TMPDIR_DEPLOY/state/data"
+
+run_remote "printf '%s\n' '$FNOS_PASSWORD_VALUE' | sudo -S -p '' sh -lc 'cd \"$REMOTE_COMPOSE_DIR\" && STORYFORGE_HUOBAO_IMAGE=storyforge-huobao:fnos docker compose -f \"$REMOTE_COMPOSE_DIR/storyforge-fnos-huobao.compose.yaml\" up -d --build --force-recreate storyforge-huobao'"
+
+curl -fsS --max-time 30 "http://$FNOS_HOST:5678/health" >/dev/null
+echo "fnOS huobao deployed: http://$FNOS_HOST:5678/health"
diff --git a/scripts/deploy_fnos_storyforge_lan_stack.sh b/scripts/deploy_fnos_storyforge_lan_stack.sh
index efdf8b8..0bf0c79 100755
--- a/scripts/deploy_fnos_storyforge_lan_stack.sh
+++ b/scripts/deploy_fnos_storyforge_lan_stack.sh
@@ -9,23 +9,32 @@ BACKEND_URL="${STORYFORGE_FNOS_BACKEND_URL:-http://$FNOS_HOST:$COLLECTOR_PORT}"
SKIP_TUNNEL="${SKIP_TUNNEL:-0}"
SKIP_SMOKE="${SKIP_SMOKE:-0}"
-echo "[1/5] ensure fnOS cutvideo tunnel"
+echo "[1/8] ensure fnOS cutvideo tunnel"
if [ "$SKIP_TUNNEL" = "1" ]; then
echo "skip tunnel deployment because SKIP_TUNNEL=1"
else
STORYFORGE_FNOS_BACKEND_URL="$BACKEND_URL" bash "$ROOT/scripts/deploy_fnos_cutvideo_tunnel.sh"
fi
-echo "[2/5] deploy fnOS live recorder"
+echo "[2/8] deploy fnOS live recorder"
bash "$ROOT/scripts/deploy_fnos_storyforge_live_recorder.sh"
-echo "[3/5] deploy fnOS collector"
+echo "[3/8] deploy fnOS local model gateway"
+bash "$ROOT/scripts/deploy_fnos_storyforge_cliproxy.sh"
+
+echo "[4/8] deploy fnOS n8n"
+bash "$ROOT/scripts/deploy_fnos_storyforge_n8n.sh"
+
+echo "[5/8] deploy fnOS huobao"
+bash "$ROOT/scripts/deploy_fnos_storyforge_huobao.sh"
+
+echo "[6/8] deploy fnOS collector"
STORYFORGE_FNOS_COLLECTOR_URL="$BACKEND_URL" bash "$ROOT/scripts/deploy_fnos_storyforge_collector.sh"
-echo "[4/5] deploy fnOS web"
+echo "[7/8] deploy fnOS web"
STORYFORGE_FNOS_BACKEND_URL="$BACKEND_URL" bash "$ROOT/scripts/deploy_fnos_storyforge_web.sh"
-echo "[5/5] smoke fnOS lan stack"
+echo "[8/8] smoke fnOS lan stack"
if [ "$SKIP_SMOKE" = "1" ]; then
echo "skip smoke because SKIP_SMOKE=1"
else
diff --git a/scripts/deploy_fnos_storyforge_n8n.sh b/scripts/deploy_fnos_storyforge_n8n.sh
new file mode 100755
index 0000000..7f4a888
--- /dev/null
+++ b/scripts/deploy_fnos_storyforge_n8n.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
+
+export CODEX_HOME="${CODEX_HOME:-$HOME/.codex}"
+export FNOS_SKILL="${FNOS_SKILL:-$CODEX_HOME/skills/fnos-hyzq-deploy}"
+export FNOS_SSH="${FNOS_SSH:-$FNOS_SKILL/scripts/fnos_ssh.sh}"
+export FNOS_SCP="${FNOS_SCP:-$FNOS_SKILL/scripts/fnos_scp.sh}"
+
+FNOS_HOST="${FNOS_HOST:-192.168.31.188}"
+REMOTE_ROOT="${STORYFORGE_FNOS_REMOTE_ROOT:-/vol1/docker/hyzq-stack/current/storyforge}"
+REMOTE_COMPOSE_DIR="${STORYFORGE_FNOS_COMPOSE_DIR:-/vol1/docker/hyzq-stack/current/deploy/fnos}"
+REMOTE_STATE_ROOT="${STORYFORGE_N8N_STATE_ROOT:-/vol1/docker/hyzq-stack/shared/storyforge-n8n}"
+
+resolve_fnos_password() {
+ if [ -n "${FNOS_PASSWORD:-}" ]; then
+ printf '%s' "$FNOS_PASSWORD"
+ return 0
+ fi
+ security find-internet-password -s "$FNOS_HOST" -a "${FNOS_USER:-krisolo}" -w
+}
+
+need_cmd() {
+ command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1" >&2; exit 1; }
+}
+
+need_cmd rsync
+need_cmd security
+need_cmd sshpass
+
+run_remote() {
+ local remote_cmd="$1"
+ sshpass -p "$FNOS_PASSWORD_VALUE" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "${FNOS_USER:-krisolo}@${FNOS_HOST}" "$remote_cmd"
+}
+
+FNOS_PASSWORD_VALUE="$(resolve_fnos_password)"
+TMPDIR_DEPLOY="$(mktemp -d)"
+trap 'rm -rf "$TMPDIR_DEPLOY"' EXIT
+
+mkdir -p "$TMPDIR_DEPLOY/data"
+rsync -a "$ROOT/data/n8n/" "$TMPDIR_DEPLOY/data/"
+
+run_remote "mkdir -p '$REMOTE_COMPOSE_DIR' '$REMOTE_ROOT' '$REMOTE_ROOT/n8n' '$REMOTE_STATE_ROOT/storage'"
+"$FNOS_SCP" "$REMOTE_COMPOSE_DIR" "$ROOT/deploy/storyforge-fnos-n8n.compose.yaml"
+"$FNOS_SCP" "$REMOTE_ROOT" "$ROOT/n8n"
+"$FNOS_SCP" "$REMOTE_STATE_ROOT" "$TMPDIR_DEPLOY/data"
+
+run_remote "printf '%s\n' '$FNOS_PASSWORD_VALUE' | sudo -S -p '' sh -lc 'cd \"$REMOTE_COMPOSE_DIR\" && docker compose -f \"$REMOTE_COMPOSE_DIR/storyforge-fnos-n8n.compose.yaml\" up -d --force-recreate storyforge-n8n'"
+
+curl -fsS --max-time 20 "http://$FNOS_HOST:5670/healthz" >/dev/null
+echo "fnOS n8n deployed: http://$FNOS_HOST:5670/healthz"
diff --git a/tests/test_platform_contracts.py b/tests/test_platform_contracts.py
index ae6dc31..2267d5f 100644
--- a/tests/test_platform_contracts.py
+++ b/tests/test_platform_contracts.py
@@ -2,9 +2,11 @@ from __future__ import annotations
import json
import os
+import shutil
import sys
import tempfile
import unittest
+import weakref
from pathlib import Path
from types import SimpleNamespace
@@ -487,9 +489,58 @@ def _seed_domestic(db: Database, owner: dict[str, object], project_row: dict[str
return account_id
+def _insert_domestic_creator_account(
+ db: Database,
+ owner: dict[str, object],
+ project_row: dict[str, object],
+ platform: str,
+ *,
+ suffix: str,
+ title: str,
+ handle: str,
+ bio: str,
+ tags: list[str],
+ keywords: list[str],
+) -> str:
+ now = utc_now()
+ account_id = f"{platform}_acct_contract_{suffix}"
+ db.execute(
+ """
+ INSERT INTO content_sources (
+ id, user_id, project_id, source_kind, platform, handle, source_url, title, local_path,
+ metadata_json, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ account_id,
+ owner["id"],
+ project_row["id"],
+ "creator_account",
+ platform,
+ handle,
+ f"https://example.com/{platform}/profile-{suffix}",
+ title,
+ "",
+ _json(
+ {
+ "bio": bio,
+ "description": bio,
+ "avatar_url": "https://example.com/avatar.png",
+ "tags": tags,
+ "keywords": keywords,
+ "max_items": 5,
+ }
+ ),
+ now,
+ now,
+ ),
+ )
+ return account_id
+
+
def _build_app(platforms: list[str]) -> tuple[FastAPI, SimpleNamespace, dict[str, object]]:
- tmpdir = tempfile.TemporaryDirectory()
- db = Database(str(Path(tmpdir.name) / "storyforge.db"))
+ tmpdir = Path(tempfile.mkdtemp(prefix="storyforge-platform-contracts-"))
+ db = Database(str(tmpdir / "storyforge.db"))
db.init_schema()
owner_row, project_row, model_row = _seed_base_account(db)
legacy = _make_legacy(db, owner_row)
@@ -497,6 +548,7 @@ def _build_app(platforms: list[str]) -> tuple[FastAPI, SimpleNamespace, dict[str
register_douyin_routes(app, legacy)
for platform in platforms:
register_domestic_platform_routes(app, legacy, platform=platform, label=platform)
+ weakref.finalize(app, shutil.rmtree, tmpdir, True)
app.state._tmpdir = tmpdir
app.state._legacy = legacy
app.state._project_row = project_row
@@ -593,6 +645,323 @@ class PlatformContractTests(unittest.TestCase):
self.assertIn("account", digest_item)
self.assertIn("video", digest_item)
+ def test_kuaishou_creator_center_sync_persists_snapshots_and_analysis_context(self) -> None:
+ app, legacy, seed = _build_app(["kuaishou"])
+ with TestClient(app) as client:
+ sync = client.post(
+ "/v2/kuaishou/accounts/sync",
+ headers={"Authorization": "Bearer dummy"},
+ json={
+ "project_id": seed["project"]["id"],
+ "profile_url": "https://www.kuaishou.com/profile/contract-creator",
+ "title": "快手合同账号",
+ "handle": "contract_creator",
+ "manual_profile_payload": {
+ "nickname": "快手合同账号",
+ "bio": "擅长创业内容和成交转化",
+ "avatar_url": "https://example.com/kuaishou/avatar.png",
+ "follower_count": 32100,
+ },
+ "manual_creator_pages": [
+ {
+ "url": "https://creator.kuaishou.com/creator/home",
+ "title": "快手创作者中心",
+ "payload": {
+ "creator": {
+ "nickname": "快手合同账号",
+ "fans_count": 32100,
+ "play_count": 987654,
+ "content_tags": ["创业", "转化"],
+ },
+ "works": {
+ "published_count": 48,
+ "avg_finish_rate": 0.43,
+ "items": [
+ {
+ "video_id": "ks_work_001",
+ "title": "创业成交拆解 1",
+ "description": "拆 1 个高转化案例",
+ "share_url": "https://www.kuaishou.com/short-video/ks_work_001",
+ "cover_url": "https://example.com/kuaishou/work-1.png",
+ "duration_sec": 38,
+ "published_at": "2026-03-20T10:00:00+00:00",
+ "play_count": 82000,
+ "like_count": 4300,
+ "comment_count": 280,
+ "share_count": 140,
+ "tags": ["创业", "成交"]
+ },
+ {
+ "video_id": "ks_work_002",
+ "title": "口播脚本结构拆解",
+ "description": "复盘 3 段爆款口播结构",
+ "share_url": "https://www.kuaishou.com/short-video/ks_work_002",
+ "cover_url": "https://example.com/kuaishou/work-2.png",
+ "duration_sec": 46,
+ "published_at": "2026-03-18T10:00:00+00:00",
+ "play_count": 65000,
+ "like_count": 3100,
+ "comment_count": 190,
+ "share_count": 96,
+ "tags": ["口播", "结构"]
+ },
+ ],
+ },
+ },
+ }
+ ],
+ },
+ )
+ self.assertEqual(sync.status_code, 200, sync.text)
+ workspace_payload = sync.json()
+ self.assertEqual(workspace_payload["account"]["platform"], "kuaishou")
+ self.assertIsNotNone(workspace_payload["latest_public_snapshot"])
+ self.assertIsNotNone(workspace_payload["latest_creator_snapshot"])
+ self.assertEqual(workspace_payload["latest_creator_snapshot"]["snapshot_type"], "creator_center")
+ self.assertEqual(workspace_payload["account"]["nickname"], "快手合同账号")
+ self.assertGreaterEqual(workspace_payload["account"]["video_summary"]["count"], 2)
+ account_id = workspace_payload["account"]["id"]
+
+ videos = client.get(
+ f"/v2/kuaishou/accounts/{account_id}/videos",
+ headers={"Authorization": "Bearer dummy"},
+ )
+ self.assertEqual(videos.status_code, 200, videos.text)
+ videos_payload = videos.json()
+ self.assertGreaterEqual(videos_payload["count"], 2)
+ self.assertTrue(videos_payload["items"])
+ self.assertEqual(videos_payload["items"][0]["title"], "创业成交拆解 1")
+ self.assertEqual(videos_payload["items"][0]["stats"]["play"], 82000)
+ self.assertTrue(videos_payload["top_scored_video_ids"])
+ self.assertEqual(videos_payload["top_scored_video_ids"][0], videos_payload["items"][0]["id"])
+
+ snapshots = client.get(
+ f"/v2/kuaishou/accounts/{account_id}/snapshots",
+ headers={"Authorization": "Bearer dummy"},
+ )
+ self.assertEqual(snapshots.status_code, 200, snapshots.text)
+ snapshots_payload = snapshots.json()
+ self.assertGreaterEqual(len(snapshots_payload), 2)
+ creator_snapshot = next(item for item in snapshots_payload if item["snapshot_type"] == "creator_center")
+
+ creator_fields = client.get(
+ f"/v2/kuaishou/accounts/{account_id}/creator-fields",
+ headers={"Authorization": "Bearer dummy"},
+ )
+ self.assertEqual(creator_fields.status_code, 200, creator_fields.text)
+ creator_fields_payload = creator_fields.json()
+ self.assertEqual(creator_fields_payload["id"], creator_snapshot["id"])
+ self.assertEqual(creator_fields_payload["snapshot_type"], "creator_center")
+ self.assertTrue(creator_fields_payload["fields"])
+
+ analyze = client.post(
+ f"/v2/kuaishou/accounts/{account_id}/analysis",
+ headers={"Authorization": "Bearer dummy"},
+ json={
+ "model_profile_ids": [],
+ "linked_account_ids": [],
+ "include_linked_accounts": True,
+ "include_recent_similar_candidates": True,
+ "max_videos": 6,
+ "extra_focus": "更关注创作者中心里的成交与转化指标",
+ "temperature": 0.35,
+ "auto_analyze_top_videos": False,
+ "top_video_analysis_count": 4,
+ },
+ )
+ self.assertEqual(analyze.status_code, 200, analyze.text)
+ analyze_payload = analyze.json()
+ self.assertIn("creator_center", analyze_payload["context"])
+ self.assertEqual(
+ analyze_payload["context"]["creator_center"]["latest_creator_snapshot"]["snapshot_type"],
+ "creator_center",
+ )
+ self.assertEqual(analyze_payload["context"]["requested_model_profile_ids"], [])
+ self.assertEqual(analyze_payload["context"]["selected_model_profile_ids"], [seed["model"]["id"]])
+ self.assertTrue(analyze_payload["context"]["request_options"]["include_linked_accounts"])
+ self.assertEqual(analyze_payload["context"]["linked_accounts"], [])
+ self.assertEqual(analyze_payload["top_video_analyses"], [])
+
+ def test_domestic_analysis_uses_requested_context_and_auto_top_video_followup(self) -> None:
+ app, legacy, seed = _build_app(["xiaohongshu"])
+ source_account_id = _seed_domestic(legacy.db, seed["owner"], seed["project"], "xiaohongshu")
+ linked_account_id = _insert_domestic_creator_account(
+ legacy.db,
+ seed["owner"],
+ seed["project"],
+ "xiaohongshu",
+ suffix="linked",
+ title="Linked Benchmark",
+ handle="xhs_linked",
+ bio="主打创业转化与爆款拆解",
+ tags=["创业", "转化"],
+ keywords=["创业", "转化"],
+ )
+ candidate_account_id = _insert_domestic_creator_account(
+ legacy.db,
+ seed["owner"],
+ seed["project"],
+ "xiaohongshu",
+ suffix="candidate",
+ title="Candidate Creator",
+ handle="xhs_candidate",
+ bio="专注创业口播、成交文案与转化漏斗",
+ tags=["创业", "口播", "转化"],
+ keywords=["口播", "成交"],
+ )
+ now = utc_now()
+ legacy.db.execute(
+ """
+ INSERT INTO xiaohongshu_account_relations (
+ id, user_id, source_account_id, target_account_id, target_profile_url,
+ relation_type, note, search_id, created_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ "xhs_relation_contract_linked",
+ seed["owner"]["id"],
+ source_account_id,
+ linked_account_id,
+ "",
+ "benchmark",
+ "linked note",
+ "",
+ now,
+ ),
+ )
+ legacy.db.execute(
+ """
+ INSERT INTO xiaohongshu_similarity_searches (
+ id, user_id, source_account_id, prompt_text, context_json, created_at
+ ) VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ (
+ "xhs_search_contract_recent",
+ seed["owner"]["id"],
+ source_account_id,
+ "recent search",
+ _json({"source_account": "xiaohongshu"}),
+ now,
+ ),
+ )
+ legacy.db.execute(
+ """
+ INSERT INTO xiaohongshu_similarity_candidates (
+ id, search_id, candidate_account_id, candidate_profile_url, heuristic_score,
+ agent_score, rationale_text, dimensions_json, raw_output_json, rank_index, created_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ "xhs_candidate_contract_recent",
+ "xhs_search_contract_recent",
+ candidate_account_id,
+ "https://example.com/xiaohongshu/profile-candidate",
+ 72,
+ 72,
+ "近期相似候选",
+ _json({"tag_overlap": 2}),
+ _json({"candidate_account_id": candidate_account_id, "candidate_profile_url": "https://example.com/xiaohongshu/profile-candidate"}),
+ 0,
+ now,
+ ),
+ )
+
+ with TestClient(app) as client:
+ analyze = client.post(
+ f"/v2/xiaohongshu/accounts/{source_account_id}/analysis",
+ headers={"Authorization": "Bearer dummy"},
+ json={
+ "model_profile_ids": [seed["model"]["id"]],
+ "linked_account_ids": [linked_account_id],
+ "include_linked_accounts": True,
+ "include_recent_similar_candidates": True,
+ "max_videos": 5,
+ "extra_focus": "关注转化路径和选题结构",
+ "temperature": 0.32,
+ "auto_analyze_top_videos": True,
+ "top_video_analysis_count": 2,
+ },
+ )
+ self.assertEqual(analyze.status_code, 200, analyze.text)
+ payload = analyze.json()
+ self.assertEqual(payload["context"]["requested_model_profile_ids"], [seed["model"]["id"]])
+ self.assertEqual(payload["context"]["selected_model_profile_ids"], [seed["model"]["id"]])
+ self.assertEqual(payload["context"]["request_options"]["top_video_analysis_count"], 2)
+ self.assertEqual(len(payload["context"]["linked_accounts"]), 1)
+ self.assertEqual(payload["context"]["linked_accounts"][0]["target_account_id"], linked_account_id)
+ self.assertEqual(len(payload["context"]["recent_similar_candidates"]), 1)
+ self.assertEqual(payload["context"]["recent_similar_candidates"][0]["candidate_account_id"], candidate_account_id)
+ self.assertEqual(payload["top_video_analyses"][0]["video_id"], "xiaohongshu_video_contract_2")
+ self.assertEqual(len(payload["top_video_analyses"]), 2)
+
+ def test_domestic_similarity_search_merges_manual_urls_and_linked_candidates(self) -> None:
+ app, legacy, seed = _build_app(["xiaohongshu"])
+ source_account_id = _seed_domestic(legacy.db, seed["owner"], seed["project"], "xiaohongshu")
+ linked_account_id = _insert_domestic_creator_account(
+ legacy.db,
+ seed["owner"],
+ seed["project"],
+ "xiaohongshu",
+ suffix="similarlinked",
+ title="Linked Similar",
+ handle="xhs_similar_linked",
+ bio="创业成交、私域转化、直播承接",
+ tags=["创业", "成交", "转化"],
+ keywords=["直播", "承接"],
+ )
+ now = utc_now()
+ legacy.db.execute(
+ """
+ INSERT INTO xiaohongshu_account_relations (
+ id, user_id, source_account_id, target_account_id, target_profile_url,
+ relation_type, note, search_id, created_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ "xhs_relation_contract_similarity",
+ seed["owner"]["id"],
+ source_account_id,
+ linked_account_id,
+ "https://example.com/xiaohongshu/profile-similarlinked",
+ "benchmark",
+ "linked similar note",
+ "",
+ now,
+ ),
+ )
+
+ with TestClient(app) as client:
+ created = client.post(
+ "/v2/xiaohongshu/similar-searches",
+ headers={"Authorization": "Bearer dummy"},
+ json={
+ "source_account_id": source_account_id,
+ "candidate_urls": [
+ "https://example.com/xiaohongshu/external-similar"
+ ],
+ "seed_linked_accounts": True,
+ "search_public_pages": False,
+ "model_profile_id": seed["model"]["id"],
+ "max_candidates": 6,
+ "extra_requirements": "优先找创业成交和口播拆解账号",
+ },
+ )
+ self.assertEqual(created.status_code, 200, created.text)
+ detail = client.get(
+ f"/v2/xiaohongshu/similar-searches/{created.json()['search_id']}",
+ headers={"Authorization": "Bearer dummy"},
+ )
+ self.assertEqual(detail.status_code, 200, detail.text)
+ payload = detail.json()
+ self.assertGreaterEqual(len(payload["candidates"]), 2)
+ self.assertEqual(payload["candidates"][0]["candidate_account_id"], linked_account_id)
+ manual_candidate = next(
+ item for item in payload["candidates"]
+ if item.get("candidate_profile_url") == "https://example.com/xiaohongshu/external-similar"
+ )
+ self.assertEqual(manual_candidate["candidate_account_id"], "")
+ self.assertIn("external", manual_candidate["candidate_nickname"].lower())
+
def test_douyin_live_first_mutation_routes_are_available(self) -> None:
app, legacy, seed = _build_app(["xiaohongshu"])
douyin_account_id = _seed_douyin(legacy.db, seed["owner"], seed["model"])
diff --git a/tests/test_production_baseline.py b/tests/test_production_baseline.py
index 541682b..a6daf8d 100644
--- a/tests/test_production_baseline.py
+++ b/tests/test_production_baseline.py
@@ -8,6 +8,7 @@ import subprocess
import sys
import tempfile
import unittest
+from unittest import mock
from pathlib import Path
from typing import Any
@@ -217,6 +218,31 @@ class ProductionBaselineTests(unittest.TestCase):
"password": login_password,
}
+ def _insert_completed_source_job(self, ctx: dict[str, Any], *, job_id: str, title: str = "Seedance Source") -> str:
+ now = self.db_module.utc_now()
+ self.core.db.execute(
+ """
+ INSERT INTO jobs (
+ id, user_id, project_id, parent_job_id, assistant_id, knowledge_base_id, content_source_id,
+ source_type, line_type, workflow_key, orchestrator, provider_name, provider_task_id,
+ source_url, title, language, status, transcript_text, style_summary, upload_status,
+ error, artifacts_json, result_json, analysis_model_profile_id, created_at, updated_at
+ ) VALUES (?, ?, ?, '', ?, ?, NULL, 'text', 'analysis', 'analysis_pipeline', 'n8n', 'collector', '', '', ?, 'auto', 'completed', '', '', 'completed', '', '{}', '{\"summary\":\"done\"}', ?, ?, ?)
+ """,
+ (
+ job_id,
+ ctx["account_id"],
+ ctx["project_id"],
+ ctx["assistant_id"],
+ ctx["kb_id"],
+ title,
+ ctx["model_id"],
+ now,
+ now,
+ ),
+ )
+ return job_id
+
def test_auto_session_issues_token_without_manual_credentials(self) -> None:
ctx = self._seed_context("auto", exhausted=False)
self.core.WEB_AUTOLOGIN_ENABLED = "1"
@@ -284,6 +310,48 @@ class ProductionBaselineTests(unittest.TestCase):
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_create_admin_huobao_config_forwards_is_active_flag(self) -> None:
+ ctx = self._seed_context("model_access_create", exhausted=False)
+ headers = {"Authorization": f"Bearer {ctx['token']}"}
+ captured: dict[str, object] = {}
+
+ def fake_huobao_api_request(method: str, path: str, *, payload=None, params=None, timeout: float = 12.0):
+ captured["method"] = method
+ captured["path"] = path
+ captured["payload"] = payload
+ return {
+ "id": "cfg_video_seedance",
+ "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": False,
+ }
+
+ with mock.patch.object(self.core, "huobao_api_request", side_effect=fake_huobao_api_request):
+ response = self.client.post(
+ "/v2/admin/model-access/huobao-configs",
+ headers=headers,
+ json={
+ "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": False,
+ "settings": "",
+ },
+ )
+ self.assertEqual(response.status_code, 200, response.text)
+ self.assertEqual(captured["method"], "POST")
+ self.assertEqual(captured["path"], "/api/v1/ai-configs")
+ self.assertEqual((captured["payload"] or {}).get("is_active"), False)
+
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']}"}
@@ -498,7 +566,6 @@ class ProductionBaselineTests(unittest.TestCase):
("POST", "/v2/pipelines/content-source-sync", {"project_id": ctx["project_id"]}, None),
("POST", "/v2/reviews", {"project_id": ctx["project_id"], "assistant_id": ctx["assistant_id"], "title": "Review"}, None),
("POST", "/v2/pipelines/real-cut", {"project_id": ctx["project_id"], "title": "Cut"}, None),
- ("POST", "/v2/pipelines/ai-video", {"project_id": ctx["project_id"], "title": "Video", "brief": "Brief"}, None),
("POST", f"/v2/assistants/{ctx['assistant_id']}/generate", {"brief": "Copy", "project_id": ctx["project_id"], "knowledge_base_ids": [ctx["kb_id"]]}, None),
("POST", "/v2/live-recorder/sources", {"project_id": ctx["project_id"], "source_url": "https://example.com/live", "title": "Live"}, None),
]
@@ -507,6 +574,30 @@ class ProductionBaselineTests(unittest.TestCase):
response = self.client.request(method, path, headers=headers, json=json_body, files=files)
self.assertEqual(response.status_code, 403, response.text)
+ with unittest.mock.patch.object(
+ self.core,
+ "huobao_api_request",
+ return_value={
+ "value": [
+ {
+ "id": "cfg_quota_video",
+ "service_type": "video",
+ "provider": "volcengine",
+ "name": "Quota Guard",
+ "base_url": "https://video.example.com",
+ "model": ["seedance-2.0-pro"],
+ "is_active": True,
+ }
+ ]
+ },
+ ):
+ ai_video_response = self.client.post(
+ "/v2/pipelines/ai-video",
+ headers=headers,
+ json={"project_id": ctx["project_id"], "title": "Video", "brief": "Brief"},
+ )
+ self.assertEqual(ai_video_response.status_code, 403, ai_video_response.text)
+
upload_response = self.client.post(
"/v2/explore/upload-video",
headers=headers,
@@ -521,6 +612,115 @@ class ProductionBaselineTests(unittest.TestCase):
)
self.assertEqual(upload_response.status_code, 403, upload_response.text)
+ def test_ai_video_rejects_when_huobao_video_config_not_ready(self) -> None:
+ ctx = self._seed_context("huobao_not_ready", exhausted=False)
+ headers = {"Authorization": f"Bearer {ctx['token']}"}
+ source_job_id = self._insert_completed_source_job(ctx, job_id="job_huobao_not_ready")
+
+ with unittest.mock.patch.object(
+ self.core,
+ "huobao_api_request",
+ side_effect=self.core.HTTPException(status_code=503, detail="HUOBAO_BASE_URL is not configured"),
+ ):
+ response = self.client.post(
+ "/v2/pipelines/ai-video",
+ headers=headers,
+ json={
+ "project_id": ctx["project_id"],
+ "assistant_id": ctx["assistant_id"],
+ "knowledge_base_id": ctx["kb_id"],
+ "source_job_id": source_job_id,
+ "title": "Huobao Not Ready",
+ "brief": "需要先检查火宝视频配置是否可用。",
+ "video_provider": "doubao",
+ },
+ )
+
+ self.assertEqual(response.status_code, 503, response.text)
+ self.assertEqual(response.json()["detail"], "AI 视频暂时不可用:Huobao 视频配置未就绪,请先在管理后台完成视频配置。")
+
+ def test_ai_video_rejects_when_no_active_huobao_video_config(self) -> None:
+ ctx = self._seed_context("huobao_inactive", exhausted=False)
+ headers = {"Authorization": f"Bearer {ctx['token']}"}
+ source_job_id = self._insert_completed_source_job(ctx, job_id="job_huobao_inactive")
+
+ with unittest.mock.patch.object(
+ self.core,
+ "huobao_api_request",
+ return_value={
+ "value": [
+ {
+ "id": "cfg_disabled_video",
+ "service_type": "video",
+ "provider": "volcengine",
+ "name": "Disabled Seedance",
+ "base_url": "https://video.example.com",
+ "model": ["seedance-2.0-pro"],
+ "is_active": False,
+ }
+ ]
+ },
+ ):
+ response = self.client.post(
+ "/v2/pipelines/ai-video",
+ headers=headers,
+ json={
+ "project_id": ctx["project_id"],
+ "assistant_id": ctx["assistant_id"],
+ "knowledge_base_id": ctx["kb_id"],
+ "source_job_id": source_job_id,
+ "title": "Huobao Inactive",
+ "brief": "需要启用至少一条视频配置。",
+ "video_provider": "doubao",
+ },
+ )
+
+ self.assertEqual(response.status_code, 409, response.text)
+ self.assertEqual(response.json()["detail"], "AI 视频暂时不可用:请先在 Huobao 启用至少一条视频配置。")
+
+ def test_ai_video_rejects_when_seedance_model_not_enabled(self) -> None:
+ ctx = self._seed_context("seedance_model_missing", exhausted=False)
+ headers = {"Authorization": f"Bearer {ctx['token']}"}
+ source_job_id = self._insert_completed_source_job(ctx, job_id="job_seedance_model_missing")
+
+ with unittest.mock.patch.object(
+ self.core,
+ "huobao_api_request",
+ return_value={
+ "value": [
+ {
+ "id": "cfg_active_video",
+ "service_type": "video",
+ "provider": "volcengine",
+ "name": "Seedance Old",
+ "base_url": "https://video.example.com",
+ "model": ["doubao-seedance-1-0-pro-250528"],
+ "is_active": True,
+ }
+ ]
+ },
+ ):
+ response = self.client.post(
+ "/v2/pipelines/ai-video",
+ headers=headers,
+ json={
+ "project_id": ctx["project_id"],
+ "assistant_id": ctx["assistant_id"],
+ "knowledge_base_id": ctx["kb_id"],
+ "source_job_id": source_job_id,
+ "title": "Seedance Missing",
+ "brief": "需要启用目标 Seedance 模型。",
+ "video_provider": "seedance2",
+ "video_model": "seedance-2.0-pro",
+ },
+ )
+
+ self.assertEqual(response.status_code, 409, response.text)
+ self.assertEqual(
+ response.json()["detail"],
+ "AI 视频暂时不可用:Huobao 启用中的视频配置未包含所选 Seedance 模型 seedance-2.0-pro。",
+ )
+
def test_successful_analysis_records_usage_and_retry_endpoints_work(self) -> None:
ctx = self._seed_context("happy", exhausted=False)
headers = {"Authorization": f"Bearer {ctx['token']}"}
@@ -545,43 +745,38 @@ class ProductionBaselineTests(unittest.TestCase):
self.assertIsNotNone(usage_row)
self.assertEqual(text_job["status"], "queued")
- now = self.db_module.utc_now()
- source_job_id = f"job_seedance_source_{ctx['project_id']}"
- self.core.db.execute(
- """
- INSERT INTO jobs (
- id, user_id, project_id, parent_job_id, assistant_id, knowledge_base_id, content_source_id,
- source_type, line_type, workflow_key, orchestrator, provider_name, provider_task_id,
- source_url, title, language, status, transcript_text, style_summary, upload_status,
- error, artifacts_json, result_json, analysis_model_profile_id, created_at, updated_at
- ) VALUES (?, ?, ?, '', ?, ?, NULL, 'text', 'analysis', 'analysis_pipeline', 'n8n', 'collector', '', '', ?, 'auto', 'completed', '', '', 'completed', '', '{}', '{\"summary\":\"done\"}', ?, ?, ?)
- """,
- (
- source_job_id,
- ctx["account_id"],
- ctx["project_id"],
- ctx["assistant_id"],
- ctx["kb_id"],
- "Seedance Source",
- ctx["model_id"],
- now,
- now,
- ),
- )
- ai_video_response = self.client.post(
- "/v2/pipelines/ai-video",
- headers=headers,
- json={
- "project_id": ctx["project_id"],
- "assistant_id": ctx["assistant_id"],
- "knowledge_base_id": ctx["kb_id"],
- "source_job_id": source_job_id,
- "title": "Seedance 2.0 视频",
- "brief": "做一条镜头推进感更强的 AI 视频。",
- "video_provider": "seedance2",
- "video_model": "",
+ source_job_id = self._insert_completed_source_job(ctx, job_id=f"job_seedance_source_{ctx['project_id']}")
+ with unittest.mock.patch.object(
+ self.core,
+ "huobao_api_request",
+ return_value={
+ "value": [
+ {
+ "id": "cfg_seedance_enabled",
+ "service_type": "video",
+ "provider": "volcengine",
+ "name": "Seedance 2.0",
+ "base_url": "https://video.example.com",
+ "model": ["seedance-2.0-pro"],
+ "is_active": True,
+ }
+ ]
},
- )
+ ):
+ ai_video_response = self.client.post(
+ "/v2/pipelines/ai-video",
+ headers=headers,
+ json={
+ "project_id": ctx["project_id"],
+ "assistant_id": ctx["assistant_id"],
+ "knowledge_base_id": ctx["kb_id"],
+ "source_job_id": source_job_id,
+ "title": "Seedance 2.0 视频",
+ "brief": "做一条镜头推进感更强的 AI 视频。",
+ "video_provider": "seedance2",
+ "video_model": "",
+ },
+ )
self.assertEqual(ai_video_response.status_code, 200, ai_video_response.text)
ai_video_payload = ai_video_response.json()
self.assertEqual(ai_video_payload["artifacts"]["video_provider"], "seedance2")
@@ -589,6 +784,77 @@ class ProductionBaselineTests(unittest.TestCase):
self.assertEqual(ai_video_payload["artifacts"]["video_dispatch_provider"], "doubao")
self.assertEqual(ai_video_payload["artifacts"]["video_dispatch_model"], "seedance-2.0-pro")
+ def test_ai_video_seedance_requires_ready_video_config(self) -> None:
+ ctx = self._seed_context("seedance_guard", exhausted=False)
+ headers = {"Authorization": f"Bearer {ctx['token']}"}
+ source_job_id = self._insert_completed_source_job(ctx, job_id=f"job_seedance_guard_{ctx['project_id']}", title="Seedance Guard Source")
+
+ with mock.patch.object(
+ self.core,
+ "huobao_api_request",
+ side_effect=self.core.HTTPException(status_code=503, detail="HUOBAO_BASE_URL is not configured"),
+ ):
+ response = self.client.post(
+ "/v2/pipelines/ai-video",
+ headers=headers,
+ json={
+ "project_id": ctx["project_id"],
+ "assistant_id": ctx["assistant_id"],
+ "knowledge_base_id": ctx["kb_id"],
+ "source_job_id": source_job_id,
+ "title": "Seedance 校验失败",
+ "brief": "做一条 Seedance 视频。",
+ "video_provider": "seedance2",
+ "video_model": "seedance-2.0-pro",
+ },
+ )
+ self.assertEqual(response.status_code, 503, response.text)
+ self.assertIn("Huobao 视频配置未就绪", response.text)
+
+ def test_ai_video_seedance_requires_matching_active_model(self) -> None:
+ ctx = self._seed_context("seedance_model_guard", exhausted=False)
+ headers = {"Authorization": f"Bearer {ctx['token']}"}
+ source_job_id = self._insert_completed_source_job(
+ ctx,
+ job_id=f"job_seedance_model_guard_{ctx['project_id']}",
+ title="Seedance Model Guard Source",
+ )
+
+ with mock.patch.object(
+ self.core,
+ "huobao_api_request",
+ return_value={
+ "value": [
+ {
+ "id": "cfg_video_default",
+ "service_type": "video",
+ "provider": "volcengine",
+ "name": "Seedance Legacy",
+ "base_url": "https://video.example.com",
+ "model": ["doubao-seedance-1-0-pro-250528"],
+ "is_active": True,
+ }
+ ]
+ },
+ ):
+ response = self.client.post(
+ "/v2/pipelines/ai-video",
+ headers=headers,
+ json={
+ "project_id": ctx["project_id"],
+ "assistant_id": ctx["assistant_id"],
+ "knowledge_base_id": ctx["kb_id"],
+ "source_job_id": source_job_id,
+ "title": "Seedance 模型缺失",
+ "brief": "做一条 Seedance 视频。",
+ "video_provider": "seedance2",
+ "video_model": "seedance-2.0-pro",
+ },
+ )
+ self.assertEqual(response.status_code, 409, response.text)
+ self.assertIn("seedance-2.0-pro", response.text)
+ self.assertIn("未包含", response.text)
+
now = self.db_module.utc_now()
failed_jobs = []
for index in range(2):
@@ -660,6 +926,9 @@ class ProductionBaselineTests(unittest.TestCase):
)
backup_path = Path(result.stdout.strip().splitlines()[-1])
self.assertTrue(backup_path.exists(), result.stdout)
- with sqlite3.connect(backup_path) as conn:
+ conn = sqlite3.connect(backup_path)
+ try:
account_count = conn.execute("SELECT COUNT(*) FROM accounts").fetchone()[0]
+ finally:
+ conn.close()
self.assertGreaterEqual(int(account_count), 1)
diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js
index 937a53f..848a2e7 100644
--- a/web/storyforge-web-v4/assets/app.js
+++ b/web/storyforge-web-v4/assets/app.js
@@ -79,6 +79,8 @@ const appState = {
selectedOnelinerRunId: "",
lastCompletedOnelinerRunId: "",
onelinerMessages: [],
+ onelinerHydrating: false,
+ onelinerHydrationMessage: "",
onelinerActionRegistry: [],
platformAgents: [],
onelinerGovernanceEffective: null,
@@ -108,6 +110,7 @@ const appState = {
mainAgentLanding: null,
lastGeneratedCopy: null,
lastSimilaritySearch: null,
+ similaritySearchResultsByAccount: {},
lastJobDetail: null,
topVideoAnalysisResults: {}
};
@@ -1221,6 +1224,12 @@ function setBusy(next, message = "") {
renderAuthUi();
}
+function setOneLinerHydrating(next, message = "") {
+ appState.onelinerHydrating = next;
+ appState.onelinerHydrationMessage = message;
+ renderOneLinerUi();
+}
+
function isWorkspaceBusy() {
return appState.busy || appState.workspaceHydrating;
}
@@ -1411,6 +1420,7 @@ function ensureActionUi() {
+
@@ -1497,7 +1507,8 @@ function openActionModal(config) {
const fields = document.querySelector('[data-role="action-fields"]');
const message = document.querySelector('[data-role="action-message"]');
const submit = document.querySelector('[data-action="submit-sheet"]');
- if (!modal || !title || !description || !fields || !message || !submit) return;
+ const testButton = document.querySelector('[data-action="test-sheet"]');
+ if (!modal || !title || !description || !fields || !message || !submit || !testButton) return;
title.textContent = config.title || "快速操作";
description.textContent = config.description || "";
fields.innerHTML = renderActionFields(config.fields || []);
@@ -1505,10 +1516,13 @@ function openActionModal(config) {
submit.textContent = config.submitLabel || "执行";
submit.disabled = false;
submit.hidden = Boolean(config.hideSubmit);
+ testButton.textContent = config.testLabel || "测试";
+ testButton.disabled = false;
+ testButton.hidden = typeof config.onTest !== "function";
document.body.classList.add("sheet-open", "action-sheet-open");
modal.classList.remove("hidden");
if (typeof config.onOpen === "function") {
- config.onOpen({ modal, title, description, fields, message, submit });
+ config.onOpen({ modal, title, description, fields, message, submit, testButton });
}
}
@@ -2026,7 +2040,9 @@ function renderOneLinerUi() {
messages.scrollTop = messages.scrollHeight;
}
if (status) {
- status.textContent = appState.busy ? appState.message || "处理中..." : "";
+ status.textContent = appState.onelinerHydrating
+ ? appState.onelinerHydrationMessage || "正在同步 OneLiner 上下文..."
+ : appState.busy ? appState.message || "处理中..." : "";
}
if (input && !input.value && !safeArray(appState.onelinerMessages).length) {
input.value = "";
@@ -2095,15 +2111,17 @@ function readActionForm() {
return values;
}
-async function submitActionModal() {
- if (!currentActionConfig?.onSubmit) return;
+async function submitActionModal(handlerName = "onSubmit", pendingMessage = "正在执行...") {
+ if (typeof currentActionConfig?.[handlerName] !== "function") return;
const message = document.querySelector('[data-role="action-message"]');
const submit = document.querySelector('[data-action="submit-sheet"]');
+ const testButton = document.querySelector('[data-action="test-sheet"]');
const values = readActionForm();
if (submit) submit.disabled = true;
- if (message) message.textContent = "正在执行...";
+ if (testButton) testButton.disabled = true;
+ if (message) message.textContent = pendingMessage;
try {
- const result = await currentActionConfig.onSubmit(values);
+ const result = await currentActionConfig[handlerName](values);
if (result?.keepOpen) {
if (message) message.textContent = result.message || "已完成";
} else {
@@ -2112,9 +2130,11 @@ async function submitActionModal() {
} catch (error) {
if (message) message.textContent = formatActionErrorMessage(error);
if (submit) submit.disabled = false;
+ if (testButton) testButton.disabled = false;
return;
}
if (submit) submit.disabled = false;
+ if (testButton) testButton.disabled = false;
}
async function storyforgeFetch(path, options = {}) {
@@ -2271,6 +2291,8 @@ async function logoutSession() {
appState.lastAction = null;
appState.lastGeneratedCopy = null;
appState.lastSimilaritySearch = null;
+ appState.similaritySearchResultsByAccount = {};
+ appState.topVideoAnalysisResults = {};
appState.lastJobDetail = null;
localStorage.removeItem(STORAGE_KEY + ":currentPlatform");
renderAll();
@@ -3145,6 +3167,7 @@ async function loadPlatformAccount(platform, accountId, requestToken = 0) {
if (token !== appState.selectedAccountRequestToken) {
return false;
}
+ appState.lastSimilaritySearch = null;
appState.selectedWorkspace = null;
appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 };
appState.snapshots = [];
@@ -3152,7 +3175,6 @@ async function loadPlatformAccount(platform, accountId, requestToken = 0) {
appState.selectedSnapshotDetail = null;
appState.creatorFields = null;
appState.analysisReports = [];
- appState.similarSearchDetail = null;
return true;
}
const workspacePath = getWorkbenchRoute(normalizedPlatform, "workspace", accountId);
@@ -3160,6 +3182,7 @@ async function loadPlatformAccount(platform, accountId, requestToken = 0) {
if (token !== appState.selectedAccountRequestToken) {
return false;
}
+ appState.lastSimilaritySearch = null;
appState.selectedWorkspace = null;
appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 };
appState.snapshots = [];
@@ -3167,10 +3190,12 @@ async function loadPlatformAccount(platform, accountId, requestToken = 0) {
appState.selectedSnapshotDetail = null;
appState.creatorFields = null;
appState.analysisReports = [];
- appState.similarSearchDetail = null;
return true;
}
const videosPath = getWorkbenchRoute(normalizedPlatform, "videos", accountId);
+ const snapshotsPath = getWorkbenchRoute(normalizedPlatform, "snapshots", accountId);
+ const analysisReportsPath = getWorkbenchRoute(normalizedPlatform, "analysisReports", accountId);
+ const creatorFieldsPath = getWorkbenchRoute(normalizedPlatform, "creatorFields", accountId);
try {
const [workspace, videos, snapshotsPayload, analysisReportsPayload] = await Promise.all([
storyforgeFetch(workspacePath),
@@ -3189,41 +3214,43 @@ async function loadPlatformAccount(platform, accountId, requestToken = 0) {
latest_video_ids: [],
high_score_threshold: 60
}),
- normalizedPlatform === "douyin"
- ? storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(accountId)}/snapshots`).catch(() => [])
+ snapshotsPath
+ ? storyforgeFetch(snapshotsPath).catch(() => [])
: Promise.resolve([]),
- normalizedPlatform === "douyin"
- ? storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(accountId)}/analysis-reports`).catch(() => [])
+ analysisReportsPath
+ ? storyforgeFetch(analysisReportsPath).catch(() => [])
: Promise.resolve([])
]);
if (token !== appState.selectedAccountRequestToken) {
return false;
}
+ appState.lastSimilaritySearch = appState.similaritySearchResultsByAccount?.[accountId] || null;
appState.selectedWorkspace = workspace;
appState.selectedVideos = videos;
- if (normalizedPlatform === "douyin") {
- appState.snapshots = safeArray(snapshotsPayload?.items || snapshotsPayload);
- appState.creatorFields = hasCreatorCenterSnapshot(appState.snapshots)
- ? await storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(accountId)}/creator-fields`).catch(() => null)
- : null;
- appState.analysisReports = safeArray(analysisReportsPayload?.items || analysisReportsPayload);
- const nextSnapshotId = appState.snapshots.find((item) => item.id === appState.selectedSnapshotId)?.id || appState.snapshots[0]?.id || "";
- appState.selectedSnapshotId = nextSnapshotId;
- appState.selectedSnapshotDetail = nextSnapshotId
- ? await storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(accountId)}/snapshots/${encodeURIComponent(nextSnapshotId)}`).catch(() => null)
- : null;
- } else {
- appState.snapshots = [];
- appState.selectedSnapshotId = "";
- appState.selectedSnapshotDetail = null;
- appState.creatorFields = null;
- appState.analysisReports = [];
- }
+ appState.snapshots = safeArray(snapshotsPayload?.items || snapshotsPayload);
+ appState.creatorFields = hasCreatorCenterSnapshot(appState.snapshots) && creatorFieldsPath
+ ? await storyforgeFetch(creatorFieldsPath).catch(() => null)
+ : null;
+ appState.analysisReports = safeArray(analysisReportsPayload?.items || analysisReportsPayload);
+ const nextSnapshotId = appState.snapshots.find((item) => item.id === appState.selectedSnapshotId)?.id || appState.snapshots[0]?.id || "";
+ appState.selectedSnapshotId = nextSnapshotId;
+ const snapshotDetailPath = getWorkbenchRoute(normalizedPlatform, "snapshotDetail", accountId, nextSnapshotId);
+ appState.selectedSnapshotDetail = nextSnapshotId && snapshotDetailPath
+ ? await storyforgeFetch(snapshotDetailPath).catch(() => null)
+ : null;
return true;
} catch (error) {
if (token !== appState.selectedAccountRequestToken) {
return false;
}
+ appState.lastSimilaritySearch = null;
+ appState.selectedWorkspace = null;
+ appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 };
+ appState.snapshots = [];
+ appState.selectedSnapshotId = "";
+ appState.selectedSnapshotDetail = null;
+ appState.creatorFields = null;
+ appState.analysisReports = [];
throw error;
}
}
@@ -3335,6 +3362,12 @@ async function hydrateWorkbenchDataAfterBootstrap(dashboard, preferredPlatform,
appState.selectedAccountId = "";
appState.selectedWorkspace = null;
appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 };
+ appState.snapshots = [];
+ appState.selectedSnapshotId = "";
+ appState.selectedSnapshotDetail = null;
+ appState.creatorFields = null;
+ appState.analysisReports = [];
+ appState.lastSimilaritySearch = null;
}
renderAll();
appState.workspaceHydrating = false;
@@ -3747,6 +3780,41 @@ function hasCreatorCenterSnapshot(items) {
return safeArray(items).some((item) => String(item?.snapshot_type || "").toLowerCase() === "creator_center");
}
+function getCreatorCenterDefaultsForPlatform(platform = "douyin") {
+ const normalized = normalizePlatformValue(platform, "douyin");
+ return {
+ douyin: {
+ platform: "douyin",
+ title: "抖音创作者中心",
+ urls: [
+ "https://creator.douyin.com/creator-micro/home",
+ "https://creator.douyin.com/creator-micro/data",
+ "https://creator.douyin.com/creator-micro/content/manage",
+ ],
+ },
+ kuaishou: {
+ platform: "kuaishou",
+ title: "快手创作者中心",
+ urls: [
+ "https://creator.kuaishou.com/creator/home",
+ "https://creator.kuaishou.com/creator/content/works",
+ "https://creator.kuaishou.com/creator/data/overview",
+ ],
+ },
+ }[normalized] || {
+ platform: normalized,
+ title: `${platformLabel(normalized)}创作者中心`,
+ urls: [],
+ };
+}
+
+function parseLineSeparatedValues(value) {
+ return String(value || "")
+ .split(/\r?\n/)
+ .map((item) => item.trim())
+ .filter(Boolean);
+}
+
function getDashboardProjectProgressSummary(project, stats, trackedAccounts) {
const total = 5;
const completed = [
@@ -4395,12 +4463,117 @@ function describeIntegrationFailure(key) {
if (key === "cutvideo" && detail.reachable && !detail.supportsUploads) {
return `${meta.label}缺少 /api/uploads`;
}
+ if (key === "huobao" && detail.reachable) {
+ if (detail.videoConfigCount <= 0) {
+ return "Huobao 没有可用的视频配置";
+ }
+ if (detail.videoConfigReady === false) {
+ return "Huobao 视频配置未就绪";
+ }
+ }
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)}`;
return `${meta.label}不可达`;
}
+function getHuobaoVideoConfigItems(options = {}) {
+ const allItems = safeArray(getAdminModelAccessState().huobao_configs?.video?.items);
+ if (options.includeInactive) return allItems;
+ return allItems.filter((item) => item?.is_active !== false);
+}
+
+function getAiVideoProviderPreflight(provider = "doubao", model = "") {
+ const huobao = getIntegrationDetail("huobao");
+ const normalizedProvider = String(provider || "doubao").trim() || "doubao";
+ const normalizedModel = String(model || "").trim() || (normalizedProvider === "seedance2" ? "seedance-2.0-pro" : "");
+ const route = huobao.videoConfigRoute || "/settings/ai-config -> 视频 -> 火山引擎";
+ const allVideoConfigs = getHuobaoVideoConfigItems({ includeInactive: true });
+ const activeVideoConfigs = getHuobaoVideoConfigItems();
+ const configuredModels = Array.from(new Set(
+ activeVideoConfigs
+ .flatMap((item) => safeArray(item?.model))
+ .map((value) => String(value || "").trim())
+ .filter(Boolean)
+ ));
+ const hasVideoConfig = huobao.videoConfigCount > 0 || allVideoConfigs.length > 0;
+ const matchedConfig = normalizedModel
+ ? activeVideoConfigs.find((item) => safeArray(item?.model).some((value) => String(value || "").trim() === normalizedModel))
+ : null;
+ if (!huobao.available) {
+ return { ready: true, reason: "", route, tone: "blue", model: normalizedModel, configuredModels, matchedConfig };
+ }
+ if (!huobao.reachable) {
+ return {
+ ready: false,
+ tone: "orange",
+ route,
+ model: normalizedModel,
+ reason: `AI 视频暂不可用:${describeIntegrationFailure("huobao")}。`,
+ configuredModels,
+ matchedConfig,
+ };
+ }
+ if (!hasVideoConfig) {
+ return {
+ ready: false,
+ tone: "orange",
+ route,
+ model: normalizedModel,
+ reason: "AI 视频暂时不可用:请先在 Huobao 启用至少一条视频配置。",
+ configuredModels,
+ matchedConfig,
+ };
+ }
+ if (huobao.videoConfigReady === false) {
+ return {
+ ready: false,
+ tone: "orange",
+ route,
+ model: normalizedModel,
+ reason: "AI 视频暂时不可用:Huobao 视频配置未就绪,请先在管理后台完成视频配置。",
+ configuredModels,
+ matchedConfig,
+ };
+ }
+ if (normalizedProvider === "seedance2" && normalizedModel) {
+ if (!/^seedance(?:-|\\b)/i.test(normalizedModel)) {
+ return {
+ ready: false,
+ tone: "orange",
+ route,
+ model: normalizedModel,
+ reason: `AI 视频暂时不可用:所选 Seedance 模型 ${normalizedModel} 不符合 Seedance 命名,请改成 seedance-* 模型。`,
+ configuredModels,
+ matchedConfig,
+ };
+ }
+ if (!activeVideoConfigs.length) {
+ return {
+ ready: false,
+ tone: "orange",
+ route,
+ model: normalizedModel,
+ reason: "AI 视频暂时不可用:请先在 Huobao 启用至少一条视频配置。",
+ configuredModels,
+ matchedConfig,
+ };
+ }
+ if (!matchedConfig) {
+ return {
+ ready: false,
+ tone: "orange",
+ route,
+ model: normalizedModel,
+ reason: `AI 视频暂时不可用:Huobao 启用中的视频配置未包含所选 Seedance 模型 ${normalizedModel}。`,
+ configuredModels,
+ matchedConfig,
+ };
+ }
+ }
+ return { ready: true, reason: "", route, tone: "green", model: normalizedModel, configuredModels, matchedConfig };
+}
+
function getPipelineGuard(kind) {
const config = PIPELINE_GUARDS[kind];
if (!config) {
@@ -4412,6 +4585,7 @@ function getPipelineGuard(kind) {
if (!item.detail.available) return false;
if (!item.detail.reachable) return true;
if (item.key === "cutvideo" && !item.detail.supportsUploads) return true;
+ if (item.key === "huobao" && (item.detail.videoConfigReady === false || item.detail.videoConfigCount <= 0)) return true;
return false;
});
if (!blocked.length) {
@@ -5533,6 +5707,13 @@ function markSavedCandidate(candidate, links) {
...appState.lastSimilaritySearch,
candidates: nextCandidates
};
+ const sourceAccountId = String(appState.lastSimilaritySearch?.source_account_id || "");
+ if (sourceAccountId) {
+ appState.similaritySearchResultsByAccount = {
+ ...(appState.similaritySearchResultsByAccount || {}),
+ [sourceAccountId]: appState.lastSimilaritySearch
+ };
+ }
}
if (appState.selectedWorkspace) {
appState.selectedWorkspace = {
@@ -5874,36 +6055,52 @@ function renderAdminModelCapabilityOverviewPanel() {
const videoConfigs = safeArray(state.huobao_configs?.video?.items);
const runtime = state.runtime || {};
const systemModels = safeArray(state.system_model_profiles);
+ const capabilityActions = {
+ language: `${actionTag("新增系统模型", "open-admin-system-model")} ${actionTag("查看总入口", "focus-admin-model-access", 'data-anchor-id="admin-system-models-anchor"')}`,
+ asr: `${actionTag("编辑运行时接入", "open-admin-runtime-config")} ${actionTag("定位到 ASR", "focus-admin-model-access", 'data-anchor-id="admin-model-runtime-asr-anchor"')}`,
+ image: `${actionTag("新增图片模型", "open-admin-huobao-ai-config", 'data-service-type="image"')} ${actionTag("前往图片配置", "focus-admin-model-access", 'data-anchor-id="admin-model-image-anchor"')}`,
+ video: `${actionTag("新增视频模型", "open-admin-huobao-ai-config", 'data-service-type="video"')} ${actionTag("前往视频配置", "focus-admin-model-access", 'data-anchor-id="admin-model-video-anchor"')}`,
+ };
const cards = [
{
label: "语言模型",
summary: `${formatNumber(systemModels.length)} 条系统模型 / ${formatNumber(textConfigs.length)} 条文本接入`,
hint: "主 Agent、策略分析和文案生成默认都走这组配置。",
- anchorId: "admin-system-models-anchor"
+ gapLabel: `语言模型缺口 ${escapeHtml(systemModels.length && textConfigs.length ? "已补齐" : "待补齐")}`,
+ anchorId: "admin-system-models-anchor",
+ actions: capabilityActions.language
},
{
label: "ASR",
summary: runtime.asr?.configured ? `已配置 · ${runtime.asr?.active_device || runtime.asr?.runtime_device_mode || "auto"}` : "未配置",
hint: "语音转文字入口,当前会显示实际运行设备和模型。",
- anchorId: "admin-model-runtime-asr-anchor"
+ gapLabel: `ASR 缺口 ${escapeHtml(runtime.asr?.configured ? "已补齐" : "待补齐")}`,
+ anchorId: "admin-model-runtime-asr-anchor",
+ actions: capabilityActions.asr
},
{
label: "文生图",
summary: `${formatNumber(imageConfigs.length)} 条图片配置`,
hint: "封面图、海报图和图片生成都在这里维护。",
- anchorId: "admin-model-image-anchor"
+ gapLabel: `文生图缺口 ${escapeHtml(imageConfigs.length ? "已补齐" : "待补齐")}`,
+ anchorId: "admin-model-image-anchor",
+ actions: capabilityActions.image
},
{
label: "图生图",
summary: `${formatNumber(imageConfigs.length)} 条图片配置`,
hint: "图生图和素材加工与文生图共用同一组图片模型配置。",
- anchorId: "admin-model-image-anchor"
+ gapLabel: `图生图缺口 ${escapeHtml(imageConfigs.length ? "已补齐" : "待补齐")}`,
+ anchorId: "admin-model-image-anchor",
+ actions: capabilityActions.image
},
{
label: "生视频",
summary: `${formatNumber(videoConfigs.length)} 条视频配置`,
hint: "AI 视频、Seedance 2.0 和火山视频引擎统一从这里接。",
- anchorId: "admin-model-video-anchor"
+ gapLabel: `生视频缺口 ${escapeHtml(videoConfigs.length ? "已补齐" : "待补齐")}`,
+ anchorId: "admin-model-video-anchor",
+ actions: capabilityActions.video
}
];
return `
@@ -5921,8 +6118,10 @@ function renderAdminModelCapabilityOverviewPanel() {
${escapeHtml(item.hint)}
${escapeHtml(item.summary)}
+ ${item.gapLabel}
前往配置
+
${item.actions}
`).join("")}
@@ -6182,11 +6381,13 @@ function renderSnapshotFieldRows(fields, limit = 8) {
`).join("");
}
-function renderDouyinInsightPanel() {
+function renderCreatorInsightPanel() {
const selected = getSelectedAccount();
- if (!selected || getAccountPlatform(selected) !== "douyin") {
+ const platform = getAccountPlatform(selected);
+ if (!selected || !isWorkbenchPlatform(platform)) {
return "";
}
+ const platformName = platformLabel(platform);
const snapshots = safeArray(appState.snapshots);
const selectedSnapshot = appState.selectedSnapshotDetail
|| snapshots.find((item) => item.id === appState.selectedSnapshotId)
@@ -6198,11 +6399,11 @@ function renderDouyinInsightPanel() {
const selectedSnapshotFields = safeArray(selectedSnapshot?.fields);
const creatorSnapshotFields = safeArray(creatorFields?.fields);
return `
-
+
-
抖音快照详情
-
快照、创作者字段和分析报告统一在这里看
+
${escapeHtml(platformName)}快照详情
+
快照、创作者中心字段和分析报告统一在这里看
@@ -7910,6 +8154,7 @@ function renderProductionMobileTaskDeck({ activeTab, activeJobs, failedJobs, rec
${actionTag("导入主页", "open-import-homepage")}
${actionTag("导入作品", "open-import-video-link")}
+ ${actionTag("接入抖音创作者中心", "open-creator-center-sync", 'data-platform="douyin" data-sync-origin="production"')}
${actionTag("视频录制", "focus-live-recorder-maintenance")}
@@ -7961,9 +8206,9 @@ function renderProductionScreen() {
{ value: "outputs", label: "作品与产物" }
];
const activeTab = getActiveDetailTab("productionDetailTab", tabs);
- const intakeEntryActionsHtml = `${button("导入主页", "open-import-homepage")} ${button("导入作品", "open-import-video-link")} ${button("导入文本", "open-import-text")} ${button("上传视频", "open-upload-video")}`;
+ const intakeEntryActionsHtml = `${button("导入主页", "open-import-homepage")} ${button("导入作品", "open-import-video-link")} ${button("导入文本", "open-import-text")} ${button("上传视频", "open-upload-video")} ${button("接入抖音创作者中心", "open-creator-center-sync", "secondary", { attrs: 'data-platform="douyin" data-sync-origin="production"' })} ${button("接入快手创作者中心", "open-creator-center-sync", "secondary", { attrs: 'data-platform="kuaishou" data-sync-origin="production"' })}`;
const productionActionsHtml = isMobileUi
- ? `${button("导入作品", "open-import-video-link", "primary")} ${button("导入主页", "open-import-homepage")} ${button("导入文本", "open-import-text")} ${button("上传视频", "open-upload-video")} ${renderPipelineButton("aiVideo")} ${renderPipelineButton("realCut")} ${button("视频录制", "focus-live-recorder-maintenance", "secondary")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: productionHandoffAttrs })}`
+ ? `${button("接入抖音创作者中心", "open-creator-center-sync", "primary", { attrs: 'data-platform="douyin" data-sync-origin="production"' })} ${button("接入快手创作者中心", "open-creator-center-sync", "secondary", { attrs: 'data-platform="kuaishou" data-sync-origin="production"' })} ${button("导入作品", "open-import-video-link")} ${button("导入主页", "open-import-homepage")} ${button("导入文本", "open-import-text")} ${button("上传视频", "open-upload-video")} ${renderPipelineButton("aiVideo")} ${renderPipelineButton("realCut")} ${button("视频录制", "focus-live-recorder-maintenance", "secondary")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: productionHandoffAttrs })}`
: `${button("导入主页", "open-import-homepage")} ${button("导入作品", "open-import-video-link")} ${button("导入文本", "open-import-text")} ${button("上传视频", "open-upload-video")} ${renderPipelineButton("aiVideo")} ${renderPipelineButton("realCut")} ${button("视频录制", "focus-live-recorder-maintenance", "secondary")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: productionHandoffAttrs })} ${button("去复盘", "goto-review", "primary")} ${button("批量恢复", "batch-recover-jobs", "secondary", { disabledReason: recoverableCount ? "" : "当前没有可恢复的失败任务" })}`;
return screenShell(
"生产中心",
@@ -8009,6 +8254,8 @@ function renderProductionScreen() {
: "先接入素材,再看处理中任务和异常,把生产链真正跑起来。"
)}
+ ${actionTag("接入抖音创作者中心", "open-creator-center-sync", 'data-platform="douyin" data-sync-origin="production"')}
+ ${actionTag("接入快手创作者中心", "open-creator-center-sync", 'data-platform="kuaishou" data-sync-origin="production"')}
${activeTab === "recovery"
? `${actionTag("批量恢复", "batch-recover-jobs")} ${actionTag("查看恢复记录", "select-page-tab", `data-page-tab-key="productionDetailTab" data-page-tab-value="recovery"`)}`
: activeTab === "outputs"
@@ -10193,6 +10440,119 @@ function openTrackSelectedAccountAction() {
});
}
+function openCreatorCenterSyncAction(defaults = {}) {
+ const project = requireSelectedProject();
+ const selectedAccount = getSelectedAccount();
+ const preferredPlatform = normalizePlatformValue(
+ defaults.platform
+ || (getAccountPlatform(selectedAccount) === "kuaishou" ? "kuaishou" : "")
+ || getCurrentPlatformValue()
+ || getPreferredPlatform(),
+ "douyin"
+ );
+ const platform = preferredPlatform === "kuaishou" ? "kuaishou" : "douyin";
+ const creatorCenter = getCreatorCenterDefaultsForPlatform(platform);
+ const selectedAccountMatchesPlatform = selectedAccount && getAccountPlatform(selectedAccount) === platform;
+ const defaultProfileUrl = selectedAccountMatchesPlatform ? getAccountProfileUrl(selectedAccount) : "";
+ const defaultHandle = selectedAccountMatchesPlatform ? getAccountHandle(selectedAccount) : "";
+ const defaultTitle = selectedAccountMatchesPlatform ? getAccountName(selectedAccount) : "";
+ openActionModal({
+ // Source contract for UI tests: title: "${platformLabel(platform)}创作者中心接入"
+ title: `${platformLabel(platform)}创作者中心接入`,
+ description: "粘贴登录 Cookie 和创作者中心页面,系统会同步账号快照并可直接触发账号分析。",
+ submitLabel: "同步并进入分析",
+ fields: [
+ { name: "platform", label: "平台", type: "select", value: platform, options: [
+ { value: "douyin", label: "抖音" },
+ { value: "kuaishou", label: "快手" },
+ ] },
+ { name: "profileUrl", label: "主页链接", type: "url", value: defaults.profileUrl || defaultProfileUrl || "", placeholder: "https://..." },
+ { name: "title", label: "账号名称", value: defaults.title || defaultTitle || "", placeholder: "可选,留空则尽量自动识别" },
+ { name: "handle", label: "账号标识 / handle", value: defaults.handle || defaultHandle || "", placeholder: "可选" },
+ { name: "sessionCookie", label: "登录 Cookie", type: "password", value: "", placeholder: "粘贴浏览器里当前账号的登录 Cookie" },
+ { name: "creatorCenterUrls", label: "创作者中心页面", type: "textarea", rows: 4, value: safeArray(defaults.creatorCenterUrls || creatorCenter.urls).join("\n"), placeholder: "一行一个创作者中心页面 URL" },
+ { name: "autoAnalyze", label: "同步后自动分析", type: "checkbox", value: true },
+ ],
+ onSubmit: async (values) => {
+ const platform = normalizePlatformValue(values.platform || preferredPlatform, "douyin");
+ const syncPath = `/v2/${platform}/accounts/sync`;
+ const profileUrl = String(values.profileUrl || "").trim();
+ if (!profileUrl) throw new Error("请先填写主页链接");
+ setBusy(true, "正在同步创作者中心...");
+ try {
+ let analyzeResult = null;
+ const workspace = await storyforgeFetch(syncPath, {
+ method: "POST",
+ body: {
+ project_id: project.id,
+ profile_url: profileUrl,
+ title: values.title || "",
+ handle: values.handle || "",
+ session_cookie: values.sessionCookie || "",
+ creator_center_urls: parseLineSeparatedValues(values.creatorCenterUrls),
+ manual_profile_payload: null,
+ manual_creator_pages: [],
+ discovery_note: defaults.origin ? `source:${defaults.origin}` : "",
+ },
+ });
+ if (Boolean(values.autoAnalyze) && workspace?.account?.id) {
+ const analyzePath = getWorkbenchRoute(platform, "analyzeAccount", workspace?.account?.id || "");
+ if (analyzePath) {
+ analyzeResult = await storyforgeFetch(analyzePath, {
+ method: "POST",
+ body: {
+ model_profile_ids: [],
+ linked_account_ids: [],
+ include_linked_accounts: true,
+ include_recent_similar_candidates: true,
+ max_videos: 6,
+ extra_focus: "优先结合创作者中心里的成交、涨粉和作品表现做账号分析。",
+ temperature: 0.35,
+ auto_analyze_top_videos: true,
+ top_video_analysis_count: 4,
+ },
+ });
+ }
+ }
+ const topVideoAnalyses = safeArray(analyzeResult?.top_video_analyses);
+ if (workspace?.account?.id && topVideoAnalyses.length) {
+ appState.topVideoAnalysisResults = {
+ ...(appState.topVideoAnalysisResults || {}),
+ [workspace.account.id]: {
+ account_id: workspace.account.id,
+ platform,
+ analyzed_count: topVideoAnalyses.length,
+ items: topVideoAnalyses,
+ created_at: analyzeResult?.created_at || new Date().toISOString()
+ }
+ };
+ }
+ await bootstrap();
+ setCurrentPlatform(platform);
+ setScreen("discovery");
+ if (workspace?.account?.id) {
+ await loadPlatformAccount(platform, workspace.account.id);
+ }
+ if (topVideoAnalyses.length) {
+ focusDiscoveryTopVideoInsights();
+ } else {
+ focusDiscoveryInsights();
+ }
+ rememberAction(
+ "创作者中心已同步",
+ topVideoAnalyses.length
+ ? `已接入 ${platformLabel(platform)} 创作者中心,并同步 ${formatNumber(topVideoAnalyses.length)} 条高分作品拆解。`
+ : `已接入 ${platformLabel(platform)} 创作者中心,并刷新快照 / 字段 / 报告区域。`,
+ "green",
+ analyzeResult || workspace
+ );
+ } finally {
+ setBusy(false, "");
+ }
+ },
+ });
+}
+
function openImportVideoLinkAction() {
const project = requireSelectedProject();
const assistants = getAssistantOptions(project.id);
@@ -10704,6 +11064,33 @@ async function openAdminHuobaoConfigAction(serviceType = "video", configId = "")
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);
+ const buildPayload = (values) => ({
+ 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 buildTestMessage = (testPayload, values) => {
+ const normalizedName = values.name || existing?.name || "当前配置";
+ const summary = String(
+ testPayload?.detail
+ || testPayload?.message
+ || testPayload?.result_reason
+ || testPayload?.reason
+ || testPayload?.status
+ || "接口已返回成功响应"
+ ).trim();
+ return summary
+ ? `测试通过:${normalizedName} · ${summary}`
+ : `测试通过:${normalizedName} 已通过 Huobao 配置测试。`;
+ };
openActionModal({
title: existing ? "编辑模型接入配置" : "新增模型接入配置",
description: normalizedServiceType === "video"
@@ -10711,7 +11098,8 @@ async function openAdminHuobaoConfigAction(serviceType = "video", configId = "")
: normalizedServiceType === "image"
? "这里维护文生图 / 图生图模型服务配置。"
: "这里维护文本大模型服务配置。",
- submitLabel: existing ? "保存配置" : "创建配置",
+ submitLabel: existing ? "保存配置" : "创建配置并测试",
+ testLabel: "测试配置",
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" : "例如:系统默认文本模型" },
@@ -10724,20 +11112,27 @@ async function openAdminHuobaoConfigAction(serviceType = "video", configId = "")
{ 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 || ""
+ onTest: async (values) => {
+ const payload = buildPayload(values);
+ if (!payload.base_url?.trim()) throw new Error("请先填写 Base URL 再测试配置");
+ if (!payload.model.length) throw new Error("请至少填写一个模型再测试配置");
+ const testPayload = await storyforgeFetch("/v2/admin/model-access/huobao-configs/test", {
+ method: "POST",
+ body: payload
+ });
+ return {
+ keepOpen: true,
+ message: buildTestMessage(testPayload, values)
};
+ },
+ onSubmit: async (values) => {
+ const payload = buildPayload(values);
+ if (!existing) {
+ await storyforgeFetch("/v2/admin/model-access/huobao-configs/test", {
+ method: "POST",
+ body: payload
+ });
+ }
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",
@@ -11873,10 +12268,34 @@ function openAnalyzeSelectedAccountAction() {
top_video_analysis_count: Number(values.topVideoCount || 4)
}
});
+ const topVideoAnalyses = safeArray(result.top_video_analyses);
const summary = result?.suggestions?.[0]?.parsed_json?.executive_summary || result?.suggestions?.[0]?.suggestion_text || "已生成新的账号分析。";
- rememberAction("对标账号分析完成", brief(summary, 120), "green", result);
+ if (safeArray(result.top_video_analyses).length) {
+ appState.topVideoAnalysisResults = {
+ ...(appState.topVideoAnalysisResults || {}),
+ [account.id]: {
+ account_id: account.id,
+ platform,
+ analyzed_count: topVideoAnalyses.length,
+ items: topVideoAnalyses,
+ created_at: result?.created_at || new Date().toISOString()
+ }
+ };
+ }
+ rememberAction(
+ "对标账号分析完成",
+ topVideoAnalyses.length
+ ? `${brief(summary, 96)} 已同步补分析 ${formatNumber(topVideoAnalyses.length)} 条高分作品。`
+ : brief(summary, 120),
+ "green",
+ result
+ );
await loadPlatformAccount(platform, account.id);
- focusDiscoveryInsights();
+ if (topVideoAnalyses.length) {
+ focusDiscoveryTopVideoInsights();
+ } else {
+ focusDiscoveryInsights();
+ }
}
});
}
@@ -11950,6 +12369,10 @@ function openSimilaritySearchAction() {
? await storyforgeFetch(detailPath)
: created;
appState.lastSimilaritySearch = detail;
+ appState.similaritySearchResultsByAccount = {
+ ...(appState.similaritySearchResultsByAccount || {}),
+ [account.id]: detail
+ };
rememberAction("相似账号已生成", `已生成 ${formatNumber(safeArray(detail.candidates).length)} 个候选账号。`, "green", detail);
await loadPlatformAccount(platform, account.id);
focusDiscoveryRelations();
@@ -12558,22 +12981,34 @@ function openGenerateCopyAction(defaults = {}) {
});
}
-function renderAiVideoProviderHintHtml(provider = "doubao") {
- const huobao = getIntegrationDetail("huobao");
- const route = huobao.videoConfigRoute || "/settings/ai-config -> 视频 -> 火山引擎";
- const providerLabel = String(provider || "doubao").trim() === "seedance2" ? "Seedance 2.0" : "当前默认视频引擎";
- const configStatus = huobao.videoConfigReady
- ? `Huobao 视频配置已就绪${huobao.videoConfigCount ? `(${huobao.videoConfigCount} 条)` : ""}`
- : "Huobao 视频配置页当前还没有录入视频引擎配置";
+function renderAiVideoProviderHintHtml(provider = "doubao", model = "") {
+ const normalizedProvider = String(provider || "doubao").trim() || "doubao";
+ const videoPreflight = getAiVideoProviderPreflight(normalizedProvider, model);
+ const providerLabel = normalizedProvider === "seedance2" ? "Seedance 2.0" : "当前默认视频引擎";
+ const selectedModel = String(videoPreflight.model || model || "").trim();
+ const modelStatus = selectedModel
+ ? (videoPreflight.ready
+ ? `当前模型 ${selectedModel} 已可用`
+ : `当前模型 ${selectedModel} 还未通过预检`)
+ : "当前模型会沿用默认视频模型";
+ const configuredModels = safeArray(videoPreflight.configuredModels);
+ const configuredModelLabel = configuredModels.length
+ ? configuredModels.join(" / ")
+ : "当前还没有读取到启用中的视频模型";
return `
${escapeHtml(`${providerLabel} 走火山视频配置`)}
-
${escapeHtml(`请在 Huobao 服务里配置火山视频 Key:${route}`)}
+
${escapeHtml(`请在 Huobao 服务里配置火山视频 Key:${videoPreflight.route}`)}
${escapeHtml("如果不是走页面配置,也可以在 huobao 服务环境变量里覆盖 HUOBAO_VIDEO_BASE_URL / HUOBAO_VIDEO_API_KEY / HUOBAO_VIDEO_MODELS。")}
+
${escapeHtml(modelStatus)}
+ ${videoPreflight.reason ? `
${escapeHtml(videoPreflight.reason)}
` : ""}
- ${escapeHtml(configStatus)}
- ${huobao.deploymentLabel ? `${escapeHtml(`部署:${huobao.deploymentLabel}`)}` : ""}
+ ${escapeHtml(videoPreflight.ready ? "Huobao 视频配置已就绪" : "Huobao 视频配置待处理")}
+ ${escapeHtml(`当前模型:${selectedModel || "默认视频模型"}`)}
+ ${escapeHtml(`配置中的模型:${configuredModelLabel}`)}
+ ${videoPreflight.matchedConfig?.name ? `${escapeHtml(`命中配置:${videoPreflight.matchedConfig.name}`)}` : ""}
+ ${getIntegrationDetail("huobao").deploymentLabel ? `${escapeHtml(`部署:${getIntegrationDetail("huobao").deploymentLabel}`)}` : ""}
${isSuperAdmin() ? '打开视频引擎配置' : ""}
查看火山配置状态
@@ -12624,13 +13059,13 @@ function bindAiVideoProviderRecommendations(fields, options = {}) {
modelInput.addEventListener("input", () => {
if (modelInput.dataset.recommendationApplying === "1") return;
modelInput.dataset.recommendationMode = "manual";
+ if (hintHtml instanceof HTMLElement) {
+ hintHtml.innerHTML = renderAiVideoProviderHintHtml(providerSelect.value || "doubao", modelInput?.value || seedanceModel);
+ }
});
}
const sync = () => {
const provider = String(providerSelect.value || "doubao").trim() || "doubao";
- if (hintHtml instanceof HTMLElement) {
- hintHtml.innerHTML = renderAiVideoProviderHintHtml(provider);
- }
if (modelInput instanceof HTMLInputElement) {
modelInput.placeholder = provider === "seedance2" ? "例如:seedance-2.0-pro" : "留空则沿用当前默认视频模型";
if (modelInput.dataset.recommendationMode !== "manual") {
@@ -12640,6 +13075,9 @@ function bindAiVideoProviderRecommendations(fields, options = {}) {
delete modelInput.dataset.recommendationApplying;
}
}
+ if (hintHtml instanceof HTMLElement) {
+ hintHtml.innerHTML = renderAiVideoProviderHintHtml(provider, modelInput?.value || seedanceModel);
+ }
};
providerSelect.addEventListener("change", sync);
sync();
@@ -12695,7 +13133,7 @@ function openCreateAiVideoAction(defaults = {}) {
name: "videoProviderHint",
label: "Seedance 配置",
type: "html",
- html: renderAiVideoProviderHintHtml(defaultVideoProvider),
+ html: renderAiVideoProviderHintHtml(defaultVideoProvider, defaultVideoModel),
},
{ name: "style", label: "风格", value: defaults.style || recommendCreativeStyle(sourceJob) },
{
@@ -12743,6 +13181,8 @@ function openCreateAiVideoAction(defaults = {}) {
if (!values.brief?.trim()) throw new Error("请填写视频 brief");
const normalizedProvider = String(values.videoProvider || "doubao").trim() || "doubao";
const normalizedVideoModel = String(values.videoModel || "").trim() || (normalizedProvider === "seedance2" ? "seedance-2.0-pro" : "");
+ const videoPreflight = getAiVideoProviderPreflight(normalizedProvider, normalizedVideoModel);
+ if (!videoPreflight.ready) throw new Error(videoPreflight.reason);
const seedanceSuffix = normalizedProvider === "seedance2"
? [
values.cameraLanguage?.trim() ? `镜头语言:${values.cameraLanguage.trim()}` : "",
@@ -13195,7 +13635,9 @@ document.addEventListener("click", async (event) => {
}
if (name === "open-oneliner") {
try {
- setBusy(true, "正在打开 OneLiner...");
+ setOneLinerHydrating(true, "正在同步 OneLiner 上下文...");
+ openOneLinerPanel();
+ renderAll();
if (appState.session) {
await loadAgentControlSurfaces(appState.selectedProjectId || "");
if (appState.selectedOnelinerSessionId) {
@@ -13204,10 +13646,9 @@ document.addEventListener("click", async (event) => {
await ensureOneLinerSession();
}
}
- openOneLinerPanel();
renderAll();
} finally {
- setBusy(false, "");
+ setOneLinerHydrating(false, "");
renderAll();
}
return;
@@ -13221,7 +13662,11 @@ document.addEventListener("click", async (event) => {
return;
}
if (name === "submit-sheet") {
- await submitActionModal();
+ await submitActionModal("onSubmit", "正在执行...");
+ return;
+ }
+ if (name === "test-sheet") {
+ await submitActionModal("onTest", "正在测试配置...");
return;
}
if (name === "submit-auth") {
@@ -13543,6 +13988,13 @@ document.addEventListener("click", async (event) => {
openUploadVideoAction();
return;
}
+ if (name === "open-creator-center-sync") {
+ openCreatorCenterSyncAction({
+ platform: action.dataset.platform || "",
+ origin: action.dataset.syncOrigin || "",
+ });
+ return;
+ }
if (name === "open-create-assistant") {
const project = getSelectedProject();
if (project?.id) {
@@ -14074,6 +14526,10 @@ document.addEventListener("click", async (event) => {
openAdminFixRunDetailAction(action.dataset.runId || "");
return;
}
+ if (name === "select-account-snapshot") {
+ await openDouyinSnapshotDetailAction(action.dataset.snapshotId || "");
+ return;
+ }
if (name === "select-douyin-snapshot") {
await openDouyinSnapshotDetailAction(action.dataset.snapshotId || "");
return;
diff --git a/web/storyforge-web-v4/assets/storyforge-platform-runtime.js b/web/storyforge-web-v4/assets/storyforge-platform-runtime.js
index 1a5ba13..27e8d37 100644
--- a/web/storyforge-web-v4/assets/storyforge-platform-runtime.js
+++ b/web/storyforge-web-v4/assets/storyforge-platform-runtime.js
@@ -6,7 +6,12 @@
function makePlatformRoutes(platform) {
return {
accounts: `/v2/${platform}/accounts`,
+ syncAccount: `/v2/${platform}/accounts/sync`,
workspace: (accountId) => `/v2/${platform}/accounts/${encodeURIComponent(accountId)}/workspace`,
+ snapshots: (accountId) => `/v2/${platform}/accounts/${encodeURIComponent(accountId)}/snapshots`,
+ snapshotDetail: (accountId, snapshotId) => `/v2/${platform}/accounts/${encodeURIComponent(accountId)}/snapshots/${encodeURIComponent(snapshotId)}`,
+ creatorFields: (accountId) => `/v2/${platform}/accounts/${encodeURIComponent(accountId)}/creator-fields`,
+ analysisReports: (accountId) => `/v2/${platform}/accounts/${encodeURIComponent(accountId)}/analysis-reports`,
videos: (accountId) => `/v2/${platform}/accounts/${encodeURIComponent(accountId)}/videos?limit=80`,
analyzeAccount: (accountId) => `/v2/${platform}/accounts/${encodeURIComponent(accountId)}/analysis`,
analyzeTopVideos: (accountId) => `/v2/${platform}/accounts/${encodeURIComponent(accountId)}/videos/analyze-top`,
diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs
index f4aadad..9ef4758 100644
--- a/web/storyforge-web-v4/tests/workbench-pages.test.mjs
+++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs
@@ -115,10 +115,18 @@ test("mobile action sheets and oneliner runtime behave like bottom sheets", () =
assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.oneliner-composer\s*\{[\s\S]*position:\s*sticky/);
});
-test("opening OneLiner clears the transient loading state after the panel is hydrated", () => {
+test("opening OneLiner keeps global loading clear and uses panel-local hydration status", () => {
const actions = extractBetween(APP, "document.addEventListener(\"click\", async (event) => {", "document.addEventListener(\"submit\", async (event) => {");
- assert.match(actions, /name === "open-oneliner"[\s\S]*setBusy\(true,\s*"正在打开 OneLiner\.\.\."\)/);
- assert.match(actions, /name === "open-oneliner"[\s\S]*finally \{[\s\S]*setBusy\(false,\s*""\);[\s\S]*renderAll\(\);[\s\S]*\}/);
+ const branch = extractBetween(actions, "if (name === \"open-oneliner\") {", "if (name === \"close-oneliner\") {");
+ assert.match(APP, /function setOneLinerHydrating\(next,\s*message = ""\)/);
+ assert.match(branch, /setOneLinerHydrating\(true,\s*"正在同步 OneLiner 上下文\.\.\."\)/);
+ assert.doesNotMatch(branch, /setBusy\(true,\s*"正在打开 OneLiner\.\.\."\)/);
+ assert.match(branch, /finally \{[\s\S]*setOneLinerHydrating\(false,\s*""\);[\s\S]*renderAll\(\);[\s\S]*\}/);
+});
+
+test("opening OneLiner opens the panel before waiting for control-surface hydration", () => {
+ const actions = extractBetween(APP, "document.addEventListener(\"click\", async (event) => {", "document.addEventListener(\"submit\", async (event) => {");
+ assert.match(actions, /name === "open-oneliner"[\s\S]*setOneLinerHydrating\(true,\s*"正在同步 OneLiner 上下文\.\.\."\)[\s\S]*openOneLinerPanel\(\);[\s\S]*renderAll\(\);[\s\S]*await loadAgentControlSurfaces/);
});
test("project creation and switching use in-app sheets instead of browser prompts", () => {
@@ -295,6 +303,57 @@ test("admin workbench exposes a dedicated model access workspace and actions", (
assert.match(clickActions, /name === "open-admin-huobao-ai-config"/);
});
+test("owned account and admin model pages surface creator-center and model setup quick actions", () => {
+ const owned = extractBetween(APP, "function renderOwnedScreen()", "function renderPlaybookScreen()");
+ const modelOverview = extractBetween(APP, "function renderAdminModelCapabilityOverviewPanel()", "function renderAdminModelAccessPanel()");
+
+ assert.match(owned, /创作者中心账号分析/);
+ assert.match(owned, /登录抖音创作者中心/);
+ assert.match(owned, /登录快手创作者中心/);
+ assert.match(owned, /open-creator-center-sync/);
+ assert.match(owned, /data-platform="douyin" data-sync-origin="owned"/);
+ assert.match(owned, /data-platform="kuaishou" data-sync-origin="owned"/);
+ assert.match(owned, /去找对标看快照/);
+
+ assert.match(modelOverview, /语言模型缺口/);
+ assert.match(modelOverview, /ASR 缺口/);
+ assert.match(modelOverview, /文生图缺口/);
+ assert.match(modelOverview, /图生图缺口/);
+ assert.match(modelOverview, /生视频缺口/);
+ assert.match(modelOverview, /open-admin-system-model/);
+ assert.match(modelOverview, /open-admin-runtime-config/);
+ assert.match(modelOverview, /open-admin-huobao-ai-config/);
+ assert.match(modelOverview, /data-service-type="image"/);
+ assert.match(modelOverview, /data-service-type="video"/);
+});
+
+test("ai video and huobao admin config expose preflight and connection test flows", () => {
+ const guardSource = extractBetween(APP, "function getPipelineGuard(kind) {", "function getIntegrationCards()");
+ const preflightSource = extractBetween(APP, "function getAiVideoProviderPreflight(provider = \"doubao\", model = \"\") {", "function renderAiVideoProviderHintHtml(");
+ const aiVideoSource = extractBetween(APP, "function openCreateAiVideoAction(defaults = {})", "function openCreateRealCutAction(");
+ const huobaoSource = extractBetween(APP, "async function openAdminHuobaoConfigAction(serviceType = \"video\", configId = \"\") {", "function openAdminHuobaoConfigDeleteAction(");
+ const clickActions = extractBetween(APP, "document.addEventListener(\"click\", async (event) => {", "document.addEventListener(\"submit\", async (event) => {");
+
+ assert.match(guardSource, /detail\.videoConfigReady === false/);
+ assert.match(guardSource, /detail\.videoConfigCount <= 0/);
+ assert.match(preflightSource, /Huobao 视频配置未就绪,请先在管理后台完成视频配置/);
+ assert.match(preflightSource, /请先在 Huobao 启用至少一条视频配置/);
+ assert.match(preflightSource, /Huobao 启用中的视频配置未包含所选 Seedance 模型/);
+ assert.match(preflightSource, /所选 Seedance 模型/);
+ assert.match(preflightSource, /seedance-2\.0-pro/);
+ assert.match(aiVideoSource, /const videoPreflight = getAiVideoProviderPreflight\(normalizedProvider, normalizedVideoModel\)/);
+ assert.match(aiVideoSource, /if \(!videoPreflight\.ready\) throw new Error\(videoPreflight\.reason\);/);
+ assert.match(aiVideoSource, /renderAiVideoProviderHintHtml\(defaultVideoProvider, defaultVideoModel\)/);
+ assert.match(APP, /renderAiVideoProviderHintHtml\(provider, modelInput\?\.value \|\| seedanceModel\)/);
+ assert.match(aiVideoSource, /videoPreflight\.reason/);
+ assert.match(huobaoSource, /submitLabel: existing \? "保存配置" : "创建配置并测试"/);
+ assert.match(huobaoSource, /testLabel:\s*"测试配置"/);
+ assert.match(huobaoSource, /const testPayload = await storyforgeFetch\("\/v2\/admin\/model-access\/huobao-configs\/test"/);
+ assert.match(huobaoSource, /keepOpen: true/);
+ assert.match(huobaoSource, /测试通过/);
+ assert.match(clickActions, /name === "test-sheet"[\s\S]*submitActionModal\("onTest"/);
+});
+
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(");
@@ -448,10 +507,12 @@ test("job detail and follow-up flows use direct generate-copy execution and pers
});
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 hintSource = extractBetween(APP, "function renderAiVideoProviderHintHtml(provider = \"doubao\", model = \"\") {", "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(hintSource, /当前模型/);
+ assert.match(hintSource, /配置中的模型/);
assert.match(clickActions, /name === "focus-huobao-video-config"[\s\S]*focusAdminModelAccessWorkspace\("admin-model-video-anchor"\)/);
});
@@ -552,6 +613,17 @@ test("production queue promotes intake entrypoints so import flows are reachable
assert.match(mobileDeck, /actionTag\("视频录制", "focus-live-recorder-maintenance"\)/);
});
+test("discovery and production promote creator-center sync entrypoints for douyin and kuaishou", () => {
+ const discovery = extractBetween(APP, "function renderDiscoveryScreen()", "function renderTrackingScreen()");
+ const production = extractBetween(APP, "function renderProductionScreen()", "function renderReviewScreen()");
+ assert.match(discovery, /button\("登录抖音创作者中心", "open-creator-center-sync"/);
+ assert.match(discovery, /button\("登录快手创作者中心", "open-creator-center-sync"/);
+ assert.match(discovery, /actionTag\("接入抖音创作者中心", "open-creator-center-sync"/);
+ assert.match(production, /button\("接入抖音创作者中心", "open-creator-center-sync"/);
+ assert.match(production, /button\("接入快手创作者中心", "open-creator-center-sync"/);
+ assert.match(production, /actionTag\("接入抖音创作者中心", "open-creator-center-sync"/);
+});
+
test("discovery page promotes selected-account actions into direct execute flows", () => {
const discovery = extractBetween(APP, "function renderDiscoveryScreen()", "function renderTrackingScreen()");
const discoveryOverview = extractBetween(APP, "function renderDiscoveryOverviewSection(", "function renderDiscoveryRelationsSection(");
@@ -577,6 +649,46 @@ test("direct discovery analysis actions gracefully fall back to forms when no ac
assert.match(APP, /if \(name === "direct-analyze-top-videos"\)[\s\S]*openAnalyzeTopVideosAction\(\);/);
});
+test("creator center sync modal covers douyin and kuaishou creator-center login flows", () => {
+ const clickActions = extractBetween(APP, 'document.addEventListener("click", async (event) => {', 'navButtons.forEach((button) => {');
+ const creatorCenterSync = extractBetween(APP, "function openCreatorCenterSyncAction(defaults = {}) {", "function openImportVideoLinkAction()");
+ assert.match(APP, /function openCreatorCenterSyncAction\(defaults = \{\}\)/);
+ assert.match(APP, /title: "\$\{platformLabel\(platform\)\}创作者中心接入"/);
+ assert.match(APP, /name: "sessionCookie", label: "登录 Cookie", type: "password"/);
+ assert.match(APP, /name: "creatorCenterUrls", label: "创作者中心页面"/);
+ assert.match(APP, /name: "autoAnalyze", label: "同步后自动分析", type: "checkbox", value: true/);
+ assert.match(APP, /const syncPath = `\/v2\/\$\{platform\}\/accounts\/sync`/);
+ assert.match(APP, /const analyzePath = getWorkbenchRoute\(platform, "analyzeAccount", workspace\?\.account\?\.id \|\| ""\)/);
+ assert.match(creatorCenterSync, /auto_analyze_top_videos: true/);
+ assert.match(creatorCenterSync, /const topVideoAnalyses = safeArray\(analyzeResult\?\.top_video_analyses\)/);
+ assert.match(creatorCenterSync, /appState\.topVideoAnalysisResults = \{/);
+ assert.match(APP, /manual_creator_pages: \[\]/);
+ assert.match(APP, /await loadPlatformAccount\(platform, workspace\.account\.id\)/);
+ assert.match(creatorCenterSync, /focusDiscoveryTopVideoInsights\(\)/);
+ assert.match(APP, /focusDiscoveryInsights\(\)/);
+ assert.match(clickActions, /name === "open-creator-center-sync"[\s\S]*openCreatorCenterSyncAction\(\{/);
+});
+
+test("workbench account loading fetches snapshots and creator fields for all supported platforms", () => {
+ const loadPlatformAccountSource = extractBetween(APP, "async function loadPlatformAccount(", "async function hydrateWorkbenchDataAfterBootstrap(");
+ const snapshotDetailAction = extractBetween(APP, "async function openDouyinSnapshotDetailAction(", "function renderLiveRecorderManagementPanel()");
+ assert.match(loadPlatformAccountSource, /const snapshotsPath = getWorkbenchRoute\(normalizedPlatform, "snapshots", accountId\);/);
+ assert.match(loadPlatformAccountSource, /const analysisReportsPath = getWorkbenchRoute\(normalizedPlatform, "analysisReports", accountId\);/);
+ assert.match(loadPlatformAccountSource, /const creatorFieldsPath = getWorkbenchRoute\(normalizedPlatform, "creatorFields", accountId\);/);
+ assert.match(loadPlatformAccountSource, /const snapshotDetailPath = getWorkbenchRoute\(normalizedPlatform, "snapshotDetail", accountId, nextSnapshotId\);/);
+ assert.doesNotMatch(loadPlatformAccountSource, /normalizedPlatform === "douyin"/);
+ assert.match(APP, /function renderCreatorInsightPanel\(\)/);
+ assert.match(snapshotDetailAction, /const detailPath = getWorkbenchRoute\(platform, "snapshotDetail", selected\.id, snapshotId\);/);
+});
+
+test("creator insight panel surfaces report model and linked-account context", () => {
+ const insightPanel = extractBetween(APP, "function renderCreatorInsightPanel() {", "function renderDouyinInsightPanel() {");
+ assert.match(insightPanel, /report\.model_profile_ids\?\.length/);
+ assert.match(insightPanel, /report\.linked_account_ids\?\.length/);
+ assert.match(insightPanel, /已绑对标/);
+ assert.match(insightPanel, /模型 /);
+});
+
test("mobile discovery prioritizes the selected-account task flow before the scrollable account list", () => {
const discovery = extractBetween(APP, "function renderDiscoveryScreen()", "function renderTrackingScreen()");
assert.match(discovery, /mobile-discovery-priority/);
@@ -1179,7 +1291,7 @@ test("ai video form explains where Seedance 火山配置 lives", () => {
'document.addEventListener("click", async (event) => {',
'navButtons.forEach((button) => {'
);
- assert.match(APP, /function renderAiVideoProviderHintHtml\(provider = "doubao"\)/);
+ assert.match(APP, /function renderAiVideoProviderHintHtml\(provider = "doubao", model = ""\)/);
assert.match(APP, /Seedance 2\.0 走火山视频配置/);
assert.match(APP, /\/settings\/ai-config/);
assert.match(APP, /视频 -> 火山引擎/);
@@ -1197,6 +1309,8 @@ test("ai video form explains where Seedance 火山配置 lives", () => {
assert.match(APP, /当前项目最近视频引擎/);
assert.match(APP, /上次创建 AI 视频时使用的是/);
assert.match(APP, /modelInput\.placeholder = provider === "seedance2" \? "例如:seedance-2\.0-pro" : "留空则沿用当前默认视频模型"/);
+ assert.match(APP, /modelInput\.addEventListener\("input", \(\) => \{/);
+ assert.match(APP, /hintHtml\.innerHTML = renderAiVideoProviderHintHtml\(provider, modelInput\?\.value \|\| seedanceModel\)/);
assert.match(APP, /bindAiVideoProviderRecommendations\(fields, \{ seedanceModel: defaultVideoModel \|\| "seedance-2\.0-pro" \}\)/);
assert.match(APP, /saveAiVideoPreferences\(project\.id, \{/);
assert.match(clickActions, /name === "focus-huobao-video-config"[\s\S]*focusAutomationHealthWorkspace\("integration-huobao-anchor"\)/);
@@ -1424,11 +1538,57 @@ test("discovery analysis actions focus the most relevant detail tab after succes
assert.match(APP, /function focusDiscoveryInsights\(\)/);
assert.match(APP, /function focusDiscoveryTopVideoInsights\(\)/);
assert.match(APP, /function focusDiscoveryRelations\(\)/);
+ assert.match(analyzeAccount, /safeArray\(result\.top_video_analyses\)\.length/);
+ assert.match(analyzeAccount, /appState\.topVideoAnalysisResults = \{/);
+ assert.match(analyzeAccount, /focusDiscoveryTopVideoInsights\(\)/);
assert.match(analyzeAccount, /focusDiscoveryInsights\(\)/);
assert.match(analyzeTopVideos, /focusDiscoveryTopVideoInsights\(\)/);
assert.match(similaritySearch, /focusDiscoveryRelations\(\)/);
});
+test("discovery relations section surfaces candidate provenance and overlap context", () => {
+ const relations = extractBetween(APP, "function renderDiscoveryRelationsSection(linkedAccounts, similarCandidates) {", "function renderDiscoveryScreen()");
+ assert.match(relations, /const searchContext = appState\.lastSimilaritySearch\?\.context \|\| \{\}/);
+ assert.match(relations, /const dimensions = candidate\.dimensions \|\| candidate\.dimensions_json \|\| \{\}/);
+ assert.match(relations, /dimensions\.source === "linked_account"/);
+ assert.match(relations, /dimensions\.source === "manual_url"/);
+ assert.match(relations, /source_overlap != null/);
+ assert.match(relations, /requirement_overlap != null/);
+ assert.match(relations, /最近这一轮会优先合并已绑关系、手动主页和本地账号池/);
+});
+
+test("similarity search state is isolated per selected account", () => {
+ const state = extractBetween(APP, "const appState = {", "};\n\nlet PLATFORM_RUNTIME");
+ const hydrateSource = extractBetween(APP, "async function hydrateWorkbenchDataAfterBootstrap(", "async function bootstrap()");
+ const loadPlatformAccountSource = extractBetween(APP, "async function loadPlatformAccount(", "async function hydrateWorkbenchDataAfterBootstrap(");
+ const markSavedCandidate = extractBetween(APP, "function markSavedCandidate(candidate, links) {", "async function saveCandidateAsBenchmark(candidateIndex, relationType = \"benchmark\") {");
+ const similaritySearch = extractBetween(APP, "function openSimilaritySearchAction()", "function openBenchmarkLinkAction(defaults = {})");
+ assert.match(state, /similaritySearchResultsByAccount: \{\}/);
+ assert.match(hydrateSource, /if \(!nextAccountId\) \{[\s\S]*appState\.lastSimilaritySearch = null;/);
+ assert.match(loadPlatformAccountSource, /appState\.lastSimilaritySearch = appState\.similaritySearchResultsByAccount\?\.\[accountId\] \|\| null;/);
+ assert.doesNotMatch(loadPlatformAccountSource, /appState\.similarSearchDetail/);
+ assert.match(markSavedCandidate, /appState\.similaritySearchResultsByAccount = \{/);
+ assert.match(similaritySearch, /appState\.similaritySearchResultsByAccount = \{/);
+ assert.match(similaritySearch, /\[account\.id\]: detail/);
+});
+
+test("logout clears account-scoped discovery caches", () => {
+ const logoutSource = extractBetween(APP, "async function logoutSession() {", "async function loadKnowledgeDocuments(");
+ assert.match(logoutSource, /appState\.lastSimilaritySearch = null;/);
+ assert.match(logoutSource, /appState\.similaritySearchResultsByAccount = \{\};/);
+ assert.match(logoutSource, /appState\.topVideoAnalysisResults = \{\};/);
+});
+
+test("account loading clears stale workbench detail before surfacing fetch failures", () => {
+ const loadPlatformAccountSource = extractBetween(APP, "async function loadPlatformAccount(", "async function hydrateWorkbenchDataAfterBootstrap(");
+ assert.match(loadPlatformAccountSource, /catch \(error\) \{[\s\S]*appState\.lastSimilaritySearch = null;/);
+ assert.match(loadPlatformAccountSource, /catch \(error\) \{[\s\S]*appState\.selectedWorkspace = null;/);
+ assert.match(loadPlatformAccountSource, /catch \(error\) \{[\s\S]*appState\.selectedVideos = \{ items: \[], meta: \{}, top_scored_video_ids: \[], latest_video_ids: \[], high_score_threshold: 60 \};/);
+ assert.match(loadPlatformAccountSource, /catch \(error\) \{[\s\S]*appState\.snapshots = \[];/);
+ assert.match(loadPlatformAccountSource, /catch \(error\) \{[\s\S]*appState\.analysisReports = \[];/);
+ assert.match(loadPlatformAccountSource, /throw error;/);
+});
+
test("tracking and benchmark actions land on the most relevant workbench area after success", () => {
const trackSelected = extractBetween(APP, "function openTrackSelectedAccountAction()", "function openImportVideoLinkAction()");
const saveCandidate = extractBetween(APP, "async function saveCandidateAsBenchmark(candidateIndex, relationType = \"benchmark\")", "function screenShell(title, subtitle, actionsHtml, bodyHtml)");