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: "
tab body
" + }); + + 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)}快照详情

+
快照、创作者中心字段和分析报告统一在这里看
${escapeHtml(formatNumber(snapshots.length))} 个快照 @@ -6236,7 +6437,7 @@ function renderDouyinInsightPanel() {

快照列表

-

点击任意快照可以切换右侧详情,便于比对公开页和 creator center 的变化。

+

点击任意快照可以切换右侧详情,便于比对公开页和创作者中心的变化。

${snapshots.map((snapshot) => `
@@ -6244,10 +6445,10 @@ function renderDouyinInsightPanel() {

${escapeHtml(brief(JSON.stringify(snapshot.summary || {}), 96))}

${escapeHtml(formatNumber(snapshot.field_count || 0))} 字段 - 查看详情 + 查看详情
- `).join("") || `

还没有快照

同步账号后,这里会自动出现 public profile 和 creator center 快照。

`} + `).join("") || `

还没有快照

同步账号后,这里会自动出现公开主页和创作者中心快照。

`}
@@ -6264,15 +6465,15 @@ function renderDouyinInsightPanel() {
-

Creator Fields

-

${escapeHtml(creatorFields ? brief(JSON.stringify(creatorSummary), 120) : "尚未拉取 creator center 字段")}

+

创作者中心字段

+

${escapeHtml(creatorFields ? brief(JSON.stringify(creatorSummary), 120) : "尚未拉取创作者中心字段")}

- ${creatorFields?.source_url ? `打开 creator center` : ""} + ${creatorFields?.source_url ? `打开创作者中心` : ""} ${creatorFields?.snapshot_type ? `${escapeHtml(creatorFields.snapshot_type)}` : ""} ${creatorFields?.field_count != null ? `${escapeHtml(formatNumber(creatorFields.field_count))} 字段` : ""}
- ${creatorSnapshotFields.length ? renderSnapshotFieldRows(creatorSnapshotFields, 6) : `

还没有 creator 字段

等 creator center 快照同步后,这里会展示字段明细。

`} + ${creatorSnapshotFields.length ? renderSnapshotFieldRows(creatorSnapshotFields, 6) : `

还没有创作者字段

等创作者中心快照同步后,这里会展示字段明细。

`}
@@ -6289,6 +6490,8 @@ function renderDouyinInsightPanel() {
${report.created_at ? `${escapeHtml(formatDateTime(report.created_at))}` : ""} ${suggestion?.model_label ? `${escapeHtml(suggestion.model_label)}` : ""} + ${report.model_profile_ids?.length ? `模型 ${escapeHtml(formatNumber(report.model_profile_ids.length))}` : ""} + ${report.linked_account_ids?.length ? `已绑对标 ${escapeHtml(formatNumber(report.linked_account_ids.length))}` : ""}
`; @@ -6300,9 +6503,14 @@ function renderDouyinInsightPanel() { `; } +function renderDouyinInsightPanel() { + return renderCreatorInsightPanel(); +} + async function openDouyinSnapshotDetailAction(snapshotId) { const selected = getSelectedAccount(); - if (!selected || getAccountPlatform(selected) !== "douyin") { + const platform = getAccountPlatform(selected); + if (!selected || !isWorkbenchPlatform(platform)) { return; } if (!snapshotId) { @@ -6310,7 +6518,8 @@ async function openDouyinSnapshotDetailAction(snapshotId) { } setBusy(true, "正在加载快照详情..."); try { - const detail = await storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(selected.id)}/snapshots/${encodeURIComponent(snapshotId)}`); + const detailPath = getWorkbenchRoute(platform, "snapshotDetail", selected.id, snapshotId); + const detail = await storyforgeFetch(detailPath); appState.selectedSnapshotId = snapshotId; appState.selectedSnapshotDetail = detail; rememberAction("快照已切换", `已打开 ${detail.snapshot_type || "snapshot"} 的完整详情。`, "green", detail); @@ -6714,7 +6923,7 @@ function focusDiscoveryInsights() { appState.screen = "discovery"; renderAll(); window.requestAnimationFrame(() => { - (document.getElementById("douyin-insight-anchor") || document.getElementById("selected-account-anchor")) + (document.getElementById("creator-insight-anchor") || document.getElementById("selected-account-anchor")) ?.scrollIntoView({ behavior: "smooth", block: "start" }); }); } @@ -6932,6 +7141,8 @@ function renderDiscoveryOverviewSection({ selected, selectedProject, importedSou } function renderDiscoveryRelationsSection(linkedAccounts, similarCandidates) { + const searchContext = appState.lastSimilaritySearch?.context || {}; + const candidateUrls = safeArray(searchContext.candidate_urls); return `
@@ -6954,20 +7165,38 @@ function renderDiscoveryRelationsSection(linkedAccounts, similarCandidates) {
-

最近相似候选

由 Agent 辅助生成
${escapeHtml(formatNumber(similarCandidates.length))} 个
+

最近相似候选

最近这一轮会优先合并已绑关系、手动主页和本地账号池。
${escapeHtml(formatNumber(similarCandidates.length))} 个
+
+ ${escapeHtml(searchContext.seed_linked_accounts === false ? "未纳入已绑关系" : "已纳入已绑关系")} + ${escapeHtml(searchContext.search_public_pages === false ? "未扩展公开页" : "允许扩展公开页")} + ${candidateUrls.length ? `手动主页 ${escapeHtml(formatNumber(candidateUrls.length))}` : ""} +
- ${similarCandidates.map((candidate, index) => ` -
-

${escapeHtml(candidate.candidate_nickname || candidate.candidate_profile_url || "候选账号")}

-

${escapeHtml(brief(candidate.rationale_text || "暂无理由", 96))}

-
- 启发分 ${escapeHtml(formatNumber(candidate.agent_score || candidate.heuristic_score || 0))} - ${candidate.candidate_account_id ? `看详情` : ""} - ${isCandidateLinked(candidate, linkedAccounts) || candidate.saved ? `已保存` : `存对标`} - ${candidate.candidate_profile_url ? `打开主页` : ""} + ${similarCandidates.map((candidate, index) => { + const dimensions = candidate.dimensions || candidate.dimensions_json || {}; + const sourceLabel = dimensions.source === "linked_account" + ? "已绑关系" + : dimensions.source === "manual_url" + ? "手动主页" + : dimensions.source === "local_account" + ? "本地账号" + : "候选来源"; + return ` +
+

${escapeHtml(candidate.candidate_nickname || candidate.candidate_profile_url || "候选账号")}

+

${escapeHtml(brief(candidate.rationale_text || "暂无理由", 96))}

+
+ 启发分 ${escapeHtml(formatNumber(candidate.agent_score || candidate.heuristic_score || 0))} + ${escapeHtml(sourceLabel)} + ${dimensions.source_overlap != null ? `标签重合 ${escapeHtml(formatNumber(dimensions.source_overlap || 0))}` : ""} + ${dimensions.requirement_overlap != null ? `要求命中 ${escapeHtml(formatNumber(dimensions.requirement_overlap || 0))}` : ""} + ${candidate.candidate_account_id ? `看详情` : ""} + ${isCandidateLinked(candidate, linkedAccounts) || candidate.saved ? `已保存` : `存对标`} + ${candidate.candidate_profile_url ? `打开主页` : ""} +
-
- `).join("") || `

还没有相似候选

先点“查相似”,这里会展示最近一轮结果。

`} + `; + }).join("") || `

还没有相似候选

先点“查相似”,这里会展示最近一轮结果。

`}
@@ -7066,13 +7295,14 @@ function renderDiscoveryScreen() { topVideoBatchResult }); } else if (activeTab === "snapshots") { - detailBodyHtml = renderDouyinInsightPanel(); + detailBodyHtml = renderCreatorInsightPanel(); } else { detailBodyHtml = renderDiscoveryRelationsSection(linkedAccounts, similarCandidates); } + const discoveryCreatorCenterActions = `${button("登录抖音创作者中心", "open-creator-center-sync", "secondary", { attrs: 'data-platform="douyin" data-sync-origin="discovery"' })} ${button("登录快手创作者中心", "open-creator-center-sync", "secondary", { attrs: 'data-platform="kuaishou" data-sync-origin="discovery"' })}`; const discoveryActionsHtml = isMobileUi - ? `${button("导入主页", "open-import-homepage")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: discoveryHandoffAttrs })} ${button(saveBenchmarkActionLabel, saveBenchmarkActionName, "primary")}` - : `${button("导入主页", "open-import-homepage")} ${button("导入当前对标", "direct-import-selected-account", "secondary")} ${button(tracked ? "更新跟踪" : "加入跟踪", "direct-track-selected-account", "secondary")} ${button("账号分析", "direct-analyze-selected-account", "secondary")} ${button("高分分析", "direct-analyze-top-videos", "secondary")} ${button("查相似", "direct-search-similar", "secondary")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: discoveryHandoffAttrs })} ${button(saveBenchmarkActionLabel, saveBenchmarkActionName, "primary")}`; + ? `${button("登录抖音创作者中心", "open-creator-center-sync", "primary", { attrs: 'data-platform="douyin" data-sync-origin="discovery"' })} ${button("登录快手创作者中心", "open-creator-center-sync", "secondary", { attrs: 'data-platform="kuaishou" data-sync-origin="discovery"' })} ${button(saveBenchmarkActionLabel, saveBenchmarkActionName, "primary")}` + : `${discoveryCreatorCenterActions} ${button("导入主页", "open-import-homepage")} ${button("导入当前对标", "direct-import-selected-account", "secondary")} ${button(tracked ? "更新跟踪" : "加入跟踪", "direct-track-selected-account", "secondary")} ${button("账号分析", "direct-analyze-selected-account", "secondary")} ${button("高分分析", "direct-analyze-top-videos", "secondary")} ${button("查相似", "direct-search-similar", "secondary")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: discoveryHandoffAttrs })} ${button(saveBenchmarkActionLabel, saveBenchmarkActionName, "primary")}`; return screenShell( "找对标", `这里已经接入真实${currentPlatformLabel}账号列表和单账号详情。`, @@ -7105,8 +7335,10 @@ function renderDiscoveryScreen() { 下一步先做 ${escapeHtml(getAccountName(selected) || "未选中")}
-

${escapeHtml(selected ? `先围绕 ${getAccountName(selected)} 做导入、分析和相似扩展。` : "先从账号池里选一个对象,再继续导入和分析。")}

+

${escapeHtml(selected ? `先围绕 ${getAccountName(selected)} 做导入、分析和相似扩展,或者直接接入抖音/快手创作者中心。` : "先从账号池里选一个对象,再继续导入、分析或接入创作者中心。")}

+ ${actionTag("接入抖音创作者中心", "open-creator-center-sync", 'data-platform="douyin" data-sync-origin="discovery"')} + ${actionTag("接入快手创作者中心", "open-creator-center-sync", 'data-platform="kuaishou" data-sync-origin="discovery"')} ${actionTag("导入当前对标", "direct-import-selected-account")} ${actionTag("账号分析", "direct-analyze-selected-account")} ${actionTag("查相似", "direct-search-similar")} @@ -7449,6 +7681,9 @@ function renderOwnedScreen() { const jobs = safeArray(appState.dashboard.recent_jobs); const completedJobs = jobs.filter((item) => item.status === "completed").length; const activeJobs = jobs.filter((item) => item.status !== "completed").length; + const selectedWorkbenchAccount = getSelectedAccount(); + const creatorCenterHandoffAction = selectedWorkbenchAccount?.id ? "goto-discovery" : "goto-tracking"; + const creatorCenterHandoffLabel = selectedWorkbenchAccount?.id ? "去找对标看快照" : "去跟踪账号"; return screenShell( "我的账号", "这里先用当前登录账号和最近产出组合成第一版总览。", @@ -7513,6 +7748,15 @@ function renderOwnedScreen() { ${actionTag("去 Agent", "goto-playbook")}
+
+

创作者中心账号分析

+

${escapeHtml(selectedWorkbenchAccount?.id ? `当前可直接围绕 ${getAccountName(selectedWorkbenchAccount)} 同步创作者中心快照,然后回到找对标继续看字段、快照和账号分析。` : "先登录抖音或快手创作者中心,同步账号快照后再继续做账号分析。")}

+
+ ${actionTag("登录抖音创作者中心", "open-creator-center-sync", 'data-platform="douyin" data-sync-origin="owned"')} + ${actionTag("登录快手创作者中心", "open-creator-center-sync", 'data-platform="kuaishou" data-sync-origin="owned"')} + ${actionTag(creatorCenterHandoffLabel, creatorCenterHandoffAction)} +
+
@@ -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)");