feat: harden storyforge runtime and repo boundary

This commit is contained in:
kris
2026-03-26 09:08:41 +08:00
parent fa9d6dda09
commit dd619448e7
33 changed files with 1028 additions and 316 deletions

View File

@@ -6,11 +6,15 @@ LOCAL_OPENAI_API_KEY=
N8N_BASE_URL=http://127.0.0.1:5670
# Dockerized collector should use the internal n8n service address.
COLLECTOR_N8N_BASE_URL=http://n8n:5678
BOOTSTRAP_SUPERADMIN_USERNAME=storyforge-admin
BOOTSTRAP_SUPERADMIN_PASSWORD=__set_a_strong_password__
BOOTSTRAP_SUPERADMIN_DISPLAY_NAME=StoryForge Admin
N8N_ANALYSIS_WEBHOOK_PATH=/webhook/storyforge-analysis
N8N_REAL_CUT_WEBHOOK_PATH=/webhook/storyforge-real-cut
N8N_AI_VIDEO_WEBHOOK_PATH=/webhook/storyforge-ai-video
N8N_CONTENT_SOURCE_SYNC_WEBHOOK_PATH=/webhook/storyforge-content-source-sync
ORCHESTRATOR_SHARED_SECRET=storyforge-local-secret
ORCHESTRATOR_SHARED_SECRET=__set_a_strong_shared_secret__
STORYFORGE_INTERNAL_BASE_URL=http://collector:8081
CUTVIDEO_BASE_URL=
CUTVIDEO_API_KEY=
CUTVIDEO_BASE_CONFIG=example.job.yaml

View File

@@ -2,6 +2,8 @@
StoryForge 现在拆成独立项目目录,和 `AI-glasses` 分开维护。
仓库边界和维护约束见:[StoryForge 仓库边界说明](./docs/STORYFORGE_REPO_BOUNDARY_2026-03-26.md)。
## 目录
- `android-app/`StoryForge Android 客户端
@@ -77,8 +79,8 @@ npx playwright install chromium
cd /Users/kris/code/StoryForge-gitea/scripts/douyin-browser-capture
npm run capture -- \
--profile-url https://www.douyin.com/user/your_account \
--storyforge-username kris \
--storyforge-password 'Asd123456.'
--storyforge-username storyforge-admin \
--storyforge-password 'your_admin_password'
```
说明:
@@ -107,6 +109,14 @@ cp .env.example .env
docker compose up -d --build
```
首次启动前,至少补齐这些配置:
```bash
ORCHESTRATOR_SHARED_SECRET=your_strong_shared_secret
BOOTSTRAP_SUPERADMIN_USERNAME=storyforge-admin
BOOTSTRAP_SUPERADMIN_PASSWORD=your_strong_admin_password
```
如果要让本机模型网关 `cli-proxy-api` 自动提供 `GLM-5`,建议在启动前确保本机环境里存在:
```bash
@@ -134,10 +144,9 @@ N8N_BASE_URL=http://127.0.0.1:5670
- `cli-proxy-api``http://127.0.0.1:8317`
- 公网入口:`https://storyforge.hyzq.net/`
默认会创建最高权限账号:
- `kris`
- `Asd123456.`
首次启动时,如果数据库里还没有 `super_admin``collector-service` 会按
`BOOTSTRAP_SUPERADMIN_USERNAME / BOOTSTRAP_SUPERADMIN_PASSWORD / BOOTSTRAP_SUPERADMIN_DISPLAY_NAME`
创建最高权限账号。未配置时不会再自动写入默认口令账号。
## 当前架构

View File

@@ -1,37 +1,28 @@
# AI Glasses Android App
# StoryForge Android App
Demo Android client for backend API validation and BLE integration scaffold.
StoryForge Android client for the current workspace entry: authentication, content import, agent management, production tracking, and OTA install.
## What is implemented
## Current flow
- Backend API calls:
- `bind-confirm`
- `create session`
- `stop session`
- `device status`
- Compose UI for debug flow
- Hichips BLE protocol manager:
- service/char: `3D20(3D21/3D22/3D23)`, `5DC0(5DC1/5DC2/5DC3)`
- packet codec: `HICH + Command + Index + Length + CRC16 + Data + IPSE`
- handshake flow (`AG_CMD_HS_DEV_UUID` -> `AG_CMD_HS_APP_UUID` -> `AG_CMD_HS_DEV_INFO`)
- wake-up audio uplink (`ASR_*` commands, audio from `5DC2`)
- camera trigger (`AG_CMD_P_TAKE_START`) and thumbnail events
- New "开始对话(硬件)" button:
- BLE scan/connect -> handshake -> backend bind/create session
- start wake-up audio stream + periodic camera capture
- app reports aggregated audio/camera relay stats to backend events
- Compose-based StoryForge shell
- Secure session storage for base URL and token
- Backend API calls for login, project/content import, agent management, and update checks
- Local video picking for learning tasks
- OTA download and install from the "我的" tab
## Default backend
The app is hardcoded to:
The app defaults to:
`https://storyforge.hyzq.net`
For local development, cleartext HTTP is only allowed for `localhost`, `127.0.0.1`, and `10.0.2.2`.
## Build APK
Open this folder in Android Studio:
`/Users/kris/code/AI-glasses/android-app`
`/Users/kris/code/StoryForge-gitea/android-app`
Then run:

View File

