36 Commits

Author SHA1 Message Date
kris
4106347b67 feat: add job details and benchmark actions to web v4 2026-03-22 11:27:02 +08:00
kris
b75c9e275b feat: add storyforge web v4 action workflows 2026-03-22 11:22:10 +08:00
kris
540be80719 feat: connect storyforge web v4 to live workspace data 2026-03-22 11:10:21 +08:00
kris
fe07a5f212 feat: implement storyforge mobile v4 shell 2026-03-22 10:39:53 +08:00
kris
35c97ffe4d feat: add storyforge mobile v4 html prototype 2026-03-22 10:33:03 +08:00
kris
1851625a53 style: refine storyforge ops ui visual rhythm 2026-03-22 08:39:39 +08:00
kris
66db9e8687 style: unify storyforge ops ui actions 2026-03-22 08:12:07 +08:00
kris
98592168b7 style: simplify storyforge ops ui copy 2026-03-22 08:08:04 +08:00
kris
e771919e4a feat: refine storyforge ops ui information architecture 2026-03-22 08:03:02 +08:00
kris
6899ebba60 feat: add storyforge ops ui prototype and tracking digest 2026-03-22 07:38:49 +08:00
kris
6b3774b543 fix: restore visible auth form for douyin workbench 2026-03-21 05:05:44 +08:00
kris
7171dae91c feat: add dedicated douyin workbench entry 2026-03-21 04:57:18 +08:00
kris
9f921fff94 chore: add business smoke check script 2026-03-21 04:52:20 +08:00
kris
39216d18b4 chore: add reliable local business run scripts 2026-03-21 02:59:52 +08:00
kris
c09a976628 feat: upgrade douyin work list filters and ranking 2026-03-21 02:36:18 +08:00
kris
1fb39e040f fix: compact token auth ui 2026-03-21 02:28:44 +08:00
kris
be94836e3c fix: collapse duplicate douyin analysis history 2026-03-21 02:26:42 +08:00
kris
c4222755b1 feat: deepen douyin commercial workbench 2026-03-21 01:34:46 +08:00
kris
f6462dbccc feat: add douyin workbench results ui 2026-03-21 00:52:23 +08:00
kris
741fe4f983 fix: harden douyin control panel auth inputs 2026-03-20 23:31:27 +08:00
kris
5d9c9cf048 feat: add douyin browser control panel 2026-03-20 22:27:54 +08:00
kris
5c52476a45 perf: streamline douyin browser sync handling 2026-03-20 19:41:31 +08:00
kris
4356c46b9e fix: guard douyin creator-center identity merges 2026-03-20 19:31:29 +08:00
kris
10820595cf fix: harden douyin browser capture persistence 2026-03-20 15:14:12 +08:00
kris
1fa1b586f7 feat: add browser-assisted douyin capture flow 2026-03-20 14:51:22 +08:00
kris
7070c3aa85 feat: restore android build path and update status docs 2026-03-20 14:17:33 +08:00
kris
ac6a8a82df feat: add account sync entry and cleanup legacy runtime 2026-03-20 14:10:30 +08:00
kris
98722a580a docs: record huobao override path and credential blocker 2026-03-20 13:47:20 +08:00
kris
e1010503ae docs: record huobao upstream smoke status 2026-03-20 13:26:45 +08:00
kris
1a055a16c2 docs: record douyin workbench validation 2026-03-20 13:19:35 +08:00
kris
f96a37a236 feat: harden douyin sync diagnostics and manual fallback 2026-03-20 13:18:45 +08:00
kris
a906e0ceda feat: formalize live collector douyin deployment 2026-03-20 13:13:03 +08:00
kris
1c539abc6e feat: add content source sync pipeline and harden asr timeouts 2026-03-20 10:11:04 +08:00
kris
63af810236 feat: auto stage real-cut inputs to cutvideo 2026-03-20 06:57:53 +08:00
kris
b145363111 feat: migrate orchestration to n8n and validate lan mvp 2026-03-18 10:05:00 +08:00
kris
d2074c3518 feat: add studio-workbench concept 2026-03-14 21:35:30 +08:00
81 changed files with 21958 additions and 555 deletions

View File

@@ -2,15 +2,34 @@ DEFAULT_EXTERNAL_BASE_URL=http://test.hyzq.net:8081
LOCAL_OPENAI_BASE_URL=http://127.0.0.1:8317/v1
LOCAL_OPENAI_MODEL=GLM-5
LOCAL_OPENAI_API_KEY=
FASTGPT_BASE_URL=http://127.0.0.1:3000
FASTGPT_DATASET_API_KEY=
# Host-side collector runs can keep using N8N_BASE_URL.
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
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
CUTVIDEO_BASE_URL=
CUTVIDEO_API_KEY=
CUTVIDEO_BASE_CONFIG=example.job.yaml
CUTVIDEO_POLL_INTERVAL_SEC=10
CUTVIDEO_MAX_WAIT_SEC=1800
CUTVIDEO_UPLOAD_TIMEOUT_SEC=1800
HUOBAO_BASE_URL=http://127.0.0.1:5678
HUOBAO_POLL_INTERVAL_SEC=10
HUOBAO_MAX_WAIT_SEC=900
YTDLP_BIN=yt-dlp
FFMPEG_BIN=ffmpeg
WHISPER_BIN=
WHISPER_MODEL=./data/collector/models/ggml-base.en.bin
POSTGRES_DB=fastgpt
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin
ASR_HTTP_BASE_URL=
ASR_HTTP_TRANSCRIBE_PATH=/transcribe
ASR_HTTP_FIELD_NAME=wav
ASR_HTTP_TIMEOUT_SEC=120
N8N_IMAGE=docker.n8n.io/n8nio/n8n:latest
WEBHOOK_URL=http://127.0.0.1:5670/
GENERIC_TIMEZONE=Asia/Shanghai
TZ=Asia/Shanghai
CLIPROXY_IMAGE=storyforge/cli-proxy-api:patched

4
.gitignore vendored
View File

