chore: sync storyforge handoff state
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -31,3 +31,8 @@ output/
|
||||
# macOS / editors
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Local agent/browser scratch state
|
||||
.playwright-cli/
|
||||
.superpowers/
|
||||
.tmp-previews*/
|
||||
|
||||
@@ -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
@@ -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 = ?",
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
16
deploy/storyforge-fnos-cliproxy.compose.yaml
Normal file
16
deploy/storyforge-fnos-cliproxy.compose.yaml
Normal 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"
|
||||
25
deploy/storyforge-fnos-huobao.compose.yaml
Normal file
25
deploy/storyforge-fnos-huobao.compose.yaml
Normal 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
|
||||
21
deploy/storyforge-fnos-n8n.compose.yaml
Normal file
21
deploy/storyforge-fnos-n8n.compose.yaml
Normal 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"
|
||||
125
docs/NEXT_THREAD_HANDOFF_2026-05-02.md
Normal file
125
docs/NEXT_THREAD_HANDOFF_2026-05-02.md
Normal 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.
|
||||
692
docs/superpowers/plans/2026-03-28-homepage-workbench-redesign.md
Normal file
692
docs/superpowers/plans/2026-03-28-homepage-workbench-redesign.md
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
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"
|
||||
```
|
||||
53
scripts/deploy_fnos_storyforge_cliproxy.sh
Executable file
53
scripts/deploy_fnos_storyforge_cliproxy.sh
Executable 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"
|
||||
64
scripts/deploy_fnos_storyforge_huobao.sh
Executable file
64
scripts/deploy_fnos_storyforge_huobao.sh
Executable 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"
|
||||
@@ -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
|
||||
|
||||
52
scripts/deploy_fnos_storyforge_n8n.sh
Executable file
52
scripts/deploy_fnos_storyforge_n8n.sh
Executable 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"
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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)");
|
||||
|
||||
Reference in New Issue
Block a user