@@ -54,9 +54,9 @@ dependencies {
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.activity:activity-compose:1.10.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.compose.ui:ui")

View File

@@ -2,14 +2,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
@@ -19,7 +11,6 @@
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@android:drawable/sym_def_app_icon"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.AIGlasses">
<activity
android:name=".MainActivity"

View File

@@ -1,5 +1,6 @@
package com.aiglasses.app.data
import com.aiglasses.app.BuildConfig
import kotlinx.serialization.json.Json
import kotlinx.serialization.ExperimentalSerializationApi
import java.util.concurrent.TimeUnit
@@ -21,7 +22,7 @@ object ApiClient {
inline fun <reified T : Any> createService(baseUrl: String): T {
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC else HttpLoggingInterceptor.Level.NONE
}
val client = OkHttpClient.Builder()
.protocols(listOf(Protocol.HTTP_1_1))

View File

@@ -259,7 +259,7 @@ class StoryForgeRepository(private val context: Context) {
private fun buildClient(connection: StoryForgeConnectionInfo, token: String): OkHttpClient {
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC else HttpLoggingInterceptor.Level.NONE
}
return OkHttpClient.Builder()
.protocols(listOf(Protocol.HTTP_1_1))

View File

@@ -41,7 +41,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -185,7 +188,9 @@ private fun AuthScreen(
onValueChange = vm::updatePassword,
modifier = Modifier.fillMaxWidth(),
label = { Text("密码") },
singleLine = true
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
Button(
onClick = { if (state.authMode == StoryForgeAuthMode.Login) vm.login() else vm.registerAccount() },
@@ -857,7 +862,9 @@ private fun MineTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onInstall
onValueChange = vm::updateNewModelApiKey,
modifier = Modifier.fillMaxWidth(),
label = { Text("API Key") },
minLines = 2
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = vm::createModelProfile) {

View File

@@ -1,6 +1,8 @@
package com.aiglasses.app.storyforge
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.aiglasses.app.BuildConfig
data class SavedStoryForgeSession(
@@ -9,7 +11,13 @@ data class SavedStoryForgeSession(
)
class StoryForgeSessionStore(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val appContext = context.applicationContext
private val legacyPrefs = appContext.getSharedPreferences(PREFS_NAME_LEGACY, Context.MODE_PRIVATE)
private val prefs: android.content.SharedPreferences by lazy { createSecurePrefs() }
init {
migrateLegacySessionIfNeeded()
}
fun load(): SavedStoryForgeSession = SavedStoryForgeSession(
baseUrl = migrateBaseUrl(prefs.getString(KEY_BASE_URL, BuildConfig.DEFAULT_STORYFORGE_BASE_URL).orEmpty()),
@@ -37,10 +45,12 @@ class StoryForgeSessionStore(context: Context) {
fun clearAll() {
prefs.edit().remove(KEY_BASE_URL).remove(KEY_TOKEN).apply()
legacyPrefs.edit().remove(KEY_BASE_URL).remove(KEY_TOKEN).apply()
}
private companion object {
private const val PREFS_NAME = "storyforge_session"
private const val PREFS_NAME_LEGACY = "storyforge_session"
private const val PREFS_NAME_SECURE = "storyforge_session_secure"
private const val KEY_BASE_URL = "base_url"
private const val KEY_TOKEN = "token"
private const val LEGACY_DOMAIN_URL = "http://test.hyzq.net:8081"
@@ -48,6 +58,38 @@ class StoryForgeSessionStore(context: Context) {
private const val LEGACY_IP_URL = "http://111.231.132.51:8081"
}
private fun createSecurePrefs(): android.content.SharedPreferences {
return runCatching {
val masterKey = MasterKey.Builder(appContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
appContext,
PREFS_NAME_SECURE,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}.getOrElse {
throw IllegalStateException("Unable to create secure session storage", it)
}
}
private fun migrateLegacySessionIfNeeded() {
if (prefs.contains(KEY_BASE_URL) || prefs.contains(KEY_TOKEN)) {
return
}
if (!legacyPrefs.contains(KEY_BASE_URL) && !legacyPrefs.contains(KEY_TOKEN)) {
return
}
val baseUrl = migrateBaseUrl(
legacyPrefs.getString(KEY_BASE_URL, BuildConfig.DEFAULT_STORYFORGE_BASE_URL).orEmpty()
)
val token = legacyPrefs.getString(KEY_TOKEN, "").orEmpty()
save(baseUrl, token)
legacyPrefs.edit().remove(KEY_BASE_URL).remove(KEY_TOKEN).apply()
}
private fun migrateBaseUrl(baseUrl: String): String {
val trimmed = baseUrl.trim()
return when {

View File

@@ -349,6 +349,7 @@ class StoryForgeViewModel(application: Application) : AndroidViewModel(applicati
appendTimeline("账号 ${account.username} 已注册,等待主管理员审批")
_state.value = _state.value.copy(
authMode = StoryForgeAuthMode.Login,
password = "",
statusMessage = "注册成功,请等待主管理员审批",
errorMessage = ""
)
@@ -375,6 +376,7 @@ class StoryForgeViewModel(application: Application) : AndroidViewModel(applicati
isAuthenticated = true,
isApproved = account.approval_status == "approved",
account = account,
password = "",
statusMessage = if (account.approval_status == "approved") "登录成功,正在同步工作台" else "账号待主管理员审批",
errorMessage = ""
)

View File

@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">10.0.2.2</domain>
</domain-config>
</network-security-config>

View File

@@ -19,7 +19,7 @@ from urllib.parse import quote, urljoin, urlparse
from fastapi import Body, Depends, FastAPI, File, Form, Header, HTTPException, Query, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
from .database import Database, utc_now
@@ -50,6 +50,9 @@ N8N_REAL_CUT_WEBHOOK_PATH = os.getenv("N8N_REAL_CUT_WEBHOOK_PATH", "/webhook/sto
N8N_AI_VIDEO_WEBHOOK_PATH = os.getenv("N8N_AI_VIDEO_WEBHOOK_PATH", "/webhook/storyforge-ai-video")
N8N_CONTENT_SOURCE_SYNC_WEBHOOK_PATH = os.getenv("N8N_CONTENT_SOURCE_SYNC_WEBHOOK_PATH", "/webhook/storyforge-content-source-sync")
ORCHESTRATOR_SHARED_SECRET = os.getenv("ORCHESTRATOR_SHARED_SECRET", "")
BOOTSTRAP_SUPERADMIN_USERNAME = os.getenv("BOOTSTRAP_SUPERADMIN_USERNAME", "")
BOOTSTRAP_SUPERADMIN_PASSWORD = os.getenv("BOOTSTRAP_SUPERADMIN_PASSWORD", "")
BOOTSTRAP_SUPERADMIN_DISPLAY_NAME = os.getenv("BOOTSTRAP_SUPERADMIN_DISPLAY_NAME", "StoryForge Admin")
CUTVIDEO_BASE_URL = os.getenv("CUTVIDEO_BASE_URL", "http://192.168.31.18:7860")
CUTVIDEO_API_KEY = os.getenv("CUTVIDEO_API_KEY", "")
HUOBAO_BASE_URL = os.getenv("HUOBAO_BASE_URL", "http://127.0.0.1:5678")
@@ -61,6 +64,16 @@ CUTVIDEO_UPLOAD_TIMEOUT_SEC = int(os.getenv("CUTVIDEO_UPLOAD_TIMEOUT_SEC", "1800
HUOBAO_POLL_INTERVAL_SEC = int(os.getenv("HUOBAO_POLL_INTERVAL_SEC", "10"))
HUOBAO_MAX_WAIT_SEC = int(os.getenv("HUOBAO_MAX_WAIT_SEC", "900"))
INVALID_CONFIG_VALUES = {
"",
"__set_a_strong_shared_secret__",
"__set_me__",
"__change_me__",
"change-me",
"changeme",
"storyforge-local-secret",
}
for path in (DATA_DIR, DOWNLOADS_DIR, JOBS_DIR, MODELS_DIR):
path.mkdir(parents=True, exist_ok=True)
@@ -355,6 +368,37 @@ def mask_api_key(value: str) -> str:
return f"{value[:4]}***{value[-4:]}"
def normalize_config_value(value: str | None) -> str:
return str(value or "").strip()
def is_placeholder_config(value: str | None) -> bool:
normalized = normalize_config_value(value)
lowered = normalized.lower()
if lowered in INVALID_CONFIG_VALUES:
return True
if lowered.startswith("__set_") and lowered.endswith("__"):
return True
return False
def orchestrator_secret_configured() -> bool:
return not is_placeholder_config(ORCHESTRATOR_SHARED_SECRET)
def bootstrap_superadmin_credentials() -> tuple[str, str, str]:
return (
normalize_config_value(BOOTSTRAP_SUPERADMIN_USERNAME),
normalize_config_value(BOOTSTRAP_SUPERADMIN_PASSWORD),
normalize_config_value(BOOTSTRAP_SUPERADMIN_DISPLAY_NAME) or "StoryForge Admin",
)
def bootstrap_superadmin_configured() -> bool:
username, password, _ = bootstrap_superadmin_credentials()
return bool(username) and not is_placeholder_config(password)
def normalize_model_profile(row: dict[str, Any]) -> dict[str, Any]:
return {
"id": row["id"],
@@ -1754,7 +1798,9 @@ def require_super_admin(account: dict[str, Any] = Depends(require_auth)) -> dict
def require_orchestrator(x_orchestrator_secret: str | None = Header(default=None)) -> bool:
if ORCHESTRATOR_SHARED_SECRET and x_orchestrator_secret != ORCHESTRATOR_SHARED_SECRET:
if not orchestrator_secret_configured():
raise HTTPException(status_code=503, detail="Orchestrator secret is not configured")
if x_orchestrator_secret != ORCHESTRATOR_SHARED_SECRET:
raise HTTPException(status_code=401, detail="Invalid orchestrator secret")
return True
@@ -2478,7 +2524,7 @@ def create_job_record(
async def wait_for_huobao_image(image_id: str | int) -> dict[str, Any]:
deadline = now_ts() + CUTVIDEO_MAX_WAIT_SEC
deadline = now_ts() + HUOBAO_MAX_WAIT_SEC
last_payload: dict[str, Any] = {}
while True:
last_payload = await huobao_client.get_image(str(image_id))
@@ -2753,6 +2799,52 @@ def on_startup() -> None:
seed_defaults()
def collect_readiness() -> dict[str, Any]:
checks: dict[str, Any] = {}
ready = True
try:
db_ok = bool(db.fetch_one("SELECT 1 AS ok"))
checks["database"] = {"ok": db_ok}
ready = ready and db_ok
except Exception as exc:
checks["database"] = {"ok": False, "error": str(exc)}
ready = False
required_tables = ["accounts", "projects", "jobs", "model_profiles"]
missing_tables = [name for name in required_tables if not db.table_exists(name)]
checks["schema"] = {"ok": not missing_tables, "missing_tables": missing_tables}
ready = ready and not missing_tables
missing_dirs = [str(path) for path in (DATA_DIR, DOWNLOADS_DIR, JOBS_DIR, MODELS_DIR) if not path.exists()]
checks["storage"] = {"ok": not missing_dirs, "missing_dirs": missing_dirs}
ready = ready and not missing_dirs
try:
row = db.fetch_one("SELECT COUNT(*) AS count FROM accounts WHERE role = ?", ("super_admin",))
super_admin_count = int((row or {}).get("count") or 0)
except Exception as exc:
checks["super_admin"] = {"ok": False, "error": str(exc)}
ready = False
else:
checks["super_admin"] = {
"ok": super_admin_count > 0 or bootstrap_superadmin_configured(),
"present_count": super_admin_count,
"bootstrap_configured": bootstrap_superadmin_configured(),
}
ready = ready and checks["super_admin"]["ok"]
checks["orchestrator_secret"] = {"ok": orchestrator_secret_configured()}
ready = ready and checks["orchestrator_secret"]["ok"]
checks["n8n"] = {
"required_for_async_pipelines": bool(N8N_BASE_URL),
**probe_http(N8N_BASE_URL, "/healthz", timeout=3.0),
}
return {"status": "ok" if ready else "degraded", "ready": ready, "checks": checks}
def probe_tcp(url: str, timeout: float = 3.0) -> dict[str, Any]:
if not url:
return {"configured": False, "reachable": False, "status_code": 0, "error": "not_configured", "url": ""}
@@ -2889,9 +2981,17 @@ def healthz() -> dict[str, Any]:
"cutvideoUploadTimeoutSec": CUTVIDEO_UPLOAD_TIMEOUT_SEC,
"huobaoBaseUrl": HUOBAO_BASE_URL,
"liveRecorderBaseUrl": LIVE_RECORDER_BASE_URL,
"orchestratorSecretConfigured": orchestrator_secret_configured(),
"bootstrapSuperadminConfigured": bootstrap_superadmin_configured(),
}
@app.get("/readyz")
def readyz() -> JSONResponse:
payload = collect_readiness()
return JSONResponse(status_code=200 if payload["ready"] else 503, content=payload)
@app.get("/v2/integrations/health")
def integrations_health(account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
_ = account
@@ -3234,9 +3334,13 @@ def seed_defaults() -> None:
now,
),
)
if not db.fetch_one("SELECT id FROM accounts WHERE username = ?", ("kris",)):
bootstrap_username, bootstrap_password, bootstrap_display_name = bootstrap_superadmin_credentials()
if not db.fetch_one("SELECT id FROM accounts WHERE role = 'super_admin' LIMIT 1") and bootstrap_username:
if is_placeholder_config(bootstrap_password):
print("StoryForge bootstrap skipped: BOOTSTRAP_SUPERADMIN_PASSWORD is not set to a non-placeholder value.")
return
account_id = make_id("acct")
password_hash, password_salt = create_password_hash("Asd123456.")
password_hash, password_salt = create_password_hash(bootstrap_password)
now = utc_now()
model_row = db.fetch_one("SELECT id FROM model_profiles WHERE is_default = 1 LIMIT 1")
db.execute(
@@ -3249,10 +3353,10 @@ def seed_defaults() -> None:
""",
(
account_id,
"kris",
bootstrap_username,
password_hash,
password_salt,
"Kris",
bootstrap_display_name,
"super_admin",
"approved",
account_id,
@@ -3262,8 +3366,8 @@ def seed_defaults() -> None:
now,
),
)
project = ensure_default_project(account_id, username="kris")
kb = ensure_user_kb(account_id, project["id"], username="kris")
project = ensure_default_project(account_id, username=bootstrap_username)
kb = ensure_user_kb(account_id, project["id"], username=bootstrap_username)
assistant_id = make_id("assistant")
db.execute(
"""
@@ -3287,6 +3391,7 @@ def seed_defaults() -> None:
"INSERT INTO assistant_knowledge_bases (assistant_id, knowledge_base_id) VALUES (?, ?)",
(assistant_id, kb["id"]),
)
print(f"StoryForge bootstrap: created super_admin account '{bootstrap_username}'.")
@app.post("/v2/auth/register")
@@ -4463,7 +4568,7 @@ async def internal_real_cut_run(
result={"cutvideo_submit": submit_result},
)
deadline = now_ts() + HUOBAO_MAX_WAIT_SEC
deadline = now_ts() + CUTVIDEO_MAX_WAIT_SEC
while True:
run_fallback: dict[str, Any] | None = None
try:

View File

@@ -15,6 +15,10 @@ Environment=DEFAULT_EXTERNAL_BASE_URL=https://storyforge.hyzq.net
Environment=LOCAL_OPENAI_BASE_URL=http://127.0.0.1:18317/v1
Environment=ASR_HTTP_BASE_URL=http://127.0.0.1:18088
Environment=N8N_BASE_URL=http://127.0.0.1:15670
Environment=ORCHESTRATOR_SHARED_SECRET=__set_a_strong_shared_secret__
Environment=BOOTSTRAP_SUPERADMIN_USERNAME=storyforge-admin
Environment=BOOTSTRAP_SUPERADMIN_PASSWORD=__set_a_strong_password__
Environment=BOOTSTRAP_SUPERADMIN_DISPLAY_NAME=StoryForge Admin
Environment=HUOBAO_BASE_URL=http://127.0.0.1:15678
Environment=CUTVIDEO_BASE_URL=http://127.0.0.1:17860
Environment=LIVE_RECORDER_BASE_URL=http://127.0.0.1:19106

View File

@@ -8,6 +8,8 @@ services:
N8N_PORT: 5678
N8N_PROTOCOL: ${N8N_PROTOCOL:-http}
WEBHOOK_URL: ${WEBHOOK_URL:-http://127.0.0.1:5670/}
STORYFORGE_INTERNAL_BASE_URL: ${STORYFORGE_INTERNAL_BASE_URL:-http://collector:8081}
STORYFORGE_ORCHESTRATOR_SECRET: ${ORCHESTRATOR_SHARED_SECRET:-__set_a_strong_shared_secret__}
GENERIC_TIMEZONE: ${GENERIC_TIMEZONE:-Asia/Shanghai}
TZ: ${TZ:-Asia/Shanghai}
N8N_SECURE_COOKIE: ${N8N_SECURE_COOKIE:-false}
@@ -37,7 +39,10 @@ services:
N8N_REAL_CUT_WEBHOOK_PATH: ${N8N_REAL_CUT_WEBHOOK_PATH:-/webhook/storyforge-real-cut}
N8N_AI_VIDEO_WEBHOOK_PATH: ${N8N_AI_VIDEO_WEBHOOK_PATH:-/webhook/storyforge-ai-video}
N8N_CONTENT_SOURCE_SYNC_WEBHOOK_PATH: ${N8N_CONTENT_SOURCE_SYNC_WEBHOOK_PATH:-/webhook/storyforge-content-source-sync}
ORCHESTRATOR_SHARED_SECRET: ${ORCHESTRATOR_SHARED_SECRET:-storyforge-local-secret}
BOOTSTRAP_SUPERADMIN_USERNAME: ${BOOTSTRAP_SUPERADMIN_USERNAME:-}
BOOTSTRAP_SUPERADMIN_PASSWORD: ${BOOTSTRAP_SUPERADMIN_PASSWORD:-}
BOOTSTRAP_SUPERADMIN_DISPLAY_NAME: ${BOOTSTRAP_SUPERADMIN_DISPLAY_NAME:-StoryForge Admin}
ORCHESTRATOR_SHARED_SECRET: ${ORCHESTRATOR_SHARED_SECRET:-__set_a_strong_shared_secret__}
CUTVIDEO_BASE_URL: ${CUTVIDEO_BASE_URL:-}
CUTVIDEO_API_KEY: ${CUTVIDEO_API_KEY:-}
CUTVIDEO_BASE_CONFIG: ${CUTVIDEO_BASE_CONFIG:-example.job.yaml}

View File

@@ -15,7 +15,10 @@ cp .env.example .env
- `N8N_BASE_URL=http://127.0.0.1:5670`,用于你在宿主机单独运行 `collector-service`
- `COLLECTOR_N8N_BASE_URL=http://n8n:5678`,用于 Docker 里的 `collector`
- `ORCHESTRATOR_SHARED_SECRET=storyforge-local-secret`
- `ORCHESTRATOR_SHARED_SECRET=your_strong_shared_secret`
- `BOOTSTRAP_SUPERADMIN_USERNAME=storyforge-admin`
- `BOOTSTRAP_SUPERADMIN_PASSWORD=your_strong_admin_password`
- `STORYFORGE_INTERNAL_BASE_URL=http://collector:8081`,用于 Docker 内的 n8n 回调 `collector`
- `CUTVIDEO_BASE_URL=http://<windows-lan-ip>:7860`
- `CUTVIDEO_API_KEY=` 如果 Windows 服务启用了鉴权
- `HUOBAO_BASE_URL=http://127.0.0.1:5678`
@@ -58,17 +61,18 @@ docker compose up -d --build
导入后:
- 检查每个 HTTP Request 节点的 `X-Orchestrator-Secret`
- 如果你改了 `.env` 的 secret这里必须同步
- 确认 n8n 运行环境里有 `STORYFORGE_INTERNAL_BASE_URL`
- 确认 n8n 运行环境里有 `STORYFORGE_ORCHESTRATOR_SECRET`
- 导入后的 HTTP Request 节点应从环境变量取值,不需要再逐个手改 secret
## 4. 登录与审批
默认超级管理员:
首次启动前请先在 `.env` 或运行环境里设置 bootstrap 管理员:
- 用户名:`kris`
- 密码:`Asd123456.`
- 用户名:`BOOTSTRAP_SUPERADMIN_USERNAME`
- 密码:`BOOTSTRAP_SUPERADMIN_PASSWORD`
新用户注册后,需要超级管理员审批。
首次启动后,用这组账号登录;新用户注册后,仍然需要超级管理员审批。
## 5. 内容分析链路验证
@@ -190,8 +194,8 @@ http://127.0.0.1:3618
cd /Users/kris/code/StoryForge-gitea/scripts/douyin-browser-capture
npm run capture -- \
--profile-url https://www.douyin.com/user/your_account \
--storyforge-username kris \
--storyforge-password 'Asd123456.'
--storyforge-username storyforge-admin \
--storyforge-password 'your_admin_password'
```
说明:

View File

@@ -0,0 +1,47 @@
# StoryForge 仓库边界说明
本文档用于固定 `StoryForge-gitea` 的维护边界,避免把 StoryForge 与 `AI Glasses` 误判成同一个项目。
## 基本原则
- `StoryForge``AI Glasses` 是两个独立项目,分别独立维护。
- 当前仓库只负责 `StoryForge` 的产品、运行时、联调、部署与发布。
- 后续在本仓库中看到的 `AI Glasses` 命名残留,应优先视为历史迁移残留或暂未完成的命名收口,不应直接推导为“需要删除 AI Glasses 项目代码”。
## 当前仓库内属于 StoryForge 的主维护范围
- `collector-service/`StoryForge 后端与业务 API。
- `web/storyforge-web-v4/`StoryForge Web 工作台和前端壳。
- `scripts/douyin-browser-capture/`:抖音浏览器辅助采集与工作台控制台。
- `n8n/`StoryForge 编排工作流导出与说明。
- `android-app/`:当前 StoryForge Android 客户端入口。
- `deploy/`StoryForge 部署模板与网关配置。
- `docs/`StoryForge 审计、联调、实施与产品逻辑文档。
- `docker-compose.yml``.env.example``scripts/start_business.sh``scripts/status_business.sh``scripts/smoke_business.sh`:当前 StoryForge 运行与联调基线。
## 需要特别注意的命名残留
以下内容说明 Android 客户端曾沿用旧命名空间,但当前业务入口已经是 StoryForge
- `android-app/app/src/main/java/com/aiglasses/app/`Android 包名仍是 `com.aiglasses.app`
- `android-app/app/src/main/java/com/aiglasses/app/MainActivity.kt`:入口已经直接加载 `StoryForgeScreen``StoryForgeViewModel`
- `android-app/app/src/main/res/values/themes.xml`:主题名仍为 `Theme.AIGlasses`
- `android-app/app/build.gradle.kts`:构建命名空间仍与 `com.aiglasses.*` 保持一致。
这些文件目前应被视为 StoryForge Android 客户端的迁移残留,不属于“删除 AI Glasses 项目代码”的操作范围。若未来要统一命名,应作为独立重构任务推进,而不是在日常功能开发中顺手清除。
## 提交与同步边界
- 提交到 Gitea 时,只纳入与 StoryForge 独立维护直接相关的改动。
- 原型、概念稿、临时预览图等目录只有在明确属于本轮 StoryForge 任务时才纳入提交。
- 本轮同步明确排除以下无关本次目标的本地变更:
- `concepts/studio-workbench/README.md`
- `.tmp-previews-b/`
## 本轮独立维护改动的收口范围
- 后端与部署安全收口:去掉默认超级管理员口令依赖,强化 orchestrator secret 校验,新增 `readyz`,修复 `huobao/cutvideo` 超时串线。
- n8n 工作流收口:内部回调地址与 secret 改为环境变量注入。
- Web 稳定性与结构收口:修账号切换竞态,收紧会话存储,引入平台能力 gate并拆出首批运行时模块。
- Android 安全收口:会话加密存储、明文流量白名单、敏感输入遮罩、日志级别收紧。
- 基线验证:新增 `scripts/check_repo_baseline.sh` 作为统一回归入口。

View File

@@ -11,17 +11,20 @@
## 约定
- 当前这套 live 链路默认通过 `http://host.docker.internal:8081` 调用宿主机 collector-service
- 如果整套流程完全运行在 Docker 内部,再把工作流里的内部调用地址切回 `http://collector:8081`
- 内部调用头部使用 `X-Orchestrator-Secret: storyforge-local-secret`
- 如果你修改了 `.env` 里的 `ORCHESTRATOR_SHARED_SECRET`,导入工作流后需要同步更新对应 HTTP Request 节点
- 工作流内部调用地址通过 `STORYFORGE_INTERNAL_BASE_URL` 注入
- 工作流内部调用密钥通过 `STORYFORGE_ORCHESTRATOR_SECRET` 注入
- Docker Compose 默认把它们映射成:
- `STORYFORGE_INTERNAL_BASE_URL=http://collector:8081`
- `STORYFORGE_ORCHESTRATOR_SECRET=$ORCHESTRATOR_SHARED_SECRET`
- 如果你的 n8n 跑在宿主机或云服务器,而 collector 跑在别处,只需要改这两个环境变量,不要再手改 workflow JSON
## 导入
1. 先执行 `docker compose up -d n8n collector`
2. 打开 `http://127.0.0.1:5670`
3. 从 UI 导入本目录下的 4 个 JSON
4. 激活工作流
4. 确认 n8n 运行环境里已设置 `STORYFORGE_INTERNAL_BASE_URL``STORYFORGE_ORCHESTRATOR_SECRET`
5. 激活工作流
## Webhook 路径

View File

@@ -21,13 +21,13 @@
{
"parameters": {
"method": "POST",
"url": "={{'http://host.docker.internal:8081/internal/jobs/steps/ai-video/render?job_id=' + ($json.body.job_id || $json.body.jobId)}}",
"url": "={{($env.STORYFORGE_INTERNAL_BASE_URL || 'http://collector:8081').replace(/\\/$/, '') + '/internal/jobs/steps/ai-video/render?job_id=' + ($json.body.job_id || $json.body.jobId)}}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Orchestrator-Secret",
"value": "storyforge-local-secret"
"value": "={{$env.STORYFORGE_ORCHESTRATOR_SECRET || ''}}"
}
]
},

View File

@@ -21,13 +21,13 @@
{
"parameters": {
"method": "POST",
"url": "={{'http://host.docker.internal:8081/internal/jobs/steps/analyze?job_id=' + ($json.body.job_id || $json.body.jobId)}}",
"url": "={{($env.STORYFORGE_INTERNAL_BASE_URL || 'http://collector:8081').replace(/\\/$/, '') + '/internal/jobs/steps/analyze?job_id=' + ($json.body.job_id || $json.body.jobId)}}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Orchestrator-Secret",
"value": "storyforge-local-secret"
"value": "={{$env.STORYFORGE_ORCHESTRATOR_SECRET || ''}}"
}
]
},

View File

@@ -21,13 +21,13 @@
{
"parameters": {
"method": "POST",
"url": "={{'http://host.docker.internal:8081/internal/jobs/steps/content-source-sync?job_id=' + ($json.body.job_id || $json.body.jobId)}}",
"url": "={{($env.STORYFORGE_INTERNAL_BASE_URL || 'http://collector:8081').replace(/\\/$/, '') + '/internal/jobs/steps/content-source-sync?job_id=' + ($json.body.job_id || $json.body.jobId)}}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Orchestrator-Secret",
"value": "storyforge-local-secret"
"value": "={{$env.STORYFORGE_ORCHESTRATOR_SECRET || ''}}"
}
]
},

View File

@@ -21,13 +21,13 @@
{
"parameters": {
"method": "POST",
"url": "={{'http://host.docker.internal:8081/internal/jobs/steps/real-cut/run?job_id=' + ($json.body.job_id || $json.body.jobId)}}",
"url": "={{($env.STORYFORGE_INTERNAL_BASE_URL || 'http://collector:8081').replace(/\\/$/, '') + '/internal/jobs/steps/real-cut/run?job_id=' + ($json.body.job_id || $json.body.jobId)}}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Orchestrator-Secret",
"value": "storyforge-local-secret"
"value": "={{$env.STORYFORGE_ORCHESTRATOR_SECRET || ''}}"
}
]
},

56
scripts/check_repo_baseline.sh Executable file
View File

@@ -0,0 +1,56 @@
#!/bin/sh
set -eu
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
need_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "missing required command: $1" >&2
exit 1
fi
}
need_cmd python3
need_cmd docker
need_cmd node
cd "$ROOT"
echo "[1/5] compile collector-service"
python3 -m compileall collector-service/app >/dev/null
echo "[2/5] validate docker compose"
docker compose config >/dev/null
echo "[3/5] validate n8n workflows"
python3 - <<'PY'
import json
import pathlib
for path in sorted(pathlib.Path("n8n/workflows").glob("*.json")):
with path.open() as handle:
json.load(handle)
print(f"workflow ok: {path.name}")
PY
echo "[4/5] validate web scripts"
for file in web/storyforge-web-v4/assets/app.js web/storyforge-web-v4/assets/storyforge-*.js; do
node --check "$file"
done
node --check scripts/douyin-browser-capture/control_panel.mjs
if [ "${STORYFORGE_SKIP_ANDROID:-0}" = "1" ]; then
echo "[5/5] skip android compile (STORYFORGE_SKIP_ANDROID=1)"
else
if command -v java >/dev/null 2>&1; then
echo "[5/5] compile android debug kotlin"
(
cd android-app
./gradlew :app:compileDebugKotlin >/dev/null
)
else
echo "[5/5] skip android compile (java not installed)"
fi
fi
echo "baseline checks passed"

View File

@@ -43,6 +43,8 @@ The control panel stores each run under:
`/Users/kris/code/StoryForge-gitea/output/playwright/douyin/control-panel`
The StoryForge token field is session-scoped in the browser and is not written back into the saved form values, so it will not be refilled from localStorage on the next launch.
## What it captures
- current profile page JSON blobs extracted from `<script>` tags

View File

@@ -1157,6 +1157,7 @@ function renderPage(mode = "full") {
const form = document.getElementById("capture-form");
const storageKey = "storyforge-douyin-control-panel";
const sessionStorageKey = "storyforge-douyin-workbench-session";
const tokenStorageKey = "storyforge-douyin-control-panel-token";
const workbenchSessionEl = document.getElementById("workbench-session");
const tokenDetailsEl = document.getElementById("token-details");
const tokenInputEl = document.getElementById("token");
@@ -1208,7 +1209,8 @@ function renderPage(mode = "full") {
selectedSnapshotDetail: null,
similarSearchDetail: null,
loadingAccountId: "",
lastAnalysisMessage: ""
lastAnalysisMessage: "",
accountSelectionToken: 0
};
function escapeHtml(value) {
@@ -1272,7 +1274,7 @@ function renderPage(mode = "full") {
const backendUrl = normalizeBackendUrl(document.getElementById("backend-url").value);
const username = document.getElementById("username").value.trim();
const password = document.getElementById("password").value;
const token = tokenInputEl.value.trim();
const token = tokenInputEl.value.trim() || getStoredCaptureToken();
return {
backendUrl,
username,
@@ -1281,9 +1283,32 @@ function renderPage(mode = "full") {
};
}
function getStoredCaptureToken() {
try {
return sessionStorage.getItem(tokenStorageKey) || "";
} catch {
return "";
}
}
function persistCaptureToken(token) {
try {
if (token) {
sessionStorage.setItem(tokenStorageKey, token);
} else {
sessionStorage.removeItem(tokenStorageKey);
}
} catch {}
}
function updateTokenUi() {
const token = tokenInputEl.value.trim();
tokenSummaryEl.textContent = token ? "已填写 Token" : "未填写 Token";
const storedToken = getStoredCaptureToken();
tokenSummaryEl.textContent = token
? "已填写 Token"
: storedToken
? "Token 已保存到当前会话"
: "未填写 Token";
if (!token && document.activeElement !== tokenInputEl) {
tokenDetailsEl.open = false;
}
@@ -1291,7 +1316,8 @@ function renderPage(mode = "full") {
function persistWorkbenchSession(session) {
workbenchState.session = session;
localStorage.setItem(sessionStorageKey, JSON.stringify(session));
sessionStorage.setItem(sessionStorageKey, JSON.stringify(session));
localStorage.removeItem(sessionStorageKey);
renderWorkbenchSession();
}
@@ -1310,7 +1336,9 @@ function renderPage(mode = "full") {
workbenchState.selectedSnapshotDetail = null;
workbenchState.similarSearchDetail = null;
workbenchState.lastAnalysisMessage = "";
sessionStorage.removeItem(sessionStorageKey);
localStorage.removeItem(sessionStorageKey);
persistCaptureToken("");
renderWorkbenchSession();
renderAccountList();
renderWorkspace();
@@ -1318,9 +1346,21 @@ function renderPage(mode = "full") {
function loadWorkbenchSession() {
try {
const saved = JSON.parse(localStorage.getItem(sessionStorageKey) || "null");
const sessionRaw = sessionStorage.getItem(sessionStorageKey);
if (sessionRaw) {
const saved = JSON.parse(sessionRaw);
if (saved && saved.token && saved.backendUrl) {
workbenchState.session = saved;
}
return;
}
const legacyRaw = localStorage.getItem(sessionStorageKey);
if (!legacyRaw) return;
const saved = JSON.parse(legacyRaw);
if (saved && saved.token && saved.backendUrl) {
workbenchState.session = saved;
sessionStorage.setItem(sessionStorageKey, JSON.stringify(saved));
localStorage.removeItem(sessionStorageKey);
}
} catch {}
}
@@ -1838,6 +1878,8 @@ function renderPage(mode = "full") {
return;
}
const preserveFeedback = options.preserveFeedback === true;
const selectionToken = (workbenchState.accountSelectionToken || 0) + 1;
workbenchState.accountSelectionToken = selectionToken;
workbenchState.selectedAccountId = accountId;
workbenchState.loadingAccountId = accountId;
if (!preserveFeedback) {
@@ -1851,6 +1893,9 @@ function renderPage(mode = "full") {
storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(accountId) + "/snapshots").catch(() => []),
storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(accountId) + "/videos?limit=1000").catch(() => ({ items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 }))
]);
if (selectionToken !== workbenchState.accountSelectionToken) {
return;
}
workbenchState.selectedWorkspace = results[0];
workbenchState.snapshots = safeArray(results[1]);
workbenchState.videoItems = safeArray(results[2]?.items);
@@ -1871,10 +1916,15 @@ function renderPage(mode = "full") {
}
analysisFeedbackEl.textContent = workbenchState.lastAnalysisMessage || "工作台已加载。";
} catch (error) {
if (selectionToken !== workbenchState.accountSelectionToken) {
return;
}
analysisFeedbackEl.textContent = "加载工作台失败: " + error.message;
} finally {
workbenchState.loadingAccountId = "";
renderAccountList();
if (selectionToken === workbenchState.accountSelectionToken) {
workbenchState.loadingAccountId = "";
renderAccountList();
}
}
}
@@ -2001,7 +2051,11 @@ function renderPage(mode = "full") {
if (saved.profileUrl) document.getElementById("profile-url").value = saved.profileUrl;
if (saved.backendUrl) document.getElementById("backend-url").value = saved.backendUrl;
if (saved.username) document.getElementById("username").value = saved.username;
if (saved.token) tokenInputEl.value = saved.token;
if (saved.token) {
persistCaptureToken(saved.token);
delete saved.token;
localStorage.setItem(storageKey, JSON.stringify(saved));
}
if (saved.note) document.getElementById("note").value = saved.note;
if (saved.maxVideos !== undefined) document.getElementById("max-videos").value = saved.maxVideos;
if (saved.syncEnabled !== undefined) document.getElementById("sync-enabled").checked = Boolean(saved.syncEnabled);
@@ -2017,7 +2071,6 @@ function renderPage(mode = "full") {
profileUrl: payload.profileUrl,
backendUrl: payload.backendUrl,
username: payload.username,
token: payload.token,
note: payload.note,
maxVideos: payload.maxVideos,
syncEnabled: payload.syncEnabled,
@@ -2026,6 +2079,7 @@ function renderPage(mode = "full") {
allowCreatorCenterFallback: payload.allowCreatorCenterFallback
};
localStorage.setItem(storageKey, JSON.stringify(saved));
persistCaptureToken(payload.token || "");
}
form.addEventListener("submit", async (event) => {
@@ -2037,8 +2091,8 @@ function renderPage(mode = "full") {
password: document.getElementById("password").value,
storyforgeUsername: document.getElementById("username").value.trim(),
storyforgePassword: document.getElementById("password").value,
token: tokenInputEl.value.trim(),
storyforgeToken: tokenInputEl.value.trim(),
token: tokenInputEl.value.trim() || getStoredCaptureToken(),
storyforgeToken: tokenInputEl.value.trim() || getStoredCaptureToken(),
note: document.getElementById("note").value.trim(),
maxVideos: document.getElementById("max-videos").value,
syncEnabled: document.getElementById("sync-enabled").checked,
@@ -2074,7 +2128,11 @@ function renderPage(mode = "full") {
tokenInputEl.addEventListener("input", updateTokenUi);
tokenDetailsEl.addEventListener("toggle", () => {
if (tokenDetailsEl.open) {
tokenSummaryEl.textContent = tokenInputEl.value.trim() ? "已填写 Token" : "填写后可跳过密码";
tokenSummaryEl.textContent = tokenInputEl.value.trim()
? "已填写 Token"
: getStoredCaptureToken()
? "Token 已保存到当前会话"
: "填写后可跳过密码";
} else {
updateTokenUi();
}

View File

@@ -2,20 +2,33 @@
set -eu
BASE_URL="${STORYFORGE_BASE_URL:-http://127.0.0.1:8081}"
USERNAME="${STORYFORGE_USERNAME:-kris}"
PASSWORD="${STORYFORGE_PASSWORD:-Asd123456.}"
USERNAME="${STORYFORGE_USERNAME:-storyforge-admin}"
PASSWORD="${STORYFORGE_PASSWORD:-}"
ACCOUNT_ID="${STORYFORGE_SMOKE_ACCOUNT_ID:-dyacct_c2b62842b228406cb48f05fac16fdfdf}"
if [ -z "$PASSWORD" ]; then
echo "STORYFORGE_PASSWORD is required. Export the bootstrap super-admin password before running smoke_business.sh." >&2
exit 1
fi
python3 - <<'PY'
import json
import os
import urllib.request
base = os.environ.get("BASE_URL", "http://127.0.0.1:8081").rstrip("/")
username = os.environ.get("USERNAME", "kris")
password = os.environ.get("PASSWORD", "Asd123456.")
username = os.environ.get("USERNAME", "storyforge-admin")
password = os.environ.get("PASSWORD", "")
account_id = os.environ.get("ACCOUNT_ID", "dyacct_c2b62842b228406cb48f05fac16fdfdf")
if not password:
raise SystemExit("STORYFORGE_PASSWORD is required")
with urllib.request.urlopen(base + "/readyz", timeout=20) as resp:
ready = json.load(resp)
if not ready.get("ready"):
raise SystemExit("collector readyz is not healthy")
login_req = urllib.request.Request(
base + "/v2/auth/login",
data=json.dumps({"username": username, "password": password}).encode(),

View File

@@ -19,7 +19,7 @@ import time
import urllib.request
checks = [
("collector", "http://127.0.0.1:8081/healthz"),
("collector", "http://127.0.0.1:8081/readyz"),
("n8n", "http://127.0.0.1:5670/healthz"),
("cli-proxy-api", "http://127.0.0.1:8317/v1/models"),
]
@@ -43,6 +43,6 @@ if pending:
PY
echo "business started"
echo "collector: http://127.0.0.1:8081/healthz"
echo "collector: http://127.0.0.1:8081/readyz"
echo "n8n: http://127.0.0.1:5670/healthz"
echo "cli-proxy-api: http://127.0.0.1:8317/v1/models"

View File

@@ -11,7 +11,7 @@ python3 - <<'PY'
import urllib.request
for name, url in [
("collector", "http://127.0.0.1:8081/healthz"),
("collector", "http://127.0.0.1:8081/readyz"),
("n8n", "http://127.0.0.1:5670/healthz"),
("cli-proxy-api", "http://127.0.0.1:8317/v1/models"),
]:

View File

@@ -6,17 +6,18 @@
- 页面:`index.html`
- 样式:`assets/styles.css`
- 页面交互:`assets/app.js`
- 页面交互:`assets/storyforge-session-store.js``assets/storyforge-api-client.js``assets/storyforge-platform-runtime.js``assets/app.js`
## 当前定位
- 这不是最终生产版,但已经不是纯静态原型
- 目录已经从 `output/ui/` 原型区独立出来,并接上了第一层真实业务接口
- 已开始把会话存储、后端请求和平台能力判断拆成浏览器直加载的小模块,`app.js` 现在更偏向页面编排
- 这里面向国内平台的 Web 承载,当前覆盖 `douyin``xiaohongshu``bilibili``kuaishou``wechat_video`
- `YouTube` 目前明确不在本轮范围内
- 已支持通过 `https://storyforge.hyzq.net/` 做公网访问
- 通用的项目、内容源、复盘、集成等流程可以正常使用
- 平台工作台和运行时数据目前只有 `douyin` 做到了完整实现,其余平台统一按 `待接入工作台` 处理
- 平台工作台和运行时数据目前只有 `douyin` 做到了完整实现,其余平台统一按 `待接入工作台` 处理,相关工作台动作也会被 capability gate 拦住
- 当前保留的核心页面结构:
- 项目总台
- 我的项目
@@ -79,6 +80,7 @@
- 最近写入 NAS 的缓存样本路径
- 会先识别后端是否具备 `tracking / reviews / integrations` 路由,再决定是否请求,避免不同版本 live collector 刷 404
- 依赖不可达时,自动拦住 AI 视频 / 实拍剪辑动作并展示原因
- 非 Douyin 平台的账号工作台动作会直接显示待接入原因,避免误触发半成品链路
- 使用 Agent 生成文案
- 创建 AI 视频任务
- 创建实拍剪辑任务

View File

@@ -1,23 +1,6 @@
const STORAGE_KEY = "storyforge-web-v4-session";
function detectDefaultBackendUrl() {
if (typeof window === "undefined") {
return "http://127.0.0.1:8081";
}
const { origin, hostname, port, pathname } = window.location;
if (/^https?:/i.test(origin) && hostname === "storyforge.hyzq.net") {
return origin;
}
if (/^https?:/i.test(origin) && pathname.startsWith("/storyforge")) {
return `${origin}/storyforge`;
}
if ((hostname === "127.0.0.1" || hostname === "localhost") && port && port !== "8081") {
return "http://127.0.0.1:8081";
}
return "http://127.0.0.1:8081";
}
const DEFAULT_BACKEND_URL = detectDefaultBackendUrl();
const SESSION_STORE = StoryForgeSessionStore.create(STORAGE_KEY);
const DEFAULT_BACKEND_URL = StoryForgeApiClient.detectDefaultBackendUrl();
const navButtons = document.querySelectorAll("[data-screen-target]");
const screens = Array.from(document.querySelectorAll("[data-screen]"));
@@ -25,12 +8,13 @@ const screenMap = Object.fromEntries(screens.map((screen) => [screen.dataset.scr
const appState = {
screen: window.location.hash.replace("#", "") || "dashboard",
session: loadStoredSession(),
session: SESSION_STORE.loadStoredSession(),
me: null,
dashboard: null,
contentSources: [],
accounts: [],
selectedAccountId: "",
selectedAccountRequestToken: 0,
selectedWorkspace: null,
selectedVideos: { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 },
documents: [],
@@ -38,7 +22,7 @@ const appState = {
currentPlatform: localStorage.getItem(STORAGE_KEY + ":currentPlatform") || "",
selectedProjectId: "",
selectedAssistantId: "",
lastSeenAt: Number(localStorage.getItem(STORAGE_KEY + ":lastSeenAt") || Date.now()),
lastSeenAt: SESSION_STORE.getLastSeenAt(Date.now()),
trackingAccounts: [],
trackingDigest: null,
reviews: [],
@@ -66,6 +50,14 @@ const appState = {
lastJobDetail: null
};
let PLATFORM_RUNTIME = null;
const API_CLIENT = StoryForgeApiClient.create({
getSession: () => appState.session,
defaultBackendUrl: DEFAULT_BACKEND_URL,
getCapabilities: () => appState.backendCapabilities
});
const INTEGRATION_ORDER = ["local_model", "live_recorder", "cutvideo", "huobao", "n8n", "asr"];
const ACTIVE_PLATFORMS = [
{ value: "douyin", label: "抖音" },
@@ -75,23 +67,7 @@ const ACTIVE_PLATFORMS = [
{ value: "wechat_video", label: "微信视频号" }
];
const ACTIVE_PLATFORM_CHIPS = ["全平台", "抖音", "小红书", "B站", "快手", "视频号"];
function makePlatformRoutes(platform) {
return {
accounts: `/v2/${platform}/accounts`,
workspace: (accountId) => `/v2/${platform}/accounts/${encodeURIComponent(accountId)}/workspace`,
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`,
similarSearches: `/v2/${platform}/similar-searches`,
similarSearchDetail: (searchId) => `/v2/${platform}/similar-searches/${encodeURIComponent(searchId)}`,
benchmarkLinks: (accountId) => `/v2/${platform}/accounts/${encodeURIComponent(accountId)}/benchmark-links`,
trackingAccounts: `/v2/${platform}/tracking/accounts`,
trackingDigest: `/v2/${platform}/tracking/digest`,
trackingRefresh: `/v2/${platform}/tracking/refresh`,
trackingCursor: `/v2/${platform}/tracking/cursor`,
trackingAccountRefresh: (trackedAccountId) => `/v2/${platform}/tracking/accounts/${encodeURIComponent(trackedAccountId)}/refresh`
};
}
const makePlatformRoutes = StoryForgePlatformRuntime.makePlatformRoutes;
const PLATFORM_REGISTRY = {
douyin: {
@@ -103,28 +79,41 @@ const PLATFORM_REGISTRY = {
xiaohongshu: {
label: "小红书",
shortLabel: "小红书",
workbenchReady: true,
workbenchReady: false,
pendingText: "小红书工作台当前还没有完整接入,请先停留在导入和通用流程。",
routes: makePlatformRoutes("xiaohongshu")
},
bilibili: {
label: "哔哩哔哩",
shortLabel: "B站",
workbenchReady: true,
workbenchReady: false,
pendingText: "B站工作台当前还没有完整接入请先停留在导入和通用流程。",
routes: makePlatformRoutes("bilibili")
},
kuaishou: {
label: "快手",
shortLabel: "快手",
workbenchReady: true,
workbenchReady: false,
pendingText: "快手工作台当前还没有完整接入,请先停留在导入和通用流程。",
routes: makePlatformRoutes("kuaishou")
},
wechat_video: {
label: "微信视频号",
shortLabel: "视频号",
workbenchReady: true,
workbenchReady: false,
pendingText: "微信视频号工作台当前还没有完整接入,请先停留在导入和通用流程。",
routes: makePlatformRoutes("wechat_video")
}
};
PLATFORM_RUNTIME = StoryForgePlatformRuntime.create({
activePlatforms: ACTIVE_PLATFORMS,
platformRegistry: PLATFORM_REGISTRY,
appState,
storage: localStorage,
storageKey: STORAGE_KEY
});
const INTEGRATION_META = {
local_model: {
label: "本机模型",
@@ -194,119 +183,75 @@ function safeArray(value) {
}
function getRuntimePlatformValues() {
const fromDashboard = safeArray(appState.dashboard?.supported_platforms)
.map((item) => normalizePlatformValue(item, ""))
.filter((item) => item && PLATFORM_REGISTRY[item]);
if (fromDashboard.length) {
return fromDashboard;
}
return ACTIVE_PLATFORMS.map((item) => item.value);
return PLATFORM_RUNTIME.getRuntimePlatformValues();
}
function getPlatformOptions() {
return getRuntimePlatformValues().map((value) => ({ value, label: getPlatformMeta(value)?.label || value }));
return PLATFORM_RUNTIME.getPlatformOptions();
}
function normalizePlatformValue(value, fallback = "douyin") {
const normalized = String(value || "").trim().toLowerCase();
if (!normalized) return fallback;
const byValue = ACTIVE_PLATFORMS.find((item) => item.value === normalized);
if (byValue) return byValue.value;
const byLabel = ACTIVE_PLATFORMS.find((item) => item.label === value);
return byLabel?.value || fallback;
return PLATFORM_RUNTIME.normalizePlatformValue(value, fallback);
}
function platformLabel(value) {
const matched = ACTIVE_PLATFORMS.find((item) => item.value === normalizePlatformValue(value, ""));
return matched?.label || String(value || "抖音");
return PLATFORM_RUNTIME.platformLabel(value);
}
function getPlatformMeta(value) {
return PLATFORM_REGISTRY[normalizePlatformValue(value, "")] || null;
return PLATFORM_RUNTIME.getPlatformMeta(value);
}
function getPlatformShortLabel(value) {
return getPlatformMeta(value)?.shortLabel || platformLabel(value);
return PLATFORM_RUNTIME.getPlatformShortLabel(value);
}
function getPlatformChips() {
return ["全平台", ...getRuntimePlatformValues().map((value) => getPlatformShortLabel(value))];
return PLATFORM_RUNTIME.getPlatformChips();
}
function isWorkbenchPlatform(value) {
return Boolean(getPlatformMeta(value)?.workbenchReady);
return PLATFORM_RUNTIME.isWorkbenchPlatform(value);
}
function getWorkbenchRoute(platform, key, ...args) {
const routes = getPlatformMeta(platform)?.routes;
if (!routes) return "";
const route = routes[key];
if (typeof route === "function") return route(...args);
return route || "";
return PLATFORM_RUNTIME.getWorkbenchRoute(platform, key, ...args);
}
function setCurrentPlatform(value) {
const normalized = normalizePlatformValue(value, "");
appState.currentPlatform = normalized;
if (normalized) {
localStorage.setItem(STORAGE_KEY + ":currentPlatform", normalized);
} else {
localStorage.removeItem(STORAGE_KEY + ":currentPlatform");
}
return PLATFORM_RUNTIME.setCurrentPlatform(value);
}
function getAccountPlatform(account) {
return normalizePlatformValue(
account?.platform
|| account?.source_platform
|| account?.metadata?.platform
|| "",
"douyin"
);
return PLATFORM_RUNTIME.getAccountPlatform(account);
}
function getAccountHandle(account) {
return String(
account?.handle
|| account?.douyin_id
|| account?.xhs_id
|| account?.bilibili_uid
|| account?.kuaishou_id
|| account?.wechat_video_id
|| account?.uid
|| account?.username
|| ""
).trim();
return PLATFORM_RUNTIME.getAccountHandle(account);
}
function getAccountProfileUrl(account) {
return String(account?.profile_url || account?.source_url || account?.homepage_url || "").trim();
return PLATFORM_RUNTIME.getAccountProfileUrl(account);
}
function getAccountName(account) {
return String(account?.nickname || getAccountHandle(account) || "未命名账号").trim();
return PLATFORM_RUNTIME.getAccountName(account);
}
function getAccountSubtitle(account) {
return getAccountHandle(account) || getAccountProfileUrl(account) || platformLabel(getAccountPlatform(account));
return PLATFORM_RUNTIME.getAccountSubtitle(account);
}
function getPendingWorkbenchReason(platform) {
const meta = getPlatformMeta(platform);
return meta?.pendingText || `${platformLabel(platform)}工作台待接入`;
return PLATFORM_RUNTIME.getPendingWorkbenchReason(platform);
}
function getAccountWorkbenchGate(account) {
return PLATFORM_RUNTIME.getAccountWorkbenchGate(account);
}
function getPreferredPlatform() {
const selectedAccountPlatform = getAccountPlatform(getSelectedAccount());
if (selectedAccountPlatform && isWorkbenchPlatform(selectedAccountPlatform)) return selectedAccountPlatform;
const current = normalizePlatformValue(appState.currentPlatform, "");
if (current && isWorkbenchPlatform(current)) return current;
const sourcePlatform = normalizePlatformValue(
safeArray(appState.contentSources).find((item) => isWorkbenchPlatform(item.platform))?.platform || "",
""
);
if (sourcePlatform) return sourcePlatform;
return "douyin";
return PLATFORM_RUNTIME.getPreferredPlatform();
}
function escapeHtml(value) {
@@ -395,28 +340,16 @@ function statusTone(status) {
}
function loadStoredSession() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
return SESSION_STORE.loadStoredSession();
}
function persistSession(session) {
appState.session = session;
if (session) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
} else {
localStorage.removeItem(STORAGE_KEY);
}
SESSION_STORE.persistSession(session);
}
function setLastSeenAt(value) {
const date = value instanceof Date ? value : new Date(value);
const time = Number.isFinite(date.getTime()) ? date.getTime() : Date.now();
appState.lastSeenAt = time;
localStorage.setItem(STORAGE_KEY + ":lastSeenAt", String(time));
appState.lastSeenAt = SESSION_STORE.setLastSeenAt(value);
}
function markSeenNow() {
@@ -527,7 +460,7 @@ function openAuthModal() {
setAuthField("backendUrl", session?.backendUrl || DEFAULT_BACKEND_URL);
setAuthField("username", session?.account?.username || "");
setAuthField("password", "");
setAuthField("token", session?.token || "");
setAuthField("token", "");
}
function closeAuthModal() {
@@ -902,70 +835,19 @@ async function submitActionModal() {
}
async function storyforgeFetch(path, options = {}) {
const backendUrl = (options.backendUrl || appState.session?.backendUrl || DEFAULT_BACKEND_URL).replace(/\/$/, "");
const headers = { ...(options.headers || {}) };
const useAuth = options.auth !== false;
const token = options.token || appState.session?.token;
if (useAuth && token) headers.Authorization = `Bearer ${token}`;
let body = options.body;
if (body && !(body instanceof FormData) && !headers["Content-Type"] && !headers["content-type"]) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(body);
}
const response = await fetch(`${backendUrl}${path}`, {
method: options.method || "GET",
headers,
body,
cache: "no-store"
});
const isJson = (response.headers.get("content-type") || "").includes("application/json");
const payload = isJson ? await response.json() : await response.text();
if (!response.ok) {
const detail = typeof payload === "object" && payload
? payload.detail || payload.message || JSON.stringify(payload)
: String(payload || response.statusText);
throw new Error(detail);
}
return payload;
return API_CLIENT.fetchJson(path, options);
}
async function storyforgeFetchBlob(path, options = {}) {
const backendUrl = (options.backendUrl || appState.session?.backendUrl || DEFAULT_BACKEND_URL).replace(/\/$/, "");
const headers = { ...(options.headers || {}) };
const useAuth = options.auth !== false;
const token = options.token || appState.session?.token;
if (useAuth && token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(`${backendUrl}${path}`, {
method: options.method || "GET",
headers,
body: options.body,
cache: "no-store"
});
if (!response.ok) {
const payload = (response.headers.get("content-type") || "").includes("application/json")
? await response.json().catch(() => null)
: await response.text().catch(() => "");
const detail = typeof payload === "object" && payload
? payload.detail || payload.message || JSON.stringify(payload)
: String(payload || response.statusText);
throw new Error(detail);
}
return response.blob();
return API_CLIENT.fetchBlob(path, options);
}
async function loadBackendCapabilities(backendUrl) {
const normalizedUrl = (backendUrl || DEFAULT_BACKEND_URL).replace(/\/$/, "");
const response = await fetch(`${normalizedUrl}/openapi.json`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
return new Set(Object.keys(payload.paths || {}));
return API_CLIENT.loadBackendCapabilities(backendUrl);
}
function backendSupports(path) {
if (!(appState.backendCapabilities instanceof Set)) return true;
return appState.backendCapabilities.has(path);
return API_CLIENT.backendSupports(path);
}
async function loginWithForm() {
@@ -1397,39 +1279,73 @@ async function executeOneLinerAction(executorKey, options = {}) {
return payload;
}
async function loadPlatformAccount(platform, accountId) {
async function loadPlatformAccount(platform, accountId, requestToken = 0) {
if (!accountId) return;
const normalizedPlatform = normalizePlatformValue(platform, getPreferredPlatform());
const token = requestToken || ((appState.selectedAccountRequestToken || 0) + 1);
if (!requestToken) {
appState.selectedAccountRequestToken = token;
}
appState.selectedAccountId = accountId;
setCurrentPlatform(normalizedPlatform);
const workspacePath = getWorkbenchRoute(normalizedPlatform, "workspace", accountId);
if (!workspacePath) {
if (!isWorkbenchPlatform(normalizedPlatform)) {
if (token !== appState.selectedAccountRequestToken) {
return false;
}
appState.selectedWorkspace = null;
appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 };
return;
appState.snapshots = [];
appState.selectedSnapshotId = "";
appState.selectedSnapshotDetail = null;
appState.similarSearchDetail = null;
return true;
}
const workspacePath = getWorkbenchRoute(normalizedPlatform, "workspace", accountId);
if (!workspacePath) {
if (token !== appState.selectedAccountRequestToken) {
return false;
}
appState.selectedWorkspace = null;
appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 };
appState.snapshots = [];
appState.selectedSnapshotId = "";
appState.selectedSnapshotDetail = null;
appState.similarSearchDetail = null;
return true;
}
const videosPath = getWorkbenchRoute(normalizedPlatform, "videos", accountId);
const supportsAccountVideos = videosPath && backendSupports(`/v2/${normalizedPlatform}/accounts/{account_id}/videos`);
const [workspace, videos] = await Promise.all([
storyforgeFetch(workspacePath),
supportsAccountVideos
? storyforgeFetch(videosPath).catch(() => ({
items: [],
meta: {},
top_scored_video_ids: [],
latest_video_ids: [],
high_score_threshold: 60
}))
: Promise.resolve({
items: [],
meta: {},
top_scored_video_ids: [],
latest_video_ids: [],
high_score_threshold: 60
})
]);
appState.selectedWorkspace = workspace;
appState.selectedVideos = videos;
try {
const [workspace, videos] = await Promise.all([
storyforgeFetch(workspacePath),
supportsAccountVideos
? storyforgeFetch(videosPath).catch(() => ({
items: [],
meta: {},
top_scored_video_ids: [],
latest_video_ids: [],
high_score_threshold: 60
}))
: Promise.resolve({
items: [],
meta: {},
top_scored_video_ids: [],
latest_video_ids: [],
high_score_threshold: 60
})
]);
if (token !== appState.selectedAccountRequestToken) {
return false;
}
appState.selectedWorkspace = workspace;
appState.selectedVideos = videos;
return true;
} catch (error) {
if (token !== appState.selectedAccountRequestToken) {
return false;
}
throw error;
}
}
function getTrackingSinceIso() {
@@ -2595,7 +2511,9 @@ function markSavedCandidate(candidate, links) {
async function saveCandidateAsBenchmark(candidateIndex, relationType = "benchmark") {
const account = requireSelectedAccountRow();
const platform = getAccountPlatform(account);
const gate = getAccountWorkbenchGate(account);
if (!gate.enabled) throw new Error(gate.reason);
const platform = gate.platform;
const benchmarkPath = getWorkbenchRoute(platform, "benchmarkLinks", account.id);
if (!benchmarkPath) throw new Error(getPendingWorkbenchReason(platform));
const candidate = safeArray(appState.lastSimilaritySearch?.candidates)[Number(candidateIndex)];
@@ -2973,7 +2891,7 @@ function renderDiscoveryScreen() {
isWorkbenchPlatform(currentPlatform)
? `这里已经接入真实${currentPlatformLabel}账号列表和单账号详情`
: `${workbenchReason}当前仍可导入内容源绑定 Agent 和沉淀复盘`,
`${button("导入主页", "open-import-homepage")} ${button("导入当前对标", "open-import-selected-account")} ${button(tracked ? "已在跟踪" : "加入跟踪", "open-track-selected-account", "secondary", { disabledReason: workbenchReason || "" })} ${button("账号分析", "analyze-selected-account", "secondary", { disabledReason: workbenchReason || "" })} ${button("高分分析", "analyze-top-videos", "secondary", { disabledReason: workbenchReason || "" })} ${button("查相似", "open-similar-search", "secondary", { disabledReason: workbenchReason || "" })} ${button("存对标", "open-benchmark-link", "primary", { disabledReason: workbenchReason || "" })}`,
`${button("导入主页", "open-import-homepage")} ${button("导入当前对标", "open-import-selected-account", "secondary", { disabledReason: workbenchReason || "" })} ${button(tracked ? "已在跟踪" : "加入跟踪", "open-track-selected-account", "secondary", { disabledReason: workbenchReason || "" })} ${button("账号分析", "analyze-selected-account", "secondary", { disabledReason: workbenchReason || "" })} ${button("高分分析", "analyze-top-videos", "secondary", { disabledReason: workbenchReason || "" })} ${button("查相似", "open-similar-search", "secondary", { disabledReason: workbenchReason || "" })} ${button("存对标", "open-benchmark-link", "primary", { disabledReason: workbenchReason || "" })}`,
`
<div class="panel">
<div class="toolbar">
@@ -3091,8 +3009,8 @@ function renderDiscoveryScreen() {
<div class="task-meta">
<span class="tag">${escapeHtml(selectedProject?.name || "未选项目")}</span>
<span class="tag">${escapeHtml(getSelectedAssistant()?.name || "未选 Agent")}</span>
<span class="tag clickable-tag" data-action="open-import-selected-account">${importedSources.length ? "继续同步" : "导入当前对标"}</span>
<span class="tag ${tracked ? "green" : "clickable-tag"}" ${tracked ? "" : `data-action="open-track-selected-account"`}>${escapeHtml(tracked ? "已在跟踪" : "加入跟踪")}</span>
${actionTag(importedSources.length ? "继续同步" : "导入当前对标", "open-import-selected-account", "", { disabledReason: workbenchReason || "" })}
${tracked ? `<span class="tag green">${escapeHtml("已在跟踪")}</span>` : actionTag("加入跟踪", "open-track-selected-account", "", { disabledReason: workbenchReason || "" })}
</div>
</div>
` : `<div class="task-item"><h4>还没有选中账号</h4><p>先从左侧列表选一个对标账号,再决定是否导入到当前项目。</p></div>`}
@@ -3929,7 +3847,13 @@ function openImportHomepageAction() {
function openImportSelectedAccountAction() {
const account = requireSelectedAccountRow();
const platform = getAccountPlatform(account);
const gate = getAccountWorkbenchGate(account);
if (!gate.enabled) {
rememberAction("当前平台待接入", gate.reason, "orange");
renderAll();
return;
}
const platform = gate.platform;
const project = requireSelectedProject();
const assistants = getAssistantOptions(project.id);
const currentSources = getCurrentProjectSourcesForAccount(account, project.id);
@@ -3997,7 +3921,13 @@ function openImportSelectedAccountAction() {
function openTrackSelectedAccountAction() {
const account = requireSelectedAccountRow();
const platform = getAccountPlatform(account);
const gate = getAccountWorkbenchGate(account);
if (!gate.enabled) {
rememberAction("当前平台待接入", gate.reason, "orange");
renderAll();
return;
}
const platform = gate.platform;
const trackingAccountsPath = getWorkbenchRoute(platform, "trackingAccounts");
if (!trackingAccountsPath) {
rememberAction("当前平台待接入", getPendingWorkbenchReason(platform), "orange");
@@ -4623,7 +4553,13 @@ function openEditAssistantAction(assistantId = "") {
function openAnalyzeSelectedAccountAction() {
const account = requireSelectedAccountRow();
const platform = getAccountPlatform(account);
const gate = getAccountWorkbenchGate(account);
if (!gate.enabled) {
rememberAction("当前平台待接入", gate.reason, "orange");
renderAll();
return;
}
const platform = gate.platform;
const analyzePath = getWorkbenchRoute(platform, "analyzeAccount", account.id);
if (!analyzePath) {
rememberAction("当前平台待接入", getPendingWorkbenchReason(platform), "orange");
@@ -4665,7 +4601,13 @@ function openAnalyzeSelectedAccountAction() {
function openAnalyzeTopVideosAction() {
const account = requireSelectedAccountRow();
const platform = getAccountPlatform(account);
const gate = getAccountWorkbenchGate(account);
if (!gate.enabled) {
rememberAction("当前平台待接入", gate.reason, "orange");
renderAll();
return;
}
const platform = gate.platform;
const analyzePath = getWorkbenchRoute(platform, "analyzeTopVideos", account.id);
if (!analyzePath || !backendSupports(`/v2/${platform}/accounts/{account_id}/videos/analyze-top`)) {
rememberAction("当前后端暂不支持", "这套 live collector 还没有接入高分作品批量分析。", "orange");
@@ -4699,7 +4641,13 @@ function openAnalyzeTopVideosAction() {
function openSimilaritySearchAction() {
const account = requireSelectedAccountRow();
const platform = getAccountPlatform(account);
const gate = getAccountWorkbenchGate(account);
if (!gate.enabled) {
rememberAction("当前平台待接入", gate.reason, "orange");
renderAll();
return;
}
const platform = gate.platform;
const createPath = getWorkbenchRoute(platform, "similarSearches");
if (!createPath) {
rememberAction("当前平台待接入", getPendingWorkbenchReason(platform), "orange");
@@ -4742,7 +4690,13 @@ function openSimilaritySearchAction() {
function openBenchmarkLinkAction(defaults = {}) {
const account = requireSelectedAccountRow();
const platform = getAccountPlatform(account);
const gate = getAccountWorkbenchGate(account);
if (!gate.enabled) {
rememberAction("当前平台待接入", gate.reason, "orange");
renderAll();
return;
}
const platform = gate.platform;
const benchmarkPath = getWorkbenchRoute(platform, "benchmarkLinks", account.id);
if (!benchmarkPath) {
rememberAction("当前平台待接入", getPendingWorkbenchReason(platform), "orange");
@@ -5701,15 +5655,23 @@ document.addEventListener("click", async (event) => {
if (name === "select-account") {
const accountId = action.dataset.accountId;
if (!accountId) return;
const requestToken = (appState.selectedAccountRequestToken || 0) + 1;
appState.selectedAccountRequestToken = requestToken;
setBusy(true, "正在加载对标详情...");
try {
const account = safeArray(appState.accounts).find((item) => item.id === accountId) || null;
await loadPlatformAccount(getAccountPlatform(account), accountId);
renderAll();
const committed = await loadPlatformAccount(getAccountPlatform(account), accountId, requestToken);
if (committed && requestToken === appState.selectedAccountRequestToken) {
renderAll();
}
} catch (error) {
alert("加载对标详情失败: " + error.message);
if (requestToken === appState.selectedAccountRequestToken) {
alert("加载对标详情失败: " + error.message);
}
} finally {
setBusy(false, "");
if (requestToken === appState.selectedAccountRequestToken) {
setBusy(false, "");
}
}
return;
}

View File

@@ -0,0 +1,111 @@
(function () {
function detectDefaultBackendUrl() {
if (typeof window === "undefined") {
return "http://127.0.0.1:8081";
}
const { origin, hostname, port, pathname } = window.location;
if (/^https?:/i.test(origin) && hostname === "storyforge.hyzq.net") {
return origin;
}
if (/^https?:/i.test(origin) && pathname.startsWith("/storyforge")) {
return `${origin}/storyforge`;
}
if ((hostname === "127.0.0.1" || hostname === "localhost") && port && port !== "8081") {
return "http://127.0.0.1:8081";
}
return "http://127.0.0.1:8081";
}
function normalizeBackendUrl(value, fallback) {
const base = String(value || fallback || "").trim();
return base.replace(/\/$/, "");
}
function create(options = {}) {
const getSession = typeof options.getSession === "function" ? options.getSession : () => null;
const getCapabilities = typeof options.getCapabilities === "function" ? options.getCapabilities : () => null;
const defaultBackendUrl = normalizeBackendUrl(options.defaultBackendUrl, detectDefaultBackendUrl());
function resolveBackendUrl(requestOptions = {}) {
return normalizeBackendUrl(
requestOptions.backendUrl || getSession()?.backendUrl || defaultBackendUrl,
defaultBackendUrl
);
}
async function request(path, requestOptions = {}) {
const backendUrl = resolveBackendUrl(requestOptions);
const headers = { ...(requestOptions.headers || {}) };
const useAuth = requestOptions.auth !== false;
const token = requestOptions.token || getSession()?.token;
if (useAuth && token) headers.Authorization = `Bearer ${token}`;
let body = requestOptions.body;
if (body && !(body instanceof FormData) && !headers["Content-Type"] && !headers["content-type"]) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(body);
}
return fetch(`${backendUrl}${path}`, {
method: requestOptions.method || "GET",
headers,
body,
cache: "no-store"
});
}
async function fetchJson(path, requestOptions = {}) {
const response = await request(path, requestOptions);
const isJson = (response.headers.get("content-type") || "").includes("application/json");
const payload = isJson ? await response.json() : await response.text();
if (!response.ok) {
const detail = typeof payload === "object" && payload
? payload.detail || payload.message || JSON.stringify(payload)
: String(payload || response.statusText);
throw new Error(detail);
}
return payload;
}
async function fetchBlob(path, requestOptions = {}) {
const response = await request(path, requestOptions);
if (!response.ok) {
const payload = (response.headers.get("content-type") || "").includes("application/json")
? await response.json().catch(() => null)
: await response.text().catch(() => "");
const detail = typeof payload === "object" && payload
? payload.detail || payload.message || JSON.stringify(payload)
: String(payload || response.statusText);
throw new Error(detail);
}
return response.blob();
}
async function loadBackendCapabilities(backendUrl) {
const normalizedUrl = normalizeBackendUrl(backendUrl, defaultBackendUrl);
const response = await fetch(`${normalizedUrl}/openapi.json`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
return new Set(Object.keys(payload.paths || {}));
}
function backendSupports(path) {
const capabilities = getCapabilities();
if (!(capabilities instanceof Set)) return true;
return capabilities.has(path);
}
return {
resolveBackendUrl,
fetchJson,
fetchBlob,
loadBackendCapabilities,
backendSupports
};
}
window.StoryForgeApiClient = {
detectDefaultBackendUrl,
create
};
})();

View File

@@ -0,0 +1,193 @@
(function () {
function safeArray(value) {
return Array.isArray(value) ? value : [];
}
function makePlatformRoutes(platform) {
return {
accounts: `/v2/${platform}/accounts`,
workspace: (accountId) => `/v2/${platform}/accounts/${encodeURIComponent(accountId)}/workspace`,
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`,
similarSearches: `/v2/${platform}/similar-searches`,
similarSearchDetail: (searchId) => `/v2/${platform}/similar-searches/${encodeURIComponent(searchId)}`,
benchmarkLinks: (accountId) => `/v2/${platform}/accounts/${encodeURIComponent(accountId)}/benchmark-links`,
trackingAccounts: `/v2/${platform}/tracking/accounts`,
trackingDigest: `/v2/${platform}/tracking/digest`,
trackingRefresh: `/v2/${platform}/tracking/refresh`,
trackingCursor: `/v2/${platform}/tracking/cursor`,
trackingAccountRefresh: (trackedAccountId) => `/v2/${platform}/tracking/accounts/${encodeURIComponent(trackedAccountId)}/refresh`
};
}
function create(options = {}) {
const appState = options.appState || {};
const activePlatforms = safeArray(options.activePlatforms);
const platformRegistry = options.platformRegistry || {};
const storage = options.storage || window.localStorage;
const storageKey = options.storageKey || "";
function normalizePlatformValue(value, fallback = "douyin") {
const normalized = String(value || "").trim().toLowerCase();
if (!normalized) return fallback;
const byValue = activePlatforms.find((item) => item.value === normalized);
if (byValue) return byValue.value;
const byLabel = activePlatforms.find((item) => item.label === value);
return byLabel?.value || fallback;
}
function getPlatformMeta(value) {
return platformRegistry[normalizePlatformValue(value, "")] || null;
}
function platformLabel(value) {
const matched = activePlatforms.find((item) => item.value === normalizePlatformValue(value, ""));
return matched?.label || String(value || "抖音");
}
function getPlatformShortLabel(value) {
return getPlatformMeta(value)?.shortLabel || platformLabel(value);
}
function getRuntimePlatformValues() {
const fromDashboard = safeArray(appState.dashboard?.supported_platforms)
.map((item) => normalizePlatformValue(item, ""))
.filter((item) => item && platformRegistry[item]);
if (fromDashboard.length) {
return fromDashboard;
}
return activePlatforms.map((item) => item.value);
}
function getPlatformOptions() {
return getRuntimePlatformValues().map((value) => ({ value, label: getPlatformMeta(value)?.label || value }));
}
function getPlatformChips() {
return ["全平台", ...getRuntimePlatformValues().map((value) => getPlatformShortLabel(value))];
}
function isWorkbenchPlatform(value) {
return Boolean(getPlatformMeta(value)?.workbenchReady);
}
function getWorkbenchRoute(platform, key, ...args) {
const routes = getPlatformMeta(platform)?.routes;
if (!routes) return "";
const route = routes[key];
if (typeof route === "function") return route(...args);
return route || "";
}
function getAccountPlatform(account) {
return normalizePlatformValue(
account?.platform
|| account?.source_platform
|| account?.metadata?.platform
|| "",
"douyin"
);
}
function getAccountHandle(account) {
return String(
account?.handle
|| account?.douyin_id
|| account?.xhs_id
|| account?.bilibili_uid
|| account?.kuaishou_id
|| account?.wechat_video_id
|| account?.uid
|| account?.username
|| ""
).trim();
}
function getAccountProfileUrl(account) {
return String(account?.profile_url || account?.source_url || account?.homepage_url || "").trim();
}
function getAccountName(account) {
return String(account?.nickname || getAccountHandle(account) || "未命名账号").trim();
}
function getAccountSubtitle(account) {
return getAccountHandle(account) || getAccountProfileUrl(account) || platformLabel(getAccountPlatform(account));
}
function getPendingWorkbenchReason(platform) {
const meta = getPlatformMeta(platform);
return meta?.pendingText || `${platformLabel(platform)}工作台待接入`;
}
function getAccountWorkbenchGate(account) {
const platform = getAccountPlatform(account);
const reason = isWorkbenchPlatform(platform) ? "" : getPendingWorkbenchReason(platform);
return {
platform,
enabled: !reason,
reason
};
}
function getSelectedAccount() {
return appState.selectedWorkspace?.account
|| safeArray(appState.accounts).find((item) => item.id === appState.selectedAccountId)
|| null;
}
function getPreferredPlatform() {
const selectedAccountPlatform = getAccountPlatform(getSelectedAccount());
if (selectedAccountPlatform && isWorkbenchPlatform(selectedAccountPlatform)) return selectedAccountPlatform;
const current = normalizePlatformValue(appState.currentPlatform, "");
if (current && isWorkbenchPlatform(current)) return current;
const sourcePlatform = normalizePlatformValue(
safeArray(appState.contentSources).find((item) => isWorkbenchPlatform(item.platform))?.platform || "",
""
);
if (sourcePlatform) return sourcePlatform;
return "douyin";
}
function setCurrentPlatform(value) {
const normalized = normalizePlatformValue(value, "");
appState.currentPlatform = normalized;
if (!storage || !storageKey) return normalized;
try {
if (normalized) {
storage.setItem(`${storageKey}:currentPlatform`, normalized);
} else {
storage.removeItem(`${storageKey}:currentPlatform`);
}
} catch {}
return normalized;
}
return {
normalizePlatformValue,
getPlatformMeta,
platformLabel,
getPlatformShortLabel,
getRuntimePlatformValues,
getPlatformOptions,
getPlatformChips,
isWorkbenchPlatform,
getWorkbenchRoute,
getAccountPlatform,
getAccountHandle,
getAccountProfileUrl,
getAccountName,
getAccountSubtitle,
getPendingWorkbenchReason,
getAccountWorkbenchGate,
getPreferredPlatform,
setCurrentPlatform
};
}
window.StoryForgePlatformRuntime = {
makePlatformRoutes,
create
};
})();

View File

@@ -0,0 +1,93 @@
(function () {
function getStorage(name) {
try {
return window[name] || null;
} catch {
return null;
}
}
function safeRead(storage, key) {
try {
return storage ? storage.getItem(key) : null;
} catch {
return null;
}
}
function safeWrite(storage, key, value) {
try {
if (storage) storage.setItem(key, value);
} catch {}
}
function safeRemove(storage, key) {
try {
if (storage) storage.removeItem(key);
} catch {}
}
function parseJson(raw) {
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function create(storageKey) {
const sessionStorageRef = getStorage("sessionStorage");
const localStorageRef = getStorage("localStorage");
const lastSeenKey = `${storageKey}:lastSeenAt`;
function loadStoredSession() {
const sessionRaw = safeRead(sessionStorageRef, storageKey);
if (sessionRaw) {
return parseJson(sessionRaw);
}
const raw = safeRead(localStorageRef, storageKey);
if (!raw) return null;
const parsed = parseJson(raw);
if (parsed) {
safeWrite(sessionStorageRef, storageKey, JSON.stringify(parsed));
safeRemove(localStorageRef, storageKey);
}
return parsed;
}
function persistSession(session) {
if (session) {
safeWrite(sessionStorageRef, storageKey, JSON.stringify(session));
safeRemove(localStorageRef, storageKey);
} else {
safeRemove(sessionStorageRef, storageKey);
safeRemove(localStorageRef, storageKey);
}
}
function setLastSeenAt(value) {
const date = value instanceof Date ? value : new Date(value);
const time = Number.isFinite(date.getTime()) ? date.getTime() : Date.now();
safeWrite(localStorageRef, lastSeenKey, String(time));
return time;
}
function getLastSeenAt(fallback = Date.now()) {
const raw = safeRead(localStorageRef, lastSeenKey);
const value = Number(raw || fallback);
return Number.isFinite(value) ? value : fallback;
}
return {
loadStoredSession,
persistSession,
setLastSeenAt,
getLastSeenAt
};
}
window.StoryForgeSessionStore = {
create
};
})();

View File

@@ -1912,6 +1912,9 @@
</main>
</div>
<script src="./assets/storyforge-session-store.js"></script>
<script src="./assets/storyforge-api-client.js"></script>
<script src="./assets/storyforge-platform-runtime.js"></script>
<script src="./assets/app.js"></script>
</body>
</html>