Compare commits
1 Commits
codex/stor
...
codex/vers
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2b9649cba |
45
.env.example
45
.env.example
@@ -2,46 +2,15 @@ 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=
|
||||
# Host-side collector runs can keep using N8N_BASE_URL.
|
||||
N8N_BASE_URL=http://127.0.0.1:5670
|
||||
# Dockerized collector should use the internal n8n service address.
|
||||
COLLECTOR_N8N_BASE_URL=http://n8n:5678
|
||||
BOOTSTRAP_SUPERADMIN_USERNAME=storyforge-admin
|
||||
BOOTSTRAP_SUPERADMIN_PASSWORD=__set_a_strong_password__
|
||||
BOOTSTRAP_SUPERADMIN_DISPLAY_NAME=StoryForge Admin
|
||||
WEB_AUTOLOGIN_ENABLED=0
|
||||
WEB_AUTOLOGIN_ACCOUNT_USERNAME=
|
||||
WEB_AUTOLOGIN_USERNAME=
|
||||
WEB_AUTOLOGIN_PASSWORD=
|
||||
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=__set_a_strong_shared_secret__
|
||||
STORYFORGE_INTERNAL_BASE_URL=http://collector:8081
|
||||
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
|
||||
FASTGPT_BASE_URL=http://127.0.0.1:3000
|
||||
FASTGPT_DATASET_API_KEY=
|
||||
YTDLP_BIN=yt-dlp
|
||||
FFMPEG_BIN=ffmpeg
|
||||
WHISPER_BIN=
|
||||
WHISPER_MODEL=./data/collector/models/ggml-base.en.bin
|
||||
ASR_HTTP_BASE_URL=
|
||||
ASR_HTTP_TRANSCRIBE_PATH=/transcribe
|
||||
ASR_HTTP_FIELD_NAME=wav
|
||||
ASR_HTTP_TIMEOUT_SEC=120
|
||||
N8N_IMAGE=docker.n8n.io/n8nio/n8n:latest
|
||||
WEBHOOK_URL=http://127.0.0.1:5670/
|
||||
GENERIC_TIMEZONE=Asia/Shanghai
|
||||
TZ=Asia/Shanghai
|
||||
POSTGRES_DB=fastgpt
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
MINIO_ROOT_USER=minioadmin
|
||||
MINIO_ROOT_PASSWORD=minioadmin
|
||||
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=
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
name: StoryForge CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
baseline:
|
||||
name: Baseline checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: python -m pip install --upgrade pip && pip install -r collector-service/requirements.txt
|
||||
|
||||
- name: Run repository baseline
|
||||
run: ./scripts/check_repo_baseline.sh
|
||||
|
||||
backend-tests:
|
||||
name: Backend tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: python -m pip install --upgrade pip && pip install -r collector-service/requirements.txt
|
||||
|
||||
- name: Run backend unittest suite
|
||||
run: python -m unittest tests.test_main_agent_governance tests.test_platform_contracts tests.test_production_baseline
|
||||
|
||||
web-tests:
|
||||
name: Web tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Run web node tests
|
||||
run: node --test web/storyforge-web-v4/tests/dashboard-home.test.mjs web/storyforge-web-v4/tests/workbench-pages.test.mjs
|
||||
63
.github/workflows/ci.yml
vendored
63
.github/workflows/ci.yml
vendored
@@ -1,63 +0,0 @@
|
||||
name: StoryForge CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
baseline:
|
||||
name: Baseline checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: python -m pip install --upgrade pip && pip install -r collector-service/requirements.txt
|
||||
|
||||
- name: Run repository baseline
|
||||
run: ./scripts/check_repo_baseline.sh
|
||||
|
||||
backend-tests:
|
||||
name: Backend tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: python -m pip install --upgrade pip && pip install -r collector-service/requirements.txt
|
||||
|
||||
- name: Run backend unittest suite
|
||||
run: python -m unittest tests.test_main_agent_governance tests.test_platform_contracts tests.test_production_baseline
|
||||
|
||||
web-tests:
|
||||
name: Web tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Run web node tests
|
||||
run: node --test web/storyforge-web-v4/tests/dashboard-home.test.mjs web/storyforge-web-v4/tests/workbench-pages.test.mjs
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -20,8 +20,6 @@ build/
|
||||
.kotlin/
|
||||
**/.gradle/
|
||||
**/.kotlin/
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
|
||||
# Runtime data and artifacts
|
||||
data/
|
||||
@@ -31,8 +29,3 @@ output/
|
||||
# macOS / editors
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Local agent/browser scratch state
|
||||
.playwright-cli/
|
||||
.superpowers/
|
||||
.tmp-previews*/
|
||||
|
||||
722
CHANGELOG.md
722
CHANGELOG.md
@@ -1,722 +0,0 @@
|
||||
# StoryForge Changelog
|
||||
|
||||
这个文件用于给 Gitea 仓库保留阶段性版本更新记录,方便直接查看每一轮里程碑,不用只依赖零散 commit。
|
||||
|
||||
## 2026-04-07
|
||||
|
||||
### 管理员模型配置页新增统一能力总览
|
||||
|
||||
- `管理员配置台 -> 模型与接入` 新增了 `统一能力总览`,把 `语言模型 / ASR / 文生图 / 图生图 / 生视频` 五类能力做成了可点击总览卡。
|
||||
- 每张卡都会直接带到对应锚点,管理员不需要再自己判断是去 `系统模型`、`运行时接入` 还是 `Huobao` 的图片/视频配置区。
|
||||
- 这样“所有需要模型的能力都在一个配置页里”不只是文案层成立,实际管理路径也更清楚了。
|
||||
|
||||
### 管理员模型配置页明确覆盖全部模型能力
|
||||
|
||||
- `管理员配置台 -> 模型与接入` 顶部新增了统一能力说明,直接标明这里覆盖 `语言模型 / ASR / 文生图 / 图生图 / 生视频`。
|
||||
- Huobao 图片模型区改名为 `文生图 / 图生图模型服务`,视频模型区改名为 `生视频模型服务`,避免管理员误以为图生图或 Seedance 还要去别的页面配置。
|
||||
- 对应前端回归已经锁住这些入口文案,后续改 UI 时不会把统一模型配置页拆散。
|
||||
|
||||
### 管理员配置台新增“模型与接入”统一配置中心
|
||||
|
||||
- `管理员配置台` 新增了 `模型与接入` 页签,只有超级管理员可以访问;它把运行时接入、系统模型、Huobao 文本/图片/视频模型配置统一收进了一个地方。
|
||||
- 管理员现在可以直接在 StoryForge 里维护:
|
||||
- `n8n / Huobao / ASR / cutvideo / live_recorder / local_model` 的运行时地址
|
||||
- 系统级文本模型的 `provider / base_url / model / API Key`
|
||||
- Huobao 的 `text / image / video` 模型配置,包含 Seedance 2.0 这类视频模型
|
||||
- `AI 视频` 表单里的 `查看火山配置状态` 现在对管理员会直接带进这个新工作区,真正进入可编辑的模型配置页,而不是只停在健康状态卡。
|
||||
|
||||
### 管理员模型配置开始纳入回归与部署护栏
|
||||
|
||||
- 后端新增了管理员专属接口:
|
||||
- `/v2/admin/model-access/overview`
|
||||
- `/v2/admin/model-access/runtime`
|
||||
- `/v2/admin/model-access/system-models`
|
||||
- `/v2/admin/model-access/huobao-configs`
|
||||
- 这些接口已经纳入后端回归,前端管理员页也纳入工作台字符串回归,所以以后不会再出现“管理员页有入口、但后端保存不了”这种断层。
|
||||
|
||||
### AI 视频表单可直接跳到火山视频配置状态
|
||||
|
||||
- `创建 AI 视频任务` 里的 `Seedance 配置` 提示现在不再只是静态文案,而是新增了 `查看火山配置状态` 入口。
|
||||
- 点击后会直接跳到 `自动流程 -> 依赖健康 -> Huobao` 卡片,立刻看到当前火山视频配置是否就绪、部署位置和配置提示,不用再自己记 `/settings/ai-config -> 视频 -> 火山引擎` 再手动找入口。
|
||||
- 同时 `依赖健康` 里的各张集成卡现在都带稳定锚点,后续其他配置提示也可以直接把用户带到最相关的健康卡,而不是只停在说明文字里。
|
||||
|
||||
### AI 视频表单开始跟随视频引擎动态刷新配置提示
|
||||
|
||||
- `视频引擎` 从 `当前默认引擎` 切到 `Seedance 2.0` 时,`引擎模型` 默认值和占位文案现在会立刻跟着刷新,不用再手动猜当前应该填什么。
|
||||
- `Seedance 配置` 提示也会随引擎切换即时更新,表单第一眼就能看出这次走的是默认视频链,还是 `Seedance 2.0 -> 火山视频配置`。
|
||||
- 这套联动同样保留“手动改过就不再自动覆盖”的原则,避免把用户已经输入的模型名冲掉。
|
||||
|
||||
### AI 视频开始按项目记忆最近一次视频引擎
|
||||
|
||||
- `创建 AI 视频任务` 现在会按项目记住你最近一次使用的 `视频引擎 / 引擎模型`。
|
||||
- 如果某个项目最近一次就是用 `Seedance 2.0 + seedance-2.0-pro`,下次再打开这张表单时会优先带出这套组合,不用每次重新选。
|
||||
- 这套记忆只在当前项目内生效,不会把一个项目的视频引擎偏好串到别的项目上。
|
||||
|
||||
### 修复额度页套餐建议引起的全局渲染报错
|
||||
|
||||
- `额度` 页面现在会先初始化 `packageRecommendation` 再渲染套餐建议,不再因为变量未定义把整个工作台渲染链打断。
|
||||
- 这次修复直接解决了公网页面点击 `AI 视频` 等直执行动作后被额度页报错拦住的问题,控制台已经恢复为无报错状态。
|
||||
- 对应前端回归也补了一条更硬的断言,锁住 `renderCreditsScreen()` 对套餐建议变量的初始化。
|
||||
|
||||
### 额度策略开始按真实用量给出套餐建议
|
||||
|
||||
- `租户额度与审计` 和 `额度` 工作区现在会根据当前项目最近的预算消耗、视频动作量、文案动作量和存储使用,直接给出 `试用 / 增长 / 规模 / 自定义` 的套餐建议。
|
||||
- `编辑租户额度` 弹层里的套餐预览也开始带上这层建议,不再只是静态展示当前选择的套餐说明;切换预设或继续调整自定义额度时,建议会跟着实时刷新。
|
||||
- 这让额度页从“只展示当前配额”继续往“告诉你现在更适合哪档套餐”收了一层,也把预算、动作池和真实使用节奏更明确地连在一起。
|
||||
|
||||
### 创作表单开始跟随来源任务动态刷新推荐值
|
||||
|
||||
- `生成文案 / 创建 AI 视频 / 创建实拍剪辑 / 写复盘` 这四类创作表单,现在不只会在打开时算一次默认值;如果你在表单里切换来源任务,平台、标题、受众、画幅、时长、目标这些推荐值会继续跟着刷新。
|
||||
- 这套联动只会在字段还处于“自动推荐”状态时继续接管;一旦用户手动改过,就会尊重手改内容,不会再被来源任务覆盖。
|
||||
- `来源任务` 摘要区也会跟着联动更新,切换任务后第一眼就能看到当前承接的是哪条任务。
|
||||
- 为了支持这层联动,输入型表单里的 HTML 字段现在也带了稳定的 `data-action-field` 标记,后续继续做表单智能化和回归锁定会更稳。
|
||||
|
||||
### 直播录制表单开始跟随项目和平台动态刷新
|
||||
|
||||
- `新增录制源 / 编辑录制源` 现在会在切换项目或平台时动态刷新录制名称占位,并同步更新可选 Agent 列表,不再停留在打开表单时的默认值。
|
||||
- `导入 URL 配置` 现在会在切换平台时实时刷新说明文案和样例配置,抖音/快手两种场景可以直接在同一张表单里切换预设。
|
||||
- 这套联动同样保留“手动改过就不再覆盖”的原则,避免自动推荐把用户已经输入的内容冲掉。
|
||||
|
||||
### 输入型表单切项目时会同步刷新 Agent 和上下文
|
||||
|
||||
- `导入主页 / 导入当前对标 / 加入跟踪 / 导入作品链接 / 导入文本 / 上传本地视频` 这几张输入型表单,现在在切换项目后会一起刷新可选 Agent 列表和顶部“当前上下文”摘要。
|
||||
- 这样不会再出现“项目已经换了,但表单里还是上一项目的 Agent 和上下文”的错位。
|
||||
- `加入跟踪` 虽然没有项目切换,但现在在切换负责 Agent 时,顶部上下文摘要也会实时更新。
|
||||
|
||||
## 2026-04-06
|
||||
|
||||
### 主 Agent 高注意图动作统一切到直执行入口
|
||||
|
||||
- `create_assistant / import_homepage / track_account / generate_copy / ai_video / real_cut` 这批高频意图动作现在统一注册成 `direct-*`,不再回退到旧的 `open-*` 表单入口。
|
||||
- 这样主 Agent 结果卡、动作注册表和工作台高频按钮现在共用同一套直执行链,后续回跳与结果落点也更一致。
|
||||
- `analyze_account / analyze_top_videos` 现在也统一切到 `direct-*`,并且在缺少当前选中账号时会自动回退到旧表单,不会把用户卡死在“缺少上下文”的提示上。
|
||||
- `direct-search-similar / direct-save-benchmark-link` 现在也会在缺少当前账号或相似候选时自动回退到旧表单,避免“查相似 / 存对标”入口因为上下文不完整直接报错。
|
||||
- `导入主页 / 导入作品链接 / 导入文本 / 上传视频` 这批输入型表单现在会按当前项目和当前平台自动推荐标题占位,并且在表单里切换项目或平台时会同步更新,不再一直停留在通用示例文案。
|
||||
|
||||
### 依赖健康卡开始显示服务部署位置
|
||||
|
||||
- `collector` 的 `/v2/integrations/health` 现在会统一带出 `deployment_scope / deployment_label`,明确说明依赖当前跑在 `服务器 / NAS / Windows / NAS 隧道 / 未启用` 哪一侧。
|
||||
- 工作台里的依赖健康卡已经开始展示 `部署:服务器`、`部署:Windows` 这类信息,和 `ASR 在线 · GPU` 一起出现,后续迁服务时不需要再靠命令行手查。
|
||||
- 当前这套口径已经覆盖 `n8n / huobao / asr / cutvideo / live_recorder / local_model`。
|
||||
|
||||
### 工作台依赖健康现在会显示 ASR 真实运行模式
|
||||
|
||||
- `collector` 的 `/v2/integrations/health` 现在会带出 ASR 的 `language_mode / runtime_device_mode / runtime_compute_type_mode / active_device / active_compute_type / model_name`。
|
||||
- 工作台里的依赖健康卡不再只是显示 `ASR 在线`,而是会直接展示 `在线 · GPU` 或 `在线 · CPU`,并补充当前模型、语言模式和 compute type。
|
||||
- 这样以后排查“Windows ASR 当前到底有没有吃到 GPU”时,不需要再手查命令行或单独打 `/health`。
|
||||
|
||||
### Windows ASR GPU 失败时自动回退 CPU
|
||||
|
||||
- Windows `ASR HTTP` 现在在 `auto` 模式下仍会优先尝试 `cuda + int8_float16`,但如果在真正推理阶段命中 `cublas/cudnn/cuda` 运行库缺失,会自动切回 `cpu + int8` 重试,不再把整次转写卡死在 GPU 路径。
|
||||
- 这让“默认优先用 GPU、但当前机器 CUDA 运行库不完整”的场景也能稳定返回结果,同时保留混合中英文自动识别。
|
||||
- `smoke_public_storyforge.sh` 与 `smoke_fnos_storyforge_lan.sh` 现在会覆盖 ASR 转写链路,公网 smoke 在遇到 `127.0.0.1` 这类服务器内网地址时会自动跳过真实转写,避免在开发机上误判。
|
||||
- Windows ASR 运行时现在会自动发现 venv 里的 `nvidia-cublas-cu12 / nvidia-cuda-runtime-cu12 / nvidia-cudnn-cu12` DLL 目录并注入搜索路径;实机验证后 `active_device` 已经恢复为 `cuda`,不再长期回退到 CPU。
|
||||
|
||||
### Windows ASR 默认改成 GPU 优先与自动语言识别
|
||||
|
||||
- Windows `ASR HTTP` 现在默认不再强锁 `zh + cpu + int8`,而是改成:
|
||||
- `WHISPER_DEVICE=auto`
|
||||
- `WHISPER_LANGUAGE=auto`
|
||||
- `WHISPER_COMPUTE_TYPE=auto`
|
||||
- 运行时会优先尝试 `cuda + int8_float16`,如果当前机器没有可用 GPU,再自动回退到 `cpu + int8`。
|
||||
- 转写请求默认不再强制指定语言,这样一句话里中英混说时,会按模型自动识别而不是强压成中文模式。
|
||||
- 健康接口现在也会明确返回:
|
||||
- 配置层 `language/device/compute_type`
|
||||
- 实际加载后的 `active_device/active_compute_type`
|
||||
便于区分“当前策略”和“本轮真实用到的运行模式”。
|
||||
|
||||
### NAS collector 改走服务器本机的 n8n 与火爆视频
|
||||
|
||||
- 新增 `fnOS -> 公网服务器` 的本地转发隧道,把服务器本机 `127.0.0.1:25670/25678` 分别映射到 NAS 的 `19570/19578`。
|
||||
- `deploy_fnos_storyforge_collector.sh` 默认值已经改成走这条隧道,不再继续依赖旧的 `192.168.31.139:5670/5678`。
|
||||
- 这样局域网和外网的 `collector` 现在统一使用同一套服务器侧 `n8n + huobao`,只有 `cutvideo / live_recorder / Windows ASR` 继续保留在局域网设备。
|
||||
- NAS `integrations/health` 里 `n8n / huobao / asr / cutvideo / live_recorder` 已全部恢复在线,`local_model` 维持为刻意禁用状态。
|
||||
|
||||
### 公网 n8n 与火爆视频迁到服务器本机
|
||||
|
||||
- 公网 `n8n` 不再依赖旧的 SSH 反向隧道,已经迁到服务器本机 Docker,健康检查地址切到 `127.0.0.1:25670/healthz`。
|
||||
- 公网 `huobao` 也已经从外部依赖迁到服务器本机 Docker,健康检查地址切到 `127.0.0.1:25678/health`。
|
||||
- 公网 `collector` 现已统一使用服务器本机的 `n8n + huobao + cutvideo + live_recorder`,同时继续保持 `local_model` 禁用、`ASR` 走 Windows 桥接。
|
||||
- 这让外网主链不再依赖你当前这台 Mac 或旧的 `192.168.31.139` 服务状态,公网 `integrations/health` 里 `n8n / huobao / asr / cutvideo / live_recorder` 现在都恢复为在线。
|
||||
|
||||
### 自动连接首屏再提速一层
|
||||
|
||||
- 自动连接成功后,`/v2/me` 和 `/v2/me/dashboard` 现在改成并行请求,不再串行等待。
|
||||
- 会话拿到以后,页面会更快进入真实项目总台和当前工作区骨架,再继续后台补齐账号、存储、Agent 控制面等重数据。
|
||||
- 这让“点开就能用”的体验更接近真实可交互,而不是长时间停在连接提示上。
|
||||
|
||||
### 自动连接工作区改成先可用后补水
|
||||
|
||||
- 自动连接成功后,不再把账号详情、存储、Agent 控制面、OneLiner 消息、文档等重数据全部串在首屏可用之前。
|
||||
- 现在会先渲染当前项目和工作区骨架,让页面尽快进入可交互状态,再后台补齐重数据。
|
||||
- 顶部连接状态和移动端状态条也会明确显示“同步中”,但不再让整页一直卡在不可用态。
|
||||
|
||||
### 导入当前对标与更新跟踪账号开始优先直执行
|
||||
|
||||
- `导入当前对标` 在当前项目里已有内容源配置或可直接拿到主页链接时,会优先直接触发同步,而不是默认打开旧表单。
|
||||
- `更新跟踪账号` 在当前账号已经处于跟踪中时,会直接沿用现有跟踪配置触发更新;只有首次加入跟踪时才继续保留表单。
|
||||
- 这样 `找对标 -> 接入项目 / 跟踪` 这条链在有足够上下文时也收成了 direct-execute。
|
||||
|
||||
### 查相似与保存对标关系入口开始优先直执行
|
||||
|
||||
- `查相似` 入口在当前已经选中账号时,会优先直接触发相似账号搜索,而不是先打开旧表单。
|
||||
- `保存对标关系` 入口在当前已有相似候选时,也会优先直接保存首个候选关系;只有缺少上下文时才回退到旧表单。
|
||||
- 这样 `找对标` 这条主链进一步从“先开表单再继续”收成了“有上下文就直接执行、没上下文才补信息”。
|
||||
|
||||
## 2026-04-05
|
||||
|
||||
### 直播录制表单开始按当前项目和平台推荐默认标题与导入样例
|
||||
|
||||
- `新增录制源` 现在会按当前项目和当前平台自动带出更合理的默认录制名称,不再每次都从空白标题开始。
|
||||
- `编辑录制源` 的占位标题也会跟着当前项目和平台变化,方便快速补齐那些原本没有手工命名的录制源。
|
||||
- `导入 URL 配置` 会按当前偏好平台切换导入样例和说明文案,让抖音/快手场景在第一眼看到的例子就更贴近当前工作流。
|
||||
|
||||
### 导入主页、导入当前对标、加入跟踪表单补齐上下文摘要
|
||||
|
||||
- `导入主页 / 导入当前对标 / 加入跟踪` 这三张仍需用户补信息的表单,现在和 `导入作品 / 导入文本 / 上传视频` 一样,都会在顶部展示 `当前项目 / 默认 Agent / 默认知识库` 的上下文摘要。
|
||||
- 默认 Agent 也统一跟随当前已选 Agent,避免用户每次打开表单都要重新对齐负责 Agent。
|
||||
- 这让“仍然必须保留表单”的入口也和前面已经收好的输入型流程保持了同一套体验语言。
|
||||
|
||||
### 文案、AI 视频、实拍剪辑、复盘表单补齐同一套上下文体验
|
||||
|
||||
- `生成文案 / 创建 AI 视频 / 创建实拍剪辑 / 写复盘` 这四张高频创作表单,现在也会在顶部展示当前项目和默认 Agent 的上下文摘要。
|
||||
- 这样高频创作动作不管是 direct-execute 还是必须补信息的表单,都已经统一到一套“先看当前上下文,再继续填写”的工作流体验里。
|
||||
|
||||
### 高优先级创作表单补齐来源任务摘要
|
||||
|
||||
- 当 `生成文案 / 创建 AI 视频 / 创建实拍剪辑 / 写复盘` 是围绕某条已完成任务打开时,表单顶部现在会直接展示这条来源任务的摘要。
|
||||
- `生成文案` 和 `写复盘` 也会优先继承来源任务的平台,避免用户再手工改一次平台。
|
||||
- 这样从任务详情或主 Agent 结果卡继续往下做时,表单第一眼就知道自己承接的是哪条任务。
|
||||
|
||||
### 高优先级创作表单开始自动推荐更合理的默认值
|
||||
|
||||
- `生成文案` 现在会按当前平台自动给出更合适的默认受众,而不再一律写成“创业者”。
|
||||
- `创建 AI 视频` 会按来源任务自动推荐风格、画幅和单镜头时长;`创建实拍剪辑` 会自动推荐目标时长和画幅。
|
||||
- 这样从主 Agent、任务详情或最近完成任务继续往下做时,表单默认值会更贴近当前任务本身,而不是每次都从通用模板起步。
|
||||
|
||||
### 高优先级创作表单开始自动补标题和剪辑目标
|
||||
|
||||
- `创建 AI 视频 / 创建实拍剪辑 / 写复盘` 现在会优先基于来源任务自动带出更合理的标题,而不是总让用户自己再补一遍。
|
||||
- `创建实拍剪辑` 还会基于来源任务摘要自动生成更贴近当前任务的默认剪辑目标。
|
||||
- 这样从某条任务继续派生后续动作时,表单不仅默认值更合理,连标题和目标文案也更像是承接当前任务的自然下一步。
|
||||
|
||||
### 主 Agent 抖音相似搜索与对标关系 live 修复
|
||||
|
||||
- 修复 `search-similar-accounts` / `save-benchmark-link` 在抖音 live 数据上错误按 `project_id` 查询账号导致的 500。
|
||||
- `OneLiner` 现在会按抖音真实表结构解析目标账号,和国内平台 `content_sources` 路径分开处理。
|
||||
- 新增抖音专用治理回归,锁住“查相似账号 -> 存对标关系”这条真实执行链。
|
||||
|
||||
### OneLiner 对话里的直接执行建议保留完整上下文
|
||||
|
||||
- OneLiner 助手消息里的 `suggested_actions` 现在不再只是渲染成一个裸 `data-action` 标签。
|
||||
- 前端会把每条建议对应的 `executor_key / platform / payload / session_id` 一起带上,所以“直接分析账号 / 直接同步跟踪池 / 直接创建 AI 视频”这类建议从对话里点下去时,会真正走当前 live 执行器。
|
||||
- 这让 OneLiner 对话、运行卡、结果卡三条链的“直接执行”行为终于统一,不会再出现运行卡能跑、对话建议却丢上下文的断层。
|
||||
|
||||
### 主页导入和高分分析的落点改成真正直达
|
||||
|
||||
- `直接导入主页` 现在不再把人扔回 `找对标` 总览,而是直接落到新建同步任务的详情页,方便立即看同步进度。
|
||||
- `直接分析高分作品` 现在会直接回到当前对象,而不是回到整个 `找对标` 首页,让高分拆解结论和相似账号建议更容易接着看。
|
||||
- LAN smoke 现在会直接校验 `import-homepage / search-similar-accounts / save-benchmark-link / refresh-tracking / mark-tracking-read` 这些主 Agent 直执行动作已经注册在线,避免后续回退。
|
||||
|
||||
### 主 Agent 可直接执行分析账号、加入跟踪、创建 Agent
|
||||
|
||||
- `OneLiner / 主 Agent` 的动作执行器现在新增了三条真实动作:
|
||||
- `直接分析账号`
|
||||
- `直接加入跟踪`
|
||||
- `直接创建 Agent`
|
||||
- 这三条链不再只是“建议 + 跳页”,而是会直接调用当前 live 后端接口完成动作,再把结果落回工作台。
|
||||
- `分析账号` 现在会直接调用对应平台的账号分析接口,并把结果回到当前对象详情。
|
||||
- `加入跟踪` 会直接创建跟踪对象,并在支持任务同步的平台上立即触发一次同步,再把落点带回任务详情或跟踪对象。
|
||||
- `创建 Agent` 会直接在当前项目下创建 Agent,并把工作流继续落到编辑页。
|
||||
- 治理回归新增了这三条执行器的 live 断言,锁住动作注册表、分析执行、跟踪执行和 Agent 创建这条链不能退回成假执行器。
|
||||
|
||||
### 主 Agent 可直接批量同步跟踪池
|
||||
|
||||
- `OneLiner / 主 Agent` 现在新增了 `直接同步跟踪池` 动作,会批量触发当前平台已跟踪账号的同步任务。
|
||||
- 这条链会直接调用 `/v2/{platform}/tracking/refresh`,不再只是建议用户先跳去跟踪页再手动点同步。
|
||||
- 如果本轮只生成了一条同步任务,结果会直接落到任务详情;如果是多条批量同步,则回到跟踪工作区继续看结果。
|
||||
- 治理回归补上了这条动作的断言,锁住动作注册表、批量同步执行和推荐落点都必须保持 live。
|
||||
|
||||
### 主 Agent 可直接标记跟踪日报已读
|
||||
|
||||
- `OneLiner / 主 Agent` 现在新增了 `直接标记日报已读` 动作,会直接调用 `/v2/{platform}/tracking/cursor` 更新当前平台的已读游标。
|
||||
- 这让跟踪流不再只停在“同步一批账号”,而是可以顺手把这轮日报窗口标成已处理。
|
||||
- 动作完成后会统一回到 `跟踪工作区`,继续看当前平台的日报和下一步跟进动作。
|
||||
|
||||
### 额度编辑弹层补成真正的套餐配置器
|
||||
|
||||
- `编辑租户额度` 不再只是裸数字表单,而是会即时预览当前套餐的预算、动作池和预警阈值。
|
||||
- 选择 `试用 / 增长 / 规模` 这类预设套餐时,前端会直接预填并锁定对应额度字段,避免用户误以为这些数值需要手工对齐。
|
||||
- 切回 `自定义套餐` 时,会恢复当前项目自己的手工额度草稿,继续支持精细化配置。
|
||||
|
||||
### 套餐档位真正变成服务端额度预设
|
||||
|
||||
- `/v2/tenant/quota` 现在会把 `trial / growth / scale / custom` 视为真正的服务端套餐档位,而不只是前端标签。
|
||||
- 当项目选择 `试用 / 增长 / 规模` 套餐时,后端会自动应用对应的预算、动作池和存储上限,并把规范化后的 `package_title / package_focus / package_defaults / warn_threshold` 一起回写给前端。
|
||||
- `自定义套餐` 仍然保留手工数值,适合已经明确成本模型或需要特殊策略的项目。
|
||||
- `额度` 页也跟着升级成更像正式产品的展示:会直接显示套餐标题和套餐定位,不再只看到生硬的 `growth/custom` 标签。
|
||||
|
||||
### 失败任务人工处理流改成站内分场景建议
|
||||
|
||||
- `生产中心` 里不再用“当前链路没有可自动恢复的模板,建议交给管理员处理”这种笼统提示。
|
||||
- 前端现在会按失败原因分流成更具体的站内处理建议:
|
||||
- 额度拦截
|
||||
- 上传素材缺失
|
||||
- 实拍剪辑缺少源任务
|
||||
- AI 视频缺少源任务
|
||||
- 内容源同步缺主页
|
||||
- 文本 / 链接缺输入
|
||||
- 通用站内处理
|
||||
- 每种场景都会直接给出更贴切的 CTA,比如 `去额度 / 重新上传 / 去导入主页 / 看源任务 / 交给主 Agent`,让失败任务不再断在泛泛提示层。
|
||||
|
||||
### AI 视频链兼容 Seedance 2.0
|
||||
|
||||
- `创建 AI 视频任务` 现在新增了 `视频引擎`、`引擎模型`、`镜头语言`、`运动节奏`、`风格约束` 和 `画幅`,可以直接用当前默认引擎或切到 `Seedance 2.0`。
|
||||
- 当前选择 `Seedance 2.0` 时,前端会把镜头语言、运动节奏和风格约束一起拼进视频 brief,不再只是把通用文案原样丢给视频链。
|
||||
- 后端新增了 `Seedance 2.0` 兼容归一化:
|
||||
- 对外仍记录真实 `video_provider = seedance2`
|
||||
- 对内渲染会按兼容映射转到当前可执行的视频引擎链
|
||||
- 同时保留 `video_dispatch_provider / video_dispatch_model / video_provider_label`
|
||||
- OneLiner 直接创建 AI 视频时,也会把 `video_provider / video_model` 一起透传,不再丢回默认视频引擎。
|
||||
- 生产侧回归新增了 `Seedance 2.0` 归一化断言,锁住 `/v2/pipelines/ai-video` 和主 Agent 创建链都必须正确带上 provider 信息。
|
||||
|
||||
## 2026-04-04
|
||||
|
||||
### 平台 Agent 变更后自动回到详情工作区
|
||||
|
||||
- `平台 Agent 配置保存 / 配置回滚 / 记忆保存 / 技能保存 / 技能验收 / 技能回滚` 这些动作成功后,不再只停在通用成功提示。
|
||||
- 前端现在会在动作完成后自动重开对应的平台 Agent 详情工作区,让用户立刻看到最新记忆、技能、最近执行和配置版本,不用自己再点回去确认结果。
|
||||
- 这条行为已经补进前端回归,锁住平台 Agent 相关变更必须能顺着同一个详情视图继续做下一步。
|
||||
|
||||
### 管理员治理保存后回到 Agent 治理区
|
||||
|
||||
- `系统主 Agent 策略`、`系统平台策略`、`OneLiner 动作注册表` 在管理员配置台里保存成功后,现在会自动回到 `管理员配置台 -> Agent 治理`。
|
||||
- 同时会尽量滚到最相关的区块:
|
||||
- 系统主 Agent / 系统平台策略回到治理摘要区
|
||||
- 动作注册表回到动作注册表区
|
||||
- 这样管理员连续调策略时,不需要每次保存后再自己切 tab 和找区块。
|
||||
|
||||
### 主 Agent 完成态保留精确对象上下文
|
||||
|
||||
- 主 Agent run 在创建时会把 `target_account_id / tracked_account_id / job_id / review_id / source_id / assistant_id` 这类对象上下文固化进执行计划,不再只记一个泛化的来源页面。
|
||||
- 完成态推荐动作现在会优先直接回到具体对象:可以直接打开当前账号、刷新当前跟踪对象、进入任务详情、打开复盘、继续录制维护,或回到刚才编辑的 Agent。
|
||||
- 前端推荐动作属性映射补齐了 `account_id / tracked_account_id / assistant_id`,当前运行卡、结果卡、最近动作卡和后续落点入口都能保住真实对象上下文。
|
||||
- 治理回归新增了“围绕当前账号继续分析”这条链路,锁住主 Agent 完成态结果必须返回 `select-account` 和真实 `account_id`。
|
||||
- NAS live collector 已完成热同步重建,线上验证通过:主 Agent 围绕当前账号继续推进时,完成态结果会直接返回 `select-account` 和目标 `account_id`,不再退回泛化的 `goto-discovery`。
|
||||
|
||||
### Live 文案与兜底反馈继续收口
|
||||
|
||||
- 首页 `重点账号 / 对标` 在没有跟踪对象时,提示改成 `先挑一个重点对象开始跟进`,不再用“等待接入”去误导成系统没接好。
|
||||
- 存储与录制相关说明改成真实 live 口径,聚焦“有文件时直接露出可回看入口”,不再写成“如果已经接入”这类半成品措辞。
|
||||
- 前端兜底动作提示改成 `暂未识别当前动作`,明确说明这是前端尚未识别精确落点,而不是产品能力没做完。
|
||||
- 平台运行时兜底文案也改成 `当前还没有可展示的工作台视图`,避免把非工作台平台一律描述成“待接入”。
|
||||
|
||||
### OneLiner 直接执行结果补齐精确落点
|
||||
|
||||
- OneLiner 直接执行动作现在统一返回结构化 `recommended_action`,不再只有“执行完成”说明块。
|
||||
- 这次补通的重点包括:
|
||||
- 平台自检会直接指向对应 `平台 Agent` 详情
|
||||
- 复盘草稿会直接打开对应复盘项
|
||||
- 导入主页和高分分析会直接回到 `找对标`
|
||||
- AI 视频 / 实拍剪辑会直接落到任务详情
|
||||
- 存储状态 / 录制状态 / 运维扫描会回到最合适的业务或治理页
|
||||
- 前端新增统一的 `buildRecommendedActionAttrs(...)`,把 `job_id / review_id / platform / source_id` 这类上下文一起带进最近动作卡和执行结果卡,后续新增直接动作时不用再重复拼接跳转参数。
|
||||
- 后端回归新增了 `review-draft / platform-self-check / generate-copy` 三类真实动作的推荐落点断言;前端回归则锁住了结果卡和最近动作卡必须使用统一的推荐动作属性映射。
|
||||
- 这轮还顺手修掉了一个真实 bug:保存录制源时,usage 记账错误地读取了 `binding["id"]`,现在已改成兼容 `binding_id / id`,不会再因为键名差异导致录制源创建链路直接报错。
|
||||
- 当前运行卡、最近完成、主 Agent 结果卡、平台 Agent 最近执行这几处“回到业务页”入口,现在也全部切到同一套结构化属性映射,不再只带 `run_id / screen`,从这些入口继续跳转时也能保留 `job_id / review_id / source_id` 这类精确上下文。
|
||||
|
||||
### 主 Agent 消息卡补齐配置追溯与主动作执行上下文
|
||||
|
||||
- OneLiner 助手消息卡里的 `主配置历史 / 平台配置历史` 现在终于拿到真实 `version_id`,不再出现“入口在,但打开后只能停在列表顶部”的半截体验。
|
||||
- 助手消息卡里的主动作也改成了和次级动作一致的执行标签:会把 `session_id / platform / executor_key / payload` 一起带上,后续再从消息卡直接执行时,不会丢掉真实上下文。
|
||||
- 后端回归新增了消息卡 `execution_card` 配置追溯断言,前端回归也锁住了主动作统一走 `actionTag + buildOnelinerActionAttrs`,避免后续又退回到只剩一个裸 `data-action`。
|
||||
|
||||
### 主 Agent 结果卡支持直达配置版本
|
||||
|
||||
- 主 Agent 当前运行卡、执行结果卡、平台 Agent 最近执行卡,现在不只显示 `配置 vN / 平台 Agent vN`,而且可以直接点进去打开对应的历史弹层。
|
||||
- 历史弹层新增“预选版本”能力:从执行结果进入时,会自动定位到本轮真实使用的那一版,不用再手动在版本列表里找。
|
||||
- 这条直达链已经补进前端回归,防止后续又退回成“只能看版本号,不能回到版本历史”。
|
||||
|
||||
### 主 Agent 配置漂移提示与平台执行追溯
|
||||
|
||||
- 主 Agent 当前运行卡、执行结果卡现在不只展示 `配置 vN`,还会在发现本轮执行使用的是旧版主配置或旧版平台 Agent 配置时,直接标出 `主配置已更新 / 平台 Agent 已更新`。
|
||||
- 对于失败、阻塞、取消后的主 Agent 运行,如果当前配置已经变更,重试入口会明确显示成 `按当前配置重跑`,不再让用户自己盯着版本号判断要不要重开。
|
||||
- 平台 Agent 的 `recent_execution` 现在补上了更完整的追溯字段:
|
||||
- `title / goal`
|
||||
- `platform_scope`
|
||||
- `delivery_mode`
|
||||
- `active_executor_key`
|
||||
- `source_action_key`
|
||||
- 平台 Agent 总览卡和详情弹层已经开始直接使用这些 live 字段,最近执行不再只是“做过一次主 Agent 任务”的摘要,而是一条可判断范围和执行模式的业务记录。
|
||||
- 前端工作台回归新增了:
|
||||
- 配置漂移提示与“按当前配置重跑”校验
|
||||
- 平台 Agent 最近执行 `title / platform_scope / delivery_mode` 展示校验
|
||||
- 后端治理回归也补上了 `recent_execution` 新字段断言,锁住这条主 Agent -> 平台 Agent 的执行追溯链。
|
||||
|
||||
### Playbook 与录制维护落点继续收口
|
||||
|
||||
- `创建 Agent / 编辑 Agent` 成功后,现在会直接回到 `Agent -> 当前 Agent / Agent 列表` 工作区,并把刚保存的 Agent 聚焦出来,不再只停在通用成功提示。
|
||||
- `新增录制源 / 编辑录制源 / 导入 URL 配置 / 启停录制源 / 删除录制源` 成功后,都会统一回到 `生产中心 -> 录制维护`,让用户顺着同一个维护区继续做下一步。
|
||||
- `当前 Agent` 面板新增显式锚点,Agent 列表项补了稳定的 `data-assistant-id`,前端回归也补齐到了这两条业务流,避免后续又退回成“成功了但要自己找结果”。
|
||||
|
||||
### 主 Agent 配置与执行落点继续收口
|
||||
|
||||
- 发现页里三类关键动作现在会落到更精确的业务区域:账号分析会直接切到快照/字段/报告区域,高分作品分析会直接滚到“最近高分拆解”,相似账号生成会直接滚到“相似对标 / 已绑关系”。
|
||||
- 复盘创建/更新完成后,不再只停留在通用成功提示,而是会自动回到“发布与复盘”,并把刚保存的复盘项聚焦出来。
|
||||
- 同一类“保存对标关系”动作也统一改成精确落到关系区域,避免成功后仍让用户自己再找结果在哪。
|
||||
|
||||
## 2026-03-30
|
||||
|
||||
### 主 Agent 治理与运行闭环
|
||||
|
||||
- 完成主 Agent 治理底座第一版,补齐系统策略、用户策略、管理员覆盖、历史版本与回滚。
|
||||
- 主 Agent 悬浮窗口已经接通运行创建、执行确认、进度追踪、结果查看、历史筛选和异常重试。
|
||||
- 业务页支持把上下文直接交给主 Agent,执行完成后会把结果和下一步动作回写到对应页面。
|
||||
|
||||
### Web 工作台信息架构
|
||||
|
||||
- 首页改成旧 UI 风格下的 `先动作、后概览` 结构,保留当前项目视角和 `1 主 2 次` 今日动作。
|
||||
- 非首页页面做了一轮可用性清理,重页面改成 tab / 分层结构,薄页面补齐首屏任务感。
|
||||
- 管理员配置台和用户侧页面边界进一步清晰,避免系统治理内容挤进普通工作流。
|
||||
|
||||
### 移动端原生适配
|
||||
|
||||
- Web V4 已补成移动端原生风工作台:移动头部、底部导航、项目切换带、底部面板式 OneLiner。
|
||||
- `找对标 / 生产中心 / Agent / 我的策略 / 我的项目 / 跟踪账号 / 复盘 / 额度 / 设置` 都增加了移动端任务卡和紧凑摘要。
|
||||
- 移动端抽屉、底部 sheet、项目切换、主 Agent 面板、结果提示等交互做了一轮真实收口。
|
||||
|
||||
### 真实能力对齐
|
||||
|
||||
- 清理了前端里一批“后端暂未接入”的旧占位文案,改成真实空状态和真实下一步。
|
||||
- `OneLiner 动作注册表 / 平台 Agent / 租户额度 / 复盘` 已按 live collector 实际能力展示,不再误导成“还没接”。
|
||||
- `额度` 和 `复盘` 页面首屏已改成围绕 live 数据的任务页,直接展示风险、主要消耗、高频结论和下一步动作。
|
||||
- `跟踪已读 / 批量跟踪同步 / 单账号跟踪同步 / 高分作品分析 / 平台技能验收` 已改成“真实调用优先”,避免旧 capability 口径把已接好的接口误判成未接入。
|
||||
- `OneLiner 会话 / 运行详情 / 治理控制面 / integrations / live-recorder` 这些固定接口也已经切成 live-first,请求失败才降级,不再先被陈旧 capability 表拦住。
|
||||
- 任务恢复链会优先真实调用 `/v2/explore/jobs/{job_id}/retry`,只有接口真的不存在时才回退到手动恢复模板。
|
||||
- `找对标 / 跟踪账号` 里一批已经失效的 “当前平台待接入” 按钮禁用与入口分支已删除,当前 active 平台都直接走真实路由,失败时再给真实反馈。
|
||||
- 工作台前端已经清掉浏览器 `alert` 弹窗,缺对象、权限不足、刷新失败和加载失败都会回到站内反馈,不再把用户从当前流程里打断出去。
|
||||
- `OneLiner 会话 / 主 Agent 运行 / 动作执行器 / 跟踪同步 / 高分分析 / 平台技能验收` 这批真接口也已经去掉“当前实例未提供”的旧降级口径,统一按 live 结果说话。
|
||||
- 新增一条前端回归护栏:静态声明出来的 `data-action` 必须有明确处理逻辑,避免后续再出现“点了没反应,最后落到动作待接入”的隐性缺口。
|
||||
- 后端契约测试新增 live-first 路由覆盖,直接校验 `分析高分作品 / 批量跟踪同步 / 单账号跟踪同步 / 跟踪游标` 这些当前前端已完全依赖的接口。
|
||||
- 后端契约测试继续向治理与运维面扩展,新增 `OneLiner 动作注册表 / 平台 Agent / 平台技能验收与回滚 / tenant quota & usage / admin ops 扫描与修复计划` 的 live 路由覆盖。
|
||||
- 修掉了平台 Agent 在“项目尚未绑定 assistant”时的真实外键问题:现在空项目也能先保存 OneLiner / 平台 Agent 配置,再逐步补齐执行 Agent,不会因为空 assistant_id 直接失败。
|
||||
- 主 Agent 治理测试的清库逻辑也收紧了,回归时不再因为外键残留跳过删除,避免后续新增治理测试后出现假红灯。
|
||||
|
||||
### NAS 联调与回归
|
||||
|
||||
- NAS 局域网联调链保持可用:
|
||||
- Web: `http://192.168.31.188:19192/`
|
||||
- Collector: `http://192.168.31.188:19193/healthz`
|
||||
- 当前基线通过:
|
||||
- 前端测试 `63/63`
|
||||
- 后端单测 `35/35`
|
||||
- `bash scripts/check_repo_baseline.sh`
|
||||
- `bash scripts/smoke_fnos_storyforge_lan.sh`
|
||||
|
||||
### 版本记录与 CI
|
||||
|
||||
- 新增仓库级 `CHANGELOG.md`,让 Gitea 仓库能直接看到阶段性更新记录。
|
||||
- 最小回归 workflow 同时落在 `.github/workflows/ci.yml` 和 `.gitea/workflows/ci.yml`,GitHub Actions 与 Gitea Actions 都能跑相同的基线、后端单测和 Web 测试。
|
||||
|
||||
## 2026-03-31
|
||||
|
||||
### 主 Agent 配置业务流收口
|
||||
|
||||
- 管理员配置台里的系统主 Agent、系统平台策略、管理员覆盖这条配置流补上了前端本地权限兜底,非超级管理员不会再直接撞到后端 403。
|
||||
- 管理员覆盖目标为空时,前端会明确提示“当前治理目录里还没有可选用户”,不再放出无效保存和回滚动作。
|
||||
- 管理员侧三类历史回滚弹层都改成了只读空态:没有历史版本时会隐藏提交按钮,也不会再让空 `version_id` 发起无效回滚请求。
|
||||
|
||||
### 配置流回归护栏
|
||||
|
||||
- Web 工作台测试新增了主 Agent 配置流空态保护和权限保护覆盖,重点锁住:
|
||||
- 管理员历史回滚空态
|
||||
- 管理员治理动作本地权限 guard
|
||||
- 管理员覆盖目标为空时的编辑/历史保护
|
||||
- 当前基线重新验证通过:
|
||||
- 前端测试 `66/66`
|
||||
- 后端单测 `35/35`
|
||||
- `bash scripts/check_repo_baseline.sh`
|
||||
- `bash scripts/smoke_fnos_storyforge_lan.sh`
|
||||
|
||||
### NAS 联调发布
|
||||
|
||||
- 最新 Web 已重新发布到 fnOS NAS:
|
||||
|
||||
### OneLiner 主配置版本化
|
||||
|
||||
- `OneLiner 主配置` 现在和策略治理层一样,已经支持版本历史、回滚和审计,不再是直接裸改。
|
||||
- 后端新增了 `GET /v2/oneliner/profile/versions`、`GET /v2/oneliner/profile/audits`、`POST /v2/oneliner/profile/rollback`,并让 `GET/PUT /v2/oneliner/profile` 直接返回当前版本、历史数量和最近审计。
|
||||
- 前端 `配置 OneLiner` 弹层补了当前版本摘要和变更原因,`Agent` 工作台也新增了 `看配置历史` 与 `历史与回滚` 入口。
|
||||
- 回滚会生成新的版本快照并保留审计链,不会直接覆盖旧记录。
|
||||
|
||||
### OneLiner 配置流回归
|
||||
|
||||
- 新增主配置版本历史和回滚测试,覆盖:
|
||||
- 初始化版本种子
|
||||
- 连续更新后的历史版本
|
||||
- 回滚生成新版本
|
||||
- 审计记录包含更新与回滚动作
|
||||
- 前端工作台测试也新增了 `OneLiner 主配置历史` 的回滚与审计入口校验。
|
||||
- 主 Agent 配置业务流的这轮修复已经同步到 Gitea,后续可以直接基于当前分支继续收剩余真实能力细节。
|
||||
|
||||
### OneLiner 配置版本进入执行链
|
||||
|
||||
- 主 Agent 在创建 run、重试 run 和完成 run 时,都会把当前 `OneLiner 主配置版本` 一起固化进治理快照和结果卡,不再只有治理页知道自己用了哪一版配置。
|
||||
- 完成态结果现在会带上 `execution_card.oneliner_profile_version`,前端浮窗、运行卡和结果卡都能统一显示 `配置 vN`,避免进入执行链后丢失配置来源。
|
||||
- run 的治理快照也收窄成“当前主配置 + 当前版本”最小运行态,不再把完整版本历史和审计链塞进每次执行记录,避免 `agent_runs.governance_json` 无限制膨胀。
|
||||
- Web 回归测试修正了 OneLiner 运行区函数边界,并新增了对执行链配置版本显示的断言;后端治理测试也补上了 run 完成态必须带配置版本的检查。
|
||||
|
||||
### 平台 Agent 配置进入执行链
|
||||
|
||||
- 主 Agent 在创建 run、重试 run 时,会把当前平台 Agent 的最小运行快照一起固化进治理快照,包括平台、Agent 名称、承接使命、assistant 名称和 readiness 状态。
|
||||
- 完成态结果现在会带上 `execution_card.platform_agent_profile`,前端浮窗、当前运行卡和结果卡都能直接看到“本轮平台 Agent”,执行链不会再丢失平台侧配置来源。
|
||||
- run 的平台 Agent 快照只保留运行时最小必要字段,不把完整平台 Agent 配置、技能列表和记忆列表塞进执行结果,避免执行记录继续膨胀。
|
||||
- Web 回归测试新增了对“本轮平台 Agent”结果渲染的断言;后端治理测试也补上了 run 创建态与完成态必须带平台 Agent 快照的检查。
|
||||
|
||||
### NAS 联调发布
|
||||
|
||||
- 最新 Web 已重新发布到 fnOS NAS:
|
||||
- Web: `http://192.168.31.188:19192/`
|
||||
- Collector: `http://192.168.31.188:19193/healthz`
|
||||
- 当前基线重新验证通过:
|
||||
- 前端测试 `67/67`
|
||||
- 后端单测 `36/36`
|
||||
|
||||
## 2026-04-04
|
||||
|
||||
### 平台 Agent 配置历史与回滚
|
||||
|
||||
- `平台 Agent 配置` 现在和 `OneLiner 主配置` 一样,已经支持版本历史、回滚和审计,不再只是直接编辑当前值。
|
||||
- 后端新增了:
|
||||
- `GET /v2/platform-agents/{platform}/profile/versions`
|
||||
- `GET /v2/platform-agents/{platform}/profile/audits`
|
||||
- `POST /v2/platform-agents/{platform}/profile/rollback`
|
||||
- `PUT /v2/platform-agents/{platform}/profile` 现在支持记录变更原因,并在保存时自动生成新的版本快照。
|
||||
- 前端 `平台 Agent 配置` 弹层新增当前版本摘要和变更原因,`平台 Agent 面板 / 详情` 也都新增了 `看配置历史` 入口。
|
||||
|
||||
### 平台 Agent 配置进入执行回写
|
||||
|
||||
- 主 Agent 在创建 run、重试 run、完成 run 时,都会把当前平台 Agent 配置版本号一起带入执行链。
|
||||
- 平台 Agent 的 `recent_execution` 现在会显示本轮使用的 `平台 Agent vN`,方便直接判断最近一次执行到底用了哪版平台配置。
|
||||
- run 完成态结果里的 `execution_card.platform_agent_profile` 也会携带平台 Agent 版本号、标题和摘要,悬浮主 Agent 结果卡能直接回看这轮平台配置来源。
|
||||
|
||||
### 回归护栏
|
||||
|
||||
- 后端治理测试新增了平台 Agent 配置版本链路覆盖:初始化版本、连续更新、回滚生成新版本、审计记录,以及执行完成后的 `recent_execution.platform_agent_profile_version_no` 回写。
|
||||
- 前端工作台测试新增了平台 Agent 配置历史入口、历史接口、回滚接口和结果卡版本显示的校验,避免后续再把这条链断开。
|
||||
- `bash scripts/check_repo_baseline.sh`
|
||||
- `bash scripts/smoke_fnos_storyforge_lan.sh`
|
||||
|
||||
## 2026-04-04
|
||||
|
||||
### CI / smoke 护栏加固
|
||||
|
||||
- `scripts/check_repo_baseline.sh` 现在会在校验 Web 资产时显式检查 `storyforge-*.js` 是否真的存在,避免后续打包产物变化后只留下一个“看起来在跑、实际漏掉文件”的空洞通过。
|
||||
- `scripts/smoke_fnos_storyforge_lan.sh` 现在对 `StoryForge` 首页、runtime 配置和 `19181` 兼容入口都做固定字符串断言;其中 `19181` 会校验真实兼容业务台文案,而不是只要抓取成功就算通过。
|
||||
- 这轮护栏加固保持了现有基线语义不变,只把原来偏宽松的检查收紧成可追踪的真实断言。
|
||||
|
||||
### 主 Agent 配置与执行结果继续打通
|
||||
|
||||
- `跟踪账号 -> 立即同步` 现在在同步成功后会自动打开对应 `sync_job_id` 的任务详情,不再停留在一条“已同步”的提示上。
|
||||
- 主 Agent 的执行结果卡、OneLiner 助手消息卡,现在都能直接跳转到 `主配置历史` 和 `平台 Agent 配置历史`,把一次执行和当时生效的治理版本真正连起来。
|
||||
- `execution_card` 里新增了主配置与平台 Agent 配置的 `version_id`,后续继续做更深的版本对比和追溯时不需要再靠标题文本猜版本。
|
||||
|
||||
### 平台 Agent 执行回写闭环
|
||||
|
||||
- 平台 Agent 配置现在不只是“被主 Agent 带进执行链”,还会在主 Agent 完成态后反向记录最近一次执行信息。
|
||||
- `platform_agent_profiles` 新增最近执行回写字段,保存:
|
||||
- 最近 run id
|
||||
- run 状态
|
||||
- 最近使用时间
|
||||
- 意图 key
|
||||
- 使用的 OneLiner 配置版本号
|
||||
- 执行摘要
|
||||
- 来源页面
|
||||
- `GET /v2/platform-agents` 现在会返回 `recent_execution`,平台 Agent 总览卡和详情弹层都会直接显示“最近执行”和“配置 vN”,方便追溯平台配置最近是怎么被主 Agent 用起来的。
|
||||
- 这条回写链已经覆盖到主 Agent 完成态读取路径,避免只在治理层能看到版本,执行面却看不到最近一次真实使用记录。
|
||||
|
||||
### 回归护栏
|
||||
|
||||
- 后端新增平台 Agent live 路由回写测试,确认:
|
||||
- 创建并确认一条主 Agent run 之后
|
||||
- `GET /v2/platform-agents` 能返回 `recent_execution`
|
||||
- 最近执行会带上 run id、intent 和 `oneliner_profile_version_no`
|
||||
- 前端工作台测试新增平台 Agent 最近执行渲染断言,锁住总览卡和详情弹层里的“最近执行”展示。
|
||||
|
||||
### 平台 Agent 最近执行继续处理
|
||||
|
||||
- 平台 Agent 总览卡和详情弹层里的“最近执行”现在都带上了直接动作,不再只是只读摘要。
|
||||
- 新增“查看执行结果”,会直接打开对应主 Agent run 的结果卡。
|
||||
- 新增“回到主 Agent 查看”,会切到对应 run 的上下文并打开主 Agent 悬浮窗口,方便顺着同一轮执行继续处理。
|
||||
- 前端回归也补上了这两个动作入口和事件处理器,避免后续又退回成只能展示、不能继续操作。
|
||||
|
||||
### 真实动作成功后的落点继续收口
|
||||
|
||||
- `加入跟踪 / 更新跟踪` 成功后,现在会直接切到 `跟踪账号` 工作区,不再只留一条成功提示。
|
||||
- `存对标 / 保存对标关系` 成功后,会直接把找对标详情切到 `关系` 视图,便于继续看刚保存的关系和候选。
|
||||
- `单任务恢复 / 批量恢复` 成功后,会优先打开新恢复出来的任务详情;如果没有拿到新任务 id,也会回到 `生产中心 -> 失败恢复`。
|
||||
- `生成文案` 成功后,会直接回到 `Agent` 工作区的“最近生成”结果区,而不是让用户自己找。
|
||||
|
||||
### 平台 Agent 最近执行字段补齐
|
||||
|
||||
- `recent_execution` 现在除了版本号和摘要,还会带:
|
||||
- `oneliner_profile_version_id`
|
||||
- `platform_agent_profile_version_id`
|
||||
- `recommended_action`
|
||||
- `workstream_key / workstream_label`
|
||||
- 平台 Agent 总览卡和详情弹层会直接利用这些字段渲染“回到业务页”动作,不需要先打开 run 详情再猜下一步。
|
||||
|
||||
### 回归护栏继续加固
|
||||
|
||||
- 前端工作台回归新增了:
|
||||
- 跟踪/对标成功后的页面落点校验
|
||||
- 恢复任务和文案生成的结果落点校验
|
||||
- 平台 Agent 最近执行 `recommended_action / workstream` 渲染校验
|
||||
- 后端治理回归新增了平台 Agent `recent_execution` 新字段断言,锁住:
|
||||
- 精确版本 id
|
||||
- 推荐业务动作
|
||||
- 工作流标签
|
||||
|
||||
### 治理保存后的工作区回跳
|
||||
|
||||
- OneLiner 主配置在保存和历史回滚成功后,会自动回到 `Agent -> 当前 Agent 工作台 -> OneLiner 主 Agent` 区块。
|
||||
- 用户全局策略、用户平台策略在保存和历史回滚成功后,会自动回到 `我的策略` 对应 tab,不再只停留在成功提示里。
|
||||
- 管理员覆盖策略在保存和历史回滚成功后,会自动回到 `管理员配置台 -> 覆盖与审计`,方便连续治理和审计查看。
|
||||
- 前端回归新增了这三条治理回跳断言,避免后续又退回成“改完策略后自己重新找页面”。
|
||||
|
||||
### 管理员治理剩余回跳补齐
|
||||
|
||||
- 管理员在切换“覆盖目标”后,会自动回到 `管理员配置台 -> 覆盖与审计`,直接进入当前目标的审计区。
|
||||
- 系统主 Agent 历史回滚、系统平台策略历史回滚完成后,会自动回到 `管理员配置台 -> Agent 治理`,方便连续调整系统默认策略。
|
||||
- 前端回归新增了这三条管理员治理落点断言,锁住“改完就能继续治理”的交互。
|
||||
|
||||
### 额度与管理员运维动作回跳补齐
|
||||
|
||||
- `租户额度` 保存后,现在会自动回到 `额度` 工作区的策略区域,不再只留一条成功提示。
|
||||
- `运维扫描 / 事件审计 / 修复计划生成 / 修复计划审计` 完成后,会统一回到 `管理员配置台 -> 运维审计`,方便连续处理下一条事件。
|
||||
- 前端回归新增了这批动作的 refocus 断言,并锁住了 `credits-quota-anchor` 与 `admin-ops-anchor` 两个工作区锚点。
|
||||
|
||||
### 跟踪与 Agent 切换顺手度补齐
|
||||
|
||||
- `跟踪摘要 -> 标记已读` 完成后,会自动回到 `跟踪账号` 工作区,方便继续处理当天的下一条跟踪任务。
|
||||
- `切换当前 Agent` 后,会自动回到 `Agent -> 当前 Agent 工作台`,并聚焦到当前选中的 Agent,而不是只在原地刷新一句提示。
|
||||
- 前端回归新增了这两条断言,锁住“切换完成后继续工作”的落点体验。
|
||||
|
||||
### 项目切换回到总台工作区
|
||||
|
||||
- 切换当前项目后,现在会自动回到 `项目总台` 的首页工作区,并聚焦到 dashboard 主内容,而不是只留在原地刷新。
|
||||
- 项目切换的移动端 sheet 和桌面项目切换入口都共用这条回跳逻辑,方便切完项目后立刻继续推进当前项目。
|
||||
- 前端回归新增了 dashboard 工作区锚点和项目切换 refocus 断言,锁住这条落点体验。
|
||||
|
||||
### 恢复链与额度文案收口
|
||||
|
||||
- `生产中心` 不再用“后续再补任务创建动作”这类半成品口径,当前页面直接按真实任务、恢复和复盘来表达。
|
||||
- 任务恢复链里的失败提示统一成“先补信息 / 需人工处理”,不再弹出“暂不支持自动恢复”这类生硬口径。
|
||||
- `额度` 页把“后续再接真实套餐”改成当前就能落地的套餐表达,明确按预算、动作池和项目阶段去配置套餐。
|
||||
|
||||
### 基于任务继续生产的视频动作统一改成 direct-execute
|
||||
|
||||
- 所有通过 `renderPipelineJobTag()` 渲染出来的 `做 AI 视频 / 做实拍剪辑`,现在都会直接走 `OneLiner` 执行器,不再落回旧的 `job-to-*` 表单打开流。
|
||||
- 从任务详情、复盘列表、生产中心等位置点这些动作时,会先关闭当前详情层,再直接创建对应任务并跳到真实任务详情,和文案生成链保持一致。
|
||||
- 前端回归新增了 `PIPELINE_GUARDS -> direct-create-*` 的断言,避免后续映射退回旧入口。
|
||||
|
||||
### 文案生成也并进 direct-execute
|
||||
|
||||
- `任务详情 -> 用摘要写文案` 和旧的 `job-to-generate-copy` 现在都会直接走 `OneLiner` 执行器,不再先弹回传统文案表单。
|
||||
- 这条链执行成功后,会把本轮生成结果直接回写到 `Agent -> 最近生成`,并自动回到对应锚点,用户不用再自己寻找结果。
|
||||
- 前端回归新增了这条 direct-execute 与结果回写断言,避免后续又退回“执行了但最近生成不更新”的半成品状态。
|
||||
|
||||
### 套餐档位与恢复引导继续补齐
|
||||
|
||||
- `额度` 页和租户额度编辑弹层新增了 `套餐档位` 与 `预算预警阈值`,现在能直接按试用、增长、规模、自定义四档去配置项目套餐。
|
||||
- 租户额度面板会直接展示当前套餐档位和预警阈值,便于把预算和动作池表达成正式产品能力,而不是只看裸配额数字。
|
||||
- 不可自动恢复的失败任务现在会打开站内“处理建议”面板,直接给出补信息、查看详情或交给主 Agent 的下一步,而不是只停在失败提示。
|
||||
|
||||
### 项目切换入口统一
|
||||
|
||||
- 所有 `select-project` 入口现在都统一走 `applySelectedProject()`,不再一部分入口回到项目总台、一部分入口只原地刷新。
|
||||
- 项目卡、项目 sheet 和其他项目切换入口都会在切换后回到 `项目总台` 主工作区,保证切完项目就能直接继续当前项目推进。
|
||||
|
||||
### 页面口径继续去掉半成品表达
|
||||
|
||||
- `Agent`、模型设置、跟踪、对标关系、复盘这些页面里的“后续再补”口径继续改成当前就能执行的表达,页面语气更像正式产品。
|
||||
- `创建 Agent / 编辑 Agent` 里的系统提示词占位改成“可先留空,后面随时补充”,减少半成品感。
|
||||
- `作品与成片`、Agent 执行项默认说明里的“再补”字眼也一起收掉,统一成当前可直接推进的表达。
|
||||
- 前端回归新增了这批文案断言,避免旧的占位口径再回流到主工作台。
|
||||
|
||||
### 依赖健康缺配置入口补齐
|
||||
|
||||
- 依赖健康卡片在“未配置地址”时,管理员可以直接点 `去管理员配置台` 继续配置。
|
||||
- 探测地址缺失文案改成“等待配置探测地址”,不再让人误以为系统异常。
|
||||
|
||||
### 主 Agent 可直接查相似与存对标
|
||||
|
||||
- `OneLiner / 主 Agent` 现在新增了 `直接查相似账号` 和 `直接存对标关系` 两条真实执行动作,不再只停留在“建议后跳回找对标”。
|
||||
- `直接查相似账号` 会调用当前平台的相似搜索接口,返回真实候选数量,并在有候选账号时直接落到该账号详情。
|
||||
- `直接存对标关系` 会优先复用最近一次相似搜索的候选,把它直接写入当前平台的对标关系,并把结果回写到找对标工作区。
|
||||
|
||||
### 找对标顶部动作改成 direct-execute
|
||||
|
||||
- `导入当前对标 / 加入跟踪 / 账号分析 / 高分分析 / 查相似 / 存对标` 这批高频动作现在默认直接执行,不再先开表单。
|
||||
- 执行后会按真实 `recommended_action` 继续落到任务详情、当前对象或关系区;只有当前没有可直接执行的候选时,才回退到原来的高级表单。
|
||||
- `接入当前项目` 卡片里的 `导入当前对标 / 加入跟踪` 也已统一切到 direct-execute,避免同一页面里出现新旧两套动作体验。
|
||||
|
||||
### 主 Agent 落点快捷动作继续下沉
|
||||
|
||||
- 主 Agent 落到 `找对标 / Agent / 生产中心 / 发布与复盘` 后,快捷动作里原先的 `高分分析 / 新建 Agent / 写复盘 / 做 AI 视频 / 做实拍剪辑` 已优先改成 direct-execute。
|
||||
- 这些动作现在直接调用 `OneLiner` 执行器并按真实结果继续落到对象详情、Agent 编辑页、复盘页或任务详情,而不是先打开旧表单。
|
||||
- `review-draft` 现在支持显式 `source_job_id`,所以从任务详情、复盘页和最近完成任务入口点“写复盘”,会围绕指定任务直接生成草稿,不再总是退回“最近一条任务”。
|
||||
|
||||
### 导入与跟踪表单统一收进执行器
|
||||
|
||||
- `导入主页 / 导入当前对标 / 加入跟踪 / 导入作品链接 / 导入文本` 这批高频表单现在都统一走 `OneLiner` 执行器,不再一部分直接调业务接口、一部分走主 Agent。
|
||||
- 后端新增了 `import-video-link / import-text` 两条真实执行动作,并且 `generate-copy / import-homepage / track-account / create-ai-video` 现在都会优先尊重显式 `assistant_id`,避免切到执行器后丢失用户在表单里选定的 Agent。
|
||||
- `runDirectWorkbenchAction / runDirectDiscoveryAction` 也已支持显式 `projectId / platform`,所以这批旧表单里的“归属项目 / 平台”选择不会在切换到执行器后失效。
|
||||
- SQLite 连接现在保持 `WAL` 优先,但在临时盘或受限文件系统无法启用 `WAL` 时会自动回退到 `DELETE`,避免测试环境和受限部署因为 `disk I/O error` 直接起不来。
|
||||
- `generate-copy` 这条执行链现在会直接推荐回到“最近生成”结果区,而不是再打开旧文案表单;LAN smoke 也同步把 `track-account / import-video-link / import-text / generate-copy / create-assistant` 纳入 action-registry 护栏。
|
||||
- 全局 `AI 视频 / 实拍剪辑` 主按钮也已经切到 direct-execute,会直接承接最近可派生任务,不再优先打开旧表单。
|
||||
- 全局 `写复盘` 旧入口现在也会优先围绕最近已完成任务 direct-execute;只有当前项目还没有可承接任务时,才回退到手工复盘表单。
|
||||
- 全局 `生成文案` 旧入口也已经做成相同分流:优先围绕最近完成任务直接生成,只有没有可承接任务时才回退到旧文案表单。
|
||||
# 2026-04-05
|
||||
|
||||
- intake: `导入作品 / 导入文本 / 上传视频` 现在会先显示当前项目、默认 Agent 和默认知识库的上下文摘要,并预填更贴近当前工作流的标题提示。
|
||||
- intake: 遗留 `导入主页` 入口现在会优先复用当前选中对标的主页链接 direct-execute,只有缺少选中对象或主页链接时才回退到表单。
|
||||
- agent: 遗留 `创建 Agent` 入口现在也会优先 direct-execute,当前项目已就绪时直接创建 Agent,只有缺上下文时才回退到旧表单。
|
||||
- pipeline: 全局旧入口 `AI 视频 / 实拍剪辑` 现在也会优先围绕最近完成任务 direct-execute,只有没有可承接任务时才回退到旧表单。
|
||||
- review: `任务详情 -> 写复盘` 旧入口改成 direct-execute,带 `source_job_id` 直接生成复盘草稿,不再优先打开旧复盘表单。
|
||||
# 2026-04-06
|
||||
|
||||
- 修复 fnOS `live_recorder` 部署链,改成同步 `DouyinLiveRecorder-main` 源码到 NAS 并在 NAS 构建,避免错误预构建镜像里缺少 `webui.py` 导致容器启动即失败。
|
||||
- 新增 `scripts/deploy_fnos_storyforge_live_recorder.sh`,并把 live recorder 并入 `deploy_fnos_storyforge_lan_stack.sh`。
|
||||
- `smoke_fnos_storyforge_lan.sh` 新增 `live_recorder` 健康检查,后续 NAS 重启或版本更新后能直接发现录制服务回退。
|
||||
# 2026-04-06
|
||||
|
||||
- Added fnOS-native deployment assets for StoryForge local dependencies:
|
||||
- `cli-proxy-api` model gateway on `:8317`
|
||||
- `n8n` on `:5670`
|
||||
- `huobao-drama` on `:5678`
|
||||
- Extended `deploy_fnos_storyforge_lan_stack.sh` so the NAS LAN stack can recreate model gateway, n8n, huobao, live recorder, collector and web from repo-managed assets.
|
||||
- Switched collector fnOS defaults away from the Mac host for `LOCAL_OPENAI_BASE_URL`, `N8N_BASE_URL`, and `HUOBAO_BASE_URL`, so the NAS stack no longer depends on local disk-hosted services for those routes.
|
||||
# 2026-04-06
|
||||
|
||||
## 公网模型 / Windows ASR 收口
|
||||
|
||||
- 默认不再为 fnOS collector 注入 `LOCAL_OPENAI_BASE_URL`,避免运行链继续误依赖本机 `8317`
|
||||
- 公网 collector 示例配置改为显式禁用 `local_model`,并把 `ASR` 桥接端口切到 `127.0.0.1:28088`
|
||||
- 新增 Windows `ASR HTTP` 服务资产,兼容 StoryForge 当前 `/transcribe` 协议,便于把 ASR 迁到 Windows 主机 `192.168.31.18`
|
||||
- Windows 端新增 `ASR` 启动脚本、云端桥接脚本与计划任务注册脚本,并放通 `8088` 入站,保证局域网和公网都可直连该 `ASR` 服务
|
||||
- 创作类表单的来源任务联动继续收口:`写复盘` 现在切换来源任务时,会同步推荐更合适的负责 Agent,并即时刷新顶部当前上下文摘要,避免标题、平台已经切过去了但负责人和上下文还停在旧任务上。
|
||||
- 套餐/额度页面补上“剩余额度预测”:额度页、额度面板和套餐预览现在都会明确显示剩余预算、剩余文案、剩余 AI 视频、剩余实拍和剩余存储,不再只展示总预算和总配额。
|
||||
- `创建 Agent / 编辑 Agent` 这两张表单也补成了带上下文和知识库联动的产品化表单:创建时切项目会同步刷新默认知识库,编辑时可以直接更新默认知识库,不必再回别处改。
|
||||
- 额度页残留的半成品口径已收口,不再出现“后端尚未完全接入真实预算”这类提示;未配置独立额度策略时,会直接引导按预算基线和动作池去建立试用、增长或规模套餐。
|
||||
- `smoke_public_storyforge.sh` 和 `smoke_fnos_storyforge_lan.sh` 现在会显式校验 `integrations/health` 的关键依赖状态、部署位置和 `local_model=not_configured` 口径,不再只看页面能打开和基础 healthz。
|
||||
|
||||
# 2026-04-07
|
||||
|
||||
- 顶层 `AI 视频 / 实拍剪辑` 主按钮改回“先开配置表单”,会自动承接最近完成任务作为默认来源,但不再直接跳过配置页;只有任务上下文里的 `做 AI 视频 / 做实拍剪辑` 仍保持 direct-execute。
|
||||
- `AI 视频` 表单新增 `Seedance 配置` 提示,明确说明当前 `Seedance 2.0` 走火山视频配置,默认应在 `Huobao /settings/ai-config -> 视频 -> 火山引擎` 配置;如果不用页面配置,也支持通过 `HUOBAO_VIDEO_BASE_URL / HUOBAO_VIDEO_API_KEY / HUOBAO_VIDEO_MODELS` 环境变量覆盖。
|
||||
- `integrations/health` 新增 `huobao` 视频配置摘要,能直接看出当前 `Huobao` 视频配置页是否已经录入视频引擎配置,以及对应的配置页路径,减少排查 `Seedance` 任务为什么只建单不出片的歧义。
|
||||
- 首页 `1 主 2 次` 动作里把 `视频录制` 抬成了高频次级动作,当前项目有生产任务时能更快进入录制维护入口。
|
||||
- `AI 视频` 表单开始直接显示“当前项目最近使用的视频引擎”,像 `Seedance 2.0 · seedance-2.0-pro` 这类信息会在打开表单时直接可见,并保留跳到火山配置状态的入口。
|
||||
- `生产中心` 现在把 `导入主页 / 导入作品 / 导入文本 / 上传视频` 这批接入入口统一接进了顶部动作区和生产队列工作流卡,不用离开生产中心也能开始接入素材。
|
||||
- `生产中心` 的 `生产队列` 首屏现在会直接显示 `接入与录制` 概况:最近内容源同步条数、录制源数量、录制服务状态,以及一组直达 `导入主页 / 导入作品 / 视频录制` 的动作。
|
||||
@@ -13,5 +13,6 @@
|
||||
- 使用 `whisper.cpp` 转写,若环境未就绪则保留原始素材并进入降级流程
|
||||
6. collector-service 调用本机 OpenAI 兼容模型提炼文案风格
|
||||
7. 结果写入用户自己的知识库文档
|
||||
8. 如有需要,可继续同步到租户自己的外部知识系统
|
||||
8. 如果配置了 `FASTGPT_DATASET_API_KEY`
|
||||
- 同步到 FastGPT 数据集
|
||||
9. 文案助手生成时按知识库关联关系取素材,结合提示词输出文案
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
1. Cloud Server
|
||||
2. Mac AI Node
|
||||
3. Local Runtime Services
|
||||
3. FastGPT
|
||||
4. Backend API
|
||||
5. Web Console
|
||||
6. Android Client
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
|
||||
The Mac node should only do the following:
|
||||
|
||||
1. Deploy StoryForge collector-service locally
|
||||
2. Ensure the cloud backend can reach collector-service
|
||||
1. Deploy FastGPT locally
|
||||
2. Ensure the cloud backend can reach FastGPT
|
||||
3. Maintain a private network connection to the server
|
||||
4. Provide the collector-service endpoint to the backend
|
||||
4. Provide the FastGPT endpoint to the backend
|
||||
|
||||
Recommended ports:
|
||||
|
||||
- Collector Service: 8081
|
||||
- n8n: 5670
|
||||
- Local Model API: 8317
|
||||
- ASR: 8088
|
||||
- FastGPT: 3000
|
||||
- MongoDB: 27017
|
||||
- PostgreSQL: 5432
|
||||
- Redis: 6379
|
||||
- MinIO: 9000
|
||||
|
||||
The local admin/control surfaces must not be exposed to the public internet directly.
|
||||
FastGPT must not be exposed to the public internet directly.
|
||||
|
||||
@@ -2,11 +2,13 @@ You are responsible for the StoryForge Mac AI node.
|
||||
|
||||
Tasks:
|
||||
|
||||
- Deploy StoryForge runtime services on the Mac node.
|
||||
- Deploy FastGPT using Docker.
|
||||
- Services:
|
||||
- collector-service
|
||||
- n8n
|
||||
- cli-proxy-api
|
||||
- FastGPT
|
||||
- MongoDB
|
||||
- PostgreSQL + pgvector
|
||||
- Redis
|
||||
- MinIO
|
||||
- Build collector-service in Python.
|
||||
- Collector features:
|
||||
- yt-dlp video download
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Mac Node Connectivity
|
||||
|
||||
- FastGPT 默认本机端口:`3000`
|
||||
- Collector Service 默认本机端口:`8081`
|
||||
- Local OpenAI Compatible API:`127.0.0.1:8317/v1`
|
||||
- n8n 默认本机端口:`5670`
|
||||
- 如需通过云端访问,优先使用内网或隧道,不直接暴露 Mac 上的本地管理接口
|
||||
- 如需通过云端访问,优先使用内网或隧道,不直接暴露 Mac 上的 FastGPT 管理接口
|
||||
|
||||
201
README.md
201
README.md
@@ -2,216 +2,39 @@
|
||||
|
||||
StoryForge 现在拆成独立项目目录,和 `AI-glasses` 分开维护。
|
||||
|
||||
仓库边界和维护约束见:[StoryForge 仓库边界说明](./docs/STORYFORGE_REPO_BOUNDARY_2026-03-26.md)。
|
||||
拆分治理方案见:[StoryForge / AI Glasses 拆分评估方案](./docs/STORYFORGE_SPLIT_ASSESSMENT_2026-03-26.md)。
|
||||
当前项目状态见:[StoryForge 当前项目状态](./docs/CURRENT_PROJECT_STATE_2026-03-26.md)。
|
||||
阶段性版本更新记录见:[StoryForge Changelog](./CHANGELOG.md)。
|
||||
`AI-glasses` 独立代码仓库已单独维护在 [krisolo/ai-glasses](https://git.hyzq.site/krisolo/ai-glasses)。
|
||||
|
||||
## 目录
|
||||
|
||||
- `collector-service/`:FastAPI 后端,负责用户体系、项目、Agent、任务、内容分析和对外能力接入
|
||||
- `web/storyforge-web-v4/`:正式 Web 工作台,承接多平台运营、对标、跟踪、生产和复盘入口
|
||||
- `n8n/`:工作流导出文件,作为流程编排中枢
|
||||
- `docker-compose.yml`:本地 `collector + n8n + cli-proxy-api` 编排
|
||||
- `android-app/`:StoryForge Android 客户端
|
||||
- `collector-service/`:FastAPI 后端,提供登录、审批、素材导入、知识库、智能体和 OTA
|
||||
- `docker-compose.yml`:本地 FastGPT / collector / 基础依赖编排
|
||||
- `Common/`:项目约束和架构说明
|
||||
- `data/collector/`:SQLite、任务文件、下载产物
|
||||
- `docs/`:审计、实施计划、联调说明、当前 MVP 状态
|
||||
|
||||
## CI
|
||||
|
||||
仓库里的最小 CI workflow 同时放在 [`.github/workflows/ci.yml`](/Users/kris/code/StoryForge-gitea/.github/workflows/ci.yml) 和 [`.gitea/workflows/ci.yml`](/Users/kris/code/StoryForge-gitea/.gitea/workflows/ci.yml),这样 GitHub Actions 和 Gitea Actions 都会在 `push`、`pull_request` 和 `workflow_dispatch` 时运行基线检查、后端单元测试和 Web Node 测试。
|
||||
|
||||
## 产品手册
|
||||
|
||||
- [新媒体运营中台产品逻辑手册](./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)(国内平台 UI 承载,当前已接上 `douyin / xiaohongshu / bilibili / kuaishou / wechat_video` 统一工作台)
|
||||
- [Mobile V4 UI 原型](./output/ui/storyforge-mobile-v4-html-prototype-2026-03-22/README.md)(仅 UI 原型,不代表当前仓库承载 Android 工程)
|
||||
|
||||
## Douyin Browser Capture
|
||||
## Android
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/StoryForge-gitea
|
||||
./scripts/start_douyin_workbench.sh
|
||||
cd /Users/kris/code/StoryForge/android-app
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
业务页:
|
||||
|
||||
```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 storyforge-admin \
|
||||
--storyforge-password 'your_admin_password'
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 这是“真实浏览器 + 人工登录/过挑战 + 自动提取 + 回写 StoryForge”的辅助采集工具
|
||||
- 默认输出到 `output/playwright/douyin/`
|
||||
- 本地控制台模式会把每次运行保存到 `output/playwright/douyin/control-panel/`
|
||||
- 控制台支持“开始采集 -> 浏览器登录 -> 网页点继续 -> 自动同步”的点击式流程
|
||||
- 详细说明见 `scripts/douyin-browser-capture/README.md`
|
||||
|
||||
## Collector Service
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/StoryForge-gitea/collector-service
|
||||
cd /Users/kris/code/StoryForge/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
|
||||
```
|
||||
|
||||
首次启动前,至少补齐这些配置:
|
||||
|
||||
```bash
|
||||
ORCHESTRATOR_SHARED_SECRET=your_strong_shared_secret
|
||||
BOOTSTRAP_SUPERADMIN_USERNAME=storyforge-admin
|
||||
BOOTSTRAP_SUPERADMIN_PASSWORD=your_strong_admin_password
|
||||
```
|
||||
|
||||
如果希望 Web 端打开后直接自动建会话,不让用户手动输入账号密码,再额外打开:
|
||||
|
||||
```bash
|
||||
WEB_AUTOLOGIN_ENABLED=1
|
||||
WEB_AUTOLOGIN_ACCOUNT_USERNAME=your_existing_approved_username
|
||||
```
|
||||
|
||||
推荐直接指定一个已经存在且已审批通过的账号用户名,服务端会直接为该账号签发自动会话,不需要额外保存该账号密码。
|
||||
|
||||
如果你更希望复用 bootstrap 超级管理员口令,或者切到专门账号,也可以继续走密码模式:
|
||||
|
||||
```bash
|
||||
WEB_AUTOLOGIN_USERNAME=your_autologin_username
|
||||
WEB_AUTOLOGIN_PASSWORD=your_autologin_password
|
||||
```
|
||||
|
||||
如果要让本机模型网关 `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`
|
||||
- 公网入口:`https://storyforge.hyzq.net/`
|
||||
|
||||
公网维护常用脚本:
|
||||
|
||||
```bash
|
||||
./scripts/smoke_public_storyforge.sh
|
||||
./scripts/deploy_public_storyforge.sh
|
||||
```
|
||||
|
||||
首次启动时,如果数据库里还没有 `super_admin`,`collector-service` 会按
|
||||
`BOOTSTRAP_SUPERADMIN_USERNAME / BOOTSTRAP_SUPERADMIN_PASSWORD / BOOTSTRAP_SUPERADMIN_DISPLAY_NAME`
|
||||
创建最高权限账号。未配置时不会再自动写入默认口令账号。
|
||||
|
||||
如果开启了 `WEB_AUTOLOGIN_ENABLED=1`,前端会在启动时直接请求 `/v2/auth/auto-session` 自动建会话,不再显示用户名 / 密码 / token 输入流程。推荐优先使用 `WEB_AUTOLOGIN_ACCOUNT_USERNAME`,只在必须时才使用 `WEB_AUTOLOGIN_USERNAME / WEB_AUTOLOGIN_PASSWORD`。
|
||||
|
||||
## 当前架构
|
||||
|
||||
- `collector-service` 负责:
|
||||
- 用户账号、多项目、多 Agent、多任务、多内容源数据边界
|
||||
- 调用下载器、本地 ASR、本机 OpenAI 兼容模型
|
||||
- 调用 Windows `cutvideo` 和 `huobao-drama`
|
||||
- 持久化任务、分镜、分析结果、事件日志
|
||||
- `n8n` 负责:
|
||||
- 触发 `analysis_pipeline`
|
||||
- 触发 `content_source_sync_pipeline`
|
||||
- 触发 `real_cut_pipeline`
|
||||
- 触发 `ai_video_pipeline`
|
||||
- 历史旧运行链已完成移除,当前运行时只保留 StoryForge 自身服务与外部执行引擎
|
||||
- 当前公网接入采用“云服务器 HTTPS 入口 + 云服务器本地 collector + 本地桥接执行引擎”模式:
|
||||
- `https://storyforge.hyzq.net/` 由云服务器 `nginx` 提供 HTTPS 入口
|
||||
- `/` 静态页由云服务器本地 `StoryForge Web V4` 直出
|
||||
- `/v2/*`、`/openapi.json`、`/healthz` 反向代理到云服务器本地 `collector-service`
|
||||
- 业务数据库已上云,当前路径为云服务器本地 `storyforge.db`
|
||||
- `n8n / cutvideo / huobao / 本机模型 / ASR / NAS 录制` 继续由本机和局域网执行链提供,并通过受控桥接暴露给云上的 `collector-service`
|
||||
- `kris`
|
||||
- `Asd123456.`
|
||||
|
||||
## 说明
|
||||
|
||||
- 新注册账号默认 `pending`
|
||||
- 主管理员审批后才可使用核心业务接口
|
||||
- 支持 `user -> project -> knowledge base / assistant(agent) / job / content source` 的多租户边界
|
||||
- 素材入口支持文字、视频链接、视频上传;内容源账号通过 `content_sources` 建模持久化,并可派生父子分析任务
|
||||
- `cutvideo` 继续运行在 Windows 机器,本系统通过 API 调度
|
||||
- fnOS / 局域网调试环境下,`cutvideo` 建议通过 NAS SSH 隧道接入,默认入口为 `http://192.168.31.188:19186`
|
||||
- `huobao-drama` 继续作为 AI 生成视频主链的核心引擎
|
||||
- 详细审计、阶段计划和联调步骤见 `docs/`
|
||||
- Windows `cutvideo` 的恢复与常驻维护见 [`WINDOWS_CUTVIDEO_OPERATIONS_2026-03-27.md`](/Users/kris/code/StoryForge-gitea/docs/WINDOWS_CUTVIDEO_OPERATIONS_2026-03-27.md)
|
||||
|
||||
fnOS / NAS 局域网交付默认三步:
|
||||
|
||||
```bash
|
||||
./scripts/deploy_fnos_cutvideo_tunnel.sh
|
||||
./scripts/deploy_fnos_storyforge_lan_stack.sh
|
||||
./scripts/smoke_fnos_storyforge_lan.sh
|
||||
```
|
||||
|
||||
这套顺序会先把 Windows `cutvideo` 通过 NAS SSH 隧道暴露到 `19186/19181`,再把 StoryForge 的 NAS 侧联调用默认主链切到 `http://192.168.31.188:19186`,最后用一键 smoke 校验整条链路。
|
||||
- 素材入口支持文字、视频链接、视频上传
|
||||
- 可选对接本机 OpenAI 兼容模型服务和 FastGPT 数据集 API
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
## Core Components
|
||||
|
||||
- Android App: 素材探索、文案生产、个人配置、管理员审批、OTA
|
||||
- Collector Service: FastAPI + SQLite,负责多租户业务边界、任务状态和 Agent 数据
|
||||
- n8n: 负责异步流程编排与 webhook 触发
|
||||
- Collector Service: FastAPI + SQLite,负责业务流程编排
|
||||
- Local Model API: 默认指向本机 `cli-proxy-api`
|
||||
- NAS / 外部执行器: 负责大文件缓存、录像、剪辑和 AI 视频执行
|
||||
- FastGPT: 负责数据集和后续工作流扩展
|
||||
- MongoDB / PostgreSQL + pgvector / Redis / MinIO: FastGPT 运行依赖
|
||||
|
||||
## Main Flow
|
||||
|
||||
User -> Android App / Web / OneLiner -> Collector Service -> Local Model / n8n / 执行引擎
|
||||
User -> Android App -> Collector Service -> Local Model / FastGPT
|
||||
|
||||
## Data Isolation
|
||||
|
||||
@@ -23,4 +23,4 @@ User -> Android App / Web / OneLiner -> Collector Service -> Local Model / n8n /
|
||||
- `model_profiles`
|
||||
- `app_updates`
|
||||
|
||||
每个用户/项目的数据通过 `user_id + project_id` 进行隔离。
|
||||
每个用户的数据通过 `user_id` 进行隔离。
|
||||
|
||||
44
android-app/README.md
Normal file
44
android-app/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# AI Glasses Android App
|
||||
|
||||
Demo Android client for backend API validation and BLE integration scaffold.
|
||||
|
||||
## What is implemented
|
||||
|
||||
- Backend API calls:
|
||||
- `bind-confirm`
|
||||
- `create session`
|
||||
- `stop session`
|
||||
- `device status`
|
||||
- Compose UI for debug flow
|
||||
- Hichips BLE protocol manager:
|
||||
- service/char: `3D20(3D21/3D22/3D23)`, `5DC0(5DC1/5DC2/5DC3)`
|
||||
- packet codec: `HICH + Command + Index + Length + CRC16 + Data + IPSE`
|
||||
- handshake flow (`AG_CMD_HS_DEV_UUID` -> `AG_CMD_HS_APP_UUID` -> `AG_CMD_HS_DEV_INFO`)
|
||||
- wake-up audio uplink (`ASR_*` commands, audio from `5DC2`)
|
||||
- camera trigger (`AG_CMD_P_TAKE_START`) and thumbnail events
|
||||
- New "开始对话(硬件)" button:
|
||||
- BLE scan/connect -> handshake -> backend bind/create session
|
||||
- start wake-up audio stream + periodic camera capture
|
||||
- app reports aggregated audio/camera relay stats to backend events
|
||||
|
||||
## Default backend
|
||||
|
||||
The app is hardcoded to:
|
||||
|
||||
`http://test.hyzq.net`
|
||||
|
||||
## Build APK
|
||||
|
||||
Open this folder in Android Studio:
|
||||
|
||||
`/Users/kris/code/AI-glasses/android-app`
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
APK output:
|
||||
|
||||
`app/build/outputs/apk/debug/app-debug.apk`
|
||||
86
android-app/app/build.gradle.kts
Normal file
86
android-app/app/build.gradle.kts
Normal file
@@ -0,0 +1,86 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.aiglasses.app"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.storyforge.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 37
|
||||
versionName = "0.6.4"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
buildConfigField("String", "DEFAULT_STORYFORGE_BASE_URL", "\"https://test.hyzq.net/storyforge\"")
|
||||
buildConfigField("String", "DEFAULT_STORYFORGE_FALLBACK_IP", "\"111.231.132.51\"")
|
||||
buildConfigField("String", "DEFAULT_LOCAL_MODEL_BASE_URL", "\"http://127.0.0.1:8317/v1\"")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.14"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2025.02.00")
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
|
||||
implementation("androidx.core:core-ktx:1.15.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||
implementation("androidx.activity:activity-compose:1.10.0")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
|
||||
|
||||
implementation("androidx.camera:camera-core:1.4.2")
|
||||
implementation("androidx.camera:camera-camera2:1.4.2")
|
||||
implementation("androidx.camera:camera-lifecycle:1.4.2")
|
||||
implementation(files("libs/brtc-3.5.0.1a.aar"))
|
||||
implementation(files("libs/lib_agent-1.0.1.4.aar"))
|
||||
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||
implementation("com.squareup.retrofit2:retrofit:2.11.0")
|
||||
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
}
|
||||
BIN
android-app/app/libs/brtc-3.5.0.1a.aar
Normal file
BIN
android-app/app/libs/brtc-3.5.0.1a.aar
Normal file
Binary file not shown.
BIN
android-app/app/libs/lib_agent-1.0.1.4.aar
Normal file
BIN
android-app/app/libs/lib_agent-1.0.1.4.aar
Normal file
Binary file not shown.
2
android-app/app/proguard-rules.pro
vendored
Normal file
2
android-app/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Keep default for demo stage.
|
||||
|
||||
44
android-app/app/src/main/AndroidManifest.xml
Normal file
44
android-app/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@android:drawable/sym_def_app_icon"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@android:drawable/sym_def_app_icon"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.AIGlasses">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.aiglasses.app
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.aiglasses.app.storyforge.StoryForgeScreen
|
||||
import com.aiglasses.app.storyforge.StoryForgeViewModel
|
||||
import com.aiglasses.app.ui.theme.AIGlassesTheme
|
||||
import com.aiglasses.app.update.AppOtaUpdater
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
AIGlassesTheme {
|
||||
val vm: StoryForgeViewModel = viewModel()
|
||||
val state by vm.state.collectAsState()
|
||||
val otaUpdater = AppOtaUpdater(this) { vm.onOtaLog(it) }
|
||||
DisposableEffect(Unit) {
|
||||
otaUpdater.register()
|
||||
onDispose { otaUpdater.release() }
|
||||
}
|
||||
val videoPicker = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument()
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
val fileName = contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||
if (nameIndex >= 0 && cursor.moveToFirst()) cursor.getString(nameIndex) else null
|
||||
} ?: (uri.lastPathSegment ?: "selected-video.mp4")
|
||||
vm.setPickedVideo(uri, fileName)
|
||||
}
|
||||
}
|
||||
StoryForgeScreen(
|
||||
state = state,
|
||||
vm = vm,
|
||||
onPickVideo = { videoPicker.launch(arrayOf("video/*")) },
|
||||
onInstallLatestUpdate = { vm.installLatestUpdate(otaUpdater) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
package com.aiglasses.app.ble
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothGattService
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.bluetooth.BluetoothStatusCodes
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanFilter
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.ParcelUuid
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.ArrayDeque
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.json.JSONObject
|
||||
|
||||
private const val MAX_FRAME_DATA = 8 * 1024
|
||||
|
||||
data class BleLinkState(
|
||||
val scanning: Boolean = false,
|
||||
val connected: Boolean = false,
|
||||
val notificationsReady: Boolean = false,
|
||||
val handshaked: Boolean = false,
|
||||
val deviceName: String = "",
|
||||
val deviceAddress: String = "",
|
||||
val devUuid: String = "",
|
||||
val lastError: String = ""
|
||||
)
|
||||
|
||||
sealed interface GlassesBleEvent {
|
||||
data class Log(val message: String) : GlassesBleEvent
|
||||
data class HandshakeOk(
|
||||
val devUuid: String,
|
||||
val devName: String,
|
||||
val devFwVer: String
|
||||
) : GlassesBleEvent
|
||||
data class StatusUpdate(val payloadJson: String) : GlassesBleEvent
|
||||
data class AudioFrame(val bytes: ByteArray, val index: Int) : GlassesBleEvent
|
||||
data class CameraThumbInfo(val sourceFileName: String, val isVideo: Boolean) : GlassesBleEvent
|
||||
data class CameraThumbData(val bytes: ByteArray, val index: Int, val isVideo: Boolean) : GlassesBleEvent
|
||||
}
|
||||
|
||||
private data class HichipsFrame(
|
||||
val command: Int,
|
||||
val index: Int,
|
||||
val payload: ByteArray
|
||||
)
|
||||
|
||||
private object HichipsUuid {
|
||||
val service3D20: UUID = shortUuid("3d20")
|
||||
val char3D21Notify: UUID = shortUuid("3d21")
|
||||
val char3D22NotifyData: UUID = shortUuid("3d22")
|
||||
val char3D23Write: UUID = shortUuid("3d23")
|
||||
|
||||
val service5DC0: UUID = shortUuid("5dc0")
|
||||
val char5DC1Notify: UUID = shortUuid("5dc1")
|
||||
val char5DC2NotifyData: UUID = shortUuid("5dc2")
|
||||
val char5DC3Write: UUID = shortUuid("5dc3")
|
||||
|
||||
val cccd: UUID = shortUuid("2902")
|
||||
|
||||
private fun shortUuid(hex: String): UUID {
|
||||
return UUID.fromString("0000${hex.lowercase()}-0000-1000-8000-00805f9b34fb")
|
||||
}
|
||||
}
|
||||
|
||||
private object HichipsCmd {
|
||||
// 5DC0 wake-up stream commands
|
||||
const val ASR_DEV_WAKE_UP = 0x0000
|
||||
const val ASR_APP_WAKE_UP = 0x0001
|
||||
const val ASR_TRANS_SETTING = 0x0002
|
||||
const val ASR_TRANS_START = 0x0003
|
||||
const val ASR_TRANS_FLOW_CTRL = 0x0004
|
||||
const val ASR_TRANS_AUDIO = 0x0005
|
||||
const val ASR_TRANS_APP_SET_STOP = 0x0006
|
||||
const val ASR_TRANS_STOP = 0x0007
|
||||
|
||||
// 3D20 common commands
|
||||
const val AG_HS_DEV_UUID = 0x0000
|
||||
const val AG_HS_APP_UUID = 0x0001
|
||||
const val AG_HS_DEV_INFO = 0x0002
|
||||
const val AG_GET_ALL_STATUS = 0x0013
|
||||
const val AG_P_TAKE_START = 0x00A0
|
||||
const val AG_P_TAKE_STOP = 0x00A1
|
||||
const val AG_P_THUMB_INFO = 0x00A2
|
||||
const val AG_P_THUMB_DATA = 0x00A3
|
||||
const val AG_V_THUMB_INFO = 0x0094
|
||||
const val AG_V_THUMB_DATA = 0x0095
|
||||
}
|
||||
|
||||
private class FrameAssembler {
|
||||
private var buffer = byteArrayOf()
|
||||
private val head = byteArrayOf(0x48, 0x49, 0x43, 0x48) // HICH
|
||||
private val end = byteArrayOf(0x49, 0x50, 0x53, 0x45) // IPSE
|
||||
|
||||
fun append(chunk: ByteArray): List<HichipsFrame> {
|
||||
if (chunk.isEmpty()) return emptyList()
|
||||
buffer += chunk
|
||||
val out = mutableListOf<HichipsFrame>()
|
||||
while (true) {
|
||||
val start = indexOf(buffer, head)
|
||||
if (start < 0) {
|
||||
buffer = if (buffer.size > 3) buffer.copyOfRange(buffer.size - 3, buffer.size) else buffer
|
||||
break
|
||||
}
|
||||
if (start > 0) {
|
||||
buffer = buffer.copyOfRange(start, buffer.size)
|
||||
}
|
||||
if (buffer.size < 18) break
|
||||
|
||||
val dataLength = leUInt32(buffer, 8)
|
||||
if (dataLength < 0 || dataLength > MAX_FRAME_DATA) {
|
||||
buffer = buffer.copyOfRange(1, buffer.size)
|
||||
continue
|
||||
}
|
||||
val total = 18 + dataLength
|
||||
if (buffer.size < total) break
|
||||
val tail = buffer.copyOfRange(total - 4, total)
|
||||
if (!tail.contentEquals(end)) {
|
||||
buffer = buffer.copyOfRange(1, buffer.size)
|
||||
continue
|
||||
}
|
||||
|
||||
val command = leUInt16(buffer, 4)
|
||||
val index = leUInt16(buffer, 6)
|
||||
val payload = if (dataLength > 0) {
|
||||
buffer.copyOfRange(14, 14 + dataLength)
|
||||
} else {
|
||||
byteArrayOf()
|
||||
}
|
||||
val crcExpected = leUInt16(buffer, 12)
|
||||
val crcActual = crc16(payload)
|
||||
if (crcExpected == crcActual) {
|
||||
out += HichipsFrame(command = command, index = index, payload = payload)
|
||||
}
|
||||
buffer = if (buffer.size == total) byteArrayOf() else buffer.copyOfRange(total, buffer.size)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
fun hasPendingFrame(): Boolean {
|
||||
return buffer.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun leUInt16(bytes: ByteArray, offset: Int): Int {
|
||||
return ((bytes[offset].toInt() and 0xFF) or ((bytes[offset + 1].toInt() and 0xFF) shl 8))
|
||||
}
|
||||
|
||||
private fun leUInt32(bytes: ByteArray, offset: Int): Int {
|
||||
val b0 = bytes[offset].toInt() and 0xFF
|
||||
val b1 = bytes[offset + 1].toInt() and 0xFF
|
||||
val b2 = bytes[offset + 2].toInt() and 0xFF
|
||||
val b3 = bytes[offset + 3].toInt() and 0xFF
|
||||
return b0 or (b1 shl 8) or (b2 shl 16) or (b3 shl 24)
|
||||
}
|
||||
|
||||
private fun indexOf(source: ByteArray, target: ByteArray): Int {
|
||||
if (target.isEmpty()) return 0
|
||||
if (source.size < target.size) return -1
|
||||
for (i in 0..(source.size - target.size)) {
|
||||
var matched = true
|
||||
for (j in target.indices) {
|
||||
if (source[i + j] != target[j]) {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (matched) return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private fun crc16(data: ByteArray): Int {
|
||||
var crc = 0xFFFF
|
||||
for (b in data) {
|
||||
crc = ((crc ushr 8) or ((crc and 0xFF) shl 8)) and 0xFFFF
|
||||
crc = crc xor (b.toInt() and 0xFF)
|
||||
crc = crc xor ((crc and 0xFF) ushr 4)
|
||||
crc = crc xor ((crc shl 8) shl 4)
|
||||
crc = crc xor (((crc and 0xFF) shl 4) shl 1)
|
||||
crc = crc and 0xFFFF
|
||||
}
|
||||
return crc and 0xFFFF
|
||||
}
|
||||
}
|
||||
|
||||
class BleManager(private val context: Context) {
|
||||
private val btManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
private val adapter: BluetoothAdapter? = btManager.adapter
|
||||
|
||||
private val _state = MutableStateFlow(BleLinkState())
|
||||
val state: StateFlow<BleLinkState> = _state.asStateFlow()
|
||||
|
||||
private val _events = MutableSharedFlow<GlassesBleEvent>(extraBufferCapacity = 256)
|
||||
val events: SharedFlow<GlassesBleEvent> = _events.asSharedFlow()
|
||||
|
||||
private var gatt: BluetoothGatt? = null
|
||||
private var scannerCallback: ScanCallback? = null
|
||||
private var pendingAppUuid: String = ""
|
||||
private var waitingAsrStart = false
|
||||
|
||||
private var write3D23: BluetoothGattCharacteristic? = null
|
||||
private var write5DC3: BluetoothGattCharacteristic? = null
|
||||
|
||||
private val notifyQueue = ArrayDeque<BluetoothGattCharacteristic>()
|
||||
private val assembler3D21 = FrameAssembler()
|
||||
private val assembler3D22 = FrameAssembler()
|
||||
private val assembler5DC1 = FrameAssembler()
|
||||
private val assembler5DC2 = FrameAssembler()
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun connectAndHandshake(appUuid: String, nameHint: String? = null) {
|
||||
val bt = adapter
|
||||
if (bt == null || !bt.isEnabled) {
|
||||
updateError("Bluetooth not enabled")
|
||||
return
|
||||
}
|
||||
pendingAppUuid = appUuid.take(32)
|
||||
if (_state.value.connected) {
|
||||
emitLog("BLE already connected, waiting for handshake packets")
|
||||
return
|
||||
}
|
||||
stopScan()
|
||||
_state.value = _state.value.copy(scanning = true, lastError = "")
|
||||
val filters = listOf(
|
||||
ScanFilter.Builder()
|
||||
.setServiceUuid(ParcelUuid(HichipsUuid.service3D20))
|
||||
.build()
|
||||
)
|
||||
val settings = ScanSettings.Builder()
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
.build()
|
||||
scannerCallback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
val device = result.device ?: return
|
||||
val deviceName = runCatching { device.name.orEmpty() }.getOrDefault("")
|
||||
if (nameHint.isNullOrBlank().not() && !deviceName.contains(nameHint!!, ignoreCase = true)) {
|
||||
return
|
||||
}
|
||||
stopScan()
|
||||
emitLog("BLE found ${device.address} ${deviceName.ifBlank { "(no-name)" }}")
|
||||
connectDevice(device)
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
updateError("BLE scan failed: $errorCode")
|
||||
}
|
||||
}
|
||||
bt.bluetoothLeScanner?.startScan(filters, settings, scannerCallback)
|
||||
emitLog("BLE scanning...")
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun disconnect() {
|
||||
stopScan()
|
||||
runCatching { gatt?.disconnect() }
|
||||
runCatching { gatt?.close() }
|
||||
gatt = null
|
||||
_state.value = BleLinkState()
|
||||
}
|
||||
|
||||
fun startWakeUpAudio() {
|
||||
waitingAsrStart = true
|
||||
val ok = sendAsrCommand(HichipsCmd.ASR_APP_WAKE_UP, null)
|
||||
emitLog(if (ok) "ASR wake-up command sent" else "ASR wake-up send failed")
|
||||
}
|
||||
|
||||
fun stopWakeUpAudio() {
|
||||
waitingAsrStart = false
|
||||
val ok = sendAsrCommand(HichipsCmd.ASR_TRANS_APP_SET_STOP, null)
|
||||
emitLog(if (ok) "ASR stop command sent" else "ASR stop send failed")
|
||||
}
|
||||
|
||||
fun triggerPhotoCapture() {
|
||||
val ok = sendAgCommand(HichipsCmd.AG_P_TAKE_START, null)
|
||||
emitLog(if (ok) "Photo capture command sent" else "Photo capture send failed")
|
||||
}
|
||||
|
||||
fun requestAllStatus() {
|
||||
sendAgCommand(HichipsCmd.AG_GET_ALL_STATUS, null)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun connectDevice(device: BluetoothDevice) {
|
||||
runCatching { gatt?.close() }
|
||||
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
|
||||
_state.value = _state.value.copy(
|
||||
scanning = false,
|
||||
connected = false,
|
||||
notificationsReady = false,
|
||||
handshaked = false,
|
||||
deviceAddress = device.address,
|
||||
deviceName = runCatching { device.name.orEmpty() }.getOrDefault("")
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun stopScan() {
|
||||
scannerCallback?.let { cb ->
|
||||
adapter?.bluetoothLeScanner?.stopScan(cb)
|
||||
}
|
||||
scannerCallback = null
|
||||
_state.value = _state.value.copy(scanning = false)
|
||||
}
|
||||
|
||||
private val callback = object : BluetoothGattCallback() {
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
updateError("BLE connect error status=$status")
|
||||
return
|
||||
}
|
||||
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
||||
_state.value = _state.value.copy(connected = true, lastError = "")
|
||||
emitLog("BLE connected, discovering services")
|
||||
gatt.requestMtu(247)
|
||||
gatt.discoverServices()
|
||||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||
_state.value = _state.value.copy(
|
||||
connected = false,
|
||||
notificationsReady = false,
|
||||
handshaked = false
|
||||
)
|
||||
emitLog("BLE disconnected")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
|
||||
emitLog("BLE mtu=$mtu status=$status")
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
updateError("Service discovery failed: $status")
|
||||
return
|
||||
}
|
||||
bindCharacteristics(gatt)
|
||||
startEnableNotifications()
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(
|
||||
gatt: BluetoothGatt,
|
||||
descriptor: BluetoothGattDescriptor,
|
||||
status: Int
|
||||
) {
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
updateError("Descriptor write failed: $status")
|
||||
return
|
||||
}
|
||||
writeNextNotificationDescriptor()
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in API 33")
|
||||
override fun onCharacteristicChanged(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic
|
||||
) {
|
||||
handleCharacteristicChanged(characteristic.uuid, characteristic.value ?: byteArrayOf())
|
||||
}
|
||||
|
||||
override fun onCharacteristicChanged(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
value: ByteArray
|
||||
) {
|
||||
handleCharacteristicChanged(characteristic.uuid, value)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun bindCharacteristics(gatt: BluetoothGatt) {
|
||||
val s3 = gatt.getService(HichipsUuid.service3D20)
|
||||
val s5 = gatt.getService(HichipsUuid.service5DC0)
|
||||
write3D23 = s3?.getCharacteristic(HichipsUuid.char3D23Write)
|
||||
write5DC3 = s5?.getCharacteristic(HichipsUuid.char5DC3Write)
|
||||
}
|
||||
|
||||
private fun startEnableNotifications() {
|
||||
val g = gatt ?: return
|
||||
notifyQueue.clear()
|
||||
enqueueNotify(g, HichipsUuid.service3D20, HichipsUuid.char3D21Notify)
|
||||
enqueueNotify(g, HichipsUuid.service3D20, HichipsUuid.char3D22NotifyData)
|
||||
enqueueNotify(g, HichipsUuid.service5DC0, HichipsUuid.char5DC1Notify)
|
||||
enqueueNotify(g, HichipsUuid.service5DC0, HichipsUuid.char5DC2NotifyData)
|
||||
writeNextNotificationDescriptor()
|
||||
}
|
||||
|
||||
private fun enqueueNotify(gatt: BluetoothGatt, serviceUuid: UUID, charUuid: UUID) {
|
||||
val characteristic = gatt.getService(serviceUuid)?.getCharacteristic(charUuid) ?: return
|
||||
notifyQueue.add(characteristic)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun writeNextNotificationDescriptor() {
|
||||
val g = gatt ?: return
|
||||
if (notifyQueue.isEmpty()) {
|
||||
_state.value = _state.value.copy(notificationsReady = true)
|
||||
emitLog("BLE notifications enabled")
|
||||
return
|
||||
}
|
||||
val c = notifyQueue.removeFirst()
|
||||
g.setCharacteristicNotification(c, true)
|
||||
val descriptor = c.getDescriptor(HichipsUuid.cccd) ?: run {
|
||||
writeNextNotificationDescriptor()
|
||||
return
|
||||
}
|
||||
val value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val result = g.writeDescriptor(descriptor, value)
|
||||
if (result != BluetoothStatusCodes.SUCCESS) {
|
||||
updateError("writeDescriptor failed: $result")
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
run {
|
||||
descriptor.value = value
|
||||
val ok = g.writeDescriptor(descriptor)
|
||||
if (!ok) updateError("writeDescriptor returned false")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCharacteristicChanged(uuid: UUID, value: ByteArray) {
|
||||
if (value.isEmpty()) return
|
||||
when (uuid) {
|
||||
HichipsUuid.char3D21Notify -> decodeAndDispatchFrames(value, assembler3D21, isWakeChannel = false, isDataChannel = false)
|
||||
HichipsUuid.char5DC1Notify -> decodeAndDispatchFrames(value, assembler5DC1, isWakeChannel = true, isDataChannel = false)
|
||||
HichipsUuid.char3D22NotifyData -> decodeAndDispatchFrames(value, assembler3D22, isWakeChannel = false, isDataChannel = true)
|
||||
HichipsUuid.char5DC2NotifyData -> decodeAndDispatchFrames(value, assembler5DC2, isWakeChannel = true, isDataChannel = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeAndDispatchFrames(
|
||||
value: ByteArray,
|
||||
assembler: FrameAssembler,
|
||||
isWakeChannel: Boolean,
|
||||
isDataChannel: Boolean
|
||||
) {
|
||||
val isPacketized = value.size >= 4 &&
|
||||
value[0] == 0x48.toByte() &&
|
||||
value[1] == 0x49.toByte() &&
|
||||
value[2] == 0x43.toByte() &&
|
||||
value[3] == 0x48.toByte()
|
||||
|
||||
if (isDataChannel && !isPacketized && !assembler.hasPendingFrame()) {
|
||||
if (isWakeChannel) {
|
||||
_events.tryEmit(GlassesBleEvent.AudioFrame(bytes = value, index = 0))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val frames = assembler.append(value)
|
||||
for (frame in frames) {
|
||||
onFrame(frame, isWakeChannel, isDataChannel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onFrame(frame: HichipsFrame, isWakeChannel: Boolean, isDataChannel: Boolean) {
|
||||
if (isWakeChannel && isDataChannel && frame.command == HichipsCmd.ASR_TRANS_AUDIO) {
|
||||
_events.tryEmit(GlassesBleEvent.AudioFrame(bytes = frame.payload, index = frame.index))
|
||||
return
|
||||
}
|
||||
|
||||
if (!isWakeChannel && isDataChannel) {
|
||||
when (frame.command) {
|
||||
HichipsCmd.AG_P_THUMB_DATA -> _events.tryEmit(
|
||||
GlassesBleEvent.CameraThumbData(
|
||||
bytes = frame.payload,
|
||||
index = frame.index,
|
||||
isVideo = false
|
||||
)
|
||||
)
|
||||
HichipsCmd.AG_V_THUMB_DATA -> _events.tryEmit(
|
||||
GlassesBleEvent.CameraThumbData(
|
||||
bytes = frame.payload,
|
||||
index = frame.index,
|
||||
isVideo = true
|
||||
)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isWakeChannel) {
|
||||
when (frame.command) {
|
||||
HichipsCmd.ASR_DEV_WAKE_UP -> {
|
||||
emitLog("Device wake-up received")
|
||||
if (waitingAsrStart) {
|
||||
val setting = JSONObject()
|
||||
.put("FlowCtrl", 0)
|
||||
.put("LengthByte", 80)
|
||||
.put("IntervalMs", 20)
|
||||
.put("Packag", 1)
|
||||
sendAsrCommand(HichipsCmd.ASR_TRANS_SETTING, setting.toString())
|
||||
}
|
||||
}
|
||||
HichipsCmd.ASR_TRANS_START -> emitLog("ASR trans start")
|
||||
HichipsCmd.ASR_TRANS_STOP -> emitLog("ASR trans stop")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
when (frame.command) {
|
||||
HichipsCmd.AG_HS_DEV_UUID -> {
|
||||
val json = parseJson(frame.payload)
|
||||
val devUuid = json?.optString("DevUuid", "").orEmpty()
|
||||
if (devUuid.isNotBlank()) {
|
||||
_state.value = _state.value.copy(devUuid = devUuid)
|
||||
val appUuidPayload = JSONObject()
|
||||
.put("Time", System.currentTimeMillis() / 1000L)
|
||||
.put("AppUuid", pendingAppUuid.take(32))
|
||||
.toString()
|
||||
sendAgCommand(HichipsCmd.AG_HS_APP_UUID, appUuidPayload)
|
||||
emitLog("Handshake step2 done, app uuid sent")
|
||||
}
|
||||
}
|
||||
HichipsCmd.AG_HS_DEV_INFO -> {
|
||||
val json = parseJson(frame.payload)
|
||||
val fail = json?.optString("Status") == "Fail"
|
||||
if (fail) {
|
||||
updateError("Handshake rejected: ${json?.optInt("ErrorCode", -1)}")
|
||||
return
|
||||
}
|
||||
val devUuid = json?.optString("DevUuid", _state.value.devUuid).orEmpty()
|
||||
val devName = json?.optString("DevName", "").orEmpty()
|
||||
val fw = json?.optString("DevFwVer", "").orEmpty()
|
||||
_state.value = _state.value.copy(
|
||||
handshaked = true,
|
||||
devUuid = devUuid.ifBlank { _state.value.devUuid },
|
||||
deviceName = devName.ifBlank { _state.value.deviceName }
|
||||
)
|
||||
_events.tryEmit(GlassesBleEvent.HandshakeOk(devUuid = _state.value.devUuid, devName = devName, devFwVer = fw))
|
||||
emitLog("Handshake completed")
|
||||
}
|
||||
HichipsCmd.AG_GET_ALL_STATUS -> {
|
||||
val jsonText = frame.payload.decodeToString()
|
||||
_events.tryEmit(GlassesBleEvent.StatusUpdate(jsonText))
|
||||
}
|
||||
HichipsCmd.AG_P_THUMB_INFO -> {
|
||||
val source = parseJson(frame.payload)?.optString("SourceFileName", "").orEmpty()
|
||||
_events.tryEmit(GlassesBleEvent.CameraThumbInfo(sourceFileName = source, isVideo = false))
|
||||
}
|
||||
HichipsCmd.AG_V_THUMB_INFO -> {
|
||||
val source = parseJson(frame.payload)?.optString("SourceFileName", "").orEmpty()
|
||||
_events.tryEmit(GlassesBleEvent.CameraThumbInfo(sourceFileName = source, isVideo = true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseJson(bytes: ByteArray): JSONObject? {
|
||||
if (bytes.isEmpty()) return null
|
||||
return runCatching {
|
||||
JSONObject(bytes.decodeToString())
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun sendAgCommand(command: Int, jsonPayload: String?): Boolean {
|
||||
val payload = jsonPayload?.toByteArray(Charsets.UTF_8) ?: byteArrayOf()
|
||||
return writeFrame(write3D23, command, payload)
|
||||
}
|
||||
|
||||
private fun sendAsrCommand(command: Int, jsonPayload: String?): Boolean {
|
||||
val payload = jsonPayload?.toByteArray(Charsets.UTF_8) ?: byteArrayOf()
|
||||
return writeFrame(write5DC3, command, payload)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun writeFrame(
|
||||
characteristic: BluetoothGattCharacteristic?,
|
||||
command: Int,
|
||||
payload: ByteArray
|
||||
): Boolean {
|
||||
val g = gatt ?: return false
|
||||
val c = characteristic ?: return false
|
||||
val frame = buildFrame(command = command, index = 0, payload = payload)
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
g.writeCharacteristic(c, frame, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) ==
|
||||
BluetoothStatusCodes.SUCCESS
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
run {
|
||||
c.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||
c.value = frame
|
||||
g.writeCharacteristic(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildFrame(command: Int, index: Int, payload: ByteArray): ByteArray {
|
||||
val buffer = ByteBuffer.allocate(18 + payload.size).order(ByteOrder.LITTLE_ENDIAN)
|
||||
buffer.put(byteArrayOf(0x48, 0x49, 0x43, 0x48)) // HICH
|
||||
buffer.putShort(command.toShort())
|
||||
buffer.putShort(index.toShort())
|
||||
buffer.putInt(payload.size)
|
||||
buffer.putShort(crc16(payload).toShort())
|
||||
if (payload.isNotEmpty()) buffer.put(payload)
|
||||
buffer.put(byteArrayOf(0x49, 0x50, 0x53, 0x45)) // IPSE
|
||||
return buffer.array()
|
||||
}
|
||||
|
||||
private fun crc16(data: ByteArray): Int {
|
||||
var crc = 0xFFFF
|
||||
for (b in data) {
|
||||
crc = ((crc ushr 8) or ((crc and 0xFF) shl 8)) and 0xFFFF
|
||||
crc = crc xor (b.toInt() and 0xFF)
|
||||
crc = crc xor ((crc and 0xFF) ushr 4)
|
||||
crc = crc xor ((crc shl 8) shl 4)
|
||||
crc = crc xor (((crc and 0xFF) shl 4) shl 1)
|
||||
crc = crc and 0xFFFF
|
||||
}
|
||||
return crc and 0xFFFF
|
||||
}
|
||||
|
||||
private fun emitLog(message: String) {
|
||||
_events.tryEmit(GlassesBleEvent.Log(message))
|
||||
}
|
||||
|
||||
private fun updateError(message: String) {
|
||||
_state.value = _state.value.copy(lastError = message)
|
||||
_events.tryEmit(GlassesBleEvent.Log("ERROR: $message"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package com.aiglasses.app.software
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import com.baidu.rtc.agent.AIAgentEngine
|
||||
import com.baidu.rtc.agent.AIAgentEngineCallback
|
||||
import com.baidu.rtc.agent.Constants
|
||||
import java.io.File
|
||||
|
||||
private const val BAIDU_AGENT_RECONNECT_DELAY_MS = 900L
|
||||
private const val BAIDU_IMAGE_UPLOAD_EXPIRE_SECONDS = 0
|
||||
|
||||
class BaiduConversationAgent(
|
||||
context: Context,
|
||||
private val onLog: (String) -> Unit,
|
||||
private val onCallReady: () -> Unit,
|
||||
private val onCallEnded: (String) -> Unit,
|
||||
private val onFinalAsr: (String) -> Unit,
|
||||
private val onAgentText: (String) -> Unit,
|
||||
private val onTtsStart: () -> Unit,
|
||||
private val onTtsEnd: () -> Unit,
|
||||
private val onPlaybackAudio: (pcm: ByteArray, sampleRate: Int, channelCount: Int) -> Unit,
|
||||
private val onImageUploadRequest: () -> Unit,
|
||||
) {
|
||||
private val appContext = context.applicationContext
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
private var engine: AIAgentEngine? = null
|
||||
private var session: SessionConfig? = null
|
||||
private var running = false
|
||||
private var callBegun = false
|
||||
private var reconnectScheduled = false
|
||||
private var stopRequested = false
|
||||
private var pendingUploadFile: File? = null
|
||||
|
||||
private val callback = object : AIAgentEngineCallback() {
|
||||
override fun onConnectionStateChange(state: Int) {
|
||||
onLog("Baidu agent connection state=$state")
|
||||
}
|
||||
|
||||
override fun onCallStateChange(state: Int) {
|
||||
when (state) {
|
||||
Constants.CallState.ON_CALL_BEGIN -> {
|
||||
callBegun = true
|
||||
onLog("Baidu agent call begin")
|
||||
onCallReady()
|
||||
flushPendingUpload()
|
||||
}
|
||||
|
||||
Constants.CallState.ON_CALL_END -> {
|
||||
callBegun = false
|
||||
onLog("Baidu agent call ended")
|
||||
onCallEnded("call_end")
|
||||
if (running && !stopRequested) {
|
||||
scheduleReconnect("call_end")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(error: Int, msg: String?, bundle: Bundle?) {
|
||||
onLog("Baidu agent error: code=$error, msg=${msg?.take(80) ?: "-"}")
|
||||
onCallEnded("error:$error")
|
||||
if (running && !stopRequested) {
|
||||
restart("error:$error")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLicenseStatus(code: Int) {
|
||||
onLog("Baidu agent license status=$code")
|
||||
}
|
||||
|
||||
override fun onUserAsrSubtitle(text: String?, isFinal: Boolean) {
|
||||
if (!isFinal) return
|
||||
val normalized = sanitizeText(text.orEmpty())
|
||||
if (normalized.isNotBlank()) {
|
||||
onFinalAsr(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAIAgentSubtitle(text: String?, isFinal: Boolean) {
|
||||
if (!isFinal) return
|
||||
val normalized = sanitizeText(text.orEmpty())
|
||||
if (normalized.isNotBlank()) {
|
||||
onAgentText(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAIAgentAudioStateChange(newState: Int) {
|
||||
when (newState) {
|
||||
Constants.AIAgentAudioStateType.SPEAKING -> onTtsStart()
|
||||
Constants.AIAgentAudioStateType.STOPPED -> onTtsEnd()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlaybackAudioFrame(data: ByteArray?, sampleRate: Int, channelCount: Int) {
|
||||
val frame = data ?: return
|
||||
if (frame.isEmpty()) return
|
||||
onPlaybackAudio(frame, sampleRate, channelCount)
|
||||
}
|
||||
|
||||
override fun onAgentIntent(type: String?, bundle: Bundle?) {
|
||||
if (type == Constants.AgentIntentType.IMAGE_UPLOAD) {
|
||||
onImageUploadRequest()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUploadFileStatus(code: Int, msg: String?) {
|
||||
onLog("Baidu visual upload status: code=$code, msg=${msg?.take(80) ?: "-"}")
|
||||
}
|
||||
|
||||
override fun onMessage(message: String?) {
|
||||
val text = sanitizeText(message.orEmpty())
|
||||
if (text.isNotBlank()) {
|
||||
onLog("Baidu agent message: ${text.take(120)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSession(
|
||||
appId: String,
|
||||
cid: String,
|
||||
token: String,
|
||||
contextJson: String,
|
||||
deviceId: String,
|
||||
appUserId: String,
|
||||
licenseKey: String,
|
||||
) {
|
||||
val next = SessionConfig(
|
||||
appId = appId.trim(),
|
||||
cid = cid.trim(),
|
||||
token = token.trim(),
|
||||
contextJson = contextJson.trim(),
|
||||
deviceId = deviceId.trim(),
|
||||
appUserId = appUserId.trim(),
|
||||
licenseKey = licenseKey.trim(),
|
||||
)
|
||||
val changed = next != session
|
||||
session = next
|
||||
if (running && changed) {
|
||||
onLog("Baidu session updated, restarting agent")
|
||||
restart("session_updated")
|
||||
}
|
||||
}
|
||||
|
||||
fun start() {
|
||||
running = true
|
||||
stopRequested = false
|
||||
startIfReady()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
stopRequested = true
|
||||
reconnectScheduled = false
|
||||
mainHandler.removeCallbacksAndMessages(RECONNECT_TOKEN)
|
||||
pendingUploadFile?.let { safeDelete(it) }
|
||||
pendingUploadFile = null
|
||||
destroyEngine()
|
||||
}
|
||||
|
||||
fun isCallActive(): Boolean = callBegun
|
||||
|
||||
fun pushAudioFrame(pcm: ByteArray, sampleRate: Int, channelCount: Int) {
|
||||
if (!callBegun || pcm.isEmpty()) return
|
||||
runCatching {
|
||||
engine?.pushAudioFrame(pcm, System.nanoTime(), sampleRate, channelCount)
|
||||
}.onFailure {
|
||||
onLog("Baidu audio push failed: ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun interrupt() {
|
||||
if (!callBegun) return
|
||||
runCatching { engine?.interrupt() }
|
||||
.onFailure { onLog("Baidu interrupt failed: ${it.message}") }
|
||||
}
|
||||
|
||||
fun uploadJpeg(jpegBytes: ByteArray): Boolean {
|
||||
val file = prepareUploadFile(jpegBytes) ?: return false
|
||||
if (!callBegun) {
|
||||
pendingUploadFile?.let { safeDelete(it) }
|
||||
pendingUploadFile = file
|
||||
onLog("Baidu visual upload queued: waiting call begin")
|
||||
return true
|
||||
}
|
||||
return sendUploadFile(file)
|
||||
}
|
||||
|
||||
private fun startIfReady() {
|
||||
if (!running || engine != null) return
|
||||
val cfg = session ?: run {
|
||||
onLog("Baidu agent start pending: session missing")
|
||||
return
|
||||
}
|
||||
if (cfg.appId.isBlank() || cfg.cid.isBlank() || cfg.token.isBlank()) {
|
||||
onLog("Baidu agent start pending: missing appId/cid/token")
|
||||
return
|
||||
}
|
||||
val cidLong = cfg.cid.toLongOrNull()
|
||||
if (cidLong == null) {
|
||||
onLog("Baidu agent start failed: cid not numeric")
|
||||
return
|
||||
}
|
||||
val params = AIAgentEngine.AIAgentEngineParams().apply {
|
||||
appId = cfg.appId
|
||||
workflow = "voiceChat"
|
||||
aiAgentInstanceId = cidLong
|
||||
context = cfg.contextJson
|
||||
verbose = true
|
||||
enableExternalAudioInput = true
|
||||
enableExternalAudioOutput = true
|
||||
enableVoiceInterrupt = false
|
||||
licenseKey = cfg.licenseKey
|
||||
// SDK internal license activation sends devId=userId, so this must be the device identity.
|
||||
userId = cfg.deviceId
|
||||
}
|
||||
val nextEngine = runCatching { AIAgentEngine.init(appContext, params) }
|
||||
.onFailure { onLog("Baidu agent init failed: ${it.message}") }
|
||||
.getOrNull() ?: return
|
||||
engine = nextEngine
|
||||
nextEngine.setCallback(callback)
|
||||
onLog(
|
||||
"Baidu agent calling: cid=${cfg.cid}, deviceId=${cfg.deviceId}, " +
|
||||
"appUserId=${cfg.appUserId}, contextLen=${cfg.contextJson.length}"
|
||||
)
|
||||
runCatching {
|
||||
nextEngine.call(cfg.token, cidLong)
|
||||
nextEngine.switchToSpeaker(true)
|
||||
}.onFailure {
|
||||
onLog("Baidu agent call failed: ${it.message}")
|
||||
destroyEngine()
|
||||
scheduleReconnect("call_failed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun restart(reason: String) {
|
||||
destroyEngine()
|
||||
scheduleReconnect(reason)
|
||||
}
|
||||
|
||||
private fun scheduleReconnect(reason: String) {
|
||||
if (!running || reconnectScheduled) return
|
||||
reconnectScheduled = true
|
||||
onLog("Baidu agent reconnect scheduled: $reason")
|
||||
mainHandler.postAtTime(
|
||||
{
|
||||
reconnectScheduled = false
|
||||
if (!running) return@postAtTime
|
||||
startIfReady()
|
||||
},
|
||||
RECONNECT_TOKEN,
|
||||
SystemClock.uptimeMillis() + BAIDU_AGENT_RECONNECT_DELAY_MS
|
||||
)
|
||||
}
|
||||
|
||||
private fun destroyEngine() {
|
||||
val current = engine ?: run {
|
||||
callBegun = false
|
||||
return
|
||||
}
|
||||
engine = null
|
||||
callBegun = false
|
||||
runCatching { current.hangup() }
|
||||
runCatching { current.destroy() }
|
||||
}
|
||||
|
||||
private fun flushPendingUpload() {
|
||||
val pending = pendingUploadFile ?: return
|
||||
pendingUploadFile = null
|
||||
sendUploadFile(pending)
|
||||
}
|
||||
|
||||
private fun sendUploadFile(file: File): Boolean {
|
||||
val current = engine ?: run {
|
||||
safeDelete(file)
|
||||
return false
|
||||
}
|
||||
val ok = runCatching { current.uploadFile(file.absolutePath, BAIDU_IMAGE_UPLOAD_EXPIRE_SECONDS) }
|
||||
.onFailure { onLog("Baidu visual upload call failed: ${it.message}") }
|
||||
.getOrDefault(false)
|
||||
if (ok) {
|
||||
onLog("Baidu visual upload sent: ${file.name}, bytes=${file.length()}")
|
||||
mainHandler.postDelayed({ safeDelete(file) }, 60_000L)
|
||||
} else {
|
||||
safeDelete(file)
|
||||
onLog("Baidu visual upload send failed")
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
private fun prepareUploadFile(jpegBytes: ByteArray): File? {
|
||||
return runCatching {
|
||||
val dir = File(appContext.cacheDir, "baidu_uploads").apply { mkdirs() }
|
||||
File.createTempFile("vision_", ".jpg", dir).apply { writeBytes(jpegBytes) }
|
||||
}.onFailure {
|
||||
onLog("Baidu visual file prepare failed: ${it.message}")
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun sanitizeText(raw: String): String {
|
||||
return raw.substringBefore("|||").trim()
|
||||
}
|
||||
|
||||
private fun safeDelete(file: File) {
|
||||
runCatching { file.delete() }
|
||||
}
|
||||
|
||||
private data class SessionConfig(
|
||||
val appId: String,
|
||||
val cid: String,
|
||||
val token: String,
|
||||
val contextJson: String,
|
||||
val deviceId: String,
|
||||
val appUserId: String,
|
||||
val licenseKey: String,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val RECONNECT_TOKEN = Any()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.aiglasses.app.software
|
||||
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
|
||||
class BaiduRealtimeWsClient(
|
||||
private val onLog: (String) -> Unit,
|
||||
private val onOpen: () -> Unit,
|
||||
private val onText: (String) -> Unit,
|
||||
private val onBinary: (ByteArray) -> Unit,
|
||||
private val onClosed: (reason: String, byClient: Boolean) -> Unit,
|
||||
) {
|
||||
private val client = OkHttpClient.Builder()
|
||||
.retryOnConnectionFailure(true)
|
||||
.pingInterval(20, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
@Volatile
|
||||
private var webSocket: WebSocket? = null
|
||||
|
||||
@Volatile
|
||||
private var closedByClient = false
|
||||
|
||||
fun connect(url: String) {
|
||||
disconnect("reconnect")
|
||||
closedByClient = false
|
||||
val request = Request.Builder().url(url).build()
|
||||
webSocket = client.newWebSocket(request, listener)
|
||||
}
|
||||
|
||||
fun disconnect(reason: String = "client_stop") {
|
||||
closedByClient = true
|
||||
val current = webSocket
|
||||
webSocket = null
|
||||
runCatching { current?.close(1000, reason) }
|
||||
runCatching { current?.cancel() }
|
||||
}
|
||||
|
||||
fun sendText(text: String): Boolean {
|
||||
return runCatching { webSocket?.send(text) == true }
|
||||
.onFailure { onLog("Realtime WS send text failed: ${it.message}") }
|
||||
.getOrDefault(false)
|
||||
}
|
||||
|
||||
fun sendBinary(bytes: ByteArray): Boolean {
|
||||
if (bytes.isEmpty()) return false
|
||||
return runCatching { webSocket?.send(bytes.toByteString()) == true }
|
||||
.onFailure { onLog("Realtime WS send binary failed: ${it.message}") }
|
||||
.getOrDefault(false)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
disconnect("release")
|
||||
runCatching { client.dispatcher.executorService.shutdown() }
|
||||
runCatching { client.connectionPool.evictAll() }
|
||||
}
|
||||
|
||||
private val listener = object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
this@BaiduRealtimeWsClient.webSocket = webSocket
|
||||
onOpen()
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
onText(text)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||
onBinary(bytes.toByteArray())
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
if (this@BaiduRealtimeWsClient.webSocket === webSocket) {
|
||||
this@BaiduRealtimeWsClient.webSocket = null
|
||||
}
|
||||
onClosed("closed:$code:${reason.ifBlank { "-" }}", closedByClient)
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
runCatching { webSocket.close(code, reason) }
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
if (this@BaiduRealtimeWsClient.webSocket === webSocket) {
|
||||
this@BaiduRealtimeWsClient.webSocket = null
|
||||
}
|
||||
val code = response?.code ?: -1
|
||||
val message = t.message ?: response?.message ?: "unknown"
|
||||
onClosed("failure:$code:$message", closedByClient)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package com.aiglasses.app.software
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import com.baidu.rtc.agent.AIAgentEngine
|
||||
import com.baidu.rtc.agent.AIAgentEngineCallback
|
||||
import com.baidu.rtc.agent.Constants
|
||||
import java.io.File
|
||||
|
||||
private const val VISUAL_UPLOAD_EXPIRE_SECONDS = 0
|
||||
private const val VISUAL_UPLOAD_KEEP_MS = 10 * 60 * 1000L
|
||||
|
||||
class BaiduVisualUploader(
|
||||
context: Context,
|
||||
private val onLog: (String) -> Unit
|
||||
) {
|
||||
private data class SessionConfig(
|
||||
val appId: String,
|
||||
val cid: String,
|
||||
val token: String,
|
||||
val userId: String,
|
||||
val licenseKey: String
|
||||
) {
|
||||
fun isValid(): Boolean = appId.isNotBlank() && cid.isNotBlank() && token.isNotBlank()
|
||||
fun key(): String = listOf(appId, cid, token, userId, licenseKey).joinToString("|")
|
||||
}
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val uploadDir = File(appContext.cacheDir, "baidu_visual_uploads").apply { mkdirs() }
|
||||
|
||||
private var sessionConfig: SessionConfig? = null
|
||||
private var startedKey = ""
|
||||
private var engine: AIAgentEngine? = null
|
||||
private var ready = false
|
||||
private var activeUploadFile: File? = null
|
||||
private var pendingUploadFile: File? = null
|
||||
|
||||
private val callback = object : AIAgentEngineCallback() {
|
||||
override fun onCallStateChange(state: Int) {
|
||||
when (state) {
|
||||
Constants.CallState.ON_CALL_BEGIN -> {
|
||||
ready = true
|
||||
engine?.muteMic(true)
|
||||
engine?.mutePlayback(true)
|
||||
onLog("Baidu visual uploader ready")
|
||||
flushPendingUpload()
|
||||
}
|
||||
|
||||
Constants.CallState.ON_CALL_END -> {
|
||||
ready = false
|
||||
onLog("Baidu visual uploader call ended")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnectionStateChange(state: Int) {
|
||||
onLog("Baidu visual connection state=$state")
|
||||
}
|
||||
|
||||
override fun onUploadFileStatus(code: Int, msg: String) {
|
||||
onLog("Baidu visual upload status: code=$code, msg=${msg.take(80)}")
|
||||
deleteFile(activeUploadFile)
|
||||
activeUploadFile = null
|
||||
}
|
||||
|
||||
override fun onLicenseStatus(code: Int) {
|
||||
onLog("Baidu visual license status=$code")
|
||||
}
|
||||
|
||||
override fun onAgentIntent(type: String, bundle: Bundle?) {
|
||||
if (type == Constants.AgentIntentType.IMAGE_UPLOAD) {
|
||||
onLog("Baidu visual agent intent: IMAGE_UPLOAD")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(error: Int, msg: String?, bundle: Bundle?) {
|
||||
onLog("Baidu visual uploader error: code=$error, msg=${msg ?: "-"}")
|
||||
}
|
||||
|
||||
override fun onMessage(message: String?) {
|
||||
if (!message.isNullOrBlank()) {
|
||||
onLog("Baidu visual message: ${message.take(80)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSession(appId: String, cid: String, token: String, userId: String, licenseKey: String) {
|
||||
val next = SessionConfig(
|
||||
appId = appId.trim(),
|
||||
cid = cid.trim(),
|
||||
token = token.trim(),
|
||||
userId = userId.trim(),
|
||||
licenseKey = licenseKey.trim()
|
||||
)
|
||||
if (next == sessionConfig) return
|
||||
sessionConfig = next
|
||||
val key = next.key()
|
||||
if (engine != null && startedKey.isNotBlank() && key != startedKey) {
|
||||
onLog("Baidu visual uploader session changed, restarting")
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
fun start() {
|
||||
ensureStarted()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
ready = false
|
||||
startedKey = ""
|
||||
runCatching { engine?.hangup() }
|
||||
runCatching { engine?.destroy() }
|
||||
engine = null
|
||||
deleteFile(activeUploadFile)
|
||||
activeUploadFile = null
|
||||
deleteFile(pendingUploadFile)
|
||||
pendingUploadFile = null
|
||||
}
|
||||
|
||||
fun uploadJpeg(jpegBytes: ByteArray): Boolean {
|
||||
if (jpegBytes.isEmpty()) return false
|
||||
val cfg = sessionConfig
|
||||
if (cfg == null || !cfg.isValid()) {
|
||||
onLog("Baidu visual uploader skipped: missing appId/cid/token")
|
||||
return false
|
||||
}
|
||||
cleanupStaleFiles()
|
||||
val file = runCatching {
|
||||
File(uploadDir, "visual_${System.currentTimeMillis()}.jpg").apply {
|
||||
writeBytes(jpegBytes)
|
||||
}
|
||||
}.getOrElse {
|
||||
onLog("Baidu visual file prepare failed: ${it.message}")
|
||||
return false
|
||||
}
|
||||
if (!ensureStarted()) {
|
||||
deleteFile(file)
|
||||
return false
|
||||
}
|
||||
if (!ready) {
|
||||
replacePendingUpload(file)
|
||||
onLog("Baidu visual upload queued: waiting call begin")
|
||||
return true
|
||||
}
|
||||
return sendUploadFile(file)
|
||||
}
|
||||
|
||||
private fun ensureStarted(): Boolean {
|
||||
val cfg = sessionConfig
|
||||
if (cfg == null || !cfg.isValid()) return false
|
||||
val key = cfg.key()
|
||||
if (engine != null && startedKey == key) return true
|
||||
val cidLong = cfg.cid.toLongOrNull()
|
||||
if (cidLong == null) {
|
||||
onLog("Baidu visual uploader skipped: cid not numeric")
|
||||
return false
|
||||
}
|
||||
stop()
|
||||
val params = AIAgentEngine.AIAgentEngineParams().apply {
|
||||
appId = cfg.appId
|
||||
workflow = "voiceChat"
|
||||
context = ""
|
||||
verbose = true
|
||||
enableExternalAudioInput = true
|
||||
enableExternalAudioOutput = true
|
||||
licenseKey = cfg.licenseKey
|
||||
userId = cfg.userId
|
||||
}
|
||||
val nextEngine = runCatching {
|
||||
AIAgentEngine.init(appContext, params)
|
||||
}.getOrElse {
|
||||
onLog("Baidu visual uploader init failed: ${it.message}")
|
||||
return false
|
||||
}
|
||||
engine = nextEngine
|
||||
engine?.setCallback(callback)
|
||||
ready = false
|
||||
startedKey = key
|
||||
onLog("Baidu visual uploader calling: cid=${cfg.cid}")
|
||||
runCatching {
|
||||
nextEngine.call(cfg.token, cidLong)
|
||||
}.onFailure {
|
||||
onLog("Baidu visual uploader call failed: ${it.message}")
|
||||
stop()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun flushPendingUpload() {
|
||||
val file = pendingUploadFile ?: return
|
||||
pendingUploadFile = null
|
||||
if (!sendUploadFile(file)) {
|
||||
replacePendingUpload(file)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendUploadFile(file: File): Boolean {
|
||||
val nextEngine = engine ?: return false
|
||||
deleteFile(activeUploadFile)
|
||||
activeUploadFile = file
|
||||
val ok = runCatching {
|
||||
nextEngine.uploadFile(file.absolutePath, VISUAL_UPLOAD_EXPIRE_SECONDS)
|
||||
}.getOrElse {
|
||||
onLog("Baidu visual upload call failed: ${it.message}")
|
||||
false
|
||||
}
|
||||
if (ok) {
|
||||
onLog("Baidu visual upload sent: ${file.name}, bytes=${file.length()}")
|
||||
} else {
|
||||
onLog("Baidu visual upload send failed")
|
||||
deleteFile(activeUploadFile)
|
||||
activeUploadFile = null
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
private fun replacePendingUpload(file: File) {
|
||||
deleteFile(pendingUploadFile)
|
||||
pendingUploadFile = file
|
||||
}
|
||||
|
||||
private fun cleanupStaleFiles() {
|
||||
val cutoff = System.currentTimeMillis() - VISUAL_UPLOAD_KEEP_MS
|
||||
uploadDir.listFiles()?.forEach { file ->
|
||||
if (file.lastModified() < cutoff) {
|
||||
deleteFile(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteFile(file: File?) {
|
||||
if (file == null) return
|
||||
runCatching {
|
||||
if (file.exists()) {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
||||
package com.aiglasses.app.storyforge
|
||||
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface StoryForgeApiService {
|
||||
@POST("v2/auth/register")
|
||||
suspend fun register(@Body request: RegisterAccountRequest): AccountDto
|
||||
|
||||
@POST("v2/auth/login")
|
||||
suspend fun login(@Body request: LoginRequest): AuthResponseDto
|
||||
|
||||
@POST("v2/auth/logout")
|
||||
suspend fun logout(): Map<String, Boolean>
|
||||
|
||||
@GET("v2/me")
|
||||
suspend fun me(): AccountDto
|
||||
|
||||
@GET("v2/me/dashboard")
|
||||
suspend fun dashboard(): DashboardDto
|
||||
|
||||
@GET("v2/model-profiles")
|
||||
suspend fun modelProfiles(): List<ModelProfileDto>
|
||||
|
||||
@POST("v2/model-profiles")
|
||||
suspend fun createModelProfile(@Body request: ModelProfileRequest): ModelProfileDto
|
||||
|
||||
@POST("v2/me/preferences/analysis-model")
|
||||
suspend fun setPreferredAnalysisModel(@Body request: PreferredModelRequest): AccountDto
|
||||
|
||||
@GET("v2/knowledge-bases")
|
||||
suspend fun knowledgeBases(): List<KnowledgeBaseDto>
|
||||
|
||||
@POST("v2/knowledge-bases")
|
||||
suspend fun createKnowledgeBase(@Body request: KnowledgeBaseCreateRequest): KnowledgeBaseDto
|
||||
|
||||
@GET("v2/knowledge-bases/{knowledgeBaseId}/documents")
|
||||
suspend fun knowledgeDocuments(@Path("knowledgeBaseId") knowledgeBaseId: String): List<KnowledgeDocumentDto>
|
||||
|
||||
@GET("v2/explore/jobs")
|
||||
suspend fun jobs(): List<JobDto>
|
||||
|
||||
@GET("v2/explore/jobs/{jobId}")
|
||||
suspend fun job(@Path("jobId") jobId: String): JobDto
|
||||
|
||||
@POST("v2/explore/video-link")
|
||||
suspend fun createVideoLinkJob(@Body request: ExploreVideoLinkRequest): JobDto
|
||||
|
||||
@POST("v2/explore/text")
|
||||
suspend fun createTextJob(@Body request: ExploreTextRequest): JobDto
|
||||
|
||||
@Multipart
|
||||
@POST("v2/explore/upload-video")
|
||||
suspend fun uploadVideo(
|
||||
@Part file: MultipartBody.Part,
|
||||
@Part("title") title: RequestBody,
|
||||
@Part("knowledge_base_id") knowledgeBaseId: RequestBody,
|
||||
@Part("assistant_id") assistantId: RequestBody,
|
||||
@Part("analysis_model_profile_id") analysisModelProfileId: RequestBody
|
||||
): JobDto
|
||||
|
||||
@GET("v2/assistants")
|
||||
suspend fun assistants(): List<AssistantDto>
|
||||
|
||||
@POST("v2/assistants")
|
||||
suspend fun createAssistant(@Body request: AssistantCreateRequest): AssistantDto
|
||||
|
||||
@PATCH("v2/assistants/{assistantId}")
|
||||
suspend fun updateAssistant(
|
||||
@Path("assistantId") assistantId: String,
|
||||
@Body request: AssistantUpdateRequest
|
||||
): AssistantDto
|
||||
|
||||
@POST("v2/assistants/{assistantId}/generate")
|
||||
suspend fun generateCopy(
|
||||
@Path("assistantId") assistantId: String,
|
||||
@Body request: GenerateCopyRequest
|
||||
): GenerateCopyResponseDto
|
||||
|
||||
@GET("v2/admin/accounts/pending")
|
||||
suspend fun pendingAccounts(): List<AccountDto>
|
||||
|
||||
@POST("v2/admin/accounts/{accountId}/approve")
|
||||
suspend fun approveAccount(@Path("accountId") accountId: String): ApprovalDecisionDto
|
||||
|
||||
@POST("v2/admin/accounts/{accountId}/reject")
|
||||
suspend fun rejectAccount(@Path("accountId") accountId: String): ApprovalDecisionDto
|
||||
|
||||
@GET("api/v1/app/update/latest")
|
||||
suspend fun latestUpdate(
|
||||
@Query("platform") platform: String = "android",
|
||||
@Query("channel") channel: String = "stable",
|
||||
@Query("currentVersionCode") currentVersionCode: Int? = null
|
||||
): AppUpdateLatestDto
|
||||
|
||||
@POST("v2/admin/app/update/publish")
|
||||
suspend fun publishAppUpdate(@Body request: PublishAppUpdateRequest): PublishAppUpdateResponseDto
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package com.aiglasses.app.storyforge
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class RegisterAccountRequest(
|
||||
val username: String,
|
||||
val password: String,
|
||||
val display_name: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoginRequest(
|
||||
val username: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AccountDto(
|
||||
val id: String,
|
||||
val username: String,
|
||||
val display_name: String,
|
||||
val role: String,
|
||||
val approval_status: String,
|
||||
val approved_by: String? = null,
|
||||
val approved_at: String? = null,
|
||||
val preferred_analysis_model_id: String = "",
|
||||
val created_at: String = "",
|
||||
val updated_at: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AuthResponseDto(
|
||||
val token: String,
|
||||
val account: AccountDto,
|
||||
val default_external_base_url: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ModelProfileDto(
|
||||
val id: String,
|
||||
val owner_account_id: String? = null,
|
||||
val name: String,
|
||||
val provider: String,
|
||||
val base_url: String,
|
||||
val api_key_masked: String = "",
|
||||
val model_name: String,
|
||||
val is_system: Boolean = false,
|
||||
val is_default: Boolean = false,
|
||||
val created_at: String = "",
|
||||
val updated_at: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ModelProfileRequest(
|
||||
val name: String,
|
||||
val base_url: String,
|
||||
val api_key: String,
|
||||
val model_name: String,
|
||||
val is_default: Boolean = false
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PreferredModelRequest(
|
||||
val model_profile_id: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KnowledgeBaseDto(
|
||||
val id: String,
|
||||
val user_id: String,
|
||||
val name: String,
|
||||
val description: String = "",
|
||||
val fastgpt_dataset_id: String? = null,
|
||||
val sync_status: String = "pending",
|
||||
val document_count: Int = 0,
|
||||
val linked_assistant_count: Int = 0,
|
||||
val created_at: String = "",
|
||||
val updated_at: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KnowledgeBaseCreateRequest(
|
||||
val name: String,
|
||||
val description: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AssistantDto(
|
||||
val id: String,
|
||||
val user_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 model_profile_id: String = "",
|
||||
val created_at: String = "",
|
||||
val updated_at: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AssistantCreateRequest(
|
||||
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 model_profile_id: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AssistantUpdateRequest(
|
||||
val name: String? = null,
|
||||
val description: String? = null,
|
||||
val system_prompt: String? = null,
|
||||
val generation_goal: String? = null,
|
||||
val knowledge_base_ids: List<String>? = null,
|
||||
val fastgpt_app_key: String? = null,
|
||||
val model_profile_id: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ExploreVideoLinkRequest(
|
||||
val video_url: String,
|
||||
val title: String? = null,
|
||||
val knowledge_base_id: String? = null,
|
||||
val assistant_id: String? = null,
|
||||
val analysis_model_profile_id: String? = null,
|
||||
val language: String = "auto"
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ExploreTextRequest(
|
||||
val title: String,
|
||||
val content: String,
|
||||
val knowledge_base_id: String? = null,
|
||||
val assistant_id: String? = null,
|
||||
val analysis_model_profile_id: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class JobDto(
|
||||
val id: String,
|
||||
val user_id: String,
|
||||
val assistant_id: String? = null,
|
||||
val knowledge_base_id: String,
|
||||
val source_type: 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 analysis_model_profile_id: String = "",
|
||||
val created_at: String = "",
|
||||
val updated_at: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KnowledgeDocumentDto(
|
||||
val id: String,
|
||||
val knowledge_base_id: String,
|
||||
val title: String,
|
||||
val source_type: String,
|
||||
val source_url: String = "",
|
||||
val transcript_text: String = "",
|
||||
val style_summary: String = "",
|
||||
val combined_text: String = "",
|
||||
val fastgpt_collection_id: String = "",
|
||||
val analysis_model_profile_id: String = "",
|
||||
val created_at: String = "",
|
||||
val updated_at: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GenerateCopyRequest(
|
||||
val brief: String,
|
||||
val platform: String = "抖音",
|
||||
val audience: String = "创业者",
|
||||
val extra_requirements: String = "",
|
||||
val knowledge_base_ids: List<String> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GenerateCopyResponseDto(
|
||||
val assistant_id: String,
|
||||
val knowledge_base_ids: List<String>,
|
||||
val content: String,
|
||||
val prompt_excerpt: String,
|
||||
val used_documents: List<KnowledgeDocumentDto> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardDto(
|
||||
val account: AccountDto,
|
||||
val knowledge_bases: List<KnowledgeBaseDto> = emptyList(),
|
||||
val assistants: List<AssistantDto> = emptyList(),
|
||||
val recent_jobs: List<JobDto> = emptyList(),
|
||||
val model_profiles: List<ModelProfileDto> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApprovalDecisionDto(
|
||||
val saved: Boolean,
|
||||
val account: AccountDto
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PublishAppUpdateRequest(
|
||||
val platform: String = "android",
|
||||
val channel: String = "stable",
|
||||
val versionCode: Int,
|
||||
val versionName: String,
|
||||
val minSupportedCode: Int,
|
||||
val apkUrl: String,
|
||||
val apkSha256: String = "",
|
||||
val notes: String = "",
|
||||
val forceUpdate: Boolean = false,
|
||||
val isActive: Boolean = true
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PublishAppUpdateResponseDto(
|
||||
val saved: Boolean,
|
||||
val action: String,
|
||||
val updateId: Int = 0
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AppUpdateLatestDto(
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,366 @@
|
||||
package com.aiglasses.app.storyforge
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import com.aiglasses.app.BuildConfig
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.InetAddress
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
|
||||
data class StoryForgeConnectionInfo(
|
||||
val rawBaseUrl: String,
|
||||
val requestBaseUrl: String,
|
||||
val originalHostHeader: String,
|
||||
val resolvedIp: String
|
||||
)
|
||||
|
||||
data class StoryForgeLoginResult(
|
||||
val auth: AuthResponseDto,
|
||||
val connection: StoryForgeConnectionInfo
|
||||
)
|
||||
|
||||
class StoryForgeRepository(private val context: Context) {
|
||||
private val appContext = context.applicationContext
|
||||
private val sessionStore = StoryForgeSessionStore(appContext)
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var cachedService: StoryForgeApiService? = null
|
||||
|
||||
@Volatile
|
||||
private var cachedConnection: StoryForgeConnectionInfo? = null
|
||||
|
||||
@Volatile
|
||||
private var cachedToken: String = ""
|
||||
|
||||
fun savedSession(): SavedStoryForgeSession = sessionStore.load()
|
||||
|
||||
fun saveBaseUrl(baseUrl: String) {
|
||||
sessionStore.saveBaseUrl(normalizeRawBaseUrl(baseUrl))
|
||||
}
|
||||
|
||||
suspend fun resolveConnection(baseUrl: String): StoryForgeConnectionInfo = withContext(Dispatchers.IO) {
|
||||
resolveConnectionInternal(baseUrl)
|
||||
}
|
||||
|
||||
suspend fun register(baseUrl: String, username: String, password: String, displayName: String): AccountDto {
|
||||
sessionStore.saveBaseUrl(normalizeRawBaseUrl(baseUrl))
|
||||
return api(baseUrl = baseUrl, token = "").register(
|
||||
RegisterAccountRequest(
|
||||
username = username,
|
||||
password = password,
|
||||
display_name = displayName
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun login(baseUrl: String, username: String, password: String): StoryForgeLoginResult {
|
||||
val auth = api(baseUrl = baseUrl, token = "").login(LoginRequest(username = username, password = password))
|
||||
val effectiveBaseUrl = auth.default_external_base_url.ifBlank { normalizeRawBaseUrl(baseUrl) }
|
||||
sessionStore.save(effectiveBaseUrl, auth.token)
|
||||
cachedService = null
|
||||
val connection = apiConnection(baseUrl = effectiveBaseUrl, token = auth.token)
|
||||
return StoryForgeLoginResult(auth = auth, connection = connection)
|
||||
}
|
||||
|
||||
suspend fun logout() {
|
||||
runCatching { api().logout() }
|
||||
sessionStore.clearToken()
|
||||
cachedToken = ""
|
||||
cachedService = null
|
||||
}
|
||||
|
||||
suspend fun me(): AccountDto = api().me()
|
||||
|
||||
suspend fun dashboard(): DashboardDto = api().dashboard()
|
||||
|
||||
suspend fun modelProfiles(): List<ModelProfileDto> = api().modelProfiles()
|
||||
|
||||
suspend fun createModelProfile(request: ModelProfileRequest): ModelProfileDto = api().createModelProfile(request)
|
||||
|
||||
suspend fun setPreferredAnalysisModel(modelProfileId: String): AccountDto =
|
||||
api().setPreferredAnalysisModel(PreferredModelRequest(model_profile_id = modelProfileId))
|
||||
|
||||
suspend fun createKnowledgeBase(name: String, description: String): KnowledgeBaseDto =
|
||||
api().createKnowledgeBase(KnowledgeBaseCreateRequest(name = name, description = description))
|
||||
|
||||
suspend fun knowledgeDocuments(knowledgeBaseId: String): List<KnowledgeDocumentDto> =
|
||||
api().knowledgeDocuments(knowledgeBaseId)
|
||||
|
||||
suspend fun jobs(): List<JobDto> = api().jobs()
|
||||
|
||||
suspend fun job(jobId: String): JobDto = api().job(jobId)
|
||||
|
||||
suspend fun createVideoLinkJob(
|
||||
videoUrl: String,
|
||||
title: String,
|
||||
knowledgeBaseId: String,
|
||||
assistantId: String,
|
||||
analysisModelProfileId: String
|
||||
): JobDto = api().createVideoLinkJob(
|
||||
ExploreVideoLinkRequest(
|
||||
video_url = videoUrl,
|
||||
title = title.ifBlank { null },
|
||||
knowledge_base_id = knowledgeBaseId.ifBlank { null },
|
||||
assistant_id = assistantId.ifBlank { null },
|
||||
analysis_model_profile_id = analysisModelProfileId.ifBlank { null }
|
||||
)
|
||||
)
|
||||
|
||||
suspend fun createTextJob(
|
||||
title: String,
|
||||
content: String,
|
||||
knowledgeBaseId: String,
|
||||
assistantId: String,
|
||||
analysisModelProfileId: String
|
||||
): JobDto = api().createTextJob(
|
||||
ExploreTextRequest(
|
||||
title = title,
|
||||
content = content,
|
||||
knowledge_base_id = knowledgeBaseId.ifBlank { null },
|
||||
assistant_id = assistantId.ifBlank { null },
|
||||
analysis_model_profile_id = analysisModelProfileId.ifBlank { null }
|
||||
)
|
||||
)
|
||||
|
||||
suspend fun uploadVideo(
|
||||
uri: Uri,
|
||||
title: String,
|
||||
knowledgeBaseId: String,
|
||||
assistantId: String,
|
||||
analysisModelProfileId: String
|
||||
): JobDto = withContext(Dispatchers.IO) {
|
||||
val tempFile = copyUriToCache(uri)
|
||||
try {
|
||||
val filePart = MultipartBody.Part.createFormData(
|
||||
name = "file",
|
||||
filename = tempFile.name,
|
||||
body = tempFile.asRequestBody(guessMimeType(tempFile.name).toMediaTypeOrNull())
|
||||
)
|
||||
api().uploadVideo(
|
||||
file = filePart,
|
||||
title = title.toRequestBody("text/plain".toMediaType()),
|
||||
knowledgeBaseId = knowledgeBaseId.toRequestBody("text/plain".toMediaType()),
|
||||
assistantId = assistantId.toRequestBody("text/plain".toMediaType()),
|
||||
analysisModelProfileId = analysisModelProfileId.toRequestBody("text/plain".toMediaType())
|
||||
)
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createAssistant(request: AssistantCreateRequest): AssistantDto = api().createAssistant(request)
|
||||
|
||||
suspend fun updateAssistant(assistantId: String, request: AssistantUpdateRequest): AssistantDto =
|
||||
api().updateAssistant(assistantId, request)
|
||||
|
||||
suspend fun generateCopy(assistantId: String, request: GenerateCopyRequest): GenerateCopyResponseDto =
|
||||
api().generateCopy(assistantId, request)
|
||||
|
||||
suspend fun pendingAccounts(): List<AccountDto> = api().pendingAccounts()
|
||||
|
||||
suspend fun approveAccount(accountId: String): ApprovalDecisionDto = api().approveAccount(accountId)
|
||||
|
||||
suspend fun rejectAccount(accountId: String): ApprovalDecisionDto = api().rejectAccount(accountId)
|
||||
|
||||
suspend fun latestUpdate(currentVersionCode: Int): AppUpdateLatestDto =
|
||||
api().latestUpdate(currentVersionCode = currentVersionCode)
|
||||
|
||||
suspend fun publishAppUpdate(request: PublishAppUpdateRequest): PublishAppUpdateResponseDto =
|
||||
api().publishAppUpdate(request)
|
||||
|
||||
suspend fun currentConnection(): StoryForgeConnectionInfo = apiConnection()
|
||||
|
||||
private suspend fun api(
|
||||
baseUrl: String? = null,
|
||||
token: String? = null
|
||||
): StoryForgeApiService = withContext(Dispatchers.IO) {
|
||||
val connection = apiConnection(baseUrl = baseUrl, token = token)
|
||||
val authToken = token ?: sessionStore.load().token
|
||||
if (cachedService != null && cachedConnection == connection && cachedToken == authToken) {
|
||||
return@withContext cachedService!!
|
||||
}
|
||||
val client = buildClient(connection, authToken)
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(connection.requestBaseUrl)
|
||||
.client(client)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
retrofit.create<StoryForgeApiService>().also {
|
||||
cachedService = it
|
||||
cachedConnection = connection
|
||||
cachedToken = authToken
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun apiConnection(
|
||||
baseUrl: String? = null,
|
||||
token: String? = null
|
||||
): StoryForgeConnectionInfo = withContext(Dispatchers.IO) {
|
||||
val saved = sessionStore.load()
|
||||
val targetBaseUrl = normalizeRawBaseUrl(baseUrl ?: saved.baseUrl)
|
||||
val resolved = resolveConnectionInternal(targetBaseUrl)
|
||||
cachedConnection = resolved
|
||||
if (token != null) {
|
||||
cachedToken = token
|
||||
}
|
||||
resolved
|
||||
}
|
||||
|
||||
private fun buildClient(connection: StoryForgeConnectionInfo, token: String): OkHttpClient {
|
||||
val logging = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BASIC
|
||||
}
|
||||
return OkHttpClient.Builder()
|
||||
.protocols(listOf(Protocol.HTTP_1_1))
|
||||
.connectTimeout(12, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.writeTimeout(120, TimeUnit.SECONDS)
|
||||
.callTimeout(150, TimeUnit.SECONDS)
|
||||
.addInterceptor { chain ->
|
||||
val builder: Request.Builder = chain.request().newBuilder()
|
||||
if (token.isNotBlank()) {
|
||||
builder.header("Authorization", "Bearer $token")
|
||||
}
|
||||
if (connection.originalHostHeader.isNotBlank()) {
|
||||
builder.header("Host", connection.originalHostHeader)
|
||||
}
|
||||
builder.header("Connection", "close")
|
||||
chain.proceed(builder.build())
|
||||
}
|
||||
.addInterceptor(logging)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun normalizeRawBaseUrl(baseUrl: String): String {
|
||||
val trimmed = baseUrl.trim().ifBlank { BuildConfig.DEFAULT_STORYFORGE_BASE_URL }
|
||||
val migrated = when {
|
||||
trimmed.startsWith("http://test.hyzq.net:8081") -> BuildConfig.DEFAULT_STORYFORGE_BASE_URL
|
||||
trimmed.startsWith("http://111.231.132.51:8081") -> BuildConfig.DEFAULT_STORYFORGE_BASE_URL
|
||||
else -> trimmed
|
||||
}
|
||||
val withScheme = if (migrated.startsWith("http://") || migrated.startsWith("https://")) migrated else "http://$migrated"
|
||||
return if (withScheme.endsWith('/')) withScheme else "$withScheme/"
|
||||
}
|
||||
|
||||
private fun resolveConnectionInternal(baseUrl: String): StoryForgeConnectionInfo {
|
||||
val normalized = normalizeRawBaseUrl(baseUrl)
|
||||
val httpUrl = normalized.toHttpUrlOrNull() ?: error("无效后端地址: $baseUrl")
|
||||
val host = httpUrl.host
|
||||
val scheme = httpUrl.scheme
|
||||
if (scheme == "https" || isIpHost(host) || host == "localhost" || host == "10.0.2.2") {
|
||||
return StoryForgeConnectionInfo(
|
||||
rawBaseUrl = normalized,
|
||||
requestBaseUrl = normalized,
|
||||
originalHostHeader = "",
|
||||
resolvedIp = if (isIpHost(host)) host else ""
|
||||
)
|
||||
}
|
||||
val resolvedIp = runCatching {
|
||||
InetAddress.getAllByName(host).firstOrNull()?.hostAddress.orEmpty()
|
||||
}.getOrDefault("")
|
||||
.takeUnless { isInvalidResolvedIp(it) }
|
||||
.orEmpty()
|
||||
.ifBlank {
|
||||
if (host.equals("test.hyzq.net", ignoreCase = true)) BuildConfig.DEFAULT_STORYFORGE_FALLBACK_IP else ""
|
||||
}
|
||||
if (resolvedIp.isBlank()) {
|
||||
return StoryForgeConnectionInfo(
|
||||
rawBaseUrl = normalized,
|
||||
requestBaseUrl = normalized,
|
||||
originalHostHeader = "",
|
||||
resolvedIp = ""
|
||||
)
|
||||
}
|
||||
val rewritten = httpUrl.newBuilder().host(resolvedIp).build().toString()
|
||||
return StoryForgeConnectionInfo(
|
||||
rawBaseUrl = normalized,
|
||||
requestBaseUrl = rewritten,
|
||||
originalHostHeader = hostHeaderValue(httpUrl.host, httpUrl.port, scheme),
|
||||
resolvedIp = resolvedIp
|
||||
)
|
||||
}
|
||||
|
||||
private fun hostHeaderValue(host: String, port: Int, scheme: String): String {
|
||||
val isDefaultPort = (scheme == "http" && port == 80) || (scheme == "https" && port == 443)
|
||||
return if (isDefaultPort) host else "$host:$port"
|
||||
}
|
||||
|
||||
private fun isIpHost(host: String): Boolean {
|
||||
return IPV4_REGEX.matches(host) || host.contains(':')
|
||||
}
|
||||
|
||||
private fun isInvalidResolvedIp(ip: String): Boolean {
|
||||
if (ip.isBlank()) return true
|
||||
if (!IPV4_REGEX.matches(ip)) return false
|
||||
val octets = ip.split('.').mapNotNull { it.toIntOrNull() }
|
||||
if (octets.size != 4) return false
|
||||
if (octets[0] == 127) return true
|
||||
if (octets[0] == 0) return true
|
||||
if (octets[0] == 169 && octets[1] == 254) return true
|
||||
if (octets[0] == 198 && (octets[1] == 18 || octets[1] == 19)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
private fun copyUriToCache(uri: Uri): File {
|
||||
val displayName = queryDisplayName(uri)
|
||||
val safeName = displayName.ifBlank { "upload-${System.currentTimeMillis()}.mp4" }
|
||||
val suffix = safeName.substringAfterLast('.', missingDelimiterValue = "mp4")
|
||||
val target = File(appContext.cacheDir, "storyforge-${System.currentTimeMillis()}.$suffix")
|
||||
appContext.contentResolver.openInputStream(uri).use { input ->
|
||||
requireNotNull(input) { "无法读取所选视频" }
|
||||
FileOutputStream(target).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
private fun queryDisplayName(uri: Uri): String {
|
||||
if (uri.scheme == "file") {
|
||||
return File(uri.path.orEmpty()).name
|
||||
}
|
||||
val cursor = appContext.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
|
||||
cursor?.use {
|
||||
val index = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (index >= 0 && it.moveToFirst()) {
|
||||
return it.getString(index).orEmpty()
|
||||
}
|
||||
}
|
||||
return uri.lastPathSegment.orEmpty()
|
||||
}
|
||||
|
||||
private fun guessMimeType(fileName: String): String = when {
|
||||
fileName.endsWith(".mov", ignoreCase = true) -> "video/quicktime"
|
||||
fileName.endsWith(".m4v", ignoreCase = true) -> "video/x-m4v"
|
||||
else -> "video/mp4"
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private val IPV4_REGEX = Regex("""^\\d{1,3}(?:\\.\\d{1,3}){3}$""")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
package com.aiglasses.app.storyforge
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun StoryForgeScreen(
|
||||
state: StoryForgeUiState,
|
||||
vm: StoryForgeViewModel,
|
||||
onPickVideo: () -> Unit,
|
||||
onInstallLatestUpdate: () -> Unit
|
||||
) {
|
||||
val heroBrush = Brush.linearGradient(
|
||||
colors = listOf(Color(0xFF0B3C5D), Color(0xFF1F6E5F), Color(0xFFB97524))
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
if (state.isAuthenticated && state.isApproved) {
|
||||
NavigationBar(modifier = Modifier.navigationBarsPadding()) {
|
||||
BottomTabItem(label = "探索", tab = StoryForgeTab.Explore, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
when {
|
||||
!state.isAuthenticated -> AuthScreen(state = state, vm = vm, heroBrush = heroBrush)
|
||||
!state.isApproved -> PendingApprovalScreen(state = state, vm = vm, heroBrush = heroBrush)
|
||||
else -> AppShell(
|
||||
state = state,
|
||||
vm = vm,
|
||||
heroBrush = heroBrush,
|
||||
onPickVideo = onPickVideo,
|
||||
onInstallLatestUpdate = onInstallLatestUpdate
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomTabItem(
|
||||
label: String,
|
||||
tab: StoryForgeTab,
|
||||
state: StoryForgeUiState,
|
||||
onSelect: (StoryForgeTab) -> Unit
|
||||
) {
|
||||
val selected = state.currentTab == tab
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.clickable { onSelect(tab) }
|
||||
.background(if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(text = label.take(1), fontWeight = FontWeight.Bold)
|
||||
Text(label, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthScreen(
|
||||
state: StoryForgeUiState,
|
||||
vm: StoryForgeViewModel,
|
||||
heroBrush: Brush
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(heroBrush)
|
||||
.padding(18.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text("StoryForge AI", style = MaterialTheme.typography.headlineSmall)
|
||||
Text(
|
||||
if (state.authMode == StoryForgeAuthMode.Login) "登录账号" else "注册新账号,提交后等待主管理员审批",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
ChoiceRow(
|
||||
options = listOf("登录" to (state.authMode == StoryForgeAuthMode.Login), "注册" to (state.authMode == StoryForgeAuthMode.Register)),
|
||||
onSelect = { label -> vm.setAuthMode(if (label == "登录") StoryForgeAuthMode.Login else StoryForgeAuthMode.Register) }
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.username,
|
||||
onValueChange = vm::updateUsername,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("账号") },
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.password,
|
||||
onValueChange = vm::updatePassword,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("密码") },
|
||||
singleLine = true
|
||||
)
|
||||
Button(
|
||||
onClick = { if (state.authMode == StoryForgeAuthMode.Login) vm.login() else vm.registerAccount() },
|
||||
enabled = !state.busy,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (state.busy) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text(if (state.authMode == StoryForgeAuthMode.Login) "登录" else "注册")
|
||||
}
|
||||
}
|
||||
if (state.statusMessage.isNotBlank()) {
|
||||
Text(state.statusMessage, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (state.errorMessage.isNotBlank()) {
|
||||
Text(state.errorMessage, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PendingApprovalScreen(
|
||||
state: StoryForgeUiState,
|
||||
vm: StoryForgeViewModel,
|
||||
heroBrush: Brush
|
||||
) {
|
||||
val account = state.account
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
HeroCard(
|
||||
title = "等待审批",
|
||||
subtitle = "${account?.display_name ?: account?.username ?: "当前账号"} 已登录,但尚未通过主管理员审批。",
|
||||
heroBrush = heroBrush,
|
||||
badges = listOf(
|
||||
"审批状态:${account?.approval_status ?: "pending"}",
|
||||
if (state.resolvedIp.isNotBlank()) "已解析到 ${state.resolvedIp}" else ""
|
||||
).filter { it.isNotBlank() }
|
||||
)
|
||||
SectionCard(title = "当前说明", subtitle = state.statusMessage) {
|
||||
Text("新注册账号在主管理员通过前,无法访问探索、生产和知识库功能。")
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(onClick = vm::refreshApprovalStatus, enabled = !state.busy) {
|
||||
Text("刷新审批状态")
|
||||
}
|
||||
OutlinedButton(onClick = vm::logout) {
|
||||
Text("退出登录")
|
||||
}
|
||||
}
|
||||
if (state.errorMessage.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Text(state.errorMessage, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppShell(
|
||||
state: StoryForgeUiState,
|
||||
vm: StoryForgeViewModel,
|
||||
heroBrush: Brush,
|
||||
onPickVideo: () -> Unit,
|
||||
onInstallLatestUpdate: () -> Unit
|
||||
) {
|
||||
val scroll = rememberScrollState()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scroll)
|
||||
.padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
HeroCard(
|
||||
title = when (state.currentTab) {
|
||||
StoryForgeTab.Explore -> "探索素材"
|
||||
StoryForgeTab.Production -> "生产文案"
|
||||
StoryForgeTab.Mine -> "我的工作台"
|
||||
},
|
||||
subtitle = state.statusMessage,
|
||||
heroBrush = heroBrush,
|
||||
badges = listOf(
|
||||
state.account?.display_name ?: state.account?.username.orEmpty(),
|
||||
state.account?.role ?: "",
|
||||
if (state.resolvedIp.isNotBlank()) "IP ${state.resolvedIp}" else ""
|
||||
).filter { it.isNotBlank() }
|
||||
)
|
||||
StatusStrip(state = state, onRefresh = vm::refreshWorkspace)
|
||||
when (state.currentTab) {
|
||||
StoryForgeTab.Explore -> ExploreTab(state = state, vm = vm, onPickVideo = onPickVideo)
|
||||
StoryForgeTab.Production -> ProductionTab(state = state, vm = vm)
|
||||
StoryForgeTab.Mine -> MineTab(state = state, vm = vm, onInstallLatestUpdate = onInstallLatestUpdate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusStrip(state: StoryForgeUiState, onRefresh: () -> Unit) {
|
||||
SectionCard(title = "连接状态", subtitle = if (state.busy) "正在同步" else "已连接") {
|
||||
Text(
|
||||
text = if (state.originalHost.isNotBlank()) {
|
||||
"外网域名已解析为 ${state.resolvedIp},请求会携带 Host=${state.originalHost}"
|
||||
} else {
|
||||
"当前使用地址:${state.baseUrl}"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedButton(onClick = onRefresh) {
|
||||
Text("刷新")
|
||||
}
|
||||
if (state.busy) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
if (state.errorMessage.isNotBlank()) {
|
||||
Text(state.errorMessage, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExploreTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onPickVideo: () -> Unit) {
|
||||
SectionCard(title = "素材入口", subtitle = "视频链接、上传视频、输入文字都会转成文本并做风格分析") {
|
||||
ChoiceRow(
|
||||
options = listOf(
|
||||
"视频链接" to (state.exploreInputMode == ExploreInputMode.VideoLink),
|
||||
"上传视频" to (state.exploreInputMode == ExploreInputMode.UploadVideo),
|
||||
"输入文字" to (state.exploreInputMode == ExploreInputMode.Text)
|
||||
),
|
||||
onSelect = { label ->
|
||||
vm.setExploreInputMode(
|
||||
when (label) {
|
||||
"视频链接" -> ExploreInputMode.VideoLink
|
||||
"上传视频" -> ExploreInputMode.UploadVideo
|
||||
else -> ExploreInputMode.Text
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
KnowledgeBaseSelector(state = state, onSelect = vm::selectKnowledgeBase)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
AssistantSelector(state = state, onSelect = vm::selectAssistant)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "当前分析模型:${state.modelProfiles.firstOrNull { it.id == state.account?.preferred_analysis_model_id }?.name ?: "本机默认模型"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
when (state.exploreInputMode) {
|
||||
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))
|
||||
Button(onClick = vm::submitVideoLink, enabled = !state.busy) {
|
||||
Text("提交视频链接")
|
||||
}
|
||||
}
|
||||
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))
|
||||
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 ->
|
||||
SectionCard(title = "最新任务", subtitle = latestJob.title) {
|
||||
KeyValueRow(label = "状态", value = latestJob.status)
|
||||
KeyValueRow(label = "上传状态", value = latestJob.upload_status)
|
||||
if (latestJob.transcript_text.isNotBlank()) {
|
||||
KeyValueBlock(label = "文本转写", value = latestJob.transcript_text)
|
||||
}
|
||||
if (latestJob.style_summary.isNotBlank()) {
|
||||
KeyValueBlock(label = "风格提炼", value = latestJob.style_summary)
|
||||
}
|
||||
if (latestJob.error.isNotBlank()) {
|
||||
Text(latestJob.error, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.documents.isNotEmpty()) {
|
||||
SectionCard(title = "当前知识库素材", subtitle = "已经沉淀到所选知识库的文本样本") {
|
||||
state.documents.forEach { document ->
|
||||
MiniCard(title = document.title, subtitle = document.style_summary.ifBlank { document.transcript_text.take(100) })
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProductionTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
|
||||
SectionCard(title = "智能体列表", subtitle = "一个智能体默认关联一个知识库,也可以关联多个知识库") {
|
||||
ChoiceRow(
|
||||
options = state.assistants.map { it.name to (state.selectedAssistantId == it.id) },
|
||||
onSelect = { label ->
|
||||
state.assistants.firstOrNull { it.name == label }?.let { vm.selectAssistant(it.id) }
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedButton(onClick = vm::startNewAssistant) {
|
||||
Text("新建智能体")
|
||||
}
|
||||
}
|
||||
|
||||
SectionCard(title = "编辑智能体", subtitle = "提示词由用户提供,可随时调整模型和知识库绑定") {
|
||||
OutlinedTextField(
|
||||
value = state.assistantName,
|
||||
onValueChange = vm::updateAssistantName,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("智能体名称") },
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = state.assistantDescription,
|
||||
onValueChange = vm::updateAssistantDescription,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("智能体说明") },
|
||||
minLines = 2
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = state.assistantSystemPrompt,
|
||||
onValueChange = vm::updateAssistantSystemPrompt,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("系统提示词") },
|
||||
minLines = 5
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = state.assistantGenerationGoal,
|
||||
onValueChange = vm::updateAssistantGenerationGoal,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("生成目标") },
|
||||
minLines = 3
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text("选择生成模型", style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
ChoiceRow(
|
||||
options = state.modelProfiles.map { it.name to (state.assistantModelProfileId == it.id) },
|
||||
onSelect = { label ->
|
||||
state.modelProfiles.firstOrNull { it.name == label }?.let { vm.updateAssistantModelProfileId(it.id) }
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text("选择要关联的知识库", style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
ChoiceRow(
|
||||
options = state.knowledgeBases.map { it.name to state.selectedAssistantKnowledgeBaseIds.contains(it.id) },
|
||||
onSelect = { label ->
|
||||
state.knowledgeBases.firstOrNull { it.name == label }?.let { vm.toggleAssistantKnowledgeBase(it.id) }
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Button(onClick = vm::saveAssistant, enabled = !state.busy) {
|
||||
Text(if (state.assistantEditorId.isNullOrBlank()) "创建智能体" else "保存智能体配置")
|
||||
}
|
||||
}
|
||||
|
||||
SectionCard(title = "生成文案", subtitle = "选择智能体后,直接基于关联知识库输出文案") {
|
||||
OutlinedTextField(
|
||||
value = state.generationBrief,
|
||||
onValueChange = vm::updateGenerationBrief,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("文案需求") },
|
||||
minLines = 4
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(
|
||||
value = state.generationPlatform,
|
||||
onValueChange = vm::updateGenerationPlatform,
|
||||
modifier = Modifier.weight(1f),
|
||||
label = { Text("平台") },
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.generationAudience,
|
||||
onValueChange = vm::updateGenerationAudience,
|
||||
modifier = Modifier.weight(1f),
|
||||
label = { Text("目标受众") },
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = state.generationExtraRequirements,
|
||||
onValueChange = vm::updateGenerationExtraRequirements,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("额外要求") },
|
||||
minLines = 3
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(onClick = vm::generateCopy, enabled = !state.generateBusy) {
|
||||
if (state.generateBusy) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text("开始生成")
|
||||
}
|
||||
}
|
||||
if (state.generationOutput.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
KeyValueBlock(label = "生成结果", value = state.generationOutput)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MineTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onInstallLatestUpdate: () -> Unit) {
|
||||
SectionCard(title = "我的账号", subtitle = state.account?.display_name ?: state.account?.username.orEmpty()) {
|
||||
KeyValueRow(label = "用户名", value = state.account?.username ?: "-")
|
||||
KeyValueRow(label = "角色", value = state.account?.role ?: "-")
|
||||
KeyValueRow(label = "审批", value = state.account?.approval_status ?: "-")
|
||||
KeyValueRow(label = "Base URL", value = state.baseUrl)
|
||||
if (state.resolvedIp.isNotBlank()) {
|
||||
KeyValueRow(label = "解析 IP", value = state.resolvedIp)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedButton(onClick = vm::logout) {
|
||||
Text("退出登录")
|
||||
}
|
||||
}
|
||||
|
||||
SectionCard(title = "分析模型", subtitle = "探索页默认使用这里选中的模型") {
|
||||
ChoiceRow(
|
||||
options = state.modelProfiles.map { it.name to (state.account?.preferred_analysis_model_id == it.id) },
|
||||
onSelect = { label ->
|
||||
state.modelProfiles.firstOrNull { it.name == label }?.let { vm.setPreferredModel(it.id) }
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
OutlinedTextField(
|
||||
value = state.newModelName,
|
||||
onValueChange = vm::updateNewModelName,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("模型别名") },
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = state.newModelBaseUrl,
|
||||
onValueChange = vm::updateNewModelBaseUrl,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Base URL") },
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = state.newModelModelName,
|
||||
onValueChange = vm::updateNewModelModelName,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("模型名称") },
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = state.newModelApiKey,
|
||||
onValueChange = vm::updateNewModelApiKey,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("API Key") },
|
||||
minLines = 2
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(onClick = vm::createModelProfile) {
|
||||
Text("保存为默认分析模型")
|
||||
}
|
||||
}
|
||||
|
||||
SectionCard(title = "OTA 更新", subtitle = state.otaStatus.ifBlank { "检查新版本并执行安装" }) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Button(onClick = vm::checkForUpdates) {
|
||||
Text("检查更新")
|
||||
}
|
||||
OutlinedButton(onClick = onInstallLatestUpdate, enabled = state.otaInfo?.hasUpdate == true) {
|
||||
Text("安装最新版本")
|
||||
}
|
||||
}
|
||||
state.otaInfo?.let { ota ->
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
KeyValueRow(label = "最新版本", value = "${ota.latestVersionName} (${ota.latestVersionCode})")
|
||||
if (ota.releaseNotes.isNotBlank()) {
|
||||
KeyValueBlock(label = "更新说明", value = ota.releaseNotes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.account?.role == "super_admin") {
|
||||
SectionCard(title = "主管理员审批", subtitle = "新注册账号需要你审批后才能正常使用全部功能") {
|
||||
if (state.pendingAccounts.isEmpty()) {
|
||||
Text("当前没有待审批账号")
|
||||
} else {
|
||||
state.pendingAccounts.forEach { account ->
|
||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(account.display_name, fontWeight = FontWeight.Bold)
|
||||
Text(account.username, style = MaterialTheme.typography.bodySmall)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(onClick = { vm.approveAccount(account.id) }) {
|
||||
Text("通过")
|
||||
}
|
||||
OutlinedButton(onClick = { vm.rejectAccount(account.id) }) {
|
||||
Text("拒绝")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionCard(title = "发布 OTA", subtitle = "主管理员可直接更新在线版本号和下载地址") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(
|
||||
value = state.publishVersionCode,
|
||||
onValueChange = vm::updatePublishVersionCode,
|
||||
modifier = Modifier.weight(1f),
|
||||
label = { Text("VersionCode") },
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.publishMinSupportedCode,
|
||||
onValueChange = vm::updatePublishMinSupportedCode,
|
||||
modifier = Modifier.weight(1f),
|
||||
label = { Text("最低支持") },
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = state.publishVersionName,
|
||||
onValueChange = vm::updatePublishVersionName,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("VersionName") },
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = state.publishApkUrl,
|
||||
onValueChange = vm::updatePublishApkUrl,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("APK 下载地址") },
|
||||
minLines = 2
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = state.publishNotes,
|
||||
onValueChange = vm::updatePublishNotes,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("更新说明") },
|
||||
minLines = 3
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("强制更新")
|
||||
Switch(checked = state.publishForceUpdate, onCheckedChange = vm::setPublishForceUpdate)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(onClick = vm::publishUpdate) {
|
||||
Text("发布 OTA")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionCard(title = "最近日志", subtitle = "用于确认审批、解析、任务和 OTA 状态") {
|
||||
state.timeline.forEach { item ->
|
||||
Text(item, style = MaterialTheme.typography.bodySmall)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun ChoiceRow(
|
||||
options: List<Pair<String, Boolean>>,
|
||||
onSelect: (String) -> Unit
|
||||
) {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
options.forEach { (label, selected) ->
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = { onSelect(label) },
|
||||
label = { Text(label) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KnowledgeBaseSelector(state: StoryForgeUiState, onSelect: (String) -> Unit) {
|
||||
Text("选择知识库", style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
ChoiceRow(
|
||||
options = state.knowledgeBases.map { it.name to (state.selectedKnowledgeBaseId == it.id) },
|
||||
onSelect = { label ->
|
||||
state.knowledgeBases.firstOrNull { it.name == label }?.let { onSelect(it.id) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AssistantSelector(state: StoryForgeUiState, onSelect: (String) -> Unit) {
|
||||
Text("选择关联智能体", style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
ChoiceRow(
|
||||
options = state.assistants.map { it.name to (state.selectedAssistantId == it.id) },
|
||||
onSelect = { label ->
|
||||
state.assistants.firstOrNull { it.name == label }?.let { onSelect(it.id) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeroCard(title: String, subtitle: String, heroBrush: Brush, badges: List<String>) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.background(heroBrush)
|
||||
.padding(20.dp)
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.headlineLarge, color = Color.White)
|
||||
Text(subtitle, style = MaterialTheme.typography.bodyLarge, color = Color(0xFFF8F5EF))
|
||||
if (badges.isNotEmpty()) {
|
||||
ChoiceRow(options = badges.map { it to true }, onSelect = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionCard(title: String, subtitle: String, content: @Composable () -> Unit) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall)
|
||||
if (subtitle.isNotBlank()) {
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeyValueRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(label, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(value, modifier = Modifier.weight(1f), maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeyValueBlock(label: String, value: String) {
|
||||
Text(label, style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), RoundedCornerShape(16.dp))
|
||||
.padding(14.dp)
|
||||
) {
|
||||
Text(value)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MiniCard(title: String, subtitle: String) {
|
||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(title, fontWeight = FontWeight.Bold)
|
||||
Text(subtitle, maxLines = 4, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.aiglasses.app.storyforge
|
||||
|
||||
import android.content.Context
|
||||
import com.aiglasses.app.BuildConfig
|
||||
|
||||
data class SavedStoryForgeSession(
|
||||
val baseUrl: String,
|
||||
val token: String
|
||||
)
|
||||
|
||||
class StoryForgeSessionStore(context: Context) {
|
||||
private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun load(): SavedStoryForgeSession = SavedStoryForgeSession(
|
||||
baseUrl = migrateBaseUrl(prefs.getString(KEY_BASE_URL, BuildConfig.DEFAULT_STORYFORGE_BASE_URL).orEmpty()),
|
||||
token = prefs.getString(KEY_TOKEN, "").orEmpty()
|
||||
)
|
||||
|
||||
fun saveBaseUrl(baseUrl: String) {
|
||||
prefs.edit().putString(KEY_BASE_URL, migrateBaseUrl(baseUrl)).apply()
|
||||
}
|
||||
|
||||
fun saveToken(token: String) {
|
||||
prefs.edit().putString(KEY_TOKEN, token).apply()
|
||||
}
|
||||
|
||||
fun save(baseUrl: String, token: String) {
|
||||
prefs.edit()
|
||||
.putString(KEY_BASE_URL, migrateBaseUrl(baseUrl))
|
||||
.putString(KEY_TOKEN, token)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun clearToken() {
|
||||
prefs.edit().remove(KEY_TOKEN).apply()
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
prefs.edit().remove(KEY_BASE_URL).remove(KEY_TOKEN).apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val PREFS_NAME = "storyforge_session"
|
||||
private const val KEY_BASE_URL = "base_url"
|
||||
private const val KEY_TOKEN = "token"
|
||||
private const val LEGACY_DOMAIN_URL = "http://test.hyzq.net:8081"
|
||||
private const val LEGACY_IP_URL = "http://111.231.132.51:8081"
|
||||
}
|
||||
|
||||
private fun migrateBaseUrl(baseUrl: String): String {
|
||||
val trimmed = baseUrl.trim()
|
||||
return when {
|
||||
trimmed.isBlank() -> BuildConfig.DEFAULT_STORYFORGE_BASE_URL
|
||||
trimmed.startsWith(LEGACY_DOMAIN_URL) -> BuildConfig.DEFAULT_STORYFORGE_BASE_URL
|
||||
trimmed.startsWith(LEGACY_IP_URL) -> BuildConfig.DEFAULT_STORYFORGE_BASE_URL
|
||||
else -> trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,907 @@
|
||||
package com.aiglasses.app.storyforge
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.aiglasses.app.BuildConfig
|
||||
import com.aiglasses.app.update.AppOtaUpdater
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
|
||||
enum class StoryForgeTab {
|
||||
Explore,
|
||||
Production,
|
||||
Mine
|
||||
}
|
||||
|
||||
enum class StoryForgeAuthMode {
|
||||
Login,
|
||||
Register
|
||||
}
|
||||
|
||||
enum class ExploreInputMode {
|
||||
VideoLink,
|
||||
UploadVideo,
|
||||
Text
|
||||
}
|
||||
|
||||
private const val DEFAULT_SYSTEM_PROMPT = "你是一个擅长学习短视频口播风格的 AI 文案助手,请优先保留素材中的钩子、节奏、转折和行动号召。"
|
||||
private const val DEFAULT_GENERATION_GOAL = "为不同渠道生成稳定风格的短视频标题、口播脚本和收尾行动号召。"
|
||||
|
||||
private fun nextVersionName(current: String): String {
|
||||
val parts = current.split('.').toMutableList()
|
||||
val last = parts.lastOrNull()?.toIntOrNull()
|
||||
if (last != null) {
|
||||
parts[parts.lastIndex] = (last + 1).toString()
|
||||
return parts.joinToString(".")
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
data class StoryForgeUiState(
|
||||
val authMode: StoryForgeAuthMode = StoryForgeAuthMode.Login,
|
||||
val baseUrl: String = BuildConfig.DEFAULT_STORYFORGE_BASE_URL,
|
||||
val resolvedBaseUrl: String = "",
|
||||
val resolvedIp: String = "",
|
||||
val originalHost: String = "",
|
||||
val isAuthenticated: Boolean = false,
|
||||
val isApproved: Boolean = false,
|
||||
val currentTab: StoryForgeTab = StoryForgeTab.Explore,
|
||||
val busy: Boolean = false,
|
||||
val generateBusy: Boolean = false,
|
||||
val statusMessage: String = "准备连接 StoryForge",
|
||||
val errorMessage: String = "",
|
||||
val account: AccountDto? = null,
|
||||
val knowledgeBases: List<KnowledgeBaseDto> = emptyList(),
|
||||
val assistants: List<AssistantDto> = emptyList(),
|
||||
val modelProfiles: List<ModelProfileDto> = emptyList(),
|
||||
val jobs: List<JobDto> = emptyList(),
|
||||
val documents: List<KnowledgeDocumentDto> = emptyList(),
|
||||
val selectedKnowledgeBaseId: String = "",
|
||||
val selectedAssistantId: String = "",
|
||||
val selectedAssistantKnowledgeBaseIds: Set<String> = emptySet(),
|
||||
val assistantEditorId: String? = null,
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val createKnowledgeBaseName: String = "",
|
||||
val createKnowledgeBaseDescription: String = "",
|
||||
val exploreInputMode: ExploreInputMode = ExploreInputMode.VideoLink,
|
||||
val videoUrl: String = "",
|
||||
val videoTitle: String = "",
|
||||
val textTitle: String = "",
|
||||
val textContent: String = "",
|
||||
val pickedVideoName: String = "",
|
||||
val latestJobId: String = "",
|
||||
val latestJob: JobDto? = null,
|
||||
val assistantName: String = "",
|
||||
val assistantDescription: String = "",
|
||||
val assistantSystemPrompt: String = DEFAULT_SYSTEM_PROMPT,
|
||||
val assistantGenerationGoal: String = DEFAULT_GENERATION_GOAL,
|
||||
val assistantModelProfileId: String = "",
|
||||
val generationBrief: String = "围绕 AI 创业做一条 60 秒短视频口播文案",
|
||||
val generationPlatform: String = "抖音",
|
||||
val generationAudience: String = "创业者",
|
||||
val generationExtraRequirements: String = "开头结论先行,结尾给一个明确行动建议。",
|
||||
val generationOutput: String = "",
|
||||
val generationPromptExcerpt: String = "",
|
||||
val newModelName: String = "",
|
||||
val newModelBaseUrl: String = BuildConfig.DEFAULT_LOCAL_MODEL_BASE_URL,
|
||||
val newModelApiKey: String = "",
|
||||
val newModelModelName: String = "GLM-5",
|
||||
val pendingAccounts: List<AccountDto> = emptyList(),
|
||||
val otaInfo: AppUpdateLatestDto? = null,
|
||||
val otaStatus: String = "",
|
||||
val publishVersionCode: String = (BuildConfig.VERSION_CODE + 1).toString(),
|
||||
val publishVersionName: String = nextVersionName(BuildConfig.VERSION_NAME),
|
||||
val publishMinSupportedCode: String = BuildConfig.VERSION_CODE.toString(),
|
||||
val publishApkUrl: String = "",
|
||||
val publishNotes: String = "",
|
||||
val publishForceUpdate: Boolean = false,
|
||||
val timeline: List<String> = listOf("应用已启动,等待连接")
|
||||
)
|
||||
|
||||
class StoryForgeViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val repository = StoryForgeRepository(application.applicationContext)
|
||||
private val _state = MutableStateFlow(StoryForgeUiState(baseUrl = repository.savedSession().baseUrl))
|
||||
val state: StateFlow<StoryForgeUiState> = _state.asStateFlow()
|
||||
|
||||
private var jobPollingJob: Job? = null
|
||||
private var pickedVideoUri: Uri? = null
|
||||
|
||||
init {
|
||||
restoreSession()
|
||||
}
|
||||
|
||||
fun updateBaseUrl(value: String) {
|
||||
_state.value = _state.value.copy(baseUrl = value)
|
||||
repository.saveBaseUrl(value)
|
||||
}
|
||||
|
||||
fun updateUsername(value: String) {
|
||||
_state.value = _state.value.copy(username = value)
|
||||
}
|
||||
|
||||
fun updatePassword(value: String) {
|
||||
_state.value = _state.value.copy(password = value)
|
||||
}
|
||||
|
||||
|
||||
fun setAuthMode(mode: StoryForgeAuthMode) {
|
||||
_state.value = _state.value.copy(authMode = mode, errorMessage = "")
|
||||
}
|
||||
|
||||
fun selectTab(tab: StoryForgeTab) {
|
||||
_state.value = _state.value.copy(currentTab = tab)
|
||||
if (tab == StoryForgeTab.Mine && state.value.account?.role == "super_admin") {
|
||||
loadPendingAccounts()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCreateKnowledgeBaseName(value: String) {
|
||||
_state.value = _state.value.copy(createKnowledgeBaseName = value)
|
||||
}
|
||||
|
||||
fun updateCreateKnowledgeBaseDescription(value: String) {
|
||||
_state.value = _state.value.copy(createKnowledgeBaseDescription = value)
|
||||
}
|
||||
|
||||
fun updateVideoUrl(value: String) {
|
||||
_state.value = _state.value.copy(videoUrl = value)
|
||||
}
|
||||
|
||||
fun updateVideoTitle(value: String) {
|
||||
_state.value = _state.value.copy(videoTitle = value)
|
||||
}
|
||||
|
||||
fun updateTextTitle(value: String) {
|
||||
_state.value = _state.value.copy(textTitle = value)
|
||||
}
|
||||
|
||||
fun updateTextContent(value: String) {
|
||||
_state.value = _state.value.copy(textContent = value)
|
||||
}
|
||||
|
||||
fun setExploreInputMode(mode: ExploreInputMode) {
|
||||
_state.value = _state.value.copy(exploreInputMode = mode, errorMessage = "")
|
||||
}
|
||||
|
||||
fun setPickedVideo(uri: Uri?, fileName: String) {
|
||||
pickedVideoUri = uri
|
||||
_state.value = _state.value.copy(pickedVideoName = fileName)
|
||||
}
|
||||
|
||||
fun selectKnowledgeBase(knowledgeBaseId: String) {
|
||||
_state.value = _state.value.copy(selectedKnowledgeBaseId = knowledgeBaseId)
|
||||
refreshDocuments()
|
||||
}
|
||||
|
||||
fun selectAssistant(assistantId: String) {
|
||||
val assistant = _state.value.assistants.firstOrNull { it.id == assistantId }
|
||||
_state.value = _state.value.copy(
|
||||
selectedAssistantId = assistantId,
|
||||
selectedAssistantKnowledgeBaseIds = assistant?.knowledge_base_ids?.toSet() ?: emptySet(),
|
||||
assistantEditorId = assistant?.id,
|
||||
assistantName = assistant?.name.orEmpty(),
|
||||
assistantDescription = assistant?.description.orEmpty(),
|
||||
assistantSystemPrompt = assistant?.system_prompt ?: DEFAULT_SYSTEM_PROMPT,
|
||||
assistantGenerationGoal = assistant?.generation_goal ?: DEFAULT_GENERATION_GOAL,
|
||||
assistantModelProfileId = assistant?.model_profile_id.orEmpty(),
|
||||
generationOutput = "",
|
||||
generationPromptExcerpt = ""
|
||||
)
|
||||
}
|
||||
|
||||
fun startNewAssistant() {
|
||||
_state.value = _state.value.copy(
|
||||
assistantEditorId = null,
|
||||
assistantName = "",
|
||||
assistantDescription = "",
|
||||
assistantSystemPrompt = DEFAULT_SYSTEM_PROMPT,
|
||||
assistantGenerationGoal = DEFAULT_GENERATION_GOAL,
|
||||
assistantModelProfileId = preferredModelId(),
|
||||
selectedAssistantKnowledgeBaseIds = listOfNotNull(state.value.selectedKnowledgeBaseId.takeIf { it.isNotBlank() }).toSet()
|
||||
)
|
||||
}
|
||||
|
||||
fun toggleAssistantKnowledgeBase(knowledgeBaseId: String) {
|
||||
val updated = _state.value.selectedAssistantKnowledgeBaseIds.toMutableSet()
|
||||
if (!updated.add(knowledgeBaseId)) {
|
||||
updated.remove(knowledgeBaseId)
|
||||
}
|
||||
_state.value = _state.value.copy(selectedAssistantKnowledgeBaseIds = updated)
|
||||
}
|
||||
|
||||
fun updateAssistantName(value: String) {
|
||||
_state.value = _state.value.copy(assistantName = value)
|
||||
}
|
||||
|
||||
fun updateAssistantDescription(value: String) {
|
||||
_state.value = _state.value.copy(assistantDescription = value)
|
||||
}
|
||||
|
||||
fun updateAssistantSystemPrompt(value: String) {
|
||||
_state.value = _state.value.copy(assistantSystemPrompt = value)
|
||||
}
|
||||
|
||||
fun updateAssistantGenerationGoal(value: String) {
|
||||
_state.value = _state.value.copy(assistantGenerationGoal = value)
|
||||
}
|
||||
|
||||
fun updateAssistantModelProfileId(value: String) {
|
||||
_state.value = _state.value.copy(assistantModelProfileId = value)
|
||||
}
|
||||
|
||||
fun updateGenerationBrief(value: String) {
|
||||
_state.value = _state.value.copy(generationBrief = value)
|
||||
}
|
||||
|
||||
fun updateGenerationPlatform(value: String) {
|
||||
_state.value = _state.value.copy(generationPlatform = value)
|
||||
}
|
||||
|
||||
fun updateGenerationAudience(value: String) {
|
||||
_state.value = _state.value.copy(generationAudience = value)
|
||||
}
|
||||
|
||||
fun updateGenerationExtraRequirements(value: String) {
|
||||
_state.value = _state.value.copy(generationExtraRequirements = value)
|
||||
}
|
||||
|
||||
fun updateNewModelName(value: String) {
|
||||
_state.value = _state.value.copy(newModelName = value)
|
||||
}
|
||||
|
||||
fun updateNewModelBaseUrl(value: String) {
|
||||
_state.value = _state.value.copy(newModelBaseUrl = value)
|
||||
}
|
||||
|
||||
fun updateNewModelApiKey(value: String) {
|
||||
_state.value = _state.value.copy(newModelApiKey = value)
|
||||
}
|
||||
|
||||
fun updateNewModelModelName(value: String) {
|
||||
_state.value = _state.value.copy(newModelModelName = value)
|
||||
}
|
||||
|
||||
fun updatePublishVersionCode(value: String) {
|
||||
_state.value = _state.value.copy(publishVersionCode = value)
|
||||
}
|
||||
|
||||
fun updatePublishVersionName(value: String) {
|
||||
_state.value = _state.value.copy(publishVersionName = value)
|
||||
}
|
||||
|
||||
fun updatePublishMinSupportedCode(value: String) {
|
||||
_state.value = _state.value.copy(publishMinSupportedCode = value)
|
||||
}
|
||||
|
||||
fun updatePublishApkUrl(value: String) {
|
||||
_state.value = _state.value.copy(publishApkUrl = value)
|
||||
}
|
||||
|
||||
fun updatePublishNotes(value: String) {
|
||||
_state.value = _state.value.copy(publishNotes = value)
|
||||
}
|
||||
|
||||
fun setPublishForceUpdate(value: Boolean) {
|
||||
_state.value = _state.value.copy(publishForceUpdate = value)
|
||||
}
|
||||
|
||||
fun registerAccount() {
|
||||
val current = state.value
|
||||
if (current.username.isBlank() || current.password.isBlank()) {
|
||||
setError("请填写用户名和密码")
|
||||
return
|
||||
}
|
||||
runBusy(message = "正在提交注册申请...", task = {
|
||||
repository.register(
|
||||
baseUrl = current.baseUrl,
|
||||
username = current.username.trim(),
|
||||
password = current.password,
|
||||
displayName = current.username.trim()
|
||||
)
|
||||
}) { account ->
|
||||
appendTimeline("账号 ${account.username} 已注册,等待主管理员审批")
|
||||
_state.value = _state.value.copy(
|
||||
authMode = StoryForgeAuthMode.Login,
|
||||
statusMessage = "注册成功,请等待主管理员审批",
|
||||
errorMessage = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun login() {
|
||||
val current = state.value
|
||||
if (current.username.isBlank() || current.password.isBlank()) {
|
||||
setError("请先填写用户名和密码")
|
||||
return
|
||||
}
|
||||
runBusy(message = "正在登录 StoryForge...", task = {
|
||||
repository.login(
|
||||
baseUrl = current.baseUrl,
|
||||
username = current.username.trim(),
|
||||
password = current.password
|
||||
)
|
||||
}) { result ->
|
||||
applyConnection(result.connection)
|
||||
appendTimeline("账号 ${result.auth.account.username} 登录成功")
|
||||
val account = result.auth.account
|
||||
_state.value = _state.value.copy(
|
||||
isAuthenticated = true,
|
||||
isApproved = account.approval_status == "approved",
|
||||
account = account,
|
||||
statusMessage = if (account.approval_status == "approved") "登录成功,正在同步工作台" else "账号待主管理员审批",
|
||||
errorMessage = ""
|
||||
)
|
||||
if (account.approval_status == "approved") {
|
||||
refreshWorkspace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshApprovalStatus() {
|
||||
runBusy(message = "正在刷新审批状态...", task = {
|
||||
repository.me() to repository.currentConnection()
|
||||
}) { (account, connection) ->
|
||||
applyConnection(connection)
|
||||
_state.value = _state.value.copy(
|
||||
isAuthenticated = true,
|
||||
isApproved = account.approval_status == "approved",
|
||||
account = account,
|
||||
statusMessage = if (account.approval_status == "approved") "审批已通过,正在同步工作台" else "当前账号仍在等待审批",
|
||||
errorMessage = ""
|
||||
)
|
||||
appendTimeline("审批状态更新为 ${account.approval_status}")
|
||||
if (account.approval_status == "approved") {
|
||||
refreshWorkspace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
repository.logout()
|
||||
jobPollingJob?.cancel()
|
||||
pickedVideoUri = null
|
||||
appendTimeline("已退出当前账号")
|
||||
_state.value = StoryForgeUiState(baseUrl = repository.savedSession().baseUrl)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshWorkspace() {
|
||||
viewModelScope.launch {
|
||||
val current = state.value
|
||||
_state.value = current.copy(busy = true, errorMessage = "", statusMessage = "正在同步工作台数据...")
|
||||
runCatching {
|
||||
val me = repository.me()
|
||||
val connection = repository.currentConnection()
|
||||
if (me.approval_status != "approved") {
|
||||
Triple(me, connection, null)
|
||||
} else {
|
||||
Triple(me, connection, repository.dashboard())
|
||||
}
|
||||
}.onSuccess { (account, connection, dashboard) ->
|
||||
applyConnection(connection)
|
||||
if (dashboard == null) {
|
||||
_state.value = state.value.copy(
|
||||
busy = false,
|
||||
isAuthenticated = true,
|
||||
isApproved = false,
|
||||
account = account,
|
||||
statusMessage = "账号待主管理员审批"
|
||||
)
|
||||
} else {
|
||||
applyDashboard(account, dashboard)
|
||||
}
|
||||
}.onFailure { throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 401) {
|
||||
repository.logout()
|
||||
_state.value = StoryForgeUiState(baseUrl = repository.savedSession().baseUrl).copy(
|
||||
errorMessage = "登录已失效,请重新登录",
|
||||
statusMessage = "请重新登录 StoryForge"
|
||||
)
|
||||
} else {
|
||||
_state.value = state.value.copy(
|
||||
busy = false,
|
||||
errorMessage = throwable.toReadableMessage(),
|
||||
statusMessage = "同步失败,请检查网络或稍后重试"
|
||||
)
|
||||
appendTimeline("同步失败: ${throwable.toReadableMessage()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createKnowledgeBase() {
|
||||
val current = state.value
|
||||
if (current.createKnowledgeBaseName.isBlank()) {
|
||||
setError("请先填写知识库名称")
|
||||
return
|
||||
}
|
||||
runBusy(message = "正在创建知识库...", task = {
|
||||
repository.createKnowledgeBase(current.createKnowledgeBaseName.trim(), current.createKnowledgeBaseDescription.trim())
|
||||
}) { knowledgeBase ->
|
||||
appendTimeline("已创建知识库 ${knowledgeBase.name}")
|
||||
_state.value = state.value.copy(
|
||||
createKnowledgeBaseName = "",
|
||||
createKnowledgeBaseDescription = "",
|
||||
selectedKnowledgeBaseId = knowledgeBase.id
|
||||
)
|
||||
refreshWorkspace()
|
||||
}
|
||||
}
|
||||
|
||||
fun submitVideoLink() {
|
||||
val current = state.value
|
||||
if (current.videoUrl.isBlank()) {
|
||||
setError("请先输入视频链接")
|
||||
return
|
||||
}
|
||||
val knowledgeBaseId = selectedKnowledgeBaseIdOrFallback()
|
||||
if (knowledgeBaseId.isBlank()) {
|
||||
setError("请先选择知识库")
|
||||
return
|
||||
}
|
||||
runBusy(message = "正在提交视频学习任务...", task = {
|
||||
repository.createVideoLinkJob(
|
||||
videoUrl = current.videoUrl.trim(),
|
||||
title = current.videoTitle.trim(),
|
||||
knowledgeBaseId = knowledgeBaseId,
|
||||
assistantId = current.selectedAssistantId,
|
||||
analysisModelProfileId = preferredModelId()
|
||||
)
|
||||
}) { job ->
|
||||
appendTimeline("视频链接任务已创建: ${job.title}")
|
||||
_state.value = state.value.copy(videoUrl = "", videoTitle = "")
|
||||
afterJobCreated(job)
|
||||
}
|
||||
}
|
||||
|
||||
fun submitText() {
|
||||
val current = state.value
|
||||
if (current.textTitle.isBlank() || current.textContent.isBlank()) {
|
||||
setError("请输入素材标题和文字内容")
|
||||
return
|
||||
}
|
||||
val knowledgeBaseId = selectedKnowledgeBaseIdOrFallback()
|
||||
if (knowledgeBaseId.isBlank()) {
|
||||
setError("请先选择知识库")
|
||||
return
|
||||
}
|
||||
runBusy(message = "正在提交文字分析任务...", task = {
|
||||
repository.createTextJob(
|
||||
title = current.textTitle.trim(),
|
||||
content = current.textContent.trim(),
|
||||
knowledgeBaseId = knowledgeBaseId,
|
||||
assistantId = current.selectedAssistantId,
|
||||
analysisModelProfileId = preferredModelId()
|
||||
)
|
||||
}) { job ->
|
||||
appendTimeline("文字素材已进入分析队列: ${job.title}")
|
||||
_state.value = state.value.copy(textTitle = "", textContent = "")
|
||||
afterJobCreated(job)
|
||||
}
|
||||
}
|
||||
|
||||
fun submitUploadVideo() {
|
||||
val current = state.value
|
||||
val uri = pickedVideoUri
|
||||
if (uri == null) {
|
||||
setError("请先选择本地视频文件")
|
||||
return
|
||||
}
|
||||
val knowledgeBaseId = selectedKnowledgeBaseIdOrFallback()
|
||||
if (knowledgeBaseId.isBlank()) {
|
||||
setError("请先选择知识库")
|
||||
return
|
||||
}
|
||||
runBusy(message = "正在上传视频并创建学习任务...", task = {
|
||||
repository.uploadVideo(
|
||||
uri = uri,
|
||||
title = current.videoTitle.trim(),
|
||||
knowledgeBaseId = knowledgeBaseId,
|
||||
assistantId = current.selectedAssistantId,
|
||||
analysisModelProfileId = preferredModelId()
|
||||
)
|
||||
}) { job ->
|
||||
appendTimeline("视频上传成功,任务已创建: ${job.title}")
|
||||
pickedVideoUri = null
|
||||
_state.value = state.value.copy(videoTitle = "", pickedVideoName = "")
|
||||
afterJobCreated(job)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveAssistant() {
|
||||
val current = state.value
|
||||
if (current.assistantName.isBlank()) {
|
||||
setError("请先填写智能体名称")
|
||||
return
|
||||
}
|
||||
if (current.selectedAssistantKnowledgeBaseIds.isEmpty()) {
|
||||
setError("请至少关联一个知识库")
|
||||
return
|
||||
}
|
||||
val request = AssistantCreateRequest(
|
||||
name = current.assistantName.trim(),
|
||||
description = current.assistantDescription.trim(),
|
||||
system_prompt = current.assistantSystemPrompt.trim(),
|
||||
generation_goal = current.assistantGenerationGoal.trim(),
|
||||
knowledge_base_ids = current.selectedAssistantKnowledgeBaseIds.toList(),
|
||||
model_profile_id = current.assistantModelProfileId.ifBlank { preferredModelId() }
|
||||
)
|
||||
if (current.assistantEditorId.isNullOrBlank()) {
|
||||
runBusy(message = "正在创建智能体...", task = {
|
||||
repository.createAssistant(request)
|
||||
}) { assistant ->
|
||||
appendTimeline("已创建智能体 ${assistant.name}")
|
||||
_state.value = state.value.copy(selectedAssistantId = assistant.id)
|
||||
refreshWorkspace()
|
||||
}
|
||||
} else {
|
||||
runBusy(message = "正在保存智能体配置...", task = {
|
||||
repository.updateAssistant(
|
||||
current.assistantEditorId,
|
||||
AssistantUpdateRequest(
|
||||
name = request.name,
|
||||
description = request.description,
|
||||
system_prompt = request.system_prompt,
|
||||
generation_goal = request.generation_goal,
|
||||
knowledge_base_ids = request.knowledge_base_ids,
|
||||
model_profile_id = request.model_profile_id
|
||||
)
|
||||
)
|
||||
}) { assistant ->
|
||||
appendTimeline("已更新智能体 ${assistant.name}")
|
||||
_state.value = state.value.copy(selectedAssistantId = assistant.id)
|
||||
refreshWorkspace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun generateCopy() {
|
||||
val current = state.value
|
||||
val assistantId = current.selectedAssistantId.ifBlank { current.assistantEditorId.orEmpty() }
|
||||
if (assistantId.isBlank()) {
|
||||
setError("请先选择一个智能体")
|
||||
return
|
||||
}
|
||||
if (current.generationBrief.isBlank()) {
|
||||
setError("请先填写文案需求")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = state.value.copy(generateBusy = true, errorMessage = "", statusMessage = "正在生成文案,请稍候...")
|
||||
runCatching {
|
||||
repository.generateCopy(
|
||||
assistantId,
|
||||
GenerateCopyRequest(
|
||||
brief = current.generationBrief.trim(),
|
||||
platform = current.generationPlatform.trim(),
|
||||
audience = current.generationAudience.trim(),
|
||||
extra_requirements = current.generationExtraRequirements.trim(),
|
||||
knowledge_base_ids = current.selectedAssistantKnowledgeBaseIds.toList()
|
||||
)
|
||||
)
|
||||
}.onSuccess { result ->
|
||||
_state.value = state.value.copy(
|
||||
generateBusy = false,
|
||||
generationOutput = result.content,
|
||||
generationPromptExcerpt = result.prompt_excerpt,
|
||||
statusMessage = "文案生成完成"
|
||||
)
|
||||
appendTimeline("智能体已生成一条新文案")
|
||||
}.onFailure { throwable ->
|
||||
_state.value = state.value.copy(
|
||||
generateBusy = false,
|
||||
errorMessage = throwable.toReadableMessage(),
|
||||
statusMessage = "文案生成失败"
|
||||
)
|
||||
appendTimeline("文案生成失败: ${throwable.toReadableMessage()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createModelProfile() {
|
||||
val current = state.value
|
||||
if (current.newModelName.isBlank() || current.newModelBaseUrl.isBlank() || current.newModelApiKey.isBlank() || current.newModelModelName.isBlank()) {
|
||||
setError("请完整填写模型名称、Base URL、API Key 和模型名")
|
||||
return
|
||||
}
|
||||
runBusy(message = "正在保存模型配置...", task = {
|
||||
repository.createModelProfile(
|
||||
ModelProfileRequest(
|
||||
name = current.newModelName.trim(),
|
||||
base_url = current.newModelBaseUrl.trim(),
|
||||
api_key = current.newModelApiKey.trim(),
|
||||
model_name = current.newModelModelName.trim(),
|
||||
is_default = true
|
||||
)
|
||||
)
|
||||
}) { profile ->
|
||||
appendTimeline("已新增模型配置 ${profile.name}")
|
||||
_state.value = state.value.copy(
|
||||
newModelName = "",
|
||||
newModelApiKey = "",
|
||||
newModelModelName = current.newModelModelName,
|
||||
assistantModelProfileId = profile.id
|
||||
)
|
||||
refreshWorkspace()
|
||||
}
|
||||
}
|
||||
|
||||
fun setPreferredModel(modelProfileId: String) {
|
||||
runBusy(message = "正在切换默认分析模型...", task = {
|
||||
repository.setPreferredAnalysisModel(modelProfileId)
|
||||
}) { account ->
|
||||
_state.value = state.value.copy(account = account)
|
||||
appendTimeline("已切换默认分析模型")
|
||||
refreshWorkspace()
|
||||
}
|
||||
}
|
||||
|
||||
fun loadPendingAccounts() {
|
||||
if (state.value.account?.role != "super_admin") return
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.pendingAccounts() }
|
||||
.onSuccess { pending ->
|
||||
_state.value = state.value.copy(pendingAccounts = pending)
|
||||
}
|
||||
.onFailure { throwable ->
|
||||
_state.value = state.value.copy(errorMessage = throwable.toReadableMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun approveAccount(accountId: String) {
|
||||
runBusy(message = "正在通过账号审批...", task = {
|
||||
repository.approveAccount(accountId)
|
||||
}) {
|
||||
appendTimeline("已通过一条账号审批")
|
||||
refreshWorkspace()
|
||||
}
|
||||
}
|
||||
|
||||
fun rejectAccount(accountId: String) {
|
||||
runBusy(message = "正在拒绝账号申请...", task = {
|
||||
repository.rejectAccount(accountId)
|
||||
}) {
|
||||
appendTimeline("已拒绝一条账号申请")
|
||||
refreshWorkspace()
|
||||
}
|
||||
}
|
||||
|
||||
fun checkForUpdates() {
|
||||
viewModelScope.launch {
|
||||
_state.value = state.value.copy(otaStatus = "正在检查更新...")
|
||||
runCatching { repository.latestUpdate(BuildConfig.VERSION_CODE) }
|
||||
.onSuccess { latest ->
|
||||
_state.value = state.value.copy(
|
||||
otaInfo = latest,
|
||||
otaStatus = if (latest.hasUpdate) {
|
||||
"发现新版本 ${latest.latestVersionName} (${latest.latestVersionCode})"
|
||||
} else {
|
||||
"当前已经是最新版本"
|
||||
}
|
||||
)
|
||||
appendTimeline("OTA 检查完成")
|
||||
}
|
||||
.onFailure { throwable ->
|
||||
_state.value = state.value.copy(otaStatus = throwable.toReadableMessage(), errorMessage = throwable.toReadableMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun publishUpdate() {
|
||||
val current = state.value
|
||||
val versionCode = current.publishVersionCode.toIntOrNull()
|
||||
val minSupportedCode = current.publishMinSupportedCode.toIntOrNull()
|
||||
if (versionCode == null || minSupportedCode == null || current.publishVersionName.isBlank() || current.publishApkUrl.isBlank()) {
|
||||
setError("请完整填写 OTA 的版本号、最小支持版本、下载地址")
|
||||
return
|
||||
}
|
||||
runBusy(message = "正在发布 OTA 配置...", task = {
|
||||
repository.publishAppUpdate(
|
||||
PublishAppUpdateRequest(
|
||||
versionCode = versionCode,
|
||||
versionName = current.publishVersionName.trim(),
|
||||
minSupportedCode = minSupportedCode,
|
||||
apkUrl = current.publishApkUrl.trim(),
|
||||
notes = current.publishNotes.trim(),
|
||||
forceUpdate = current.publishForceUpdate
|
||||
)
|
||||
)
|
||||
}) { response ->
|
||||
_state.value = state.value.copy(otaStatus = "已发布 OTA: ${response.action}")
|
||||
appendTimeline("主管理员已发布 OTA ${current.publishVersionName}")
|
||||
checkForUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
fun onOtaLog(message: String) {
|
||||
appendTimeline(message)
|
||||
_state.value = state.value.copy(otaStatus = message)
|
||||
}
|
||||
|
||||
fun installLatestUpdate(otaUpdater: AppOtaUpdater) {
|
||||
val latest = state.value.otaInfo
|
||||
if (latest == null || !latest.hasUpdate || latest.downloadUrl.isBlank()) {
|
||||
setError("当前没有可安装的更新")
|
||||
return
|
||||
}
|
||||
val started = otaUpdater.downloadAndInstall(
|
||||
apkUrl = latest.downloadUrl,
|
||||
versionName = latest.latestVersionName.ifBlank { "${latest.latestVersionCode}" },
|
||||
expectedSha256 = latest.apkSha256
|
||||
)
|
||||
_state.value = state.value.copy(otaStatus = if (started) "OTA 下载已启动" else "OTA 下载启动失败")
|
||||
}
|
||||
|
||||
private fun restoreSession() {
|
||||
val saved = repository.savedSession()
|
||||
_state.value = state.value.copy(baseUrl = saved.baseUrl)
|
||||
if (saved.token.isBlank()) {
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.resolveConnection(saved.baseUrl) }
|
||||
.onSuccess { applyConnection(it) }
|
||||
}
|
||||
return
|
||||
}
|
||||
refreshWorkspace()
|
||||
}
|
||||
|
||||
private fun refreshDocuments() {
|
||||
val knowledgeBaseId = state.value.selectedKnowledgeBaseId
|
||||
if (knowledgeBaseId.isBlank() || !state.value.isApproved) return
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.knowledgeDocuments(knowledgeBaseId) }
|
||||
.onSuccess { documents ->
|
||||
_state.value = state.value.copy(documents = documents)
|
||||
}
|
||||
.onFailure { throwable ->
|
||||
_state.value = state.value.copy(errorMessage = throwable.toReadableMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun afterJobCreated(job: JobDto) {
|
||||
_state.value = state.value.copy(
|
||||
latestJob = job,
|
||||
latestJobId = job.id,
|
||||
currentTab = StoryForgeTab.Explore
|
||||
)
|
||||
refreshWorkspace()
|
||||
startJobPolling(job.id)
|
||||
}
|
||||
|
||||
private fun startJobPolling(jobId: String) {
|
||||
jobPollingJob?.cancel()
|
||||
jobPollingJob = viewModelScope.launch {
|
||||
repeat(30) {
|
||||
delay(5000)
|
||||
runCatching { repository.job(jobId) }
|
||||
.onSuccess { job ->
|
||||
_state.value = state.value.copy(latestJob = job, latestJobId = job.id)
|
||||
if (job.status == "completed" || job.status == "failed") {
|
||||
appendTimeline("素材任务 ${job.title} 已${if (job.status == "completed") "完成" else "失败"}")
|
||||
refreshWorkspace()
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyDashboard(account: AccountDto, dashboard: DashboardDto) {
|
||||
val selectedKbId = state.value.selectedKnowledgeBaseId.takeIf { id -> dashboard.knowledge_bases.any { it.id == id } }
|
||||
?: dashboard.knowledge_bases.firstOrNull()?.id.orEmpty()
|
||||
val selectedAssistantId = state.value.selectedAssistantId.takeIf { id -> dashboard.assistants.any { it.id == id } }
|
||||
?: dashboard.assistants.firstOrNull()?.id.orEmpty()
|
||||
val selectedAssistant = dashboard.assistants.firstOrNull { it.id == selectedAssistantId }
|
||||
_state.value = state.value.copy(
|
||||
busy = false,
|
||||
isAuthenticated = true,
|
||||
isApproved = true,
|
||||
account = account,
|
||||
knowledgeBases = dashboard.knowledge_bases,
|
||||
assistants = dashboard.assistants,
|
||||
modelProfiles = dashboard.model_profiles,
|
||||
jobs = dashboard.recent_jobs,
|
||||
documents = emptyList(),
|
||||
selectedKnowledgeBaseId = selectedKbId,
|
||||
selectedAssistantId = selectedAssistantId,
|
||||
selectedAssistantKnowledgeBaseIds = selectedAssistant?.knowledge_base_ids?.toSet()
|
||||
?: listOfNotNull(selectedKbId.takeIf { it.isNotBlank() }).toSet(),
|
||||
assistantEditorId = selectedAssistant?.id,
|
||||
assistantName = selectedAssistant?.name.orEmpty(),
|
||||
assistantDescription = selectedAssistant?.description.orEmpty(),
|
||||
assistantSystemPrompt = selectedAssistant?.system_prompt ?: DEFAULT_SYSTEM_PROMPT,
|
||||
assistantGenerationGoal = selectedAssistant?.generation_goal ?: DEFAULT_GENERATION_GOAL,
|
||||
assistantModelProfileId = (selectedAssistant?.model_profile_id ?: "").ifBlank { preferredModelId(dashboard, account) },
|
||||
latestJob = dashboard.recent_jobs.firstOrNull(),
|
||||
latestJobId = dashboard.recent_jobs.firstOrNull()?.id.orEmpty(),
|
||||
pendingAccounts = if (account.role == "super_admin") state.value.pendingAccounts else emptyList(),
|
||||
statusMessage = "工作台已同步完成",
|
||||
errorMessage = ""
|
||||
)
|
||||
refreshDocuments()
|
||||
if (account.role == "super_admin") {
|
||||
loadPendingAccounts()
|
||||
}
|
||||
}
|
||||
|
||||
private fun preferredModelId(
|
||||
dashboard: DashboardDto? = null,
|
||||
account: AccountDto? = state.value.account
|
||||
): String {
|
||||
val currentDashboard = dashboard
|
||||
val accountPreferred = account?.preferred_analysis_model_id.orEmpty()
|
||||
if (accountPreferred.isNotBlank()) return accountPreferred
|
||||
val profiles = currentDashboard?.model_profiles ?: state.value.modelProfiles
|
||||
return profiles.firstOrNull { it.is_default }?.id.orEmpty()
|
||||
}
|
||||
|
||||
private fun selectedKnowledgeBaseIdOrFallback(): String {
|
||||
return state.value.selectedKnowledgeBaseId.ifBlank {
|
||||
state.value.knowledgeBases.firstOrNull()?.id.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyConnection(connection: StoryForgeConnectionInfo) {
|
||||
_state.value = state.value.copy(
|
||||
baseUrl = connection.rawBaseUrl,
|
||||
resolvedBaseUrl = connection.requestBaseUrl,
|
||||
resolvedIp = connection.resolvedIp,
|
||||
originalHost = connection.originalHostHeader
|
||||
)
|
||||
}
|
||||
|
||||
private fun setError(message: String) {
|
||||
_state.value = state.value.copy(errorMessage = message, statusMessage = message)
|
||||
}
|
||||
|
||||
private fun appendTimeline(message: String) {
|
||||
val next = (listOf(message) + state.value.timeline).distinct().take(16)
|
||||
_state.value = state.value.copy(timeline = next)
|
||||
}
|
||||
|
||||
private fun <T> runBusy(
|
||||
message: String,
|
||||
task: suspend () -> T,
|
||||
onSuccess: (T) -> Unit
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_state.value = state.value.copy(busy = true, errorMessage = "", statusMessage = message)
|
||||
runCatching { task() }
|
||||
.onSuccess { result ->
|
||||
_state.value = state.value.copy(busy = false, errorMessage = "")
|
||||
onSuccess(result)
|
||||
}
|
||||
.onFailure { throwable ->
|
||||
_state.value = state.value.copy(
|
||||
busy = false,
|
||||
errorMessage = throwable.toReadableMessage(),
|
||||
statusMessage = throwable.toReadableMessage()
|
||||
)
|
||||
appendTimeline(throwable.toReadableMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Throwable.toReadableMessage(): String {
|
||||
if (this is HttpException) {
|
||||
val body = response()?.errorBody()?.string().orEmpty()
|
||||
return if (body.isNotBlank()) {
|
||||
body.take(240)
|
||||
} else {
|
||||
"请求失败 (${code()})"
|
||||
}
|
||||
}
|
||||
return message ?: "发生未知错误"
|
||||
}
|
||||
1387
android-app/app/src/main/java/com/aiglasses/app/ui/MainViewModel.kt
Normal file
1387
android-app/app/src/main/java/com/aiglasses/app/ui/MainViewModel.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
package com.aiglasses.app.ui.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
private val LightColors = lightColorScheme(
|
||||
primary = Color(0xFF0E4B43),
|
||||
secondary = Color(0xFF9C6427),
|
||||
tertiary = Color(0xFF2A5B8A),
|
||||
background = Color(0xFFF7F3EC),
|
||||
surface = Color(0xFFFFFCF8),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onBackground = Color(0xFF1A1713),
|
||||
onSurface = Color(0xFF1A1713)
|
||||
)
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = Color(0xFF7FD6C7),
|
||||
secondary = Color(0xFFFFC27A),
|
||||
tertiary = Color(0xFF98C7FF),
|
||||
background = Color(0xFF101714),
|
||||
surface = Color(0xFF18211D),
|
||||
onPrimary = Color(0xFF062D29),
|
||||
onSecondary = Color(0xFF4B2B00),
|
||||
onBackground = Color(0xFFF0E8DB),
|
||||
onSurface = Color(0xFFF0E8DB)
|
||||
)
|
||||
|
||||
private val AppTypography = Typography(
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = FontFamily.Serif,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 34.sp,
|
||||
lineHeight = 40.sp
|
||||
),
|
||||
headlineSmall = TextStyle(
|
||||
fontFamily = FontFamily.Serif,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AIGlassesTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = if (darkTheme) DarkColors else LightColors,
|
||||
typography = AppTypography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
package com.aiglasses.app.update
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.provider.Settings
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.security.MessageDigest
|
||||
|
||||
class AppOtaUpdater(
|
||||
context: Context,
|
||||
private val onLog: (String) -> Unit
|
||||
) {
|
||||
private val appContext = context.applicationContext
|
||||
private val downloadManager = appContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
private val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var receiverRegistered = false
|
||||
private var activeDownloadId = -1L
|
||||
private var activeDownloadUrl = ""
|
||||
private var activeExpectedSha256 = ""
|
||||
private var activeFileName = ""
|
||||
private var progressTask: Runnable? = null
|
||||
private var lastProgressPercent = -1
|
||||
private var lastProgressLogAt = 0L
|
||||
private var lastProgressBytes = -1L
|
||||
private var lastProgressBytesAt = 0L
|
||||
|
||||
private data class DownloadSnapshot(
|
||||
val exists: Boolean = false,
|
||||
val status: Int = 0,
|
||||
val reason: Int = -1,
|
||||
val soFar: Long = 0L,
|
||||
val total: Long = 0L,
|
||||
val url: String = ""
|
||||
)
|
||||
|
||||
private val downloadReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action != DownloadManager.ACTION_DOWNLOAD_COMPLETE) return
|
||||
val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1L)
|
||||
if (id <= 0 || id != activeDownloadId) return
|
||||
handleDownloadComplete(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun register() {
|
||||
if (receiverRegistered) return
|
||||
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
appContext.registerReceiver(downloadReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
appContext.registerReceiver(downloadReceiver, filter)
|
||||
}
|
||||
receiverRegistered = true
|
||||
recoverTrackedDownload()
|
||||
}
|
||||
|
||||
fun release() {
|
||||
if (!receiverRegistered) return
|
||||
runCatching { appContext.unregisterReceiver(downloadReceiver) }
|
||||
receiverRegistered = false
|
||||
stopProgressPolling()
|
||||
}
|
||||
|
||||
fun downloadAndInstall(apkUrl: String, versionName: String, expectedSha256: String = ""): Boolean {
|
||||
val url = apkUrl.trim()
|
||||
if (url.isBlank()) {
|
||||
onLog("OTA: missing apk url")
|
||||
return false
|
||||
}
|
||||
val expected = expectedSha256.trim().lowercase()
|
||||
recoverTrackedDownload()
|
||||
val existing = findDownloadByUrl(url)
|
||||
if (existing > 0) {
|
||||
val snapshot = queryDownload(existing)
|
||||
when (snapshot.status) {
|
||||
DownloadManager.STATUS_SUCCESSFUL -> {
|
||||
onLog("OTA: 发现已下载完成任务,直接安装 id=$existing")
|
||||
activeDownloadId = existing
|
||||
activeDownloadUrl = url
|
||||
activeExpectedSha256 = expected
|
||||
persistTrackedDownload()
|
||||
handleDownloadComplete(existing)
|
||||
return true
|
||||
}
|
||||
DownloadManager.STATUS_PENDING,
|
||||
DownloadManager.STATUS_PAUSED,
|
||||
DownloadManager.STATUS_RUNNING -> {
|
||||
activeDownloadId = existing
|
||||
activeDownloadUrl = url
|
||||
activeExpectedSha256 = expected
|
||||
if (activeFileName.isBlank()) {
|
||||
activeFileName = buildStableFileName(versionName)
|
||||
}
|
||||
persistTrackedDownload()
|
||||
onLog("OTA: 继续已有下载任务 id=$existing")
|
||||
startProgressPolling(existing)
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (snapshot.status == DownloadManager.STATUS_FAILED) {
|
||||
onLog("OTA: 清理失败下载任务 id=$existing 后重试")
|
||||
runCatching { downloadManager.remove(existing) }
|
||||
if (activeDownloadId == existing) {
|
||||
clearTrackedDownload()
|
||||
}
|
||||
}
|
||||
}
|
||||
val fileName = buildStableFileName(versionName)
|
||||
val req = DownloadManager.Request(Uri.parse(url))
|
||||
.setTitle("AI Glasses 更新包")
|
||||
.setDescription("下载并安装 $versionName")
|
||||
.setAllowedOverMetered(true)
|
||||
.setAllowedOverRoaming(true)
|
||||
.setMimeType("application/vnd.android.package-archive")
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
.setDestinationInExternalFilesDir(appContext, Environment.DIRECTORY_DOWNLOADS, fileName)
|
||||
if (activeDownloadId > 0 && activeDownloadUrl != url) {
|
||||
onLog("OTA: 切换到新下载地址,取消旧任务 id=$activeDownloadId")
|
||||
runCatching { downloadManager.remove(activeDownloadId) }
|
||||
}
|
||||
stopProgressPolling()
|
||||
resetProgressTracking()
|
||||
activeDownloadUrl = url
|
||||
activeExpectedSha256 = expected
|
||||
activeFileName = fileName
|
||||
activeDownloadId = runCatching { downloadManager.enqueue(req) }
|
||||
.onFailure { onLog("OTA: download enqueue failed: ${it.message}") }
|
||||
.getOrDefault(-1L)
|
||||
if (activeDownloadId <= 0) return false
|
||||
persistTrackedDownload()
|
||||
onLog("OTA: 开始下载更新包 id=$activeDownloadId")
|
||||
onLog("OTA: 下载地址 ${url.take(120)}")
|
||||
startProgressPolling(activeDownloadId)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun handleDownloadComplete(downloadId: Long) {
|
||||
stopProgressPolling()
|
||||
val cursor = downloadManager.query(DownloadManager.Query().setFilterById(downloadId))
|
||||
cursor.use { c ->
|
||||
if (!c.moveToFirst()) {
|
||||
onLog("OTA: 下载任务不存在 id=$downloadId")
|
||||
clearTrackedDownload()
|
||||
return
|
||||
}
|
||||
val statusIdx = c.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||
if (statusIdx < 0) {
|
||||
onLog("OTA: 无法读取下载状态")
|
||||
clearTrackedDownload()
|
||||
return
|
||||
}
|
||||
val status = c.getInt(statusIdx)
|
||||
if (status != DownloadManager.STATUS_SUCCESSFUL) {
|
||||
val reasonIdx = c.getColumnIndex(DownloadManager.COLUMN_REASON)
|
||||
val reason = if (reasonIdx >= 0) c.getInt(reasonIdx) else -1
|
||||
onLog("OTA: 下载失败 status=$status reason=${reasonToText(reason)}($reason)")
|
||||
clearTrackedDownload()
|
||||
return
|
||||
}
|
||||
}
|
||||
onLog("OTA: 下载完成 id=$downloadId")
|
||||
val uri = downloadManager.getUriForDownloadedFile(downloadId)
|
||||
if (uri == null) {
|
||||
onLog("OTA: 找不到已下载文件 URI")
|
||||
clearTrackedDownload()
|
||||
return
|
||||
}
|
||||
if (!verifyDownloadedApkSha256(uri, activeExpectedSha256)) {
|
||||
clearTrackedDownload()
|
||||
return
|
||||
}
|
||||
if (!canInstallPackages()) {
|
||||
openInstallPermissionSettings()
|
||||
onLog("OTA: 下载完成,请允许本应用安装未知来源后再次点击更新")
|
||||
persistTrackedDownload()
|
||||
return
|
||||
}
|
||||
val installUri = materializeInstallUri(uri, activeFileName)
|
||||
if (installUri == null) {
|
||||
onLog("OTA: 无法准备安装包")
|
||||
clearTrackedDownload()
|
||||
return
|
||||
}
|
||||
val ok = installApk(installUri)
|
||||
onLog(if (ok) "OTA: 已拉起安装流程" else "OTA: 拉起安装失败")
|
||||
clearTrackedDownload()
|
||||
}
|
||||
|
||||
private fun startProgressPolling(downloadId: Long) {
|
||||
stopProgressPolling()
|
||||
val task = object : Runnable {
|
||||
override fun run() {
|
||||
if (activeDownloadId != downloadId || activeDownloadId <= 0) return
|
||||
val keep = emitDownloadProgress(downloadId)
|
||||
if (!keep) return
|
||||
mainHandler.postDelayed(this, 1000L)
|
||||
}
|
||||
}
|
||||
progressTask = task
|
||||
mainHandler.post(task)
|
||||
}
|
||||
|
||||
private fun stopProgressPolling() {
|
||||
progressTask?.let { mainHandler.removeCallbacks(it) }
|
||||
progressTask = null
|
||||
}
|
||||
|
||||
private fun emitDownloadProgress(downloadId: Long): Boolean {
|
||||
val cursor = downloadManager.query(DownloadManager.Query().setFilterById(downloadId))
|
||||
cursor.use { c ->
|
||||
if (!c.moveToFirst()) {
|
||||
onLog("OTA: 下载任务丢失 id=$downloadId")
|
||||
clearTrackedDownload()
|
||||
return false
|
||||
}
|
||||
val statusIdx = c.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||
val soFarIdx = c.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
|
||||
val totalIdx = c.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
|
||||
val reasonIdx = c.getColumnIndex(DownloadManager.COLUMN_REASON)
|
||||
if (statusIdx < 0 || soFarIdx < 0 || totalIdx < 0) {
|
||||
return true
|
||||
}
|
||||
val status = c.getInt(statusIdx)
|
||||
val soFar = c.getLong(soFarIdx).coerceAtLeast(0L)
|
||||
val total = c.getLong(totalIdx).coerceAtLeast(0L)
|
||||
val percent = if (total > 0L) {
|
||||
((soFar * 100L) / total).toInt().coerceIn(0, 100)
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
when {
|
||||
soFar > lastProgressBytes -> {
|
||||
lastProgressBytes = soFar
|
||||
lastProgressBytesAt = now
|
||||
}
|
||||
lastProgressBytes < 0L -> {
|
||||
lastProgressBytes = soFar
|
||||
lastProgressBytesAt = now
|
||||
}
|
||||
}
|
||||
val shouldLog = when {
|
||||
status == DownloadManager.STATUS_RUNNING && percent >= 0 ->
|
||||
(percent != lastProgressPercent && (percent % 2 == 0 || percent >= 98)) ||
|
||||
(now - lastProgressLogAt >= 4_000L)
|
||||
status == DownloadManager.STATUS_RUNNING ->
|
||||
now - lastProgressLogAt >= 3_000L
|
||||
status == DownloadManager.STATUS_PENDING || status == DownloadManager.STATUS_PAUSED ->
|
||||
now - lastProgressLogAt >= 3000L
|
||||
else -> false
|
||||
}
|
||||
if (shouldLog) {
|
||||
lastProgressLogAt = now
|
||||
if (percent >= 0) {
|
||||
lastProgressPercent = percent
|
||||
onLog(
|
||||
"OTA: 下载进度 $percent% (${formatBytes(soFar)}/${formatBytes(total)}) status=${statusToText(status)}"
|
||||
)
|
||||
} else {
|
||||
val reason = if (reasonIdx >= 0) c.getInt(reasonIdx) else -1
|
||||
onLog(
|
||||
if (status == DownloadManager.STATUS_RUNNING) {
|
||||
"OTA: 下载中 ${formatBytes(soFar)} (总大小未知)"
|
||||
} else {
|
||||
"OTA: 下载状态=${statusToText(status)} reason=${reasonToText(reason)} ${formatBytes(soFar)}"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
return when (status) {
|
||||
DownloadManager.STATUS_PENDING, DownloadManager.STATUS_PAUSED, DownloadManager.STATUS_RUNNING -> true
|
||||
DownloadManager.STATUS_SUCCESSFUL -> {
|
||||
handleDownloadComplete(downloadId)
|
||||
false
|
||||
}
|
||||
DownloadManager.STATUS_FAILED -> {
|
||||
val reason = if (reasonIdx >= 0) c.getInt(reasonIdx) else -1
|
||||
onLog("OTA: 下载失败 reason=${reasonToText(reason)}($reason)")
|
||||
clearTrackedDownload()
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun recoverTrackedDownload() {
|
||||
if (activeDownloadId <= 0L) {
|
||||
activeDownloadId = prefs.getLong(KEY_DOWNLOAD_ID, -1L)
|
||||
activeDownloadUrl = prefs.getString(KEY_DOWNLOAD_URL, "") ?: ""
|
||||
activeExpectedSha256 = prefs.getString(KEY_EXPECTED_SHA256, "") ?: ""
|
||||
activeFileName = prefs.getString(KEY_FILE_NAME, "") ?: ""
|
||||
}
|
||||
if (activeDownloadId <= 0L) return
|
||||
val snapshot = queryDownload(activeDownloadId)
|
||||
if (!snapshot.exists) {
|
||||
clearTrackedDownload()
|
||||
return
|
||||
}
|
||||
if (activeDownloadUrl.isBlank()) {
|
||||
activeDownloadUrl = snapshot.url
|
||||
}
|
||||
when (snapshot.status) {
|
||||
DownloadManager.STATUS_PENDING,
|
||||
DownloadManager.STATUS_PAUSED,
|
||||
DownloadManager.STATUS_RUNNING -> {
|
||||
onLog("OTA: 恢复下载任务 id=$activeDownloadId")
|
||||
persistTrackedDownload()
|
||||
resetProgressTracking(snapshot.soFar)
|
||||
startProgressPolling(activeDownloadId)
|
||||
}
|
||||
DownloadManager.STATUS_SUCCESSFUL -> {
|
||||
onLog("OTA: 检测到已完成下载任务,继续安装")
|
||||
handleDownloadComplete(activeDownloadId)
|
||||
}
|
||||
DownloadManager.STATUS_FAILED -> {
|
||||
onLog(
|
||||
"OTA: 上次下载任务已失败 reason=${reasonToText(snapshot.reason)}(${snapshot.reason})"
|
||||
)
|
||||
clearTrackedDownload()
|
||||
}
|
||||
else -> {
|
||||
persistTrackedDownload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findDownloadByUrl(url: String): Long {
|
||||
if (activeDownloadId > 0L && activeDownloadUrl == url) {
|
||||
val active = queryDownload(activeDownloadId)
|
||||
if (active.exists) return activeDownloadId
|
||||
}
|
||||
val savedId = prefs.getLong(KEY_DOWNLOAD_ID, -1L)
|
||||
val savedUrl = prefs.getString(KEY_DOWNLOAD_URL, "") ?: ""
|
||||
if (savedId > 0L && savedUrl == url) {
|
||||
val saved = queryDownload(savedId)
|
||||
if (saved.exists) return savedId
|
||||
}
|
||||
val query = DownloadManager.Query().setFilterByStatus(
|
||||
DownloadManager.STATUS_PENDING or
|
||||
DownloadManager.STATUS_PAUSED or
|
||||
DownloadManager.STATUS_RUNNING or
|
||||
DownloadManager.STATUS_SUCCESSFUL
|
||||
)
|
||||
val cursor = downloadManager.query(query)
|
||||
var latestId = -1L
|
||||
cursor.use { c ->
|
||||
val idIdx = c.getColumnIndex(DownloadManager.COLUMN_ID)
|
||||
val urlIdx = c.getColumnIndex(DownloadManager.COLUMN_URI)
|
||||
if (idIdx < 0 || urlIdx < 0) return@use
|
||||
while (c.moveToNext()) {
|
||||
val itemUrl = c.getString(urlIdx).orEmpty()
|
||||
if (itemUrl != url) continue
|
||||
val id = c.getLong(idIdx)
|
||||
if (id > latestId) latestId = id
|
||||
}
|
||||
}
|
||||
return latestId
|
||||
}
|
||||
|
||||
private fun queryDownload(downloadId: Long): DownloadSnapshot {
|
||||
if (downloadId <= 0L) return DownloadSnapshot()
|
||||
val cursor = downloadManager.query(DownloadManager.Query().setFilterById(downloadId))
|
||||
cursor.use { c ->
|
||||
if (!c.moveToFirst()) return DownloadSnapshot()
|
||||
val statusIdx = c.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||
val reasonIdx = c.getColumnIndex(DownloadManager.COLUMN_REASON)
|
||||
val soFarIdx = c.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
|
||||
val totalIdx = c.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
|
||||
val urlIdx = c.getColumnIndex(DownloadManager.COLUMN_URI)
|
||||
return DownloadSnapshot(
|
||||
exists = true,
|
||||
status = if (statusIdx >= 0) c.getInt(statusIdx) else 0,
|
||||
reason = if (reasonIdx >= 0) c.getInt(reasonIdx) else -1,
|
||||
soFar = if (soFarIdx >= 0) c.getLong(soFarIdx) else 0L,
|
||||
total = if (totalIdx >= 0) c.getLong(totalIdx) else 0L,
|
||||
url = if (urlIdx >= 0) c.getString(urlIdx).orEmpty() else ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistTrackedDownload() {
|
||||
if (activeDownloadId <= 0L) return
|
||||
prefs.edit()
|
||||
.putLong(KEY_DOWNLOAD_ID, activeDownloadId)
|
||||
.putString(KEY_DOWNLOAD_URL, activeDownloadUrl)
|
||||
.putString(KEY_EXPECTED_SHA256, activeExpectedSha256)
|
||||
.putString(KEY_FILE_NAME, activeFileName)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun clearTrackedDownload() {
|
||||
activeDownloadId = -1L
|
||||
activeDownloadUrl = ""
|
||||
activeExpectedSha256 = ""
|
||||
activeFileName = ""
|
||||
resetProgressTracking()
|
||||
prefs.edit()
|
||||
.remove(KEY_DOWNLOAD_ID)
|
||||
.remove(KEY_DOWNLOAD_URL)
|
||||
.remove(KEY_EXPECTED_SHA256)
|
||||
.remove(KEY_FILE_NAME)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun buildStableFileName(versionName: String): String {
|
||||
val safeName = versionName.ifBlank { "latest" }.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||
return "ai-glasses-$safeName.apk"
|
||||
}
|
||||
|
||||
private fun resetProgressTracking(initialBytes: Long = -1L) {
|
||||
lastProgressPercent = -1
|
||||
lastProgressLogAt = 0L
|
||||
lastProgressBytes = initialBytes
|
||||
lastProgressBytesAt = if (initialBytes >= 0L) SystemClock.elapsedRealtime() else 0L
|
||||
}
|
||||
|
||||
private fun verifyDownloadedApkSha256(uri: Uri, expectedSha256: String): Boolean {
|
||||
if (expectedSha256.isBlank()) return true
|
||||
val digest = runCatching {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
appContext.contentResolver.openInputStream(uri)?.use { input ->
|
||||
val buffer = ByteArray(16 * 1024)
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n <= 0) break
|
||||
md.update(buffer, 0, n)
|
||||
}
|
||||
} ?: return false
|
||||
md.digest().joinToString("") { "%02x".format(it) }
|
||||
}.onFailure {
|
||||
onLog("OTA: 校验失败 ${it.message}")
|
||||
}.getOrNull() ?: return false
|
||||
if (digest != expectedSha256) {
|
||||
onLog("OTA: 文件校验不匹配 expected=${expectedSha256.take(10)} actual=${digest.take(10)}")
|
||||
return false
|
||||
}
|
||||
onLog("OTA: 文件校验通过")
|
||||
return true
|
||||
}
|
||||
|
||||
private fun installApk(uri: Uri): Boolean {
|
||||
return runCatching {
|
||||
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE).apply {
|
||||
data = uri
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
|
||||
putExtra(Intent.EXTRA_RETURN_RESULT, false)
|
||||
}
|
||||
intent.resolveActivity(appContext.packageManager)
|
||||
?: throw IllegalStateException("no package installer activity")
|
||||
appContext.startActivity(intent)
|
||||
true
|
||||
}.onFailure {
|
||||
onLog("OTA: 安装 Intent 失败 ${it.message}")
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun materializeInstallUri(sourceUri: Uri, fileName: String): Uri? {
|
||||
return runCatching {
|
||||
val otaDir = File(appContext.cacheDir, "ota").apply { mkdirs() }
|
||||
val apkFile = File(otaDir, fileName.ifBlank { "ai-glasses-update.apk" })
|
||||
appContext.contentResolver.openInputStream(sourceUri)?.use { input ->
|
||||
FileOutputStream(apkFile, false).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: return null
|
||||
FileProvider.getUriForFile(
|
||||
appContext,
|
||||
"${appContext.packageName}.fileprovider",
|
||||
apkFile
|
||||
)
|
||||
}.onFailure {
|
||||
onLog("OTA: 准备安装包失败 ${it.message}")
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun formatBytes(value: Long): String {
|
||||
if (value < 1024L) return "${value}B"
|
||||
val kb = value / 1024.0
|
||||
if (kb < 1024.0) return String.format("%.1fKB", kb)
|
||||
val mb = kb / 1024.0
|
||||
if (mb < 1024.0) return String.format("%.1fMB", mb)
|
||||
val gb = mb / 1024.0
|
||||
return String.format("%.2fGB", gb)
|
||||
}
|
||||
|
||||
private fun reasonToText(reason: Int): String {
|
||||
return when (reason) {
|
||||
DownloadManager.ERROR_CANNOT_RESUME -> "CANNOT_RESUME"
|
||||
DownloadManager.ERROR_DEVICE_NOT_FOUND -> "DEVICE_NOT_FOUND"
|
||||
DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "FILE_ALREADY_EXISTS"
|
||||
DownloadManager.ERROR_FILE_ERROR -> "FILE_ERROR"
|
||||
DownloadManager.ERROR_HTTP_DATA_ERROR -> "HTTP_DATA_ERROR"
|
||||
DownloadManager.ERROR_INSUFFICIENT_SPACE -> "INSUFFICIENT_SPACE"
|
||||
DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "TOO_MANY_REDIRECTS"
|
||||
DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "UNHANDLED_HTTP_CODE"
|
||||
DownloadManager.ERROR_UNKNOWN -> "UNKNOWN"
|
||||
DownloadManager.PAUSED_QUEUED_FOR_WIFI -> "PAUSED_QUEUED_FOR_WIFI"
|
||||
DownloadManager.PAUSED_WAITING_FOR_NETWORK -> "PAUSED_WAITING_FOR_NETWORK"
|
||||
DownloadManager.PAUSED_WAITING_TO_RETRY -> "PAUSED_WAITING_TO_RETRY"
|
||||
DownloadManager.PAUSED_UNKNOWN -> "PAUSED_UNKNOWN"
|
||||
else -> "OTHER"
|
||||
}
|
||||
}
|
||||
|
||||
private fun statusToText(status: Int): String {
|
||||
return when (status) {
|
||||
DownloadManager.STATUS_PENDING -> "PENDING"
|
||||
DownloadManager.STATUS_RUNNING -> "RUNNING"
|
||||
DownloadManager.STATUS_PAUSED -> "PAUSED"
|
||||
DownloadManager.STATUS_SUCCESSFUL -> "SUCCESSFUL"
|
||||
DownloadManager.STATUS_FAILED -> "FAILED"
|
||||
else -> "UNKNOWN"
|
||||
}
|
||||
}
|
||||
|
||||
private fun canInstallPackages(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
appContext.packageManager.canRequestPackageInstalls()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun openInstallPermissionSettings() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
runCatching {
|
||||
val intent = Intent(
|
||||
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:${appContext.packageName}")
|
||||
).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
appContext.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFS_NAME = "ota_updater_prefs"
|
||||
const val KEY_DOWNLOAD_ID = "download_id"
|
||||
const val KEY_DOWNLOAD_URL = "download_url"
|
||||
const val KEY_EXPECTED_SHA256 = "expected_sha256"
|
||||
const val KEY_FILE_NAME = "file_name"
|
||||
}
|
||||
}
|
||||
3
android-app/app/src/main/res/values/strings.xml
Normal file
3
android-app/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">StoryForge AI</string>
|
||||
</resources>
|
||||
5
android-app/app/src/main/res/values/themes.xml
Normal file
5
android-app/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.AIGlasses" parent="Theme.Material3.DayNight.NoActionBar" />
|
||||
</resources>
|
||||
|
||||
6
android-app/app/src/main/res/xml/file_paths.xml
Normal file
6
android-app/app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path
|
||||
name="ota_cache"
|
||||
path="ota/" />
|
||||
</paths>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
</network-security-config>
|
||||
|
||||
6
android-app/build.gradle.kts
Normal file
6
android-app/build.gradle.kts
Normal file
@@ -0,0 +1,6 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.5.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false
|
||||
}
|
||||
|
||||
5
android-app/gradle.properties
Normal file
5
android-app/gradle.properties
Normal file
@@ -0,0 +1,5 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
|
||||
BIN
android-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
android-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
249
android-app/gradlew
vendored
Executable file
249
android-app/gradlew
vendored
Executable file
@@ -0,0 +1,249 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
92
android-app/gradlew.bat
vendored
Normal file
92
android-app/gradlew.bat
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
19
android-app/settings.gradle.kts
Normal file
19
android-app/settings.gradle.kts
Normal file
@@ -0,0 +1,19 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "AIGlassesApp"
|
||||
include(":app")
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.venv311
|
||||
app/__pycache__
|
||||
*.pyc
|
||||
@@ -1,9 +1,4 @@
|
||||
ARG BASE_IMAGE=python:3.11-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt ./
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""Collector service source overlay for legacy pyc-backed app."""
|
||||
# StoryForge collector-service package
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
|
||||
SQLITE_BUSY_TIMEOUT_MS = int(os.getenv("SQLITE_BUSY_TIMEOUT_MS", "5000"))
|
||||
SQLITE_CONNECT_TIMEOUT_SEC = float(os.getenv("SQLITE_CONNECT_TIMEOUT_SEC", "30"))
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -27,20 +22,9 @@ class Database:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.path, timeout=SQLITE_CONNECT_TIMEOUT_SEC)
|
||||
conn = sqlite3.connect(self.path)
|
||||
conn.row_factory = dict_factory
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
except sqlite3.OperationalError:
|
||||
# Some temporary or restricted filesystems used by tests cannot
|
||||
# enable WAL mode reliably. Fall back to the default journal mode
|
||||
# so the database remains usable instead of failing to open.
|
||||
conn.execute("PRAGMA journal_mode = DELETE")
|
||||
conn.execute("PRAGMA synchronous = NORMAL")
|
||||
conn.execute(f"PRAGMA busy_timeout = {SQLITE_BUSY_TIMEOUT_MS}")
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("PRAGMA temp_store = MEMORY")
|
||||
conn.execute("PRAGMA wal_autocheckpoint = 1000")
|
||||
return conn
|
||||
|
||||
@contextmanager
|
||||
@@ -64,18 +48,6 @@ 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 (
|
||||
@@ -118,10 +90,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 '',
|
||||
sync_status TEXT NOT NULL DEFAULT 'ready',
|
||||
fastgpt_dataset_id TEXT,
|
||||
sync_status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE
|
||||
@@ -136,9 +108,7 @@ class Database:
|
||||
transcript_text TEXT NOT NULL DEFAULT '',
|
||||
style_summary TEXT NOT NULL DEFAULT '',
|
||||
combined_text TEXT NOT NULL DEFAULT '',
|
||||
analysis_json TEXT NOT NULL DEFAULT '{}',
|
||||
storyboard_json TEXT NOT NULL DEFAULT '[]',
|
||||
source_artifact_json TEXT NOT NULL DEFAULT '{}',
|
||||
fastgpt_collection_id TEXT NOT NULL DEFAULT '',
|
||||
analysis_model_profile_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
@@ -148,12 +118,11 @@ 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 '',
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
fastgpt_app_key TEXT NOT NULL DEFAULT '',
|
||||
model_profile_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
@@ -171,27 +140,19 @@ class Database:
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
project_id TEXT,
|
||||
parent_job_id TEXT,
|
||||
assistant_id TEXT,
|
||||
knowledge_base_id TEXT NOT NULL,
|
||||
content_source_id TEXT,
|
||||
source_type TEXT NOT NULL,
|
||||
line_type TEXT NOT NULL DEFAULT 'analysis',
|
||||
workflow_key TEXT NOT NULL DEFAULT '',
|
||||
orchestrator TEXT NOT NULL DEFAULT 'n8n',
|
||||
provider_name TEXT NOT NULL DEFAULT '',
|
||||
provider_task_id TEXT NOT NULL DEFAULT '',
|
||||
source_url TEXT,
|
||||
title TEXT NOT NULL,
|
||||
language TEXT NOT NULL DEFAULT 'auto',
|
||||
status TEXT NOT NULL,
|
||||
transcript_text TEXT NOT NULL DEFAULT '',
|
||||
style_summary TEXT NOT NULL DEFAULT '',
|
||||
fastgpt_collection_id TEXT NOT NULL DEFAULT '',
|
||||
upload_status TEXT NOT NULL DEFAULT 'pending',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
artifacts_json TEXT NOT NULL DEFAULT '{}',
|
||||
result_json TEXT NOT NULL DEFAULT '{}',
|
||||
analysis_model_profile_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
@@ -200,131 +161,6 @@ 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 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 live_recorder_sources (
|
||||
id TEXT PRIMARY KEY,
|
||||
platform TEXT NOT NULL DEFAULT '',
|
||||
source_url TEXT NOT NULL,
|
||||
remote_name TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(platform, source_url)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS live_recorder_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
project_id TEXT,
|
||||
assistant_id TEXT,
|
||||
source_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
quality TEXT NOT NULL DEFAULT '原画',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(user_id, source_id),
|
||||
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY(assistant_id) REFERENCES assistants(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY(source_id) REFERENCES live_recorder_sources(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tenant_quota_profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL DEFAULT '',
|
||||
monthly_budget_cents INTEGER NOT NULL DEFAULT 0,
|
||||
storage_limit_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
analysis_quota INTEGER NOT NULL DEFAULT 0,
|
||||
copy_quota INTEGER NOT NULL DEFAULT 0,
|
||||
ai_video_quota INTEGER NOT NULL DEFAULT 0,
|
||||
real_cut_quota INTEGER NOT NULL DEFAULT 0,
|
||||
recorder_quota INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(user_id, project_id),
|
||||
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 tenant_usage_ledger (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL DEFAULT '',
|
||||
category TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL DEFAULT 1,
|
||||
cost_cents INTEGER NOT NULL DEFAULT 0,
|
||||
reference_type TEXT NOT NULL DEFAULT '',
|
||||
reference_id TEXT NOT NULL DEFAULT '',
|
||||
details_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_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,
|
||||
@@ -340,124 +176,6 @@ class Database:
|
||||
published_at INTEGER NOT NULL,
|
||||
created_by TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_runtime_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value_json TEXT NOT NULL DEFAULT '{}',
|
||||
updated_by TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
with self.session() as conn:
|
||||
conn.executescript(schema)
|
||||
self.migrate_schema()
|
||||
|
||||
def migrate_schema(self) -> None:
|
||||
table_columns: dict[str, dict[str, str]] = {
|
||||
"knowledge_bases": {
|
||||
"project_id": "TEXT",
|
||||
},
|
||||
"knowledge_documents": {
|
||||
"analysis_json": "TEXT NOT NULL DEFAULT '{}'",
|
||||
"storyboard_json": "TEXT NOT NULL DEFAULT '[]'",
|
||||
"source_artifact_json": "TEXT NOT NULL DEFAULT '{}'",
|
||||
},
|
||||
"assistants": {
|
||||
"project_id": "TEXT",
|
||||
"config_json": "TEXT NOT NULL DEFAULT '{}'",
|
||||
},
|
||||
"jobs": {
|
||||
"project_id": "TEXT",
|
||||
"parent_job_id": "TEXT",
|
||||
"content_source_id": "TEXT",
|
||||
"line_type": "TEXT NOT NULL DEFAULT 'analysis'",
|
||||
"workflow_key": "TEXT NOT NULL DEFAULT ''",
|
||||
"orchestrator": "TEXT NOT NULL DEFAULT 'n8n'",
|
||||
"provider_name": "TEXT NOT NULL DEFAULT ''",
|
||||
"provider_task_id": "TEXT NOT NULL DEFAULT ''",
|
||||
"result_json": "TEXT NOT NULL DEFAULT '{}'",
|
||||
},
|
||||
"platform_agent_profiles": {
|
||||
"last_run_id": "TEXT NOT NULL DEFAULT ''",
|
||||
"last_run_status": "TEXT NOT NULL DEFAULT ''",
|
||||
"last_used_at": "TEXT NOT NULL DEFAULT ''",
|
||||
"last_intent_key": "TEXT NOT NULL DEFAULT ''",
|
||||
"last_oneliner_profile_version_no": "INTEGER NOT NULL DEFAULT 0",
|
||||
"last_platform_profile_version_no": "INTEGER NOT NULL DEFAULT 0",
|
||||
"last_execution_summary": "TEXT NOT NULL DEFAULT ''",
|
||||
"last_source_screen": "TEXT NOT NULL DEFAULT ''",
|
||||
},
|
||||
}
|
||||
|
||||
for table, columns in table_columns.items():
|
||||
if not self.table_exists(table):
|
||||
continue
|
||||
for column, definition in columns.items():
|
||||
if self.column_exists(table, column):
|
||||
continue
|
||||
self.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
|
||||
|
||||
self.ensure_default_projects()
|
||||
|
||||
def ensure_default_projects(self) -> None:
|
||||
if not self.table_exists("projects"):
|
||||
return
|
||||
|
||||
accounts = self.fetch_all("SELECT id, username FROM accounts ORDER BY created_at ASC")
|
||||
for account in accounts:
|
||||
project = self.fetch_one(
|
||||
"SELECT * FROM projects WHERE user_id = ? ORDER BY created_at ASC LIMIT 1",
|
||||
(account["id"],),
|
||||
)
|
||||
if not project:
|
||||
project_id = f"proj_{account['id']}"
|
||||
now = utc_now()
|
||||
self.execute(
|
||||
"""
|
||||
INSERT INTO projects (id, user_id, name, description, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
project_id,
|
||||
account["id"],
|
||||
f"{account['username']} 默认项目",
|
||||
"系统自动创建的默认项目",
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
project = self.fetch_one("SELECT * FROM projects WHERE id = ?", (project_id,))
|
||||
|
||||
if not project:
|
||||
continue
|
||||
|
||||
if self.column_exists("knowledge_bases", "project_id"):
|
||||
self.execute(
|
||||
"""
|
||||
UPDATE knowledge_bases
|
||||
SET project_id = ?
|
||||
WHERE user_id = ? AND (project_id IS NULL OR project_id = '')
|
||||
""",
|
||||
(project["id"], account["id"]),
|
||||
)
|
||||
|
||||
if self.column_exists("assistants", "project_id"):
|
||||
self.execute(
|
||||
"""
|
||||
UPDATE assistants
|
||||
SET project_id = ?
|
||||
WHERE user_id = ? AND (project_id IS NULL OR project_id = '')
|
||||
""",
|
||||
(project["id"], account["id"]),
|
||||
)
|
||||
|
||||
if self.column_exists("jobs", "project_id"):
|
||||
self.execute(
|
||||
"""
|
||||
UPDATE jobs
|
||||
SET project_id = ?
|
||||
WHERE user_id = ? AND (project_id IS NULL OR project_id = '')
|
||||
""",
|
||||
(project["id"], account["id"]),
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
48
collector-service/app/fastgpt.py
Normal file
48
collector-service/app/fastgpt.py
Normal file
@@ -0,0 +1,48 @@
|
||||
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()
|
||||
@@ -1,226 +0,0 @@
|
||||
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())
|
||||
|
||||
async def list_runs(self) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
_join_url(self.base_url, "/api/runs"),
|
||||
headers=self._headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return _unwrap_response(response.json())
|
||||
|
||||
|
||||
class AsrHttpClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
transcribe_path: str = "/transcribe",
|
||||
field_name: str = "wav",
|
||||
timeout: float = 120.0,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.transcribe_path = transcribe_path
|
||||
self.field_name = field_name.strip() or "wav"
|
||||
self.timeout = timeout
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self.base_url)
|
||||
|
||||
async def transcribe_audio(self, audio_path: Path) -> dict[str, Any]:
|
||||
content_type = mimetypes.guess_type(audio_path.name)[0] or "application/octet-stream"
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
with audio_path.open("rb") as handle:
|
||||
response = await client.post(
|
||||
_join_url(self.base_url, self.transcribe_path),
|
||||
files={self.field_name: (audio_path.name, handle, content_type)},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return _unwrap_response(response.json())
|
||||
|
||||
|
||||
class HuobaoDramaClient:
|
||||
def __init__(self, *, base_url: str, timeout: float = 180.0) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self.base_url)
|
||||
|
||||
async def create_drama(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
_join_url(self.base_url, "/api/v1/dramas"),
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return _unwrap_response(response.json())
|
||||
|
||||
async def generate_image(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
_join_url(self.base_url, "/api/v1/images"),
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return _unwrap_response(response.json())
|
||||
|
||||
async def get_image(self, image_id: str) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
_join_url(self.base_url, f"/api/v1/images/{image_id}"),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return _unwrap_response(response.json())
|
||||
|
||||
async def generate_video(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
_join_url(self.base_url, "/api/v1/videos"),
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return _unwrap_response(response.json())
|
||||
|
||||
async def get_video(self, video_id: str) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
_join_url(self.base_url, f"/api/v1/videos/{video_id}"),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return _unwrap_response(response.json())
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,4 +3,3 @@ uvicorn[standard]==0.34.0
|
||||
httpx==0.28.1
|
||||
python-multipart==0.0.20
|
||||
pydantic==2.11.1
|
||||
yt-dlp
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PORT="${PORT:-18083}"
|
||||
HOST="${HOST:-127.0.0.1}"
|
||||
|
||||
# Mirror the current live collector runtime so we can verify the source overlay
|
||||
# against the same database and external integrations without touching 8081.
|
||||
export DATA_DIR="${DATA_DIR:-/Users/kris/code/StoryForge-gitea/data/collector}"
|
||||
export DATABASE_PATH="${DATABASE_PATH:-$DATA_DIR/storyforge.db}"
|
||||
export DEFAULT_EXTERNAL_BASE_URL="${DEFAULT_EXTERNAL_BASE_URL:-https://storyforge.hyzq.net}"
|
||||
export LOCAL_OPENAI_BASE_URL="${LOCAL_OPENAI_BASE_URL:-http://host.docker.internal:8317/v1}"
|
||||
export LOCAL_OPENAI_MODEL="${LOCAL_OPENAI_MODEL:-GLM-5}"
|
||||
export LOCAL_OPENAI_API_KEY="${LOCAL_OPENAI_API_KEY:-}"
|
||||
export YTDLP_BIN="${YTDLP_BIN:-yt-dlp}"
|
||||
export FFMPEG_BIN="${FFMPEG_BIN:-ffmpeg}"
|
||||
export WHISPER_BIN="${WHISPER_BIN:-}"
|
||||
export WHISPER_MODEL="${WHISPER_MODEL:-$DATA_DIR/models/ggml-base.en.bin}"
|
||||
export ASR_HTTP_BASE_URL="${ASR_HTTP_BASE_URL:-http://host.docker.internal:8088}"
|
||||
export ASR_HTTP_TRANSCRIBE_PATH="${ASR_HTTP_TRANSCRIBE_PATH:-/transcribe}"
|
||||
export ASR_HTTP_FIELD_NAME="${ASR_HTTP_FIELD_NAME:-wav}"
|
||||
export ASR_HTTP_TIMEOUT_SEC="${ASR_HTTP_TIMEOUT_SEC:-120}"
|
||||
export N8N_BASE_URL="${N8N_BASE_URL:-http://n8n:5678}"
|
||||
export N8N_ANALYSIS_WEBHOOK_PATH="${N8N_ANALYSIS_WEBHOOK_PATH:-/webhook/storyforge-analysis}"
|
||||
export N8N_REAL_CUT_WEBHOOK_PATH="${N8N_REAL_CUT_WEBHOOK_PATH:-/webhook/storyforge-real-cut}"
|
||||
export N8N_AI_VIDEO_WEBHOOK_PATH="${N8N_AI_VIDEO_WEBHOOK_PATH:-/webhook/storyforge-ai-video}"
|
||||
export N8N_CONTENT_SOURCE_SYNC_WEBHOOK_PATH="${N8N_CONTENT_SOURCE_SYNC_WEBHOOK_PATH:-/webhook/storyforge-content-source-sync}"
|
||||
export ORCHESTRATOR_SHARED_SECRET="${ORCHESTRATOR_SHARED_SECRET:-storyforge-local-secret}"
|
||||
export CUTVIDEO_BASE_URL="${CUTVIDEO_BASE_URL:-http://192.168.31.18:7860}"
|
||||
export CUTVIDEO_API_KEY="${CUTVIDEO_API_KEY:-}"
|
||||
export CUTVIDEO_BASE_CONFIG="${CUTVIDEO_BASE_CONFIG:-example.job.yaml}"
|
||||
export CUTVIDEO_POLL_INTERVAL_SEC="${CUTVIDEO_POLL_INTERVAL_SEC:-10}"
|
||||
export CUTVIDEO_MAX_WAIT_SEC="${CUTVIDEO_MAX_WAIT_SEC:-1800}"
|
||||
export CUTVIDEO_UPLOAD_TIMEOUT_SEC="${CUTVIDEO_UPLOAD_TIMEOUT_SEC:-1800}"
|
||||
export HUOBAO_BASE_URL="${HUOBAO_BASE_URL:-http://host.docker.internal:5678}"
|
||||
export HUOBAO_POLL_INTERVAL_SEC="${HUOBAO_POLL_INTERVAL_SEC:-10}"
|
||||
export HUOBAO_MAX_WAIT_SEC="${HUOBAO_MAX_WAIT_SEC:-900}"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
exec ./.venv311/bin/python -m uvicorn app.main:app --host "$HOST" --port "$PORT"
|
||||
26
concepts/explore-first/README.md
Normal file
26
concepts/explore-first/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Version A: Explore First
|
||||
|
||||
Direction: `BibiGPT x mobile creator app`
|
||||
|
||||
This version optimizes for the fastest path from raw material to reusable knowledge.
|
||||
|
||||
## Product thesis
|
||||
|
||||
- User intent starts with a single material input, not assistant configuration.
|
||||
- The app should feel like `paste link -> learn style -> save to knowledge base`.
|
||||
- Assistant creation is still present, but secondary.
|
||||
|
||||
## Key decisions
|
||||
|
||||
- `Explore` is the hero tab and the default opening screen.
|
||||
- Three inputs are elevated equally: video link, upload video, paste text.
|
||||
- The main feedback loop is a clean processing timeline instead of a complex dashboard.
|
||||
- Knowledge bases are shown as lightweight chips so the user can move fast.
|
||||
- Generated assistant suggestions appear after learning is complete.
|
||||
|
||||
## When this version is best
|
||||
|
||||
- Solo creators
|
||||
- Fast content collection
|
||||
- Teams that want low onboarding cost
|
||||
- Users who are not technical and do not want to think in workflows
|
||||
387
concepts/explore-first/index.html
Normal file
387
concepts/explore-first/index.html
Normal file
@@ -0,0 +1,387 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>StoryForge Version A</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4efe6;
|
||||
--panel: rgba(255,255,255,0.82);
|
||||
--ink: #1f1c17;
|
||||
--muted: #6e6558;
|
||||
--accent: #e26d3d;
|
||||
--accent-soft: #ffd8c9;
|
||||
--line: rgba(56, 40, 24, 0.12);
|
||||
--shadow: 0 22px 60px rgba(72, 44, 23, 0.16);
|
||||
--radius-xl: 30px;
|
||||
--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(236,128,78,0.24), transparent 32%),
|
||||
radial-gradient(circle at 85% 18%, rgba(121,154,120,0.2), transparent 26%),
|
||||
linear-gradient(180deg, #f7f0e8 0%, #efe6da 100%);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.phone {
|
||||
width: 390px;
|
||||
min-height: 844px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.82), rgba(255,250,245,0.94));
|
||||
border: 1px solid rgba(80,52,28,0.1);
|
||||
border-radius: 38px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 26px 24px 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -24px;
|
||||
top: -34px;
|
||||
width: 168px;
|
||||
height: 168px;
|
||||
background: radial-gradient(circle, rgba(226,109,61,0.24), transparent 66%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.74);
|
||||
border: 1px solid rgba(91,58,36,0.08);
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 16px 0 10px;
|
||||
font-size: 34px;
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-xl);
|
||||
margin: 0 18px 16px;
|
||||
padding: 18px;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.modes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.mode {
|
||||
padding: 12px 10px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
background: rgba(255,255,255,0.62);
|
||||
}
|
||||
|
||||
.mode.active {
|
||||
background: linear-gradient(180deg, #fff0e8, #ffd8c9);
|
||||
color: #4f2818;
|
||||
border-color: rgba(226,109,61,0.28);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.input {
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fffdf9;
|
||||
padding: 16px;
|
||||
min-height: 104px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cta-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.button {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
padding: 14px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
background: linear-gradient(180deg, #ea7d4f, #d85d2e);
|
||||
color: white;
|
||||
box-shadow: 0 16px 28px rgba(205, 94, 44, 0.28);
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: rgba(255,255,255,0.75);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mini {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.job {
|
||||
border-radius: 22px;
|
||||
padding: 14px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.88), rgba(252,246,238,0.88));
|
||||
border: 1px solid rgba(85,56,34,0.08);
|
||||
}
|
||||
|
||||
.job-top, .assistant-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.job-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: var(--accent-soft);
|
||||
color: #7a381d;
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.step {
|
||||
padding: 7px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(248,239,230,0.95);
|
||||
color: #66594d;
|
||||
font-size: 11px;
|
||||
border: 1px solid rgba(95,69,42,0.08);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.7);
|
||||
border: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.assistants {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.assistant {
|
||||
border-radius: 24px;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, rgba(255,247,239,0.92), rgba(244,255,246,0.9));
|
||||
border: 1px solid rgba(86,70,41,0.08);
|
||||
}
|
||||
|
||||
.assistant h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.assistant p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tabbar {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
border-radius: 24px;
|
||||
background: rgba(30, 24, 18, 0.92);
|
||||
color: rgba(255,255,255,0.58);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
padding: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
text-align: center;
|
||||
padding: 12px 8px;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: linear-gradient(180deg, rgba(233,123,73,0.95), rgba(204,85,39,0.95));
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="phone">
|
||||
<section class="hero">
|
||||
<div class="eyebrow">StoryForge · Explore First</div>
|
||||
<h1>Paste a video. Learn a voice. Build a style library.</h1>
|
||||
<p class="sub">A mobile-first flow for collecting creator material, turning it into text, and dropping the refined style signal into private knowledge bases.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="modes">
|
||||
<div class="mode active">Video Link</div>
|
||||
<div class="mode">Upload Video</div>
|
||||
<div class="mode">Paste Text</div>
|
||||
</div>
|
||||
<div class="input">https://www.douyin.com/video/creator-style-01
|
||||
|
||||
Target KB: Founder Hooks
|
||||
Assistant: AI Startup Scriptwriter
|
||||
Analysis model: Local GLM-5</div>
|
||||
<div class="cta-row">
|
||||
<button class="button primary">Start learning</button>
|
||||
<button class="button secondary">Save draft</button>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<div class="chip">Founder Hooks</div>
|
||||
<div class="chip">Sales Emotion</div>
|
||||
<div class="chip">Short CTA</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<span>Processing timeline</span>
|
||||
<span class="mini">2 active jobs</span>
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<div class="job">
|
||||
<div class="job-top">
|
||||
<div class="job-title">AI startup founder talking-head sample</div>
|
||||
<div class="badge">Analyzing</div>
|
||||
</div>
|
||||
<div class="steps">
|
||||
<div class="step">Downloaded</div>
|
||||
<div class="step">Audio extracted</div>
|
||||
<div class="step">ASR complete</div>
|
||||
<div class="step">Style patterns</div>
|
||||
<div class="step">KB sync next</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="job">
|
||||
<div class="job-top">
|
||||
<div class="job-title">Closing CTA collection</div>
|
||||
<div class="badge">Saved</div>
|
||||
</div>
|
||||
<div class="steps">
|
||||
<div class="step">22 hooks found</div>
|
||||
<div class="step">3 tone labels</div>
|
||||
<div class="step">Knowledge base updated</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" style="margin-bottom: 112px;">
|
||||
<div class="section-title">
|
||||
<span>Suggested assistants</span>
|
||||
<span class="mini">Generated from your latest material</span>
|
||||
</div>
|
||||
<div class="assistants">
|
||||
<div class="assistant">
|
||||
<div class="assistant-top">
|
||||
<h3>AI Startup Scriptwriter</h3>
|
||||
<div class="badge">1 KB</div>
|
||||
</div>
|
||||
<p>Sharp hook in the first sentence, short explanatory middle, strong “do this now” ending. Built from founder-style narration.</p>
|
||||
</div>
|
||||
<div class="assistant">
|
||||
<div class="assistant-top">
|
||||
<h3>Emotion-driven Sales Closer</h3>
|
||||
<div class="badge">2 KBs</div>
|
||||
</div>
|
||||
<p>More intimate and persuasive. Best for conversion-driven scripts, private-domain follow-up, and urgency messaging.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav class="tabbar">
|
||||
<div class="tab active">Explore</div>
|
||||
<div class="tab">Production</div>
|
||||
<div class="tab">Mine</div>
|
||||
</nav>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,29 +0,0 @@
|
||||
# Version B: Studio Workbench
|
||||
|
||||
Direction: `Castmagic x content ops studio`
|
||||
|
||||
Preview prototype: [index.html](index.html)
|
||||
|
||||
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
|
||||
@@ -1,426 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,70 +0,0 @@
|
||||
# StoryForge `storyforge.hyzq.net` 公网入口
|
||||
|
||||
当前公网接入不是把执行链整体迁到云服务器,而是:
|
||||
|
||||
1. 云服务器 `nginx` 提供 `https://storyforge.hyzq.net/`
|
||||
2. 云服务器本地 `storyforge-web-v4.service` 承接静态前端
|
||||
3. 云服务器本地 `collector-service` 直接承接业务 API 与数据库
|
||||
4. 本机通过 SSH 反向隧道只桥接本地和局域网执行引擎到云服务器
|
||||
|
||||
当前已验证的 SSH 维护入口:
|
||||
|
||||
- `ubuntu@111.231.132.51`
|
||||
- 公网部署目录:`/home/ubuntu/storyforge`
|
||||
- systemd 服务:
|
||||
- `storyforge-web-v4`
|
||||
- `storyforge-collector`
|
||||
- `nginx`
|
||||
|
||||
## 端口映射
|
||||
|
||||
- 云服务器 `127.0.0.1:8081` -> 云服务器本地 `collector-service`
|
||||
- 云服务器 `127.0.0.1:19191` -> 云服务器本地 `StoryForge Web V4` 静态服务
|
||||
- 云服务器 `127.0.0.1:15670` -> 本机 `n8n :5670`
|
||||
- 云服务器不再默认依赖本机模型网关
|
||||
- 云服务器 `127.0.0.1:28088` -> Windows `ASR :8088`
|
||||
- 云服务器 `127.0.0.1:15678` -> 本机 `huobao :5678`
|
||||
- 云服务器 `127.0.0.1:17860` -> 局域网 Windows `cutvideo :7860`
|
||||
- 云服务器 `127.0.0.1:19106` -> 局域网 NAS `live-recorder :19106`
|
||||
|
||||
## 本机常驻服务
|
||||
|
||||
- `com.storyforge.cloud-bridge`
|
||||
- 本机 `com.storyforge.collector` 可保留为本地开发,不再是公网必需项
|
||||
- 本机 `com.storyforge.web-v4` 仍可保留为本地预览,不再是公网必需项
|
||||
|
||||
## 云服务器 `nginx` 路由
|
||||
|
||||
- `/` -> `127.0.0.1:19191`
|
||||
- `/v2/*` -> `127.0.0.1:8081`
|
||||
- `/openapi.json` -> `127.0.0.1:8081/openapi.json`
|
||||
- `/healthz` -> `127.0.0.1:8081/healthz`
|
||||
- `/downloads/*` -> `127.0.0.1:8081/downloads/*`
|
||||
|
||||
## 当前优点
|
||||
|
||||
- `collector-service` 和数据库已经上云,公网主链不再依赖本机业务 API
|
||||
- 不需要把 `cutvideo / huobao / NAS live-recorder / 本机模型` 全部搬上云
|
||||
- 公网入口统一
|
||||
- 前端静态页不再依赖本机桥接
|
||||
- 本地和局域网执行层不需要迁移即可继续提供能力
|
||||
|
||||
## 当前限制
|
||||
|
||||
- 本地桥接断开时,相关执行引擎会不可用,但登录和基础业务 API 仍可用
|
||||
- 这仍是混合部署测试架构,不是最终完全云原生部署
|
||||
|
||||
## 标准化发布与回归
|
||||
|
||||
仓库内已经补了两个标准脚本:
|
||||
|
||||
```bash
|
||||
./scripts/deploy_public_storyforge.sh
|
||||
./scripts/smoke_public_storyforge.sh
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `deploy_public_storyforge.sh` 会备份远端 `web/storyforge-web-v4`,同步当前仓库的前端和 `collector-service/app`,重启 `storyforge-web-v4` / `storyforge-collector`,最后做公网 smoke。
|
||||
- `smoke_public_storyforge.sh` 会检查公网 `/healthz`、`/`、`/assets/app.js` 和 `/openapi.json`,确认最新 Web bundle 与多平台路由都已经对外可见。
|
||||
- 默认 SSH 口令可通过 `STORYFORGE_PUBLIC_PASSWORD` 传入,或从 macOS Keychain 的 `STORYFORGE_PUBLIC_KEYCHAIN_SERVICE` 读取;当前本机可沿用现有 `ai-glasses-debug-ssh` 条目。
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/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 runtime cleanup completed"
|
||||
@@ -1,48 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.storyforge.cloud-bridge</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/bin/ssh</string>
|
||||
<string>-N</string>
|
||||
<string>-i</string>
|
||||
<string>/Users/kris/.ssh/id_ed25519_kylin188</string>
|
||||
<string>-o</string>
|
||||
<string>BatchMode=yes</string>
|
||||
<string>-o</string>
|
||||
<string>ExitOnForwardFailure=yes</string>
|
||||
<string>-o</string>
|
||||
<string>ServerAliveInterval=30</string>
|
||||
<string>-o</string>
|
||||
<string>ServerAliveCountMax=3</string>
|
||||
<string>-o</string>
|
||||
<string>StrictHostKeyChecking=no</string>
|
||||
<string>-o</string>
|
||||
<string>UserKnownHostsFile=/Users/kris/.ssh/known_hosts</string>
|
||||
<string>-R</string>
|
||||
<string>127.0.0.1:15670:127.0.0.1:5670</string>
|
||||
<string>-R</string>
|
||||
<string>127.0.0.1:18317:127.0.0.1:8317</string>
|
||||
<string>-R</string>
|
||||
<string>127.0.0.1:18088:127.0.0.1:8088</string>
|
||||
<string>-R</string>
|
||||
<string>127.0.0.1:15678:127.0.0.1:5678</string>
|
||||
<string>-R</string>
|
||||
<string>127.0.0.1:17860:192.168.31.18:7860</string>
|
||||
<string>-R</string>
|
||||
<string>127.0.0.1:19106:192.168.31.188:19106</string>
|
||||
<string>ubuntu@111.231.132.51</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/Users/kris/code/StoryForge/data/collector/cloud-bridge.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Users/kris/code/StoryForge/data/collector/cloud-bridge.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,29 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.storyforge.web-v4</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/bin/python3</string>
|
||||
<string>-m</string>
|
||||
<string>http.server</string>
|
||||
<string>3918</string>
|
||||
<string>--bind</string>
|
||||
<string>127.0.0.1</string>
|
||||
<string>--directory</string>
|
||||
<string>/Users/kris/code/StoryForge/web/storyforge-web-v4</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/kris/code/StoryForge/web/storyforge-web-v4</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/Users/kris/code/StoryForge/data/collector/web-v4.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Users/kris/code/StoryForge/data/collector/web-v4.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="${ROOT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
BASE_COMPOSE_FILE="${BASE_COMPOSE_FILE:-$ROOT_DIR/docker-compose.yml}"
|
||||
RUNTIME_FIXES_COMPOSE_FILE="${RUNTIME_FIXES_COMPOSE_FILE:-$ROOT_DIR/deploy/storyforge-collector-runtime-fixes.yml}"
|
||||
OVERLAY_COMPOSE_FILE="${OVERLAY_COMPOSE_FILE:-$ROOT_DIR/deploy/storyforge-collector-source-overlay.yml}"
|
||||
PROJECT_NAME="${PROJECT_NAME:-storyforge-gitea}"
|
||||
COLLECTOR_URL="${COLLECTOR_URL:-http://127.0.0.1:8081}"
|
||||
MAX_ATTEMPTS="${MAX_ATTEMPTS:-25}"
|
||||
SLEEP_SEC="${SLEEP_SEC:-2}"
|
||||
|
||||
compose_with_overlay() {
|
||||
docker compose -p "$PROJECT_NAME" -f "$BASE_COMPOSE_FILE" -f "$RUNTIME_FIXES_COMPOSE_FILE" -f "$OVERLAY_COMPOSE_FILE" "$@"
|
||||
}
|
||||
|
||||
compose_base() {
|
||||
docker compose -p "$PROJECT_NAME" -f "$BASE_COMPOSE_FILE" -f "$RUNTIME_FIXES_COMPOSE_FILE" "$@"
|
||||
}
|
||||
|
||||
verify_overlay() {
|
||||
curl -fsS "$COLLECTOR_URL/healthz" >/dev/null
|
||||
local paths
|
||||
paths="$(curl -fsS "$COLLECTOR_URL/openapi.json" | jq -r '.paths | keys[]')"
|
||||
grep -qx '/v2/douyin/accounts' <<<"$paths"
|
||||
grep -qx '/v2/pipelines/real-cut' <<<"$paths"
|
||||
grep -qx '/v2/pipelines/ai-video' <<<"$paths"
|
||||
grep -qx '/v2/pipelines/content-source-sync' <<<"$paths"
|
||||
}
|
||||
|
||||
echo "[cutover] recreating collector with source overlay"
|
||||
compose_with_overlay up -d --force-recreate collector
|
||||
|
||||
for attempt in $(seq 1 "$MAX_ATTEMPTS"); do
|
||||
if verify_overlay; then
|
||||
echo "[cutover] collector overlay is live on $COLLECTOR_URL"
|
||||
exit 0
|
||||
fi
|
||||
sleep "$SLEEP_SEC"
|
||||
done
|
||||
|
||||
echo "[cutover] verification failed, rolling back to base compose"
|
||||
compose_base up -d --force-recreate collector
|
||||
exit 1
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="${ROOT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
BASE_COMPOSE_FILE="${BASE_COMPOSE_FILE:-$ROOT_DIR/docker-compose.yml}"
|
||||
RUNTIME_FIXES_COMPOSE_FILE="${RUNTIME_FIXES_COMPOSE_FILE:-$ROOT_DIR/deploy/storyforge-collector-runtime-fixes.yml}"
|
||||
PROJECT_NAME="${PROJECT_NAME:-storyforge-gitea}"
|
||||
|
||||
docker compose -p "$PROJECT_NAME" -f "$BASE_COMPOSE_FILE" -f "$RUNTIME_FIXES_COMPOSE_FILE" up -d --force-recreate collector
|
||||
@@ -1,4 +0,0 @@
|
||||
services:
|
||||
collector:
|
||||
environment:
|
||||
N8N_BASE_URL: http://n8n:5678
|
||||
@@ -1,6 +0,0 @@
|
||||
services:
|
||||
collector:
|
||||
environment:
|
||||
N8N_BASE_URL: http://n8n:5678
|
||||
volumes:
|
||||
- ${COLLECTOR_APP_OVERLAY_DIR:-/Users/kris/code/StoryForge/collector-service/app}:/app/app:ro
|
||||
@@ -1,34 +0,0 @@
|
||||
[Unit]
|
||||
Description=StoryForge Collector Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=ubuntu
|
||||
Group=ubuntu
|
||||
WorkingDirectory=/home/ubuntu/storyforge/collector-service
|
||||
Environment=DATA_DIR=/home/ubuntu/storyforge/data/collector
|
||||
Environment=DATABASE_PATH=/home/ubuntu/storyforge/data/collector/storyforge.db
|
||||
Environment=JOBS_DIR=/home/ubuntu/storyforge/data/collector/jobs
|
||||
Environment=DOWNLOADS_DIR=/home/ubuntu/storyforge/data/collector/downloads
|
||||
Environment=MODELS_DIR=/home/ubuntu/storyforge/data/collector/models
|
||||
Environment=DEFAULT_EXTERNAL_BASE_URL=https://storyforge.hyzq.net
|
||||
Environment=LOCAL_OPENAI_BASE_URL=
|
||||
Environment=ASR_HTTP_BASE_URL=http://127.0.0.1:28088
|
||||
Environment=N8N_BASE_URL=http://127.0.0.1:15670
|
||||
Environment=ORCHESTRATOR_SHARED_SECRET=__set_a_strong_shared_secret__
|
||||
Environment=BOOTSTRAP_SUPERADMIN_USERNAME=storyforge-admin
|
||||
Environment=BOOTSTRAP_SUPERADMIN_PASSWORD=__set_a_strong_password__
|
||||
Environment=BOOTSTRAP_SUPERADMIN_DISPLAY_NAME=StoryForge Admin
|
||||
Environment=WEB_AUTOLOGIN_ENABLED=1
|
||||
Environment=WEB_AUTOLOGIN_ACCOUNT_USERNAME=
|
||||
Environment=WEB_AUTOLOGIN_USERNAME=
|
||||
Environment=WEB_AUTOLOGIN_PASSWORD=
|
||||
Environment=HUOBAO_BASE_URL=http://127.0.0.1:15678
|
||||
Environment=CUTVIDEO_BASE_URL=http://127.0.0.1:17860
|
||||
Environment=LIVE_RECORDER_BASE_URL=http://127.0.0.1:19106
|
||||
ExecStart=/home/ubuntu/storyforge/collector-service/.venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8081
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,16 +0,0 @@
|
||||
services:
|
||||
storyforge-cliproxyapi:
|
||||
image: ${STORYFORGE_CLIPROXY_IMAGE:-eceasy/cli-proxy-api:latest}
|
||||
container_name: storyforge-cliproxyapi
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- ./CLIProxyAPI
|
||||
- -config
|
||||
- /CLIProxyAPI/config.yaml
|
||||
ports:
|
||||
- "${STORYFORGE_CLIPROXY_PORT:-8317}:8317"
|
||||
- "${STORYFORGE_CLIPROXY_MANAGEMENT_PORT:-18085}:8085"
|
||||
volumes:
|
||||
- "${STORYFORGE_CLIPROXY_STATE_ROOT:-/vol1/docker/hyzq-stack/shared/storyforge-cliproxyapi}/config.yaml:/CLIProxyAPI/config.yaml:ro"
|
||||
- "${STORYFORGE_CLIPROXY_STATE_ROOT:-/vol1/docker/hyzq-stack/shared/storyforge-cliproxyapi}/auths:/root/.cli-proxy-api"
|
||||
- "${STORYFORGE_CLIPROXY_STATE_ROOT:-/vol1/docker/hyzq-stack/shared/storyforge-cliproxyapi}/logs:/CLIProxyAPI/logs"
|
||||
@@ -1,51 +0,0 @@
|
||||
services:
|
||||
storyforge-collector-dev:
|
||||
image: ${STORYFORGE_COLLECTOR_IMAGE:-storyforge-collector-dev:fnos}
|
||||
build:
|
||||
context: ../../storyforge/collector-service
|
||||
args:
|
||||
BASE_IMAGE: ${STORYFORGE_COLLECTOR_BASE_IMAGE:-docker.m.daocloud.io/library/python:3.11-slim}
|
||||
container_name: storyforge-collector-dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${STORYFORGE_COLLECTOR_DEV_PORT:-19193}:8081"
|
||||
environment:
|
||||
DATA_DIR: /data/collector
|
||||
DATABASE_PATH: /data/collector/storyforge.db
|
||||
DEFAULT_EXTERNAL_BASE_URL: ${DEFAULT_EXTERNAL_BASE_URL:-http://192.168.31.188:19193}
|
||||
LOCAL_OPENAI_BASE_URL: ${LOCAL_OPENAI_BASE_URL:-}
|
||||
LOCAL_OPENAI_MODEL: ${LOCAL_OPENAI_MODEL:-GLM-5}
|
||||
LOCAL_OPENAI_API_KEY: ${LOCAL_OPENAI_API_KEY:-}
|
||||
N8N_BASE_URL: ${N8N_BASE_URL:-}
|
||||
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}
|
||||
BOOTSTRAP_SUPERADMIN_USERNAME: ${BOOTSTRAP_SUPERADMIN_USERNAME:-}
|
||||
BOOTSTRAP_SUPERADMIN_PASSWORD: ${BOOTSTRAP_SUPERADMIN_PASSWORD:-}
|
||||
BOOTSTRAP_SUPERADMIN_DISPLAY_NAME: ${BOOTSTRAP_SUPERADMIN_DISPLAY_NAME:-StoryForge Admin}
|
||||
WEB_AUTOLOGIN_ENABLED: ${WEB_AUTOLOGIN_ENABLED:-1}
|
||||
WEB_AUTOLOGIN_ACCOUNT_USERNAME: ${WEB_AUTOLOGIN_ACCOUNT_USERNAME:-kris}
|
||||
WEB_AUTOLOGIN_USERNAME: ${WEB_AUTOLOGIN_USERNAME:-}
|
||||
WEB_AUTOLOGIN_PASSWORD: ${WEB_AUTOLOGIN_PASSWORD:-}
|
||||
ORCHESTRATOR_SHARED_SECRET: ${ORCHESTRATOR_SHARED_SECRET:-storyforge-local-secret}
|
||||
CUTVIDEO_BASE_URL: ${CUTVIDEO_BASE_URL:-http://192.168.31.188:19186}
|
||||
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:-}
|
||||
YTDLP_BIN: ${YTDLP_BIN:-yt-dlp}
|
||||
FFMPEG_BIN: ${FFMPEG_BIN:-ffmpeg}
|
||||
WHISPER_BIN: ${WHISPER_BIN:-}
|
||||
WHISPER_MODEL: ${WHISPER_MODEL:-/data/collector/models/ggml-base.en.bin}
|
||||
ASR_HTTP_BASE_URL: ${ASR_HTTP_BASE_URL:-}
|
||||
ASR_HTTP_TRANSCRIBE_PATH: ${ASR_HTTP_TRANSCRIBE_PATH:-/transcribe}
|
||||
ASR_HTTP_FIELD_NAME: ${ASR_HTTP_FIELD_NAME:-wav}
|
||||
ASR_HTTP_TIMEOUT_SEC: ${ASR_HTTP_TIMEOUT_SEC:-120}
|
||||
HUOBAO_POLL_INTERVAL_SEC: ${HUOBAO_POLL_INTERVAL_SEC:-10}
|
||||
HUOBAO_MAX_WAIT_SEC: ${HUOBAO_MAX_WAIT_SEC:-900}
|
||||
LIVE_RECORDER_BASE_URL: ${LIVE_RECORDER_BASE_URL:-http://192.168.31.188:19106}
|
||||
volumes:
|
||||
- ../../storyforge/data/collector:/data/collector
|
||||
@@ -1,25 +0,0 @@
|
||||
services:
|
||||
storyforge-huobao:
|
||||
image: ${STORYFORGE_HUOBAO_IMAGE:-storyforge-huobao:fnos}
|
||||
build:
|
||||
context: ../../storyforge/huobao-drama-source
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
DOCKER_REGISTRY: ${STORYFORGE_HUOBAO_DOCKER_REGISTRY:-docker.m.daocloud.io/library/}
|
||||
NPM_REGISTRY: ${STORYFORGE_HUOBAO_NPM_REGISTRY:-https://registry.npmmirror.com}
|
||||
GO_PROXY: ${STORYFORGE_HUOBAO_GO_PROXY:-https://goproxy.cn,direct}
|
||||
ALPINE_MIRROR: ${STORYFORGE_HUOBAO_ALPINE_MIRROR:-mirrors.aliyun.com}
|
||||
container_name: storyforge-huobao
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${STORYFORGE_HUOBAO_PORT:-5678}:5678"
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
volumes:
|
||||
- "${STORYFORGE_HUOBAO_STATE_ROOT:-/vol1/docker/hyzq-stack/shared/storyforge-huobao}/data:/app/data"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5678/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
@@ -1,64 +0,0 @@
|
||||
services:
|
||||
# StoryForge server-side live recorder for multi-platform sources.
|
||||
#
|
||||
# Recommended upstream source repo:
|
||||
# /Users/kris/code/DouyinLiveRecorder-main
|
||||
#
|
||||
# Rationale:
|
||||
# - Supports 40+ live platforms.
|
||||
# - Linux/Docker friendly.
|
||||
# - Exposes HTTP APIs that StoryForge can call directly:
|
||||
# GET /api/healthz
|
||||
# GET /api/status-lite
|
||||
# GET /api/status
|
||||
# GET /api/recordings
|
||||
# POST /api/url-config/import
|
||||
# POST /api/url-config/set-enabled
|
||||
# POST /api/recorder/start
|
||||
# POST /api/recorder/stop
|
||||
# GET /api/downloads
|
||||
# GET /downloads/<path>
|
||||
#
|
||||
# Suggested fnOS state root:
|
||||
# /vol1/docker/hyzq-stack/shared/storyforge-live-recorder
|
||||
#
|
||||
# Suggested fnOS external port:
|
||||
# 19106
|
||||
#
|
||||
# Example StoryForge call flow:
|
||||
# 1. POST /api/url-config/import
|
||||
# {"raw":"原画,https://live.kuaishou.com/u/anchor123"}
|
||||
# 2. POST /api/recorder/start
|
||||
# 3. Poll GET /api/status-lite or /api/recordings
|
||||
# 4. Read output via GET /api/downloads or /downloads/<path>
|
||||
storyforge-live-recorder:
|
||||
image: ${STORYFORGE_LIVE_RECORDER_IMAGE:-storyforge-live-recorder:fnos}
|
||||
build:
|
||||
context: ../../storyforge/live-recorder-source
|
||||
dockerfile: Dockerfile.storyforge
|
||||
args:
|
||||
BASE_IMAGE: ${STORYFORGE_LIVE_RECORDER_BASE_IMAGE:-docker.m.daocloud.io/library/python:3.11-slim}
|
||||
container_name: storyforge-live-recorder
|
||||
restart: unless-stopped
|
||||
tty: true
|
||||
stdin_open: true
|
||||
command: ["python", "webui.py", "--host", "0.0.0.0", "--port", "8899"]
|
||||
ports:
|
||||
- "${STORYFORGE_LIVE_RECORDER_PORT:-19106}:8899"
|
||||
environment:
|
||||
TERM: xterm-256color
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
WEBUI_HOST: 0.0.0.0
|
||||
WEBUI_PORT: 8899
|
||||
RECORDER_PYTHON: python
|
||||
volumes:
|
||||
- "${STORYFORGE_LIVE_RECORDER_STATE_ROOT:-/vol1/docker/hyzq-stack/shared/storyforge-live-recorder}/config:/app/config"
|
||||
- "${STORYFORGE_LIVE_RECORDER_STATE_ROOT:-/vol1/docker/hyzq-stack/shared/storyforge-live-recorder}/logs:/app/logs"
|
||||
- "${STORYFORGE_LIVE_RECORDER_STATE_ROOT:-/vol1/docker/hyzq-stack/shared/storyforge-live-recorder}/backup_config:/app/backup_config"
|
||||
- "${STORYFORGE_LIVE_RECORDER_STATE_ROOT:-/vol1/docker/hyzq-stack/shared/storyforge-live-recorder}/downloads:/app/downloads"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8899/api/healthz', timeout=5).read()"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
@@ -1,21 +0,0 @@
|
||||
services:
|
||||
storyforge-n8n:
|
||||
image: ${STORYFORGE_N8N_IMAGE:-docker.m.daocloud.io/n8nio/n8n:latest}
|
||||
container_name: storyforge-n8n
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${STORYFORGE_N8N_PORT:-5670}:5678"
|
||||
environment:
|
||||
N8N_HOST: ${N8N_HOST:-0.0.0.0}
|
||||
N8N_PORT: 5678
|
||||
N8N_PROTOCOL: ${N8N_PROTOCOL:-http}
|
||||
WEBHOOK_URL: ${WEBHOOK_URL:-http://192.168.31.188:5670/}
|
||||
STORYFORGE_INTERNAL_BASE_URL: ${STORYFORGE_INTERNAL_BASE_URL:-http://192.168.31.188:19193}
|
||||
STORYFORGE_ORCHESTRATOR_SECRET: ${ORCHESTRATOR_SHARED_SECRET:-storyforge-local-secret}
|
||||
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}
|
||||
volumes:
|
||||
- "${STORYFORGE_N8N_STATE_ROOT:-/vol1/docker/hyzq-stack/shared/storyforge-n8n}/storage:/home/node/.n8n"
|
||||
- "${STORYFORGE_N8N_WORKFLOW_ROOT:-/vol1/docker/hyzq-stack/current/storyforge/n8n}:/workspace/n8n:ro"
|
||||
@@ -1,10 +0,0 @@
|
||||
services:
|
||||
storyforge-web-v4-dev:
|
||||
image: docker.m.daocloud.io/library/nginx:alpine
|
||||
container_name: storyforge-web-v4-dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${STORYFORGE_WEB_V4_DEV_PORT:-19192}:80"
|
||||
volumes:
|
||||
- ../../storyforge/web/storyforge-web-v4:/usr/share/nginx/html:ro
|
||||
- ./storyforge-fnos-web-v4.nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
@@ -1,27 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /assets/storyforge-runtime-config.js {
|
||||
add_header Cache-Control "no-store";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
add_header Cache-Control "no-store";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header Cache-Control "no-store";
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name storyforge.hyzq.net;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
default_type text/plain;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name storyforge.hyzq.net;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/storyforge.hyzq.net/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/storyforge.hyzq.net/privkey.pem;
|
||||
|
||||
location = /healthz {
|
||||
auth_basic off;
|
||||
proxy_pass http://127.0.0.1:8081/healthz;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /openapi.json {
|
||||
auth_basic off;
|
||||
proxy_pass http://127.0.0.1:8081/openapi.json;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /api/v1/app/update/latest {
|
||||
auth_basic off;
|
||||
proxy_pass http://127.0.0.1:8081/api/v1/app/update/latest;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ^~ /downloads/ {
|
||||
auth_basic off;
|
||||
proxy_pass http://127.0.0.1:8081/downloads/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
location ^~ /v2/ {
|
||||
auth_basic off;
|
||||
proxy_pass http://127.0.0.1:8081/v2/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location ^~ /docs {
|
||||
auth_basic off;
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location ^~ /redoc {
|
||||
auth_basic off;
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location / {
|
||||
auth_basic off;
|
||||
proxy_pass http://127.0.0.1:19191/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
services:
|
||||
storyforge-huobao:
|
||||
image: ${STORYFORGE_HUOBAO_IMAGE:-storyforge-huobao:cloud}
|
||||
build:
|
||||
context: ../../huobao-drama-source
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
DOCKER_REGISTRY: ${STORYFORGE_HUOBAO_DOCKER_REGISTRY:-docker.m.daocloud.io/library/}
|
||||
NPM_REGISTRY: ${STORYFORGE_HUOBAO_NPM_REGISTRY:-https://registry.npmmirror.com}
|
||||
GO_PROXY: ${STORYFORGE_HUOBAO_GO_PROXY:-https://goproxy.cn,direct}
|
||||
ALPINE_MIRROR: ${STORYFORGE_HUOBAO_ALPINE_MIRROR:-mirrors.aliyun.com}
|
||||
container_name: storyforge-huobao
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${STORYFORGE_HUOBAO_PORT:-127.0.0.1:25678:5678}"
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
volumes:
|
||||
- "${STORYFORGE_HUOBAO_STATE_ROOT:-/home/ubuntu/storyforge/data/huobao}/data:/app/data"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5678/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
@@ -1,27 +0,0 @@
|
||||
services:
|
||||
storyforge-n8n:
|
||||
image: ${STORYFORGE_N8N_IMAGE:-docker.m.daocloud.io/n8nio/n8n:latest}
|
||||
container_name: storyforge-n8n
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${STORYFORGE_N8N_PORT:-127.0.0.1:25670:5678}"
|
||||
environment:
|
||||
N8N_HOST: ${N8N_HOST:-0.0.0.0}
|
||||
N8N_PORT: 5678
|
||||
N8N_PROTOCOL: ${N8N_PROTOCOL:-https}
|
||||
WEBHOOK_URL: ${WEBHOOK_URL:-https://storyforge.hyzq.net/}
|
||||
STORYFORGE_INTERNAL_BASE_URL: ${STORYFORGE_INTERNAL_BASE_URL:-http://127.0.0.1:8081}
|
||||
STORYFORGE_ORCHESTRATOR_SECRET: ${ORCHESTRATOR_SHARED_SECRET:-storyforge-local-secret}
|
||||
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}
|
||||
volumes:
|
||||
- "${STORYFORGE_N8N_STATE_ROOT:-/home/ubuntu/storyforge/data/n8n}:/home/node/.n8n"
|
||||
- "${STORYFORGE_N8N_WORKFLOW_ROOT:-/home/ubuntu/storyforge/n8n}:/workspace/n8n:ro"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5678/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
@@ -1,14 +0,0 @@
|
||||
[Unit]
|
||||
Description=StoryForge Web V4 Static Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=ubuntu
|
||||
Group=ubuntu
|
||||
WorkingDirectory=/home/ubuntu/storyforge/web/storyforge-web-v4
|
||||
ExecStart=/usr/bin/python3 -m http.server 19191 --bind 127.0.0.1 --directory /home/ubuntu/storyforge/web/storyforge-web-v4
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,207 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import sysconfig
|
||||
import tempfile
|
||||
import time
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, File, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
MODEL_NAME = os.getenv("WHISPER_MODEL", "base")
|
||||
BEAM_SIZE = int(os.getenv("WHISPER_BEAM_SIZE", "5"))
|
||||
VAD_FILTER = os.getenv("WHISPER_VAD_FILTER", "1").strip().lower() not in {"0", "false", "no"}
|
||||
DOWNLOAD_ROOT = Path(os.getenv("WHISPER_DOWNLOAD_ROOT", str(Path(__file__).resolve().parent / "models-cache")))
|
||||
|
||||
app = FastAPI(title="storyforge-windows-asr", version="1.0.0")
|
||||
_dll_handles: list[object] = []
|
||||
|
||||
|
||||
def describe_language_mode() -> str:
|
||||
value = (os.getenv("WHISPER_LANGUAGE", "") or "").strip()
|
||||
if not value or value.lower() in {"auto", "detect"}:
|
||||
return "auto"
|
||||
return value
|
||||
|
||||
|
||||
def resolve_language() -> str | None:
|
||||
value = describe_language_mode()
|
||||
return None if value == "auto" else value
|
||||
|
||||
|
||||
def describe_device_mode() -> str:
|
||||
value = (os.getenv("WHISPER_DEVICE", "") or "").strip().lower()
|
||||
return value or "auto"
|
||||
|
||||
|
||||
def describe_compute_mode() -> str:
|
||||
value = (os.getenv("WHISPER_COMPUTE_TYPE", "") or "").strip()
|
||||
return value or "auto"
|
||||
|
||||
|
||||
def build_runtime_profiles() -> list[tuple[str, str]]:
|
||||
fallback_profile = getattr(app.state, "runtime_fallback_profile", None)
|
||||
if fallback_profile:
|
||||
return [fallback_profile]
|
||||
device = describe_device_mode()
|
||||
compute = describe_compute_mode()
|
||||
if device != "auto":
|
||||
return [(device, compute if compute != "auto" else "int8")]
|
||||
if compute != "auto":
|
||||
return [("cuda", compute), ("cpu", compute)]
|
||||
return [("cuda", "int8_float16"), ("cpu", "int8")]
|
||||
|
||||
|
||||
def should_retry_on_cpu(exc: Exception) -> bool:
|
||||
if describe_device_mode() != "auto":
|
||||
return False
|
||||
message = str(exc).lower()
|
||||
return any(token in message for token in ("cublas", "cudnn", "cuda"))
|
||||
|
||||
|
||||
def activate_cpu_fallback() -> None:
|
||||
app.state.runtime_fallback_profile = ("cpu", "int8")
|
||||
app.state.runtime_device = "cpu"
|
||||
app.state.runtime_compute_type = "int8"
|
||||
get_model.cache_clear()
|
||||
|
||||
|
||||
def find_windows_cuda_runtime_dirs(site_packages_root: Path | None = None) -> list[Path]:
|
||||
root = site_packages_root or Path(sysconfig.get_paths()["purelib"])
|
||||
dirs = []
|
||||
for rel in (
|
||||
"nvidia/cublas/bin",
|
||||
"nvidia/cuda_runtime/bin",
|
||||
"nvidia/cuda_nvrtc/bin",
|
||||
"nvidia/cudnn/bin",
|
||||
):
|
||||
path = root / rel
|
||||
if path.exists():
|
||||
dirs.append(path)
|
||||
return dirs
|
||||
|
||||
|
||||
def configure_windows_cuda_runtime() -> None:
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
configured = getattr(app.state, "windows_cuda_runtime_dirs", None)
|
||||
if configured is not None:
|
||||
return
|
||||
runtime_dirs = find_windows_cuda_runtime_dirs()
|
||||
app.state.windows_cuda_runtime_dirs = [str(path) for path in runtime_dirs]
|
||||
if not runtime_dirs:
|
||||
return
|
||||
path_parts = os.environ.get("PATH", "").split(os.pathsep)
|
||||
for runtime_dir in runtime_dirs:
|
||||
runtime_dir_str = str(runtime_dir)
|
||||
if runtime_dir_str not in path_parts:
|
||||
path_parts.insert(0, runtime_dir_str)
|
||||
if hasattr(os, "add_dll_directory"):
|
||||
_dll_handles.append(os.add_dll_directory(runtime_dir_str))
|
||||
os.environ["PATH"] = os.pathsep.join(path_parts)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_model():
|
||||
configure_windows_cuda_runtime()
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
DOWNLOAD_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
last_error: Exception | None = None
|
||||
for device, compute_type in build_runtime_profiles():
|
||||
try:
|
||||
model = WhisperModel(
|
||||
MODEL_NAME,
|
||||
device=device,
|
||||
compute_type=compute_type,
|
||||
download_root=str(DOWNLOAD_ROOT),
|
||||
)
|
||||
app.state.runtime_device = device
|
||||
app.state.runtime_compute_type = compute_type
|
||||
return model
|
||||
except Exception as exc: # pragma: no cover - exercised on real hosts
|
||||
last_error = exc
|
||||
assert last_error is not None
|
||||
raise last_error
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, object]:
|
||||
configure_windows_cuda_runtime()
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": "storyforge-windows-asr",
|
||||
"model_name": MODEL_NAME,
|
||||
"language": describe_language_mode(),
|
||||
"device": describe_device_mode(),
|
||||
"compute_type": describe_compute_mode(),
|
||||
"active_device": getattr(app.state, "runtime_device", ""),
|
||||
"active_compute_type": getattr(app.state, "runtime_compute_type", ""),
|
||||
"download_root": str(DOWNLOAD_ROOT),
|
||||
"model_loaded": get_model.cache_info().currsize > 0,
|
||||
"windows_cuda_runtime_dirs": getattr(app.state, "windows_cuda_runtime_dirs", []),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root() -> dict[str, str]:
|
||||
return {"service": "storyforge-windows-asr", "docs": "/docs"}
|
||||
|
||||
|
||||
def transcribe_file(temp_path: Path, started: float) -> dict[str, object]:
|
||||
model = get_model()
|
||||
try:
|
||||
segments, info = model.transcribe(
|
||||
str(temp_path),
|
||||
language=resolve_language(),
|
||||
beam_size=max(1, BEAM_SIZE),
|
||||
vad_filter=VAD_FILTER,
|
||||
)
|
||||
except Exception as exc:
|
||||
if not should_retry_on_cpu(exc):
|
||||
raise
|
||||
activate_cpu_fallback()
|
||||
model = get_model()
|
||||
segments, info = model.transcribe(
|
||||
str(temp_path),
|
||||
language=resolve_language(),
|
||||
beam_size=max(1, BEAM_SIZE),
|
||||
vad_filter=VAD_FILTER,
|
||||
)
|
||||
text = "".join(segment.text for segment in segments).strip()
|
||||
duration_ms = int((time.perf_counter() - started) * 1000)
|
||||
return {
|
||||
"text": text,
|
||||
"success": bool(text),
|
||||
"duration_ms": duration_ms,
|
||||
"detected_language": getattr(info, "language", None),
|
||||
"detected_language_probability": getattr(info, "language_probability", None),
|
||||
"error_message": None if text else "empty transcription",
|
||||
}
|
||||
|
||||
|
||||
@app.post("/transcribe", response_model=None)
|
||||
async def transcribe(wav: UploadFile = File(...)):
|
||||
started = time.perf_counter()
|
||||
suffix = Path(wav.filename or "segment.wav").suffix or ".wav"
|
||||
with tempfile.NamedTemporaryFile(prefix="storyforge-asr-", suffix=suffix, delete=False) as handle:
|
||||
temp_path = Path(handle.name)
|
||||
handle.write(await wav.read())
|
||||
|
||||
try:
|
||||
return transcribe_file(temp_path, started)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"text": "",
|
||||
"success": False,
|
||||
"duration_ms": int((time.perf_counter() - started) * 1000),
|
||||
"error_message": str(exc),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
@@ -1,19 +0,0 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$serverHost = if ($env:STORYFORGE_CLOUD_HOST) { $env:STORYFORGE_CLOUD_HOST } else { "111.231.132.51" }
|
||||
$serverUser = if ($env:STORYFORGE_CLOUD_USER) { $env:STORYFORGE_CLOUD_USER } else { "ubuntu" }
|
||||
$localPort = if ($env:STORYFORGE_ASR_LOCAL_PORT) { $env:STORYFORGE_ASR_LOCAL_PORT } else { "8088" }
|
||||
$remotePort = if ($env:STORYFORGE_ASR_REMOTE_PORT) { $env:STORYFORGE_ASR_REMOTE_PORT } else { "28088" }
|
||||
$identity = if ($env:STORYFORGE_CLOUD_IDENTITY) { $env:STORYFORGE_CLOUD_IDENTITY } else { (Join-Path $env:USERPROFILE ".ssh\storyforge_cloud_bridge_ed25519") }
|
||||
|
||||
$sshArgs = @(
|
||||
"-N",
|
||||
"-i", $identity,
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "ServerAliveInterval=30",
|
||||
"-o", "ServerAliveCountMax=3",
|
||||
"-R", "127.0.0.1:$remotePort`:127.0.0.1:$localPort",
|
||||
"$serverUser@$serverHost"
|
||||
)
|
||||
|
||||
& ssh.exe @sshArgs
|
||||
@@ -1,14 +0,0 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$runScript = Join-Path $scriptDir "run.ps1"
|
||||
|
||||
$existing = Get-NetTCPConnection -State Listen -LocalPort 8088 -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
Start-Process -FilePath "powershell.exe" `
|
||||
-ArgumentList @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $runScript) `
|
||||
-WorkingDirectory $scriptDir `
|
||||
-WindowStyle Hidden
|
||||
@@ -1,22 +0,0 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$runScript = Join-Path $scriptDir "run.ps1"
|
||||
$launchAsrScript = Join-Path $scriptDir "launch-asr.ps1"
|
||||
$bridgeScript = Join-Path $scriptDir "bridge-cloud.ps1"
|
||||
|
||||
$tasks = @(
|
||||
@{
|
||||
Name = "StoryForgeWindowsAsr"
|
||||
Script = $launchAsrScript
|
||||
},
|
||||
@{
|
||||
Name = "StoryForgeWindowsAsrCloudBridge"
|
||||
Script = $bridgeScript
|
||||
}
|
||||
)
|
||||
|
||||
foreach ($task in $tasks) {
|
||||
schtasks /Create /F /SC ONLOGON /RL HIGHEST /TN $task.Name /TR "powershell.exe -NoProfile -ExecutionPolicy Bypass -File `"$($task.Script)`""
|
||||
schtasks /Run /TN $task.Name
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
fastapi==0.116.1
|
||||
uvicorn[standard]==0.35.0
|
||||
python-multipart==0.0.20
|
||||
faster-whisper>=1.1,<2
|
||||
nvidia-cublas-cu12; platform_system == "Windows"
|
||||
nvidia-cuda-runtime-cu12; platform_system == "Windows"
|
||||
nvidia-cudnn-cu12; platform_system == "Windows"
|
||||
@@ -1,28 +0,0 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$venvDir = Join-Path $scriptDir ".venv"
|
||||
$python = "py -3.11"
|
||||
|
||||
if (-not (Test-Path $venvDir)) {
|
||||
Invoke-Expression "$python -m venv `"$venvDir`""
|
||||
}
|
||||
|
||||
$venvPython = Join-Path $venvDir "Scripts\python.exe"
|
||||
& $venvPython -m pip install --upgrade pip
|
||||
& $venvPython -m pip install -r (Join-Path $scriptDir "requirements.txt")
|
||||
|
||||
$env:WHISPER_MODEL = if ($env:WHISPER_MODEL) { $env:WHISPER_MODEL } else { "base" }
|
||||
$env:WHISPER_LANGUAGE = if ($env:WHISPER_LANGUAGE) { $env:WHISPER_LANGUAGE } else { "" }
|
||||
$env:WHISPER_DEVICE = if ($env:WHISPER_DEVICE) { $env:WHISPER_DEVICE } else { "auto" }
|
||||
$env:WHISPER_COMPUTE_TYPE = if ($env:WHISPER_COMPUTE_TYPE) { $env:WHISPER_COMPUTE_TYPE } else { "" }
|
||||
$env:WHISPER_BEAM_SIZE = if ($env:WHISPER_BEAM_SIZE) { $env:WHISPER_BEAM_SIZE } else { "5" }
|
||||
$env:WHISPER_VAD_FILTER = if ($env:WHISPER_VAD_FILTER) { $env:WHISPER_VAD_FILTER } else { "1" }
|
||||
$env:WHISPER_DOWNLOAD_ROOT = if ($env:WHISPER_DOWNLOAD_ROOT) { $env:WHISPER_DOWNLOAD_ROOT } else { (Join-Path $scriptDir "models-cache") }
|
||||
|
||||
Push-Location $scriptDir
|
||||
try {
|
||||
& $venvPython -m uvicorn app:app --host 0.0.0.0 --port 8088
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -1,83 +1,101 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
n8n:
|
||||
image: ${N8N_IMAGE:-docker.n8n.io/n8nio/n8n:latest}
|
||||
container_name: storyforge-n8n
|
||||
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
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
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/}
|
||||
STORYFORGE_INTERNAL_BASE_URL: ${STORYFORGE_INTERNAL_BASE_URL:-http://collector:8081}
|
||||
STORYFORGE_ORCHESTRATOR_SECRET: ${ORCHESTRATOR_SHARED_SECRET:-__set_a_strong_shared_secret__}
|
||||
GENERIC_TIMEZONE: ${GENERIC_TIMEZONE:-Asia/Shanghai}
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
N8N_SECURE_COOKIE: ${N8N_SECURE_COOKIE:-false}
|
||||
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: ${N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS:-false}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-fastgpt}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
ports:
|
||||
- "5670:5678"
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- ./data/n8n:/home/node/.n8n
|
||||
- ./n8n:/workspace/n8n:ro
|
||||
- ./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
|
||||
|
||||
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
|
||||
DEFAULT_EXTERNAL_BASE_URL: ${DEFAULT_EXTERNAL_BASE_URL:-https://storyforge.hyzq.net}
|
||||
DEFAULT_EXTERNAL_BASE_URL: ${DEFAULT_EXTERNAL_BASE_URL:-https://test.hyzq.net/storyforge}
|
||||
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:-}
|
||||
N8N_BASE_URL: ${COLLECTOR_N8N_BASE_URL:-http://n8n:5678}
|
||||
N8N_ANALYSIS_WEBHOOK_PATH: ${N8N_ANALYSIS_WEBHOOK_PATH:-/webhook/storyforge-analysis}
|
||||
N8N_REAL_CUT_WEBHOOK_PATH: ${N8N_REAL_CUT_WEBHOOK_PATH:-/webhook/storyforge-real-cut}
|
||||
N8N_AI_VIDEO_WEBHOOK_PATH: ${N8N_AI_VIDEO_WEBHOOK_PATH:-/webhook/storyforge-ai-video}
|
||||
N8N_CONTENT_SOURCE_SYNC_WEBHOOK_PATH: ${N8N_CONTENT_SOURCE_SYNC_WEBHOOK_PATH:-/webhook/storyforge-content-source-sync}
|
||||
BOOTSTRAP_SUPERADMIN_USERNAME: ${BOOTSTRAP_SUPERADMIN_USERNAME:-}
|
||||
BOOTSTRAP_SUPERADMIN_PASSWORD: ${BOOTSTRAP_SUPERADMIN_PASSWORD:-}
|
||||
BOOTSTRAP_SUPERADMIN_DISPLAY_NAME: ${BOOTSTRAP_SUPERADMIN_DISPLAY_NAME:-StoryForge Admin}
|
||||
ORCHESTRATOR_SHARED_SECRET: ${ORCHESTRATOR_SHARED_SECRET:-__set_a_strong_shared_secret__}
|
||||
CUTVIDEO_BASE_URL: ${CUTVIDEO_BASE_URL:-}
|
||||
CUTVIDEO_API_KEY: ${CUTVIDEO_API_KEY:-}
|
||||
CUTVIDEO_BASE_CONFIG: ${CUTVIDEO_BASE_CONFIG:-example.job.yaml}
|
||||
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}
|
||||
FASTGPT_BASE_URL: ${FASTGPT_BASE_URL:-http://host.docker.internal:3000}
|
||||
FASTGPT_DATASET_API_KEY: ${FASTGPT_DATASET_API_KEY:-}
|
||||
YTDLP_BIN: ${YTDLP_BIN:-yt-dlp}
|
||||
FFMPEG_BIN: ${FFMPEG_BIN:-ffmpeg}
|
||||
WHISPER_BIN: ${WHISPER_BIN:-}
|
||||
WHISPER_MODEL: ${WHISPER_MODEL:-/data/collector/models/ggml-base.en.bin}
|
||||
ASR_HTTP_BASE_URL: ${ASR_HTTP_BASE_URL:-}
|
||||
ASR_HTTP_TRANSCRIBE_PATH: ${ASR_HTTP_TRANSCRIBE_PATH:-/transcribe}
|
||||
ASR_HTTP_FIELD_NAME: ${ASR_HTTP_FIELD_NAME:-wav}
|
||||
ASR_HTTP_TIMEOUT_SEC: ${ASR_HTTP_TIMEOUT_SEC:-120}
|
||||
HUOBAO_POLL_INTERVAL_SEC: ${HUOBAO_POLL_INTERVAL_SEC:-10}
|
||||
HUOBAO_MAX_WAIT_SEC: ${HUOBAO_MAX_WAIT_SEC:-900}
|
||||
ports:
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
- ./data/collector:/data/collector
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8081
|
||||
|
||||
fastgpt:
|
||||
image: ghcr.io/labring/fastgpt:latest
|
||||
container_name: storyforge-fastgpt
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- mongo
|
||||
- vectorDB
|
||||
- redis
|
||||
- minio
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
sandbox:
|
||||
image: ghcr.io/labring/fastgpt-sandbox:latest
|
||||
container_name: storyforge-sandbox
|
||||
restart: unless-stopped
|
||||
|
||||
fastgpt-plugin:
|
||||
image: ghcr.io/labring/fastgpt-plugin:latest
|
||||
container_name: storyforge-fastgpt-plugin
|
||||
restart: unless-stopped
|
||||
|
||||
cli-proxy-api:
|
||||
image: ${CLIPROXY_IMAGE:-storyforge/cli-proxy-api:patched}
|
||||
container_name: storyforge-cliproxyapi
|
||||
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:
|
||||
- "8317:8317"
|
||||
- "8085:8085"
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
# StoryForge 现状审计
|
||||
|
||||
日期:2026-03-18
|
||||
更新:2026-03-20
|
||||
|
||||
## 结论
|
||||
|
||||
当前应以 `/Users/kris/code/StoryForge-gitea` 作为主工作区继续推进,而不是历史旧导入目录。后者更像一次不完整的导入快照,前者才是可持续开发的真实仓库。
|
||||
|
||||
## 现有功能归位
|
||||
|
||||
### 1. `collector-service` 之前承担的功能
|
||||
|
||||
- 账号注册、登录、审批
|
||||
- 本地模型配置
|
||||
- 知识库、智能体、任务管理
|
||||
- 视频链接/上传视频/文本三类入口
|
||||
- 下载器、ffmpeg、whisper.cpp 风格的本地处理调用
|
||||
|
||||
### 2. 旧数据集运行链实际承担的功能
|
||||
|
||||
- 仅承担“数据集/文档同步”的外部依赖角色
|
||||
- 代码痕迹集中在:
|
||||
- `collector-service/app/fastgpt.py`
|
||||
- `docker-compose.yml`
|
||||
- 若干 `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。
|
||||
|
||||
## 当前已完成迁移面
|
||||
|
||||
- 旧运行链依赖已从 `collector-service` 主代码中剥离
|
||||
- 旧运行残留容器 `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` 自身源码构建,不再依赖旧导入目录的临时 bind mount
|
||||
- `collector-service` 现已在 live `8081` 提供 `/v2/douyin/*` 接口,并保留原有 `real-cut / ai-video / content-source-sync` 路由
|
||||
- 曾混入本仓库的 `android-app/` 已确认来自独立 `AI Glasses` 工程叠加,现已从 StoryForge 主仓库边界中拆出,后续不再作为当前主工作区的一部分维护
|
||||
|
||||
## 当前主要风险
|
||||
|
||||
1. 小红书账号级内容源还未做真实平台验证
|
||||
2. `douyin` public 直抓仍受反爬限制,但现在已经有“真实浏览器 + 人工登录 + 自动提取 + 回写现有工作台”的可落地协作链
|
||||
3. `huobao-drama-upstream` 已完成代码迁移并可编译,但 fresh smoke 受外部图片/视频凭证 `403 invalid user` 阻塞
|
||||
4. Android / OTA 旧链路已拆出当前仓库,相关验证和发布不再属于 StoryForge 主线范围
|
||||
@@ -1,86 +0,0 @@
|
||||
# StoryForge 当前项目状态
|
||||
|
||||
日期:2026-03-26
|
||||
|
||||
本文档用于固定当前 `StoryForge-gitea` 的真实维护范围、主运行链和继续开发基线。
|
||||
|
||||
## 当前项目边界
|
||||
|
||||
- 当前仓库只维护 `StoryForge`。
|
||||
- `AI Glasses` 已拆回独立仓库维护,不再属于当前仓库主线。
|
||||
- 当前仓库主维护目录:
|
||||
- `collector-service/`
|
||||
- `web/storyforge-web-v4/`
|
||||
- `scripts/douyin-browser-capture/`
|
||||
- `n8n/`
|
||||
- `deploy/`
|
||||
- `docs/`
|
||||
|
||||
## 当前产品主线
|
||||
|
||||
- `collector-service`:FastAPI 主状态中心,承接登录、项目、Agent、内容源、任务、平台工作台与内部执行接口。
|
||||
- `web/storyforge-web-v4`:当前正式业务 Web 壳,面向日常运营工作台。
|
||||
- `n8n`:分析、内容源同步、AI 视频、实拍剪辑编排工作流。
|
||||
- `scripts/douyin-browser-capture`:抖音真实浏览器辅助采集工具,作为反爬环境下的兜底采集入口。
|
||||
|
||||
## 当前已经接通的主要能力
|
||||
|
||||
- 多用户与审批体系。
|
||||
- `project / assistant / knowledge base / job / content source` 主数据模型。
|
||||
- 文本、视频链接、上传视频分析。
|
||||
- `n8n` 工作流触发与任务编排。
|
||||
- 本地 ASR、本机模型、Windows `cutvideo`、本机 `huobao-drama` 的后端接入。
|
||||
- Web 工作台已经承接:
|
||||
- 项目总台
|
||||
- 对标导入
|
||||
- 多平台账号工作台
|
||||
- 跟踪账号与日报
|
||||
- Agent 控制面
|
||||
- 生产中心
|
||||
- 复盘
|
||||
- 额度与运维面板
|
||||
- 自动建会话连接
|
||||
|
||||
## 当前量产基线
|
||||
|
||||
- SQLite 已默认启用 `WAL`、`busy_timeout`、`synchronous=NORMAL`、`foreign_keys=ON` 等连接参数,减少并发写入时的锁冲突。
|
||||
- `tenant_quota_profiles` 与 `tenant_usage_ledger` 已接入核心生产链,`explore/*`、`content-source-sync`、`reviews`、`real-cut`、`ai-video`、`assistants/{id}/generate`、`live-recorder create` 都会先做额度硬拦截,再记账。
|
||||
- `jobs` 已补 `retry / requeue` 单任务入口,以及管理员批量重试失败任务入口,便于失败链路恢复。
|
||||
- 仓库内已新增 SQLite 备份脚本,可在发布或故障前快速生成一致性快照。
|
||||
- Web 前端已改成固定后端自动建会话模式,不再要求用户手动输入账号密码;是否启用由服务端 `WEB_AUTOLOGIN_*` 环境变量控制,推荐直接用 `WEB_AUTOLOGIN_ACCOUNT_USERNAME` 绑定现有已审批账号。
|
||||
|
||||
## 当前支持的平台
|
||||
|
||||
- `douyin`
|
||||
- `xiaohongshu`
|
||||
- `bilibili`
|
||||
- `kuaishou`
|
||||
- `wechat_video`
|
||||
|
||||
说明:
|
||||
|
||||
- Web V4 当前已经按统一工作台模型接上以上平台的账号列表、单账号详情、作品列表、账号分析、高分作品分析、相似账号搜索、对标关系、跟踪账号与日报入口。
|
||||
- 其中 `douyin` 仍然是采集与验证最完整的平台。
|
||||
- 其余国内平台的工作台接口已由 `collector-service` 正式挂载,前端也已切成统一可用工作台;但真实平台采集质量仍取决于后续各平台专项验证。
|
||||
|
||||
## 当前仍受外部依赖限制的项
|
||||
|
||||
- 抖音 public 页直抓仍可能触发反爬挑战,需要真实浏览器登录或手工页面辅助采集。
|
||||
- 小红书账号级内容源还需要补真实平台验证。
|
||||
- `huobao-drama` fresh 生成仍依赖可用的外部图片 / 视频凭证;仓库代码已预留 env 覆盖能力,但没有新 key 时无法靠本仓库单独打通。
|
||||
|
||||
## 当前公网部署目标
|
||||
|
||||
- 公网入口:`https://storyforge.hyzq.net/`
|
||||
- 云服务器 `nginx` 提供 HTTPS 入口。
|
||||
- 云服务器本地 `storyforge-web-v4.service` 承接静态前端。
|
||||
- 云服务器本地 `collector-service` 承接 `/v2/*`、`/openapi.json`、`/healthz`、`/downloads/*`。
|
||||
- `n8n / huobao / cutvideo / 本机模型 / ASR / 录制链路` 继续通过本机和局域网桥接提供。
|
||||
|
||||
## 后续开发建议基线
|
||||
|
||||
1. 继续按当前仓库边界维护,不再把 `AI Glasses` 代码重新叠进来。
|
||||
2. Web 功能优先围绕多平台工作台、生产中心和租户控制面继续深化。
|
||||
3. 需要真实平台验证的事项,单独作为联调任务推进,不再和仓库边界治理混在一起。
|
||||
4. 生产基线任务优先按“任务恢复、额度硬控、数据库备份、观测补齐”继续深化。
|
||||
5. 公网环境出现异常时,先检查云服务器上的 `nginx / storyforge-web-v4.service / collector-service`,再检查本机桥接链。
|
||||
@@ -1,130 +0,0 @@
|
||||
# StoryForge fnOS / NAS LAN Delivery Runbook
|
||||
|
||||
日期:2026-03-27
|
||||
|
||||
## 目标
|
||||
|
||||
这份 runbook 统一说明 StoryForge 在 fnOS / NAS 局域网交付时的默认主链。
|
||||
|
||||
默认原则只有一条:NAS SSH 隧道是主链,Windows `7860` 只做自检。
|
||||
|
||||
## 默认链路
|
||||
|
||||
1. 先把 Windows `cutvideo` 通过 fnOS 的 SSH 隧道暴露到 NAS。
|
||||
2. 再让 StoryForge 的 NAS 侧服务统一指向 NAS 隧道地址。
|
||||
3. 最后用一键 smoke 验证整条链路是否可用。
|
||||
|
||||
推荐默认顺序:
|
||||
|
||||
```bash
|
||||
./scripts/deploy_fnos_cutvideo_tunnel.sh
|
||||
./scripts/deploy_fnos_storyforge_lan_stack.sh
|
||||
./scripts/smoke_fnos_storyforge_lan.sh
|
||||
```
|
||||
|
||||
## 默认端口
|
||||
|
||||
- Windows `cutvideo` 自检口:`http://192.168.31.18:7860`
|
||||
- NAS 主链 `cutvideo` 入口:`http://192.168.31.188:19186`
|
||||
- NAS 兼容/上传入口:`http://192.168.31.188:19181`
|
||||
- StoryForge collector:`http://127.0.0.1:8081`
|
||||
- fnOS 内部 n8n:`http://127.0.0.1:5670`
|
||||
|
||||
## 默认路由
|
||||
|
||||
- StoryForge 的 `CUTVIDEO_BASE_URL` 默认应指向 `http://192.168.31.188:19186`
|
||||
- `19186` 是交付主链,不要再把 `7860` 当成 StoryForge 默认主入口
|
||||
- `7860` 仅用于确认 Windows 上的 `cutvideo` 服务本身是否活着
|
||||
- 如果任务涉及上传或 staging,再顺带确认 `19181` 可达
|
||||
|
||||
## 重启后验证
|
||||
|
||||
### Windows 重启后
|
||||
|
||||
- 先确认 `22 / 3389 / 5985` 仍可达
|
||||
- 再检查 `http://192.168.31.18:7860/api/bootstrap`
|
||||
- 如果 `7860` 超时,但管理通道正常,优先判断为 `cutvideo` 服务未起来
|
||||
- 如果 `7860` 可达,再确认 Windows 任务计划程序 `\Codex\cutvideo-web` 仍在托管服务
|
||||
|
||||
### fnOS 重启后
|
||||
|
||||
- 先跑 `./scripts/deploy_fnos_cutvideo_tunnel.sh`
|
||||
- 再跑 `./scripts/deploy_fnos_storyforge_lan_stack.sh`
|
||||
- 确认 `19186` 和 `19181` 都重新可达
|
||||
- 确认 StoryForge collector 仍然把 `CUTVIDEO_BASE_URL` 指向 `19186`
|
||||
|
||||
### StoryForge 服务重启后
|
||||
|
||||
- 检查 collector 还能正常返回 health
|
||||
- 检查 NAS 侧服务没有回退到 Windows 直连 `7860`
|
||||
- 检查 smoke 是否还能把 real-cut 链路跑通
|
||||
|
||||
## Smoke 命令
|
||||
|
||||
```bash
|
||||
./scripts/smoke_fnos_storyforge_lan.sh
|
||||
```
|
||||
|
||||
这条 smoke 应该至少覆盖:
|
||||
|
||||
- `19186` 可达
|
||||
- `19181` 可达
|
||||
- `cutvideo` 在线
|
||||
- StoryForge NAS 侧链路可用
|
||||
|
||||
## 故障分流
|
||||
|
||||
### 1. `19186` 不通
|
||||
|
||||
先看 fnOS 的 SSH 隧道是否还在:
|
||||
|
||||
- 重新执行 `./scripts/deploy_fnos_cutvideo_tunnel.sh`
|
||||
- 确认 Windows 主机可连
|
||||
- 再确认 Windows `7860` 本身是否正常
|
||||
|
||||
### 2. `7860` 不通,但 `22 / 3389 / 5985` 还通
|
||||
|
||||
这通常是 Windows 上的 `cutvideo` 没启动,不是网络地址失效。
|
||||
|
||||
优先检查:
|
||||
|
||||
- Windows 任务计划程序 `\Codex\cutvideo-web`
|
||||
- `D:\ai-code\cutvideo\.venv`
|
||||
- `http://192.168.31.18:7860/api/bootstrap`
|
||||
|
||||
### 3. `19186` 通,但 StoryForge 链路失败
|
||||
|
||||
说明隧道大概率是好的,问题更可能在 NAS 侧服务配置。
|
||||
|
||||
优先检查:
|
||||
|
||||
- `./scripts/deploy_fnos_storyforge_lan_stack.sh` 是否已重新跑过
|
||||
- `CUTVIDEO_BASE_URL` 是否仍然是 `http://192.168.31.188:19186`
|
||||
- collector 是否回退到了 Windows 直连 `7860`
|
||||
|
||||
### 4. `19186` 和 `7860` 都正常,但 smoke 失败
|
||||
|
||||
优先看失败点属于哪一层:
|
||||
|
||||
- 只是 `collector` health 失败,先看 NAS 侧服务
|
||||
- 只是上传失败,先看 `19181`
|
||||
- 只是 `cutvideo` 任务失败,先看 Windows 服务日志
|
||||
|
||||
### 5. Windows 或 fnOS 重启后出现“短时间都不通”
|
||||
|
||||
先按默认顺序重新跑:
|
||||
|
||||
```bash
|
||||
./scripts/deploy_fnos_cutvideo_tunnel.sh
|
||||
./scripts/deploy_fnos_storyforge_lan_stack.sh
|
||||
./scripts/smoke_fnos_storyforge_lan.sh
|
||||
```
|
||||
|
||||
如果这三步后仍失败,再进入对应故障分流。
|
||||
|
||||
## 维护原则
|
||||
|
||||
- 默认主链永远是 NAS SSH 隧道
|
||||
- Windows `7860` 只做自检,不做 StoryForge 默认入口
|
||||
- 交付时先保证 `19186` 稳,再谈其他端口
|
||||
- 新人接手时,先跑 smoke,再看详细日志
|
||||
@@ -1,97 +0,0 @@
|
||||
# StoryForge 分阶段实施计划
|
||||
|
||||
日期:2026-03-18
|
||||
|
||||
## Phase 0: 审计与基线收拢
|
||||
|
||||
- 确认主工作区
|
||||
- 识别旧数据集运行链的真实职责
|
||||
- 识别多用户、多项目需要的主数据模型
|
||||
- 对比 `huobao-drama` 旧改版与 upstream
|
||||
- 审计 `cutvideo` 接口能力
|
||||
|
||||
状态:已完成
|
||||
|
||||
## Phase 1: 业务后端改造成主状态中心
|
||||
|
||||
- 引入 `projects`
|
||||
- 引入 `content_sources`
|
||||
- 引入 `job_events`
|
||||
- 让 `knowledge_bases / assistants / jobs` 全部 project 化
|
||||
- 去掉 `collector-service` 中的旧运行链逻辑
|
||||
- 增加 `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: 删除旧运行链依赖
|
||||
|
||||
- 删除代码依赖
|
||||
- 删除 compose 服务
|
||||
- 删除环境变量
|
||||
- 删除 README 说明
|
||||
|
||||
状态:已完成主仓库首版
|
||||
|
||||
## Phase 7: 联调与验证
|
||||
|
||||
- Python 语法检查
|
||||
- Compose 配置检查
|
||||
- `collector-service` 本地启动
|
||||
- `n8n` workflow 导入
|
||||
- Windows `cutvideo` 局域网调度
|
||||
- `huobao-drama` 本机调用
|
||||
|
||||
状态:进行中
|
||||
@@ -1,348 +0,0 @@
|
||||
# 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=your_strong_shared_secret`
|
||||
- `BOOTSTRAP_SUPERADMIN_USERNAME=storyforge-admin`
|
||||
- `BOOTSTRAP_SUPERADMIN_PASSWORD=your_strong_admin_password`
|
||||
- `STORYFORGE_INTERNAL_BASE_URL=http://collector:8081`,用于 Docker 内的 n8n 回调 `collector`
|
||||
- `CUTVIDEO_BASE_URL=http://192.168.31.188:19186`,默认主链走 NAS SSH 隧道
|
||||
- `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 调度失败
|
||||
- 当前更稳定的 NAS 转发地址是 `http://192.168.31.188:19186`
|
||||
- Windows 直连地址 `http://192.168.31.18:7860` 仅用于主机内自检,不再建议作为 StoryForge 主链默认值
|
||||
- 只要是 StoryForge 的 fnOS / NAS 联调与交付,优先把 `CUTVIDEO_BASE_URL` 视为 `19186`,把 `7860` 视为 Windows 本机自检口
|
||||
- 当前已验证可用的本机 HTTP ASR 入口是 `http://host.docker.internal:8088/transcribe`
|
||||
- 如果你用的是本机 `mac-whisper-service`,建议同时以 `WHISPER_TIMEOUT_MS=120000` 启动,否则长视频会直接 504
|
||||
|
||||
推荐先执行:
|
||||
|
||||
```bash
|
||||
./scripts/deploy_fnos_cutvideo_tunnel.sh
|
||||
./scripts/deploy_fnos_storyforge_lan_stack.sh
|
||||
./scripts/smoke_fnos_storyforge_lan.sh
|
||||
```
|
||||
|
||||
如果你只想先把底座打通,也可以先跑前两步,再单独 smoke。
|
||||
|
||||
它们分别负责:
|
||||
|
||||
- 在 fnOS 上生成并持久化 Windows SSH 隧道密钥
|
||||
- 把 fnOS 公钥写入 Windows OpenSSH 管理员授权文件
|
||||
- 在 fnOS 上常驻 `19186 -> Windows 127.0.0.1:7860` 和 `19181 -> Windows 127.0.0.1:8081`,并写入 `@reboot` 自启动
|
||||
- 把 StoryForge 的 NAS 侧服务统一切到 `http://192.168.31.188:19186` 的默认主链
|
||||
- 通过一键 smoke 校验 `cutvideo`、`collector` 和整条 LAN 交付链路
|
||||
|
||||
`cutvideo` 维护补充(2026-03-27):
|
||||
|
||||
- 当前 Windows 主机 SSH 别名是 `shuziren-win`,对应 `192.168.31.18`
|
||||
- 如果 `http://192.168.31.18:7860/api/bootstrap` 超时,但主机 `22 / 3389 / 5985` 仍可达,优先判断为 `cutvideo` 服务未启动,不是局域网地址失效
|
||||
- 本次已确认的真实故障是 `D:\ai-code\cutvideo\.venv` 损坏,仍指向已不存在的 `Python311`
|
||||
- 修复后 `cutvideo` 已改由 Windows 任务计划程序 `\Codex\cutvideo-web` 托管,避免服务随 SSH 会话结束一起退出
|
||||
- 详细恢复步骤见 [`WINDOWS_CUTVIDEO_OPERATIONS_2026-03-27.md`](/Users/kris/code/StoryForge-gitea/docs/WINDOWS_CUTVIDEO_OPERATIONS_2026-03-27.md)
|
||||
|
||||
## 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`
|
||||
|
||||
导入后:
|
||||
|
||||
- 确认 n8n 运行环境里有 `STORYFORGE_INTERNAL_BASE_URL`
|
||||
- 确认 n8n 运行环境里有 `STORYFORGE_ORCHESTRATOR_SECRET`
|
||||
- 导入后的 HTTP Request 节点应从环境变量取值,不需要再逐个手改 secret
|
||||
|
||||
## 4. 登录与审批
|
||||
|
||||
首次启动前请先在 `.env` 或运行环境里设置 bootstrap 管理员:
|
||||
|
||||
- 用户名:`BOOTSTRAP_SUPERADMIN_USERNAME`
|
||||
- 密码:`BOOTSTRAP_SUPERADMIN_PASSWORD`
|
||||
|
||||
首次启动后,用这组账号登录;新用户注册后,仍然需要超级管理员审批。
|
||||
|
||||
## 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 storyforge-admin \
|
||||
--storyforge-password 'your_admin_password'
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 脚本会打开真实 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 联调样例`
|
||||
|
||||
补充说明(2026-03-27):
|
||||
|
||||
- `GET /api/bootstrap` 恢复为 `200`,`GET /api/uploads` 返回 `405 Method Not Allowed`
|
||||
- 上面的 `405` 是正常现象,说明上传接口存在且只接受 `POST`
|
||||
- `StoryForge collector` 的 `/v2/integrations/health` 已重新识别到 `cutvideo.reachable=true`、`supports_uploads=true`
|
||||
- fnOS 局域网调试链现在默认走 `http://192.168.31.188:19186`,Windows 机器直接开放 `7860` 仅保留为自检入口
|
||||
- 如果 UI 里 `自动剪辑` 再次掉线,先按 [`WINDOWS_CUTVIDEO_OPERATIONS_2026-03-27.md`](/Users/kris/code/StoryForge-gitea/docs/WINDOWS_CUTVIDEO_OPERATIONS_2026-03-27.md) 检查 Windows 任务计划程序和 `.venv`
|
||||
|
||||
## 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. 旧运行链残留清理
|
||||
|
||||
- 旧运行链残留容器已在 2026-03-20 实际清理完成:
|
||||
- `storyforge-fastgpt-plugin`
|
||||
- `storyforge-sandbox`
|
||||
- `storyforge-pg`
|
||||
- `storyforge-minio`
|
||||
- `storyforge-redis`
|
||||
- `storyforge-mongo`
|
||||
- 清理脚本已纳入仓库:
|
||||
- `/Users/kris/code/StoryForge-gitea/deploy/cleanup_legacy_runtime.sh`
|
||||
- 脚本会在清理前后校验:
|
||||
- `http://127.0.0.1:8081/healthz`
|
||||
- `http://127.0.0.1:5670/healthz`
|
||||
|
||||
## 11. Android 说明
|
||||
|
||||
`android-app/` 已确认属于独立 `AI Glasses` 工程的叠加目录,现已从当前 StoryForge 主仓库拆出。
|
||||
|
||||
当前联调范围只包含:
|
||||
|
||||
- `collector-service`
|
||||
- `n8n`
|
||||
- `web/storyforge-web-v4`
|
||||
- `scripts/douyin-browser-capture`
|
||||
|
||||
如果后续需要维护 Android / OTA 链路,请转到独立仓库:
|
||||
|
||||
- Gitea:`https://git.hyzq.site/krisolo/ai-glasses`
|
||||
- 本机工作区:`/Users/kris/code/AI-glasses`
|
||||
@@ -1,70 +0,0 @@
|
||||
# StoryForge MVP 状态
|
||||
|
||||
日期:2026-03-18
|
||||
更新:2026-03-26
|
||||
|
||||
## 已跑通或已完成代码接通
|
||||
|
||||
- 多用户账号体系
|
||||
- 审批机制
|
||||
- `user -> project -> assistant / knowledge base / job / content source` 数据模型
|
||||
- 文本 / 视频链接 / 上传视频 三类分析任务创建
|
||||
- 内容源账号同步任务创建与子任务派发
|
||||
- `n8n` 工作流导入、激活与触发接口
|
||||
- 本地下载器调用
|
||||
- 本地 `ffmpeg` / `whisper` 风格入口封装
|
||||
- HTTP ASR 常驻服务入口绑定
|
||||
- 本地大模型内容分析、二创文案、分镜生成
|
||||
- Windows `cutvideo` API 调度与结果回写接口
|
||||
- `upload_video -> source_job_id -> cutvideo` 自动 staging 闭环
|
||||
- `collector` live 运行态已从临时源码挂载切回 `StoryForge-gitea` 正式镜像
|
||||
- live `collector` 已挂出 `/v2/douyin/*` 能力并通过认证接口验证
|
||||
- 多平台工作台响应契约已对齐,`domestic_platform_features.py` 统一补出 `latest_public_snapshot`、`latest_creator_snapshot`、`recent_reports`、`recent_similarity_searches`、`available_model_profiles` 和更一致的 tracking digest envelope
|
||||
- `douyin` tracking digest 已补齐 `generated_at` / `since` 等与多平台一致的包裹字段,便于前端统一消费
|
||||
- `collector-service/app/main.py` 已收口到源码主线,不再保留 `legacy_runtime` fallback
|
||||
- 已删除未接入主应用的旧 `xiaohongshu_features / bilibili_features / kuaishou_features / wechat_video_features / legacy_runtime` 残留模块,后端只保留当前 live 主线
|
||||
- `scripts/smoke_business.sh` 已扩展为多平台最小 smoke,可同时验证 `douyin / xiaohongshu / bilibili / kuaishou / wechat_video` 的列表、workspace 和 tracking digest 形状
|
||||
- `douyin` 支持从分享文案中提取 `profile_url`,并在 public 页面命中抖音反爬挑战时返回明确诊断
|
||||
- `douyin` 手工 payload 导入与账号分析链路已跑通
|
||||
- `douyin` 浏览器辅助采集工具已接入,可用真实 Playwright Chromium 会话采集主页 / 视频页并直接调用现有 `/v2/douyin/accounts/sync`
|
||||
- `douyin` 本地控制台已接入,可通过网页点击方式驱动浏览器辅助采集并查看最近运行结果
|
||||
- 本机 `huobao-drama` API 调度、首尾帧生成、视频生成与结果回写接口
|
||||
- 旧运行链依赖删除
|
||||
- 旧运行残留容器已实际下线
|
||||
|
||||
## 已验证的真实任务
|
||||
|
||||
- 分析链路:`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`
|
||||
- `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 / OTA 链路已拆回 `AI Glasses` 独立仓库,不再纳入当前 StoryForge MVP 范围
|
||||
|
||||
## 下一步优先级
|
||||
|
||||
1. 更新 `huobao` 可用图片/视频凭证后,用新的 env 覆盖能力对 upstream 版补一轮完整 `drama -> images -> video` fresh smoke
|
||||
2. 补抖音真实账号的 cookie / 手工页面采集联调,以及小红书账号级验证
|
||||
3. 把 `collector` live 切换结果和部署回滚说明继续固化到仓库
|
||||
@@ -1,125 +0,0 @@
|
||||
# StoryForge Next Thread Handoff - 2026-05-02
|
||||
|
||||
## Gitea
|
||||
|
||||
- Repository: https://git.hyzq.site/krisolo/storyforge
|
||||
- Current branch: `codex/storyforge-live-orchestrator-sync-20260323`
|
||||
- Public workbench: https://storyforge.hyzq.net/
|
||||
- Public health endpoint: https://storyforge.hyzq.net/healthz
|
||||
|
||||
## Project Goal
|
||||
|
||||
StoryForge is being shaped into a multi-platform new-media operating workbench: project-first workspace, benchmark discovery, creator-center account analysis, production queue, live recording, AI video generation, review, and a OneLiner main Agent layer that can route unfinished flows into platform Agents.
|
||||
|
||||
## Current Progress
|
||||
|
||||
- The public web workbench is deployed at `storyforge.hyzq.net` and can auto-login with the configured web auto-session.
|
||||
- The UI has been returned to the preferred current design direction and refined for mobile/workbench use. The dashboard keeps the `1 main + 2 secondary` action model.
|
||||
- OneLiner now opens immediately. Context hydration happens inside the OneLiner panel instead of leaving the global header stuck on `正在打开 OneLiner`.
|
||||
- Discovery/creator-center flows now support Douyin and Kuaishou style creator-center sync, account analysis, top-video analysis, similar-account state isolation, and selected-account cache cleanup.
|
||||
- Production Center exposes intake entry points for creator-center sync, import homepage/video/text, upload video, AI video, real-cut, and live-recorder maintenance.
|
||||
- Admin Model Access centralizes language model, ASR, image, image-to-image, video, Huobao, Seedance, and runtime integration configuration behind super-admin access.
|
||||
- Seedance 2.0 is routed through Huobao/Volcengine style video config. AI video creation preflights Huobao video config before dispatch.
|
||||
- Public deployment scripts and fnOS/NAS deployment scripts are present for web, collector, live-recorder, cutvideo tunnel, n8n, Huobao, and CLI proxy.
|
||||
|
||||
## Architecture Snapshot
|
||||
|
||||
- Frontend: static vanilla JS app under `web/storyforge-web-v4`, with runtime config, API client, session store, platform runtime, and large workbench renderer in `assets/app.js`.
|
||||
- Backend: FastAPI collector under `collector-service/app`, with `core_main.py` as the main app surface and feature modules for Douyin, domestic platforms, OneLiner, integrations, and database access.
|
||||
- Data: server-side SQLite under `/home/ubuntu/storyforge/data/collector/storyforge.db` in production.
|
||||
- Public server: `https://storyforge.hyzq.net` proxies the static web and collector API.
|
||||
- fnOS/NAS: local storage and optional service workloads live under `/vol1/docker/hyzq-stack/...` on the fnOS host.
|
||||
- Windows ASR target: intended Windows host is `192.168.31.18`, using faster-whisper with GPU-capable auto mode and mixed Chinese/English recognition.
|
||||
|
||||
## Current Public Runtime Status
|
||||
|
||||
Fresh checks on 2026-05-02:
|
||||
|
||||
- `GET https://storyforge.hyzq.net/healthz`: OK.
|
||||
- `POST https://storyforge.hyzq.net/v2/auth/auto-session`: OK, returns the `kris` super-admin session.
|
||||
- `cutvideo`: configured and reachable at the server-local route.
|
||||
- `n8n`: configured and reachable at the server-local route.
|
||||
- `Huobao`: configured and reachable, but video config count is `0`; Seedance/AI video still needs an enabled Huobao video config.
|
||||
- `local_model`: intentionally not configured because the project decision is to use public/cloud models rather than local models.
|
||||
- `ASR`: configured as Windows deployment, but public collector currently reports `Connection refused` on `http://127.0.0.1:28088/health`.
|
||||
- `live_recorder`: configured as NAS deployment, but public collector currently reports connection reset on `http://127.0.0.1:19106/api/healthz`.
|
||||
|
||||
## Important Files For The Next Thread
|
||||
|
||||
- `web/storyforge-web-v4/assets/app.js`: primary workbench UI, OneLiner runtime, admin model config, discovery, production, and mobile interaction logic.
|
||||
- `web/storyforge-web-v4/assets/storyforge-platform-runtime.js`: platform route contract for Douyin/Kuaishou/Xiaohongshu/Bilibili/Video Account style workbenches.
|
||||
- `web/storyforge-web-v4/tests/workbench-pages.test.mjs`: frontend contract tests; most UI workflow guarantees live here.
|
||||
- `collector-service/app/core_main.py`: collector API, auth, integrations, runtime config, live recorder proxy, Huobao model access, AI video job creation.
|
||||
- `collector-service/app/domestic_platform_features.py`: domestic-platform creator-center sync, analysis, relations, video persistence, and top-video followups.
|
||||
- `collector-service/app/douyin_features.py`: Douyin-specific account and public fetch behavior.
|
||||
- `collector-service/app/oneliner_features.py`: OneLiner main Agent, governance, run lifecycle, execution cards, and platform Agent routing.
|
||||
- `tests/test_platform_contracts.py`: backend route contracts for platform sync/analysis flows.
|
||||
- `tests/test_production_baseline.py`: production, model access, AI video, and integration baseline tests.
|
||||
- `docs/superpowers/specs/*` and `docs/superpowers/plans/*`: design and implementation plans used during this build phase.
|
||||
- `docs/FNOS_LAN_DELIVERY_RUNBOOK_2026-03-27.md`: fnOS/NAS deployment guide.
|
||||
- `docs/WINDOWS_CUTVIDEO_OPERATIONS_2026-03-27.md`: Windows cutvideo operating notes.
|
||||
- `deploy/STORYFORGE_PUBLIC_GATEWAY.md`: public gateway deployment notes.
|
||||
|
||||
## Recent Change Highlights
|
||||
|
||||
- OneLiner opening behavior:
|
||||
- Added `onelinerHydrating` and `onelinerHydrationMessage`.
|
||||
- `open-oneliner` opens the panel first, renders immediately, then hydrates control surfaces and messages.
|
||||
- Loading text is panel-local (`正在同步 OneLiner 上下文...`) and clears after hydration.
|
||||
|
||||
- Creator-center and benchmark discovery:
|
||||
- Kuaishou/Douyin creator-center sync can persist snapshots and creator works into video sources.
|
||||
- Account analysis carries model profile, linked-account, recent-similar, creator-center, and top-video context.
|
||||
- Similar-account search results are isolated by selected account to avoid stale/cross-account state.
|
||||
|
||||
- AI video and Seedance:
|
||||
- AI video form exposes provider/model controls and points admins to Huobao video config.
|
||||
- Backend validates that Huobao has active video config before AI video dispatch.
|
||||
- Seedance 2.0 uses the Huobao/Volcengine config path, not a local model path.
|
||||
|
||||
- Runtime governance and admin config:
|
||||
- Admin Model Access covers runtime config, system model config, Huobao AI config, quota, policy, and integration status.
|
||||
- Local model is left blank by design; public/cloud model configuration is the intended path.
|
||||
|
||||
- Deployment:
|
||||
- Added fnOS compose/deploy scripts for CLI proxy, Huobao, and n8n.
|
||||
- LAN stack deployment now includes cutvideo tunnel, live recorder, CLI proxy, n8n, Huobao, collector, web, and smoke checks.
|
||||
|
||||
## Verification Commands
|
||||
|
||||
Run from repository root:
|
||||
|
||||
```bash
|
||||
node --test web/storyforge-web-v4/tests/workbench-pages.test.mjs
|
||||
python3 -m unittest tests.test_platform_contracts
|
||||
python3 -m unittest tests.test_production_baseline
|
||||
curl -fsS https://storyforge.hyzq.net/healthz
|
||||
```
|
||||
|
||||
Useful public deploy commands:
|
||||
|
||||
```bash
|
||||
STORYFORGE_PUBLIC_SYNC_COLLECTOR=0 ./scripts/deploy_public_storyforge.sh
|
||||
STORYFORGE_PUBLIC_SYNC_COLLECTOR=1 ./scripts/deploy_public_storyforge.sh
|
||||
```
|
||||
|
||||
Useful fnOS/NAS deploy commands:
|
||||
|
||||
```bash
|
||||
SKIP_SMOKE=1 ./scripts/deploy_fnos_storyforge_lan_stack.sh
|
||||
./scripts/deploy_fnos_storyforge_cliproxy.sh
|
||||
./scripts/deploy_fnos_storyforge_n8n.sh
|
||||
./scripts/deploy_fnos_storyforge_huobao.sh
|
||||
```
|
||||
|
||||
## Known Follow-Up Work
|
||||
|
||||
- Restore ASR reachability from the public collector to the Windows ASR host. The intended host is `192.168.31.18`; check whether the server-side runtime config should point at the relay/tunnel URL rather than `127.0.0.1:28088`.
|
||||
- Restore live-recorder health from the public collector to the NAS service. The current public probe reports connection reset.
|
||||
- Configure at least one active Huobao video model config for Seedance 2.0 before expecting AI video jobs to dispatch successfully.
|
||||
- The public deploy smoke can fail if ASR/live-recorder are offline even when the web and collector deploy succeeded; check the individual health results before assuming the deploy itself failed.
|
||||
- Keep secrets out of Git: API keys, cookies, creator-center login cookies, and Gitea credentials must stay in runtime config, Keychain, or server-side storage.
|
||||
|
||||
## Handoff Recommendation
|
||||
|
||||
For the next thread, start by pulling this branch from Gitea, reading this document, then running the verification commands above. After that, focus first on the three runtime gaps: ASR, live-recorder, and Huobao Seedance video config. Once those are green, test the real creator-center account flow and AI video creation from the public site.
|
||||
@@ -1,47 +0,0 @@
|
||||
# StoryForge 生产基线
|
||||
|
||||
日期:2026-03-26
|
||||
|
||||
本文档描述当前仓库已经落地的量产底盘,便于后续继续开发和运维。
|
||||
|
||||
## 已落地能力
|
||||
|
||||
- SQLite 默认连接参数已收紧:
|
||||
- `journal_mode=WAL`
|
||||
- `synchronous=NORMAL`
|
||||
- `busy_timeout`
|
||||
- `foreign_keys=ON`
|
||||
- `temp_store=MEMORY`
|
||||
- 核心生产 API 已接入 tenant quota 硬控制与 usage ledger 记账:
|
||||
- `POST /v2/explore/text`
|
||||
- `POST /v2/explore/video-link`
|
||||
- `POST /v2/explore/upload-video`
|
||||
- `POST /v2/pipelines/content-source-sync`
|
||||
- `POST /v2/reviews`
|
||||
- `POST /v2/pipelines/real-cut`
|
||||
- `POST /v2/pipelines/ai-video`
|
||||
- `POST /v2/assistants/{assistant_id}/generate`
|
||||
- `POST /v2/live-recorder/sources`
|
||||
- 失败任务恢复入口已补齐:
|
||||
- `POST /v2/explore/jobs/{job_id}/retry`
|
||||
- `POST /v2/explore/jobs/{job_id}/requeue`
|
||||
- `POST /v2/admin/jobs/retry-failed`
|
||||
- Web 已支持固定后端自动建会话:
|
||||
- `POST /v2/auth/auto-session`
|
||||
- 开关由 `WEB_AUTOLOGIN_ENABLED` 控制
|
||||
- 推荐使用 `WEB_AUTOLOGIN_ACCOUNT_USERNAME` 直接绑定现有已审批账号
|
||||
- 兼容 `WEB_AUTOLOGIN_USERNAME / WEB_AUTOLOGIN_PASSWORD` 或 bootstrap 超级管理员口令回退
|
||||
- 仓库内已新增 SQLite 备份脚本:
|
||||
- `scripts/backup_storyforge_sqlite.sh`
|
||||
|
||||
## 运行建议
|
||||
|
||||
- 发布前先执行一次数据库备份,再执行服务升级。
|
||||
- quota 配置建议按 project 维度维护,避免不同项目之间互相干扰。
|
||||
- 批量 retry 建议优先筛选 `workflow_key` 或 `source_type`,避免把不同流水线一起打回去。
|
||||
|
||||
## 当前外部阻塞
|
||||
|
||||
- 真正的额度策略仍取决于业务侧如何配置 `tenant_quota_profiles`。
|
||||
- `real-cut`、`ai-video`、`content-source-sync` 的完整链路仍依赖外部服务可用性。
|
||||
- 抖音等真实平台采集仍可能受到平台风控影响,需要真实联调确认。
|
||||
@@ -1,555 +0,0 @@
|
||||
# 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 驱动、覆盖多平台调研、导入分析、内容生产和复盘的新媒体运营中台。**
|
||||
@@ -1,42 +0,0 @@
|
||||
# StoryForge 仓库边界说明
|
||||
|
||||
本文档用于固定 `StoryForge-gitea` 的维护边界,避免把 StoryForge 与 `AI Glasses` 误判成同一个项目。
|
||||
|
||||
## 基本原则
|
||||
|
||||
- `StoryForge` 与 `AI Glasses` 是两个独立项目,分别独立维护。
|
||||
- 当前仓库只负责 `StoryForge` 的产品、运行时、联调、部署与发布。
|
||||
- `AI Glasses` 当前独立维护仓库为 [krisolo/ai-glasses](https://git.hyzq.site/krisolo/ai-glasses)。
|
||||
- 当前仓库已经移除混入的 `android-app/` 目录;历史提交中的 Android / `com.aiglasses.*` 痕迹只作为拆分审计证据保留。
|
||||
|
||||
## 当前仓库内属于 StoryForge 的主维护范围
|
||||
|
||||
- `collector-service/`:StoryForge 后端与业务 API。
|
||||
- `web/storyforge-web-v4/`:StoryForge Web 工作台和前端壳。
|
||||
- `scripts/douyin-browser-capture/`:抖音浏览器辅助采集与工作台控制台。
|
||||
- `n8n/`:StoryForge 编排工作流导出与说明。
|
||||
- `deploy/`:StoryForge 部署模板与网关配置。
|
||||
- `docs/`:StoryForge 审计、联调、实施与产品逻辑文档。
|
||||
- `docker-compose.yml`、`.env.example`、`scripts/start_business.sh`、`scripts/status_business.sh`、`scripts/smoke_business.sh`:当前 StoryForge 运行与联调基线。
|
||||
|
||||
## 已拆出的独立项目边界
|
||||
|
||||
- `AI Glasses` 的 Android / BLE / Baidu / AAR / OTA 代码不再属于当前 StoryForge 主仓库边界。
|
||||
- 与其相关的当前维护仓库、分支、发布应在 `krisolo/ai-glasses` 中进行。
|
||||
- 若后续需要回看叠加来源,可参考 Git 历史中的 `acb1103`、`ac6a8a8`、`7070c3a`、`fe07a5f` 等提交,以及 [StoryForge / AI Glasses 拆分评估方案](./STORYFORGE_SPLIT_ASSESSMENT_2026-03-26.md)。
|
||||
|
||||
## 提交与同步边界
|
||||
|
||||
- 提交到 Gitea 时,只纳入与 StoryForge 独立维护直接相关的改动。
|
||||
- 原型、概念稿、临时预览图等目录只有在明确属于本轮 StoryForge 任务时才纳入提交。
|
||||
- 本轮同步明确排除以下无关本次目标的本地变更:
|
||||
- `concepts/studio-workbench/README.md`
|
||||
- `.tmp-previews-b/`
|
||||
|
||||
## 本轮独立维护改动的收口范围
|
||||
|
||||
- 后端与部署安全收口:去掉默认超级管理员口令依赖,强化 orchestrator secret 校验,新增 `readyz`,修复 `huobao/cutvideo` 超时串线。
|
||||
- n8n 工作流收口:内部回调地址与 secret 改为环境变量注入。
|
||||
- Web 稳定性与结构收口:修账号切换竞态,收紧会话存储,引入平台能力 gate,并拆出首批运行时模块。
|
||||
- 仓库边界收口:将混入的 `android-app/` 从 StoryForge 主仓库移出,并确认 `AI Glasses` 继续在独立 Gitea 仓库维护。
|
||||
- 基线验证:新增 `scripts/check_repo_baseline.sh` 作为统一回归入口。
|
||||
@@ -1,252 +0,0 @@
|
||||
# StoryForge / AI Glasses 拆分评估方案
|
||||
|
||||
执行状态(2026-03-26):
|
||||
|
||||
- 已确认独立仓库存在:`https://git.hyzq.site/krisolo/ai-glasses`
|
||||
- 已确认本机独立工作区存在:`/Users/kris/code/AI-glasses`
|
||||
- 当前评估方案已进入执行阶段:`StoryForge-gitea` 将移除混入的 `android-app/`
|
||||
|
||||
## 1. 结论摘要
|
||||
|
||||
当前仓库的问题更像是“项目导入时发生了目录叠加”,而不是后续开发过程中出现了随机数据错乱。
|
||||
|
||||
明确证据如下:
|
||||
|
||||
- Gitea 现有历史只有一个根提交:`acb1103`,日期为 `2026-03-14`。
|
||||
- 这个根提交从一开始就包含完整的 `android-app/` 子树。
|
||||
- 该 `android-app/` 子树内同时存在:
|
||||
- `StoryForge` 相关界面与接口代码;
|
||||
- 明显属于 `AI Glasses` 的包名、BLE、Baidu 实时能力、硬件依赖和 AAR。
|
||||
|
||||
因此,当前更合理的判断是:
|
||||
|
||||
- `StoryForge` 与 `AI Glasses` 原本是两个独立项目;
|
||||
- 在 `StoryForge-gitea` 建库或导入时,把一个带 `AI Glasses` Android 子项目的目录整体叠加进来了;
|
||||
- 后续又在这个混合目录上继续写入了一部分 `StoryForge` Android 代码,导致边界越来越模糊。
|
||||
|
||||
## 2. 现状诊断
|
||||
|
||||
### 2.1 明显属于 StoryForge 的主干目录
|
||||
|
||||
这些目录整体上是当前 StoryForge 的核心交付面:
|
||||
|
||||
- `collector-service/`
|
||||
- `web/storyforge-web-v4/`
|
||||
- `scripts/douyin-browser-capture/`
|
||||
- `n8n/`
|
||||
- `deploy/`
|
||||
- `docs/`
|
||||
- `Common/`
|
||||
- `docker-compose.yml`
|
||||
- `.env.example`
|
||||
|
||||
### 2.2 明显带有 AI Glasses 叠加痕迹的区域
|
||||
|
||||
`android-app/` 是本仓库最明显的混合区,内部包含三类内容:
|
||||
|
||||
1. 明显偏 AI Glasses / 硬件链路的内容:
|
||||
|
||||
- `android-app/app/src/main/java/com/aiglasses/app/ble/BleManager.kt`
|
||||
- `android-app/app/src/main/java/com/aiglasses/app/software/BaiduConversationAgent.kt`
|
||||
- `android-app/app/src/main/java/com/aiglasses/app/software/BaiduRealtimeWsClient.kt`
|
||||
- `android-app/app/src/main/java/com/aiglasses/app/software/BaiduVisualUploader.kt`
|
||||
- `android-app/app/src/main/java/com/aiglasses/app/software/SoftwareConversationController.kt`
|
||||
- `android-app/app/src/main/java/com/aiglasses/app/ui/MainViewModel.kt`
|
||||
- `android-app/app/libs/lib_agent-1.0.1.4.aar`
|
||||
- `android-app/app/libs/brtc-3.5.0.1a.aar`
|
||||
|
||||
2. 明显是 StoryForge 业务,但写在旧命名空间里的内容:
|
||||
|
||||
- `android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeApiService.kt`
|
||||
- `android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeModels.kt`
|
||||
- `android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeRepository.kt`
|
||||
- `android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeScreen.kt`
|
||||
- `android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeSessionStore.kt`
|
||||
- `android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeViewModel.kt`
|
||||
- `android-app/app/src/main/java/com/aiglasses/app/MainActivity.kt`
|
||||
|
||||
3. 明显属于旧项目命名残留的工程设置:
|
||||
|
||||
- `android-app/settings.gradle.kts` 中的 `rootProject.name = "AIGlassesApp"`
|
||||
- `android-app/app/build.gradle.kts` 中的 `namespace = "com.aiglasses.app"`
|
||||
- `android-app/app/src/main/res/values/themes.xml` 中的 `Theme.AIGlasses`
|
||||
- `android-app/app/src/main/AndroidManifest.xml` 当前仍引用 `Theme.AIGlasses`
|
||||
|
||||
### 2.3 Git 历史上的关键时间点
|
||||
|
||||
- `2026-03-14` `acb1103`
|
||||
- Gitea 根提交。
|
||||
- 从第一天就已带入 `android-app/` 和 `com.aiglasses.*`。
|
||||
- `2026-03-20 14:10` `ac6a8a8`
|
||||
- 开始明显向 StoryForge Android UI / 交互继续推进。
|
||||
- `2026-03-20 14:17` `7070c3a`
|
||||
- 提交信息直接是 `restore android build path`,说明 Android 构建链被重新激活。
|
||||
- `2026-03-22` `fe07a5f`
|
||||
- 明确进入 `storyforge mobile v4 shell` 阶段。
|
||||
|
||||
结论是:Gitea 历史里没有“完全纯净、完全不含 Android 叠加痕迹”的版本,但存在“尚未明显进入 APK 推进阶段”的较早切点。
|
||||
|
||||
## 3. 目标定义
|
||||
|
||||
基于当前产品节奏,推荐把拆分目标定义成:
|
||||
|
||||
- `StoryForge-gitea` 只保留 StoryForge 当前实际在推进的主线:
|
||||
- Web
|
||||
- Backend
|
||||
- n8n orchestration
|
||||
- Douyin browser capture
|
||||
- deploy / docs / ops
|
||||
- `AI Glasses` 相关 Android / BLE / Baidu / AAR / OTA 旧链路,移出当前仓库边界。
|
||||
- 如果未来要做 StoryForge Mobile,重新在一个干净边界内启动,而不是继续沿用 `com.aiglasses.*` 的混合目录。
|
||||
|
||||
## 4. 拆分策略选项
|
||||
|
||||
### 方案 A:按目录硬拆,StoryForge 先回到 Web 主线
|
||||
|
||||
做法:
|
||||
|
||||
- 从当前 StoryForge 仓库中移除整个 `android-app/` 目录。
|
||||
- 同步清理 README、docs、脚本中所有 Android/APK 主线描述。
|
||||
- 保留 Web、Backend、n8n、browser capture、deploy、docs 作为 StoryForge 正式主干。
|
||||
|
||||
优点:
|
||||
|
||||
- 边界最清楚,最符合“此前一直在做 Web 版本”的项目认知。
|
||||
- 能最快结束当前“两个项目目录叠加”的混乱状态。
|
||||
- 后续所有开发决策都会更简单。
|
||||
|
||||
缺点:
|
||||
|
||||
- 当前 `android-app/storyforge/*` 里写过的一些 StoryForge 业务代码会一起被移出,需要单独存档。
|
||||
|
||||
适用判断:
|
||||
|
||||
- 如果当前项目目标就是 Web 优先、暂不做 APK,这是最推荐方案。
|
||||
|
||||
### 方案 B:保留 StoryForge Android 子集,拆掉 AI Glasses 硬件链
|
||||
|
||||
做法:
|
||||
|
||||
- 在 `android-app/` 中只保留 `storyforge/*`、`MainActivity.kt`、必要的网络与 OTA 文件;
|
||||
- 删除 `ble/`、`software/`、旧 `ui/MainViewModel.kt`、AAR、旧权限与旧命名;
|
||||
- 后续再把包名重构到 `com.storyforge.*`。
|
||||
|
||||
优点:
|
||||
|
||||
- 保留了已写过的 StoryForge 移动端业务界面。
|
||||
|
||||
缺点:
|
||||
|
||||
- 仍要处理大量命名空间和依赖残留。
|
||||
- 会继续占用当前 StoryForge 项目的精力。
|
||||
- 和“你之前并没有打算做 APK”的事实不完全一致。
|
||||
|
||||
适用判断:
|
||||
|
||||
- 只有在你确认近期确实要保留 StoryForge Android 端时才值得做。
|
||||
|
||||
### 方案 C:直接回滚到较早基线
|
||||
|
||||
候选点:
|
||||
|
||||
- `acb1103`:最早基线,但已经带着 Android 叠加目录。
|
||||
- `1c539ab`:仍未明显进入 Android 壳推进,但已有少量 Android 接口同步。
|
||||
|
||||
优点:
|
||||
|
||||
- 操作简单。
|
||||
|
||||
缺点:
|
||||
|
||||
- 无法真正解决“根提交就已经叠加”的结构问题。
|
||||
- 会回退掉后续大量有价值的 Web / backend / deploy 进展。
|
||||
|
||||
适用判断:
|
||||
|
||||
- 只适合做参考,不适合作为主方案。
|
||||
|
||||
## 5. 推荐方案
|
||||
|
||||
推荐采用 `方案 A:按目录硬拆,StoryForge 先回到 Web 主线`。
|
||||
|
||||
原因:
|
||||
|
||||
- 它最符合当前产品事实:你确认之前的实际推进重点一直是 Web,而不是 APK。
|
||||
- 它最符合现有目录证据:`android-app/` 是混合最严重的区域,且根提交就已叠加。
|
||||
- 它最符合后续治理成本:先把 StoryForge 主仓库边界收干净,后面要不要重建移动端,再单独决定。
|
||||
|
||||
## 6. 实施步骤
|
||||
|
||||
### 第 0 阶段:安全快照
|
||||
|
||||
- 基于当前 Gitea 状态打一个拆分前快照分支。
|
||||
- 导出 `android-app/` 的完整目录快照,作为独立归档或后续 AI Glasses 仓库恢复源。
|
||||
- 记录关键参考提交:
|
||||
- `acb1103`
|
||||
- `1c539ab`
|
||||
- `ac6a8a8`
|
||||
- `7070c3a`
|
||||
- `fe07a5f`
|
||||
|
||||
### 第 1 阶段:StoryForge 主仓库边界清理
|
||||
|
||||
- 从 StoryForge 仓库中移除整个 `android-app/`。
|
||||
- 清理以下入口中的 Android/APK 主线描述:
|
||||
- `README.md`
|
||||
- `docs/AUDIT_2026-03-18.md`
|
||||
- `docs/MVP_STATUS_2026-03-18.md`
|
||||
- `docs/LAN_E2E_GUIDE_2026-03-18.md`
|
||||
- 其他出现 `compileDebugKotlin`、`assembleDebug`、`APK`、`com.aiglasses` 的说明文档
|
||||
- 调整基线检查脚本,不再把 Android 编译当成 StoryForge 主仓库必检项。
|
||||
|
||||
### 第 2 阶段:AI Glasses 资产外置
|
||||
|
||||
- 将 `android-app/` 单独落到 AI Glasses 仓库或归档仓库。
|
||||
- 在那个仓库中保留 `com.aiglasses.*`、BLE、Baidu、AAR、OTA 等原始工程语义。
|
||||
|
||||
### 第 3 阶段:StoryForge 后续演进
|
||||
|
||||
- 当前仓库继续只推进:
|
||||
- `collector-service/`
|
||||
- `web/storyforge-web-v4/`
|
||||
- `scripts/douyin-browser-capture/`
|
||||
- `n8n/`
|
||||
- `deploy/`
|
||||
- `docs/`
|
||||
- 若未来确实需要 StoryForge Mobile,再开一个全新、干净的移动端工程,不复用当前混合 Android 目录。
|
||||
|
||||
## 7. 风险与控制
|
||||
|
||||
### 风险 1:误删仍有参考价值的 StoryForge Android 代码
|
||||
|
||||
控制:
|
||||
|
||||
- 在删除前先对 `android-app/` 做完整快照导出。
|
||||
- 如果担心未来要参考 `storyforge/*` 子目录,可以单独保留一份只读归档。
|
||||
|
||||
### 风险 2:文档和状态记录出现历史断层
|
||||
|
||||
控制:
|
||||
|
||||
- 不改历史提交。
|
||||
- 仅在当前分支上明确标记“自本次拆分起,StoryForge 主仓库不再承载 Android 主线”。
|
||||
|
||||
### 风险 3:脚本和检查项仍假设存在 Android
|
||||
|
||||
控制:
|
||||
|
||||
- 统一核对:
|
||||
- `README.md`
|
||||
- `scripts/check_repo_baseline.sh`
|
||||
- 任何引用 `./gradlew` 的脚本或文档
|
||||
|
||||
## 8. 最终建议
|
||||
|
||||
不要先回滚历史,也不要先做大规模重命名。
|
||||
|
||||
更稳妥的动作顺序应当是:
|
||||
|
||||
1. 先承认当前问题是“目录叠加”而不是“功能开发方向变化”。
|
||||
2. 先把 `android-app/` 整体从 StoryForge 主仓库边界中拆出去。
|
||||
3. 把 StoryForge 主仓库重新收敛成 Web / Backend / Orchestration 主线。
|
||||
4. 最后再决定是否需要单独保留一个 StoryForge Mobile 项目。
|
||||
@@ -1,158 +0,0 @@
|
||||
# Windows `cutvideo` 运维与恢复
|
||||
|
||||
日期:2026-03-27
|
||||
|
||||
## 1. 适用场景
|
||||
|
||||
当 StoryForge 局域网前端里 `自动剪辑` 显示 `不可达`,或者 `collector-service` 的 `/v2/integrations/health` 显示:
|
||||
|
||||
- `cutvideo.reachable = false`
|
||||
- `cutvideo.url = http://192.168.31.18:7860/api/bootstrap`
|
||||
|
||||
优先按本文处理。
|
||||
|
||||
## 2. 当前基线
|
||||
|
||||
- Windows 主机:`192.168.31.18`
|
||||
- SSH 别名:`shuziren-win`
|
||||
- `cutvideo` 仓库目录:`D:\ai-code\cutvideo`
|
||||
- 目标服务地址:`http://192.168.31.18:7860`
|
||||
- 当前常驻方式:Windows 任务计划程序 `\Codex\cutvideo-web`
|
||||
|
||||
## 3. 本次故障根因
|
||||
|
||||
2026-03-27 这次实际故障不是网络不通,而是运行环境损坏:
|
||||
|
||||
- Windows 主机仍在线,`22 / 135 / 139 / 445 / 3389 / 5985` 都可达
|
||||
- 只有 `7860` 超时
|
||||
- `D:\ai-code\cutvideo\.venv` 内部仍引用已不存在的 `Python311`
|
||||
- `start-cutvideo-web-background.ps1` 因为坏掉的 `.venv` 回退失败,导致 Web 服务无法启动
|
||||
|
||||
## 4. 快速判断
|
||||
|
||||
在 Mac 上执行:
|
||||
|
||||
```bash
|
||||
ssh shuziren-win hostname
|
||||
curl --max-time 5 http://192.168.31.18:7860/api/bootstrap
|
||||
```
|
||||
|
||||
判断逻辑:
|
||||
|
||||
- 如果 SSH 能连,但 `api/bootstrap` 超时,优先怀疑 `cutvideo` 服务没起来
|
||||
- 如果 `GET /api/uploads` 返回 `405 Method Not Allowed`,这是正常现象,表示接口存在且只接受 `POST`
|
||||
|
||||
## 5. 标准恢复步骤
|
||||
|
||||
### 5.1 重建 `cutvideo` 虚拟环境
|
||||
|
||||
在 Windows 上执行:
|
||||
|
||||
```powershell
|
||||
Set-Location D:\ai-code\cutvideo
|
||||
$ts = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
if (Test-Path .venv -PathType Container) {
|
||||
Rename-Item .venv (".venv-broken-$ts") -Force
|
||||
}
|
||||
C:\Program Files\Python312\python.exe -m venv .venv
|
||||
.\.venv\Scripts\python.exe -m pip install --upgrade pip setuptools wheel
|
||||
.\.venv\Scripts\python.exe -m pip install -e .
|
||||
.\.venv\Scripts\python.exe -c "import cutvideo, typer, fastapi, uvicorn; print(cutvideo.__file__)"
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- `pip install -e .` 成功
|
||||
- 最后的导入检查不报错
|
||||
|
||||
### 5.2 直接启动一次 Web 服务
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File D:\ai-code\cutvideo\scripts\start-cutvideo-web-background.ps1 -Port 7860
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- 返回 `PID=<number>`
|
||||
- `curl http://192.168.31.18:7860/api/bootstrap` 返回 `200`
|
||||
|
||||
### 5.3 注册为常驻任务
|
||||
|
||||
这一步必须做。否则服务可能随着临时会话结束而退出。
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File D:\ai-code\cutvideo\scripts\register-resident-services.ps1 -StartNow
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 该脚本会写入 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`
|
||||
- 当前恢复后额外补了任务计划程序 `\Codex\cutvideo-web`
|
||||
- 建议后续把 `cutvideo-web` 继续作为主要常驻入口
|
||||
|
||||
## 6. 验证步骤
|
||||
|
||||
### 6.1 Windows 本机
|
||||
|
||||
```powershell
|
||||
cmd /c "netstat -ano | findstr :7860"
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- 出现 `0.0.0.0:7860 ... LISTENING`
|
||||
|
||||
### 6.2 Mac / NAS
|
||||
|
||||
```bash
|
||||
curl http://192.168.31.18:7860/api/bootstrap
|
||||
curl -i http://192.168.31.18:7860/api/uploads
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- `/api/bootstrap` 返回 `200`
|
||||
- `/api/uploads` 返回 `405`
|
||||
|
||||
### 6.3 StoryForge collector
|
||||
|
||||
调用:
|
||||
|
||||
```bash
|
||||
POST /v2/auth/auto-session
|
||||
GET /v2/integrations/health
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- `cutvideo.reachable = true`
|
||||
- `cutvideo.supports_uploads = true`
|
||||
- `upload_status_code = 405`
|
||||
|
||||
## 7. 常用命令
|
||||
|
||||
Mac 上探测:
|
||||
|
||||
```bash
|
||||
ssh shuziren-win hostname
|
||||
ssh shuziren-win "cmd /c netstat -ano | findstr :7860"
|
||||
curl --max-time 5 http://192.168.31.18:7860/api/bootstrap
|
||||
```
|
||||
|
||||
Windows 上日志:
|
||||
|
||||
```powershell
|
||||
Get-Content D:\ai-code\cutvideo\runs\service-logs\cutvideo-web.out.log -Tail 120
|
||||
Get-Content D:\ai-code\cutvideo\runs\service-logs\cutvideo-web.err.log -Tail 120
|
||||
Get-Content D:\ai-code\cutvideo\runs\service-logs\resident-supervisor.out.log -Tail 120
|
||||
Get-Content D:\ai-code\cutvideo\runs\service-logs\resident-supervisor.err.log -Tail 120
|
||||
```
|
||||
|
||||
## 8. 当前已验证状态
|
||||
|
||||
截至 2026-03-27:
|
||||
|
||||
- `http://192.168.31.18:7860/api/bootstrap` 已恢复
|
||||
- `GET /api/uploads` 返回 `405`
|
||||
- StoryForge NAS collector 已恢复识别 `cutvideo` 在线
|
||||
- 前端工作台应恢复显示 `自动剪辑` 在线
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user