chore: sync storyforge handoff state
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled

This commit is contained in:
kris
2026-05-02 17:50:21 +08:00
parent 6f0d944a75
commit 65db3cd336
20 changed files with 3780 additions and 250 deletions

5
.gitignore vendored
View File

@@ -31,3 +31,8 @@ output/
# macOS / editors
.idea/
.vscode/
# Local agent/browser scratch state
.playwright-cli/
.superpowers/
.tmp-previews*/

View File

@@ -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"],

File diff suppressed because it is too large Load Diff

View File

@@ -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 = ?",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.

View File

@@ -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") =>
`<button class="btn btn-${tone}" data-action="${action}">${label}</button>`
};
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function renderTags(items, escapeHtml) {
return (items || []).map((item) => `<span class="tag">${escapeHtml(item)}</span>`).join("");
}
function renderSecondaryAction(item, index, escapeHtml) {
return `
<div class="dashboard-action-secondary">
<div class="dashboard-action-index">${index + 2}</div>
<div>
<h5>${escapeHtml(item.title)}</h5>
<p>${escapeHtml(item.reason)}</p>
</div>
<div class="dashboard-action-buttons">
<button class="btn btn-ghost" data-action="${escapeHtml(item.reasonAction || "open-action-reason")}">原因</button>
<button class="btn btn-secondary" data-action="${escapeHtml(item.goAction || "goto-production")}">${escapeHtml(item.goLabel || "去处理")}</button>
</div>
</div>
`;
}
function renderDashboardHome(model, helpers = {}) {
const escapeHtml = helpers.escapeHtml || defaultEscapeHtml;
return `
<div class="dashboard-home">
<div class="dashboard-context-row">
<div class="dashboard-context-left">
<div class="dashboard-context-chip">
<strong>当前工作区</strong><span>${escapeHtml(model.workspaceLabel)}</span>
</div>
<button class="dashboard-context-chip" data-action="open-dashboard-project-switcher">
<strong>当前项目</strong><span>${escapeHtml(model.currentProjectName)}</span>
</button>
</div>
<div class="dashboard-context-right">
${model.contextLinks.map((item) => `
<button class="dashboard-context-chip" data-action="${escapeHtml(item.action)}">
<span>${escapeHtml(item.label)}</span><strong>${escapeHtml(item.value)}</strong>
</button>
`).join("")}
</div>
</div>
<div class="panel pad dashboard-priority-panel">
<div class="panel-head">
<div>
<h3>今天先做什么</h3>
<div class="panel-subtitle">先做决定,再看细节。</div>
</div>
<span class="tag blue">${escapeHtml(model.actionSourceLabel)}</span>
</div>
<div class="dashboard-action-primary">
<div>
<h4>${escapeHtml(model.primaryAction.title)}</h4>
<p>${escapeHtml(model.primaryAction.reason)}</p>
<div class="task-meta">${renderTags(model.primaryAction.badges, escapeHtml)}</div>
</div>
<div class="dashboard-action-buttons">
<button class="btn btn-ghost" data-action="open-action-reason">查看原因</button>
<button class="btn btn-secondary" data-action="${escapeHtml(model.primaryAction.goAction)}">${escapeHtml(model.primaryAction.goLabel)}</button>
<button class="btn btn-primary" data-action="open-oneliner">${escapeHtml(model.primaryAction.agentLabel)}</button>
</div>
</div>
<div class="dashboard-action-secondary-list">
${model.secondaryActions.map((item, index) => renderSecondaryAction(item, index, escapeHtml)).join("")}
</div>
</div>
<div class="panel pad dashboard-overview-panel">
<div class="panel-head">
<div>
<h3>项目概览</h3>
<div class="panel-subtitle">按需展开,不抢首页第一优先级。</div>
</div>
<span class="tag">${escapeHtml(model.activeTabLabel)}</span>
</div>
<div class="dashboard-overview-tabs">
${model.summaryTabs.map((item) => `
<button class="dashboard-overview-tab ${item.active ? "is-active" : ""}" data-action="select-dashboard-tab" data-dashboard-tab="${escapeHtml(item.key)}">
<small>${escapeHtml(item.label)}</small>
<strong>${escapeHtml(item.value)}</strong>
<span>${escapeHtml(item.hint)}</span>
</button>
`).join("")}
</div>
<div class="dashboard-overview-body">${model.overviewBodyHtml}</div>
</div>
</div>
`;
}
window.StoryForgeDashboardHome = {
renderDashboardHome
};
})();
```
- [ ] **Step 4: Wire the new module into the page**
Modify `web/storyforge-web-v4/index.html`:
```html
<script src="./assets/storyforge-dashboard-home.js"></script>
<script src="./assets/app.js"></script>
```
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: "<section>tab body</section>"
});
assert.ok(html.includes('data-action="select-dashboard-tab"'));
assert.ok(!html.includes("当前项目推进详情"));
assert.ok(!html.includes("重点账号 / 对标</h3><div class=\"panel-subtitle\">右栏保留"));
});
```
- [ ] **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
<button class="nav-item hidden" data-screen-target="admin-workbench" data-role-gate="super_admin">
<span class="icon"></span>
<span>管理员配置台</span>
</button>
```
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"
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,29 +745,24 @@ 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,
),
)
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,
@@ -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)

File diff suppressed because it is too large Load Diff

View File

@@ -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`,

View File

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