@@ -20,9 +20,13 @@ build/
.kotlin/
**/.gradle/
**/.kotlin/
node_modules/
**/node_modules/
# Runtime data and artifacts
data/
!android-app/app/src/main/java/com/aiglasses/app/data/
!android-app/app/src/main/java/com/aiglasses/app/data/**
output/
*.log

127
README.md
View File

@@ -5,36 +5,151 @@ StoryForge 现在拆成独立项目目录,和 `AI-glasses` 分开维护。
## 目录
- `android-app/`StoryForge Android 客户端
- `collector-service/`FastAPI 后端,提供登录、审批、素材导入、知识库、智能体和 OTA
- `docker-compose.yml`:本地 FastGPT / collector / 基础依赖编排
- `collector-service/`FastAPI 后端,负责用户体系、项目、Agent、任务、内容分析和对外能力接入
- `n8n/`:工作流导出文件,作为流程编排中枢
- `docker-compose.yml`:本地 `collector + n8n + cli-proxy-api` 编排
- `Common/`:项目约束和架构说明
- `data/collector/`SQLite、任务文件、下载产物
- `docs/`:审计、实施计划、联调说明、当前 MVP 状态
## 产品手册
- [新媒体运营中台产品逻辑手册](./docs/PRODUCT_LOGIC_NEW_MEDIA_OPERATING_SYSTEM_2026-03-22.md)
- [新媒体运营平台 UI 参考包](./output/ui/new-media-ops-reference-2026-03-22/README.md)
- [Web V4 UI 原型](./output/ui/storyforge-web-v4-html-prototype-2026-03-22/README.md)
- [Web V4 前端骨架](./web/storyforge-web-v4/README.md)
- [Mobile V4 UI 原型](./output/ui/storyforge-mobile-v4-html-prototype-2026-03-22/README.md)
## Android
```bash
cd /Users/kris/code/StoryForge/android-app
cd /Users/kris/code/StoryForge-gitea/android-app
./gradlew assembleDebug
```
## Douyin Browser Capture
```bash
cd /Users/kris/code/StoryForge-gitea
./scripts/start_douyin_workbench.sh
```
业务页:
```text
http://127.0.0.1:3618/workbench
```
完整采集控制台:
```text
http://127.0.0.1:3618
```
常用脚本:
```bash
./scripts/start_douyin_workbench.sh
./scripts/status_douyin_workbench.sh
./scripts/stop_douyin_workbench.sh
./scripts/cleanup_debug_ui.sh
```
如果第一次使用,还需要先安装浏览器依赖:
```bash
cd /Users/kris/code/StoryForge-gitea/scripts/douyin-browser-capture
npm install
npx playwright install chromium
```
当前本地页面已经拆成两个入口:
- `/workbench`:业务优先的 `Douyin Workbench`,可直接查看账号列表、商业化账号分析、快照详情、相似账号和对标关系
- `/`:完整浏览器辅助采集控制台,同时保留工作台能力
- 作品工作台支持按 `高分作品 / 最新作品 / 全部作品` 切换,并可按综合分、受欢迎程度、商业价值、发布时间、播放、点赞、分享、评论排序
- 作品列表支持 `视频 / 图文` 类型筛选,并可直接打开原作品链接
- 高分作品支持自动化分析,每条作品卡片下都会展示商业判断、复刻计划、运营动作和风险提醒
或者继续用命令行:
```bash
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”的辅助采集工具
- 默认输出到 `output/playwright/douyin/`
- 本地控制台模式会把每次运行保存到 `output/playwright/douyin/control-panel/`
- 控制台支持“开始采集 -> 浏览器登录 -> 网页点继续 -> 自动同步”的点击式流程
- 详细说明见 `scripts/douyin-browser-capture/README.md`
## Collector Service
```bash
cd /Users/kris/code/StoryForge/collector-service
cd /Users/kris/code/StoryForge-gitea/collector-service
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 8081 --reload
```
## Docker Compose
```bash
cd /Users/kris/code/StoryForge-gitea
cp .env.example .env
docker compose up -d --build
```
如果 `collector` 跑在 Docker 里,建议保留:
```bash
COLLECTOR_N8N_BASE_URL=http://n8n:5678
```
如果你单独在宿主机启动 `collector-service`,它读取的仍然是:
```bash
N8N_BASE_URL=http://127.0.0.1:5670
```
默认会启动:
- `collector-service``http://127.0.0.1:8081`
- `n8n``http://127.0.0.1:5670`
- `cli-proxy-api``http://127.0.0.1:8317`
默认会创建最高权限账号:
- `kris`
- `Asd123456.`
## 当前架构
- `collector-service` 负责:
- 用户账号、多项目、多 Agent、多任务、多内容源数据边界
- 调用下载器、本地 ASR、本机 OpenAI 兼容模型
- 调用 Windows `cutvideo``huobao-drama`
- 持久化任务、分镜、分析结果、事件日志
- `n8n` 负责:
- 触发 `analysis_pipeline`
- 触发 `content_source_sync_pipeline`
- 触发 `real_cut_pipeline`
- 触发 `ai_video_pipeline`
- FastGPT 已从主流程设计中移除,不再作为运行时依赖
## 说明
- 新注册账号默认 `pending`
- 主管理员审批后才可使用核心业务接口
- 素材入口支持文字、视频链接、视频上传
- 可选对接本机 OpenAI 兼容模型服务和 FastGPT 数据集 API
- 支持 `user -> project -> knowledge base / assistant(agent) / job / content source` 的多租户边界
- 素材入口支持文字、视频链接、视频上传;内容源账号通过 `content_sources` 建模持久化,并可派生父子分析任务
- `cutvideo` 继续运行在 Windows 机器,本系统通过 API 调度
- `huobao-drama` 继续作为 AI 生成视频主链的核心引擎
- 详细审计、阶段计划和联调步骤见 `docs/`

View File

@@ -0,0 +1,50 @@
package com.aiglasses.app.data
import kotlinx.serialization.json.Json
import kotlinx.serialization.ExperimentalSerializationApi
import java.util.concurrent.TimeUnit
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Protocol
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.create
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
object ApiClient {
@OptIn(ExperimentalSerializationApi::class)
val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
inline fun <reified T : Any> createService(baseUrl: String): T {
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
val client = OkHttpClient.Builder()
.protocols(listOf(Protocol.HTTP_1_1))
.connectTimeout(12, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.callTimeout(25, TimeUnit.SECONDS)
.addInterceptor { chain ->
val request: Request = chain.request().newBuilder()
.header("Connection", "close")
.build()
chain.proceed(request)
}
.addInterceptor(logging)
.build()
val normalizedBaseUrl = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/"
return Retrofit.Builder()
.baseUrl(normalizedBaseUrl)
.client(client)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create<T>()
}
}

View File

@@ -0,0 +1,154 @@
package com.aiglasses.app.data
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface ApiService {
@GET("/healthz")
suspend fun healthz(): ApiEnvelope<HealthzData>
@POST("/api/v1/devices/bind-confirm")
suspend fun bindConfirm(
@Body request: BindConfirmRequest
): ApiEnvelope<BindConfirmData>
@POST("/api/v1/ai/sessions")
suspend fun createSession(
@Header("Idempotency-Key") idempotencyKey: String?,
@Body request: CreateSessionRequest
): ApiEnvelope<SessionData>
@POST("/api/v1/ai/sessions/{sessionId}/stop")
suspend fun stopSession(
@Path("sessionId") sessionId: String,
@Body request: StopSessionRequest
): ApiEnvelope<StopSessionData>
@POST("/api/v1/ai/sessions/{sessionId}/heartbeat")
suspend fun heartbeat(
@Path("sessionId") sessionId: String,
@Body request: HeartbeatRequest
): ApiEnvelope<HeartbeatData>
@GET("/api/v1/devices/{deviceId}/status")
suspend fun getDeviceStatus(
@Path("deviceId") deviceId: String
): ApiEnvelope<DeviceStatusData>
@POST("/api/v1/events")
suspend fun postEvent(
@Body request: ClientEventRequest
): ApiEnvelope<EventSavedData>
@POST("/api/v1/events/batch")
suspend fun postEventsBatch(
@Body request: ClientEventBatchRequest
): ApiEnvelope<EventsBatchSavedData>
@POST("/api/v1/ai/sessions/{sessionId}/messages")
suspend fun sendMessage(
@Path("sessionId") sessionId: String,
@Body request: SessionMessageRequest
): ApiEnvelope<ProviderActionData>
@POST("/api/v1/ai/sessions/{sessionId}/scene-role")
suspend fun switchRole(
@Path("sessionId") sessionId: String,
@Body request: SwitchRoleRequest
): ApiEnvelope<ProviderActionData>
@POST("/api/v1/ai/sessions/{sessionId}/interrupt")
suspend fun interruptSession(
@Path("sessionId") sessionId: String,
@Body request: SessionInterruptRequest
): ApiEnvelope<ProviderActionData>
@GET("/api/v1/baidu/activation/query")
suspend fun activationQuery(
@Query("deviceId") deviceId: String,
@Query("appId") appId: String? = null
): ApiEnvelope<ActivationQueryData>
@POST("/api/v1/licenses/reload")
suspend fun reloadLicenses(): ApiEnvelope<ReloadLicensesData>
@GET("/api/v1/admin/overview")
suspend fun adminOverview(): ApiEnvelope<AdminOverviewData>
@GET("/api/v1/app/update/latest")
suspend fun appUpdateLatest(
@Query("platform") platform: String = "android",
@Query("channel") channel: String = "stable",
@Query("currentVersionCode") currentVersionCode: Int
): ApiEnvelope<AppUpdateLatestData>
@GET("/v2/douyin/accounts")
suspend fun listDouyinAccounts(): ApiEnvelope<List<DouyinAccountSummary>>
@POST("/v2/douyin/accounts/sync")
suspend fun syncDouyinAccount(
@Body request: DouyinAccountSyncRequest
): ApiEnvelope<DouyinAccountWorkspace>
@GET("/v2/douyin/accounts/{accountId}")
suspend fun getDouyinAccount(
@Path("accountId") accountId: String
): ApiEnvelope<DouyinAccountWorkspace>
@GET("/v2/douyin/accounts/{accountId}/workspace")
suspend fun getDouyinWorkspace(
@Path("accountId") accountId: String
): ApiEnvelope<DouyinAccountWorkspace>
@GET("/v2/douyin/accounts/{accountId}/snapshots")
suspend fun listDouyinSnapshots(
@Path("accountId") accountId: String
): ApiEnvelope<List<DouyinSnapshotSummary>>
@GET("/v2/douyin/accounts/{accountId}/snapshots/{snapshotId}")
suspend fun getDouyinSnapshot(
@Path("accountId") accountId: String,
@Path("snapshotId") snapshotId: String
): ApiEnvelope<DouyinSnapshotDetail>
@GET("/v2/douyin/accounts/{accountId}/creator-fields")
suspend fun getDouyinCreatorFields(
@Path("accountId") accountId: String
): ApiEnvelope<DouyinSnapshotDetail>
@POST("/v2/douyin/accounts/{accountId}/analysis")
suspend fun analyzeDouyinAccount(
@Path("accountId") accountId: String,
@Body request: DouyinAccountAnalysisRequest
): ApiEnvelope<DouyinAnalysisResult>
@GET("/v2/douyin/accounts/{accountId}/analysis-reports")
suspend fun listDouyinAnalysisReports(
@Path("accountId") accountId: String
): ApiEnvelope<List<DouyinAnalysisReport>>
@POST("/v2/douyin/similar-searches")
suspend fun createDouyinSimilarSearch(
@Body request: DouyinSimilarSearchRequest
): ApiEnvelope<DouyinSimilaritySearchResult>
@GET("/v2/douyin/similar-searches/{searchId}")
suspend fun getDouyinSimilarSearch(
@Path("searchId") searchId: String
): ApiEnvelope<DouyinSimilaritySearchDetail>
@GET("/v2/douyin/accounts/{accountId}/benchmark-links")
suspend fun listDouyinBenchmarkLinks(
@Path("accountId") accountId: String
): ApiEnvelope<List<DouyinLinkedAccount>>
@POST("/v2/douyin/accounts/{accountId}/benchmark-links")
suspend fun createDouyinBenchmarkLinks(
@Path("accountId") accountId: String,
@Body request: DouyinBenchmarkLinkRequest
): ApiEnvelope<DouyinBenchmarkLinkResult>
}

View File

@@ -0,0 +1,276 @@
package com.aiglasses.app.data
import java.util.UUID
class BackendRepository(private var baseUrl: String) {
private var api: ApiService = ApiClient.createService(baseUrl)
fun updateBaseUrl(url: String) {
if (url != baseUrl) {
baseUrl = url
api = ApiClient.createService(baseUrl)
}
}
suspend fun bindDevice(deviceId: String, userId: String): BindConfirmData {
val resp = api.bindConfirm(BindConfirmRequest(deviceId = deviceId, appUserId = userId))
return resp.data
}
suspend fun healthz(): HealthzData {
val resp = api.healthz()
return resp.data
}
suspend fun createSession(deviceId: String, userId: String): SessionData {
val idempotencyKey = "app-${UUID.randomUUID()}"
val resp = api.createSession(
idempotencyKey = idempotencyKey,
request = CreateSessionRequest(deviceId = deviceId, appUserId = userId)
)
return resp.data
}
suspend fun stopSession(sessionId: String): StopSessionData {
val resp = api.stopSession(sessionId, StopSessionRequest())
return resp.data
}
suspend fun heartbeat(sessionId: String): HeartbeatData {
val resp = api.heartbeat(sessionId, HeartbeatRequest())
return resp.data
}
suspend fun getDeviceStatus(deviceId: String): DeviceStatusData {
val resp = api.getDeviceStatus(deviceId)
return resp.data
}
suspend fun postDemoEvent(deviceId: String, sessionId: String?): EventSavedData {
return postEvent(
deviceId = deviceId,
sessionId = sessionId,
eventType = "APP_DEBUG_PING",
eventLevel = "INFO",
payload = mapOf("source" to "android")
)
}
suspend fun postEvent(
deviceId: String,
sessionId: String?,
eventType: String,
eventLevel: String = "INFO",
payload: Map<String, String> = emptyMap()
): EventSavedData {
val resp = api.postEvent(
ClientEventRequest(
sessionId = sessionId,
deviceId = deviceId,
eventType = eventType,
eventLevel = eventLevel,
payload = payload
)
)
return resp.data
}
suspend fun postEventsBatch(events: List<ClientEventRequest>): EventsBatchSavedData {
val resp = api.postEventsBatch(ClientEventBatchRequest(events = events))
return resp.data
}
suspend fun sendMessage(sessionId: String, message: String): ProviderActionData {
val resp = api.sendMessage(
sessionId = sessionId,
request = SessionMessageRequest(message = message)
)
return resp.data
}
suspend fun sendVoiceMessage(
sessionId: String,
pcmBase64: String,
sampleRate: Int,
durationMs: Int,
rms: Int
): ProviderActionData {
val resp = api.sendMessage(
sessionId = sessionId,
request = SessionMessageRequest(
message = "voice_chunk",
messageType = "voice",
extra = mapOf(
"audio_base64" to pcmBase64,
"audio_format" to "pcm_s16le",
"sample_rate" to sampleRate.toString(),
"channels" to "1",
"duration_ms" to durationMs.toString(),
"rms" to rms.toString(),
"encoding" to "base64"
)
)
)
return resp.data
}
suspend fun sendVisionMessage(
sessionId: String,
message: String,
imageBase64: String,
width: Int,
height: Int,
bytes: Int
): ProviderActionData {
val resp = api.sendMessage(
sessionId = sessionId,
request = SessionMessageRequest(
message = message,
messageType = "text",
extra = mapOf(
"image_base64" to imageBase64,
"imageBase64" to imageBase64,
"image" to imageBase64,
"resource_base64" to imageBase64,
"resourceBase64" to imageBase64,
"image_encoding" to "base64",
"imageEncoding" to "base64",
"encoding" to "base64",
"image_format" to "jpeg",
"imageFormat" to "jpeg",
"mime_type" to "image/jpeg",
"mimeType" to "image/jpeg",
"image_width" to width.toString(),
"imageWidth" to width.toString(),
"image_height" to height.toString(),
"imageHeight" to height.toString(),
"image_bytes" to bytes.toString(),
"imageBytes" to bytes.toString(),
"resource_type" to "image",
"resourceType" to "image",
"camera_source" to "android_phone",
"multimodal" to "true",
"with_vision" to "1"
)
)
)
return resp.data
}
suspend fun switchRole(sessionId: String, sceneId: String, roleId: String): ProviderActionData {
val resp = api.switchRole(
sessionId = sessionId,
request = SwitchRoleRequest(sceneId = sceneId, roleId = roleId)
)
return resp.data
}
suspend fun interrupt(
sessionId: String,
interrupt: Boolean,
extra: Map<String, String> = emptyMap()
): ProviderActionData {
val resp = api.interruptSession(
sessionId = sessionId,
request = SessionInterruptRequest(interrupt = interrupt, extra = extra)
)
return resp.data
}
suspend fun activationQuery(deviceId: String): ActivationQueryData {
val resp = api.activationQuery(deviceId = deviceId)
return resp.data
}
suspend fun reloadLicenses(): ReloadLicensesData {
val resp = api.reloadLicenses()
return resp.data
}
suspend fun adminOverview(): AdminOverviewData {
val resp = api.adminOverview()
return resp.data
}
suspend fun appUpdateLatest(currentVersionCode: Int): AppUpdateLatestData {
val resp = api.appUpdateLatest(
platform = "android",
channel = "stable",
currentVersionCode = currentVersionCode
)
return resp.data
}
suspend fun listDouyinAccounts(): List<DouyinAccountSummary> {
val resp = api.listDouyinAccounts()
return resp.data
}
suspend fun syncDouyinAccount(request: DouyinAccountSyncRequest): DouyinAccountWorkspace {
val resp = api.syncDouyinAccount(request)
return resp.data
}
suspend fun getDouyinAccount(accountId: String): DouyinAccountWorkspace {
val resp = api.getDouyinAccount(accountId)
return resp.data
}
suspend fun getDouyinWorkspace(accountId: String): DouyinAccountWorkspace {
val resp = api.getDouyinWorkspace(accountId)
return resp.data
}
suspend fun listDouyinSnapshots(accountId: String): List<DouyinSnapshotSummary> {
val resp = api.listDouyinSnapshots(accountId)
return resp.data
}
suspend fun getDouyinSnapshot(accountId: String, snapshotId: String): DouyinSnapshotDetail {
val resp = api.getDouyinSnapshot(accountId, snapshotId)
return resp.data
}
suspend fun getDouyinCreatorFields(accountId: String): DouyinSnapshotDetail {
val resp = api.getDouyinCreatorFields(accountId)
return resp.data
}
suspend fun analyzeDouyinAccount(
accountId: String,
request: DouyinAccountAnalysisRequest
): DouyinAnalysisResult {
val resp = api.analyzeDouyinAccount(accountId, request)
return resp.data
}
suspend fun listDouyinAnalysisReports(accountId: String): List<DouyinAnalysisReport> {
val resp = api.listDouyinAnalysisReports(accountId)
return resp.data
}
suspend fun createDouyinSimilarSearch(
request: DouyinSimilarSearchRequest
): DouyinSimilaritySearchResult {
val resp = api.createDouyinSimilarSearch(request)
return resp.data
}
suspend fun getDouyinSimilarSearch(searchId: String): DouyinSimilaritySearchDetail {
val resp = api.getDouyinSimilarSearch(searchId)
return resp.data
}
suspend fun listDouyinBenchmarkLinks(accountId: String): List<DouyinLinkedAccount> {
val resp = api.listDouyinBenchmarkLinks(accountId)
return resp.data
}
suspend fun createDouyinBenchmarkLinks(
accountId: String,
request: DouyinBenchmarkLinkRequest
): DouyinBenchmarkLinkResult {
val resp = api.createDouyinBenchmarkLinks(accountId, request)
return resp.data
}
}

View File

@@ -0,0 +1,540 @@
package com.aiglasses.app.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
@Serializable
data class ApiEnvelope<T>(
val code: Int,
val message: String,
val traceId: String,
val data: T
)
@Serializable
data class HealthzData(
val status: String = "",
val env: String = "",
val dbPath: String = ""
)
@Serializable
data class BindConfirmRequest(
val deviceId: String,
val deviceSn: String? = null,
val deviceModel: String? = null,
val deviceFwVer: String? = null,
val appUserId: String
)
@Serializable
data class BindConfirmData(
val bindStatus: String,
val licenseStatus: String,
val licenseKeyMasked: String,
val licenseKey: String = ""
)
@Serializable
data class CreateSessionRequest(
val deviceId: String,
val appUserId: String,
val scene: String = "voice_assistant",
val language: String = "zh-CN",
val clientTs: Long? = null
)
@Serializable
data class SessionData(
val sessionId: String,
val provider: String,
val cid: String,
val token: String,
val tokenExpireAt: Long,
val wsUrl: String,
val heartbeatSec: Int,
val appId: String = "",
val context: String = "",
val realtimeWsUrl: String = ""
)
@Serializable
data class StopSessionRequest(
val reason: String = "user_stop"
)
@Serializable
data class StopSessionData(
val sessionStatus: String
)
@Serializable
data class HeartbeatRequest(
val networkType: String? = "wifi",
val bleRssi: Int? = null
)
@Serializable
data class HeartbeatData(
val sessionStatus: String,
val heartbeatAt: Long
)
@Serializable
data class ClientEventRequest(
val sessionId: String? = null,
val deviceId: String,
val eventType: String,
val eventLevel: String = "INFO",
val payload: Map<String, String> = emptyMap(),
val ts: Long? = null
)
@Serializable
data class ClientEventBatchRequest(
val events: List<ClientEventRequest> = emptyList()
)
@Serializable
data class EventSavedData(
val saved: Boolean
)
@Serializable
data class EventsBatchSavedData(
val saved: Int = 0
)
@Serializable
data class SessionMessageRequest(
val message: String,
val messageType: String = "text",
val messageId: String? = null,
val extra: Map<String, String> = emptyMap()
)
@Serializable
data class ProviderActionData(
val status: String = "UNKNOWN",
val detail: String = "",
val asrText: String = "",
val ttsText: String = "",
val audioBase64: String = "",
val audioUrl: String = ""
)
@Serializable
data class SwitchRoleRequest(
val sceneId: String,
val roleId: String,
val extra: Map<String, String> = emptyMap()
)
@Serializable
data class SessionInterruptRequest(
val interrupt: Boolean = true,
val extra: Map<String, String> = emptyMap()
)
@Serializable
data class DeviceStatusData(
val bindStatus: String,
val licenseStatus: String,
val activeSessionId: String? = null,
val activeSessionStatus: String? = null
)
@Serializable
data class AdminStats(
val totalDevices: Int = 0,
val totalSessions: Int = 0,
val runningSessions: Int = 0,
val totalLicenses: Int = 0,
val usedLicenseQuota: Int = 0
)
@Serializable
data class BaiduInfo(
val mode: String = "-",
val generateConfigured: Boolean = false,
val stopConfigured: Boolean = false,
val activationQueryConfigured: Boolean = false
)
@Serializable
data class AdminOverviewData(
val stats: AdminStats = AdminStats(),
val baidu: BaiduInfo = BaiduInfo()
)
@Serializable
data class ActivationQueryData(
val deviceId: String = "",
val appId: String = "",
val status: String = "UNKNOWN",
val detail: String = "",
val licenseKeyMasked: String = ""
)
@Serializable
data class ReloadLicensesData(
val inserted: Int = 0
)
@Serializable
data class AppUpdateLatestData(
val platform: String = "android",
val channel: String = "stable",
val hasUpdate: Boolean = false,
val latestVersionCode: Int = 0,
val latestVersionName: String = "",
val minSupportedCode: Int = 0,
val downloadUrl: String = "",
val apkSha256: String = "",
val releaseNotes: String = "",
val forceUpdate: Boolean = false,
val publishedAt: Long = 0L
)
@Serializable
data class DouyinManualPageCaptureRequest(
val url: String = "",
val title: String = "",
val payload: JsonObject = JsonObject(emptyMap())
)
@Serializable
data class DouyinAccountSyncRequest(
@SerialName("profile_url")
val profileUrl: String = "",
@SerialName("session_cookie")
val sessionCookie: String = "",
@SerialName("creator_center_urls")
val creatorCenterUrls: List<String> = emptyList(),
@SerialName("manual_profile_payload")
val manualProfilePayload: JsonObject? = null,
@SerialName("manual_creator_pages")
val manualCreatorPages: List<DouyinManualPageCaptureRequest> = emptyList(),
@SerialName("manual_work_payloads")
val manualWorkPayloads: List<JsonObject> = emptyList(),
@SerialName("discovery_note")
val discoveryNote: String = ""
)
@Serializable
data class DouyinProfileStats(
val followers: Double = 0.0,
val following: Double = 0.0,
val likes: Double = 0.0,
val videos: Double = 0.0
)
@Serializable
data class DouyinVideoStats(
val play: Double = 0.0,
val like: Double = 0.0,
val comment: Double = 0.0,
val share: Double = 0.0,
val collect: Double = 0.0
)
@Serializable
data class DouyinVideoSummaryItem(
@SerialName("aweme_id")
val awemeId: String = "",
val title: String = "",
val description: String = "",
val tags: List<String> = emptyList(),
@SerialName("published_at")
val publishedAt: String? = null,
val stats: DouyinVideoStats = DouyinVideoStats()
)
@Serializable
data class DouyinVideoSummary(
val count: Int = 0,
@SerialName("top_tags")
val topTags: List<String> = emptyList(),
@SerialName("avg_play")
val avgPlay: Double = 0.0,
@SerialName("avg_like")
val avgLike: Double = 0.0,
@SerialName("avg_comment")
val avgComment: Double = 0.0,
@SerialName("avg_share")
val avgShare: Double = 0.0,
val videos: List<DouyinVideoSummaryItem> = emptyList()
)
@Serializable
data class DouyinAccountSummary(
val id: String = "",
val nickname: String = "",
val signature: String = "",
@SerialName("profile_url")
val profileUrl: String = "",
@SerialName("avatar_url")
val avatarUrl: String = "",
@SerialName("sec_uid")
val secUid: String = "",
@SerialName("douyin_id")
val douyinId: String = "",
@SerialName("profile_stats")
val profileStats: DouyinProfileStats = DouyinProfileStats(),
val tags: List<String> = emptyList(),
val keywords: List<String> = emptyList(),
@SerialName("sync_status")
val syncStatus: String = "",
@SerialName("video_summary")
val videoSummary: DouyinVideoSummary = DouyinVideoSummary()
)
@Serializable
data class DouyinSnapshotSummary(
val id: String = "",
@SerialName("snapshot_type")
val snapshotType: String = "",
@SerialName("source_url")
val sourceUrl: String = "",
@SerialName("field_count")
val fieldCount: Int = 0,
@SerialName("collected_at")
val collectedAt: String = "",
val summary: JsonObject = JsonObject(emptyMap())
)
@Serializable
data class DouyinModelProfileSummary(
val id: String = "",
val name: String = "",
@SerialName("model_name")
val modelName: String = "",
@SerialName("base_url")
val baseUrl: String = "",
@SerialName("is_default")
val isDefault: Boolean = false
)
@Serializable
data class DouyinAnalysisSuggestion(
val id: String = "",
@SerialName("model_profile_id")
val modelProfileId: String = "",
@SerialName("model_label")
val modelLabel: String = "",
val status: String = "",
@SerialName("suggestion_text")
val suggestionText: String = "",
@SerialName("parsed_json")
val parsedJson: JsonElement = JsonObject(emptyMap())
)
@Serializable
data class DouyinAnalysisReport(
val id: String = "",
@SerialName("focus_text")
val focusText: String = "",
@SerialName("model_profile_ids")
val modelProfileIds: List<String> = emptyList(),
@SerialName("linked_account_ids")
val linkedAccountIds: List<String> = emptyList(),
@SerialName("created_at")
val createdAt: String = "",
val suggestions: List<DouyinAnalysisSuggestion> = emptyList()
)
@Serializable
data class DouyinSimilaritySearchPreview(
val id: String = "",
val keywords: List<String> = emptyList(),
@SerialName("created_at")
val createdAt: String = ""
)
@Serializable
data class DouyinLinkedAccount(
@SerialName("relation_id")
val relationId: String = "",
@SerialName("relation_type")
val relationType: String = "",
val note: String = "",
@SerialName("search_id")
val searchId: String = "",
@SerialName("created_at")
val createdAt: String = "",
@SerialName("target_account_id")
val targetAccountId: String? = null,
@SerialName("target_profile_url")
val targetProfileUrl: String = "",
@SerialName("target_nickname")
val targetNickname: String = "",
@SerialName("target_signature")
val targetSignature: String = "",
@SerialName("target_profile_stats")
val targetProfileStats: DouyinProfileStats = DouyinProfileStats(),
@SerialName("target_tags")
val targetTags: List<String> = emptyList()
)
@Serializable
data class DouyinAccountWorkspace(
val account: DouyinAccountSummary = DouyinAccountSummary(),
@SerialName("latest_public_snapshot")
val latestPublicSnapshot: DouyinSnapshotSummary? = null,
@SerialName("latest_creator_snapshot")
val latestCreatorSnapshot: DouyinSnapshotSummary? = null,
@SerialName("linked_accounts")
val linkedAccounts: List<DouyinLinkedAccount> = emptyList(),
@SerialName("recent_reports")
val recentReports: List<DouyinAnalysisReport> = emptyList(),
@SerialName("recent_similarity_searches")
val recentSimilaritySearches: List<DouyinSimilaritySearchPreview> = emptyList(),
@SerialName("available_model_profiles")
val availableModelProfiles: List<DouyinModelProfileSummary> = emptyList(),
@SerialName("sync_errors")
val syncErrors: List<String> = emptyList()
)
@Serializable
data class DouyinAccountAnalysisRequest(
@SerialName("model_profile_ids")
val modelProfileIds: List<String> = emptyList(),
@SerialName("linked_account_ids")
val linkedAccountIds: List<String> = emptyList(),
@SerialName("include_linked_accounts")
val includeLinkedAccounts: Boolean = true,
@SerialName("include_recent_similar_candidates")
val includeRecentSimilarCandidates: Boolean = true,
@SerialName("max_videos")
val maxVideos: Int = 12,
@SerialName("extra_focus")
val extraFocus: String = "",
val temperature: Double = 0.35
)
@Serializable
data class DouyinAnalysisResult(
@SerialName("report_id")
val reportId: String = "",
@SerialName("created_at")
val createdAt: String = "",
val context: JsonElement = JsonObject(emptyMap()),
val suggestions: List<DouyinAnalysisSuggestion> = emptyList()
)
@Serializable
data class DouyinSimilarSearchRequest(
@SerialName("source_account_id")
val sourceAccountId: String? = null,
@SerialName("profile_url")
val profileUrl: String? = null,
@SerialName("candidate_urls")
val candidateUrls: List<String> = emptyList(),
@SerialName("seed_linked_accounts")
val seedLinkedAccounts: Boolean = true,
@SerialName("search_public_pages")
val searchPublicPages: Boolean = true,
@SerialName("model_profile_id")
val modelProfileId: String? = null,
@SerialName("max_candidates")
val maxCandidates: Int = 10,
@SerialName("extra_requirements")
val extraRequirements: String = ""
)
@Serializable
data class DouyinSimilarCandidate(
val id: String = "",
@SerialName("candidate_account_id")
val candidateAccountId: String? = null,
@SerialName("candidate_profile_url")
val candidateProfileUrl: String = "",
@SerialName("candidate_nickname")
val candidateNickname: String = "",
@SerialName("heuristic_score")
val heuristicScore: Double = 0.0,
@SerialName("agent_score")
val agentScore: Double = 0.0,
@SerialName("rationale_text")
val rationaleText: String = "",
val dimensions: JsonElement = JsonObject(emptyMap()),
@SerialName("rank_index")
val rankIndex: Int = 0
)
@Serializable
data class DouyinSimilaritySearchResult(
@SerialName("search_id")
val searchId: String = "",
@SerialName("source_account")
val sourceAccount: DouyinAccountSummary = DouyinAccountSummary(),
@SerialName("model_profile")
val modelProfile: JsonObject = JsonObject(emptyMap()),
@SerialName("raw_model_output")
val rawModelOutput: String = "",
val candidates: List<DouyinSimilarCandidate> = emptyList()
)
@Serializable
data class DouyinSimilaritySearchDetail(
val id: String = "",
@SerialName("source_account_id")
val sourceAccountId: String? = null,
@SerialName("source_profile_url")
val sourceProfileUrl: String = "",
val keywords: List<String> = emptyList(),
val context: JsonElement = JsonObject(emptyMap()),
@SerialName("created_at")
val createdAt: String = "",
val candidates: List<DouyinSimilarCandidate> = emptyList()
)
@Serializable
data class DouyinBenchmarkLinkRequest(
@SerialName("target_account_ids")
val targetAccountIds: List<String> = emptyList(),
@SerialName("target_profile_urls")
val targetProfileUrls: List<String> = emptyList(),
@SerialName("relation_type")
val relationType: String = "benchmark",
val note: String = "",
@SerialName("search_id")
val searchId: String = ""
)
@Serializable
data class DouyinBenchmarkLinkResult(
val saved: Int = 0,
@SerialName("relation_ids")
val relationIds: List<String> = emptyList(),
val links: List<DouyinLinkedAccount> = emptyList()
)
@Serializable
data class DouyinSnapshotField(
@SerialName("field_path")
val fieldPath: String = "",
@SerialName("field_type")
val fieldType: String = "",
@SerialName("field_value_text")
val fieldValueText: String = ""
)
@Serializable
data class DouyinSnapshotDetail(
val id: String = "",
@SerialName("snapshot_type")
val snapshotType: String = "",
@SerialName("source_url")
val sourceUrl: String = "",
@SerialName("field_count")
val fieldCount: Int = 0,
@SerialName("collected_at")
val collectedAt: String = "",
val summary: JsonObject = JsonObject(emptyMap()),
@SerialName("raw_payload")
val rawPayload: JsonElement = JsonObject(emptyMap()),
val fields: List<DouyinSnapshotField> = emptyList()
)

View File

@@ -57,6 +57,9 @@ interface StoryForgeApiService {
@POST("v2/explore/text")
suspend fun createTextJob(@Body request: ExploreTextRequest): JobDto
@POST("v2/pipelines/content-source-sync")
suspend fun createContentSourceSyncJob(@Body request: ContentSourceSyncRequest): JobDto
@Multipart
@POST("v2/explore/upload-video")
suspend fun uploadVideo(

View File

@@ -1,6 +1,10 @@
package com.aiglasses.app.storyforge
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
@Serializable
data class RegisterAccountRequest(
@@ -66,12 +70,22 @@ data class PreferredModelRequest(
)
@Serializable
data class KnowledgeBaseDto(
data class ProjectDto(
val id: String,
val user_id: String,
val name: String,
val description: String = "",
val fastgpt_dataset_id: String? = null,
val created_at: String = "",
val updated_at: String = ""
)
@Serializable
data class KnowledgeBaseDto(
val id: String,
val user_id: String,
val project_id: String = "",
val name: String,
val description: String = "",
val sync_status: String = "pending",
val document_count: Int = 0,
val linked_assistant_count: Int = 0,
@@ -82,6 +96,7 @@ data class KnowledgeBaseDto(
@Serializable
data class KnowledgeBaseCreateRequest(
val name: String,
val project_id: String = "",
val description: String = ""
)
@@ -89,12 +104,13 @@ data class KnowledgeBaseCreateRequest(
data class AssistantDto(
val id: String,
val user_id: String,
val project_id: String = "",
val name: String,
val description: String = "",
val system_prompt: String = "",
val generation_goal: String = "",
val knowledge_base_ids: List<String> = emptyList(),
val fastgpt_app_key: String = "",
val config: JsonObject = buildJsonObject { },
val model_profile_id: String = "",
val created_at: String = "",
val updated_at: String = ""
@@ -107,7 +123,7 @@ data class AssistantCreateRequest(
val system_prompt: String = "",
val generation_goal: String = "",
val knowledge_base_ids: List<String> = emptyList(),
val fastgpt_app_key: String = "",
val project_id: String = "",
val model_profile_id: String = ""
)
@@ -118,7 +134,7 @@ data class AssistantUpdateRequest(
val system_prompt: String? = null,
val generation_goal: String? = null,
val knowledge_base_ids: List<String>? = null,
val fastgpt_app_key: String? = null,
val project_id: String? = null,
val model_profile_id: String? = null
)
@@ -126,6 +142,7 @@ data class AssistantUpdateRequest(
data class ExploreVideoLinkRequest(
val video_url: String,
val title: String? = null,
val project_id: String? = null,
val knowledge_base_id: String? = null,
val assistant_id: String? = null,
val analysis_model_profile_id: String? = null,
@@ -136,28 +153,54 @@ data class ExploreVideoLinkRequest(
data class ExploreTextRequest(
val title: String,
val content: String,
val project_id: String? = null,
val knowledge_base_id: String? = null,
val assistant_id: String? = null,
val analysis_model_profile_id: String? = null
)
@Serializable
data class ContentSourceSyncRequest(
val project_id: String = "",
val knowledge_base_id: String = "",
val assistant_id: String = "",
val content_source_id: String = "",
val platform: String = "",
val handle: String = "",
val source_url: String = "",
val title: String = "",
val analysis_model_profile_id: String = "",
val language: String = "auto",
val max_items: Int = 5,
val skip_existing: Boolean = true,
val auto_trigger_analysis: Boolean = true
)
@Serializable
data class JobDto(
val id: String,
val user_id: String,
val project_id: String = "",
val parent_job_id: String = "",
val assistant_id: String? = null,
val knowledge_base_id: String,
val content_source_id: String = "",
val source_type: String,
val line_type: String = "analysis",
val workflow_key: String = "",
val orchestrator: String = "n8n",
val provider_name: String = "",
val provider_task_id: String = "",
val source_url: String? = null,
val title: String,
val language: String,
val status: String,
val transcript_text: String = "",
val style_summary: String = "",
val fastgpt_collection_id: String = "",
val upload_status: String = "pending",
val error: String = "",
val artifacts: Map<String, String> = emptyMap(),
val artifacts: JsonObject = buildJsonObject { },
val result: JsonObject = buildJsonObject { },
val analysis_model_profile_id: String = "",
val created_at: String = "",
val updated_at: String = ""
@@ -173,7 +216,9 @@ data class KnowledgeDocumentDto(
val transcript_text: String = "",
val style_summary: String = "",
val combined_text: String = "",
val fastgpt_collection_id: String = "",
val analysis: JsonObject = buildJsonObject { },
val storyboards: JsonArray = buildJsonArray { },
val source_artifacts: JsonObject = buildJsonObject { },
val analysis_model_profile_id: String = "",
val created_at: String = "",
val updated_at: String = ""
@@ -200,6 +245,7 @@ data class GenerateCopyResponseDto(
@Serializable
data class DashboardDto(
val account: AccountDto,
val projects: List<ProjectDto> = emptyList(),
val knowledge_bases: List<KnowledgeBaseDto> = emptyList(),
val assistants: List<AssistantDto> = emptyList(),
val recent_jobs: List<JobDto> = emptyList(),

View File

@@ -147,6 +147,32 @@ class StoryForgeRepository(private val context: Context) {
)
)
suspend fun createContentSourceSyncJob(
platform: String,
handle: String,
sourceUrl: String,
title: String,
knowledgeBaseId: String,
assistantId: String,
analysisModelProfileId: String,
maxItems: Int,
skipExisting: Boolean,
autoTriggerAnalysis: Boolean
): JobDto = api().createContentSourceSyncJob(
ContentSourceSyncRequest(
knowledge_base_id = knowledgeBaseId,
assistant_id = assistantId,
platform = platform,
handle = handle,
source_url = sourceUrl,
title = title,
analysis_model_profile_id = analysisModelProfileId,
max_items = maxItems,
skip_existing = skipExisting,
auto_trigger_analysis = autoTriggerAnalysis
)
)
suspend fun uploadVideo(
uri: Uri,
title: String,

View File

@@ -53,14 +53,19 @@ fun StoryForgeScreen(
onInstallLatestUpdate: () -> Unit
) {
val heroBrush = Brush.linearGradient(
colors = listOf(Color(0xFF0B3C5D), Color(0xFF1F6E5F), Color(0xFFB97524))
colors = listOf(Color(0xFFEAF3FF), Color(0xFFD6E9FF), Color(0xFFF7FBFF))
)
Scaffold(
bottomBar = {
if (state.isAuthenticated && state.isApproved) {
NavigationBar(modifier = Modifier.navigationBarsPadding()) {
BottomTabItem(label = "探索", tab = StoryForgeTab.Explore, state = state, onSelect = vm::selectTab)
NavigationBar(
modifier = Modifier.navigationBarsPadding(),
containerColor = MaterialTheme.colorScheme.surface
) {
BottomTabItem(label = "总览", tab = StoryForgeTab.Overview, state = state, onSelect = vm::selectTab)
BottomTabItem(label = "对标", tab = StoryForgeTab.Benchmark, state = state, onSelect = vm::selectTab)
BottomTabItem(label = "Agent", tab = StoryForgeTab.Agent, state = state, onSelect = vm::selectTab)
BottomTabItem(label = "生产", tab = StoryForgeTab.Production, state = state, onSelect = vm::selectTab)
BottomTabItem(label = "我的", tab = StoryForgeTab.Mine, state = state, onSelect = vm::selectTab)
}
@@ -100,13 +105,29 @@ private fun BottomTabItem(
modifier = Modifier
.clip(RoundedCornerShape(18.dp))
.clickable { onSelect(tab) }
.background(if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent)
.padding(horizontal = 14.dp, vertical = 10.dp),
.background(
if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) else Color.Transparent
)
.padding(horizontal = 10.dp, vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = label.take(1), fontWeight = FontWeight.Bold)
Text(label, style = MaterialTheme.typography.labelSmall)
Box(
modifier = Modifier
.size(10.dp)
.clip(RoundedCornerShape(999.dp))
.background(
if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.outline.copy(alpha = 0.45f)
)
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f)
)
}
}
}
@@ -127,7 +148,7 @@ private fun AuthScreen(
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
shape = RoundedCornerShape(28.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
modifier = Modifier.fillMaxWidth()
) {
Column(
@@ -136,15 +157,21 @@ private fun AuthScreen(
.padding(22.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("StoryForge AI", style = MaterialTheme.typography.headlineSmall)
Text("StoryForge", style = MaterialTheme.typography.headlineMedium)
Text(
if (state.authMode == StoryForgeAuthMode.Login) "登录账号" else "注册新账号,提交后等待主管理员审批",
if (state.authMode == StoryForgeAuthMode.Login) "登录工作区继续对标、Agent 和生产流程。"
else "先创建账号,审批通过后就能开始搭项目和 Agent。",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f)
)
ChoiceRow(
options = listOf("登录" to (state.authMode == StoryForgeAuthMode.Login), "注册" to (state.authMode == StoryForgeAuthMode.Register)),
onSelect = { label -> vm.setAuthMode(if (label == "登录") StoryForgeAuthMode.Login else StoryForgeAuthMode.Register) }
options = listOf(
"登录" to (state.authMode == StoryForgeAuthMode.Login),
"注册" to (state.authMode == StoryForgeAuthMode.Register)
),
onSelect = { label ->
vm.setAuthMode(if (label == "登录") StoryForgeAuthMode.Login else StoryForgeAuthMode.Register)
}
)
OutlinedTextField(
value = state.username,
@@ -168,7 +195,7 @@ private fun AuthScreen(
if (state.busy) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Text(if (state.authMode == StoryForgeAuthMode.Login) "登录" else "注册")
Text(if (state.authMode == StoryForgeAuthMode.Login) "进入工作区" else "提交注册")
}
}
if (state.statusMessage.isNotBlank()) {
@@ -197,22 +224,22 @@ private fun PendingApprovalScreen(
) {
HeroCard(
title = "等待审批",
subtitle = "${account?.display_name ?: account?.username ?: "当前账号"} 已登录,但尚未通过主管理员审批",
subtitle = "${account?.display_name ?: account?.username ?: "当前账号"} 已登录,待主管理员通过后继续使用",
heroBrush = heroBrush,
badges = listOf(
"审批状态${account?.approval_status ?: "pending"}",
if (state.resolvedIp.isNotBlank()) "已解析 ${state.resolvedIp}" else ""
"状态 ${account?.approval_status ?: "pending"}",
if (state.resolvedIp.isNotBlank()) "已解析 ${state.resolvedIp}" else ""
).filter { it.isNotBlank() }
)
SectionCard(title = "当前说明", subtitle = state.statusMessage) {
Text("新注册账号在主管理员通过前,无法访问探索、生产和知识库功能")
Text("审批通过前项目、对标、Agent 和生产入口都会先锁定")
Spacer(modifier = Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = vm::refreshApprovalStatus, enabled = !state.busy) {
Text("刷新审批状态")
Text("刷新状态")
}
OutlinedButton(onClick = vm::logout) {
Text("退出登录")
Text("退出")
}
}
if (state.errorMessage.isNotBlank()) {
@@ -241,11 +268,19 @@ private fun AppShell(
) {
HeroCard(
title = when (state.currentTab) {
StoryForgeTab.Explore -> "探索素材"
StoryForgeTab.Production -> "生产文案"
StoryForgeTab.Mine -> "我的工作台"
StoryForgeTab.Overview -> "项目总览"
StoryForgeTab.Benchmark -> "找对标"
StoryForgeTab.Agent -> "Agent"
StoryForgeTab.Production -> "生产中心"
StoryForgeTab.Mine -> "我的"
},
subtitle = when (state.currentTab) {
StoryForgeTab.Overview -> "今天先看项目状态、跟踪日报和高价值动作。"
StoryForgeTab.Benchmark -> "导入主页、作品或本地视频,让 Agent 识别并归类学习。"
StoryForgeTab.Agent -> "定义账号方向、主模型和调研目标,再生成内容。"
StoryForgeTab.Production -> "把文案、封面、实拍剪辑和 AI 视频放进同一条生产泳道。"
StoryForgeTab.Mine -> "管理账号、模型、审批、OTA 和系统状态。"
},
subtitle = state.statusMessage,
heroBrush = heroBrush,
badges = listOf(
state.account?.display_name ?: state.account?.username.orEmpty(),
@@ -255,7 +290,9 @@ private fun AppShell(
)
StatusStrip(state = state, onRefresh = vm::refreshWorkspace)
when (state.currentTab) {
StoryForgeTab.Explore -> ExploreTab(state = state, vm = vm, onPickVideo = onPickVideo)
StoryForgeTab.Overview -> OverviewTab(state = state, vm = vm)
StoryForgeTab.Benchmark -> BenchmarkTab(state = state, vm = vm, onPickVideo = onPickVideo)
StoryForgeTab.Agent -> AgentTab(state = state, vm = vm)
StoryForgeTab.Production -> ProductionTab(state = state, vm = vm)
StoryForgeTab.Mine -> MineTab(state = state, vm = vm, onInstallLatestUpdate = onInstallLatestUpdate)
}
@@ -267,9 +304,9 @@ private fun StatusStrip(state: StoryForgeUiState, onRefresh: () -> Unit) {
SectionCard(title = "连接状态", subtitle = if (state.busy) "正在同步" else "已连接") {
Text(
text = if (state.originalHost.isNotBlank()) {
"外网域名已解析为 ${state.resolvedIp},请求会携带 Host=${state.originalHost}"
"当前请求会保留 Host=${state.originalHost},解析 IP=${state.resolvedIp.ifBlank { "未解析" }}"
} else {
"当前使用地址:${state.baseUrl}"
"当前地址:${state.baseUrl}"
},
style = MaterialTheme.typography.bodySmall
)
@@ -289,42 +326,213 @@ private fun StatusStrip(state: StoryForgeUiState, onRefresh: () -> Unit) {
}
@Composable
private fun ExploreTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onPickVideo: () -> Unit) {
SectionCard(title = "素材入口", subtitle = "视频链接、上传视频、输入文字都会转成文本并做风格分析") {
private fun OverviewTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
SectionCard(title = "今日概览", subtitle = "先看库存、活跃 Agent 和待处理任务。") {
StatsRow(
metrics = listOf(
"知识库" to state.knowledgeBases.size.toString(),
"Agent" to state.assistants.size.toString(),
"任务" to state.jobs.size.toString(),
"素材" to state.documents.size.toString()
)
)
}
SectionCard(title = "跟踪日报", subtitle = "这里先用最近任务和时间线模拟移动端日报摘要。") {
val latest = state.jobs.take(3)
if (latest.isEmpty()) {
Text("今天还没有新的更新,先去找对标导入一个账号或作品。")
} else {
latest.forEach { job ->
MiniCard(
title = job.title,
subtitle = buildString {
append("状态 ${job.status}")
if (job.workflow_key.isNotBlank()) append(" · ${job.workflow_key}")
if (job.style_summary.isNotBlank()) append(" · ${job.style_summary.take(42)}")
}
)
Spacer(modifier = Modifier.height(10.dp))
}
}
}
SectionCard(title = "今天先做什么", subtitle = "把最高频动作放到首屏。") {
ActionRow(
actions = listOf(
"找对标" to { vm.selectTab(StoryForgeTab.Benchmark) },
"配 Agent" to { vm.selectTab(StoryForgeTab.Agent) },
"去生产" to { vm.selectTab(StoryForgeTab.Production) },
"看我的" to { vm.selectTab(StoryForgeTab.Mine) }
)
)
}
SectionCard(title = "最近动态", subtitle = "确认最近一次导入、审批和生成结果。") {
state.timeline.take(6).forEach { item ->
Text(item, style = MaterialTheme.typography.bodySmall)
Spacer(modifier = Modifier.height(6.dp))
}
}
}
@Composable
private fun BenchmarkTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onPickVideo: () -> Unit) {
SectionCard(title = "导入对标", subtitle = "导入主页、视频或本地素材,再决定手动绑定还是交给 Agent 自动归类。") {
ChoiceRow(
options = listOf(
"视频链接" to (state.exploreInputMode == ExploreInputMode.VideoLink),
"上传视频" to (state.exploreInputMode == ExploreInputMode.UploadVideo),
"输入文字" to (state.exploreInputMode == ExploreInputMode.Text)
"主页" to (state.exploreInputMode == ExploreInputMode.ContentSource),
"视频" to (state.exploreInputMode == ExploreInputMode.VideoLink),
"上传" to (state.exploreInputMode == ExploreInputMode.UploadVideo),
"文本" to (state.exploreInputMode == ExploreInputMode.Text)
),
onSelect = { label ->
vm.setExploreInputMode(
when (label) {
"视频链接" -> ExploreInputMode.VideoLink
"上传视频" -> ExploreInputMode.UploadVideo
"主页" -> ExploreInputMode.ContentSource
"视频" -> ExploreInputMode.VideoLink
"上传" -> ExploreInputMode.UploadVideo
else -> ExploreInputMode.Text
}
)
}
)
Spacer(modifier = Modifier.height(12.dp))
KnowledgeBaseSelector(state = state, onSelect = vm::selectKnowledgeBase)
Spacer(modifier = Modifier.height(12.dp))
AssistantSelector(state = state, onSelect = vm::selectAssistant)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "当前分析模型:${state.modelProfiles.firstOrNull { it.id == state.account?.preferred_analysis_model_id }?.name ?: "本机默认模型"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
KnowledgeBaseSelector(state = state, onSelect = vm::selectKnowledgeBase)
Spacer(modifier = Modifier.height(12.dp))
BenchmarkInputPanel(state = state, vm = vm, onPickVideo = onPickVideo)
}
SectionCard(title = "对标池", subtitle = "已经导入的任务和沉淀素材会先堆在这里。") {
if (state.jobs.isEmpty() && state.documents.isEmpty()) {
Text("先导入一个主页或作品,这里会开始形成你的学习池。")
} else {
state.jobs.take(3).forEach { job ->
MiniCard(
title = job.title,
subtitle = "${job.source_type} · ${job.status} · ${job.line_type.ifBlank { "analysis" }}"
)
Spacer(modifier = Modifier.height(10.dp))
}
state.documents.take(2).forEach { document ->
MiniCard(
title = document.title,
subtitle = document.style_summary.ifBlank { document.transcript_text.take(48) }
)
Spacer(modifier = Modifier.height(10.dp))
}
}
}
state.latestJob?.let { latest ->
SectionCard(title = "参考详情", subtitle = latest.title) {
KeyValueRow(label = "状态", value = latest.status)
KeyValueRow(label = "工作流", value = latest.workflow_key.ifBlank { latest.line_type.ifBlank { "-" } })
if (latest.transcript_text.isNotBlank()) {
KeyValueBlock(label = "文本转写", value = latest.transcript_text)
}
if (latest.style_summary.isNotBlank()) {
KeyValueBlock(label = "学习摘要", value = latest.style_summary)
}
}
}
}
@Composable
private fun BenchmarkInputPanel(
state: StoryForgeUiState,
vm: StoryForgeViewModel,
onPickVideo: () -> Unit
) {
when (state.exploreInputMode) {
ExploreInputMode.ContentSource -> {
ChoiceRow(
options = listOf(
"抖音" to (state.accountSyncPlatform == "抖音"),
"B站" to (state.accountSyncPlatform == "bilibili"),
"小红书" to (state.accountSyncPlatform == "小红书")
),
onSelect = { label ->
vm.updateAccountSyncPlatform(if (label == "B站") "bilibili" else label)
}
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.accountSyncUrl,
onValueChange = vm::updateAccountSyncUrl,
modifier = Modifier.fillMaxWidth(),
label = { Text("主页链接或分享页") },
minLines = 2
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.accountSyncHandle,
onValueChange = vm::updateAccountSyncHandle,
modifier = Modifier.fillMaxWidth(),
label = { Text("账号标识,可选") },
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.accountSyncTitle,
onValueChange = vm::updateAccountSyncTitle,
modifier = Modifier.fillMaxWidth(),
label = { Text("导入标题,可选") },
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.accountSyncMaxItems,
onValueChange = vm::updateAccountSyncMaxItems,
modifier = Modifier.fillMaxWidth(),
label = { Text("最近拉取数量") },
singleLine = true
)
Spacer(modifier = Modifier.height(12.dp))
when (state.exploreInputMode) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("跳过旧内容", style = MaterialTheme.typography.bodySmall)
Switch(
checked = state.accountSyncSkipExisting,
onCheckedChange = vm::setAccountSyncSkipExisting
)
}
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("自动分析", style = MaterialTheme.typography.bodySmall)
Switch(
checked = state.accountSyncAutoTriggerAnalysis,
onCheckedChange = vm::setAccountSyncAutoTriggerAnalysis
)
}
}
Spacer(modifier = Modifier.height(12.dp))
ActionRow(
actions = listOf(
"手动绑定" to vm::submitContentSourceSync,
"交给 Agent" to vm::submitContentSourceSync
)
)
}
ExploreInputMode.VideoLink -> {
OutlinedTextField(
value = state.videoUrl,
onValueChange = vm::updateVideoUrl,
modifier = Modifier.fillMaxWidth(),
label = { Text("短视频链接") },
label = { Text("作品链接") },
minLines = 2
)
Spacer(modifier = Modifier.height(10.dp))
@@ -332,26 +540,30 @@ private fun ExploreTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onPick
value = state.videoTitle,
onValueChange = vm::updateVideoTitle,
modifier = Modifier.fillMaxWidth(),
label = { Text("素材标题可选") },
label = { Text("作品标题可选") },
singleLine = true
)
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = vm::submitVideoLink, enabled = !state.busy) {
Text("提交视频链接")
}
ActionRow(
actions = listOf(
"手动导入" to vm::submitVideoLink,
"交给 Agent" to vm::submitVideoLink
)
)
}
ExploreInputMode.UploadVideo -> {
OutlinedTextField(
value = state.videoTitle,
onValueChange = vm::updateVideoTitle,
modifier = Modifier.fillMaxWidth(),
label = { Text("素材标题可选") },
label = { Text("素材标题可选") },
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(onClick = onPickVideo) {
Text(if (state.pickedVideoName.isBlank()) "选择视频文件" else "重新选择")
Text(if (state.pickedVideoName.isBlank()) "选择视频" else "重新选择")
}
Text(
text = if (state.pickedVideoName.isBlank()) "未选择文件" else state.pickedVideoName,
@@ -361,16 +573,21 @@ private fun ExploreTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onPick
)
}
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = vm::submitUploadVideo, enabled = !state.busy && state.pickedVideoName.isNotBlank()) {
Text("上传并开始学习")
}
ActionRow(
actions = listOf(
"手动导入" to vm::submitUploadVideo,
"交给 Agent" to vm::submitUploadVideo
),
enabled = !state.busy && state.pickedVideoName.isNotBlank()
)
}
ExploreInputMode.Text -> {
OutlinedTextField(
value = state.textTitle,
onValueChange = vm::updateTextTitle,
modifier = Modifier.fillMaxWidth(),
label = { Text("素材标题") },
label = { Text("标题") },
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
@@ -378,46 +595,23 @@ private fun ExploreTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onPick
value = state.textContent,
onValueChange = vm::updateTextContent,
modifier = Modifier.fillMaxWidth(),
label = { Text("素材文字") },
label = { Text("正文") },
minLines = 5
)
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = vm::submitText, enabled = !state.busy) {
Text("分析并沉淀到知识库")
}
}
}
}
state.latestJob?.let { latestJob ->
SectionCard(title = "最新任务", subtitle = latestJob.title) {
KeyValueRow(label = "状态", value = latestJob.status)
KeyValueRow(label = "上传状态", value = latestJob.upload_status)
if (latestJob.transcript_text.isNotBlank()) {
KeyValueBlock(label = "文本转写", value = latestJob.transcript_text)
}
if (latestJob.style_summary.isNotBlank()) {
KeyValueBlock(label = "风格提炼", value = latestJob.style_summary)
}
if (latestJob.error.isNotBlank()) {
Text(latestJob.error, color = MaterialTheme.colorScheme.error)
}
}
}
if (state.documents.isNotEmpty()) {
SectionCard(title = "当前知识库素材", subtitle = "已经沉淀到所选知识库的文本样本") {
state.documents.forEach { document ->
MiniCard(title = document.title, subtitle = document.style_summary.ifBlank { document.transcript_text.take(100) })
Spacer(modifier = Modifier.height(10.dp))
}
ActionRow(
actions = listOf(
"手动导入" to vm::submitText,
"交给 Agent" to vm::submitText
)
)
}
}
}
@Composable
private fun ProductionTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
SectionCard(title = "智能体列表", subtitle = "一个智能体默认关联一个知识库,也可以关联多个知识库") {
private fun AgentTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
SectionCard(title = "Agent 列表", subtitle = "一个 Agent 可以学习多个知识库,并服务多个平台。") {
ChoiceRow(
options = state.assistants.map { it.name to (state.selectedAssistantId == it.id) },
onSelect = { label ->
@@ -426,16 +620,16 @@ private fun ProductionTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(onClick = vm::startNewAssistant) {
Text("新建智能体")
Text("新建 Agent")
}
}
SectionCard(title = "编辑智能体", subtitle = "提示词由用户提供,可随时调整模型和知识库绑定") {
SectionCard(title = "Agent 定义", subtitle = "先定义账号方向、变现方式和主模型,再决定学习哪些知识库") {
OutlinedTextField(
value = state.assistantName,
onValueChange = vm::updateAssistantName,
modifier = Modifier.fillMaxWidth(),
label = { Text("智能体名称") },
label = { Text("Agent 名称") },
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
@@ -443,7 +637,7 @@ private fun ProductionTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
value = state.assistantDescription,
onValueChange = vm::updateAssistantDescription,
modifier = Modifier.fillMaxWidth(),
label = { Text("智能体说明") },
label = { Text("账号方向 / 变现方式") },
minLines = 2
)
Spacer(modifier = Modifier.height(10.dp))
@@ -459,11 +653,25 @@ private fun ProductionTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
value = state.assistantGenerationGoal,
onValueChange = vm::updateAssistantGenerationGoal,
modifier = Modifier.fillMaxWidth(),
label = { Text("生成目标") },
label = { Text("Agent 目标") },
minLines = 3
)
Spacer(modifier = Modifier.height(12.dp))
Text("选择生成模型", style = MaterialTheme.typography.titleSmall)
Text("目标平台", style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.height(8.dp))
ChoiceRow(
options = listOf(
"抖音" to state.generationPlatform.contains("抖音"),
"小红书" to state.generationPlatform.contains("小红书"),
"快手" to state.generationPlatform.contains("快手"),
"视频号" to state.generationPlatform.contains("视频号"),
"YouTube" to state.generationPlatform.contains("YouTube"),
"B站" to state.generationPlatform.contains("B站")
),
onSelect = {}
)
Spacer(modifier = Modifier.height(12.dp))
Text("主模型", style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.height(8.dp))
ChoiceRow(
options = state.modelProfiles.map { it.name to (state.assistantModelProfileId == it.id) },
@@ -472,7 +680,7 @@ private fun ProductionTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
}
)
Spacer(modifier = Modifier.height(12.dp))
Text("选择要关联的知识库", style = MaterialTheme.typography.titleSmall)
Text("学习知识库", style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.height(8.dp))
ChoiceRow(
options = state.knowledgeBases.map { it.name to state.selectedAssistantKnowledgeBaseIds.contains(it.id) },
@@ -482,16 +690,16 @@ private fun ProductionTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
)
Spacer(modifier = Modifier.height(14.dp))
Button(onClick = vm::saveAssistant, enabled = !state.busy) {
Text(if (state.assistantEditorId.isNullOrBlank()) "创建智能体" else "保存智能体配置")
Text(if (state.assistantEditorId.isNullOrBlank()) "创建 Agent" else "保存 Agent")
}
}
SectionCard(title = "生成文案", subtitle = "选择智能体后,直接基于关联知识库输出文案") {
SectionCard(title = "调研与试跑", subtitle = "创建完 Agent 后,先跑一轮调研,再试一次文案输出。") {
OutlinedTextField(
value = state.generationBrief,
onValueChange = vm::updateGenerationBrief,
modifier = Modifier.fillMaxWidth(),
label = { Text("文案需求") },
label = { Text("本轮调研或文案需求") },
minLines = 4
)
Spacer(modifier = Modifier.height(10.dp))
@@ -500,14 +708,14 @@ private fun ProductionTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
value = state.generationPlatform,
onValueChange = vm::updateGenerationPlatform,
modifier = Modifier.weight(1f),
label = { Text("平台") },
label = { Text("平台") },
singleLine = true
)
OutlinedTextField(
value = state.generationAudience,
onValueChange = vm::updateGenerationAudience,
modifier = Modifier.weight(1f),
label = { Text("目标受众") },
label = { Text("人群") },
singleLine = true
)
}
@@ -524,12 +732,72 @@ private fun ProductionTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
if (state.generateBusy) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Text("开始生成")
Text("开始试跑")
}
}
if (state.generationOutput.isNotBlank()) {
Spacer(modifier = Modifier.height(16.dp))
KeyValueBlock(label = "生成结果", value = state.generationOutput)
KeyValueBlock(label = "最近输出", value = state.generationOutput)
}
}
}
@Composable
private fun ProductionTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
SectionCard(title = "生产泳道", subtitle = "同一页管理文案、封面、实拍剪辑和 AI 视频。") {
StatsRow(
metrics = listOf(
"文案" to if (state.generationOutput.isNotBlank()) "就绪" else "待生成",
"封面" to "待接入",
"实拍剪辑" to state.jobs.count { it.line_type == "real_cut" }.toString(),
"AI 视频" to state.jobs.count { it.line_type == "ai_video" }.toString()
)
)
Spacer(modifier = Modifier.height(12.dp))
ActionRow(
actions = listOf(
"写文案" to vm::generateCopy,
"补封面" to {},
"实拍剪辑" to {},
"AI 视频" to {}
),
enabled = !state.generateBusy
)
}
SectionCard(title = "作品与成片", subtitle = "这里承接生产完成后的作品库和当前任务。") {
state.latestJob?.let { latest ->
MiniCard(
title = latest.title,
subtitle = buildString {
append("状态 ${latest.status}")
if (latest.upload_status.isNotBlank()) append(" · 上传 ${latest.upload_status}")
if (latest.style_summary.isNotBlank()) append(" · ${latest.style_summary.take(32)}")
}
)
Spacer(modifier = Modifier.height(10.dp))
}
state.documents.take(3).forEach { document ->
MiniCard(
title = document.title,
subtitle = document.style_summary.ifBlank { document.transcript_text.take(54) }
)
Spacer(modifier = Modifier.height(10.dp))
}
if (state.latestJob == null && state.documents.isEmpty()) {
Text("还没有可看的作品,先去找对标导入,或者先创建一个 Agent。")
}
}
SectionCard(title = "文案结果", subtitle = "先保留当前可用链路,后续把封面和视频能力一起接进来。") {
if (state.generationOutput.isBlank()) {
Text("还没有生成结果,先到 Agent 页完成一次试跑。")
} else {
KeyValueBlock(label = "文案", value = state.generationOutput)
if (state.generationPromptExcerpt.isNotBlank()) {
Spacer(modifier = Modifier.height(10.dp))
KeyValueBlock(label = "提示词摘要", value = state.generationPromptExcerpt)
}
}
}
}
@@ -540,17 +808,17 @@ private fun MineTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onInstall
KeyValueRow(label = "用户名", value = state.account?.username ?: "-")
KeyValueRow(label = "角色", value = state.account?.role ?: "-")
KeyValueRow(label = "审批", value = state.account?.approval_status ?: "-")
KeyValueRow(label = "Base URL", value = state.baseUrl)
KeyValueRow(label = "地址", value = state.baseUrl)
if (state.resolvedIp.isNotBlank()) {
KeyValueRow(label = "解析 IP", value = state.resolvedIp)
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(onClick = vm::logout) {
Text("退出登录")
Text("退出")
}
}
SectionCard(title = "分析模型", subtitle = "探索页默认使用这里选中的模型") {
SectionCard(title = "分析模型", subtitle = "用户不管 Key只切主模型和默认分析模型") {
ChoiceRow(
options = state.modelProfiles.map { it.name to (state.account?.preferred_analysis_model_id == it.id) },
onSelect = { label ->
@@ -593,22 +861,22 @@ private fun MineTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onInstall
)
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = vm::createModelProfile) {
Text("保存默认分析模型")
Text("保存默认模型")
}
}
SectionCard(title = "OTA 更新", subtitle = state.otaStatus.ifBlank { "检查新版本并执行安装" }) {
SectionCard(title = "OTA 更新", subtitle = state.otaStatus.ifBlank { "检查并安装最新版本。" }) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
Button(onClick = vm::checkForUpdates) {
Text("检查更新")
Text("检查")
}
OutlinedButton(onClick = onInstallLatestUpdate, enabled = state.otaInfo?.hasUpdate == true) {
Text("安装最新版本")
Text("安装")
}
}
state.otaInfo?.let { ota ->
Spacer(modifier = Modifier.height(12.dp))
KeyValueRow(label = "最新版本", value = "${ota.latestVersionName} (${ota.latestVersionCode})")
KeyValueRow(label = "版本", value = "${ota.latestVersionName} (${ota.latestVersionCode})")
if (ota.releaseNotes.isNotBlank()) {
KeyValueBlock(label = "更新说明", value = ota.releaseNotes)
}
@@ -616,13 +884,18 @@ private fun MineTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onInstall
}
if (state.account?.role == "super_admin") {
SectionCard(title = "主管理员审批", subtitle = "新注册账号需要你审批后才能正常使用全部功能") {
SectionCard(title = "审批", subtitle = "主管理员审批新用户。") {
if (state.pendingAccounts.isEmpty()) {
Text("当前没有待审批账号")
Text("当前没有待审批账号")
} else {
state.pendingAccounts.forEach { account ->
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
Column(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(account.display_name, fontWeight = FontWeight.Bold)
Text(account.username, style = MaterialTheme.typography.bodySmall)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -639,62 +912,10 @@ private fun MineTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onInstall
}
}
}
SectionCard(title = "发布 OTA", subtitle = "主管理员可直接更新在线版本号和下载地址") {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = state.publishVersionCode,
onValueChange = vm::updatePublishVersionCode,
modifier = Modifier.weight(1f),
label = { Text("VersionCode") },
singleLine = true
)
OutlinedTextField(
value = state.publishMinSupportedCode,
onValueChange = vm::updatePublishMinSupportedCode,
modifier = Modifier.weight(1f),
label = { Text("最低支持") },
singleLine = true
)
}
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.publishVersionName,
onValueChange = vm::updatePublishVersionName,
modifier = Modifier.fillMaxWidth(),
label = { Text("VersionName") },
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.publishApkUrl,
onValueChange = vm::updatePublishApkUrl,
modifier = Modifier.fillMaxWidth(),
label = { Text("APK 下载地址") },
minLines = 2
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.publishNotes,
onValueChange = vm::updatePublishNotes,
modifier = Modifier.fillMaxWidth(),
label = { Text("更新说明") },
minLines = 3
)
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text("强制更新")
Switch(checked = state.publishForceUpdate, onCheckedChange = vm::setPublishForceUpdate)
}
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = vm::publishUpdate) {
Text("发布 OTA")
}
}
}
SectionCard(title = "最近日志", subtitle = "确认审批、解析、任务和 OTA 状态") {
state.timeline.forEach { item ->
SectionCard(title = "最近日志", subtitle = "确认审批、解析、任务和 OTA 状态") {
state.timeline.take(8).forEach { item ->
Text(item, style = MaterialTheme.typography.bodySmall)
Spacer(modifier = Modifier.height(6.dp))
}
@@ -707,7 +928,10 @@ private fun ChoiceRow(
options: List<Pair<String, Boolean>>,
onSelect: (String) -> Unit
) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
options.forEach { (label, selected) ->
FilterChip(
selected = selected,
@@ -718,6 +942,54 @@ private fun ChoiceRow(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ActionRow(
actions: List<Pair<String, () -> Unit>>,
enabled: Boolean = true
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
actions.forEachIndexed { index, (label, action) ->
if (index == 0) {
Button(onClick = action, enabled = enabled) {
Text(label)
}
} else {
OutlinedButton(onClick = action, enabled = enabled) {
Text(label)
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun StatsRow(metrics: List<Pair<String, String>>) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
metrics.forEach { (label, value) ->
Box(
modifier = Modifier
.width(140.dp)
.clip(RoundedCornerShape(18.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f))
.padding(14.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.68f))
Text(value, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
}
}
}
}
@Composable
private fun KnowledgeBaseSelector(state: StoryForgeUiState, onSelect: (String) -> Unit) {
Text("选择知识库", style = MaterialTheme.typography.titleSmall)
@@ -732,7 +1004,7 @@ private fun KnowledgeBaseSelector(state: StoryForgeUiState, onSelect: (String) -
@Composable
private fun AssistantSelector(state: StoryForgeUiState, onSelect: (String) -> Unit) {
Text("选择关联智能体", style = MaterialTheme.typography.titleSmall)
Text("绑定 Agent", style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.height(8.dp))
ChoiceRow(
options = state.assistants.map { it.name to (state.selectedAssistantId == it.id) },
@@ -749,11 +1021,16 @@ private fun HeroCard(title: String, subtitle: String, heroBrush: Brush, badges:
.fillMaxWidth()
.clip(RoundedCornerShape(28.dp))
.background(heroBrush)
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f),
shape = RoundedCornerShape(28.dp)
)
.padding(20.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(title, style = MaterialTheme.typography.headlineLarge, color = Color.White)
Text(subtitle, style = MaterialTheme.typography.bodyLarge, color = Color(0xFFF8F5EF))
Text(title, style = MaterialTheme.typography.headlineLarge, color = MaterialTheme.colorScheme.onSurface)
Text(subtitle, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.74f))
if (badges.isNotEmpty()) {
ChoiceRow(options = badges.map { it to true }, onSelect = {})
}
@@ -779,7 +1056,7 @@ private fun SectionCard(title: String, subtitle: String, content: @Composable ()
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f)
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.68f)
)
}
Spacer(modifier = Modifier.height(6.dp))
@@ -809,7 +1086,11 @@ private fun KeyValueBlock(label: String, value: String) {
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), RoundedCornerShape(16.dp))
.border(
1.dp,
MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
RoundedCornerShape(16.dp)
)
.padding(14.dp)
) {
Text(value)
@@ -818,10 +1099,20 @@ private fun KeyValueBlock(label: String, value: String) {
@Composable
private fun MiniCard(title: String, subtitle: String) {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
Column(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.58f))) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(title, fontWeight = FontWeight.Bold)
Text(subtitle, maxLines = 4, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall)
Text(
subtitle,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall
)
}
}
}

View File

@@ -15,7 +15,9 @@ import kotlinx.coroutines.launch
import retrofit2.HttpException
enum class StoryForgeTab {
Explore,
Overview,
Benchmark,
Agent,
Production,
Mine
}
@@ -26,6 +28,7 @@ enum class StoryForgeAuthMode {
}
enum class ExploreInputMode {
ContentSource,
VideoLink,
UploadVideo,
Text
@@ -52,7 +55,7 @@ data class StoryForgeUiState(
val originalHost: String = "",
val isAuthenticated: Boolean = false,
val isApproved: Boolean = false,
val currentTab: StoryForgeTab = StoryForgeTab.Explore,
val currentTab: StoryForgeTab = StoryForgeTab.Overview,
val busy: Boolean = false,
val generateBusy: Boolean = false,
val statusMessage: String = "准备连接 StoryForge",
@@ -72,6 +75,13 @@ data class StoryForgeUiState(
val createKnowledgeBaseName: String = "",
val createKnowledgeBaseDescription: String = "",
val exploreInputMode: ExploreInputMode = ExploreInputMode.VideoLink,
val accountSyncPlatform: String = "抖音",
val accountSyncHandle: String = "",
val accountSyncUrl: String = "",
val accountSyncTitle: String = "",
val accountSyncMaxItems: String = "5",
val accountSyncSkipExisting: Boolean = true,
val accountSyncAutoTriggerAnalysis: Boolean = true,
val videoUrl: String = "",
val videoTitle: String = "",
val textTitle: String = "",
@@ -155,6 +165,35 @@ class StoryForgeViewModel(application: Application) : AndroidViewModel(applicati
_state.value = _state.value.copy(videoUrl = value)
}
fun updateAccountSyncPlatform(value: String) {
_state.value = _state.value.copy(accountSyncPlatform = value)
}
fun updateAccountSyncHandle(value: String) {
_state.value = _state.value.copy(accountSyncHandle = value)
}
fun updateAccountSyncUrl(value: String) {
_state.value = _state.value.copy(accountSyncUrl = value)
}
fun updateAccountSyncTitle(value: String) {
_state.value = _state.value.copy(accountSyncTitle = value)
}
fun updateAccountSyncMaxItems(value: String) {
val digits = value.filter { it.isDigit() }
_state.value = _state.value.copy(accountSyncMaxItems = digits)
}
fun setAccountSyncSkipExisting(value: Boolean) {
_state.value = _state.value.copy(accountSyncSkipExisting = value)
}
fun setAccountSyncAutoTriggerAnalysis(value: Boolean) {
_state.value = _state.value.copy(accountSyncAutoTriggerAnalysis = value)
}
fun updateVideoTitle(value: String) {
_state.value = _state.value.copy(videoTitle = value)
}
@@ -463,6 +502,43 @@ class StoryForgeViewModel(application: Application) : AndroidViewModel(applicati
}
}
fun submitContentSourceSync() {
val current = state.value
if (current.accountSyncUrl.isBlank()) {
setError("请先输入账号主页链接")
return
}
val knowledgeBaseId = selectedKnowledgeBaseIdOrFallback()
if (knowledgeBaseId.isBlank()) {
setError("请先选择知识库")
return
}
val maxItems = current.accountSyncMaxItems.toIntOrNull()?.coerceIn(1, 20) ?: 5
runBusy(message = "正在创建账号同步任务...", task = {
repository.createContentSourceSyncJob(
platform = current.accountSyncPlatform.trim(),
handle = current.accountSyncHandle.trim(),
sourceUrl = current.accountSyncUrl.trim(),
title = current.accountSyncTitle.trim(),
knowledgeBaseId = knowledgeBaseId,
assistantId = current.selectedAssistantId,
analysisModelProfileId = preferredModelId(),
maxItems = maxItems,
skipExisting = current.accountSyncSkipExisting,
autoTriggerAnalysis = current.accountSyncAutoTriggerAnalysis
)
}) { job ->
appendTimeline("账号同步任务已创建: ${job.title}")
_state.value = state.value.copy(
accountSyncHandle = "",
accountSyncUrl = "",
accountSyncTitle = "",
accountSyncMaxItems = maxItems.toString()
)
afterJobCreated(job)
}
}
fun submitText() {
val current = state.value
if (current.textTitle.isBlank() || current.textContent.isBlank()) {
@@ -773,7 +849,7 @@ class StoryForgeViewModel(application: Application) : AndroidViewModel(applicati
_state.value = state.value.copy(
latestJob = job,
latestJobId = job.id,
currentTab = StoryForgeTab.Explore
currentTab = StoryForgeTab.Benchmark
)
refreshWorkspace()
startJobPolling(job.id)

View File

@@ -13,51 +13,82 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
private val LightColors = lightColorScheme(
primary = Color(0xFF0E4B43),
secondary = Color(0xFF9C6427),
tertiary = Color(0xFF2A5B8A),
background = Color(0xFFF7F3EC),
surface = Color(0xFFFFFCF8),
primary = Color(0xFF4E89F5),
secondary = Color(0xFF87AEEB),
tertiary = Color(0xFF17283A),
background = Color(0xFFF2F7FF),
surface = Color(0xFFFFFFFF),
surfaceVariant = Color(0xFFEAF2FF),
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color(0xFF1A1713),
onSurface = Color(0xFF1A1713)
onBackground = Color(0xFF152332),
onSurface = Color(0xFF152332),
outline = Color(0xFFC9D8EA)
)
private val DarkColors = darkColorScheme(
primary = Color(0xFF7FD6C7),
secondary = Color(0xFFFFC27A),
tertiary = Color(0xFF98C7FF),
background = Color(0xFF101714),
surface = Color(0xFF18211D),
onPrimary = Color(0xFF062D29),
onSecondary = Color(0xFF4B2B00),
onBackground = Color(0xFFF0E8DB),
onSurface = Color(0xFFF0E8DB)
primary = Color(0xFF8CB7FF),
secondary = Color(0xFF7EA5DE),
tertiary = Color(0xFFE6EEF9),
background = Color(0xFF101823),
surface = Color(0xFF162131),
surfaceVariant = Color(0xFF1D2B3D),
onPrimary = Color(0xFF0C1B30),
onSecondary = Color(0xFF0C1B30),
onBackground = Color(0xFFEAF1FB),
onSurface = Color(0xFFEAF1FB),
outline = Color(0xFF35506F)
)
private val AppTypography = Typography(
headlineLarge = TextStyle(
fontFamily = FontFamily.Serif,
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 34.sp,
lineHeight = 40.sp
fontSize = 30.sp,
lineHeight = 36.sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 26.sp,
lineHeight = 32.sp
),
headlineSmall = TextStyle(
fontFamily = FontFamily.Serif,
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
lineHeight = 26.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.SansSerif,
fontSize = 16.sp,
lineHeight = 24.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.SansSerif,
fontSize = 14.sp,
lineHeight = 21.sp
),
bodySmall = TextStyle(
fontFamily = FontFamily.SansSerif,
fontSize = 12.sp,
lineHeight = 18.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Medium,
fontSize = 14.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Medium,
fontSize = 11.sp
)
)

View File

@@ -1,5 +1,9 @@
FROM python:3.11-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

View File

@@ -48,6 +48,18 @@ class Database:
with self.session() as conn:
conn.execute(sql, params)
def table_exists(self, name: str) -> bool:
row = self.fetch_one(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
(name,),
)
return bool(row)
def column_exists(self, table: str, column: str) -> bool:
with self.session() as conn:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
return any(row["name"] == column for row in rows)
def init_schema(self) -> None:
schema = """
CREATE TABLE IF NOT EXISTS accounts (
@@ -90,10 +102,10 @@ class Database:
CREATE TABLE IF NOT EXISTS knowledge_bases (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
project_id TEXT,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
fastgpt_dataset_id TEXT,
sync_status TEXT NOT NULL DEFAULT 'pending',
sync_status TEXT NOT NULL DEFAULT 'ready',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE
@@ -108,7 +120,9 @@ class Database:
transcript_text TEXT NOT NULL DEFAULT '',
style_summary TEXT NOT NULL DEFAULT '',
combined_text TEXT NOT NULL DEFAULT '',
fastgpt_collection_id TEXT NOT NULL DEFAULT '',
analysis_json TEXT NOT NULL DEFAULT '{}',
storyboard_json TEXT NOT NULL DEFAULT '[]',
source_artifact_json TEXT NOT NULL DEFAULT '{}',
analysis_model_profile_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
@@ -118,11 +132,12 @@ class Database:
CREATE TABLE IF NOT EXISTS assistants (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
project_id TEXT,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
system_prompt TEXT NOT NULL DEFAULT '',
generation_goal TEXT NOT NULL DEFAULT '',
fastgpt_app_key TEXT NOT NULL DEFAULT '',
config_json TEXT NOT NULL DEFAULT '{}',
model_profile_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
@@ -140,19 +155,27 @@ class Database:
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
project_id TEXT,
parent_job_id TEXT,
assistant_id TEXT,
knowledge_base_id TEXT NOT NULL,
content_source_id TEXT,
source_type TEXT NOT NULL,
line_type TEXT NOT NULL DEFAULT 'analysis',
workflow_key TEXT NOT NULL DEFAULT '',
orchestrator TEXT NOT NULL DEFAULT 'n8n',
provider_name TEXT NOT NULL DEFAULT '',
provider_task_id TEXT NOT NULL DEFAULT '',
source_url TEXT,
title TEXT NOT NULL,
language TEXT NOT NULL DEFAULT 'auto',
status TEXT NOT NULL,
transcript_text TEXT NOT NULL DEFAULT '',
style_summary TEXT NOT NULL DEFAULT '',
fastgpt_collection_id TEXT NOT NULL DEFAULT '',
upload_status TEXT NOT NULL DEFAULT 'pending',
error TEXT NOT NULL DEFAULT '',
artifacts_json TEXT NOT NULL DEFAULT '{}',
result_json TEXT NOT NULL DEFAULT '{}',
analysis_model_profile_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
@@ -161,6 +184,42 @@ class Database:
FOREIGN KEY(knowledge_base_id) REFERENCES knowledge_bases(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS content_sources (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
project_id TEXT,
source_kind TEXT NOT NULL,
platform TEXT NOT NULL DEFAULT '',
handle TEXT NOT NULL DEFAULT '',
source_url TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
local_path TEXT NOT NULL DEFAULT '',
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS job_events (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
FOREIGN KEY(job_id) REFERENCES jobs(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS app_updates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
@@ -179,3 +238,103 @@ class Database:
"""
with self.session() as conn:
conn.executescript(schema)
self.migrate_schema()
def migrate_schema(self) -> None:
table_columns: dict[str, dict[str, str]] = {
"knowledge_bases": {
"project_id": "TEXT",
},
"knowledge_documents": {
"analysis_json": "TEXT NOT NULL DEFAULT '{}'",
"storyboard_json": "TEXT NOT NULL DEFAULT '[]'",
"source_artifact_json": "TEXT NOT NULL DEFAULT '{}'",
},
"assistants": {
"project_id": "TEXT",
"config_json": "TEXT NOT NULL DEFAULT '{}'",
},
"jobs": {
"project_id": "TEXT",
"parent_job_id": "TEXT",
"content_source_id": "TEXT",
"line_type": "TEXT NOT NULL DEFAULT 'analysis'",
"workflow_key": "TEXT NOT NULL DEFAULT ''",
"orchestrator": "TEXT NOT NULL DEFAULT 'n8n'",
"provider_name": "TEXT NOT NULL DEFAULT ''",
"provider_task_id": "TEXT NOT NULL DEFAULT ''",
"result_json": "TEXT NOT NULL DEFAULT '{}'",
},
}
for table, columns in table_columns.items():
if not self.table_exists(table):
continue
for column, definition in columns.items():
if self.column_exists(table, column):
continue
self.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
self.ensure_default_projects()
def ensure_default_projects(self) -> None:
if not self.table_exists("projects"):
return
accounts = self.fetch_all("SELECT id, username FROM accounts ORDER BY created_at ASC")
for account in accounts:
project = self.fetch_one(
"SELECT * FROM projects WHERE user_id = ? ORDER BY created_at ASC LIMIT 1",
(account["id"],),
)
if not project:
project_id = f"proj_{account['id']}"
now = utc_now()
self.execute(
"""
INSERT INTO projects (id, user_id, name, description, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
project_id,
account["id"],
f"{account['username']} 默认项目",
"系统自动创建的默认项目",
now,
now,
),
)
project = self.fetch_one("SELECT * FROM projects WHERE id = ?", (project_id,))
if not project:
continue
if self.column_exists("knowledge_bases", "project_id"):
self.execute(
"""
UPDATE knowledge_bases
SET project_id = ?
WHERE user_id = ? AND (project_id IS NULL OR project_id = '')
""",
(project["id"], account["id"]),
)
if self.column_exists("assistants", "project_id"):
self.execute(
"""
UPDATE assistants
SET project_id = ?
WHERE user_id = ? AND (project_id IS NULL OR project_id = '')
""",
(project["id"], account["id"]),
)
if self.column_exists("jobs", "project_id"):
self.execute(
"""
UPDATE jobs
SET project_id = ?
WHERE user_id = ? AND (project_id IS NULL OR project_id = '')
""",
(project["id"], account["id"]),
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +0,0 @@
from __future__ import annotations
from typing import Any
import httpx
class FastGPTClient:
def __init__(self, *, base_url: str, dataset_api_key: str, timeout: float = 60.0) -> None:
self.base_url = base_url.rstrip("/")
self.dataset_api_key = dataset_api_key.strip()
self.timeout = timeout
@property
def enabled(self) -> bool:
return bool(self.base_url and self.dataset_api_key)
async def ensure_dataset(self, name: str, intro: str = "") -> dict[str, Any] | None:
if not self.enabled:
return None
payload = {"name": name, "intro": intro}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/core/dataset/create",
headers={"Authorization": f"Bearer {self.dataset_api_key}"},
json=payload,
)
response.raise_for_status()
return response.json().get("data") or response.json()
async def add_text_document(self, dataset_id: str, name: str, text: str) -> dict[str, Any] | None:
if not self.enabled or not dataset_id.strip():
return None
payload = {
"datasetId": dataset_id,
"type": "text",
"name": name,
"trainingType": "chunk",
"text": text,
}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/core/dataset/collection/create/text",
headers={"Authorization": f"Bearer {self.dataset_api_key}"},
json=payload,
)
response.raise_for_status()
return response.json().get("data") or response.json()

View File

@@ -0,0 +1,217 @@
from __future__ import annotations
import mimetypes
from pathlib import Path
from typing import Any
import httpx
def _join_url(base_url: str, path: str) -> str:
base = base_url.rstrip("/")
if path.startswith("http://") or path.startswith("https://"):
return path
return f"{base}/{path.lstrip('/')}"
def _unwrap_response(payload: Any) -> dict[str, Any]:
if not isinstance(payload, dict):
return {"value": payload}
if payload.get("success") is True and "data" in payload:
data = payload.get("data")
if isinstance(data, dict):
return data
return {"value": data}
return payload
class N8NClient:
def __init__(
self,
*,
base_url: str,
workflow_paths: dict[str, str],
shared_secret: str = "",
timeout: float = 60.0,
) -> None:
self.base_url = base_url.rstrip("/")
self.workflow_paths = workflow_paths
self.shared_secret = shared_secret.strip()
self.timeout = timeout
@property
def enabled(self) -> bool:
return bool(self.base_url)
async def trigger(self, workflow_key: str, payload: dict[str, Any]) -> dict[str, Any]:
workflow_path = self.workflow_paths.get(workflow_key, "").strip()
if not workflow_path:
raise ValueError(f"workflow path not configured for {workflow_key}")
try:
workflow_path = workflow_path.format(**payload)
except KeyError:
pass
headers: dict[str, str] = {}
if self.shared_secret:
headers["X-Orchestrator-Secret"] = self.shared_secret
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
_join_url(self.base_url, workflow_path),
json=payload,
headers=headers,
)
response.raise_for_status()
if not response.content:
return {"triggered": True}
return _unwrap_response(response.json())
class CutVideoClient:
def __init__(
self,
*,
base_url: str,
api_key: str = "",
timeout: float = 120.0,
upload_timeout: float = 1800.0,
) -> None:
self.base_url = base_url.rstrip("/")
self.api_key = api_key.strip()
self.timeout = timeout
self.upload_timeout = upload_timeout
@property
def enabled(self) -> bool:
return bool(self.base_url)
def _headers(self) -> dict[str, str]:
headers: dict[str, str] = {}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
return headers
async def submit_job(self, payload: dict[str, Any]) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
_join_url(self.base_url, "/api/jobs"),
json=payload,
headers=self._headers(),
)
response.raise_for_status()
return _unwrap_response(response.json())
async def upload_source_file(self, source_path: Path, *, folder_name: str = "") -> dict[str, Any]:
content_type = mimetypes.guess_type(source_path.name)[0] or "application/octet-stream"
headers = self._headers()
data = {"folder_name": folder_name} if folder_name else {}
async with httpx.AsyncClient(timeout=self.upload_timeout) as client:
with source_path.open("rb") as handle:
response = await client.post(
_join_url(self.base_url, "/api/uploads"),
data=data,
files={"files": (source_path.name, handle, content_type)},
headers=headers,
)
response.raise_for_status()
return _unwrap_response(response.json())
async def get_task(self, task_id: str) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
_join_url(self.base_url, f"/api/tasks/{task_id}"),
headers=self._headers(),
)
response.raise_for_status()
return _unwrap_response(response.json())
async def get_run(self, run_id: str) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
_join_url(self.base_url, f"/api/runs/{run_id}"),
headers=self._headers(),
)
response.raise_for_status()
return _unwrap_response(response.json())
class AsrHttpClient:
def __init__(
self,
*,
base_url: str,
transcribe_path: str = "/transcribe",
field_name: str = "wav",
timeout: float = 120.0,
) -> None:
self.base_url = base_url.rstrip("/")
self.transcribe_path = transcribe_path
self.field_name = field_name.strip() or "wav"
self.timeout = timeout
@property
def enabled(self) -> bool:
return bool(self.base_url)
async def transcribe_audio(self, audio_path: Path) -> dict[str, Any]:
content_type = mimetypes.guess_type(audio_path.name)[0] or "application/octet-stream"
async with httpx.AsyncClient(timeout=self.timeout) as client:
with audio_path.open("rb") as handle:
response = await client.post(
_join_url(self.base_url, self.transcribe_path),
files={self.field_name: (audio_path.name, handle, content_type)},
)
response.raise_for_status()
return _unwrap_response(response.json())
class HuobaoDramaClient:
def __init__(self, *, base_url: str, timeout: float = 180.0) -> None:
self.base_url = base_url.rstrip("/")
self.timeout = timeout
@property
def enabled(self) -> bool:
return bool(self.base_url)
async def create_drama(self, payload: dict[str, Any]) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
_join_url(self.base_url, "/api/v1/dramas"),
json=payload,
)
response.raise_for_status()
return _unwrap_response(response.json())
async def generate_image(self, payload: dict[str, Any]) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
_join_url(self.base_url, "/api/v1/images"),
json=payload,
)
response.raise_for_status()
return _unwrap_response(response.json())
async def get_image(self, image_id: str) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
_join_url(self.base_url, f"/api/v1/images/{image_id}"),
)
response.raise_for_status()
return _unwrap_response(response.json())
async def generate_video(self, payload: dict[str, Any]) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
_join_url(self.base_url, "/api/v1/videos"),
json=payload,
)
response.raise_for_status()
return _unwrap_response(response.json())
async def get_video(self, video_id: str) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
_join_url(self.base_url, f"/api/v1/videos/{video_id}"),
)
response.raise_for_status()
return _unwrap_response(response.json())

File diff suppressed because it is too large Load Diff

View File

@@ -3,3 +3,4 @@ uvicorn[standard]==0.34.0
httpx==0.28.1
python-multipart==0.0.20
pydantic==2.11.1
yt-dlp

View File

@@ -0,0 +1,27 @@
# Version B: Studio Workbench
Direction: `Castmagic x content ops studio`
This version optimizes for teams that want to turn one material source into many structured outputs.
## Product thesis
- Users should feel they are operating a content studio, not just a summarizer.
- Material ingestion is one panel inside a broader production system.
- Knowledge bases, assistants, and output assets should be visible at once.
- This is stronger for repeatable workflows and team collaboration.
## Key decisions
- `Production` becomes the emotional center of the app.
- The screen is split into material, assistant, and output zones.
- The user can see which knowledge bases feed which assistant.
- One source material can drive multiple output formats immediately.
- This layout is heavier, but it better communicates long-term business value.
## When this version is best
- Small content teams
- Agencies managing multiple client voices
- Users who need assistant governance and model routing
- Teams that value throughput over the fastest first-use experience

View File

@@ -0,0 +1,426 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>StoryForge Version B</title>
<style>
:root {
--bg: #0e1416;
--panel: #121b1e;
--panel-soft: #162226;
--panel-bright: #1a282d;
--ink: #edf3ef;
--muted: #9db1a8;
--teal: #66c2a5;
--amber: #f2a65a;
--coral: #ff7a59;
--line: rgba(202, 224, 215, 0.1);
--shadow: 0 24px 70px rgba(0, 0, 0, 0.42);
--radius-xl: 32px;
--radius-lg: 22px;
--radius-md: 16px;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: "Avenir Next", "SF Pro Display", "Segoe UI", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(102,194,165,0.18), transparent 28%),
radial-gradient(circle at 88% 14%, rgba(255,122,89,0.16), transparent 24%),
linear-gradient(180deg, #0d1214 0%, #10181b 100%);
display: grid;
place-items: center;
padding: 26px;
}
.frame {
width: 1440px;
min-height: 900px;
border-radius: 34px;
background: rgba(16, 23, 25, 0.95);
border: 1px solid rgba(199, 225, 215, 0.08);
box-shadow: var(--shadow);
display: grid;
grid-template-columns: 250px 1fr;
overflow: hidden;
}
.sidebar {
background: linear-gradient(180deg, #0f181b, #111b1f);
border-right: 1px solid var(--line);
padding: 26px 18px;
display: flex;
flex-direction: column;
gap: 18px;
}
.brand {
padding: 12px 14px;
border-radius: 18px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--line);
}
.brand small {
display: block;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 11px;
margin-bottom: 8px;
}
.brand strong {
font-size: 24px;
letter-spacing: -0.04em;
}
.nav {
display: grid;
gap: 8px;
}
.nav-item {
padding: 14px 16px;
border-radius: 18px;
color: var(--muted);
border: 1px solid transparent;
background: transparent;
font-weight: 700;
}
.nav-item.active {
color: #0d1416;
background: linear-gradient(180deg, #79d1b6, #56b394);
}
.nav-item.alert {
border-color: rgba(255,122,89,0.22);
background: rgba(255,122,89,0.08);
color: #ffd5cb;
}
.sidebar-footer {
margin-top: auto;
padding: 16px;
border-radius: 18px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--line);
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.content {
padding: 24px;
display: grid;
grid-template-rows: auto auto 1fr;
gap: 18px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 18px;
}
.headline h1 {
margin: 0;
font-size: 34px;
letter-spacing: -0.04em;
}
.headline p {
margin: 8px 0 0;
color: var(--muted);
font-size: 14px;
}
.top-actions {
display: flex;
gap: 10px;
}
.button {
padding: 14px 18px;
border-radius: 16px;
border: 1px solid var(--line);
background: var(--panel-soft);
color: var(--ink);
font-weight: 700;
}
.button.primary {
background: linear-gradient(180deg, #f7b36a, #ee9143);
color: #27140b;
border-color: transparent;
}
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
.stat {
padding: 16px 18px;
border-radius: 20px;
background: var(--panel);
border: 1px solid var(--line);
}
.stat span {
display: block;
color: var(--muted);
font-size: 12px;
margin-bottom: 8px;
}
.stat strong {
font-size: 30px;
letter-spacing: -0.05em;
}
.workspace {
display: grid;
grid-template-columns: 1.15fr 0.95fr 1.1fr;
gap: 18px;
}
.card {
background: linear-gradient(180deg, rgba(22,34,38,0.96), rgba(17,27,30,0.98));
border: 1px solid var(--line);
border-radius: 26px;
padding: 18px;
}
.card h2 {
margin: 0 0 6px;
font-size: 20px;
}
.card p {
margin: 0 0 14px;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.composer {
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.03);
min-height: 118px;
padding: 16px;
color: var(--muted);
margin-bottom: 12px;
}
.option-grid {
display: grid;
gap: 10px;
margin-top: 14px;
}
.option {
padding: 14px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.03);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.option small, .asset small {
display: block;
color: var(--muted);
margin-top: 4px;
font-size: 12px;
}
.pill {
padding: 7px 11px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
}
.pill.teal { background: rgba(102,194,165,0.15); color: #9be4ce; }
.pill.amber { background: rgba(242,166,90,0.15); color: #ffd6a7; }
.pill.coral { background: rgba(255,122,89,0.14); color: #ffc6b6; }
.pipeline {
display: grid;
gap: 12px;
}
.stage {
padding: 14px;
border-radius: 18px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--line);
}
.stage strong {
display: block;
margin-bottom: 8px;
font-size: 14px;
}
.asset-grid {
display: grid;
gap: 12px;
}
.asset {
border-radius: 20px;
padding: 16px;
background: linear-gradient(180deg, rgba(26,40,45,0.96), rgba(20,31,35,0.96));
border: 1px solid rgba(200, 225, 216, 0.08);
}
.asset strong {
display: block;
font-size: 15px;
}
.asset .meta {
margin-top: 10px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
</style>
</head>
<body>
<main class="frame">
<aside class="sidebar">
<div class="brand">
<small>StoryForge</small>
<strong>Studio Workbench</strong>
</div>
<div class="nav">
<div class="nav-item">Explore</div>
<div class="nav-item active">Production</div>
<div class="nav-item">Knowledge</div>
<div class="nav-item">Assistants</div>
<div class="nav-item">Models</div>
<div class="nav-item alert">3 accounts pending</div>
</div>
<div class="sidebar-footer">
Best for a team that wants traceable content operations: one material source, many assistant outputs, one clear knowledge map.
</div>
</aside>
<section class="content">
<header class="topbar">
<div class="headline">
<h1>Run your copywriting system like a studio.</h1>
<p>Ingest material, route it through the right knowledge bases, and send different assistants to generate platform-specific outputs.</p>
</div>
<div class="top-actions">
<button class="button">Add material</button>
<button class="button primary">Generate outputs</button>
</div>
</header>
<section class="stats">
<div class="stat"><span>Materials this week</span><strong>42</strong></div>
<div class="stat"><span>Knowledge bases</span><strong>9</strong></div>
<div class="stat"><span>Active assistants</span><strong>6</strong></div>
<div class="stat"><span>Reusable outputs</span><strong>128</strong></div>
</section>
<section class="workspace">
<article class="card">
<h2>Material intake</h2>
<p>Every source enters here first. Links, files, and text all converge into a transcript-centered asset.</p>
<div class="composer">Paste a Douyin or YouTube link here.
Knowledge target: Founder Hooks + Proof Framing
Assistant route: AI Startup Scriptwriter + Sales CTA Finisher
Analysis model: Local GLM-5</div>
<div class="option-grid">
<div class="option">
<div>
<strong>VC founder talking-head sample</strong>
<small>12m · transcript ready · hook density 8.9/10</small>
</div>
<span class="pill teal">Linked</span>
</div>
<div class="option">
<div>
<strong>High-conversion CTA collection</strong>
<small>text note · 38 CTA endings extracted</small>
</div>
<span class="pill amber">Text</span>
</div>
</div>
</article>
<article class="card">
<h2>Knowledge routing</h2>
<p>Make the knowledge graph visible. Users should always know which assistant can read which material pool.</p>
<div class="pipeline">
<div class="stage">
<strong>1. Transcript clean-up</strong>
Remove filler, split hooks, isolate claims, normalize CTA language.
</div>
<div class="stage">
<strong>2. Style abstraction</strong>
Extract rhythm, sentence energy, authority level, objection handling patterns.
</div>
<div class="stage">
<strong>3. Knowledge base sync</strong>
Founder Hooks, Sales Emotion, Short CTA, Proof Framing.
</div>
<div class="stage">
<strong>4. Assistant generation</strong>
Bind one or more KBs, assign model, generate title/script/variant bundle.
</div>
</div>
</article>
<article class="card">
<h2>Output assets</h2>
<p>One material source should fan out into multiple reusable content assets immediately.</p>
<div class="asset-grid">
<div class="asset">
<strong>AI Startup Scriptwriter</strong>
<small>Bound to Founder Hooks + Proof Framing · Model: Local GLM-5</small>
<div class="meta">
<span class="pill coral">60s oral script</span>
<span class="pill teal">3 title variants</span>
<span class="pill amber">Closing CTA</span>
</div>
</div>
<div class="asset">
<strong>Emotion-driven Sales Closer</strong>
<small>Bound to Sales Emotion + Short CTA · Model: Gemini via local proxy</small>
<div class="meta">
<span class="pill amber">Private-domain follow-up</span>
<span class="pill coral">Urgency rewrite</span>
</div>
</div>
<div class="asset">
<strong>Authority-led Brand Explainer</strong>
<small>Bound to Proof Framing only · Safer for educational content</small>
<div class="meta">
<span class="pill teal">Long caption</span>
<span class="pill amber">Carousel outline</span>
</div>
</div>
</div>
</article>
</section>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
LEGACY_CONTAINERS=(
storyforge-fastgpt-plugin
storyforge-sandbox
storyforge-pg
storyforge-minio
storyforge-redis
storyforge-mongo
)
LEGACY_NETWORK="storyforge-net"
COLLECTOR_HEALTH_URL="${COLLECTOR_HEALTH_URL:-http://127.0.0.1:8081/healthz}"
N8N_HEALTH_URL="${N8N_HEALTH_URL:-http://127.0.0.1:5670/healthz}"
APPLY="${APPLY:-0}"
log() {
printf '[cleanup] %s\n' "$*"
}
check_url() {
local url="$1"
curl -fsS "$url" >/dev/null
}
log "preflight: verifying StoryForge collector and n8n"
check_url "$COLLECTOR_HEALTH_URL"
check_url "$N8N_HEALTH_URL"
log "legacy containers:"
for container in "${LEGACY_CONTAINERS[@]}"; do
if docker ps -a --format '{{.Names}}' | grep -Fxq "$container"; then
status="$(docker inspect --format '{{.State.Status}}' "$container")"
printf ' - %s (%s)\n' "$container" "$status"
else
printf ' - %s (missing)\n' "$container"
fi
done
if [[ "$APPLY" != "1" ]]; then
log "dry run complete. Re-run with APPLY=1 to stop and remove legacy containers."
exit 0
fi
for container in "${LEGACY_CONTAINERS[@]}"; do
if docker ps -a --format '{{.Names}}' | grep -Fxq "$container"; then
log "removing $container"
docker rm -f "$container" >/dev/null
fi
done
if docker network inspect "$LEGACY_NETWORK" >/dev/null 2>&1; then
if [[ "$(docker network inspect "$LEGACY_NETWORK" --format '{{len .Containers}}')" == "0" ]]; then
log "removing empty network $LEGACY_NETWORK"
docker network rm "$LEGACY_NETWORK" >/dev/null
else
log "network $LEGACY_NETWORK still has attached containers; leaving it in place"
fi
fi
log "post-check: verifying StoryForge collector and n8n"
check_url "$COLLECTOR_HEALTH_URL"
check_url "$N8N_HEALTH_URL"
log "legacy FastGPT runtime cleanup completed"

View File

@@ -1,56 +1,30 @@
version: "3.9"
services:
mongo:
image: mongo:6
container_name: storyforge-mongo
restart: unless-stopped
ports:
- "27017:27017"
volumes:
- ./data/mongo:/data/db
vectorDB:
image: pgvector/pgvector:pg16
container_name: storyforge-pgvector
n8n:
image: ${N8N_IMAGE:-docker.n8n.io/n8nio/n8n:latest}
container_name: storyforge-n8n
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-fastgpt}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
N8N_HOST: ${N8N_HOST:-0.0.0.0}
N8N_PORT: 5678
N8N_PROTOCOL: ${N8N_PROTOCOL:-http}
WEBHOOK_URL: ${WEBHOOK_URL:-http://127.0.0.1:5670/}
GENERIC_TIMEZONE: ${GENERIC_TIMEZONE:-Asia/Shanghai}
TZ: ${TZ:-Asia/Shanghai}
N8N_SECURE_COOKIE: ${N8N_SECURE_COOKIE:-false}
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: ${N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS:-false}
ports:
- "5432:5432"
- "5670:5678"
volumes:
- ./data/pg:/var/lib/postgresql/data
redis:
image: redis:7-alpine
container_name: storyforge-redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- ./data/redis:/data
minio:
image: minio/minio:RELEASE.2025-02-07T23-21-09Z
container_name: storyforge-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
ports:
- "9000:9000"
- "9001:9001"
volumes:
- ./data/minio:/data
- ./data/n8n:/home/node/.n8n
- ./n8n:/workspace/n8n:ro
collector:
build:
context: ./collector-service
container_name: storyforge-collector
restart: unless-stopped
depends_on:
- n8n
environment:
DATA_DIR: /data/collector
DATABASE_PATH: /data/collector/storyforge.db
@@ -58,40 +32,35 @@ services:
LOCAL_OPENAI_BASE_URL: ${LOCAL_OPENAI_BASE_URL:-http://host.docker.internal:8317/v1}
LOCAL_OPENAI_MODEL: ${LOCAL_OPENAI_MODEL:-GLM-5}
LOCAL_OPENAI_API_KEY: ${LOCAL_OPENAI_API_KEY:-}
FASTGPT_BASE_URL: ${FASTGPT_BASE_URL:-http://host.docker.internal:3000}
FASTGPT_DATASET_API_KEY: ${FASTGPT_DATASET_API_KEY:-}
N8N_BASE_URL: ${COLLECTOR_N8N_BASE_URL:-http://n8n:5678}
N8N_ANALYSIS_WEBHOOK_PATH: ${N8N_ANALYSIS_WEBHOOK_PATH:-/webhook/storyforge-analysis}
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}
CUTVIDEO_BASE_URL: ${CUTVIDEO_BASE_URL:-}
CUTVIDEO_API_KEY: ${CUTVIDEO_API_KEY:-}
CUTVIDEO_BASE_CONFIG: ${CUTVIDEO_BASE_CONFIG:-example.job.yaml}
CUTVIDEO_POLL_INTERVAL_SEC: ${CUTVIDEO_POLL_INTERVAL_SEC:-10}
CUTVIDEO_MAX_WAIT_SEC: ${CUTVIDEO_MAX_WAIT_SEC:-1800}
CUTVIDEO_UPLOAD_TIMEOUT_SEC: ${CUTVIDEO_UPLOAD_TIMEOUT_SEC:-1800}
HUOBAO_BASE_URL: ${HUOBAO_BASE_URL:-http://host.docker.internal:5678}
YTDLP_BIN: ${YTDLP_BIN:-yt-dlp}
FFMPEG_BIN: ${FFMPEG_BIN:-ffmpeg}
WHISPER_BIN: ${WHISPER_BIN:-}
WHISPER_MODEL: ${WHISPER_MODEL:-/data/collector/models/ggml-base.en.bin}
ASR_HTTP_BASE_URL: ${ASR_HTTP_BASE_URL:-}
ASR_HTTP_TRANSCRIBE_PATH: ${ASR_HTTP_TRANSCRIBE_PATH:-/transcribe}
ASR_HTTP_FIELD_NAME: ${ASR_HTTP_FIELD_NAME:-wav}
ASR_HTTP_TIMEOUT_SEC: ${ASR_HTTP_TIMEOUT_SEC:-120}
HUOBAO_POLL_INTERVAL_SEC: ${HUOBAO_POLL_INTERVAL_SEC:-10}
HUOBAO_MAX_WAIT_SEC: ${HUOBAO_MAX_WAIT_SEC:-900}
ports:
- "8081:8081"
volumes:
- ./data/collector:/data/collector
command: uvicorn app.main:app --host 0.0.0.0 --port 8081
fastgpt:
image: ghcr.io/labring/fastgpt:latest
container_name: storyforge-fastgpt
restart: unless-stopped
depends_on:
- mongo
- vectorDB
- redis
- minio
ports:
- "3000:3000"
sandbox:
image: ghcr.io/labring/fastgpt-sandbox:latest
container_name: storyforge-sandbox
restart: unless-stopped
fastgpt-plugin:
image: ghcr.io/labring/fastgpt-plugin:latest
container_name: storyforge-fastgpt-plugin
restart: unless-stopped
cli-proxy-api:
image: ${CLIPROXY_IMAGE:-storyforge/cli-proxy-api:patched}
container_name: storyforge-cliproxyapi

191
docs/AUDIT_2026-03-18.md Normal file
View File

@@ -0,0 +1,191 @@
# StoryForge 现状审计
日期2026-03-18
更新2026-03-20
## 结论
当前应以 `/Users/kris/code/StoryForge-gitea` 作为主工作区继续推进,而不是 `/Users/kris/code/Fastgpt`。后者更像一次不完整的导入快照,前者才是可持续开发的真实仓库。
## 现有功能归位
### 1. `collector-service` 之前承担的功能
- 账号注册、登录、审批
- 本地模型配置
- 知识库、智能体、任务管理
- 视频链接/上传视频/文本三类入口
- 下载器、ffmpeg、whisper.cpp 风格的本地处理调用
- Android OTA 查询/发布
### 2. FastGPT 实际承担的功能
- 仅承担“数据集/文档同步”的外部依赖角色
- 代码痕迹集中在:
- `collector-service/app/fastgpt.py`
- `docker-compose.yml`
- 若干 `fastgpt_*` 字段
结论FastGPT 并不是业务内核,适合迁移后整体删除。
### 3. n8n 适合接管的功能
- 任务触发
- 工作流分流
- 外部能力编排入口
- 任务执行顺序控制
不适合承载:
- 用户、项目、Agent、知识库、任务、历史记录的主数据
- 业务状态唯一真相源
结论:应采用“业务状态在 `collector-service`,流程编排在 `n8n`”的分层。
## 多用户与数据边界
当前已明确采用:
- `accounts`
- `projects`
- `knowledge_bases`
- `assistants`
- `content_sources`
- `jobs`
- `job_events`
推荐模型:`user + project`
理由:
- 只做 `user` 级隔离,会导致一个用户内部不同内容工作流难以再分边界
- `project` 可以自然承接“一个创作者方向 / 一个客户 / 一个账号矩阵 / 一个内容实验”
- `assistant``knowledge_base``job``content_source` 都能挂到 `project`,便于后续扩展协作空间
## 外部链路审计
### 1. 下载器
- 已存在,不需要重写
- 现阶段通过 `yt-dlp` 命令集成
- 账号级内容源同步同样复用 `yt-dlp --flat-playlist`,不额外维护抓取器
### 2. ASR
- 现有实现已部署,入口现已标准化为“两级优先级”:
- 优先调用 HTTP ASR 服务
- HTTP 不可用或返回空结果时,回退到 `whisper.cpp` 命令行
- 本次已按 `mac-whisper-service``/transcribe` 协议完成接入,并用任务 `job_e95f9b5579fd4c5aa40f04de611e9fd0` 验证 `artifacts.asr_backend=http`
- 进一步联调发现真实长视频转写耗时约 44 秒,因此 `collector``ASR_HTTP_TIMEOUT_SEC` 默认值已提升到 120 秒;本机 `mac-whisper-service` 运行时也需要把 `WHISPER_TIMEOUT_MS` 提升到 `120000`
- 修复后再次验证成功,任务 `job_bb405e2e878849e38c4bb31f7781e1e3` 已写入真实 HTTP ASR 文本并记录 `artifacts.asr_http_payload`
- `collector` 运行镜像已补上 `ffmpeg``yt-dlp`,避免容器内缺依赖导致音频抽取或下载失效
### 2.1 内容源账号同步
- 已新增 `content_source_sync_pipeline`
- 用户可通过 `POST /v2/pipelines/content-source-sync` 提交创作者账号 URL
- 后端会创建父任务,使用 `yt-dlp --flat-playlist` 抓取最近 N 条视频 URL再自动派生用户自己的 `video_link` 子分析任务
- `jobs.parent_job_id` 已加入数据模型,父子任务关系可持久化查询
- 已用 bilibili 账号 URL 联调验证:
- 父任务:`job_b02109cf9e8244fbb5b86f184a7c7574`
- 子任务:`job_7f169db61af441f8a7f186d03db2d91c``job_28c47774028441378a3974860c375ab7`
结论:账号级调度不再是空白能力,但目前只验证了 `bilibili` URL 形态,抖音 / 小红书仍需真链路核实。
### 3. Windows `cutvideo`
- 仓库:`/Users/kris/code/cutvideo`
- 具备清晰 API
- `POST /api/jobs`
- `POST /api/uploads`
- `GET /api/tasks/{task_id}`
- `GET /api/runs/{run_id}`
- 适合集成为“由 StoryForge 后端授权调用的局域网剪辑能力”
当前状态:
- StoryForge 已支持把 `upload_video` 或已完成的 `video_link` 源素材自动上传到 `cutvideo`
- `real-cut` 任务可直接传 `source_job_id`,由后端完成 staging 后再提交到剪辑服务
- Windows 机器已部署带 `POST /api/uploads``cutvideo` 版本,并完成局域网联调
### 3.1 `douyin` 工作台
- `collector-service` 已具备 `/v2/douyin/*` 工作台接口
- 已补充两类关键联调增强:
- 分享文案中的 URL 自动提取与归一化
- public 页面命中抖音反爬挑战时的显式诊断返回
- 真实 smoke 结果表明,纯 public 主页抓取会落到 `byted_acrawler` 挑战页,而不是正常 profile 数据页
- 同时,`manual_profile_payload + manual_work_payloads` 已验证可完成账号入库、分析报告生成、相似账号搜索和对标关系写入
- 现已新增浏览器辅助采集工具 `/Users/kris/code/StoryForge-gitea/scripts/douyin-browser-capture/capture_and_sync.mjs`
- 同目录现已新增本地控制台 `/Users/kris/code/StoryForge-gitea/scripts/douyin-browser-capture/control_panel.mjs`
- 该工具使用真实 Playwright Chromium 会话打开抖音页面,允许人工登录 / 过滑块后继续自动提取 `<script>` JSON、网络 JSON、视频详情页和创作者中心页数据
- 浏览器工具最终直接调用现有 `/v2/douyin/accounts/sync`,不新增第二套持久化模型
- 控制台模式已经支持“开始采集 -> 浏览器登录 -> 网页点继续 -> 自动同步”的点击式流程,并修复了 ready-file 提前点击的竞态
结论:`douyin` 方向不再是“接口存在但不可用”当前状态是“public 直抓受反爬限制,但人工采集兜底链已跑通”。
### 4. `huobao-drama`
- 旧改版位置:`/Users/kris/code/huobaoduanju/huobao-drama-master`
- 最新 upstream`/Users/kris/code/huobao-drama-upstream`
- 旧改版主要多了一套 `ad_workflow` 方向,和当前 StoryForge MVP 不完全对齐
- 最新版已具备:
- `POST /api/v1/dramas`
- `POST /api/v1/images`
- `GET /api/v1/images/{id}`
- `POST /api/v1/videos`
- `GET /api/v1/videos/{id}`
- `reference_mode=first_last`
本次真实联调里,旧改版为了兼容 `qnaigc` 需要补 4 个点:
- `pkg/image/openai_image_client.go`
- `application/services/image_generation_service.go`
- `pkg/video/openai_sora_client.go`
- `application/services/video_generation_service.go`
核对结果:
- 以上 4 个文件与本机 upstream 同名文件在补丁前没有明显结构分叉
- 当前差异基本就是 `qnaigc` 图片异步查询、Kling 视频 JSON 协议、结果 URL 解析、远程首尾帧 URL 保留这几处兼容逻辑
结论这批补丁是可移植补丁MVP 已在旧改版实例上验证通过;下一步应把同样补丁迁到最新版 `huobao-drama-upstream`,而不是继续在旧目录长期演进。
补充验证2026-03-20
- `/Users/kris/code/huobao-drama-upstream` 当前工作分支为 `codex/qnaigc-compat`
- 该分支已包含 qnaigc 图片异步查询、Kling 视频协议、结果 URL 解析、远程首尾帧保留等补丁
- 另外补了 `ResourceTransferService` 的 no-op MinIO 转存方法,当前 `go build ./...` 已可全量通过
- 使用复制自旧目录的 `config.yaml + drama_generator.db + data/storage` 在隔离目录启动了 upstream 实例,地址为 `http://127.0.0.1:5681`
- 上游实例健康检查通过,`POST /api/v1/dramas` 可正常创建剧本
- 新的图片和视频生成请求已能走到 provider 调用层,但当前复制出的 AI 配置凭证返回 `403 access denied for invalid user`
- 进一步在旧改版隔离实例 `http://127.0.0.1:5682` 上重放了 fresh 图片请求,返回同样的 `403 access denied for invalid user`
- 结论因此进一步收敛:当前 blocker 不是 upstream 回归,而是外部图片/视频凭证已失效
- 已在 `huobao-drama-upstream` 增加按服务类型的运行时覆盖能力,可用 `HUOBAO_TEXT_* / HUOBAO_IMAGE_* / HUOBAO_VIDEO_*` 环境变量接管数据库中的 AI 配置
- 已在 `huobao-drama-upstream` 固化 `scripts/run_storyforge_smoke.sh`,可自动复制旧库配置与数据、起隔离实例并校验 `/health`
结论更新:`huobao-drama-upstream` 的代码级兼容迁移已经完成,当前剩余 blocker 是外部图片/视频凭证失效,导致无法用“旧配置副本”继续 fresh 生成;但新的运行时 env 覆盖路径已经就位,后续补新 key 不需要再手改 SQLite。
## 当前已完成迁移面
- FastGPT 运行时依赖已从 `collector-service` 主代码中剥离
- 旧 FastGPT 运行残留容器 `storyforge-fastgpt-plugin / sandbox / pg / minio / redis / mongo` 已于 2026-03-20 实际下线并清理
- 数据库已支持 `project/content_source/job_events`
- `collector-service` 已增加:
- `n8n` 触发
- `cutvideo` 集成 client
- `huobao-drama` 集成 client
- 内部编排接口
- `docker-compose.yml` 已改为 `collector + n8n + cli-proxy-api`
- `n8n` 工作流导出文件已纳入仓库
- `collector-service` 的 live 运行态已回归到 `StoryForge-gitea` 自身源码构建,不再依赖 `/Users/kris/code/Fastgpt/collector-service/app` 的临时 bind mount
- `collector-service` 现已在 live `8081` 提供 `/v2/douyin/*` 接口,并保留原有 `real-cut / ai-video / content-source-sync` 路由
- Android Explore 页已补上“账号同步”入口,可直接创建内容源账号同步任务,并支持平台、主页链接、账号标识、最大抓取条数、跳过已存在、自动触发分析等参数
- Android 工作区缺失的 `com.aiglasses.app.data` 数据层已从同源代码补回,当前 `./gradlew :app:compileDebugKotlin``:app:assembleDebug` 均已通过,并产出 `app-debug.apk`
## 当前主要风险
1. 小红书账号级内容源还未做真实平台验证
2. `douyin` public 直抓仍受反爬限制,但现在已经有“真实浏览器 + 人工登录 + 自动提取 + 回写现有工作台”的可落地协作链
3. `huobao-drama-upstream` 已完成代码迁移并可编译,但 fresh smoke 受外部图片/视频凭证 `403 invalid user` 阻塞
4. Android 端目前已能完成 Debug APK 构建,但仍缺少真机安装和功能回归验证

View File

@@ -0,0 +1,97 @@
# StoryForge 分阶段实施计划
日期2026-03-18
## Phase 0: 审计与基线收拢
- 确认主工作区
- 识别 FastGPT 真实职责
- 识别多用户、多项目需要的主数据模型
- 对比 `huobao-drama` 旧改版与 upstream
- 审计 `cutvideo` 接口能力
状态:已完成
## Phase 1: 业务后端改造成主状态中心
- 引入 `projects`
- 引入 `content_sources`
- 引入 `job_events`
-`knowledge_bases / assistants / jobs` 全部 project 化
- 去掉 `collector-service` 中的 FastGPT 运行时逻辑
- 增加 `agents` 别名接口,统一 Agent 语义
状态:已完成首版
## Phase 2: n8n 接管流程编排
- 公共任务创建接口只负责建任务并触发工作流
- `n8n` 负责分发:
- `analysis_pipeline`
- `real_cut_pipeline`
- `ai_video_pipeline`
- 业务步骤落在 `collector-service` 内部接口,保证状态统一入库
状态:已完成首版
## Phase 3: 内容分析主线 MVP
- 支持文本
- 支持视频链接
- 支持上传视频
- 接下载器
- 接本地 ASR
- 接本地 LLM
- 产出:
- transcript
- style_summary
- analysis
- rewrite
- storyboards
状态:已完成首版
## Phase 4: 实拍自动剪辑主线 MVP
- 建立 `real_cut` 任务类型
- 通过 `n8n -> collector -> cutvideo` 调度 Windows 机器
- 记录 `task_id / run_id / 结果产物`
状态:已完成 API 级集成
待补:
- 用户上传素材到 Windows 侧的文件转运闭环
## Phase 5: AI 自动生成视频主线 MVP
- 建立 `ai_video` 任务类型
- 从分析结果或直接 brief 生成分镜
-`huobao-drama`
- 创建 drama
- 生成首帧
- 生成尾帧
- 基于首尾帧生成视频
- 结果回写任务
状态:已完成 API 级集成
## Phase 6: 删除 FastGPT 运行依赖
- 删除代码依赖
- 删除 compose 服务
- 删除环境变量
- 删除 README 说明
状态:已完成主仓库首版
## Phase 7: 联调与验证
- Python 语法检查
- Compose 配置检查
- `collector-service` 本地启动
- `n8n` workflow 导入
- Windows `cutvideo` 局域网调度
- `huobao-drama` 本机调用
状态:进行中

View File

@@ -0,0 +1,312 @@
# StoryForge 本地 / 局域网联调说明
日期2026-03-18
## 1. 准备 `.env`
复制:
```bash
cd /Users/kris/code/StoryForge-gitea
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`
- `CUTVIDEO_BASE_URL=http://<windows-lan-ip>:7860`
- `CUTVIDEO_API_KEY=` 如果 Windows 服务启用了鉴权
- `HUOBAO_BASE_URL=http://127.0.0.1:5678`
- `WHISPER_BIN=` 指向你现有本地 ASR 可执行文件时填写
- `ASR_HTTP_BASE_URL=` 如果你已有常驻 ASR 服务,填写它的基地址
- `ASR_HTTP_TRANSCRIBE_PATH=/transcribe`
- `ASR_HTTP_FIELD_NAME=wav`
- `ASR_HTTP_TIMEOUT_SEC=120`
说明:
- 如果你单独重建 `collector`,要确保运行时仍带上 `CUTVIDEO_BASE_URL`,否则容器会退回空值
- `collector` 容器不要直接复用宿主机的 `N8N_BASE_URL=http://127.0.0.1:5670`,否则容器内会连回自己并导致 webhook 调度失败
- 当前已验证可用的 Windows `cutvideo` 地址是 `http://192.168.31.18:7860`
- 当前已验证可用的本机 HTTP ASR 入口是 `http://host.docker.internal:8088/transcribe`
- 如果你用的是本机 `mac-whisper-service`,建议同时以 `WHISPER_TIMEOUT_MS=120000` 启动,否则长视频会直接 504
## 2. 启动基础服务
```bash
cd /Users/kris/code/StoryForge-gitea
docker compose up -d --build
```
检查:
- `collector-service``http://127.0.0.1:8081/healthz`
- `n8n``http://127.0.0.1:5670`
- `cli-proxy-api``http://127.0.0.1:8317`
- 本机 `huobao-drama``http://127.0.0.1:5678/health`
## 3. 导入 n8n workflows
`n8n/workflows/` 导入:
- `storyforge-analysis.json`
- `storyforge-real-cut.json`
- `storyforge-ai-video.json`
- `storyforge-content-source-sync.json`
导入后:
- 检查每个 HTTP Request 节点的 `X-Orchestrator-Secret`
- 如果你改了 `.env` 的 secret这里必须同步
## 4. 登录与审批
默认超级管理员:
- 用户名:`kris`
- 密码:`Asd123456.`
新用户注册后,需要用超级管理员审批。
## 5. 内容分析链路验证
### 文本
调用 `POST /v2/explore/text`
预期:
- 任务创建成功
- `n8n` webhook 被触发
- 任务最终进入 `completed`
- 知识库文档里出现 transcript / style_summary / analysis / storyboards
已验证样例:
- `job_203bc8e9b20f4b1cbbc6cf7da79e46f4`
### 视频链接
调用 `POST /v2/explore/video-link`
前提:
- `yt-dlp` 可用
- `ffmpeg` 可用
- ASR 可调用
已验证样例:
- `job_bb405e2e878849e38c4bb31f7781e1e3` (`artifacts.asr_backend=http`)
### 上传视频
调用 `POST /v2/explore/upload-video`
预期与视频链接类似,但素材来源为本地上传
## 6. 内容源账号同步验证
调用 `POST /v2/pipelines/content-source-sync`
推荐最小请求体:
```json
{
"source_url": "https://space.bilibili.com/546195/video",
"platform": "bilibili",
"title": "Bilibili Creator Sync Smoke",
"max_items": 2,
"skip_existing": true,
"auto_trigger_analysis": true
}
```
预期:
- 创建一个 `content_source_sync` 父任务
- `n8n` 触发 `content_source_sync_pipeline`
- 父任务写回 `discovered_videos / child_job_ids / queued_job_ids`
- 子任务以 `parent_job_id` 挂到父任务下,并自动进入分析主线
已验证样例:
- 父任务:`job_b02109cf9e8244fbb5b86f184a7c7574`
- 子任务:`job_7f169db61af441f8a7f186d03db2d91c`
- 子任务:`job_28c47774028441378a3974860c375ab7`
## 6.1 `douyin` 账号工作台验证
基础接口:
- `POST /v2/douyin/accounts/sync`
- `POST /v2/douyin/accounts/{account_id}/analysis`
说明:
- `profile_url` 现在支持直接传分享文案,后端会自动提取里面的 URL
- 如果 public 页面命中抖音反爬挑战,接口会返回 `public_profile_anti_bot_challenge`
- 遇到挑战页时,继续可用的路径是 `manual_profile_payload``manual_work_payloads``manual_creator_pages`
已验证样例:
- public 页面 smoke返回 `public_profile_anti_bot_challenge`
- 手工导入账号:`dyacct_c2b62842b228406cb48f05fac16fdfdf`
- 手工账号分析报告:`dyreport_10d6b8d2d52a404192f54a3a05d44546`
- 相似账号搜索:`dysearch_c247b75db0df49429a1d127407fe4486`
- 对标关系:`dyrel_c8df266341e74237b99c880eb4b572d8`
浏览器辅助采集:
```bash
cd /Users/kris/code/StoryForge-gitea/scripts/douyin-browser-capture
npm install
npx playwright install chromium
npm run control-panel
```
浏览器打开:
```text
http://127.0.0.1:3618
```
控制台步骤:
1. 填写抖音主页链接和 StoryForge 账号
2. 如需查看采集结果,不用离开这个页面;下半部分 `Douyin Workbench` 会展示账号列表、Agent 结论、快照详情和对标结果
3. `作品工作台` 支持高分榜、最新榜和全部作品切换,并支持多种排序方式
4. 点击“自动分析高分作品”后,每条高分作品下会补齐商业判断、复刻建议、运营动作和风险提醒
2. 点击 `开始采集`
3. 在弹出的 Chromium 里登录或通过挑战页
4. 回到控制台点击 `已完成登录,继续采集`
5. 等待 `summary.json` 和可选的 `storyforge-sync-response.json`
命令行方式仍然保留:
```bash
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.'
```
说明:
- 脚本会打开真实 Chromium 会话,默认复用 `~/.storyforge/douyin-playwright` 登录态
- 如果出现扫码登录、滑块或挑战页,先在浏览器里人工完成,再回终端继续
- 脚本会保存 `profile-bundle.json``storyforge-sync-request.json` 和同步响应
- 当前已完成 headless 最小 smoke输出目录
- `/tmp/storyforge-douyin-capture-smoke/2026-03-20T06-49-37.705Z-storyforge_test_001`
- 当前已完成本地控制台 smoke输出目录
- `/Users/kris/code/StoryForge-gitea/output/playwright/douyin/control-panel/run-mmyzplxp-cw0o7q/2026-03-20T14-24-13.174Z-storyforge_test_001`
- `/Users/kris/code/StoryForge-gitea/output/playwright/douyin/control-panel/run-mmyzshsp-c6vdhi/2026-03-20T14-26-27.792Z-storyforge_test_001`
- 控制台模式已经修复“提前点击继续导致 ready 信号丢失”的竞态,早于等待点按钮也不会卡死
## 7. `cutvideo` 实拍剪辑链路验证
调用 `POST /v2/pipelines/real-cut`
当前 MVP 前提:
- 方式 A直接传 `input_dir`,它必须是 Windows `cutvideo` 机器可访问的目录
- 方式 B`source_job_id``collector-service` 会把 `upload_video` 或已完成的 `video_link` 源素材自动上传到 Windows `cutvideo`,再继续发起任务
- 如果走方式 B大文件上传超时由 `CUTVIDEO_UPLOAD_TIMEOUT_SEC` 控制
预期:
- 任务创建成功
- 如果用了 `source_job_id`,任务 `artifacts.cutvideo_upload` 会记录 Windows staging 结果
- `n8n` 调用 `collector-service` 内部 real-cut step
- 后端记录 `provider_task_id`
- 最终任务写回 `cutvideo_run`
已验证样例:
- `job_5ebd829c3f2144bca5c941183e75bdcd`
- `job_01a6f283cbda42e4ae692b268b811a50` (`source_job_id` 自动 staging本机 `cutvideo` 联调)
- Windows 返回 `task_id=8d8f4a0cd5d9`
- 运行目录 `20260318-093520-Windows cutvideo 联调样例`
## 8. `huobao-drama` AI 视频链路验证
调用 `POST /v2/pipelines/ai-video`
推荐方式:
- 先完成一个分析任务
- 再把该分析任务的 `source_job_id` 传给 AI 视频任务
预期:
- 创建 drama
- 每个分镜生成首帧、尾帧
- 每个分镜生成视频
- 最终 `job.result.rendered_scenes` 有完整结果
已验证样例:
- `job_01828c40377747cf914b51be360cc333`
- `provider_task_id=10`
- `video.task_id=qvideo-1380265978-1773799215825814468`
- 最终视频已回写到 `job.result.rendered_scenes[0].video.video_url`
补充说明2026-03-20
- `huobao-drama-upstream` 已在隔离目录用复制的旧配置和数据库起过实例,`/health` 正常
- fresh 图片/视频生成请求已能进入 provider 调用,但当前复制出的图片/视频凭证返回 `403 invalid user`
- 同样的 fresh 图片请求已在旧改版隔离实例 `http://127.0.0.1:5682` 上重放,结论一致,所以当前不是 upstream 回归问题
- `huobao-drama-upstream` 现在支持 `HUOBAO_TEXT_* / HUOBAO_IMAGE_* / HUOBAO_VIDEO_*` 运行时覆盖数据库里的 AI 配置
- `huobao-drama-upstream` 已新增 `/Users/kris/code/huobao-drama-upstream/scripts/run_storyforge_smoke.sh`,可自动复制旧目录配置和数据,在默认 `5681` 端口起隔离实例并校验 `/health`
- 如果你要重新验证 upstream fresh 生成,优先给 huobao 进程补这些环境变量,再复跑即可
推荐覆盖字段:
- `HUOBAO_TEXT_PROVIDER / BASE_URL / API_KEY / MODELS`
- `HUOBAO_IMAGE_PROVIDER / BASE_URL / API_KEY / MODELS`
- `HUOBAO_VIDEO_PROVIDER / BASE_URL / API_KEY / MODELS`
- 如需强制指定端点,还可补 `ENDPOINT / QUERY_ENDPOINT`
## 9. 当前已知卡点
- 抖音 public 页面直抓会命中反爬挑战;生产接入仍需要 cookie 或人工页面采集协助
- 小红书账号级内容源还未做真实平台验证
- `huobao-drama-upstream` 代码已迁移完成,但 fresh 生成仍受外部图片/视频凭证 `403 invalid user` 阻塞
## 10. 旧 FastGPT 残留清理
- 旧 FastGPT runtime 容器已在 2026-03-20 实际清理完成:
- `storyforge-fastgpt-plugin`
- `storyforge-sandbox`
- `storyforge-pg`
- `storyforge-minio`
- `storyforge-redis`
- `storyforge-mongo`
- 清理脚本已纳入仓库:
- `/Users/kris/code/StoryForge-gitea/deploy/cleanup_legacy_fastgpt_runtime.sh`
- 脚本会在清理前后校验:
- `http://127.0.0.1:8081/healthz`
- `http://127.0.0.1:5670/healthz`
## 11. Android 本地构建
如果你要在本机重新打 Android 包:
```bash
cd /Users/kris/code/StoryForge-gitea/android-app
./gradlew :app:assembleDebug
```
当前已验证结果:
- `:app:compileDebugKotlin` 通过
- `:app:assembleDebug` 通过
- APK 输出路径:
- `/Users/kris/code/StoryForge-gitea/android-app/app/build/outputs/apk/debug/app-debug.apk`
补充说明:
- 工作区根目录的 `.gitignore` 里保留了通用 `data/` 忽略规则,但已对 Android 源码目录 `android-app/app/src/main/java/com/aiglasses/app/data/` 做了白名单放行,避免误伤客户端代码

View File

@@ -0,0 +1,68 @@
# StoryForge MVP 状态
日期2026-03-18
更新2026-03-20
## 已跑通或已完成代码接通
- 多用户账号体系
- 审批机制
- `user -> project -> assistant / knowledge base / job / content source` 数据模型
- 文本 / 视频链接 / 上传视频 三类分析任务创建
- 内容源账号同步任务创建与子任务派发
- Android Explore 页已补上内容源账号同步入口
- Android `com.aiglasses.app.data` 数据层已补回,`compileDebugKotlin``assembleDebug` 已通过
- `n8n` 工作流导入、激活与触发接口
- 本地下载器调用
- 本地 `ffmpeg` / `whisper` 风格入口封装
- HTTP ASR 常驻服务入口绑定
- 本地大模型内容分析、二创文案、分镜生成
- Windows `cutvideo` API 调度与结果回写接口
- `upload_video -> source_job_id -> cutvideo` 自动 staging 闭环
- `collector` live 运行态已从临时源码挂载切回 `StoryForge-gitea` 正式镜像
- live `collector` 已挂出 `/v2/douyin/*` 能力并通过认证接口验证
- `douyin` 支持从分享文案中提取 `profile_url`,并在 public 页面命中抖音反爬挑战时返回明确诊断
- `douyin` 手工 payload 导入与账号分析链路已跑通
- `douyin` 浏览器辅助采集工具已接入,可用真实 Playwright Chromium 会话采集主页 / 视频页并直接调用现有 `/v2/douyin/accounts/sync`
- `douyin` 本地控制台已接入,可通过网页点击方式驱动浏览器辅助采集并查看最近运行结果
- 本机 `huobao-drama` API 调度、首尾帧生成、视频生成与结果回写接口
- FastGPT 运行时依赖删除
- 旧 FastGPT 运行残留容器已实际下线
## 已验证的真实任务
- 分析链路:`job_203bc8e9b20f4b1cbbc6cf7da79e46f4`
- HTTP ASR 分析链路:`job_e95f9b5579fd4c5aa40f04de611e9fd0`
- 账号级内容源同步链路:`job_b02109cf9e8244fbb5b86f184a7c7574`
- 账号级同步派生分析任务:`job_7f169db61af441f8a7f186d03db2d91c``job_28c47774028441378a3974860c375ab7`
- 长视频 HTTP ASR 超时修复后链路:`job_bb405e2e878849e38c4bb31f7781e1e3`
- 实拍剪辑链路:`job_5ebd829c3f2144bca5c941183e75bdcd`
- 实拍剪辑自动 staging 联调:`job_01a6f283cbda42e4ae692b268b811a50`
- AI 视频链路:`job_01828c40377747cf914b51be360cc333`
- Windows `cutvideo` 部署后联调:`job_5838515ed5c34679acd55a52cfcd424b`
- `douyin` 手工导入账号:`dyacct_c2b62842b228406cb48f05fac16fdfdf`
- `douyin` 账号分析报告:`dyreport_10d6b8d2d52a404192f54a3a05d44546`
- `douyin` 相似账号搜索:`dysearch_c247b75db0df49429a1d127407fe4486`
- `douyin` 对标关系:`dyrel_c8df266341e74237b99c880eb4b572d8`
- `huobao-upstream` 隔离 smoke 剧本:`drama_id=11` (`http://127.0.0.1:5681`)
- `huobao-upstream` 隔离 smoke 启动脚本:`/Users/kris/code/huobao-drama-upstream/scripts/run_storyforge_smoke.sh`
- Android Debug APK`/Users/kris/code/StoryForge-gitea/android-app/app/build/outputs/apk/debug/app-debug.apk`
- `douyin` 浏览器采集最小 smoke`/tmp/storyforge-douyin-capture-smoke/2026-03-20T06-49-37.705Z-storyforge_test_001`
- `douyin` 控制台 smoke`/Users/kris/code/StoryForge-gitea/output/playwright/douyin/control-panel/run-mmyzplxp-cw0o7q/2026-03-20T14-24-13.174Z-storyforge_test_001`
- `douyin` 控制台提前继续回归 smoke`/Users/kris/code/StoryForge-gitea/output/playwright/douyin/control-panel/run-mmyzshsp-c6vdhi/2026-03-20T14-26-27.792Z-storyforge_test_001`
## 尚未完全跑通
- 小红书账号级内容源还未做真实平台验证
- `douyin` public 主页直抓会命中 `public_profile_anti_bot_challenge`;当前已验证手工 payload 导入、分析、相似账号搜索和对标关系可作为可用兜底路径
- `douyin` 浏览器辅助采集已经能真实输出 `profile-bundle.json / storyforge-sync-request.json`,但要拿到有效主页数据仍需要用户在浏览器里完成登录或挑战校验
- `douyin` 控制台点击流已可用,但它仍然依赖本机可打开 Chromium 的环境,不适合放进纯 Docker 容器内部跑 GUI
- `huobao-upstream` 已能全量编译;并且旧改版隔离实例也已重放确认,当前 fresh 生成被外部图片/视频凭证统一返回 `403 invalid user`
- `huobao-upstream` 已新增 `HUOBAO_TEXT_* / HUOBAO_IMAGE_* / HUOBAO_VIDEO_*` 运行时覆盖能力,后续补新 key 可直接接管数据库配置
- Android Debug 包已可本地构建,但尚未完成真机安装验证
## 下一步优先级
1. 更新 `huobao` 可用图片/视频凭证后,用新的 env 覆盖能力对 upstream 版补一轮完整 `drama -> images -> video` fresh smoke
2. 补抖音真实账号的 cookie / 手工页面采集联调,以及小红书账号级验证
3.`collector` live 切换结果和部署回滚说明继续固化到仓库

View File

@@ -0,0 +1,555 @@
# StoryForge 产品逻辑重构手册
日期2026-03-22
## 1. 目标重定义
StoryForge 不应再被定义成“AI 内容工具集合”。
更准确的定位应是:
**一个以“项目”为入口、以 Agent 为执行中枢、面向多平台账号经营的新媒体运营与生产中台。**
覆盖的平台至少包括:
- 小红书
- 抖音
- 快手
- 微信视频号
- YouTube
- 哔哩哔哩
新的核心能力不是“直接生成一条内容”,而是先完成:
1. 用户先建项目,明确这是已绑定账号项目还是预调研项目
2. 项目创建后先创建 Agent
3. Agent 完成账号画像、多平台市场调研和导入分析
4. 持续跟踪重点创作者的更新并自动汇总日报
5. 再把分析结果转成内容生产链与复盘闭环
## 2. 为什么要调整
之前的系统更偏:
- 任务中心
- Agent 中心
- Pipeline 中心
这对研发是友好的,但对创作者不够自然。
创作者真正的心智顺序是:
1. 我先要建一个项目
2. 这个项目是运营自己的账号,还是先做市场调研
3. 我应该先创建哪个 Agent
4. 这个 Agent 要服务哪些平台、靠什么变现
5. 参考作品和主页怎么导入,谁来分析
6. 哪条内容该走文案、封面、实拍剪辑还是 AI 视频
7. 产生的额度和成本怎么管
8. 发完之后效果如何
因此 StoryForge 的主对象必须重构。
## 3. 新的主对象模型
### 3.1 项目 Project
项目是 StoryForge 的第一层入口,分为两类:
- `bound_account_project`:已绑定账号项目,适合直接围绕自己的账号运营
- `pre_research_project`:预调研项目,适合先做市场和账号研究,再决定后续是否绑定账号
项目创建后,不直接进入生产,而是先进入 Agent 创建流程。
### 3.2 工作区 Workspace
代表一个团队、品牌、创作者个人,或者一个客户项目集合。
### 3.3 平台账号 Platform Account
按平台保存账号实体,必须带平台字段:
- `xiaohongshu`
- `douyin`
- `kuaishou`
- `wechat_video`
- `youtube`
- `bilibili`
账号类型分两类:
- `reference_account`:参考账号 / 精品账号 / 对标账号
- `owned_account`:自己在运营的账号
### 3.4 Agent
Agent 是项目内的执行中枢,不是用户直接操作内容的替代品。
创建 Agent 时必须定义:
- 账号类型
- 变现方式
- 目标平台
- 默认主大模型
- 可选对比模型
目标平台必须支持多选,至少包括:
- 小红书
- 抖音
- 快手
- 微信视频号
- YouTube
- 哔哩哔哩
Agent 创建完成后,默认先做多平台市场调研,再进入账号导入、分析、生产和复盘。
### 3.5 多平台市场调研
这是 Agent 创建后的第一步工作,不是可选项。
调研输出建议包含:
- 平台机会判断
- 账号类型差异
- 内容形态偏好
- 变现方式匹配度
- 竞争密度
- 适合先做的平台建议
### 3.6 账号画像 Account Insight
对一个账号的阶段性总结,不是单次报告。
建议固定结构:
- 账号定位
- 栏目结构
- 内容支柱
- 爆款规律
- 商业化机会
- 风险与短板
- 下阶段动作建议
### 3.7 作品 Content Item
所有作品统一抽象,不管来源于哪个平台,都沉淀到生产中心里的“作品与成片”区域。
作品需要统一字段:
- 标题
- 平台
- 作者
- 发布时间
- 内容类型:视频 / 图文 / 长视频 / Shorts
- 互动指标:播放、点赞、评论、收藏、转发
- 平台原链接
- 标准化热度分
- 标准化商业价值分
- 标准化可复刻分
### 3.8 跟踪账号 Tracking Account
这是区别于“一次性导入”的持续性对象。
用户可以手动把某些参考账号加入跟踪列表,系统随后持续监控:
- 是否有新作品发布
- 自上次打开后新增了哪些内容
- 哪些新内容值得借鉴
- 应该送给哪个 Agent 做进一步学习
跟踪账号需要绑定:
- 平台
- 账号主页
- 所属项目
- 关联 Agent
- 是否开启自动日报
### 3.9 更新日报 Update Digest
日报不是固定按自然日生成,而是按“自用户上次打开后”或“自上次已读后”的更新窗口动态汇总。
例如:
- 用户 1 天没打开,则生成 1 天更新汇总
- 用户 5 天没打开,则自动生成 5 天汇总
日报内容应包含:
- 跟踪账号新增内容
- 作品摘要
- Agent 标注的借鉴点
- 风险点
- 建议动作
- 一键加入学习集 / Playbook / 生产中心作品区
### 3.10 内容打法 Playbook
从精品账号和高分作品中总结出的可学习方法论。
例如:
- 开头钩子模板
- 文案结构模板
- 镜头节奏模板
- 情绪驱动模板
- 选题组合模板
### 3.11 生产任务 Production Task
生产任务不是平台发现逻辑,而是执行逻辑。
统一分为:
- 文案生成任务
- 封面生成任务
- 实拍剪辑任务
- AI 视频任务
- 发布准备任务
- 复盘任务
### 3.12 发布复盘 Publish Review
真正的闭环在发布后。
复盘必须沉淀:
- 作品最终版本
- 发布时间
- 实际平台链接
- 实际数据表现
- 是否达到目标
- 下一步建议
## 4. 核心业务闭环
StoryForge 的闭环应该改成下面 8 步:
### 第 1 步:创建项目
用户先建项目,项目分两类:
- 已绑定账号项目:直接围绕自己的账号运营
- 预调研项目:先研究市场和参考账号,再决定是否进入绑定账号运营
### 第 2 步:创建 Agent
项目创建后先创建 Agent并在创建时定义
- 账号类型
- 变现方式
- 目标平台
- 默认主大模型
- 可选对比模型
### 第 3 步:多平台市场调研
Agent 创建后先做多平台市场调研,为项目判断优先平台和内容方向。
### 第 4 步:导入参考作品或主页
参考作品 / 参考主页导入时必须支持:
- 手动绑定 Agent
- 自动关联 Agent
导入后的分析不由用户手工处理,而由 Agent 负责完成。
### 第 5 步:跟踪重点账号并生成更新日报
用户可以把重点参考账号加入“跟踪账号”列表。
系统应在账号更新后自动:
- 抓取最新作品
- 汇总自上次打开后的新增内容
- 由关联 Agent 标注借鉴点
- 生成日报供用户进入系统后优先查看
### 第 6 步:沉淀账号画像与内容打法
Agent 将调研和导入分析结果转成结构化资产:
- 账号画像
- 内容打法
- Playbook
- 选题池
### 第 7 步:进入生产链
生产链统一分流为:
- 文案
- 封面生成
- 实拍剪辑
- AI 视频
### 第 8 步:发布与复盘
发布后把真实反馈写回系统,更新:
- 项目策略
- 账号策略
- 选题池
- Playbook
- Agent 学习集
## 5. 页面与信息架构
## 5.1 Web 端一级导航
建议固定为:
- 运营总台
- 我的项目
- Agent
- 找对标
- 跟踪账号
- 自运营账号
- Playbook
- 生产中心
- 发布与复盘
- 自动流程
- 设置
## 5.2 运营总台
首页不应该先展示工具,而应该先展示业务动作:
- 今日待办
- 待创建的项目
- 待创建的 Agent
- 新发现的高价值账号
- 新发现的高价值作品
- 本周重点选题
- 待生产任务
- 待复盘任务
- 平台异常提醒
## 5.3 找对标页
这个页面应借鉴 `飞瓜 / 千瓜` 的榜单和筛选思路,但它不只是“发现页”,还要承接对标账号的页内详情。
核心结构:
- 页内搜索
- 顶部平台切换
- 赛道筛选
- 榜单类型切换
- 排序切换
- 列表区
- 页内详情区或展开态
- 快速加入项目 / 绑定 Agent
补充要求:
- 全局搜索保留,但找对标页必须有页内搜索
- 页内搜索支持账号名、主页链接、作品链接、关键词
- “变现方式”不应只保留单一选项,至少支持不限、知识付费、广告合作、带货转化、私域咨询
## 5.4 跟踪账号页
这是一个高价值的持续运营页面,必须进入一级导航。
核心结构:
- 跟踪账号列表
- 最近更新时间
- 关联 Agent
- 更新日报
- 借鉴点标注
- 一键加入学习集 / Playbook / 生产中心作品区
逻辑要求:
- 跟踪账号由用户手动添加
- 系统自动监控更新
- 日报按“上次打开后”汇总,而不是死板按自然日切分
- 如果用户多天未登录,则进入平台后看到的是多天汇总日报
## 5.5 找对标页内详情态
对标账号的详情不要再拆成独立一级页面,而应在 `找对标` 页面里用页内展开、右侧详情区或抽屉承接。
建议包含:
- 总览
- 高分作品
- 账号画像
- 内容打法
- 相似账号
- 已学习 Agent
## 5.6 我的项目
“我的项目”是新的主入口,建议展示:
- 项目类型
- 绑定状态
- 已创建 Agent
- 调研状态
- 导入状态
- 生产进度
- 复盘状态
项目详情里要能直接进入 Agent 创建和 Agent 管理。
## 5.7 自运营账号工作区
比参考账号多两块:
- 生产计划
- 发布复盘
## 5.8 生产中心里的作品与成片
作品不再单独拆成一级页,而是并入生产中心里的“作品与成片”区域。
这个区域必须支持:
- 平台筛选
- 类型筛选
- 时间筛选
- AI 分数排序
- 互动热度排序
- 商业价值排序
- 可复刻排序
每条内容下面必须同时展示:
- 基础数据
- AI 摘要
- 可借鉴点
- 风险点
- 一键加入 Playbook / 选题池 / Agent 学习集
## 5.9 Playbook 页
这是 StoryForge 未来的核心资产层。
Playbook 不能只是文本。
应结构化为:
- 适用平台
- 适用赛道
- 适用人群
- 钩子模板
- 结构模板
- 表达模板
- 商业承接方式
- 不适用场景
## 5.10 Agent 工作台
Agent 页面不要做成技术配置页。
应分为:
- 学习源
- 能力标签
- 当前任务
- 输出风格
- 产出记录
- 账号类型
- 变现方式
- 目标平台
- 默认主大模型
- 可选对比模型
高级 Prompt 和模型切换才进入高级设置。
## 5.11 生产中心
生产中心统一承接所有内容生产,不要再拆成分散入口。
主分流:
- 文案
- 封面生成
- 实拍剪辑
- AI 视频
同时要内置“作品与成片”区域,让用户在生产页面里直接查看:
- 当前在产内容
- 已沉淀的高分内容
- 待审核成片
- 已发布后待复盘内容
## 5.12 发布与复盘
这个模块是现在最缺的。
建议结构:
- 待发布
- 已发布
- 7 日复盘
- 30 日复盘
- 继续做 / 停止做 / 升级做
## 6. 产品规则补充
### 6.1 参考作品和主页导入
导入参考作品或主页时,必须支持两种方式:
- 手动绑定到某个 Agent
- 系统自动关联到推荐 Agent
无论哪种方式,后续的导入分析都由 Agent 负责,不再依赖用户手工整理。
### 6.2 跟踪账号与日报
跟踪账号是长期行为,不是一次性导入。
规则建议:
- 用户手动把账号加入跟踪列表
- 系统监控是否有新增作品
- 新增作品按“上次打开后”自动汇总
- 由用户创建的 Agent 分析借鉴点
- 用户打开平台后优先看到这组日报
- 高价值更新可一键送入学习集 / Playbook / 生产中心作品区
### 6.3 API key 管理
API key 统一后台托管,用户不直接管理密钥。
产品侧只展示:
- 当前可用模型
- 模型能力说明
- 额度消耗情况
- 是否支持对比模型
### 6.4 积分 / 额度体系
新增积分 / 额度体系,先按三类额度表达:
- 文案额度
- 封面额度
- 视频额度
额度用于控制生成、渲染和调用成本,不要求用户感知底层 API key。
## 7. 对当前 StoryForge 的直接调整建议
### 7.1 产品抽象调整
从:
- Workspace
- Job
- Pipeline
改成:
- 项目
- Agent
- 账号
- 作品
- Playbook
- 生产
- 复盘
### 7.2 Douyin Workbench 调整
当前 Douyin Workbench 是一个阶段性工具页。
下一步要升级成通用的 `Platform Account Workspace`
也就是:
- 不再只服务抖音
- 抖音先做出来,但模型上必须对齐未来多平台
### 7.3 Agent 展示方式调整
Agent 必须保留,并成为项目执行主中枢,但不应替代项目作为一级入口。
一级主视角应该是:
- 项目
- Agent
- 账号
- 作品
- Playbook
- 生产
- 复盘
### 7.4 API Key 管理调整
这一项直接沿用 6.3 的规则,产品落地时只需要把“可用模型、能力说明、额度消耗、对比模型支持情况”放到前台,不把密钥暴露给用户。
### 7.5 额度体系调整
这一项直接沿用 6.4 的规则,产品层面只暴露三类额度:
- 文案额度
- 封面额度
- 视频额度
额度用于控制生成、渲染和调用成本,不要求用户感知底层 API key。
## 8. 当前优先级建议
### P0
- 定义新的项目对象模型
- 定义多平台账号模型
- 重做 Web 信息架构
- 把“项目 -> Agent -> 调研 -> 导入分析 -> 生产 -> 复盘”的闭环做清楚
- 打通 API key 后台托管
- 打通文案 / 封面 / 视频三类额度
### P1
- 打通 Playbook
- 打通发布与复盘
- 把 Douyin Workbench 升级成多平台工作区框架
- 打通参考作品 / 主页导入时的手动绑定与自动关联 Agent
- 打通 Agent 的多平台市场调研
### P2
- 团队协作
- 审批流
- 批量投放与品牌协作
## 9. 最终一句话
StoryForge 的下一阶段不应该再做成“AI 工具后台”。
它应该做成:
**一个以项目为入口、由 Agent 驱动、覆盖多平台调研、导入分析、内容生产和复盘的新媒体运营中台。**

30
n8n/README.md Normal file
View File

@@ -0,0 +1,30 @@
# n8n Workflows
本目录保存 StoryForge 的工作流导出文件,避免流程只存在于 n8n UI。
## 工作流
- `workflows/storyforge-analysis.json`:内容分析主线
- `workflows/storyforge-real-cut.json`Windows `cutvideo` 调度主线
- `workflows/storyforge-ai-video.json``huobao-drama` AI 生成视频主线
- `workflows/storyforge-content-source-sync.json`:内容源账号同步与批量分析派发主线
## 约定
- 工作流内部默认通过 `http://collector:8081` 调用 `collector-service`
- 内部调用头部使用 `X-Orchestrator-Secret: storyforge-local-secret`
- 如果你修改了 `.env` 里的 `ORCHESTRATOR_SHARED_SECRET`,导入工作流后需要同步更新对应 HTTP Request 节点
## 导入
1. 先执行 `docker compose up -d n8n collector`
2. 打开 `http://127.0.0.1:5670`
3. 从 UI 导入本目录下的 4 个 JSON
4. 激活工作流
## Webhook 路径
- `/webhook/storyforge-analysis`
- `/webhook/storyforge-real-cut`
- `/webhook/storyforge-ai-video`
- `/webhook/storyforge-content-source-sync`

View File

@@ -0,0 +1,70 @@
{
"name": "StoryForge AI Video Pipeline",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "storyforge-ai-video",
"responseMode": "onReceived",
"options": {}
},
"id": "aivideo-webhook",
"name": "AI Video Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
220,
300
],
"webhookId": "storyforge-ai-video"
},
{
"parameters": {
"method": "POST",
"url": "={{'http://collector:8081/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"
}
]
},
"options": {
"timeout": 3600000
}
},
"id": "aivideo-runner",
"name": "Run AI Video Step",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
520,
300
]
}
],
"connections": {
"AI Video Webhook": {
"main": [
[
{
"node": "Run AI Video Step",
"type": "main",
"index": 0
}
]
]
},
"Run AI Video Step": {
"main": [
[]
]
}
},
"active": false,
"settings": {},
"pinData": {},
"versionId": "storyforge-ai-video-v1"
}

View File

@@ -0,0 +1,70 @@
{
"name": "StoryForge Analysis Pipeline",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "storyforge-analysis",
"responseMode": "onReceived",
"options": {}
},
"id": "analysis-webhook",
"name": "Analysis Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
220,
300
],
"webhookId": "storyforge-analysis"
},
{
"parameters": {
"method": "POST",
"url": "={{'http://collector:8081/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"
}
]
},
"options": {
"timeout": 600000
}
},
"id": "analysis-runner",
"name": "Run Analysis Step",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
520,
300
]
}
],
"connections": {
"Analysis Webhook": {
"main": [
[
{
"node": "Run Analysis Step",
"type": "main",
"index": 0
}
]
]
},
"Run Analysis Step": {
"main": [
[]
]
}
},
"active": false,
"settings": {},
"pinData": {},
"versionId": "storyforge-analysis-v1"
}

View File

@@ -0,0 +1,70 @@
{
"name": "StoryForge Content Source Sync",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "storyforge-content-source-sync",
"responseMode": "onReceived",
"options": {}
},
"id": "content-source-sync-webhook",
"name": "Content Source Sync Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
220,
300
],
"webhookId": "storyforge-content-source-sync"
},
{
"parameters": {
"method": "POST",
"url": "={{'http://collector:8081/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"
}
]
},
"options": {
"timeout": 600000
}
},
"id": "content-source-sync-runner",
"name": "Run Content Source Sync Step",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
540,
300
]
}
],
"connections": {
"Content Source Sync Webhook": {
"main": [
[
{
"node": "Run Content Source Sync Step",
"type": "main",
"index": 0
}
]
]
},
"Run Content Source Sync Step": {
"main": [
[]
]
}
},
"active": false,
"settings": {},
"pinData": {},
"versionId": "storyforge-content-source-sync-v1"
}

View File

@@ -0,0 +1,70 @@
{
"name": "StoryForge Real Cut Pipeline",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "storyforge-real-cut",
"responseMode": "onReceived",
"options": {}
},
"id": "realcut-webhook",
"name": "Real Cut Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
220,
300
],
"webhookId": "storyforge-real-cut"
},
{
"parameters": {
"method": "POST",
"url": "={{'http://collector:8081/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"
}
]
},
"options": {
"timeout": 3600000
}
},
"id": "realcut-runner",
"name": "Run Real Cut Step",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
520,
300
]
}
],
"connections": {
"Real Cut Webhook": {
"main": [
[
{
"node": "Run Real Cut Step",
"type": "main",
"index": 0
}
]
]
},
"Run Real Cut Step": {
"main": [
[]
]
}
},
"active": false,
"settings": {},
"pinData": {},
"versionId": "storyforge-real-cut-v1"
}

View File

@@ -0,0 +1,68 @@
# 新媒体运营平台 UI 参考包
这份参考包只保留更贴近 `StoryForge` 业务逻辑的页面,不再以通用 AI 后台为主。
目标场景:
- 发现各平台精品账号
- 分析账号和作品
- 沉淀可学习的内容方法论
- 创建 Agent 学习这些方法论
- 反推到自己的选题、生产、发布与复盘
预览入口:
- [图片墙预览](./index.html)
## 当前收录
### 千瓜数据
来源:
- `https://www.qian-gua.com/rank/fans/1/7/20250112/0.html`
- `https://www.qian-gua.com/Home/AllPrice`
适合借鉴:
- 小红书达人榜单页
- 多维筛选条件
- 榜单 -> 详情 -> 收藏/监控 的路径
- 平台能力地图
文件:
- [01-qiangua-rank-page.png](./raw/01-qiangua-rank-page.png)
- [02-qiangua-plan-and-modules.png](./raw/02-qiangua-plan-and-modules.png)
### 飞瓜数据
来源:
- `https://dy.feigua.cn/help/detail/9/447.html`
适合借鉴:
- 抖音运营后台一级导航
- 账号发现与涨粉榜
- 数据监测工作台
- 品牌投放 / 竞品投放
文件:
- [03-feigua-menu-structure.png](./raw/03-feigua-menu-structure.png)
- [04-feigua-account-discovery.png](./raw/04-feigua-account-discovery.png)
- [05-feigua-monitoring-workbench.png](./raw/05-feigua-monitoring-workbench.png)
- [06-feigua-brand-delivery.png](./raw/06-feigua-brand-delivery.png)
## 我对这批参考的判断
最适合 StoryForge 的,不是照搬它们某一家的界面,而是组合借法:
1.`飞瓜数据` 借后台主壳与一级导航分组
2.`千瓜数据` 借榜单、达人筛选、监测和内容资产视角
3. StoryForge 自己再把平台差异抽象成统一对象:
- 平台账号
- 参考账号
- 作品
- 账号洞察
- 内容打法
- Agent
- 生产任务
- 发布复盘
## 不建议直接照搬的点
- 不要照搬纯投放平台风格,那会过度偏品牌投放
- 不要把账号分析页做成单纯长报告
- 不要把 Agent 放在用户主视角,创作者更关心“账号、作品、选题、生产、复盘”

View File

@@ -0,0 +1,136 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>StoryForge 新媒体运营参考 UI</title>
<style>
:root {
--bg: #f4f8fd;
--panel: #fff;
--line: #dbe7f2;
--text: #152131;
--muted: #62758d;
--blue: #8fc2ff;
--blue-deep: #3f7fe7;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
color: var(--text);
background: linear-gradient(180deg, #f7fbff 0%, #eef4fb 100%);
}
.wrap { max-width: 1440px; margin: 0 auto; padding: 28px 22px 56px; }
.hero {
background: rgba(255,255,255,.86);
border: 1px solid rgba(143,194,255,.35);
border-radius: 24px;
box-shadow: 0 22px 48px rgba(80, 113, 145, .12);
padding: 28px;
}
h1 { margin: 0 0 10px; font-size: 30px; }
.hero p { margin: 0; line-height: 1.8; color: var(--muted); max-width: 980px; }
.hero ul { margin: 12px 0 0; padding-left: 18px; color: var(--muted); line-height: 1.9; }
.section { margin-top: 28px; }
.section h2 { margin: 0 0 12px; font-size: 22px; }
.section p { margin: 0 0 16px; color: var(--muted); }
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 18px;
overflow: hidden;
box-shadow: 0 10px 24px rgba(44, 72, 102, .08);
}
.card img { width: 100%; display: block; background: #eff5fc; }
.meta { padding: 16px 18px 18px; }
.meta h3 { margin: 0 0 8px; font-size: 18px; }
.meta .source { margin: 0 0 8px; color: var(--blue-deep); font-size: 13px; }
.meta .use { margin: 0; color: var(--muted); line-height: 1.8; font-size: 14px; }
@media (max-width: 960px) {
.grid { grid-template-columns: 1fr; }
.wrap { padding: 18px 14px 40px; }
h1 { font-size: 24px; }
}
</style>
</head>
<body>
<div class="wrap">
<div class="hero">
<h1>StoryForge 新媒体运营参考 UI</h1>
<p>
这批参考不是通用 AI 后台,而是更贴近“抖音 / 小红书 / B站 / YouTube 多平台账号运营”的产品形态。
重点观察的是:账号发现、精品账号分析、内容监控、竞品跟踪、品牌投放、内容复盘。
</p>
<ul>
<li>飞瓜:适合借抖音后台主壳、一级导航、监控与投放模块</li>
<li>千瓜:适合借小红书榜单、达人筛选、竞品监测与内容资产视角</li>
<li>StoryForge 自己再抽象成统一的跨平台工作区,而不是按平台裂成四套后台</li>
</ul>
</div>
<div class="section">
<h2>千瓜数据</h2>
<p>更适合作为“小红书方向的榜单发现、达人筛选、行业观察、内容洞察”的参考。</p>
<div class="grid">
<div class="card">
<img src="./raw/01-qiangua-rank-page.png" alt="千瓜达人榜单页" />
<div class="meta">
<h3>达人榜单页</h3>
<p class="source">来源qian-gua.com</p>
<p class="use">适合借榜单页的筛选区、排行表格、达人卡摘要信息。可转译为 StoryForge 的“精品账号发现”。</p>
</div>
</div>
<div class="card">
<img src="./raw/02-qiangua-plan-and-modules.png" alt="千瓜模块与能力地图" />
<div class="meta">
<h3>模块与能力地图</h3>
<p class="source">来源qian-gua.com</p>
<p class="use">这张不是最终界面参考,而是产品能力地图参考,适合帮助我们定义菜单层级和模块边界。</p>
</div>
</div>
</div>
</div>
<div class="section">
<h2>飞瓜数据</h2>
<p>更适合作为“抖音方向的后台主壳、账号发现、监控工作台、品牌投放”的参考。</p>
<div class="grid">
<div class="card">
<img src="./raw/03-feigua-menu-structure.png" alt="飞瓜菜单结构图" />
<div class="meta">
<h3>一级菜单结构</h3>
<p class="source">来源dy.feigua.cn/help</p>
<p class="use">适合借一级导航分组方式。StoryForge 可转译成运营总台、账号发现、内容库、Agent、生产、发布复盘、设置。</p>
</div>
</div>
<div class="card">
<img src="./raw/04-feigua-account-discovery.png" alt="飞瓜账号发现页" />
<div class="meta">
<h3>账号发现页</h3>
<p class="source">来源dy.feigua.cn/help</p>
<p class="use">适合借涨粉榜、行业筛选、账号列表和“详情”入口,用于 StoryForge 的精品账号发现和对标账号收录。</p>
</div>
</div>
<div class="card">
<img src="./raw/05-feigua-monitoring-workbench.png" alt="飞瓜监测工作台" />
<div class="meta">
<h3>数据监测工作台</h3>
<p class="source">来源dy.feigua.cn/help</p>
<p class="use">适合借监控任务页和持续追踪视角,可转译为 StoryForge 的“账号跟踪 / 作品跟踪 / 竞品跟踪”。</p>
</div>
</div>
<div class="card">
<img src="./raw/06-feigua-brand-delivery.png" alt="飞瓜品牌投放页" />
<div class="meta">
<h3>品牌投放与竞品页</h3>
<p class="source">来源dy.feigua.cn/help</p>
<p class="use">适合借品牌搜索、筛选和表格摘要区。StoryForge 可转译为“品牌案例库 / 商业化机会 / 竞品投放观察”。</p>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View File

@@ -0,0 +1,30 @@
# StoryForge Mobile V4 HTML Prototype
这是一套只做界面、不接真实功能的移动端高保真原型,用来配合当前 `Web V4` 的产品逻辑评审。
## 入口
- 预览文件:`index.html`
## 页面结构
1. `登录与工作区`
2. `总览`
3. `找对标`
4. `跟踪日报`
5. `Agent`
6. `生产中心`
7. `我的`
## 设计口径
- 产品定位:多平台新媒体运营助手
- 主题色:淡蓝、白、黑、灰
- 主对象项目、对标、Agent、生产、复盘
- 导航方式:底部 5 栏,适合 Android / iOS 通用使用
## 说明
- 这是静态 HTML 原型,不依赖本地服务
- 命名和业务逻辑与 `Web V4` 保持一致
- 当前重点是验证信息层级、导航、操作入口和视觉节奏

View File

@@ -0,0 +1,785 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>StoryForge Mobile V4 Prototype</title>
<style>
:root {
--bg: #edf6ff;
--bg-soft: #f7fbff;
--panel: #ffffff;
--panel-soft: #f5f9ff;
--line: #dbe7f3;
--line-strong: #cddded;
--text: #152332;
--muted: #64788e;
--blue-50: #f3f8ff;
--blue-100: #e6f1ff;
--blue-500: #70a8ff;
--blue-600: #4d8ff0;
--blue-700: #356fd0;
--green: #23a873;
--orange: #f2a64a;
--red: #df6e6e;
--shadow: 0 22px 56px rgba(21, 35, 50, 0.1);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(113, 171, 255, 0.22), transparent 26%),
linear-gradient(180deg, #f8fbff 0%, var(--bg) 100%);
}
main {
max-width: 1900px;
margin: 0 auto;
padding: 28px;
}
.hero {
margin-bottom: 20px;
}
.hero h1 {
margin: 0 0 8px;
font-size: 34px;
}
.hero p {
margin: 0;
max-width: 920px;
color: var(--muted);
line-height: 1.6;
}
.board {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 24px;
}
.meta-card {
background: rgba(255,255,255,0.82);
border: 1px solid var(--line);
border-radius: 26px;
padding: 18px;
box-shadow: var(--shadow);
}
.meta-card h2 {
margin: 0 0 6px;
font-size: 20px;
}
.meta-card p {
margin: 0 0 16px;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.phone {
width: 100%;
max-width: 430px;
aspect-ratio: 430 / 932;
margin: 0 auto;
background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
border: 1px solid var(--line-strong);
border-radius: 34px;
box-shadow: 0 26px 60px rgba(21, 35, 50, 0.14);
overflow: hidden;
position: relative;
}
.status {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 20px 10px;
font-size: 13px;
font-weight: 600;
color: var(--muted);
}
.screen {
display: flex;
flex-direction: column;
height: calc(100% - 40px);
padding: 0 16px 16px;
gap: 12px;
}
.screen.login {
justify-content: space-between;
padding-top: 10px;
}
.brand-box,
.card,
.sheet {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: 0 10px 26px rgba(21,35,50,0.06);
}
.brand-box {
padding: 24px;
background: linear-gradient(145deg, #eef6ff 0%, #ffffff 78%);
}
.logo {
width: 58px;
height: 58px;
border-radius: 18px;
display: grid;
place-items: center;
background: linear-gradient(145deg, var(--blue-500), var(--blue-600));
color: #fff;
font-weight: 800;
font-size: 24px;
margin-bottom: 16px;
}
.brand-box h3,
.card h3,
.sheet h3 {
margin: 0 0 6px;
font-size: 18px;
}
.sub {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.form {
display: grid;
gap: 12px;
}
.field {
padding: 14px 16px;
border-radius: 18px;
border: 1px solid var(--line);
background: #fff;
}
.field label {
display: block;
font-size: 11px;
color: var(--muted);
margin-bottom: 6px;
}
.field div {
font-size: 15px;
font-weight: 600;
}
.actions,
.chips,
.stats,
.tabs,
.kv {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 16px;
padding: 12px 14px;
font-size: 13px;
font-weight: 700;
border: 1px solid var(--line);
background: #fff;
color: var(--text);
}
.btn.primary {
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
color: #fff;
border-color: rgba(77,143,240,0.18);
}
.btn.ghost {
background: var(--blue-50);
color: var(--blue-700);
border-color: rgba(77,143,240,0.16);
}
.chip,
.pill {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--blue-50);
color: var(--muted);
font-size: 11px;
line-height: 1;
}
.chip.active,
.pill.active {
background: var(--blue-100);
color: var(--blue-700);
border-color: rgba(77,143,240,0.16);
font-weight: 700;
}
.metric {
flex: 1 1 0;
min-width: 92px;
padding: 12px;
border-radius: 18px;
background: linear-gradient(180deg, #ffffff 0%, #f4f9ff 100%);
border: 1px solid var(--line);
}
.metric small {
display: block;
color: var(--muted);
font-size: 11px;
margin-bottom: 6px;
}
.metric strong {
font-size: 18px;
}
.card {
padding: 16px;
display: grid;
gap: 10px;
}
.card.tight {
gap: 8px;
}
.stack {
display: grid;
gap: 10px;
}
.row {
display: flex;
gap: 10px;
align-items: start;
}
.avatar {
width: 42px;
height: 42px;
border-radius: 14px;
background: linear-gradient(145deg, #c9e0ff, #92bcff);
display: grid;
place-items: center;
color: #fff;
font-weight: 800;
flex: 0 0 auto;
}
.title {
font-size: 16px;
font-weight: 700;
line-height: 1.35;
}
.caption {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.progress {
height: 8px;
border-radius: 999px;
background: #edf3fa;
overflow: hidden;
}
.progress > span {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #8cc2ff, #4f8fee);
}
.list-item {
padding: 14px;
border-radius: 20px;
border: 1px solid var(--line);
background: linear-gradient(180deg, #fff 0%, #f8fbff 100%);
display: grid;
gap: 8px;
}
.bottom-nav {
margin-top: auto;
padding: 10px;
border-radius: 24px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.96);
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
box-shadow: 0 12px 30px rgba(21,35,50,0.08);
}
.nav-tab {
text-align: center;
padding: 10px 0;
border-radius: 18px;
font-size: 11px;
color: var(--muted);
font-weight: 700;
}
.nav-tab.active {
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
color: #fff;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.sheet {
padding: 14px;
background: linear-gradient(180deg, #f7fbff 0%, #ffffff 100%);
}
@media (max-width: 1480px) {
.board { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 980px) {
main { padding: 18px; }
.board { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main>
<section class="hero">
<h1>StoryForge Mobile V4</h1>
<p>这版移动端不照搬 Web而是保留高频动作。主逻辑仍然是“我的项目 -> 找对标 -> 跟踪日报 -> Agent -> 生产”,把重配置留在 Web把快决策、快查看、快推进留给手机。</p>
</section>
<section class="board">
<article class="meta-card">
<h2>01 登录与工作区</h2>
<p>先进入工作区再同步项目、Agent 和今日任务。</p>
<div class="phone">
<div class="status"><span>9:41</span><span>5G 100%</span></div>
<div class="screen login">
<div class="brand-box">
<div class="logo">SF</div>
<h3>StoryForge</h3>
<div class="sub">创作者移动工作台</div>
</div>
<div class="form">
<div class="field">
<label>工作区</label>
<div>星流内容组</div>
</div>
<div class="field">
<label>手机号</label>
<div style="color:#9aa9b8;font-weight:500;">请输入登录手机号</div>
</div>
<div class="field">
<label>密码</label>
<div>••••••••</div>
</div>
<div class="chips">
<span class="chip active">短信登录</span>
<span class="chip">Token</span>
<span class="chip">高级登录</span>
</div>
<div class="actions">
<span class="btn primary" style="width:100%;">进入工作台</span>
</div>
</div>
<div class="sub">登录后自动同步你的项目、Agent、生产队列和跟踪日报。</div>
</div>
</div>
</article>
<article class="meta-card">
<h2>02 总览</h2>
<p>先看今天该推进什么,再跳转到对应动作。</p>
<div class="phone">
<div class="status"><span>9:41</span><span>Wi-Fi 93%</span></div>
<div class="screen">
<div class="brand-box">
<div class="title">早上好,林闻</div>
<div class="caption">星流内容组 · 今天有 3 个动作值得先做</div>
<div class="stats" style="margin-top:12px;">
<div class="metric"><small>项目</small><strong>5</strong></div>
<div class="metric"><small>更新</small><strong>12</strong></div>
<div class="metric"><small>待调研</small><strong>4</strong></div>
</div>
</div>
<div class="card">
<div class="section-head">
<h3>今日重点</h3>
<span class="pill active">4 项</span>
</div>
<div class="list-item">
<div class="title">先为“副业增长实验室”建项目</div>
<div class="caption">先开项目,再决定要不要绑定账号。</div>
</div>
<div class="list-item">
<div class="title">补齐“教育切片助手”的平台和变现</div>
<div class="caption">补完后再跑首轮调研。</div>
</div>
</div>
<div class="split">
<div class="card tight">
<h3>高分提醒</h3>
<div class="caption">《副业失败的 3 个坑》适合转系列。</div>
</div>
<div class="card tight">
<h3>跟踪日报</h3>
<div class="caption">5 天内 7 条更新值得学。</div>
</div>
</div>
<div class="card">
<h3>本周进度</h3>
<div class="progress"><span style="width:68%;"></span></div>
<div class="caption">已完成 8 / 12 个内容动作</div>
<div class="chips">
<span class="chip active">同步账号</span>
<span class="chip">找对标</span>
<span class="chip">去生产</span>
<span class="chip">看复盘</span>
</div>
</div>
<div class="bottom-nav">
<div class="nav-tab active">总览</div>
<div class="nav-tab">对标</div>
<div class="nav-tab">Agent</div>
<div class="nav-tab">生产</div>
<div class="nav-tab">我的</div>
</div>
</div>
</div>
</article>
<article class="meta-card">
<h2>03 找对标</h2>
<p>在手机上做快速筛选、快速收藏、快速导入。</p>
<div class="phone">
<div class="status"><span>9:41</span><span>5G 91%</span></div>
<div class="screen">
<div class="card">
<div class="title">找对标</div>
<div class="caption">搜账号、主页链接、作品链接。</div>
<div class="field" style="padding:12px 14px;">
<div style="font-size:13px;color:#91a2b3;font-weight:500;">搜账号、主页链接、作品链接</div>
</div>
<div class="chips">
<span class="chip active">抖音</span>
<span class="chip">小红书</span>
<span class="chip">B站</span>
<span class="chip">YouTube</span>
</div>
<div class="chips">
<span class="chip active">涨粉</span>
<span class="chip">互动</span>
<span class="chip">商业价值</span>
</div>
</div>
<div class="list-item">
<div class="row">
<div class="avatar"></div>
<div>
<div class="title">阿元创业手记</div>
<div class="caption">抖音 · 创业成长 · AI 可学习度 93</div>
</div>
</div>
<div class="chips">
<span class="chip active">查看</span>
<span class="chip">导入</span>
<span class="chip">绑 Agent</span>
</div>
</div>
<div class="sheet">
<div class="section-head">
<h3>当前选中对标</h3>
<span class="pill active">阿元创业手记</span>
</div>
<div class="split">
<div class="metric"><small>可学习度</small><strong>93</strong></div>
<div class="metric"><small>商业价值</small><strong>88</strong></div>
</div>
<div class="stack" style="margin-top:10px;">
<div class="caption">画像:反常识切入、案例推进强、结尾动作明确。</div>
<div class="caption">高分内容:《副业失败的 3 个坑》适合提炼成系列。</div>
</div>
<div class="actions" style="margin-top:12px;">
<span class="btn ghost" style="flex:1 1 0;">导入项目</span>
<span class="btn primary" style="flex:1 1 0;">创建 Agent</span>
</div>
</div>
<div class="bottom-nav">
<div class="nav-tab">总览</div>
<div class="nav-tab active">对标</div>
<div class="nav-tab">Agent</div>
<div class="nav-tab">生产</div>
<div class="nav-tab">我的</div>
</div>
</div>
</div>
</article>
<article class="meta-card">
<h2>04 跟踪日报</h2>
<p>手机端优先看“上次打开后”的更新,不必再翻后台。</p>
<div class="phone">
<div class="status"><span>9:41</span><span>5G 89%</span></div>
<div class="screen">
<div class="card">
<div class="section-head">
<h3>跟踪日报</h3>
<span class="pill active">5 天汇总</span>
</div>
<div class="caption">自上次打开后,共有 5 个账号更新Agent 标了 7 条借鉴点。</div>
<div class="chips">
<span class="chip active">刷新</span>
<span class="chip">看全部</span>
<span class="chip">新增跟踪</span>
</div>
</div>
<div class="list-item">
<div class="title">秋芝2046 · 新增 3 条作品</div>
<div class="caption">教育切片助手判断:其中 2 条适合转 30 秒口播。</div>
<div class="chips">
<span class="chip active">有借鉴点</span>
<span class="chip">入学习集</span>
</div>
</div>
<div class="list-item">
<div class="title">晨风老师 · 新增 2 条图文</div>
<div class="caption">更适合补小红书搜索承接模板。</div>
<div class="chips">
<span class="chip active">适合图文线</span>
<span class="chip">加 Playbook</span>
</div>
</div>
<div class="card tight">
<h3>今日建议</h3>
<div class="caption">先把 3 条高价值更新送入 Agent 学习,再决定是否转生产。</div>
</div>
<div class="bottom-nav">
<div class="nav-tab active">总览</div>
<div class="nav-tab">对标</div>
<div class="nav-tab">Agent</div>
<div class="nav-tab">生产</div>
<div class="nav-tab">我的</div>
</div>
</div>
</div>
</article>
<article class="meta-card">
<h2>05 Agent</h2>
<p>手机上更适合快速建 Agent、看学习源、看首轮调研结果。</p>
<div class="phone">
<div class="status"><span>9:41</span><span>Wi-Fi 90%</span></div>
<div class="screen">
<div class="card">
<div class="section-head">
<h3>Agent</h3>
<span class="pill active">待调研 4</span>
</div>
<div class="caption">先定项目、平台、变现和主模型,再跑首轮调研。</div>
<div class="chips">
<span class="chip active">抖音</span>
<span class="chip active">小红书</span>
<span class="chip">视频号</span>
</div>
<div class="chips">
<span class="chip active">知识付费</span>
<span class="chip">广告合作</span>
<span class="chip">私域咨询</span>
</div>
<div class="actions">
<span class="btn ghost" style="flex:1 1 0;">跑调研</span>
<span class="btn primary" style="flex:1 1 0;">创建 Agent</span>
</div>
</div>
<div class="list-item">
<div class="title">选题助手 · 教育切片</div>
<div class="caption">学习源:高信任图文 + 强观点短视频</div>
<div class="chips">
<span class="chip active">主模型:通义</span>
<span class="chip">已调研</span>
<span class="chip">最近产出 6 条</span>
</div>
</div>
<div class="list-item">
<div class="title">导入分析 Agent</div>
<div class="caption">负责解析主页、单条作品和本地视频,自动给出绑定建议。</div>
<div class="chips">
<span class="chip active">自动归类</span>
<span class="chip">支持复核</span>
</div>
</div>
<div class="bottom-nav">
<div class="nav-tab">总览</div>
<div class="nav-tab">对标</div>
<div class="nav-tab active">Agent</div>
<div class="nav-tab">生产</div>
<div class="nav-tab">我的</div>
</div>
</div>
</div>
</article>
<article class="meta-card">
<h2>06 生产中心</h2>
<p>移动端重点看队列、看卡点、看成片,不做重配置。</p>
<div class="phone">
<div class="status"><span>9:41</span><span>5G 87%</span></div>
<div class="screen">
<div class="card">
<div class="section-head">
<h3>生产中心</h3>
<span class="pill active">在产 6</span>
</div>
<div class="chips">
<span class="chip active">文案</span>
<span class="chip">封面</span>
<span class="chip">实拍</span>
<span class="chip">AI 视频</span>
</div>
</div>
<div class="list-item">
<div class="title">《副业避坑》封面生成</div>
<div class="caption">阿里 / 火山 / 通用图像 · 当前卡在选图</div>
<div class="progress"><span style="width:72%;"></span></div>
<div class="chips">
<span class="chip active">补封面</span>
<span class="chip">看样片</span>
</div>
</div>
<div class="list-item">
<div class="title">作品与成片</div>
<div class="caption">从生产结果反看当前最值得继续推进的内容。</div>
<div class="chips">
<span class="chip active">高分</span>
<span class="chip">最新</span>
<span class="chip">看成片</span>
</div>
</div>
<div class="sheet">
<h3>当前选中内容</h3>
<div class="caption">《副业失败的 3 个真实坑》适合继续生成脚本,封面先做 3 个模型对比。</div>
<div class="actions" style="margin-top:12px;">
<span class="btn ghost" style="flex:1 1 0;">补封面</span>
<span class="btn primary" style="flex:1 1 0;">继续做</span>
</div>
</div>
<div class="bottom-nav">
<div class="nav-tab">总览</div>
<div class="nav-tab">对标</div>
<div class="nav-tab">Agent</div>
<div class="nav-tab active">生产</div>
<div class="nav-tab">我的</div>
</div>
</div>
</div>
</article>
<article class="meta-card">
<h2>07 我的</h2>
<p>工作区、自动流程、额度和通知都放在这里。</p>
<div class="phone">
<div class="status"><span>9:41</span><span>5G 94%</span></div>
<div class="screen">
<div class="brand-box">
<div class="title">我的</div>
<div class="caption">星流内容组 · 林闻 · 杭州工作区</div>
<div class="chips" style="margin-top:12px;">
<span class="chip active">流程 7/8</span>
<span class="chip">额度正常</span>
<span class="chip">2 台设备在线</span>
</div>
</div>
<div class="list-item">
<div class="title">自动流程</div>
<div class="caption">账号同步、跟踪日报、失败补跑、异常提醒。</div>
</div>
<div class="list-item">
<div class="title">额度</div>
<div class="caption">文案 / 封面 / 视频三类额度分开看。</div>
</div>
<div class="list-item">
<div class="title">通知与同步</div>
<div class="caption">日报提醒、任务结果、设备同步都在这里。</div>
</div>
<div class="actions">
<span class="btn ghost" style="flex:1 1 0;">看额度</span>
<span class="btn primary" style="flex:1 1 0;">看流程</span>
</div>
<div class="bottom-nav">
<div class="nav-tab">总览</div>
<div class="nav-tab">对标</div>
<div class="nav-tab">Agent</div>
<div class="nav-tab">生产</div>
<div class="nav-tab active">我的</div>
</div>
</div>
</div>
</article>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,43 @@
# StoryForge Web V4 HTML Prototype
这是一个只做界面、不接业务功能的静态 Web 原型。
入口:
- [index.html](./index.html)
包含页面:
- 项目总台
- 我的项目
- 找对标
- 跟踪账号
- 我的账号
- Agent
- 生产中心
- 发布与复盘
- 自动流程
- 额度
说明:
- 这版不依赖后端接口
- 主要用于确认新的产品逻辑、信息架构和布局方向
- 主业务流已调整为:项目 -> Agent -> 首轮调研 -> 导入绑定 -> 生产 -> 发布复盘
- 新增:跟踪账号 -> 自动汇总更新 -> Agent 标注借鉴点 -> 日报回看
- 导入分析以 Agent 为中心,不再以规则判断为主
- `找对标` 已经把列表和详情收在一个页面里,不再单独拆 `参考详情`
- `作品库` 已并入 `生产中心` 的“作品与成片”区域
- 模型凭证默认后台托管,用户界面只表达模型选择与额度消耗
- 主题色以淡蓝、白、黑、灰为主
新增预览:
- [05-intake.png](./previews/05-intake.png)
- [06-agent.png](./previews/06-agent.png)
- [07-production.png](./previews/07-production.png)
- [08-credits.png](./previews/08-credits.png)
- [09-dashboard-lite.png](./previews/09-dashboard-lite.png)
- [10-find-reference-lite.png](./previews/10-find-reference-lite.png)
- [11-reference-detail-lite.png](./previews/11-reference-detail-lite.png)
- [12-tracking-digest.png](./previews/12-tracking-digest.png)
- [13-find-reference-search.png](./previews/13-find-reference-search.png)
备份:
- [pre-simplify backup](/Users/kris/code/StoryForge-gitea/output/ui/backups/storyforge-web-v4-html-prototype-2026-03-22-pre-simplify-2026-03-22)

View File

@@ -0,0 +1,24 @@
const navButtons = document.querySelectorAll("[data-screen-target]");
const screens = document.querySelectorAll("[data-screen]");
function activateScreen(id) {
navButtons.forEach((button) => {
const active = button.dataset.screenTarget === id;
button.classList.toggle("is-active", active);
});
screens.forEach((screen) => {
screen.classList.toggle("is-active", screen.dataset.screen === id);
});
}
navButtons.forEach((button) => {
button.addEventListener("click", () => {
const next = button.dataset.screenTarget;
activateScreen(next);
window.location.hash = next;
});
});
const initial = window.location.hash.replace("#", "") || "dashboard";
activateScreen(initial);

View File

@@ -0,0 +1,865 @@
:root {
--bg: #f4f8fd;
--bg-soft: #eef4fb;
--panel: #ffffff;
--panel-soft: #f7fbff;
--line: #d9e5f2;
--line-strong: #c8d8ea;
--text: #182433;
--muted: #66788f;
--blue-50: #f3f8ff;
--blue-100: #e8f1ff;
--blue-200: #d9e8ff;
--blue-300: #c4ddff;
--blue-500: #6aa4ff;
--blue-600: #4f8fee;
--blue-700: #3977d8;
--green: #2db584;
--orange: #f29a38;
--red: #e46767;
--shadow: 0 18px 40px rgba(67, 93, 125, 0.12);
--shadow-soft: 0 10px 24px rgba(67, 93, 125, 0.08);
--radius-xl: 24px;
--radius-lg: 18px;
--radius-md: 14px;
--radius-sm: 10px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at top left, rgba(129, 180, 255, 0.18), transparent 28%),
linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
color: var(--text);
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
select {
font: inherit;
}
.app-shell {
display: grid;
grid-template-columns: 272px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
background: rgba(255, 255, 255, 0.82);
border-right: 1px solid rgba(201, 220, 239, 0.75);
backdrop-filter: blur(14px);
padding: 22px 18px 18px;
position: sticky;
top: 0;
height: 100vh;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 10px 20px;
}
.brand-mark {
width: 42px;
height: 42px;
border-radius: 14px;
background: linear-gradient(145deg, #b9d7ff 0%, #6ea8ff 100%);
display: grid;
place-items: center;
color: white;
font-weight: 700;
letter-spacing: 0.04em;
}
.brand h1 {
margin: 0;
font-size: 18px;
}
.brand p {
margin: 4px 0 0;
font-size: 12px;
color: var(--muted);
}
.nav-group {
margin-top: 14px;
}
.nav-title {
padding: 0 10px 8px;
color: var(--muted);
font-size: 12px;
letter-spacing: 0.04em;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
border: none;
background: transparent;
border-radius: 14px;
padding: 11px 12px;
color: var(--text);
cursor: pointer;
text-align: left;
transition: 0.18s ease;
}
.nav-item:hover {
background: rgba(106, 164, 255, 0.08);
}
.nav-item.is-active {
background: linear-gradient(180deg, #edf5ff 0%, #e6f0ff 100%);
box-shadow: inset 0 0 0 1px rgba(106, 164, 255, 0.22);
color: var(--blue-700);
}
.nav-item .icon {
width: 28px;
height: 28px;
border-radius: 10px;
background: var(--blue-50);
display: grid;
place-items: center;
font-size: 14px;
}
.sidebar-foot {
margin-top: 22px;
padding: 14px;
border-radius: 18px;
background: linear-gradient(180deg, #f7fbff 0%, #eef5ff 100%);
border: 1px solid var(--line);
}
.sidebar-foot h3 {
margin: 0 0 8px;
font-size: 14px;
}
.sidebar-foot p {
margin: 0 0 10px;
color: var(--muted);
line-height: 1.55;
font-size: 12px;
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
padding: 6px 10px;
border-radius: 999px;
background: var(--blue-50);
border: 1px solid var(--line);
color: var(--muted);
font-size: 12px;
}
.chip.active {
background: var(--blue-100);
color: var(--blue-700);
border-color: rgba(79, 143, 238, 0.22);
}
.content {
padding: 18px 22px 26px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 16px 18px;
border-radius: 22px;
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(12px);
border: 1px solid rgba(201, 220, 239, 0.75);
box-shadow: var(--shadow-soft);
}
.topbar-left,
.topbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.workspace-switch,
.search,
.mini-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
}
.workspace-switch {
padding: 10px 14px;
min-width: 190px;
}
.workspace-switch strong {
display: block;
font-size: 13px;
}
.workspace-switch span {
font-size: 12px;
color: var(--muted);
}
.search {
display: flex;
align-items: center;
gap: 10px;
min-width: 340px;
padding: 12px 14px;
color: var(--muted);
}
.search input {
border: none;
outline: none;
background: transparent;
width: 100%;
color: var(--text);
}
.top-pill {
padding: 8px 12px;
border-radius: 999px;
background: var(--blue-50);
color: var(--muted);
border: 1px solid var(--line);
font-size: 12px;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 12px;
background: linear-gradient(145deg, #bedcff 0%, #82b8ff 100%);
display: grid;
place-items: center;
font-size: 13px;
color: white;
font-weight: 700;
}
.screen {
display: none;
margin-top: 18px;
}
.screen.is-active {
display: block;
}
.screen-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 18px;
margin-bottom: 18px;
}
.screen-head h2 {
margin: 0 0 6px;
font-size: 28px;
}
.screen-head p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.45;
max-width: 560px;
}
.action-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
border: none;
border-radius: 12px;
padding: 10px 13px;
font-size: 13px;
font-weight: 600;
line-height: 1;
cursor: pointer;
transition: 0.18s ease;
}
.btn-primary {
background: linear-gradient(180deg, var(--blue-500) 0%, var(--blue-600) 100%);
color: white;
box-shadow: 0 8px 18px rgba(79, 143, 238, 0.22);
}
.btn-secondary {
background: white;
color: var(--text);
border: 1px solid var(--line);
}
.btn:hover {
transform: translateY(-1px);
}
.layout-grid {
display: grid;
gap: 18px;
}
.grid-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.grid-5 {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.grid-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid-main {
grid-template-columns: minmax(0, 1.45fr) minmax(0, 1fr);
}
.grid-split {
grid-template-columns: 280px minmax(0, 1fr) 310px;
}
.panel {
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(201, 220, 239, 0.9);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-soft);
overflow: hidden;
}
.panel.pad {
padding: 17px;
}
.panel h3,
.panel h4 {
margin: 0;
}
.panel-subtitle {
margin-top: 6px;
color: var(--muted);
font-size: 11px;
line-height: 1.4;
}
.stat-card {
padding: 18px;
border-radius: 20px;
background: linear-gradient(180deg, #fbfdff 0%, #f3f8ff 100%);
border: 1px solid rgba(201, 220, 239, 0.9);
box-shadow: var(--shadow);
}
.stat-card small {
color: var(--muted);
}
.stat-card strong {
display: block;
margin-top: 10px;
font-size: 28px;
}
.stat-foot {
margin-top: 10px;
display: flex;
align-items: center;
justify-content: space-between;
color: var(--muted);
font-size: 12px;
}
.positive { color: var(--green); }
.warn { color: var(--orange); }
.negative { color: var(--red); }
.list {
display: grid;
gap: 10px;
}
.task-item,
.entity-card,
.topic-card,
.review-card,
.queue-card {
border-radius: 18px;
border: 1px solid var(--line);
background: linear-gradient(180deg, #fff 0%, #f9fbff 100%);
}
.task-item,
.queue-card,
.review-card {
padding: 15px;
}
.task-item h4,
.entity-card h4,
.topic-card h4,
.queue-card h4,
.review-card h4 {
margin: 0 0 6px;
font-size: 15px;
}
.task-item p,
.entity-card p,
.topic-card p,
.queue-card p,
.review-card p {
margin: 0;
color: var(--muted);
line-height: 1.4;
font-size: 11px;
}
.task-meta,
.entity-meta,
.row-meta {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 10px;
}
.tag {
padding: 5px 9px;
border-radius: 999px;
background: #f6f9fe;
border: 1px solid var(--line);
color: var(--muted);
font-size: 11px;
line-height: 1.1;
}
.row-meta .tag {
background: var(--blue-50);
border-color: rgba(106, 164, 255, 0.18);
color: var(--blue-700);
font-weight: 600;
}
.tag.blue {
background: var(--blue-100);
color: var(--blue-700);
}
.tag.green {
background: rgba(45, 181, 132, 0.1);
border-color: rgba(45, 181, 132, 0.18);
color: #1b8b61;
}
.tag.orange {
background: rgba(242, 154, 56, 0.1);
border-color: rgba(242, 154, 56, 0.18);
color: #b76d16;
}
.tag.red {
background: rgba(228, 103, 103, 0.1);
border-color: rgba(228, 103, 103, 0.18);
color: #b24c4c;
}
.two-col {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
}
.three-col {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 920px;
}
th,
td {
padding: 12px 12px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
thead th {
background: #f8fbff;
color: var(--muted);
font-weight: 600;
font-size: 12px;
letter-spacing: 0.02em;
}
tbody tr:hover {
background: rgba(106, 164, 255, 0.055);
}
.entity-cell {
display: flex;
gap: 12px;
align-items: start;
}
.avatar-lg {
width: 46px;
height: 46px;
border-radius: 15px;
background: linear-gradient(145deg, #c9e2ff 0%, #8bbcff 100%);
display: grid;
place-items: center;
color: white;
font-weight: 700;
}
.cell-title {
font-weight: 600;
margin-bottom: 4px;
}
.cell-desc {
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
.kpi-inline {
display: flex;
flex-wrap: wrap;
gap: 14px;
color: var(--muted);
font-size: 12px;
}
.metric {
font-weight: 600;
color: var(--text);
}
.toolbar {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 16px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, #fbfdff 0%, #f4f9ff 100%);
}
.toolbar-stack {
display: grid;
gap: 10px;
min-width: min(760px, 100%);
}
.search-inline {
min-width: 320px;
width: min(720px, 100%);
padding: 10px 12px;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.filter {
min-width: 132px;
padding: 10px 11px;
border-radius: 12px;
border: 1px solid var(--line);
background: white;
color: var(--muted);
font-size: 12px;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
margin-bottom: 12px;
}
.side-stack {
display: grid;
gap: 16px;
}
.insight-card {
padding: 15px;
border-radius: 18px;
border: 1px solid var(--line);
background: linear-gradient(180deg, #fff 0%, #f6faff 100%);
}
.insight-card h4 {
margin: 0 0 8px;
font-size: 15px;
}
.insight-card ul {
margin: 0;
padding-left: 18px;
color: var(--muted);
line-height: 1.5;
font-size: 11px;
}
.tab-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 16px 0 18px;
}
.tab {
padding: 8px 12px;
border-radius: 999px;
border: 1px solid var(--line);
background: #fff;
color: var(--muted);
font-size: 13px;
}
.tab.active {
background: var(--blue-100);
color: var(--blue-700);
border-color: rgba(79, 143, 238, 0.2);
}
.hero-card {
padding: 20px;
border-radius: 24px;
background: linear-gradient(145deg, rgba(212, 230, 255, 0.85) 0%, rgba(245, 250, 255, 0.96) 72%);
border: 1px solid rgba(180, 210, 248, 0.85);
box-shadow: var(--shadow-soft);
}
.hero-card h3 {
margin: 0 0 8px;
font-size: 18px;
}
.hero-card p {
margin: 0;
color: var(--muted);
line-height: 1.45;
font-size: 13px;
}
.mini-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.mini-card {
padding: 14px;
}
.mini-card strong {
display: block;
margin-top: 8px;
font-size: 17px;
}
.playbook-list {
display: grid;
gap: 12px;
}
.playbook-item {
padding: 14px;
border-radius: 16px;
border: 1px solid var(--line);
background: linear-gradient(180deg, #fff 0%, #f8fbff 100%);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.8);
}
.playbook-item.active {
border-color: rgba(79, 143, 238, 0.24);
background: linear-gradient(180deg, #f8fbff 0%, #eef6ff 100%);
}
.timeline {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.step {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
background: #fff;
border: 1px solid var(--line);
color: var(--muted);
font-size: 12px;
}
.step.done {
color: #167657;
border-color: rgba(45, 181, 132, 0.18);
background: rgba(45, 181, 132, 0.08);
}
.step.current {
color: var(--blue-700);
border-color: rgba(79, 143, 238, 0.2);
background: var(--blue-100);
}
.bar-chart {
display: grid;
gap: 10px;
}
.bar-row {
display: grid;
grid-template-columns: 108px minmax(0, 1fr) 48px;
gap: 10px;
align-items: center;
font-size: 13px;
color: var(--muted);
}
.bar-track {
height: 10px;
border-radius: 999px;
background: #eef3f8;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #93c3ff 0%, #5c95ef 100%);
}
.calendar {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 10px;
}
.day {
min-height: 118px;
border: 1px solid var(--line);
border-radius: 16px;
padding: 12px;
background: linear-gradient(180deg, #fff 0%, #f9fbff 100%);
}
.day strong {
display: block;
margin-bottom: 8px;
font-size: 13px;
}
.slot {
margin-top: 8px;
padding: 8px 10px;
border-radius: 12px;
background: var(--blue-50);
border: 1px solid rgba(106, 164, 255, 0.16);
font-size: 12px;
color: var(--text);
line-height: 1.5;
}
.footer-note {
margin-top: 18px;
color: var(--muted);
font-size: 12px;
text-align: right;
}
@media (max-width: 1320px) {
.grid-main,
.grid-split,
.grid-5,
.grid-4,
.grid-3,
.three-col,
.two-col {
grid-template-columns: 1fr;
}
.calendar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 1080px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
position: relative;
height: auto;
}
.topbar {
flex-direction: column;
align-items: stretch;
}
.topbar-left,
.topbar-right {
flex-wrap: wrap;
}
.search {
min-width: 0;
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

View File

@@ -1,7 +1,6 @@
#!/bin/sh
set -eu
cd /Users/kris/code/StoryForge/collector-service
python3 -m venv .venv
. .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 8081 --reload
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
"$ROOT/scripts/start_business.sh"

39
scripts/cleanup_debug_ui.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/bin/sh
set -eu
PORT="${1:-3618}"
SCRIPT_PATH="/Users/kris/code/StoryForge-gitea/scripts/douyin-browser-capture/control_panel.mjs"
lsof -tiTCP:"$PORT" -sTCP:LISTEN | xargs -r kill || true
osascript -e 'tell application "Terminal"' \
-e 'if it is running then' \
-e 'repeat with w in windows' \
-e 'set shouldClose to false' \
-e 'repeat with t in tabs of w' \
-e 'try' \
-e 'set tabText to contents of t' \
-e 'if tabText contains "'"$SCRIPT_PATH"'" then set shouldClose to true' \
-e 'end try' \
-e 'end repeat' \
-e 'if shouldClose then close w saving no' \
-e 'end repeat' \
-e 'end if' \
-e 'end tell' >/dev/null 2>&1 || true
for app in "Google Chrome" "Brave Browser" "Arc" "Safari"; do
osascript -e 'try' \
-e 'tell application "'"$app"'"' \
-e 'repeat with w in windows' \
-e 'repeat with i from (count of tabs of w) to 1 by -1' \
-e 'try' \
-e 'set tabUrl to URL of tab i of w' \
-e 'if tabUrl contains "127.0.0.1:'"$PORT"'" then close tab i of w' \
-e 'end try' \
-e 'end repeat' \
-e 'end repeat' \
-e 'end tell' \
-e 'end try' >/dev/null 2>&1 || true
done
echo "debug ui cleaned: port $PORT"

View File

@@ -0,0 +1,75 @@
# Douyin Browser Capture
This tool drives a real Playwright Chromium session, lets a human log into Douyin, captures the loaded profile and work pages, and can sync the captured bundle into StoryForge's existing `/v2/douyin/accounts/sync` endpoint.
## Install
```bash
cd /Users/kris/code/StoryForge-gitea/scripts/douyin-browser-capture
npm install
npx playwright install chromium
```
## Run
```bash
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.'
```
The browser uses a persistent state directory under `~/.storyforge/douyin-playwright`, so Douyin login can survive between runs.
## Control Panel
If you do not want to remember CLI arguments, start the local control panel:
```bash
cd /Users/kris/code/StoryForge-gitea/scripts/douyin-browser-capture
npm run control-panel
```
Then open [http://127.0.0.1:3618](http://127.0.0.1:3618) and use this flow:
1. Fill in the Douyin profile URL and StoryForge credentials.
2. Click `开始采集`.
3. A real Chromium window opens. Log into Douyin and solve any captcha there.
4. Return to the control panel and click `已完成登录,继续采集`.
5. Wait for `summary.json` and the optional StoryForge sync result.
The control panel stores each run under:
`/Users/kris/code/StoryForge-gitea/output/playwright/douyin/control-panel`
## What it captures
- current profile page JSON blobs extracted from `<script>` tags
- selected window globals such as `__INITIAL_STATE__`
- relevant JSON network responses
- creator-center pages using the same logged-in browser context
- a limited number of video detail pages linked from the profile
## Output
Default output directory:
`/Users/kris/code/StoryForge-gitea/output/playwright/douyin`
Each run writes:
- `profile-bundle.json`
- `creator-*.json`
- `video-*.json`
- `storyforge-sync-request.json`
- `storyforge-sync-response.json` when sync is enabled
- `summary.json`
## Notes
- This is designed as a browser-assisted capture flow, not a fully headless anti-bot bypass.
- If Douyin shows a slider or challenge page, solve it manually in the opened browser window and then continue.
- Use `--no-sync` if you only want to save a local bundle for inspection.
- Use `--ready-file /tmp/storyforge-ready.signal` if you want another process or webpage to decide when capture continues.
- Creator-center pages belong to the currently logged-in Douyin account. StoryForge now treats them as supplemental evidence by default and will not let them overwrite the target profile unless you explicitly pass `--allow-creator-center-fallback`.

View File

@@ -0,0 +1,855 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import { execFileSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { chromium } from "playwright";
const DEFAULT_CREATOR_CENTER_URLS = [
"https://creator.douyin.com/creator-micro/home",
"https://creator.douyin.com/creator-micro/data",
"https://creator.douyin.com/creator-micro/content/manage"
];
const DEFAULT_OUTPUT_DIR = "/Users/kris/code/StoryForge-gitea/output/playwright/douyin";
const DEFAULT_STATE_DIR = path.join(os.homedir(), ".storyforge", "douyin-playwright");
const DEFAULT_BACKEND_URL = "http://127.0.0.1:8081";
const JSON_CAPTURE_LIMIT = 1_500_000;
const SCRIPT_SCAN_LIMIT = 2_000_000;
const WAIT_AFTER_NAV_MS = 4_000;
const RESPONSE_READ_TIMEOUT_MS = 2_000;
const PYTHON_HTTP_BRIDGE = `
import json
import sys
import urllib.error
import urllib.request
url, method, headers_json, body_mode, body_value = sys.argv[1:6]
headers = json.loads(headers_json)
body = None
if body_mode == "text":
body = body_value.encode("utf-8")
elif body_mode == "path":
with open(body_value, "rb") as handle:
body = handle.read()
request = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=120) as response:
raw = response.read().decode("utf-8", "replace")
try:
payload = json.loads(raw) if raw else None
except Exception:
payload = {"raw": raw}
print(json.dumps({"status": response.status, "data": payload}, ensure_ascii=False))
except urllib.error.HTTPError as error:
raw = error.read().decode("utf-8", "replace")
try:
payload = json.loads(raw) if raw else None
except Exception:
payload = {"raw": raw}
print(json.dumps({"status": error.code, "data": payload}, ensure_ascii=False))
except Exception as error:
print(json.dumps({"status": 599, "data": {"raw": str(error)}}, ensure_ascii=False))
`;
function printHelp() {
console.log(`StoryForge Douyin Browser Capture
Usage:
node capture_and_sync.mjs --profile-url <douyin-profile-url> [options]
Core options:
--profile-url <url> Douyin profile URL to capture
--backend-url <url> StoryForge collector base URL (default: ${DEFAULT_BACKEND_URL})
--output-dir <dir> Capture output directory (default: ${DEFAULT_OUTPUT_DIR})
--state-dir <dir> Persistent browser state dir (default: ${DEFAULT_STATE_DIR})
--max-videos <n> Max video detail pages to capture (default: 4)
--scroll-count <n> Scroll times on profile page (default: 5)
--wait-ms <n> Wait after each navigation in ms (default: ${WAIT_AFTER_NAV_MS})
--ready-file <path> Wait for this file to appear instead of terminal prompt
StoryForge auth:
--storyforge-token <token> Existing StoryForge bearer token
--storyforge-username <name> Login username for StoryForge
--storyforge-password <pass> Login password for StoryForge
Mode flags:
--headless Run browser headless
--skip-login-prompt Do not pause for manual login / captcha completion
--no-sync Capture only, do not import into StoryForge
--no-creator-center Skip creator-center page capture
--allow-creator-center-fallback
Allow creator-center identity to replace a missing public profile
--note <text> Discovery note saved into StoryForge
Examples:
npm run capture -- \\
--profile-url https://www.douyin.com/user/your_account \\
--storyforge-username kris --storyforge-password 'Asd123456.'
npm run capture -- \\
--profile-url https://www.douyin.com/user/your_account \\
--storyforge-token <token> --headless --skip-login-prompt --no-creator-center
`);
}
function parseArgs(argv) {
const options = {
backendUrl: DEFAULT_BACKEND_URL,
outputDir: DEFAULT_OUTPUT_DIR,
stateDir: DEFAULT_STATE_DIR,
maxVideos: 4,
scrollCount: 5,
waitMs: WAIT_AFTER_NAV_MS,
headless: false,
manualPrompt: true,
syncEnabled: true,
creatorCenterEnabled: true,
allowCreatorCenterFallback: false,
creatorCenterUrls: [...DEFAULT_CREATOR_CENTER_URLS],
note: "",
profileUrl: "",
readyFile: "",
storyforgeToken: "",
storyforgeUsername: "",
storyforgePassword: ""
};
const requireValue = (index, flag) => {
const value = argv[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`Missing value for ${flag}`);
}
return value;
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--help":
case "-h":
options.help = true;
break;
case "--profile-url":
options.profileUrl = requireValue(index, arg);
index += 1;
break;
case "--backend-url":
options.backendUrl = requireValue(index, arg);
index += 1;
break;
case "--output-dir":
options.outputDir = requireValue(index, arg);
index += 1;
break;
case "--state-dir":
options.stateDir = requireValue(index, arg);
index += 1;
break;
case "--max-videos":
options.maxVideos = Number.parseInt(requireValue(index, arg), 10);
index += 1;
break;
case "--scroll-count":
options.scrollCount = Number.parseInt(requireValue(index, arg), 10);
index += 1;
break;
case "--wait-ms":
options.waitMs = Number.parseInt(requireValue(index, arg), 10);
index += 1;
break;
case "--ready-file":
options.readyFile = requireValue(index, arg);
index += 1;
break;
case "--storyforge-token":
options.storyforgeToken = requireValue(index, arg);
index += 1;
break;
case "--storyforge-username":
options.storyforgeUsername = requireValue(index, arg);
index += 1;
break;
case "--storyforge-password":
options.storyforgePassword = requireValue(index, arg);
index += 1;
break;
case "--note":
options.note = requireValue(index, arg);
index += 1;
break;
case "--headless":
options.headless = true;
break;
case "--skip-login-prompt":
options.manualPrompt = false;
break;
case "--no-sync":
options.syncEnabled = false;
break;
case "--no-creator-center":
options.creatorCenterEnabled = false;
break;
case "--allow-creator-center-fallback":
options.allowCreatorCenterFallback = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
function sanitizeName(value) {
return String(value || "capture")
.replace(/[^a-zA-Z0-9._-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 80) || "capture";
}
async function ensureDir(dir) {
await fs.mkdir(dir, { recursive: true });
}
function nowStamp() {
return new Date().toISOString().replace(/[:]/g, "-");
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function navigateAndSettle(page, url, waitMs) {
await page.goto(url, { waitUntil: "commit", timeout: 30_000 }).catch(() => null);
await page.waitForLoadState("domcontentloaded", { timeout: 15_000 }).catch(() => {});
await sleep(waitMs);
}
async function maybePrompt(message, enabled, readyFile = "") {
if (!enabled) {
return;
}
if (readyFile) {
console.error(`${message}\nWaiting for ready file: ${readyFile}`);
await waitForReadyFile(readyFile);
return;
}
const rl = readline.createInterface({ input, output });
try {
await rl.question(`${message}\nPress Enter to continue... `);
} finally {
rl.close();
}
}
async function waitForReadyFile(filePath) {
await ensureDir(path.dirname(filePath));
while (true) {
try {
await fs.access(filePath);
return;
} catch {
await sleep(600);
}
}
}
function uniqueStrings(values) {
const seen = new Set();
const output = [];
for (const value of values) {
const item = String(value || "").trim();
if (!item || seen.has(item)) {
continue;
}
seen.add(item);
output.push(item);
}
return output;
}
function looksLikeRelevantJsonUrl(url) {
const lower = url.toLowerCase();
return (
lower.includes("douyin.com/aweme") ||
lower.includes("douyin.com/web/api") ||
lower.includes("douyin.com/creator") ||
lower.includes("douyin.com/user") ||
lower.includes("creator.douyin.com") ||
lower.includes("iesdouyin.com")
);
}
function findJsonEnd(text, start) {
const opening = text[start];
const closing = opening === "{" ? "}" : "]";
let depth = 0;
let inString = false;
let escaped = false;
for (let index = start; index < text.length; index += 1) {
const char = text[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === "\"") {
inString = false;
}
continue;
}
if (char === "\"") {
inString = true;
continue;
}
if (char === opening) {
depth += 1;
continue;
}
if (char === closing) {
depth -= 1;
if (depth === 0) {
return index + 1;
}
}
}
return -1;
}
async function createResponseCapture(page) {
const records = [];
const seen = new Set();
const pending = [];
const listener = (response) => {
const promise = (async () => {
try {
const url = response.url();
const headers = response.headers();
const contentType = String(headers["content-type"] || "").toLowerCase();
if (!contentType.includes("json") && !looksLikeRelevantJsonUrl(url)) {
return;
}
const key = `${response.request().method()} ${url}`;
if (seen.has(key)) {
return;
}
const text = await Promise.race([
response.text(),
sleep(RESPONSE_READ_TIMEOUT_MS).then(() => {
throw new Error("response read timeout");
})
]);
if (!text || text.length > JSON_CAPTURE_LIMIT) {
return;
}
let payload = null;
try {
payload = JSON.parse(text);
} catch {
return;
}
seen.add(key);
records.push({
url,
method: response.request().method(),
status: response.status(),
payload
});
} catch {
// Ignore network capture failures; page-level capture is still useful.
}
})();
pending.push(promise);
};
page.on("response", listener);
return {
records,
async stop() {
page.off("response", listener);
await Promise.race([
Promise.allSettled(pending),
sleep(RESPONSE_READ_TIMEOUT_MS + 500)
]);
return records;
}
};
}
function extractJsonObjectsFromText(text) {
const candidates = [text];
const seen = new Set();
const results = [];
for (const candidate of candidates) {
const snippet = String(candidate || "").slice(0, SCRIPT_SCAN_LIMIT);
for (let index = 0; index < snippet.length; index += 1) {
const char = snippet[index];
if (char !== "{" && char !== "[") {
continue;
}
const end = findJsonEnd(snippet, index);
if (end <= index) {
continue;
}
try {
const parsed = JSON.parse(snippet.slice(index, end));
const marker = JSON.stringify(parsed);
if (seen.has(marker)) {
continue;
}
seen.add(marker);
results.push(parsed);
if (results.length >= 50) {
return results;
}
} catch {
// Keep scanning.
}
}
}
return results;
}
function extractScriptPayloads(html) {
const results = [];
const seen = new Set();
const regex = /<script([^>]*)>([\s\S]*?)<\/script>/gi;
let match = null;
while ((match = regex.exec(html)) !== null) {
const attrs = match[1] || "";
const content = match[2] || "";
const idMatch = attrs.match(/id=["']([^"']+)["']/i);
const scriptId = idMatch ? idMatch[1] : "";
for (const payload of extractJsonObjectsFromText(content.trim())) {
const marker = JSON.stringify(payload);
if (seen.has(marker)) {
continue;
}
seen.add(marker);
results.push({ script_id: scriptId, payload });
}
}
return results;
}
async function collectWindowGlobals(page) {
return page.evaluate(() => {
const globalNames = [
"__INITIAL_STATE__",
"__NEXT_DATA__",
"__ROUTER_DATA__",
"SIGI_STATE",
"__APOLLO_STATE__"
];
const result = {};
for (const name of globalNames) {
const value = globalThis[name];
if (value === undefined) {
continue;
}
try {
result[name] = JSON.parse(JSON.stringify(value));
} catch {
// Skip non-serializable globals.
}
}
return result;
});
}
async function collectVideoLinks(page) {
const hrefs = await page.evaluate(() => {
return Array.from(document.querySelectorAll("a[href]"))
.map((node) => node.getAttribute("href") || "")
.filter(Boolean);
});
return uniqueStrings(
hrefs
.map((href) => {
if (href.startsWith("//")) {
return `https:${href}`;
}
if (href.startsWith("/")) {
return `https://www.douyin.com${href}`;
}
return href;
})
.filter((href) => href.includes("/video/"))
);
}
async function clickFirstVisible(page, selectors) {
for (const selector of selectors) {
const locator = page.locator(selector).first();
try {
if (await locator.isVisible({ timeout: 1000 })) {
await locator.click({ timeout: 1000 });
return true;
}
} catch {
// Try next selector.
}
}
return false;
}
function escapeRegExp(value) {
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function decodeEscapedUrl(value) {
return String(value || "")
.replace(/\\u002F/g, "/")
.replace(/\\\//g, "/")
.replace(/&amp;/g, "&");
}
async function resolveCreatorPrefetchUrl(page) {
const current = new URL(page.url());
const html = await page.content();
const escapedPath = escapeRegExp(current.pathname);
const mapped = html.match(
new RegExp(`"${escapedPath}"\\s*:\\s*"(https://creator\\.douyin\\.com[^"]+prefetch\\.json)"`)
);
if (mapped?.[1]) {
return decodeEscapedUrl(mapped[1]);
}
const discovered = Array.from(
new Set(
[...html.matchAll(/https:\/\/creator\.douyin\.com\/goofy\/douyin_creator_pc\/mono\/prefetch\/[^"]+prefetch\.json/g)].map(
(match) => decodeEscapedUrl(match[0])
)
)
);
return (
discovered.find((candidate) => candidate.includes(current.pathname.replace(/^\/creator-micro\//, ""))) ||
discovered[0] ||
`https://creator.douyin.com/goofy/douyin_creator_pc/mono/prefetch${current.pathname}/prefetch.json`
);
}
async function collectCreatorPrefetchResults(page) {
const prefetchUrl = await resolveCreatorPrefetchUrl(page);
return page.evaluate(async ({ prefetchUrl }) => {
try {
const prefetchResp = await fetch(prefetchUrl, { credentials: "same-origin" });
const prefetchText = await prefetchResp.text();
const prefetch = JSON.parse(prefetchText);
const results = [];
for (const api of prefetch?.apis || []) {
const target = new URL(api.url, window.location.origin);
for (const [key, value] of Object.entries(api.params || {})) {
target.searchParams.set(key, String(value));
}
const resp = await fetch(target.toString(), {
credentials: api.credentials || "same-origin",
});
const payload = await resp.json().catch(() => null);
results.push({
url: target.toString(),
payload,
});
}
return {
prefetch_url: prefetchUrl,
prefetch,
results,
};
} catch (error) {
return {
prefetch_url: prefetchUrl,
error: String(error),
results: [],
};
}
}, { prefetchUrl });
}
async function prepareProfilePage(page, options) {
await clickFirstVisible(page, [
"text=作品",
"text=视频",
"text=全部作品",
"[role='tab']:has-text('作品')"
]);
for (let index = 0; index < 3; index += 1) {
await clickFirstVisible(page, [
"text=展开",
"text=更多",
"text=查看全部"
]);
}
for (let index = 0; index < options.scrollCount; index += 1) {
await page.evaluate(() => window.scrollBy(0, window.innerHeight * 0.85));
await sleep(1200);
}
}
async function capturePageBundle(page, label, responseCapture, extra = {}) {
const html = await page.content();
const loginGateDetected =
html.includes("扫码登录") ||
html.includes("验证码登录") ||
html.includes("登录后免费畅享高清视频");
const antiBotDetected =
html.includes("window.byted_acrawler.init") ||
html.includes("__ac_signature") ||
html.includes("__ac_nonce");
const scripts = extractScriptPayloads(html);
const globals = await collectWindowGlobals(page);
const network = await responseCapture.stop();
const bundle = {
label,
captured_at: new Date().toISOString(),
page_url: page.url(),
page_title: await page.title().catch(() => ""),
page_meta: await page.evaluate(() => ({
href: window.location.href,
title: document.title,
text_excerpt: (document.body?.innerText || "").trim().slice(0, 8000)
})),
capture_hints: {
login_gate_detected: loginGateDetected,
anti_bot_detected: antiBotDetected
},
scripts,
globals,
network,
extra
};
return bundle;
}
async function saveJson(filePath, value) {
await ensureDir(path.dirname(filePath));
await fs.writeFile(filePath, JSON.stringify(value, null, 2), "utf8");
}
async function saveJsonSafe(filePath, value) {
try {
await saveJson(filePath, value);
} catch (error) {
console.error(`Failed to write ${filePath}: ${error?.message || error}`);
}
}
async function requestJson(urlString, { method = "GET", headers = {}, body = null, bodyPath = "" } = {}) {
const bodyMode = bodyPath ? "path" : body === null ? "none" : "text";
const bodyValue = bodyPath || (typeof body === "string" ? body : JSON.stringify(body));
const stdout = execFileSync(
"python3",
["-c", PYTHON_HTTP_BRIDGE, urlString, method, JSON.stringify(headers), bodyMode, bodyValue],
{ maxBuffer: 20 * 1024 * 1024, encoding: "utf8" }
);
const payload = JSON.parse(String(stdout || "").trim() || "{}");
if ((payload.status || 500) >= 400) {
throw new Error(`Request failed: ${payload.status} ${JSON.stringify(payload.data)}`);
}
return payload.data;
}
async function loginStoryForge(baseUrl, username, password) {
return requestJson(`${baseUrl.replace(/\/$/, "")}/v2/auth/login`, {
method: "POST",
headers: { "content-type": "application/json" },
body: { username, password }
});
}
async function syncCapture(baseUrl, token, bodyPath) {
return requestJson(`${baseUrl.replace(/\/$/, "")}/v2/douyin/accounts/sync`, {
method: "POST",
headers: {
"content-type": "application/json",
Authorization: `Bearer ${token}`
},
bodyPath
});
}
async function captureCreatorPages(context, options, runDir) {
const pages = [];
if (!options.creatorCenterEnabled) {
return pages;
}
for (const [index, url] of options.creatorCenterUrls.entries()) {
const page = await context.newPage();
const responseCapture = await createResponseCapture(page);
try {
console.error(`Capturing creator-center page: ${url}`);
await navigateAndSettle(page, url, options.waitMs);
const prefetchResults = await collectCreatorPrefetchResults(page);
const bundle = await capturePageBundle(page, "creator_center", responseCapture, {
creator_prefetch: prefetchResults
});
pages.push({
url: bundle.page_url,
title: bundle.page_title,
payload: bundle
});
await saveJson(
path.join(runDir, `creator-${String(index + 1).padStart(2, "0")}-${sanitizeName(bundle.page_title || bundle.page_url)}.json`),
bundle
);
} finally {
await page.close().catch(() => {});
}
}
return pages;
}
async function captureVideoPages(context, videoLinks, options, runDir) {
const pages = [];
for (const link of videoLinks.slice(0, Math.max(options.maxVideos, 0))) {
const page = await context.newPage();
const responseCapture = await createResponseCapture(page);
try {
console.error(`Capturing video page: ${link}`);
await navigateAndSettle(page, link, options.waitMs);
const bundle = await capturePageBundle(page, "video_detail", responseCapture, { source_link: link });
pages.push(bundle);
await saveJson(path.join(runDir, `video-${sanitizeName(link)}.json`), bundle);
} finally {
await page.close().catch(() => {});
}
}
return pages;
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
if (!options.profileUrl) {
throw new Error("--profile-url is required");
}
if (
options.syncEnabled &&
!options.storyforgeToken &&
!(options.storyforgeUsername && options.storyforgePassword)
) {
throw new Error("Sync mode requires --storyforge-token or both --storyforge-username and --storyforge-password");
}
const runDir = path.join(
options.outputDir,
`${nowStamp()}-${sanitizeName(options.profileUrl.split("/").pop() || "douyin")}`
);
await ensureDir(runDir);
await ensureDir(options.stateDir);
const summary = {
profile_url: options.profileUrl,
output_dir: runDir,
video_link_count: 0,
captured_video_pages: 0,
captured_creator_pages: 0,
sync_enabled: options.syncEnabled,
status: "running"
};
await saveJsonSafe(path.join(runDir, "summary.json"), summary);
let storyforgeToken = options.storyforgeToken;
if (options.syncEnabled && !storyforgeToken) {
const auth = await loginStoryForge(
options.backendUrl,
options.storyforgeUsername,
options.storyforgePassword
);
storyforgeToken = auth.token;
await saveJson(path.join(runDir, "storyforge-login.json"), {
account: auth.account,
default_external_base_url: auth.default_external_base_url
});
}
const context = await chromium.launchPersistentContext(options.stateDir, {
headless: options.headless,
viewport: { width: 1440, height: 1024 },
args: ["--disable-blink-features=AutomationControlled"]
});
try {
const page = await context.newPage();
const responseCapture = await createResponseCapture(page);
console.error(`Opening profile page: ${options.profileUrl}`);
await navigateAndSettle(page, options.profileUrl, options.waitMs);
await maybePrompt(
`Browser opened ${options.profileUrl}.\nLog into Douyin if needed, solve any slider/captcha, and optionally click into the creator homepage before capture.`,
options.manualPrompt,
options.readyFile
);
await prepareProfilePage(page, options);
await sleep(options.waitMs);
const videoLinks = await collectVideoLinks(page);
console.error(`Collected ${videoLinks.length} candidate video links`);
const profileBundle = await capturePageBundle(page, "profile", responseCapture, { video_links: videoLinks });
await saveJson(path.join(runDir, "profile-bundle.json"), profileBundle);
await page.close().catch(() => {});
const creatorPages = await captureCreatorPages(context, options, runDir);
const videoPages = await captureVideoPages(context, videoLinks, options, runDir);
const syncBody = {
profile_url: options.profileUrl,
allow_creator_center_profile_fallback: options.allowCreatorCenterFallback,
compact_response: true,
manual_profile_payload: profileBundle,
manual_creator_pages: creatorPages,
manual_work_payloads: videoPages,
discovery_note: options.note || "browser-assisted capture"
};
const syncRequestPath = path.join(runDir, "storyforge-sync-request.json");
await saveJson(syncRequestPath, syncBody);
summary.video_link_count = videoLinks.length;
summary.captured_video_pages = videoPages.length;
summary.captured_creator_pages = creatorPages.length;
if (options.syncEnabled) {
const workspace = await syncCapture(options.backendUrl, storyforgeToken, syncRequestPath);
summary.sync_result = {
account_id: workspace.account?.id || "",
nickname: workspace.account?.nickname || "",
sync_errors: workspace.sync_errors || []
};
await saveJson(path.join(runDir, "storyforge-sync-response.json"), workspace);
}
summary.status = "completed";
await saveJson(path.join(runDir, "summary.json"), summary);
console.log(JSON.stringify(summary, null, 2));
} catch (error) {
summary.status = "failed";
summary.error = error?.stack || String(error);
await saveJsonSafe(path.join(runDir, "summary.json"), summary);
await saveJsonSafe(path.join(runDir, "storyforge-sync-error.json"), {
error: error?.stack || String(error)
});
throw error;
} finally {
await context.close().catch(() => {});
}
}
main().catch((error) => {
console.error(error?.stack || String(error));
process.exitCode = 1;
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,59 @@
{
"name": "storyforge-douyin-browser-capture",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "storyforge-douyin-browser-capture",
"version": "0.1.0",
"dependencies": {
"playwright": "^1.56.1"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

View File

@@ -0,0 +1,15 @@
{
"name": "storyforge-douyin-browser-capture",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Browser-assisted Douyin capture and sync tool for StoryForge",
"scripts": {
"capture": "node ./capture_and_sync.mjs",
"control-panel": "node ./control_panel.mjs",
"help": "node ./capture_and_sync.mjs --help"
},
"dependencies": {
"playwright": "^1.56.1"
}
}

58
scripts/smoke_business.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/bin/sh
set -eu
BASE_URL="${STORYFORGE_BASE_URL:-http://127.0.0.1:8081}"
USERNAME="${STORYFORGE_USERNAME:-kris}"
PASSWORD="${STORYFORGE_PASSWORD:-Asd123456.}"
ACCOUNT_ID="${STORYFORGE_SMOKE_ACCOUNT_ID:-dyacct_c2b62842b228406cb48f05fac16fdfdf}"
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.")
account_id = os.environ.get("ACCOUNT_ID", "dyacct_c2b62842b228406cb48f05fac16fdfdf")
login_req = urllib.request.Request(
base + "/v2/auth/login",
data=json.dumps({"username": username, "password": password}).encode(),
headers={"content-type": "application/json"},
)
with urllib.request.urlopen(login_req, timeout=20) as resp:
login = json.load(resp)
token = login["token"]
headers = {"authorization": "Bearer " + token}
checks = [
("/v2/douyin/accounts", "accounts"),
(f"/v2/douyin/accounts/{account_id}/workspace", "workspace"),
(f"/v2/douyin/accounts/{account_id}/videos?limit=5&sort_by=score", "videos"),
]
print("smoke login: ok")
for path, label in checks:
req = urllib.request.Request(base + path, headers=headers)
with urllib.request.urlopen(req, timeout=30) as resp:
payload = json.load(resp)
if label == "accounts":
summary = {"accounts": len(payload)}
elif label == "workspace":
summary = {
"account": payload.get("account", {}).get("nickname"),
"reports": len(payload.get("recent_reports") or []),
"linked_accounts": len(payload.get("linked_accounts") or []),
"high_score_threshold": (payload.get("video_workspace") or {}).get("high_score_threshold"),
}
else:
items = payload.get("items") or []
summary = {
"videos": len(items),
"first_title": items[0].get("title") if items else None,
"first_has_analysis": bool(items and items[0].get("latest_analysis")),
}
print(f"{label}: " + json.dumps(summary, ensure_ascii=False))
PY

39
scripts/start_business.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/bin/sh
set -eu
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
COMPOSE_FILE="$ROOT/docker-compose.yml"
cd "$ROOT"
docker compose -f "$COMPOSE_FILE" up -d --build collector n8n
python3 - <<'PY'
import time
import urllib.request
checks = [
("collector", "http://127.0.0.1:8081/healthz"),
("n8n", "http://127.0.0.1:5670/healthz"),
]
deadline = time.time() + 45
pending = dict(checks)
while pending and time.time() < deadline:
for name, url in list(pending.items()):
try:
with urllib.request.urlopen(url, timeout=5) as resp:
print(f"{name} ready: {resp.status}")
pending.pop(name, None)
except Exception:
pass
if pending:
time.sleep(1)
if pending:
print("startup timeout:", ", ".join(pending))
raise SystemExit(1)
PY
echo "business started"
echo "collector: http://127.0.0.1:8081/healthz"
echo "n8n: http://127.0.0.1:5670/healthz"

View File

@@ -1,25 +1,28 @@
#!/bin/sh
set -eu
ROOT="/Users/kris/code/StoryForge"
PID_FILE="$ROOT/data/collector/collector.pid"
LOG_FILE="$ROOT/data/collector/collector.log"
VENV="$ROOT/collector-service/.venv311"
mkdir -p "$ROOT/data/collector"
if [ ! -x "$VENV/bin/python" ]; then
/opt/homebrew/bin/python3.11 -m venv "$VENV"
. "$VENV/bin/activate"
pip install -q -r "$ROOT/collector-service/requirements.txt"
else
. "$VENV/bin/activate"
fi
if [ -f "$PID_FILE" ]; then
PID="$(cat "$PID_FILE" || true)"
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
echo "collector already running: $PID"
exit 0
fi
fi
cd "$ROOT/collector-service"
nohup "$VENV/bin/python" -m uvicorn app.main:app --host 0.0.0.0 --port 8081 >"$LOG_FILE" 2>&1 &
echo $! > "$PID_FILE"
echo "collector started: $(cat "$PID_FILE")"
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
COMPOSE_FILE="$ROOT/docker-compose.yml"
cd "$ROOT"
docker compose -f "$COMPOSE_FILE" up -d --build collector
python3 - <<'PY'
import time
import urllib.request
url = "http://127.0.0.1:8081/healthz"
deadline = time.time() + 30
last_error = ""
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=5) as resp:
print(f"collector ready: {resp.status} {resp.read().decode('utf-8', 'ignore')[:160]}")
raise SystemExit(0)
except Exception as exc:
last_error = str(exc)
time.sleep(1)
print(f"collector start timeout: {last_error}")
raise SystemExit(1)
PY

View File

@@ -0,0 +1,38 @@
#!/bin/sh
set -eu
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
PORT="${DOUYIN_WORKBENCH_PORT:-3618}"
SCRIPT="$ROOT/scripts/douyin-browser-capture/control_panel.mjs"
LOG_FILE="${DOUYIN_WORKBENCH_LOG:-/tmp/storyforge-douyin-workbench.log}"
SESSION_NAME="${DOUYIN_WORKBENCH_SESSION:-storyforge-douyin-workbench}"
if lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
echo "douyin workbench already running: http://127.0.0.1:$PORT/workbench"
exit 0
fi
screen -wipe >/dev/null 2>&1 || true
screen -S "$SESSION_NAME" -X quit >/dev/null 2>&1 || true
screen -dmS "$SESSION_NAME" /bin/sh -lc "exec env PORT='$PORT' node '$SCRIPT' >>'$LOG_FILE' 2>&1"
python3 - <<'PY'
import os
import time
import urllib.request
port = os.environ.get("PORT", "3618")
url = f"http://127.0.0.1:{port}/workbench"
deadline = time.time() + 15
last_error = ""
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=3) as resp:
print(f"douyin workbench ready: {resp.status} {url}")
raise SystemExit(0)
except Exception as exc:
last_error = str(exc)
time.sleep(0.5)
print(f"douyin workbench start timeout: {last_error}")
raise SystemExit(1)
PY

22
scripts/status_business.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/sh
set -eu
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
COMPOSE_FILE="$ROOT/docker-compose.yml"
cd "$ROOT"
docker compose -f "$COMPOSE_FILE" ps
echo "---"
python3 - <<'PY'
import urllib.request
for name, url in [
("collector", "http://127.0.0.1:8081/healthz"),
("n8n", "http://127.0.0.1:5670/healthz"),
]:
try:
with urllib.request.urlopen(url, timeout=5) as resp:
print(f"{name}: {resp.status} {resp.read().decode('utf-8', 'ignore')[:200]}")
except Exception as exc:
print(f"{name}: ERROR {exc}")
PY

View File

@@ -1,15 +1,21 @@
#!/bin/sh
set -eu
PID_FILE="/Users/kris/code/StoryForge/data/collector/collector.pid"
if [ -f "$PID_FILE" ]; then
PID="$(cat "$PID_FILE" || true)"
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
echo "running:$PID"
exit 0
fi
fi
if lsof -nP -iTCP:8081 -sTCP:LISTEN >/dev/null 2>&1; then
echo "running:port"
else
echo "stopped"
fi
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
COMPOSE_FILE="$ROOT/docker-compose.yml"
cd "$ROOT"
docker compose -f "$COMPOSE_FILE" ps collector
echo "---"
python3 - <<'PY'
import urllib.request
url = "http://127.0.0.1:8081/healthz"
try:
with urllib.request.urlopen(url, timeout=5) as resp:
print(f"collector health: {resp.status}")
print(resp.read().decode("utf-8", "ignore")[:400])
except Exception as exc:
print(f"collector health error: {exc}")
raise SystemExit(1)
PY

View File

@@ -0,0 +1,23 @@
#!/bin/sh
set -eu
PORT="${DOUYIN_WORKBENCH_PORT:-3618}"
SESSION_NAME="${DOUYIN_WORKBENCH_SESSION:-storyforge-douyin-workbench}"
if ! lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
echo "douyin workbench stopped"
exit 1
fi
screen -ls | grep "$SESSION_NAME" || true
echo "---"
python3 - <<'PY'
import os
import urllib.request
port = os.environ.get("PORT", "3618")
for path in ("/workbench", "/"):
url = f"http://127.0.0.1:{port}{path}"
with urllib.request.urlopen(url, timeout=5) as resp:
print(f"{path}: {resp.status}")
PY

View File

@@ -1,15 +1,9 @@
#!/bin/sh
set -eu
PID_FILE="/Users/kris/code/StoryForge/data/collector/collector.pid"
if [ ! -f "$PID_FILE" ]; then
echo "collector not running"
exit 0
fi
PID="$(cat "$PID_FILE" || true)"
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
kill "$PID"
echo "collector stopped: $PID"
else
echo "collector pid stale: $PID"
fi
rm -f "$PID_FILE"
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
COMPOSE_FILE="$ROOT/docker-compose.yml"
cd "$ROOT"
docker compose -f "$COMPOSE_FILE" stop collector
echo "collector stopped"

View File

@@ -0,0 +1,9 @@
#!/bin/sh
set -eu
PORT="${DOUYIN_WORKBENCH_PORT:-3618}"
SESSION_NAME="${DOUYIN_WORKBENCH_SESSION:-storyforge-douyin-workbench}"
screen -S "$SESSION_NAME" -X quit >/dev/null 2>&1 || true
lsof -tiTCP:"$PORT" -sTCP:LISTEN | xargs -r kill
echo "douyin workbench stopped: $PORT"

View File

@@ -0,0 +1,77 @@
# StoryForge Web V4
这是 `StoryForge` 当前面向正式前端实现的 Web 承载目录。
## 入口
- 页面:`index.html`
- 样式:`assets/styles.css`
- 页面交互:`assets/app.js`
## 当前定位
- 这不是最终生产版,但已经不是纯静态原型
- 目录已经从 `output/ui/` 原型区独立出来,并接上了第一层真实业务接口
- 当前保留的核心页面结构:
- 项目总台
- 我的项目
- 找对标
- 跟踪账号
- 自动流程
- Agent
- 生产中心
- 发布与复盘
- 额度
## 当前已接入的真实能力
- 后端登录与会话保持
- 工作区信息与 `/v2/me`
- 项目总台 `/v2/me/dashboard`
- 项目创建 `/v2/projects`
- 内容源列表 `/v2/content-sources`
- 抖音对标账号 `/v2/douyin/accounts`
- 单账号工作台 `/v2/douyin/accounts/{id}/workspace`
- 单账号作品列表 `/v2/douyin/accounts/{id}/videos`
- 最近知识库文档 `/v2/knowledge-bases/{id}/documents`
## 当前已接入的真实动作
- 新建项目
- 导入主页并触发内容源同步
- 导入作品链接并触发分析
- 导入文本素材并触发分析
- 上传本地视频并触发分析
- 创建 Agent
- 对当前 Douyin 对标账号重跑分析
- 批量分析高分作品
- 查找相似对标账号
- 查看任务详情、事件和 artifacts/result
- 使用 Agent 生成文案
- 创建 AI 视频任务
- 创建实拍剪辑任务
## 本地预览
推荐直接在目录内起一个临时静态服务:
```bash
cd /Users/kris/code/StoryForge-gitea/web/storyforge-web-v4
python3 -m http.server 3918
```
然后打开:
- [http://127.0.0.1:3918/index.html](http://127.0.0.1:3918/index.html)
首次进入需要手动连接后端,默认地址是:
- `http://127.0.0.1:8081`
## 后续建议
- 继续补动作型接口,例如导入、绑定 Agent、触发分析与生产
- 把全局搜索和页内搜索合并成统一搜索体验
-`生产中心 / 发布与复盘` 接入更完整的任务与成片对象
- 不要把这套页面重新塞回 `scripts/douyin-browser-capture/control_panel.mjs`
- 抖音采集控制台仍作为独立工具存在,这里才是正式业务应用壳

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff