feat: migrate orchestration to n8n and validate lan mvp

This commit is contained in:
kris
2026-03-18 10:05:00 +08:00
parent d2074c3518
commit b145363111
16 changed files with 2429 additions and 254 deletions

View File

@@ -2,15 +2,25 @@ DEFAULT_EXTERNAL_BASE_URL=http://test.hyzq.net:8081
LOCAL_OPENAI_BASE_URL=http://127.0.0.1:8317/v1
LOCAL_OPENAI_MODEL=GLM-5
LOCAL_OPENAI_API_KEY=
FASTGPT_BASE_URL=http://127.0.0.1:3000
FASTGPT_DATASET_API_KEY=
N8N_BASE_URL=http://127.0.0.1:5670
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
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
HUOBAO_BASE_URL=http://127.0.0.1:5678
HUOBAO_POLL_INTERVAL_SEC=10
HUOBAO_MAX_WAIT_SEC=900
YTDLP_BIN=yt-dlp
FFMPEG_BIN=ffmpeg
WHISPER_BIN=
WHISPER_MODEL=./data/collector/models/ggml-base.en.bin
POSTGRES_DB=fastgpt
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin
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

View File

@@ -5,36 +5,68 @@ StoryForge 现在拆成独立项目目录,和 `AI-glasses` 分开维护。
## 目录
- `android-app/`StoryForge Android 客户端
- `collector-service/`FastAPI 后端,提供登录、审批、素材导入、知识库、智能体和 OTA
- `docker-compose.yml`:本地 FastGPT / collector / 基础依赖编排
- `collector-service/`FastAPI 后端,负责用户体系、项目、Agent、任务、内容分析和对外能力接入
- `n8n/`:工作流导出文件,作为流程编排中枢
- `docker-compose.yml`:本地 `collector + n8n + cli-proxy-api` 编排
- `Common/`:项目约束和架构说明
- `data/collector/`SQLite、任务文件、下载产物
- `docs/`:审计、实施计划、联调说明、当前 MVP 状态
## Android
```bash
cd /Users/kris/code/StoryForge/android-app
cd /Users/kris/code/StoryForge-gitea/android-app
./gradlew assembleDebug
```
## Collector Service
```bash
cd /Users/kris/code/StoryForge/collector-service
cd /Users/kris/code/StoryForge-gitea/collector-service
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 8081 --reload
```
## Docker Compose
```bash
cd /Users/kris/code/StoryForge-gitea
cp .env.example .env
docker compose up -d --build
```
默认会启动:
- `collector-service``http://127.0.0.1:8081`
- `n8n``http://127.0.0.1:5670`
- `cli-proxy-api``http://127.0.0.1:8317`
默认会创建最高权限账号:
- `kris`
- `Asd123456.`
## 当前架构
- `collector-service` 负责:
- 用户账号、多项目、多 Agent、多任务、多内容源数据边界
- 调用下载器、本地 ASR、本机 OpenAI 兼容模型
- 调用 Windows `cutvideo``huobao-drama`
- 持久化任务、分镜、分析结果、事件日志
- `n8n` 负责:
- 触发 `analysis_pipeline`
- 触发 `real_cut_pipeline`
- 触发 `ai_video_pipeline`
- FastGPT 已从主流程设计中移除,不再作为运行时依赖
## 说明
- 新注册账号默认 `pending`
- 主管理员审批后才可使用核心业务接口
- 素材入口支持文字、视频链接、视频上传
- 可选对接本机 OpenAI 兼容模型服务和 FastGPT 数据集 API
- 支持 `user -> project -> knowledge base / assistant(agent) / job / content source` 的多租户边界
- 素材入口支持文字、视频链接、视频上传;内容源账号通过 `content_sources` 建模持久化
- `cutvideo` 继续运行在 Windows 机器,本系统通过 API 调度
- `huobao-drama` 继续作为 AI 生成视频主链的核心引擎
- 详细审计、阶段计划和联调步骤见 `docs/`

View File

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

View File

@@ -48,6 +48,18 @@ class Database:
with self.session() as conn:
conn.execute(sql, params)
def table_exists(self, name: str) -> bool:
row = self.fetch_one(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
(name,),
)
return bool(row)
def column_exists(self, table: str, column: str) -> bool:
with self.session() as conn:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
return any(row["name"] == column for row in rows)
def init_schema(self) -> None:
schema = """
CREATE TABLE IF NOT EXISTS accounts (
@@ -90,10 +102,10 @@ class Database:
CREATE TABLE IF NOT EXISTS knowledge_bases (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
project_id TEXT,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
fastgpt_dataset_id TEXT,
sync_status TEXT NOT NULL DEFAULT 'pending',
sync_status TEXT NOT NULL DEFAULT 'ready',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE
@@ -108,7 +120,9 @@ class Database:
transcript_text TEXT NOT NULL DEFAULT '',
style_summary TEXT NOT NULL DEFAULT '',
combined_text TEXT NOT NULL DEFAULT '',
fastgpt_collection_id TEXT NOT NULL DEFAULT '',
analysis_json TEXT NOT NULL DEFAULT '{}',
storyboard_json TEXT NOT NULL DEFAULT '[]',
source_artifact_json TEXT NOT NULL DEFAULT '{}',
analysis_model_profile_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
@@ -118,11 +132,12 @@ class Database:
CREATE TABLE IF NOT EXISTS assistants (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
project_id TEXT,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
system_prompt TEXT NOT NULL DEFAULT '',
generation_goal TEXT NOT NULL DEFAULT '',
fastgpt_app_key TEXT NOT NULL DEFAULT '',
config_json TEXT NOT NULL DEFAULT '{}',
model_profile_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
@@ -140,19 +155,26 @@ class Database:
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
project_id TEXT,
assistant_id TEXT,
knowledge_base_id TEXT NOT NULL,
content_source_id TEXT,
source_type TEXT NOT NULL,
line_type TEXT NOT NULL DEFAULT 'analysis',
workflow_key TEXT NOT NULL DEFAULT '',
orchestrator TEXT NOT NULL DEFAULT 'n8n',
provider_name TEXT NOT NULL DEFAULT '',
provider_task_id TEXT NOT NULL DEFAULT '',
source_url TEXT,
title TEXT NOT NULL,
language TEXT NOT NULL DEFAULT 'auto',
status TEXT NOT NULL,
transcript_text TEXT NOT NULL DEFAULT '',
style_summary TEXT NOT NULL DEFAULT '',
fastgpt_collection_id TEXT NOT NULL DEFAULT '',
upload_status TEXT NOT NULL DEFAULT 'pending',
error TEXT NOT NULL DEFAULT '',
artifacts_json TEXT NOT NULL DEFAULT '{}',
result_json TEXT NOT NULL DEFAULT '{}',
analysis_model_profile_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
@@ -161,6 +183,42 @@ class Database:
FOREIGN KEY(knowledge_base_id) REFERENCES knowledge_bases(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS content_sources (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
project_id TEXT,
source_kind TEXT NOT NULL,
platform TEXT NOT NULL DEFAULT '',
handle TEXT NOT NULL DEFAULT '',
source_url TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
local_path TEXT NOT NULL DEFAULT '',
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS job_events (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
FOREIGN KEY(job_id) REFERENCES jobs(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS app_updates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
@@ -179,3 +237,102 @@ class Database:
"""
with self.session() as conn:
conn.executescript(schema)
self.migrate_schema()
def migrate_schema(self) -> None:
table_columns: dict[str, dict[str, str]] = {
"knowledge_bases": {
"project_id": "TEXT",
},
"knowledge_documents": {
"analysis_json": "TEXT NOT NULL DEFAULT '{}'",
"storyboard_json": "TEXT NOT NULL DEFAULT '[]'",
"source_artifact_json": "TEXT NOT NULL DEFAULT '{}'",
},
"assistants": {
"project_id": "TEXT",
"config_json": "TEXT NOT NULL DEFAULT '{}'",
},
"jobs": {
"project_id": "TEXT",
"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"]),
)

View File

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

View File

@@ -0,0 +1,162 @@
from __future__ import annotations
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) -> None:
self.base_url = base_url.rstrip("/")
self.api_key = api_key.strip()
self.timeout = 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 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 HuobaoDramaClient:
def __init__(self, *, base_url: str, timeout: float = 180.0) -> None:
self.base_url = base_url.rstrip("/")
self.timeout = timeout
@property
def enabled(self) -> bool:
return bool(self.base_url)
async def create_drama(self, payload: dict[str, Any]) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
_join_url(self.base_url, "/api/v1/dramas"),
json=payload,
)
response.raise_for_status()
return _unwrap_response(response.json())
async def generate_image(self, payload: dict[str, Any]) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
_join_url(self.base_url, "/api/v1/images"),
json=payload,
)
response.raise_for_status()
return _unwrap_response(response.json())
async def get_image(self, image_id: str) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
_join_url(self.base_url, f"/api/v1/images/{image_id}"),
)
response.raise_for_status()
return _unwrap_response(response.json())
async def generate_video(self, payload: dict[str, Any]) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
_join_url(self.base_url, "/api/v1/videos"),
json=payload,
)
response.raise_for_status()
return _unwrap_response(response.json())
async def get_video(self, video_id: str) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
_join_url(self.base_url, f"/api/v1/videos/{video_id}"),
)
response.raise_for_status()
return _unwrap_response(response.json())

File diff suppressed because it is too large Load Diff

View File

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

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

@@ -0,0 +1,134 @@
# StoryForge 现状审计
日期2026-03-18
## 结论
当前应以 `/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` 命令集成
### 2. ASR
- 现有实现已部署,但入口未完全标准化
- 当前后端支持 `ffmpeg + whisper.cpp` 风格接入
- 若需要接现有常驻 ASR 服务,后续只需把 `transcribe_media()` 改成 HTTP/RPC 适配即可
### 3. Windows `cutvideo`
- 仓库:`/Users/kris/code/cutvideo`
- 具备清晰 API
- `POST /api/jobs`
- `GET /api/tasks/{task_id}`
- `GET /api/runs/{run_id}`
- 适合集成为“由 StoryForge 后端授权调用的局域网剪辑能力”
当前限制:
- 现有 `cutvideo` API 主要接受 `input_dir`
- 对“用户上传实拍素材后直接推送到 Windows 机器”这一步,还缺一层文件转运方案
### 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`,而不是继续在旧目录长期演进。
## 当前已完成迁移面
- FastGPT 运行时依赖已从 `collector-service` 主代码中剥离
- 数据库已支持 `project/content_source/job_events`
- `collector-service` 已增加:
- `n8n` 触发
- `cutvideo` 集成 client
- `huobao-drama` 集成 client
- 内部编排接口
- `docker-compose.yml` 已改为 `collector + n8n + cli-proxy-api`
- `n8n` 工作流导出文件已纳入仓库
## 当前主要风险
1. `cutvideo` 的素材传输还未完整闭环
2. 本地 ASR 的“最终生产入口”仍需按你现有部署方式再绑一次
3. `huobao-drama` 已在本机旧改版实例上跑通,但兼容补丁尚未迁到 upstream 仓库并形成正式提交

View File

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

View File

@@ -0,0 +1,146 @@
# 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`
- `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 可执行文件时填写
说明:
- 如果你单独重建 `collector`,要确保运行时仍带上 `CUTVIDEO_BASE_URL`,否则容器会退回空值
- 当前已验证可用的 Windows `cutvideo` 地址是 `http://192.168.31.18:7860`
## 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`
导入后:
- 检查每个 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 可调用
### 上传视频
调用 `POST /v2/explore/upload-video`
预期与视频链接类似,但素材来源为本地上传
## 6. `cutvideo` 实拍剪辑链路验证
调用 `POST /v2/pipelines/real-cut`
当前 MVP 前提:
- `input_dir` 必须是 Windows `cutvideo` 机器可访问的目录
- 该目录中的素材已准备好
预期:
- 任务创建成功
- `n8n` 调用 `collector-service` 内部 real-cut step
- 后端记录 `provider_task_id`
- 最终任务写回 `cutvideo_run`
已验证样例:
- `job_5ebd829c3f2144bca5c941183e75bdcd`
- Windows 返回 `task_id=8d8f4a0cd5d9`
- 运行目录 `20260318-093520-Windows cutvideo 联调样例`
## 7. `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`
## 8. 当前已知卡点
- `cutvideo` 端到端“上传素材后自动送到 Windows 机器”还未彻底闭环
- ASR 如果不是命令行模式,而是你现有常驻服务模式,需要再做一次入口绑定
- `huobao-drama` 目前跑通依赖本地旧改版中的 qnaigc 兼容补丁,下一步要迁到 upstream 仓库

View File

@@ -0,0 +1,41 @@
# StoryForge MVP 状态
日期2026-03-18
## 已跑通或已完成代码接通
- 多用户账号体系
- 审批机制
- `user -> project -> assistant / knowledge base / job / content source` 数据模型
- 文本 / 视频链接 / 上传视频 三类分析任务创建
- `n8n` 工作流导入、激活与触发接口
- 本地下载器调用
- 本地 `ffmpeg` / `whisper` 风格入口封装
- 本地大模型内容分析、二创文案、分镜生成
- Windows `cutvideo` API 调度与结果回写接口
- 本机 `huobao-drama` API 调度、首尾帧生成、视频生成与结果回写接口
- FastGPT 运行时依赖删除
## 已验证的真实任务
- 分析链路:`job_203bc8e9b20f4b1cbbc6cf7da79e46f4`
- 实拍剪辑链路:`job_5ebd829c3f2144bca5c941183e75bdcd`
- AI 视频链路:`job_01828c40377747cf914b51be360cc333`
## 已实现但仍待环境验证
- 现有 ASR 部署入口与 `collector-service` 的最终绑定
## 尚未完全跑通
- 用户上传实拍素材后,自动把素材转运到 Windows `cutvideo` 机器的闭环
- 对“抖音 / bilibili / 小红书账号级内容源”的批量抓取与分析调度
- `huobao-drama` 本地兼容补丁向 upstream 仓库的迁移、分支化和提交
## 下一步优先级
1.`huobao-drama` 本地兼容补丁迁到 `/Users/kris/code/huobao-drama-upstream`
2. 绑定你的真实 ASR 入口
3. 决定实拍素材转运方案:共享目录优先,上传 API 作为备选
4. 补账号级内容源抓取调度
5. 把改动整理成提交并推送

28
n8n/README.md Normal file
View File

@@ -0,0 +1,28 @@
# n8n Workflows
本目录保存 StoryForge 的工作流导出文件,避免流程只存在于 n8n UI。
## 工作流
- `workflows/storyforge-analysis.json`:内容分析主线
- `workflows/storyforge-real-cut.json`Windows `cutvideo` 调度主线
- `workflows/storyforge-ai-video.json``huobao-drama` AI 生成视频主线
## 约定
- 工作流内部默认通过 `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 导入本目录下的 3 个 JSON
4. 激活工作流
## Webhook 路径
- `/webhook/storyforge-analysis`
- `/webhook/storyforge-real-cut`
- `/webhook/storyforge-ai-video`

View File

@@ -0,0 +1,70 @@
{
"name": "StoryForge AI Video Pipeline",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "storyforge-ai-video",
"responseMode": "onReceived",
"options": {}
},
"id": "aivideo-webhook",
"name": "AI Video Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
220,
300
],
"webhookId": "storyforge-ai-video"
},
{
"parameters": {
"method": "POST",
"url": "={{'http://collector:8081/internal/jobs/steps/ai-video/render?job_id=' + ($json.body.job_id || $json.body.jobId)}}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Orchestrator-Secret",
"value": "storyforge-local-secret"
}
]
},
"options": {
"timeout": 3600000
}
},
"id": "aivideo-runner",
"name": "Run AI Video Step",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
520,
300
]
}
],
"connections": {
"AI Video Webhook": {
"main": [
[
{
"node": "Run AI Video Step",
"type": "main",
"index": 0
}
]
]
},
"Run AI Video Step": {
"main": [
[]
]
}
},
"active": false,
"settings": {},
"pinData": {},
"versionId": "storyforge-ai-video-v1"
}

View File

@@ -0,0 +1,70 @@
{
"name": "StoryForge Analysis Pipeline",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "storyforge-analysis",
"responseMode": "onReceived",
"options": {}
},
"id": "analysis-webhook",
"name": "Analysis Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
220,
300
],
"webhookId": "storyforge-analysis"
},
{
"parameters": {
"method": "POST",
"url": "={{'http://collector:8081/internal/jobs/steps/analyze?job_id=' + ($json.body.job_id || $json.body.jobId)}}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Orchestrator-Secret",
"value": "storyforge-local-secret"
}
]
},
"options": {
"timeout": 600000
}
},
"id": "analysis-runner",
"name": "Run Analysis Step",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
520,
300
]
}
],
"connections": {
"Analysis Webhook": {
"main": [
[
{
"node": "Run Analysis Step",
"type": "main",
"index": 0
}
]
]
},
"Run Analysis Step": {
"main": [
[]
]
}
},
"active": false,
"settings": {},
"pinData": {},
"versionId": "storyforge-analysis-v1"
}

View File

@@ -0,0 +1,70 @@
{
"name": "StoryForge 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"
}