feat: harden storyforge runtime and repo boundary
This commit is contained in:
@@ -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
|
||||
|
||||
21
README.md
21
README.md
@@ -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`
|
||||
创建最高权限账号。未配置时不会再自动写入默认口令账号。
|
||||
|
||||
## 当前架构
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = ""
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
47
docs/STORYFORGE_REPO_BOUNDARY_2026-03-26.md
Normal file
47
docs/STORYFORGE_REPO_BOUNDARY_2026-03-26.md
Normal 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` 作为统一回归入口。
|
||||
@@ -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 路径
|
||||
|
||||
|
||||
@@ -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 || ''}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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 || ''}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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 || ''}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
56
scripts/check_repo_baseline.sh
Executable 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"
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
]:
|
||||
|
||||
@@ -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 视频任务
|
||||
- 创建实拍剪辑任务
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
111
web/storyforge-web-v4/assets/storyforge-api-client.js
Normal file
111
web/storyforge-web-v4/assets/storyforge-api-client.js
Normal 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
|
||||
};
|
||||
})();
|
||||
193
web/storyforge-web-v4/assets/storyforge-platform-runtime.js
Normal file
193
web/storyforge-web-v4/assets/storyforge-platform-runtime.js
Normal 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
|
||||
};
|
||||
})();
|
||||
93
web/storyforge-web-v4/assets/storyforge-session-store.js
Normal file
93
web/storyforge-web-v4/assets/storyforge-session-store.js
Normal 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
|
||||
};
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user