Compare commits
47 Commits
main
...
codex/stor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea6a855890 | ||
|
|
042188f954 | ||
|
|
c657db9b38 | ||
|
|
652f0c9f79 | ||
|
|
dab444a83c | ||
|
|
ed5bcaef84 | ||
|
|
7500d02730 | ||
|
|
37709d37b7 | ||
|
|
9ed5f24364 | ||
|
|
031ba04d4e | ||
|
|
32dea8e3a6 | ||
|
|
4106347b67 | ||
|
|
b75c9e275b | ||
|
|
540be80719 | ||
|
|
fe07a5f212 | ||
|
|
35c97ffe4d | ||
|
|
1851625a53 | ||
|
|
66db9e8687 | ||
|
|
98592168b7 | ||
|
|
e771919e4a | ||
|
|
6899ebba60 | ||
|
|
6b3774b543 | ||
|
|
7171dae91c | ||
|
|
9f921fff94 | ||
|
|
39216d18b4 | ||
|
|
c09a976628 | ||
|
|
1fb39e040f | ||
|
|
be94836e3c | ||
|
|
c4222755b1 | ||
|
|
f6462dbccc | ||
|
|
741fe4f983 | ||
|
|
5d9c9cf048 | ||
|
|
5c52476a45 | ||
|
|
4356c46b9e | ||
|
|
10820595cf | ||
|
|
1fa1b586f7 | ||
|
|
7070c3aa85 | ||
|
|
ac6a8a82df | ||
|
|
98722a580a | ||
|
|
e1010503ae | ||
|
|
1a055a16c2 | ||
|
|
f96a37a236 | ||
|
|
a906e0ceda | ||
|
|
1c539abc6e | ||
|
|
63af810236 | ||
|
|
b145363111 | ||
|
|
d2074c3518 |
37
.env.example
@@ -2,15 +2,38 @@ DEFAULT_EXTERNAL_BASE_URL=http://test.hyzq.net:8081
|
|||||||
LOCAL_OPENAI_BASE_URL=http://127.0.0.1:8317/v1
|
LOCAL_OPENAI_BASE_URL=http://127.0.0.1:8317/v1
|
||||||
LOCAL_OPENAI_MODEL=GLM-5
|
LOCAL_OPENAI_MODEL=GLM-5
|
||||||
LOCAL_OPENAI_API_KEY=
|
LOCAL_OPENAI_API_KEY=
|
||||||
FASTGPT_BASE_URL=http://127.0.0.1:3000
|
# Host-side collector runs can keep using N8N_BASE_URL.
|
||||||
FASTGPT_DATASET_API_KEY=
|
N8N_BASE_URL=http://127.0.0.1:5670
|
||||||
|
# Dockerized collector should use the internal n8n service address.
|
||||||
|
COLLECTOR_N8N_BASE_URL=http://n8n:5678
|
||||||
|
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
|
YTDLP_BIN=yt-dlp
|
||||||
FFMPEG_BIN=ffmpeg
|
FFMPEG_BIN=ffmpeg
|
||||||
WHISPER_BIN=
|
WHISPER_BIN=
|
||||||
WHISPER_MODEL=./data/collector/models/ggml-base.en.bin
|
WHISPER_MODEL=./data/collector/models/ggml-base.en.bin
|
||||||
POSTGRES_DB=fastgpt
|
ASR_HTTP_BASE_URL=
|
||||||
POSTGRES_USER=postgres
|
ASR_HTTP_TRANSCRIBE_PATH=/transcribe
|
||||||
POSTGRES_PASSWORD=postgres
|
ASR_HTTP_FIELD_NAME=wav
|
||||||
MINIO_ROOT_USER=minioadmin
|
ASR_HTTP_TIMEOUT_SEC=120
|
||||||
MINIO_ROOT_PASSWORD=minioadmin
|
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
|
CLIPROXY_IMAGE=storyforge/cli-proxy-api:patched
|
||||||
|
CLIPROXY_MANAGEMENT_SECRET=storyforge-local-management
|
||||||
|
CLIPROXY_DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||||
|
# Optional but recommended for local model gateway recovery.
|
||||||
|
# DASHSCOPE_API_KEY=
|
||||||
|
|||||||
4
.gitignore
vendored
@@ -20,9 +20,13 @@ build/
|
|||||||
.kotlin/
|
.kotlin/
|
||||||
**/.gradle/
|
**/.gradle/
|
||||||
**/.kotlin/
|
**/.kotlin/
|
||||||
|
node_modules/
|
||||||
|
**/node_modules/
|
||||||
|
|
||||||
# Runtime data and artifacts
|
# Runtime data and artifacts
|
||||||
data/
|
data/
|
||||||
|
!android-app/app/src/main/java/com/aiglasses/app/data/
|
||||||
|
!android-app/app/src/main/java/com/aiglasses/app/data/**
|
||||||
output/
|
output/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
|||||||
135
README.md
@@ -5,36 +5,159 @@ StoryForge 现在拆成独立项目目录,和 `AI-glasses` 分开维护。
|
|||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
- `android-app/`:StoryForge Android 客户端
|
- `android-app/`:StoryForge Android 客户端
|
||||||
- `collector-service/`:FastAPI 后端,提供登录、审批、素材导入、知识库、智能体和 OTA
|
- `collector-service/`:FastAPI 后端,负责用户体系、项目、Agent、任务、内容分析和对外能力接入
|
||||||
- `docker-compose.yml`:本地 FastGPT / collector / 基础依赖编排
|
- `n8n/`:工作流导出文件,作为流程编排中枢
|
||||||
|
- `docker-compose.yml`:本地 `collector + n8n + cli-proxy-api` 编排
|
||||||
- `Common/`:项目约束和架构说明
|
- `Common/`:项目约束和架构说明
|
||||||
- `data/collector/`:SQLite、任务文件、下载产物
|
- `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
|
## Android
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /Users/kris/code/StoryForge/android-app
|
cd /Users/kris/code/StoryForge-gitea/android-app
|
||||||
./gradlew assembleDebug
|
./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
|
## Collector Service
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /Users/kris/code/StoryForge/collector-service
|
cd /Users/kris/code/StoryForge-gitea/collector-service
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
uvicorn app.main:app --host 0.0.0.0 --port 8081 --reload
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
如果要让本机模型网关 `cli-proxy-api` 自动提供 `GLM-5`,建议在启动前确保本机环境里存在:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DASHSCOPE_API_KEY=your_dashscope_key
|
||||||
|
```
|
||||||
|
|
||||||
|
或者把它写进本地 `.env`。`./scripts/start_business.sh` 会自动生成 `data/cliproxyapi/config.yaml` 并把 `glm-5 -> GLM-5` 映射到本机网关。
|
||||||
|
|
||||||
|
如果 `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`
|
- `kris`
|
||||||
- `Asd123456.`
|
- `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`
|
- 新注册账号默认 `pending`
|
||||||
- 主管理员审批后才可使用核心业务接口
|
- 主管理员审批后才可使用核心业务接口
|
||||||
- 素材入口支持文字、视频链接、视频上传
|
- 支持 `user -> project -> knowledge base / assistant(agent) / job / content source` 的多租户边界
|
||||||
- 可选对接本机 OpenAI 兼容模型服务和 FastGPT 数据集 API
|
- 素材入口支持文字、视频链接、视频上传;内容源账号通过 `content_sources` 建模持久化,并可派生父子分析任务
|
||||||
|
- `cutvideo` 继续运行在 Windows 机器,本系统通过 API 调度
|
||||||
|
- `huobao-drama` 继续作为 AI 生成视频主链的核心引擎
|
||||||
|
- 详细审计、阶段计划和联调步骤见 `docs/`
|
||||||
|
|||||||
@@ -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>()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
540
android-app/app/src/main/java/com/aiglasses/app/data/Models.kt
Normal 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()
|
||||||
|
)
|
||||||
@@ -57,6 +57,9 @@ interface StoryForgeApiService {
|
|||||||
@POST("v2/explore/text")
|
@POST("v2/explore/text")
|
||||||
suspend fun createTextJob(@Body request: ExploreTextRequest): JobDto
|
suspend fun createTextJob(@Body request: ExploreTextRequest): JobDto
|
||||||
|
|
||||||
|
@POST("v2/pipelines/content-source-sync")
|
||||||
|
suspend fun createContentSourceSyncJob(@Body request: ContentSourceSyncRequest): JobDto
|
||||||
|
|
||||||
@Multipart
|
@Multipart
|
||||||
@POST("v2/explore/upload-video")
|
@POST("v2/explore/upload-video")
|
||||||
suspend fun uploadVideo(
|
suspend fun uploadVideo(
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package com.aiglasses.app.storyforge
|
package com.aiglasses.app.storyforge
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
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
|
@Serializable
|
||||||
data class RegisterAccountRequest(
|
data class RegisterAccountRequest(
|
||||||
@@ -66,12 +70,22 @@ data class PreferredModelRequest(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class KnowledgeBaseDto(
|
data class ProjectDto(
|
||||||
val id: String,
|
val id: String,
|
||||||
val user_id: String,
|
val user_id: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val description: 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 sync_status: String = "pending",
|
||||||
val document_count: Int = 0,
|
val document_count: Int = 0,
|
||||||
val linked_assistant_count: Int = 0,
|
val linked_assistant_count: Int = 0,
|
||||||
@@ -82,6 +96,7 @@ data class KnowledgeBaseDto(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class KnowledgeBaseCreateRequest(
|
data class KnowledgeBaseCreateRequest(
|
||||||
val name: String,
|
val name: String,
|
||||||
|
val project_id: String = "",
|
||||||
val description: String = ""
|
val description: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -89,12 +104,13 @@ data class KnowledgeBaseCreateRequest(
|
|||||||
data class AssistantDto(
|
data class AssistantDto(
|
||||||
val id: String,
|
val id: String,
|
||||||
val user_id: String,
|
val user_id: String,
|
||||||
|
val project_id: String = "",
|
||||||
val name: String,
|
val name: String,
|
||||||
val description: String = "",
|
val description: String = "",
|
||||||
val system_prompt: String = "",
|
val system_prompt: String = "",
|
||||||
val generation_goal: String = "",
|
val generation_goal: String = "",
|
||||||
val knowledge_base_ids: List<String> = emptyList(),
|
val knowledge_base_ids: List<String> = emptyList(),
|
||||||
val fastgpt_app_key: String = "",
|
val config: JsonObject = buildJsonObject { },
|
||||||
val model_profile_id: String = "",
|
val model_profile_id: String = "",
|
||||||
val created_at: String = "",
|
val created_at: String = "",
|
||||||
val updated_at: String = ""
|
val updated_at: String = ""
|
||||||
@@ -107,7 +123,7 @@ data class AssistantCreateRequest(
|
|||||||
val system_prompt: String = "",
|
val system_prompt: String = "",
|
||||||
val generation_goal: String = "",
|
val generation_goal: String = "",
|
||||||
val knowledge_base_ids: List<String> = emptyList(),
|
val knowledge_base_ids: List<String> = emptyList(),
|
||||||
val fastgpt_app_key: String = "",
|
val project_id: String = "",
|
||||||
val model_profile_id: String = ""
|
val model_profile_id: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -118,7 +134,7 @@ data class AssistantUpdateRequest(
|
|||||||
val system_prompt: String? = null,
|
val system_prompt: String? = null,
|
||||||
val generation_goal: String? = null,
|
val generation_goal: String? = null,
|
||||||
val knowledge_base_ids: List<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
|
val model_profile_id: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -126,6 +142,7 @@ data class AssistantUpdateRequest(
|
|||||||
data class ExploreVideoLinkRequest(
|
data class ExploreVideoLinkRequest(
|
||||||
val video_url: String,
|
val video_url: String,
|
||||||
val title: String? = null,
|
val title: String? = null,
|
||||||
|
val project_id: String? = null,
|
||||||
val knowledge_base_id: String? = null,
|
val knowledge_base_id: String? = null,
|
||||||
val assistant_id: String? = null,
|
val assistant_id: String? = null,
|
||||||
val analysis_model_profile_id: String? = null,
|
val analysis_model_profile_id: String? = null,
|
||||||
@@ -136,28 +153,54 @@ data class ExploreVideoLinkRequest(
|
|||||||
data class ExploreTextRequest(
|
data class ExploreTextRequest(
|
||||||
val title: String,
|
val title: String,
|
||||||
val content: String,
|
val content: String,
|
||||||
|
val project_id: String? = null,
|
||||||
val knowledge_base_id: String? = null,
|
val knowledge_base_id: String? = null,
|
||||||
val assistant_id: String? = null,
|
val assistant_id: String? = null,
|
||||||
val analysis_model_profile_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
|
@Serializable
|
||||||
data class JobDto(
|
data class JobDto(
|
||||||
val id: String,
|
val id: String,
|
||||||
val user_id: String,
|
val user_id: String,
|
||||||
|
val project_id: String = "",
|
||||||
|
val parent_job_id: String = "",
|
||||||
val assistant_id: String? = null,
|
val assistant_id: String? = null,
|
||||||
val knowledge_base_id: String,
|
val knowledge_base_id: String,
|
||||||
|
val content_source_id: String = "",
|
||||||
val source_type: 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 source_url: String? = null,
|
||||||
val title: String,
|
val title: String,
|
||||||
val language: String,
|
val language: String,
|
||||||
val status: String,
|
val status: String,
|
||||||
val transcript_text: String = "",
|
val transcript_text: String = "",
|
||||||
val style_summary: String = "",
|
val style_summary: String = "",
|
||||||
val fastgpt_collection_id: String = "",
|
|
||||||
val upload_status: String = "pending",
|
val upload_status: String = "pending",
|
||||||
val error: String = "",
|
val error: String = "",
|
||||||
val artifacts: Map<String, String> = emptyMap(),
|
val artifacts: JsonObject = buildJsonObject { },
|
||||||
|
val result: JsonObject = buildJsonObject { },
|
||||||
val analysis_model_profile_id: String = "",
|
val analysis_model_profile_id: String = "",
|
||||||
val created_at: String = "",
|
val created_at: String = "",
|
||||||
val updated_at: String = ""
|
val updated_at: String = ""
|
||||||
@@ -173,7 +216,9 @@ data class KnowledgeDocumentDto(
|
|||||||
val transcript_text: String = "",
|
val transcript_text: String = "",
|
||||||
val style_summary: String = "",
|
val style_summary: String = "",
|
||||||
val combined_text: 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 analysis_model_profile_id: String = "",
|
||||||
val created_at: String = "",
|
val created_at: String = "",
|
||||||
val updated_at: String = ""
|
val updated_at: String = ""
|
||||||
@@ -200,6 +245,7 @@ data class GenerateCopyResponseDto(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class DashboardDto(
|
data class DashboardDto(
|
||||||
val account: AccountDto,
|
val account: AccountDto,
|
||||||
|
val projects: List<ProjectDto> = emptyList(),
|
||||||
val knowledge_bases: List<KnowledgeBaseDto> = emptyList(),
|
val knowledge_bases: List<KnowledgeBaseDto> = emptyList(),
|
||||||
val assistants: List<AssistantDto> = emptyList(),
|
val assistants: List<AssistantDto> = emptyList(),
|
||||||
val recent_jobs: List<JobDto> = emptyList(),
|
val recent_jobs: List<JobDto> = emptyList(),
|
||||||
|
|||||||
@@ -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(
|
suspend fun uploadVideo(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
title: String,
|
title: String,
|
||||||
|
|||||||
@@ -53,14 +53,19 @@ fun StoryForgeScreen(
|
|||||||
onInstallLatestUpdate: () -> Unit
|
onInstallLatestUpdate: () -> Unit
|
||||||
) {
|
) {
|
||||||
val heroBrush = Brush.linearGradient(
|
val heroBrush = Brush.linearGradient(
|
||||||
colors = listOf(Color(0xFF0B3C5D), Color(0xFF1F6E5F), Color(0xFFB97524))
|
colors = listOf(Color(0xFFEAF3FF), Color(0xFFD6E9FF), Color(0xFFF7FBFF))
|
||||||
)
|
)
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
if (state.isAuthenticated && state.isApproved) {
|
if (state.isAuthenticated && state.isApproved) {
|
||||||
NavigationBar(modifier = Modifier.navigationBarsPadding()) {
|
NavigationBar(
|
||||||
BottomTabItem(label = "探索", tab = StoryForgeTab.Explore, state = state, onSelect = vm::selectTab)
|
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.Production, state = state, onSelect = vm::selectTab)
|
||||||
BottomTabItem(label = "我的", tab = StoryForgeTab.Mine, state = state, onSelect = vm::selectTab)
|
BottomTabItem(label = "我的", tab = StoryForgeTab.Mine, state = state, onSelect = vm::selectTab)
|
||||||
}
|
}
|
||||||
@@ -100,13 +105,29 @@ private fun BottomTabItem(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clip(RoundedCornerShape(18.dp))
|
.clip(RoundedCornerShape(18.dp))
|
||||||
.clickable { onSelect(tab) }
|
.clickable { onSelect(tab) }
|
||||||
.background(if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent)
|
.background(
|
||||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) else Color.Transparent
|
||||||
|
)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 10.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text(text = label.take(1), fontWeight = FontWeight.Bold)
|
Box(
|
||||||
Text(label, style = MaterialTheme.typography.labelSmall)
|
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(
|
Card(
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||||
shape = RoundedCornerShape(28.dp),
|
shape = RoundedCornerShape(28.dp),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -136,15 +157,21 @@ private fun AuthScreen(
|
|||||||
.padding(22.dp),
|
.padding(22.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Text("StoryForge AI", style = MaterialTheme.typography.headlineSmall)
|
Text("StoryForge", style = MaterialTheme.typography.headlineMedium)
|
||||||
Text(
|
Text(
|
||||||
if (state.authMode == StoryForgeAuthMode.Login) "登录账号" else "注册新账号,提交后等待主管理员审批",
|
if (state.authMode == StoryForgeAuthMode.Login) "登录工作区,继续对标、Agent 和生产流程。"
|
||||||
|
else "先创建账号,审批通过后就能开始搭项目和 Agent。",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f)
|
||||||
)
|
)
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
options = listOf("登录" to (state.authMode == StoryForgeAuthMode.Login), "注册" to (state.authMode == StoryForgeAuthMode.Register)),
|
options = listOf(
|
||||||
onSelect = { label -> vm.setAuthMode(if (label == "登录") StoryForgeAuthMode.Login else StoryForgeAuthMode.Register) }
|
"登录" to (state.authMode == StoryForgeAuthMode.Login),
|
||||||
|
"注册" to (state.authMode == StoryForgeAuthMode.Register)
|
||||||
|
),
|
||||||
|
onSelect = { label ->
|
||||||
|
vm.setAuthMode(if (label == "登录") StoryForgeAuthMode.Login else StoryForgeAuthMode.Register)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.username,
|
value = state.username,
|
||||||
@@ -168,7 +195,7 @@ private fun AuthScreen(
|
|||||||
if (state.busy) {
|
if (state.busy) {
|
||||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||||
} else {
|
} else {
|
||||||
Text(if (state.authMode == StoryForgeAuthMode.Login) "登录" else "注册")
|
Text(if (state.authMode == StoryForgeAuthMode.Login) "进入工作区" else "提交注册")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (state.statusMessage.isNotBlank()) {
|
if (state.statusMessage.isNotBlank()) {
|
||||||
@@ -197,22 +224,22 @@ private fun PendingApprovalScreen(
|
|||||||
) {
|
) {
|
||||||
HeroCard(
|
HeroCard(
|
||||||
title = "等待审批",
|
title = "等待审批",
|
||||||
subtitle = "${account?.display_name ?: account?.username ?: "当前账号"} 已登录,但尚未通过主管理员审批。",
|
subtitle = "${account?.display_name ?: account?.username ?: "当前账号"} 已登录,待主管理员通过后继续使用。",
|
||||||
heroBrush = heroBrush,
|
heroBrush = heroBrush,
|
||||||
badges = listOf(
|
badges = listOf(
|
||||||
"审批状态:${account?.approval_status ?: "pending"}",
|
"状态 ${account?.approval_status ?: "pending"}",
|
||||||
if (state.resolvedIp.isNotBlank()) "已解析到 ${state.resolvedIp}" else ""
|
if (state.resolvedIp.isNotBlank()) "已解析 ${state.resolvedIp}" else ""
|
||||||
).filter { it.isNotBlank() }
|
).filter { it.isNotBlank() }
|
||||||
)
|
)
|
||||||
SectionCard(title = "当前说明", subtitle = state.statusMessage) {
|
SectionCard(title = "当前说明", subtitle = state.statusMessage) {
|
||||||
Text("新注册账号在主管理员通过前,无法访问探索、生产和知识库功能。")
|
Text("审批通过前,项目、对标、Agent 和生产入口都会先锁定。")
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Button(onClick = vm::refreshApprovalStatus, enabled = !state.busy) {
|
Button(onClick = vm::refreshApprovalStatus, enabled = !state.busy) {
|
||||||
Text("刷新审批状态")
|
Text("刷新状态")
|
||||||
}
|
}
|
||||||
OutlinedButton(onClick = vm::logout) {
|
OutlinedButton(onClick = vm::logout) {
|
||||||
Text("退出登录")
|
Text("退出")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (state.errorMessage.isNotBlank()) {
|
if (state.errorMessage.isNotBlank()) {
|
||||||
@@ -241,11 +268,19 @@ private fun AppShell(
|
|||||||
) {
|
) {
|
||||||
HeroCard(
|
HeroCard(
|
||||||
title = when (state.currentTab) {
|
title = when (state.currentTab) {
|
||||||
StoryForgeTab.Explore -> "探索素材"
|
StoryForgeTab.Overview -> "项目总览"
|
||||||
StoryForgeTab.Production -> "生产文案"
|
StoryForgeTab.Benchmark -> "找对标"
|
||||||
StoryForgeTab.Mine -> "我的工作台"
|
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,
|
heroBrush = heroBrush,
|
||||||
badges = listOf(
|
badges = listOf(
|
||||||
state.account?.display_name ?: state.account?.username.orEmpty(),
|
state.account?.display_name ?: state.account?.username.orEmpty(),
|
||||||
@@ -255,7 +290,9 @@ private fun AppShell(
|
|||||||
)
|
)
|
||||||
StatusStrip(state = state, onRefresh = vm::refreshWorkspace)
|
StatusStrip(state = state, onRefresh = vm::refreshWorkspace)
|
||||||
when (state.currentTab) {
|
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.Production -> ProductionTab(state = state, vm = vm)
|
||||||
StoryForgeTab.Mine -> MineTab(state = state, vm = vm, onInstallLatestUpdate = onInstallLatestUpdate)
|
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 "已连接") {
|
SectionCard(title = "连接状态", subtitle = if (state.busy) "正在同步" else "已连接") {
|
||||||
Text(
|
Text(
|
||||||
text = if (state.originalHost.isNotBlank()) {
|
text = if (state.originalHost.isNotBlank()) {
|
||||||
"外网域名已解析为 ${state.resolvedIp},请求会携带 Host=${state.originalHost}"
|
"当前请求会保留 Host=${state.originalHost},解析 IP=${state.resolvedIp.ifBlank { "未解析" }}"
|
||||||
} else {
|
} else {
|
||||||
"当前使用地址:${state.baseUrl}"
|
"当前地址:${state.baseUrl}"
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodySmall
|
style = MaterialTheme.typography.bodySmall
|
||||||
)
|
)
|
||||||
@@ -289,135 +326,292 @@ private fun StatusStrip(state: StoryForgeUiState, onRefresh: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ExploreTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onPickVideo: () -> Unit) {
|
private fun OverviewTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
|
||||||
SectionCard(title = "素材入口", subtitle = "视频链接、上传视频、输入文字都会转成文本并做风格分析") {
|
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(
|
ChoiceRow(
|
||||||
options = listOf(
|
options = listOf(
|
||||||
"视频链接" to (state.exploreInputMode == ExploreInputMode.VideoLink),
|
"主页" to (state.exploreInputMode == ExploreInputMode.ContentSource),
|
||||||
"上传视频" to (state.exploreInputMode == ExploreInputMode.UploadVideo),
|
"视频" to (state.exploreInputMode == ExploreInputMode.VideoLink),
|
||||||
"输入文字" to (state.exploreInputMode == ExploreInputMode.Text)
|
"上传" to (state.exploreInputMode == ExploreInputMode.UploadVideo),
|
||||||
|
"文本" to (state.exploreInputMode == ExploreInputMode.Text)
|
||||||
),
|
),
|
||||||
onSelect = { label ->
|
onSelect = { label ->
|
||||||
vm.setExploreInputMode(
|
vm.setExploreInputMode(
|
||||||
when (label) {
|
when (label) {
|
||||||
"视频链接" -> ExploreInputMode.VideoLink
|
"主页" -> ExploreInputMode.ContentSource
|
||||||
"上传视频" -> ExploreInputMode.UploadVideo
|
"视频" -> ExploreInputMode.VideoLink
|
||||||
|
"上传" -> ExploreInputMode.UploadVideo
|
||||||
else -> ExploreInputMode.Text
|
else -> ExploreInputMode.Text
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
KnowledgeBaseSelector(state = state, onSelect = vm::selectKnowledgeBase)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
AssistantSelector(state = state, onSelect = vm::selectAssistant)
|
AssistantSelector(state = state, onSelect = vm::selectAssistant)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Text(
|
KnowledgeBaseSelector(state = state, onSelect = vm::selectKnowledgeBase)
|
||||||
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)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
when (state.exploreInputMode) {
|
BenchmarkInputPanel(state = state, vm = vm, onPickVideo = onPickVideo)
|
||||||
ExploreInputMode.VideoLink -> {
|
}
|
||||||
OutlinedTextField(
|
|
||||||
value = state.videoUrl,
|
SectionCard(title = "对标池", subtitle = "已经导入的任务和沉淀素材会先堆在这里。") {
|
||||||
onValueChange = vm::updateVideoUrl,
|
if (state.jobs.isEmpty() && state.documents.isEmpty()) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Text("先导入一个主页或作品,这里会开始形成你的学习池。")
|
||||||
label = { Text("短视频链接") },
|
} else {
|
||||||
minLines = 2
|
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))
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
OutlinedTextField(
|
|
||||||
value = state.videoTitle,
|
|
||||||
onValueChange = vm::updateVideoTitle,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
label = { Text("素材标题(可选)") },
|
|
||||||
singleLine = true
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Button(onClick = vm::submitVideoLink, enabled = !state.busy) {
|
|
||||||
Text("提交视频链接")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ExploreInputMode.UploadVideo -> {
|
state.documents.take(2).forEach { document ->
|
||||||
OutlinedTextField(
|
MiniCard(
|
||||||
value = state.videoTitle,
|
title = document.title,
|
||||||
onValueChange = vm::updateVideoTitle,
|
subtitle = document.style_summary.ifBlank { document.transcript_text.take(48) }
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
label = { Text("素材标题(可选)") },
|
|
||||||
singleLine = true
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
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(
|
|
||||||
text = if (state.pickedVideoName.isBlank()) "未选择文件" else state.pickedVideoName,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Button(onClick = vm::submitUploadVideo, enabled = !state.busy && state.pickedVideoName.isNotBlank()) {
|
|
||||||
Text("上传并开始学习")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ExploreInputMode.Text -> {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.textTitle,
|
|
||||||
onValueChange = vm::updateTextTitle,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
label = { Text("素材标题") },
|
|
||||||
singleLine = true
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.textContent,
|
|
||||||
onValueChange = vm::updateTextContent,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
label = { Text("素材文字") },
|
|
||||||
minLines = 5
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Button(onClick = vm::submitText, enabled = !state.busy) {
|
|
||||||
Text("分析并沉淀到知识库")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.latestJob?.let { latestJob ->
|
state.latestJob?.let { latest ->
|
||||||
SectionCard(title = "最新任务", subtitle = latestJob.title) {
|
SectionCard(title = "参考详情", subtitle = latest.title) {
|
||||||
KeyValueRow(label = "状态", value = latestJob.status)
|
KeyValueRow(label = "状态", value = latest.status)
|
||||||
KeyValueRow(label = "上传状态", value = latestJob.upload_status)
|
KeyValueRow(label = "工作流", value = latest.workflow_key.ifBlank { latest.line_type.ifBlank { "-" } })
|
||||||
if (latestJob.transcript_text.isNotBlank()) {
|
if (latest.transcript_text.isNotBlank()) {
|
||||||
KeyValueBlock(label = "文本转写", value = latestJob.transcript_text)
|
KeyValueBlock(label = "文本转写", value = latest.transcript_text)
|
||||||
}
|
}
|
||||||
if (latestJob.style_summary.isNotBlank()) {
|
if (latest.style_summary.isNotBlank()) {
|
||||||
KeyValueBlock(label = "风格提炼", value = latestJob.style_summary)
|
KeyValueBlock(label = "学习摘要", value = latest.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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ProductionTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
|
private fun BenchmarkInputPanel(
|
||||||
SectionCard(title = "智能体列表", subtitle = "一个智能体默认关联一个知识库,也可以关联多个知识库") {
|
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))
|
||||||
|
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("作品链接") },
|
||||||
|
minLines = 2
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.videoTitle,
|
||||||
|
onValueChange = vm::updateVideoTitle,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("作品标题,可选") },
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
ActionRow(
|
||||||
|
actions = listOf(
|
||||||
|
"手动导入" to vm::submitVideoLink,
|
||||||
|
"交给 Agent" to vm::submitVideoLink
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ExploreInputMode.UploadVideo -> {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.videoTitle,
|
||||||
|
onValueChange = vm::updateVideoTitle,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
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(
|
||||||
|
text = if (state.pickedVideoName.isBlank()) "未选择文件" else state.pickedVideoName,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
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("标题") },
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.textContent,
|
||||||
|
onValueChange = vm::updateTextContent,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("正文") },
|
||||||
|
minLines = 5
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
ActionRow(
|
||||||
|
actions = listOf(
|
||||||
|
"手动导入" to vm::submitText,
|
||||||
|
"交给 Agent" to vm::submitText
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgentTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
|
||||||
|
SectionCard(title = "Agent 列表", subtitle = "一个 Agent 可以学习多个知识库,并服务多个平台。") {
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
options = state.assistants.map { it.name to (state.selectedAssistantId == it.id) },
|
options = state.assistants.map { it.name to (state.selectedAssistantId == it.id) },
|
||||||
onSelect = { label ->
|
onSelect = { label ->
|
||||||
@@ -426,16 +620,16 @@ private fun ProductionTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
OutlinedButton(onClick = vm::startNewAssistant) {
|
OutlinedButton(onClick = vm::startNewAssistant) {
|
||||||
Text("新建智能体")
|
Text("新建 Agent")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SectionCard(title = "编辑智能体", subtitle = "提示词由用户提供,可随时调整模型和知识库绑定") {
|
SectionCard(title = "Agent 定义", subtitle = "先定义账号方向、变现方式和主模型,再决定学习哪些知识库。") {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.assistantName,
|
value = state.assistantName,
|
||||||
onValueChange = vm::updateAssistantName,
|
onValueChange = vm::updateAssistantName,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text("智能体名称") },
|
label = { Text("Agent 名称") },
|
||||||
singleLine = true
|
singleLine = true
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
@@ -443,7 +637,7 @@ private fun ProductionTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
|
|||||||
value = state.assistantDescription,
|
value = state.assistantDescription,
|
||||||
onValueChange = vm::updateAssistantDescription,
|
onValueChange = vm::updateAssistantDescription,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text("智能体说明") },
|
label = { Text("账号方向 / 变现方式") },
|
||||||
minLines = 2
|
minLines = 2
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
@@ -459,11 +653,25 @@ private fun ProductionTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
|
|||||||
value = state.assistantGenerationGoal,
|
value = state.assistantGenerationGoal,
|
||||||
onValueChange = vm::updateAssistantGenerationGoal,
|
onValueChange = vm::updateAssistantGenerationGoal,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text("生成目标") },
|
label = { Text("Agent 目标") },
|
||||||
minLines = 3
|
minLines = 3
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
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))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
options = state.modelProfiles.map { it.name to (state.assistantModelProfileId == it.id) },
|
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))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Text("选择要关联的知识库", style = MaterialTheme.typography.titleSmall)
|
Text("学习知识库", style = MaterialTheme.typography.titleSmall)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
options = state.knowledgeBases.map { it.name to state.selectedAssistantKnowledgeBaseIds.contains(it.id) },
|
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))
|
Spacer(modifier = Modifier.height(14.dp))
|
||||||
Button(onClick = vm::saveAssistant, enabled = !state.busy) {
|
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(
|
OutlinedTextField(
|
||||||
value = state.generationBrief,
|
value = state.generationBrief,
|
||||||
onValueChange = vm::updateGenerationBrief,
|
onValueChange = vm::updateGenerationBrief,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text("文案需求") },
|
label = { Text("本轮调研或文案需求") },
|
||||||
minLines = 4
|
minLines = 4
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
@@ -500,14 +708,14 @@ private fun ProductionTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
|
|||||||
value = state.generationPlatform,
|
value = state.generationPlatform,
|
||||||
onValueChange = vm::updateGenerationPlatform,
|
onValueChange = vm::updateGenerationPlatform,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
label = { Text("平台") },
|
label = { Text("主平台") },
|
||||||
singleLine = true
|
singleLine = true
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.generationAudience,
|
value = state.generationAudience,
|
||||||
onValueChange = vm::updateGenerationAudience,
|
onValueChange = vm::updateGenerationAudience,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
label = { Text("目标受众") },
|
label = { Text("人群") },
|
||||||
singleLine = true
|
singleLine = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -524,12 +732,72 @@ private fun ProductionTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
|
|||||||
if (state.generateBusy) {
|
if (state.generateBusy) {
|
||||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||||
} else {
|
} else {
|
||||||
Text("开始生成")
|
Text("开始试跑")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (state.generationOutput.isNotBlank()) {
|
if (state.generationOutput.isNotBlank()) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
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?.username ?: "-")
|
||||||
KeyValueRow(label = "角色", value = state.account?.role ?: "-")
|
KeyValueRow(label = "角色", value = state.account?.role ?: "-")
|
||||||
KeyValueRow(label = "审批", value = state.account?.approval_status ?: "-")
|
KeyValueRow(label = "审批", value = state.account?.approval_status ?: "-")
|
||||||
KeyValueRow(label = "Base URL", value = state.baseUrl)
|
KeyValueRow(label = "地址", value = state.baseUrl)
|
||||||
if (state.resolvedIp.isNotBlank()) {
|
if (state.resolvedIp.isNotBlank()) {
|
||||||
KeyValueRow(label = "解析 IP", value = state.resolvedIp)
|
KeyValueRow(label = "解析 IP", value = state.resolvedIp)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
OutlinedButton(onClick = vm::logout) {
|
OutlinedButton(onClick = vm::logout) {
|
||||||
Text("退出登录")
|
Text("退出")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SectionCard(title = "分析模型", subtitle = "探索页默认使用这里选中的模型") {
|
SectionCard(title = "分析模型", subtitle = "用户不管 Key,只切主模型和默认分析模型。") {
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
options = state.modelProfiles.map { it.name to (state.account?.preferred_analysis_model_id == it.id) },
|
options = state.modelProfiles.map { it.name to (state.account?.preferred_analysis_model_id == it.id) },
|
||||||
onSelect = { label ->
|
onSelect = { label ->
|
||||||
@@ -593,22 +861,22 @@ private fun MineTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onInstall
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Button(onClick = vm::createModelProfile) {
|
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) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Button(onClick = vm::checkForUpdates) {
|
Button(onClick = vm::checkForUpdates) {
|
||||||
Text("检查更新")
|
Text("检查")
|
||||||
}
|
}
|
||||||
OutlinedButton(onClick = onInstallLatestUpdate, enabled = state.otaInfo?.hasUpdate == true) {
|
OutlinedButton(onClick = onInstallLatestUpdate, enabled = state.otaInfo?.hasUpdate == true) {
|
||||||
Text("安装最新版本")
|
Text("安装")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state.otaInfo?.let { ota ->
|
state.otaInfo?.let { ota ->
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
KeyValueRow(label = "最新版本", value = "${ota.latestVersionName} (${ota.latestVersionCode})")
|
KeyValueRow(label = "版本", value = "${ota.latestVersionName} (${ota.latestVersionCode})")
|
||||||
if (ota.releaseNotes.isNotBlank()) {
|
if (ota.releaseNotes.isNotBlank()) {
|
||||||
KeyValueBlock(label = "更新说明", value = ota.releaseNotes)
|
KeyValueBlock(label = "更新说明", value = ota.releaseNotes)
|
||||||
}
|
}
|
||||||
@@ -616,13 +884,18 @@ private fun MineTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onInstall
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.account?.role == "super_admin") {
|
if (state.account?.role == "super_admin") {
|
||||||
SectionCard(title = "主管理员审批", subtitle = "新注册账号需要你审批后才能正常使用全部功能") {
|
SectionCard(title = "审批", subtitle = "主管理员审批新用户。") {
|
||||||
if (state.pendingAccounts.isEmpty()) {
|
if (state.pendingAccounts.isEmpty()) {
|
||||||
Text("当前没有待审批账号")
|
Text("当前没有待审批账号。")
|
||||||
} else {
|
} else {
|
||||||
state.pendingAccounts.forEach { account ->
|
state.pendingAccounts.forEach { account ->
|
||||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
|
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.display_name, fontWeight = FontWeight.Bold)
|
||||||
Text(account.username, style = MaterialTheme.typography.bodySmall)
|
Text(account.username, style = MaterialTheme.typography.bodySmall)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
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 状态") {
|
SectionCard(title = "最近日志", subtitle = "用来确认审批、解析、任务和 OTA 状态。") {
|
||||||
state.timeline.forEach { item ->
|
state.timeline.take(8).forEach { item ->
|
||||||
Text(item, style = MaterialTheme.typography.bodySmall)
|
Text(item, style = MaterialTheme.typography.bodySmall)
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
}
|
}
|
||||||
@@ -707,7 +928,10 @@ private fun ChoiceRow(
|
|||||||
options: List<Pair<String, Boolean>>,
|
options: List<Pair<String, Boolean>>,
|
||||||
onSelect: (String) -> Unit
|
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) ->
|
options.forEach { (label, selected) ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = selected,
|
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
|
@Composable
|
||||||
private fun KnowledgeBaseSelector(state: StoryForgeUiState, onSelect: (String) -> Unit) {
|
private fun KnowledgeBaseSelector(state: StoryForgeUiState, onSelect: (String) -> Unit) {
|
||||||
Text("选择知识库", style = MaterialTheme.typography.titleSmall)
|
Text("选择知识库", style = MaterialTheme.typography.titleSmall)
|
||||||
@@ -732,7 +1004,7 @@ private fun KnowledgeBaseSelector(state: StoryForgeUiState, onSelect: (String) -
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AssistantSelector(state: StoryForgeUiState, onSelect: (String) -> Unit) {
|
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))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
options = state.assistants.map { it.name to (state.selectedAssistantId == it.id) },
|
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()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(28.dp))
|
.clip(RoundedCornerShape(28.dp))
|
||||||
.background(heroBrush)
|
.background(heroBrush)
|
||||||
|
.border(
|
||||||
|
width = 1.dp,
|
||||||
|
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f),
|
||||||
|
shape = RoundedCornerShape(28.dp)
|
||||||
|
)
|
||||||
.padding(20.dp)
|
.padding(20.dp)
|
||||||
) {
|
) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
Text(title, style = MaterialTheme.typography.headlineLarge, color = Color.White)
|
Text(title, style = MaterialTheme.typography.headlineLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||||
Text(subtitle, style = MaterialTheme.typography.bodyLarge, color = Color(0xFFF8F5EF))
|
Text(subtitle, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.74f))
|
||||||
if (badges.isNotEmpty()) {
|
if (badges.isNotEmpty()) {
|
||||||
ChoiceRow(options = badges.map { it to true }, onSelect = {})
|
ChoiceRow(options = badges.map { it to true }, onSelect = {})
|
||||||
}
|
}
|
||||||
@@ -779,7 +1056,7 @@ private fun SectionCard(title: String, subtitle: String, content: @Composable ()
|
|||||||
Text(
|
Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
@@ -809,7 +1086,11 @@ private fun KeyValueBlock(label: String, value: String) {
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.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)
|
.padding(14.dp)
|
||||||
) {
|
) {
|
||||||
Text(value)
|
Text(value)
|
||||||
@@ -818,10 +1099,20 @@ private fun KeyValueBlock(label: String, value: String) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MiniCard(title: String, subtitle: String) {
|
private fun MiniCard(title: String, subtitle: String) {
|
||||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
|
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.58f))) {
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(14.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
Text(title, fontWeight = FontWeight.Bold)
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import kotlinx.coroutines.launch
|
|||||||
import retrofit2.HttpException
|
import retrofit2.HttpException
|
||||||
|
|
||||||
enum class StoryForgeTab {
|
enum class StoryForgeTab {
|
||||||
Explore,
|
Overview,
|
||||||
|
Benchmark,
|
||||||
|
Agent,
|
||||||
Production,
|
Production,
|
||||||
Mine
|
Mine
|
||||||
}
|
}
|
||||||
@@ -26,6 +28,7 @@ enum class StoryForgeAuthMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum class ExploreInputMode {
|
enum class ExploreInputMode {
|
||||||
|
ContentSource,
|
||||||
VideoLink,
|
VideoLink,
|
||||||
UploadVideo,
|
UploadVideo,
|
||||||
Text
|
Text
|
||||||
@@ -52,7 +55,7 @@ data class StoryForgeUiState(
|
|||||||
val originalHost: String = "",
|
val originalHost: String = "",
|
||||||
val isAuthenticated: Boolean = false,
|
val isAuthenticated: Boolean = false,
|
||||||
val isApproved: Boolean = false,
|
val isApproved: Boolean = false,
|
||||||
val currentTab: StoryForgeTab = StoryForgeTab.Explore,
|
val currentTab: StoryForgeTab = StoryForgeTab.Overview,
|
||||||
val busy: Boolean = false,
|
val busy: Boolean = false,
|
||||||
val generateBusy: Boolean = false,
|
val generateBusy: Boolean = false,
|
||||||
val statusMessage: String = "准备连接 StoryForge",
|
val statusMessage: String = "准备连接 StoryForge",
|
||||||
@@ -72,6 +75,13 @@ data class StoryForgeUiState(
|
|||||||
val createKnowledgeBaseName: String = "",
|
val createKnowledgeBaseName: String = "",
|
||||||
val createKnowledgeBaseDescription: String = "",
|
val createKnowledgeBaseDescription: String = "",
|
||||||
val exploreInputMode: ExploreInputMode = ExploreInputMode.VideoLink,
|
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 videoUrl: String = "",
|
||||||
val videoTitle: String = "",
|
val videoTitle: String = "",
|
||||||
val textTitle: String = "",
|
val textTitle: String = "",
|
||||||
@@ -155,6 +165,35 @@ class StoryForgeViewModel(application: Application) : AndroidViewModel(applicati
|
|||||||
_state.value = _state.value.copy(videoUrl = value)
|
_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) {
|
fun updateVideoTitle(value: String) {
|
||||||
_state.value = _state.value.copy(videoTitle = value)
|
_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() {
|
fun submitText() {
|
||||||
val current = state.value
|
val current = state.value
|
||||||
if (current.textTitle.isBlank() || current.textContent.isBlank()) {
|
if (current.textTitle.isBlank() || current.textContent.isBlank()) {
|
||||||
@@ -773,7 +849,7 @@ class StoryForgeViewModel(application: Application) : AndroidViewModel(applicati
|
|||||||
_state.value = state.value.copy(
|
_state.value = state.value.copy(
|
||||||
latestJob = job,
|
latestJob = job,
|
||||||
latestJobId = job.id,
|
latestJobId = job.id,
|
||||||
currentTab = StoryForgeTab.Explore
|
currentTab = StoryForgeTab.Benchmark
|
||||||
)
|
)
|
||||||
refreshWorkspace()
|
refreshWorkspace()
|
||||||
startJobPolling(job.id)
|
startJobPolling(job.id)
|
||||||
|
|||||||
@@ -13,51 +13,82 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
private val LightColors = lightColorScheme(
|
private val LightColors = lightColorScheme(
|
||||||
primary = Color(0xFF0E4B43),
|
primary = Color(0xFF4E89F5),
|
||||||
secondary = Color(0xFF9C6427),
|
secondary = Color(0xFF87AEEB),
|
||||||
tertiary = Color(0xFF2A5B8A),
|
tertiary = Color(0xFF17283A),
|
||||||
background = Color(0xFFF7F3EC),
|
background = Color(0xFFF2F7FF),
|
||||||
surface = Color(0xFFFFFCF8),
|
surface = Color(0xFFFFFFFF),
|
||||||
|
surfaceVariant = Color(0xFFEAF2FF),
|
||||||
onPrimary = Color.White,
|
onPrimary = Color.White,
|
||||||
onSecondary = Color.White,
|
onSecondary = Color.White,
|
||||||
onBackground = Color(0xFF1A1713),
|
onBackground = Color(0xFF152332),
|
||||||
onSurface = Color(0xFF1A1713)
|
onSurface = Color(0xFF152332),
|
||||||
|
outline = Color(0xFFC9D8EA)
|
||||||
)
|
)
|
||||||
|
|
||||||
private val DarkColors = darkColorScheme(
|
private val DarkColors = darkColorScheme(
|
||||||
primary = Color(0xFF7FD6C7),
|
primary = Color(0xFF8CB7FF),
|
||||||
secondary = Color(0xFFFFC27A),
|
secondary = Color(0xFF7EA5DE),
|
||||||
tertiary = Color(0xFF98C7FF),
|
tertiary = Color(0xFFE6EEF9),
|
||||||
background = Color(0xFF101714),
|
background = Color(0xFF101823),
|
||||||
surface = Color(0xFF18211D),
|
surface = Color(0xFF162131),
|
||||||
onPrimary = Color(0xFF062D29),
|
surfaceVariant = Color(0xFF1D2B3D),
|
||||||
onSecondary = Color(0xFF4B2B00),
|
onPrimary = Color(0xFF0C1B30),
|
||||||
onBackground = Color(0xFFF0E8DB),
|
onSecondary = Color(0xFF0C1B30),
|
||||||
onSurface = Color(0xFFF0E8DB)
|
onBackground = Color(0xFFEAF1FB),
|
||||||
|
onSurface = Color(0xFFEAF1FB),
|
||||||
|
outline = Color(0xFF35506F)
|
||||||
)
|
)
|
||||||
|
|
||||||
private val AppTypography = Typography(
|
private val AppTypography = Typography(
|
||||||
headlineLarge = TextStyle(
|
headlineLarge = TextStyle(
|
||||||
fontFamily = FontFamily.Serif,
|
fontFamily = FontFamily.SansSerif,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontSize = 34.sp,
|
fontSize = 30.sp,
|
||||||
lineHeight = 40.sp
|
lineHeight = 36.sp
|
||||||
|
),
|
||||||
|
headlineMedium = TextStyle(
|
||||||
|
fontFamily = FontFamily.SansSerif,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 26.sp,
|
||||||
|
lineHeight = 32.sp
|
||||||
),
|
),
|
||||||
headlineSmall = TextStyle(
|
headlineSmall = TextStyle(
|
||||||
fontFamily = FontFamily.Serif,
|
fontFamily = FontFamily.SansSerif,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 22.sp,
|
fontSize = 22.sp,
|
||||||
lineHeight = 28.sp
|
lineHeight = 28.sp
|
||||||
),
|
),
|
||||||
|
titleLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.SansSerif,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
lineHeight = 26.sp
|
||||||
|
),
|
||||||
bodyLarge = TextStyle(
|
bodyLarge = TextStyle(
|
||||||
fontFamily = FontFamily.SansSerif,
|
fontFamily = FontFamily.SansSerif,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
lineHeight = 24.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(
|
labelLarge = TextStyle(
|
||||||
fontFamily = FontFamily.SansSerif,
|
fontFamily = FontFamily.SansSerif,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
fontSize = 14.sp
|
fontSize = 14.sp
|
||||||
|
),
|
||||||
|
labelSmall = TextStyle(
|
||||||
|
fontFamily = FontFamily.SansSerif,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 11.sp
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
FROM python:3.11-slim
|
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
|
WORKDIR /app
|
||||||
COPY requirements.txt ./
|
COPY requirements.txt ./
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|||||||
@@ -48,6 +48,18 @@ class Database:
|
|||||||
with self.session() as conn:
|
with self.session() as conn:
|
||||||
conn.execute(sql, params)
|
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:
|
def init_schema(self) -> None:
|
||||||
schema = """
|
schema = """
|
||||||
CREATE TABLE IF NOT EXISTS accounts (
|
CREATE TABLE IF NOT EXISTS accounts (
|
||||||
@@ -90,10 +102,10 @@ class Database:
|
|||||||
CREATE TABLE IF NOT EXISTS knowledge_bases (
|
CREATE TABLE IF NOT EXISTS knowledge_bases (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
|
project_id TEXT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
fastgpt_dataset_id TEXT,
|
sync_status TEXT NOT NULL DEFAULT 'ready',
|
||||||
sync_status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE
|
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE
|
||||||
@@ -108,7 +120,9 @@ class Database:
|
|||||||
transcript_text TEXT NOT NULL DEFAULT '',
|
transcript_text TEXT NOT NULL DEFAULT '',
|
||||||
style_summary TEXT NOT NULL DEFAULT '',
|
style_summary TEXT NOT NULL DEFAULT '',
|
||||||
combined_text 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 '',
|
analysis_model_profile_id TEXT NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
@@ -118,11 +132,12 @@ class Database:
|
|||||||
CREATE TABLE IF NOT EXISTS assistants (
|
CREATE TABLE IF NOT EXISTS assistants (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
|
project_id TEXT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
system_prompt TEXT NOT NULL DEFAULT '',
|
system_prompt TEXT NOT NULL DEFAULT '',
|
||||||
generation_goal 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 '',
|
model_profile_id TEXT NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
@@ -140,19 +155,27 @@ class Database:
|
|||||||
CREATE TABLE IF NOT EXISTS jobs (
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
|
project_id TEXT,
|
||||||
|
parent_job_id TEXT,
|
||||||
assistant_id TEXT,
|
assistant_id TEXT,
|
||||||
knowledge_base_id TEXT NOT NULL,
|
knowledge_base_id TEXT NOT NULL,
|
||||||
|
content_source_id TEXT,
|
||||||
source_type TEXT NOT NULL,
|
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,
|
source_url TEXT,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
language TEXT NOT NULL DEFAULT 'auto',
|
language TEXT NOT NULL DEFAULT 'auto',
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
transcript_text TEXT NOT NULL DEFAULT '',
|
transcript_text TEXT NOT NULL DEFAULT '',
|
||||||
style_summary TEXT NOT NULL DEFAULT '',
|
style_summary TEXT NOT NULL DEFAULT '',
|
||||||
fastgpt_collection_id TEXT NOT NULL DEFAULT '',
|
|
||||||
upload_status TEXT NOT NULL DEFAULT 'pending',
|
upload_status TEXT NOT NULL DEFAULT 'pending',
|
||||||
error TEXT NOT NULL DEFAULT '',
|
error TEXT NOT NULL DEFAULT '',
|
||||||
artifacts_json TEXT NOT NULL DEFAULT '{}',
|
artifacts_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
result_json TEXT NOT NULL DEFAULT '{}',
|
||||||
analysis_model_profile_id TEXT NOT NULL DEFAULT '',
|
analysis_model_profile_id TEXT NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
@@ -161,6 +184,66 @@ class Database:
|
|||||||
FOREIGN KEY(knowledge_base_id) REFERENCES knowledge_bases(id) ON DELETE CASCADE
|
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 publish_reviews (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
project_id TEXT,
|
||||||
|
source_job_id TEXT,
|
||||||
|
assistant_id TEXT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
platform TEXT NOT NULL DEFAULT 'douyin',
|
||||||
|
content_type TEXT NOT NULL DEFAULT 'video',
|
||||||
|
publish_url TEXT NOT NULL DEFAULT '',
|
||||||
|
published_at TEXT NOT NULL DEFAULT '',
|
||||||
|
metrics_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
verdict TEXT NOT NULL DEFAULT '',
|
||||||
|
highlights TEXT NOT NULL DEFAULT '',
|
||||||
|
next_actions TEXT NOT NULL DEFAULT '',
|
||||||
|
notes 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,
|
||||||
|
FOREIGN KEY(source_job_id) REFERENCES jobs(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY(assistant_id) REFERENCES assistants(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 (
|
CREATE TABLE IF NOT EXISTS app_updates (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
platform TEXT NOT NULL,
|
platform TEXT NOT NULL,
|
||||||
@@ -179,3 +262,103 @@ class Database:
|
|||||||
"""
|
"""
|
||||||
with self.session() as conn:
|
with self.session() as conn:
|
||||||
conn.executescript(schema)
|
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"]),
|
||||||
|
)
|
||||||
|
|||||||
3487
collector-service/app/douyin_features.py
Normal 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()
|
|
||||||
217
collector-service/app/integrations.py
Normal 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())
|
||||||
@@ -3,3 +3,4 @@ uvicorn[standard]==0.34.0
|
|||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
pydantic==2.11.1
|
pydantic==2.11.1
|
||||||
|
yt-dlp
|
||||||
|
|||||||
27
concepts/studio-workbench/README.md
Normal 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
|
||||||
426
concepts/studio-workbench/index.html
Normal 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>
|
||||||
65
deploy/cleanup_legacy_fastgpt_runtime.sh
Executable 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"
|
||||||
@@ -1,56 +1,30 @@
|
|||||||
version: "3.9"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
mongo:
|
n8n:
|
||||||
image: mongo:6
|
image: ${N8N_IMAGE:-docker.n8n.io/n8nio/n8n:latest}
|
||||||
container_name: storyforge-mongo
|
container_name: storyforge-n8n
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "27017:27017"
|
|
||||||
volumes:
|
|
||||||
- ./data/mongo:/data/db
|
|
||||||
|
|
||||||
vectorDB:
|
|
||||||
image: pgvector/pgvector:pg16
|
|
||||||
container_name: storyforge-pgvector
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-fastgpt}
|
N8N_HOST: ${N8N_HOST:-0.0.0.0}
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
N8N_PORT: 5678
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
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:
|
ports:
|
||||||
- "5432:5432"
|
- "5670:5678"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/pg:/var/lib/postgresql/data
|
- ./data/n8n:/home/node/.n8n
|
||||||
|
- ./n8n:/workspace/n8n:ro
|
||||||
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
|
|
||||||
|
|
||||||
collector:
|
collector:
|
||||||
build:
|
build:
|
||||||
context: ./collector-service
|
context: ./collector-service
|
||||||
container_name: storyforge-collector
|
container_name: storyforge-collector
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- n8n
|
||||||
environment:
|
environment:
|
||||||
DATA_DIR: /data/collector
|
DATA_DIR: /data/collector
|
||||||
DATABASE_PATH: /data/collector/storyforge.db
|
DATABASE_PATH: /data/collector/storyforge.db
|
||||||
@@ -58,44 +32,47 @@ services:
|
|||||||
LOCAL_OPENAI_BASE_URL: ${LOCAL_OPENAI_BASE_URL:-http://host.docker.internal:8317/v1}
|
LOCAL_OPENAI_BASE_URL: ${LOCAL_OPENAI_BASE_URL:-http://host.docker.internal:8317/v1}
|
||||||
LOCAL_OPENAI_MODEL: ${LOCAL_OPENAI_MODEL:-GLM-5}
|
LOCAL_OPENAI_MODEL: ${LOCAL_OPENAI_MODEL:-GLM-5}
|
||||||
LOCAL_OPENAI_API_KEY: ${LOCAL_OPENAI_API_KEY:-}
|
LOCAL_OPENAI_API_KEY: ${LOCAL_OPENAI_API_KEY:-}
|
||||||
FASTGPT_BASE_URL: ${FASTGPT_BASE_URL:-http://host.docker.internal:3000}
|
N8N_BASE_URL: ${COLLECTOR_N8N_BASE_URL:-http://n8n:5678}
|
||||||
FASTGPT_DATASET_API_KEY: ${FASTGPT_DATASET_API_KEY:-}
|
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}
|
YTDLP_BIN: ${YTDLP_BIN:-yt-dlp}
|
||||||
FFMPEG_BIN: ${FFMPEG_BIN:-ffmpeg}
|
FFMPEG_BIN: ${FFMPEG_BIN:-ffmpeg}
|
||||||
WHISPER_BIN: ${WHISPER_BIN:-}
|
WHISPER_BIN: ${WHISPER_BIN:-}
|
||||||
WHISPER_MODEL: ${WHISPER_MODEL:-/data/collector/models/ggml-base.en.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:
|
ports:
|
||||||
- "8081:8081"
|
- "8081:8081"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/collector:/data/collector
|
- ./data/collector:/data/collector
|
||||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8081
|
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:
|
cli-proxy-api:
|
||||||
image: ${CLIPROXY_IMAGE:-storyforge/cli-proxy-api:patched}
|
image: ${CLIPROXY_IMAGE:-storyforge/cli-proxy-api:patched}
|
||||||
container_name: storyforge-cliproxyapi
|
container_name: storyforge-cliproxyapi
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- ./CLIProxyAPI
|
||||||
|
- -config
|
||||||
|
- /CLIProxyAPI/config.yaml
|
||||||
|
volumes:
|
||||||
|
- ./data/cliproxyapi/config.yaml:/CLIProxyAPI/config.yaml:ro
|
||||||
|
- ./data/cliproxyapi/auths:/root/.cli-proxy-api
|
||||||
|
- ./data/cliproxyapi/logs:/CLIProxyAPI/logs
|
||||||
ports:
|
ports:
|
||||||
- "8317:8317"
|
- "8317:8317"
|
||||||
- "8085:8085"
|
- "8085:8085"
|
||||||
|
|||||||
191
docs/AUDIT_2026-03-18.md
Normal 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 构建,但仍缺少真机安装和功能回归验证
|
||||||
97
docs/IMPLEMENTATION_PLAN_2026-03-18.md
Normal 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` 本机调用
|
||||||
|
|
||||||
|
状态:进行中
|
||||||
312
docs/LAN_E2E_GUIDE_2026-03-18.md
Normal 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/` 做了白名单放行,避免误伤客户端代码
|
||||||
68
docs/MVP_STATUS_2026-03-18.md
Normal 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 切换结果和部署回滚说明继续固化到仓库
|
||||||
555
docs/PRODUCT_LOGIC_NEW_MEDIA_OPERATING_SYSTEM_2026-03-22.md
Normal 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
@@ -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`
|
||||||
70
n8n/workflows/storyforge-ai-video.json
Normal 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"
|
||||||
|
}
|
||||||
70
n8n/workflows/storyforge-analysis.json
Normal 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"
|
||||||
|
}
|
||||||
70
n8n/workflows/storyforge-content-source-sync.json
Normal 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"
|
||||||
|
}
|
||||||
70
n8n/workflows/storyforge-real-cut.json
Normal 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"
|
||||||
|
}
|
||||||
68
output/ui/new-media-ops-reference-2026-03-22/README.md
Normal 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 放在用户主视角,创作者更关心“账号、作品、选题、生产、复盘”
|
||||||
136
output/ui/new-media-ops-reference-2026-03-22/index.html
Normal 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>
|
||||||
|
After Width: | Height: | Size: 596 KiB |
|
After Width: | Height: | Size: 937 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 112 KiB |
@@ -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` 保持一致
|
||||||
|
- 当前重点是验证信息层级、导航、操作入口和视觉节奏
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1916
output/ui/storyforge-web-v4-html-prototype-2026-03-22/index.html
Normal file
|
After Width: | Height: | Size: 488 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 363 KiB |
|
After Width: | Height: | Size: 659 KiB |
|
After Width: | Height: | Size: 577 KiB |
|
After Width: | Height: | Size: 482 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 587 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 442 KiB |
|
After Width: | Height: | Size: 486 KiB |
|
After Width: | Height: | Size: 302 KiB |
@@ -1,7 +1,6 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
cd /Users/kris/code/StoryForge/collector-service
|
|
||||||
python3 -m venv .venv
|
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||||
. .venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
"$ROOT/scripts/start_business.sh"
|
||||||
uvicorn app.main:app --host 0.0.0.0 --port 8081 --reload
|
|
||||||
|
|||||||
39
scripts/cleanup_debug_ui.sh
Executable 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"
|
||||||
75
scripts/douyin-browser-capture/README.md
Normal 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`.
|
||||||
855
scripts/douyin-browser-capture/capture_and_sync.mjs
Normal 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(/&/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;
|
||||||
|
});
|
||||||
2288
scripts/douyin-browser-capture/control_panel.mjs
Normal file
59
scripts/douyin-browser-capture/package-lock.json
generated
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
scripts/douyin-browser-capture/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
73
scripts/render_cliproxy_config.sh
Executable file
@@ -0,0 +1,73 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||||
|
|
||||||
|
if [ -f "$ROOT/.env" ]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. "$ROOT/.env"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
DATA_DIR="$ROOT/data/cliproxyapi"
|
||||||
|
CONFIG_PATH="$DATA_DIR/config.yaml"
|
||||||
|
mkdir -p "$DATA_DIR/auths" "$DATA_DIR/logs"
|
||||||
|
|
||||||
|
: "${CLIPROXY_MANAGEMENT_SECRET:=storyforge-local-management}"
|
||||||
|
: "${CLIPROXY_DASHSCOPE_BASE_URL:=https://dashscope.aliyuncs.com/compatible-mode/v1}"
|
||||||
|
|
||||||
|
python3 - <<'PY' "$CONFIG_PATH" "$CLIPROXY_MANAGEMENT_SECRET" "$CLIPROXY_DASHSCOPE_BASE_URL"
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
config_path = Path(sys.argv[1])
|
||||||
|
management_secret = sys.argv[2]
|
||||||
|
base_url = sys.argv[3]
|
||||||
|
dashscope_api_key = os.environ.get("DASHSCOPE_API_KEY", "").strip()
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
'host: ""',
|
||||||
|
'port: 8317',
|
||||||
|
'tls:',
|
||||||
|
' enable: false',
|
||||||
|
' cert: ""',
|
||||||
|
' key: ""',
|
||||||
|
'remote-management:',
|
||||||
|
' allow-remote: false',
|
||||||
|
f' secret-key: "{management_secret}"',
|
||||||
|
' disable-control-panel: false',
|
||||||
|
'auth-dir: "/root/.cli-proxy-api"',
|
||||||
|
'debug: false',
|
||||||
|
'logging-to-file: true',
|
||||||
|
'logs-max-total-size-mb: 200',
|
||||||
|
'usage-statistics-enabled: true',
|
||||||
|
'request-retry: 2',
|
||||||
|
]
|
||||||
|
|
||||||
|
if dashscope_api_key:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
'openai-compatibility:',
|
||||||
|
' - name: "dashscope"',
|
||||||
|
f' base-url: "{base_url}"',
|
||||||
|
' api-key-entries:',
|
||||||
|
f' - api-key: "{dashscope_api_key}"',
|
||||||
|
' models:',
|
||||||
|
' - name: "glm-5"',
|
||||||
|
' alias: "GLM-5"',
|
||||||
|
' - name: "glm-5"',
|
||||||
|
' alias: "glm-5"',
|
||||||
|
' - name: "qwen3.5-plus"',
|
||||||
|
' alias: "qwen3.5-plus"',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
if dashscope_api_key:
|
||||||
|
print(f"rendered cliproxy config with DashScope upstream -> {config_path}")
|
||||||
|
else:
|
||||||
|
print(f"rendered cliproxy config without upstream credentials -> {config_path}")
|
||||||
|
PY
|
||||||
58
scripts/smoke_business.sh
Executable 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
|
||||||
48
scripts/start_business.sh
Executable file
@@ -0,0 +1,48 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||||
|
COMPOSE_FILE="$ROOT/docker-compose.yml"
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
"$ROOT/scripts/render_cliproxy_config.sh"
|
||||||
|
|
||||||
|
OWNER="$(docker inspect storyforge-cliproxyapi --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}' 2>/dev/null || true)"
|
||||||
|
if [ -n "$OWNER" ] && [ "$OWNER" != "$ROOT" ]; then
|
||||||
|
docker rm -f storyforge-cliproxyapi >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d --build collector n8n cli-proxy-api
|
||||||
|
|
||||||
|
python3 - <<'PY'
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
checks = [
|
||||||
|
("collector", "http://127.0.0.1:8081/healthz"),
|
||||||
|
("n8n", "http://127.0.0.1:5670/healthz"),
|
||||||
|
("cli-proxy-api", "http://127.0.0.1:8317/v1/models"),
|
||||||
|
]
|
||||||
|
|
||||||
|
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"
|
||||||
|
echo "cli-proxy-api: http://127.0.0.1:8317/v1/models"
|
||||||
@@ -1,25 +1,28 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
ROOT="/Users/kris/code/StoryForge"
|
|
||||||
PID_FILE="$ROOT/data/collector/collector.pid"
|
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||||
LOG_FILE="$ROOT/data/collector/collector.log"
|
COMPOSE_FILE="$ROOT/docker-compose.yml"
|
||||||
VENV="$ROOT/collector-service/.venv311"
|
|
||||||
mkdir -p "$ROOT/data/collector"
|
cd "$ROOT"
|
||||||
if [ ! -x "$VENV/bin/python" ]; then
|
docker compose -f "$COMPOSE_FILE" up -d --build collector
|
||||||
/opt/homebrew/bin/python3.11 -m venv "$VENV"
|
|
||||||
. "$VENV/bin/activate"
|
python3 - <<'PY'
|
||||||
pip install -q -r "$ROOT/collector-service/requirements.txt"
|
import time
|
||||||
else
|
import urllib.request
|
||||||
. "$VENV/bin/activate"
|
|
||||||
fi
|
url = "http://127.0.0.1:8081/healthz"
|
||||||
if [ -f "$PID_FILE" ]; then
|
deadline = time.time() + 30
|
||||||
PID="$(cat "$PID_FILE" || true)"
|
last_error = ""
|
||||||
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
|
while time.time() < deadline:
|
||||||
echo "collector already running: $PID"
|
try:
|
||||||
exit 0
|
with urllib.request.urlopen(url, timeout=5) as resp:
|
||||||
fi
|
print(f"collector ready: {resp.status} {resp.read().decode('utf-8', 'ignore')[:160]}")
|
||||||
fi
|
raise SystemExit(0)
|
||||||
cd "$ROOT/collector-service"
|
except Exception as exc:
|
||||||
nohup "$VENV/bin/python" -m uvicorn app.main:app --host 0.0.0.0 --port 8081 >"$LOG_FILE" 2>&1 &
|
last_error = str(exc)
|
||||||
echo $! > "$PID_FILE"
|
time.sleep(1)
|
||||||
echo "collector started: $(cat "$PID_FILE")"
|
|
||||||
|
print(f"collector start timeout: {last_error}")
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
|||||||
38
scripts/start_douyin_workbench.sh
Executable 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
|
||||||
23
scripts/status_business.sh
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/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"),
|
||||||
|
("cli-proxy-api", "http://127.0.0.1:8317/v1/models"),
|
||||||
|
]:
|
||||||
|
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
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
PID_FILE="/Users/kris/code/StoryForge/data/collector/collector.pid"
|
|
||||||
if [ -f "$PID_FILE" ]; then
|
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||||
PID="$(cat "$PID_FILE" || true)"
|
COMPOSE_FILE="$ROOT/docker-compose.yml"
|
||||||
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
|
|
||||||
echo "running:$PID"
|
cd "$ROOT"
|
||||||
exit 0
|
docker compose -f "$COMPOSE_FILE" ps collector
|
||||||
fi
|
echo "---"
|
||||||
fi
|
python3 - <<'PY'
|
||||||
if lsof -nP -iTCP:8081 -sTCP:LISTEN >/dev/null 2>&1; then
|
import urllib.request
|
||||||
echo "running:port"
|
|
||||||
else
|
url = "http://127.0.0.1:8081/healthz"
|
||||||
echo "stopped"
|
try:
|
||||||
fi
|
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
|
||||||
|
|||||||
23
scripts/status_douyin_workbench.sh
Executable 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
|
||||||
@@ -1,15 +1,9 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
PID_FILE="/Users/kris/code/StoryForge/data/collector/collector.pid"
|
|
||||||
if [ ! -f "$PID_FILE" ]; then
|
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||||
echo "collector not running"
|
COMPOSE_FILE="$ROOT/docker-compose.yml"
|
||||||
exit 0
|
|
||||||
fi
|
cd "$ROOT"
|
||||||
PID="$(cat "$PID_FILE" || true)"
|
docker compose -f "$COMPOSE_FILE" stop collector
|
||||||
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
|
echo "collector stopped"
|
||||||
kill "$PID"
|
|
||||||
echo "collector stopped: $PID"
|
|
||||||
else
|
|
||||||
echo "collector pid stale: $PID"
|
|
||||||
fi
|
|
||||||
rm -f "$PID_FILE"
|
|
||||||
|
|||||||
9
scripts/stop_douyin_workbench.sh
Executable 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"
|
||||||
95
web/storyforge-web-v4/README.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# 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/douyin/tracking/accounts`
|
||||||
|
- 跟踪日报 `/v2/douyin/tracking/digest`
|
||||||
|
- 发布复盘 `/v2/reviews`
|
||||||
|
- 集成健康 `/v2/integrations/health`
|
||||||
|
- 最近知识库文档 `/v2/knowledge-bases/{id}/documents`
|
||||||
|
|
||||||
|
## 当前已接入的真实动作
|
||||||
|
|
||||||
|
- 新建项目
|
||||||
|
- 导入主页并触发内容源同步
|
||||||
|
- 把当前对标账号直接导入到当前项目,并绑定 Agent 触发同步
|
||||||
|
- 导入作品链接并触发分析
|
||||||
|
- 导入文本素材并触发分析
|
||||||
|
- 上传本地视频并触发分析
|
||||||
|
- 创建 Agent
|
||||||
|
- 对当前 Douyin 对标账号重跑分析
|
||||||
|
- 批量分析高分作品
|
||||||
|
- 查找相似对标账号
|
||||||
|
- 从相似候选一键保存对标关系
|
||||||
|
- 把当前对标账号加入跟踪,并绑定 Agent
|
||||||
|
- 单账号立即同步跟踪对象
|
||||||
|
- 批量同步全部跟踪对象
|
||||||
|
- 日报手动标记已读,不再在刷新页面时自动吞掉未读摘要
|
||||||
|
- 按上次打开后生成跟踪日报与借鉴点摘要
|
||||||
|
- 查看任务详情、事件、子任务和 artifacts/result
|
||||||
|
- 从任务详情直接衔接 AI 视频 / 实拍剪辑 / 文案生成
|
||||||
|
- 在生产中心 / 发布与复盘常驻最近一次任务详情摘要
|
||||||
|
- 在 Web 中直接创建和编辑复盘
|
||||||
|
- 在页面里直接看到 `本机模型 / cutvideo / huobao / n8n / ASR` 的真实健康状态
|
||||||
|
- 依赖不可达时,自动拦住 AI 视频 / 实拍剪辑动作并展示原因
|
||||||
|
- 使用 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`
|
||||||
|
|
||||||
|
## 后续建议
|
||||||
|
|
||||||
|
- 继续补多平台真实接入,而不只是一套 Douyin 工作流
|
||||||
|
- 把对标导入后的 Agent 绑定和知识库入库反馈做得更完整
|
||||||
|
- 把跟踪日报从 Douyin 扩到多平台统一模型,并接入真正的定时调度
|
||||||
|
- 把全局搜索和页内搜索合并成统一搜索体验
|
||||||
|
- 为 `生产中心 / 发布与复盘` 接入更完整的成片预览与封面对象
|
||||||
|
- 不要把这套页面重新塞回 `scripts/douyin-browser-capture/control_panel.mjs`
|
||||||
|
- 抖音采集控制台仍作为独立工具存在,这里才是正式业务应用壳
|
||||||
3309
web/storyforge-web-v4/assets/app.js
Normal file
11
web/storyforge-web-v4/assets/favicon.svg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="sfg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#dff4ff"/>
|
||||||
|
<stop offset="100%" stop-color="#86cfff"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="4" y="4" width="56" height="56" rx="16" fill="url(#sfg)"/>
|
||||||
|
<path d="M19 23.5c0-2.5 2-4.5 4.5-4.5h17c1.8 0 3.2 1.4 3.2 3.2 0 1.6-1.1 3-2.7 3.2l-10.8 1.6c-1 .1-1.7 1-1.7 2 0 1.1.9 2 2 2h8.2c3.5 0 6.3 2.8 6.3 6.3 0 4.2-3.4 7.7-7.7 7.7H21.2v-5.8h16.2c1 0 1.8-.8 1.8-1.8s-.8-1.8-1.8-1.8h-8.5c-4 0-7.3-3.3-7.3-7.3 0-3.6 2.6-6.7 6.2-7.2l9-1.3H19v-5.5Z" fill="#0f172a"/>
|
||||||
|
<path d="M18 44.8h9.1l-4.6-7.6H18v7.6Z" fill="#0f172a" opacity=".85"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 720 B |
1782
web/storyforge-web-v4/assets/styles.css
Normal file
BIN
web/storyforge-web-v4/favicon.ico
Normal file
|
After Width: | Height: | Size: 13 KiB |