Compare commits
213 Commits
codex/stor
...
codex/stor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65db3cd336 | ||
|
|
6f0d944a75 | ||
|
|
748c30517c | ||
|
|
9e4f32077e | ||
|
|
07680dce4f | ||
|
|
4cd9ff77d9 | ||
|
|
4f3ca3f20f | ||
|
|
f0ce9ed80c | ||
|
|
8dce288e3a | ||
|
|
8304022641 | ||
|
|
0aaac7998a | ||
|
|
206599551a | ||
|
|
b31c338120 | ||
|
|
b72427eea8 | ||
|
|
670f631475 | ||
|
|
3cf56a4db6 | ||
|
|
cc58e2f03d | ||
|
|
76affab96b | ||
|
|
b67876a458 | ||
|
|
3b4fdbc70a | ||
|
|
02231cfb6e | ||
|
|
332064b088 | ||
|
|
f276d6e7fb | ||
|
|
27801107b5 | ||
|
|
022c9e5456 | ||
|
|
4acde19ffe | ||
|
|
aa3aa9170f | ||
|
|
f53a4b4461 | ||
|
|
4ff7efb61c | ||
|
|
7698b5e1e4 | ||
|
|
c3e9b7edbc | ||
|
|
a048bd26b1 | ||
|
|
1395493208 | ||
|
|
0185106932 | ||
|
|
8ecbca9cb0 | ||
|
|
16bc9fd5e4 | ||
|
|
4546c95e8c | ||
|
|
ec35927fa5 | ||
|
|
571be69571 | ||
|
|
d0ae34ae4a | ||
|
|
fc17c8d90b | ||
|
|
6ef5d94fc6 | ||
|
|
ce5a530427 | ||
|
|
4adb545e0d | ||
|
|
4270c3f586 | ||
|
|
cb69525c4f | ||
|
|
791c1ac80d | ||
|
|
70e528614f | ||
|
|
0061909cdf | ||
|
|
3c50be4f59 | ||
|
|
1ea00ff5e4 | ||
|
|
0b96562e86 | ||
|
|
78d90542cc | ||
|
|
696f90b3fe | ||
|
|
10b3c0cf42 | ||
|
|
ad05a4dfbc | ||
|
|
de36ce7fe9 | ||
|
|
f73d5cd406 | ||
|
|
7a75f1cd85 | ||
|
|
6a98559e78 | ||
|
|
905c3adabe | ||
|
|
88ccc62c71 | ||
|
|
f320ec1b44 | ||
|
|
22f6e6e686 | ||
|
|
5dab485e81 | ||
|
|
2cb6d6b1aa | ||
|
|
c61c12127f | ||
|
|
b78d1eaa51 | ||
|
|
968d5715dd | ||
|
|
cd4021cf17 | ||
|
|
5cff65417f | ||
|
|
2f74b46324 | ||
|
|
82394670ed | ||
|
|
02cdba834d | ||
|
|
7888797663 | ||
|
|
6f1e56daca | ||
|
|
e910d976f8 | ||
|
|
75142941f2 | ||
|
|
4ef0b3e805 | ||
|
|
fe9a6c7cc1 | ||
|
|
b272c5edfd | ||
|
|
3f93d5c088 | ||
|
|
ae4d4cd2ad | ||
|
|
294846e603 | ||
|
|
64da9a4e9b | ||
|
|
f68862b981 | ||
|
|
96446a25df | ||
|
|
5e38ed39aa | ||
|
|
53b1854c21 | ||
|
|
895e3f3b13 | ||
|
|
01ce085f6a | ||
|
|
a76bdb432f | ||
|
|
f890a0ace7 | ||
|
|
bbceada4f1 | ||
|
|
c14e573152 | ||
|
|
1f3631a648 | ||
|
|
2f98a1735d | ||
|
|
7897ce6c3d | ||
|
|
7a928b5df9 | ||
|
|
3f3c8de949 | ||
|
|
9317d4c0a5 | ||
|
|
ea643aad63 | ||
|
|
aa2893b392 | ||
|
|
67bddcf4b3 | ||
|
|
f766fea2b9 | ||
|
|
b0199a6b85 | ||
|
|
b93b32f59d | ||
|
|
0f275e25bb | ||
|
|
30cc0ca029 | ||
|
|
f2c75755b6 | ||
|
|
f492cb3f83 | ||
|
|
c400c1af44 | ||
|
|
a53a591c05 | ||
|
|
3f7aa2514d | ||
|
|
5dae80c4ac | ||
|
|
1b11929909 | ||
|
|
d88d7c0a5f | ||
|
|
4242b40f5c | ||
|
|
fd3d3c8ae5 | ||
|
|
ee39fbfaa0 | ||
|
|
794de0133e | ||
|
|
1d2bbdf201 | ||
|
|
cb3a4d2755 | ||
|
|
a50d1b00f1 | ||
|
|
28863b208e | ||
|
|
31ebe0431e | ||
|
|
566e412a3a | ||
|
|
1cb6c3e78f | ||
|
|
ae99a4b962 | ||
|
|
d7c25d1627 | ||
|
|
62caaa0ab0 | ||
|
|
18351993df | ||
|
|
25a050453e | ||
|
|
0466f5b672 | ||
|
|
5ff76ca4ce | ||
|
|
151129ce26 | ||
|
|
5ec86ae48a | ||
|
|
32c28fb7d6 | ||
|
|
568e8091c1 | ||
|
|
2f3ac47439 | ||
|
|
6c9b45ab37 | ||
|
|
e65e32e5a8 | ||
|
|
e0486708b7 | ||
|
|
f13c83a583 | ||
|
|
e48074e24b | ||
|
|
f14e773aa3 | ||
|
|
6c0f40c908 | ||
|
|
c83c54053f | ||
|
|
1c5108dcc1 | ||
|
|
d4be3a2ce1 | ||
|
|
30e37e5ce1 | ||
|
|
ccbe6ca565 | ||
|
|
8a133a4f78 | ||
|
|
f813b6e5c0 | ||
|
|
8bb58be5ff | ||
|
|
26f86f8484 | ||
|
|
cb17fb0760 | ||
|
|
dff369aafd | ||
|
|
f05a43fee3 | ||
|
|
693be5bca9 | ||
|
|
7bf93e610e | ||
|
|
17809605da | ||
|
|
7bec3680fb | ||
|
|
45f6dca984 | ||
|
|
b35d653610 | ||
|
|
32bc94f924 | ||
|
|
195a5e5ff6 | ||
|
|
c65c8e39f3 | ||
|
|
2a1e2201fb | ||
|
|
82560d1415 | ||
|
|
d0673d08a5 | ||
|
|
ea2d305a3c | ||
|
|
a30ed8decd | ||
|
|
38b02a9799 | ||
|
|
160cece196 | ||
|
|
8d62da7e91 | ||
|
|
dd619448e7 | ||
|
|
fa9d6dda09 | ||
|
|
f27a12ca3d | ||
|
|
17b419f8ef | ||
|
|
f093f72ae4 | ||
|
|
841985c0d2 | ||
|
|
aeccea585e | ||
|
|
fb61f4c1ce | ||
|
|
1d9dbfa8a4 | ||
|
|
71465b3d55 | ||
|
|
5f7359c243 | ||
|
|
628eeb0d08 | ||
|
|
6928cb4201 | ||
|
|
8d54c21786 | ||
|
|
56255688c1 | ||
|
|
3ecf6c1916 | ||
|
|
a5f82bd0aa | ||
|
|
660f539204 | ||
|
|
f9e34287db | ||
|
|
941e171455 | ||
|
|
ac7fe786f3 | ||
|
|
3fe01d2f23 | ||
|
|
9a753f60d8 | ||
|
|
d7132fe932 | ||
|
|
54afca2bf4 | ||
|
|
4ab0b26821 | ||
|
|
ea6a855890 | ||
|
|
042188f954 | ||
|
|
c657db9b38 | ||
|
|
652f0c9f79 | ||
|
|
dab444a83c | ||
|
|
ed5bcaef84 | ||
|
|
7500d02730 | ||
|
|
37709d37b7 | ||
|
|
9ed5f24364 | ||
|
|
031ba04d4e | ||
|
|
32dea8e3a6 |
14
.env.example
14
.env.example
@@ -6,11 +6,19 @@ LOCAL_OPENAI_API_KEY=
|
||||
N8N_BASE_URL=http://127.0.0.1:5670
|
||||
# Dockerized collector should use the internal n8n service address.
|
||||
COLLECTOR_N8N_BASE_URL=http://n8n:5678
|
||||
BOOTSTRAP_SUPERADMIN_USERNAME=storyforge-admin
|
||||
BOOTSTRAP_SUPERADMIN_PASSWORD=__set_a_strong_password__
|
||||
BOOTSTRAP_SUPERADMIN_DISPLAY_NAME=StoryForge Admin
|
||||
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=storyforge-local-secret
|
||||
ORCHESTRATOR_SHARED_SECRET=__set_a_strong_shared_secret__
|
||||
STORYFORGE_INTERNAL_BASE_URL=http://collector:8081
|
||||
CUTVIDEO_BASE_URL=
|
||||
CUTVIDEO_API_KEY=
|
||||
CUTVIDEO_BASE_CONFIG=example.job.yaml
|
||||
@@ -33,3 +41,7 @@ WEBHOOK_URL=http://127.0.0.1:5670/
|
||||
GENERIC_TIMEZONE=Asia/Shanghai
|
||||
TZ=Asia/Shanghai
|
||||
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=
|
||||
|
||||
63
.gitea/workflows/ci.yml
Normal file
63
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,63 @@
|
||||
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
Normal file
63
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
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
@@ -25,11 +25,14 @@ node_modules/
|
||||
|
||||
# Runtime data and artifacts
|
||||
data/
|
||||
!android-app/app/src/main/java/com/aiglasses/app/data/
|
||||
!android-app/app/src/main/java/com/aiglasses/app/data/**
|
||||
output/
|
||||
*.log
|
||||
|
||||
# macOS / editors
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Local agent/browser scratch state
|
||||
.playwright-cli/
|
||||
.superpowers/
|
||||
.tmp-previews*/
|
||||
|
||||
722
CHANGELOG.md
Normal file
722
CHANGELOG.md
Normal file
@@ -0,0 +1,722 @@
|
||||
# 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,6 +13,5 @@
|
||||
- 使用 `whisper.cpp` 转写,若环境未就绪则保留原始素材并进入降级流程
|
||||
6. collector-service 调用本机 OpenAI 兼容模型提炼文案风格
|
||||
7. 结果写入用户自己的知识库文档
|
||||
8. 如果配置了 `FASTGPT_DATASET_API_KEY`
|
||||
- 同步到 FastGPT 数据集
|
||||
8. 如有需要,可继续同步到租户自己的外部知识系统
|
||||
9. 文案助手生成时按知识库关联关系取素材,结合提示词输出文案
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
1. Cloud Server
|
||||
2. Mac AI Node
|
||||
3. FastGPT
|
||||
3. Local Runtime Services
|
||||
4. Backend API
|
||||
5. Web Console
|
||||
6. Android Client
|
||||
|
||||
@@ -2,17 +2,16 @@
|
||||
|
||||
The Mac node should only do the following:
|
||||
|
||||
1. Deploy FastGPT locally
|
||||
2. Ensure the cloud backend can reach FastGPT
|
||||
1. Deploy StoryForge collector-service locally
|
||||
2. Ensure the cloud backend can reach collector-service
|
||||
3. Maintain a private network connection to the server
|
||||
4. Provide the FastGPT endpoint to the backend
|
||||
4. Provide the collector-service endpoint to the backend
|
||||
|
||||
Recommended ports:
|
||||
|
||||
- FastGPT: 3000
|
||||
- MongoDB: 27017
|
||||
- PostgreSQL: 5432
|
||||
- Redis: 6379
|
||||
- MinIO: 9000
|
||||
- Collector Service: 8081
|
||||
- n8n: 5670
|
||||
- Local Model API: 8317
|
||||
- ASR: 8088
|
||||
|
||||
FastGPT must not be exposed to the public internet directly.
|
||||
The local admin/control surfaces must not be exposed to the public internet directly.
|
||||
|
||||
@@ -2,13 +2,11 @@ You are responsible for the StoryForge Mac AI node.
|
||||
|
||||
Tasks:
|
||||
|
||||
- Deploy FastGPT using Docker.
|
||||
- Deploy StoryForge runtime services on the Mac node.
|
||||
- Services:
|
||||
- FastGPT
|
||||
- MongoDB
|
||||
- PostgreSQL + pgvector
|
||||
- Redis
|
||||
- MinIO
|
||||
- collector-service
|
||||
- n8n
|
||||
- cli-proxy-api
|
||||
- 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`
|
||||
- 如需通过云端访问,优先使用内网或隧道,不直接暴露 Mac 上的 FastGPT 管理接口
|
||||
- n8n 默认本机端口:`5670`
|
||||
- 如需通过云端访问,优先使用内网或隧道,不直接暴露 Mac 上的本地管理接口
|
||||
|
||||
94
README.md
94
README.md
@@ -2,30 +2,33 @@
|
||||
|
||||
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)。
|
||||
|
||||
## 目录
|
||||
|
||||
- `android-app/`:StoryForge Android 客户端
|
||||
- `collector-service/`:FastAPI 后端,负责用户体系、项目、Agent、任务、内容分析和对外能力接入
|
||||
- `web/storyforge-web-v4/`:正式 Web 工作台,承接多平台运营、对标、跟踪、生产和复盘入口
|
||||
- `n8n/`:工作流导出文件,作为流程编排中枢
|
||||
- `docker-compose.yml`:本地 `collector + n8n + cli-proxy-api` 编排
|
||||
- `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)
|
||||
- [Mobile V4 UI 原型](./output/ui/storyforge-mobile-v4-html-prototype-2026-03-22/README.md)
|
||||
|
||||
## Android
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/StoryForge-gitea/android-app
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
- [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
|
||||
|
||||
@@ -77,8 +80,8 @@ npx playwright install chromium
|
||||
cd /Users/kris/code/StoryForge-gitea/scripts/douyin-browser-capture
|
||||
npm run capture -- \
|
||||
--profile-url https://www.douyin.com/user/your_account \
|
||||
--storyforge-username kris \
|
||||
--storyforge-password 'Asd123456.'
|
||||
--storyforge-username storyforge-admin \
|
||||
--storyforge-password 'your_admin_password'
|
||||
```
|
||||
|
||||
说明:
|
||||
@@ -107,6 +110,38 @@ 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
|
||||
@@ -124,11 +159,20 @@ 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/`
|
||||
|
||||
默认会创建最高权限账号:
|
||||
公网维护常用脚本:
|
||||
|
||||
- `kris`
|
||||
- `Asd123456.`
|
||||
```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`。
|
||||
|
||||
## 当前架构
|
||||
|
||||
@@ -142,7 +186,13 @@ N8N_BASE_URL=http://127.0.0.1:5670
|
||||
- 触发 `content_source_sync_pipeline`
|
||||
- 触发 `real_cut_pipeline`
|
||||
- 触发 `ai_video_pipeline`
|
||||
- FastGPT 已从主流程设计中移除,不再作为运行时依赖
|
||||
- 历史旧运行链已完成移除,当前运行时只保留 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`
|
||||
|
||||
## 说明
|
||||
|
||||
@@ -151,5 +201,17 @@ N8N_BASE_URL=http://127.0.0.1:5670
|
||||
- 支持 `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 校验整条链路。
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
## Core Components
|
||||
|
||||
- Android App: 素材探索、文案生产、个人配置、管理员审批、OTA
|
||||
- Collector Service: FastAPI + SQLite,负责业务流程编排
|
||||
- Collector Service: FastAPI + SQLite,负责多租户业务边界、任务状态和 Agent 数据
|
||||
- n8n: 负责异步流程编排与 webhook 触发
|
||||
- Local Model API: 默认指向本机 `cli-proxy-api`
|
||||
- FastGPT: 负责数据集和后续工作流扩展
|
||||
- MongoDB / PostgreSQL + pgvector / Redis / MinIO: FastGPT 运行依赖
|
||||
- NAS / 外部执行器: 负责大文件缓存、录像、剪辑和 AI 视频执行
|
||||
|
||||
## Main Flow
|
||||
|
||||
User -> Android App -> Collector Service -> Local Model / FastGPT
|
||||
User -> Android App / Web / OneLiner -> Collector Service -> Local Model / n8n / 执行引擎
|
||||
|
||||
## Data Isolation
|
||||
|
||||
@@ -23,4 +23,4 @@ User -> Android App -> Collector Service -> Local Model / FastGPT
|
||||
- `model_profiles`
|
||||
- `app_updates`
|
||||
|
||||
每个用户的数据通过 `user_id` 进行隔离。
|
||||
每个用户/项目的数据通过 `user_id + project_id` 进行隔离。
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# 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`
|
||||
@@ -1,86 +0,0 @@
|
||||
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")
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
2
android-app/app/proguard-rules.pro
vendored
2
android-app/app/proguard-rules.pro
vendored
@@ -1,2 +0,0 @@
|
||||
# Keep default for demo stage.
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,51 +0,0 @@
|
||||
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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,638 +0,0 @@
|
||||
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"))
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package com.aiglasses.app.data
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
|
||||
object ApiClient {
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> createService(baseUrl: String): T {
|
||||
val logging = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
val client = OkHttpClient.Builder()
|
||||
.protocols(listOf(Protocol.HTTP_1_1))
|
||||
.connectTimeout(12, TimeUnit.SECONDS)
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.writeTimeout(20, TimeUnit.SECONDS)
|
||||
.callTimeout(25, TimeUnit.SECONDS)
|
||||
.addInterceptor { chain ->
|
||||
val request: Request = chain.request().newBuilder()
|
||||
.header("Connection", "close")
|
||||
.build()
|
||||
chain.proceed(request)
|
||||
}
|
||||
.addInterceptor(logging)
|
||||
.build()
|
||||
|
||||
val normalizedBaseUrl = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/"
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(normalizedBaseUrl)
|
||||
.client(client)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
.create<T>()
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package com.aiglasses.app.data
|
||||
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface ApiService {
|
||||
@GET("/healthz")
|
||||
suspend fun healthz(): ApiEnvelope<HealthzData>
|
||||
|
||||
@POST("/api/v1/devices/bind-confirm")
|
||||
suspend fun bindConfirm(
|
||||
@Body request: BindConfirmRequest
|
||||
): ApiEnvelope<BindConfirmData>
|
||||
|
||||
@POST("/api/v1/ai/sessions")
|
||||
suspend fun createSession(
|
||||
@Header("Idempotency-Key") idempotencyKey: String?,
|
||||
@Body request: CreateSessionRequest
|
||||
): ApiEnvelope<SessionData>
|
||||
|
||||
@POST("/api/v1/ai/sessions/{sessionId}/stop")
|
||||
suspend fun stopSession(
|
||||
@Path("sessionId") sessionId: String,
|
||||
@Body request: StopSessionRequest
|
||||
): ApiEnvelope<StopSessionData>
|
||||
|
||||
@POST("/api/v1/ai/sessions/{sessionId}/heartbeat")
|
||||
suspend fun heartbeat(
|
||||
@Path("sessionId") sessionId: String,
|
||||
@Body request: HeartbeatRequest
|
||||
): ApiEnvelope<HeartbeatData>
|
||||
|
||||
@GET("/api/v1/devices/{deviceId}/status")
|
||||
suspend fun getDeviceStatus(
|
||||
@Path("deviceId") deviceId: String
|
||||
): ApiEnvelope<DeviceStatusData>
|
||||
|
||||
@POST("/api/v1/events")
|
||||
suspend fun postEvent(
|
||||
@Body request: ClientEventRequest
|
||||
): ApiEnvelope<EventSavedData>
|
||||
|
||||
@POST("/api/v1/events/batch")
|
||||
suspend fun postEventsBatch(
|
||||
@Body request: ClientEventBatchRequest
|
||||
): ApiEnvelope<EventsBatchSavedData>
|
||||
|
||||
@POST("/api/v1/ai/sessions/{sessionId}/messages")
|
||||
suspend fun sendMessage(
|
||||
@Path("sessionId") sessionId: String,
|
||||
@Body request: SessionMessageRequest
|
||||
): ApiEnvelope<ProviderActionData>
|
||||
|
||||
@POST("/api/v1/ai/sessions/{sessionId}/scene-role")
|
||||
suspend fun switchRole(
|
||||
@Path("sessionId") sessionId: String,
|
||||
@Body request: SwitchRoleRequest
|
||||
): ApiEnvelope<ProviderActionData>
|
||||
|
||||
@POST("/api/v1/ai/sessions/{sessionId}/interrupt")
|
||||
suspend fun interruptSession(
|
||||
@Path("sessionId") sessionId: String,
|
||||
@Body request: SessionInterruptRequest
|
||||
): ApiEnvelope<ProviderActionData>
|
||||
|
||||
@GET("/api/v1/baidu/activation/query")
|
||||
suspend fun activationQuery(
|
||||
@Query("deviceId") deviceId: String,
|
||||
@Query("appId") appId: String? = null
|
||||
): ApiEnvelope<ActivationQueryData>
|
||||
|
||||
@POST("/api/v1/licenses/reload")
|
||||
suspend fun reloadLicenses(): ApiEnvelope<ReloadLicensesData>
|
||||
|
||||
@GET("/api/v1/admin/overview")
|
||||
suspend fun adminOverview(): ApiEnvelope<AdminOverviewData>
|
||||
|
||||
@GET("/api/v1/app/update/latest")
|
||||
suspend fun appUpdateLatest(
|
||||
@Query("platform") platform: String = "android",
|
||||
@Query("channel") channel: String = "stable",
|
||||
@Query("currentVersionCode") currentVersionCode: Int
|
||||
): ApiEnvelope<AppUpdateLatestData>
|
||||
|
||||
@GET("/v2/douyin/accounts")
|
||||
suspend fun listDouyinAccounts(): ApiEnvelope<List<DouyinAccountSummary>>
|
||||
|
||||
@POST("/v2/douyin/accounts/sync")
|
||||
suspend fun syncDouyinAccount(
|
||||
@Body request: DouyinAccountSyncRequest
|
||||
): ApiEnvelope<DouyinAccountWorkspace>
|
||||
|
||||
@GET("/v2/douyin/accounts/{accountId}")
|
||||
suspend fun getDouyinAccount(
|
||||
@Path("accountId") accountId: String
|
||||
): ApiEnvelope<DouyinAccountWorkspace>
|
||||
|
||||
@GET("/v2/douyin/accounts/{accountId}/workspace")
|
||||
suspend fun getDouyinWorkspace(
|
||||
@Path("accountId") accountId: String
|
||||
): ApiEnvelope<DouyinAccountWorkspace>
|
||||
|
||||
@GET("/v2/douyin/accounts/{accountId}/snapshots")
|
||||
suspend fun listDouyinSnapshots(
|
||||
@Path("accountId") accountId: String
|
||||
): ApiEnvelope<List<DouyinSnapshotSummary>>
|
||||
|
||||
@GET("/v2/douyin/accounts/{accountId}/snapshots/{snapshotId}")
|
||||
suspend fun getDouyinSnapshot(
|
||||
@Path("accountId") accountId: String,
|
||||
@Path("snapshotId") snapshotId: String
|
||||
): ApiEnvelope<DouyinSnapshotDetail>
|
||||
|
||||
@GET("/v2/douyin/accounts/{accountId}/creator-fields")
|
||||
suspend fun getDouyinCreatorFields(
|
||||
@Path("accountId") accountId: String
|
||||
): ApiEnvelope<DouyinSnapshotDetail>
|
||||
|
||||
@POST("/v2/douyin/accounts/{accountId}/analysis")
|
||||
suspend fun analyzeDouyinAccount(
|
||||
@Path("accountId") accountId: String,
|
||||
@Body request: DouyinAccountAnalysisRequest
|
||||
): ApiEnvelope<DouyinAnalysisResult>
|
||||
|
||||
@GET("/v2/douyin/accounts/{accountId}/analysis-reports")
|
||||
suspend fun listDouyinAnalysisReports(
|
||||
@Path("accountId") accountId: String
|
||||
): ApiEnvelope<List<DouyinAnalysisReport>>
|
||||
|
||||
@POST("/v2/douyin/similar-searches")
|
||||
suspend fun createDouyinSimilarSearch(
|
||||
@Body request: DouyinSimilarSearchRequest
|
||||
): ApiEnvelope<DouyinSimilaritySearchResult>
|
||||
|
||||
@GET("/v2/douyin/similar-searches/{searchId}")
|
||||
suspend fun getDouyinSimilarSearch(
|
||||
@Path("searchId") searchId: String
|
||||
): ApiEnvelope<DouyinSimilaritySearchDetail>
|
||||
|
||||
@GET("/v2/douyin/accounts/{accountId}/benchmark-links")
|
||||
suspend fun listDouyinBenchmarkLinks(
|
||||
@Path("accountId") accountId: String
|
||||
): ApiEnvelope<List<DouyinLinkedAccount>>
|
||||
|
||||
@POST("/v2/douyin/accounts/{accountId}/benchmark-links")
|
||||
suspend fun createDouyinBenchmarkLinks(
|
||||
@Path("accountId") accountId: String,
|
||||
@Body request: DouyinBenchmarkLinkRequest
|
||||
): ApiEnvelope<DouyinBenchmarkLinkResult>
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
package com.aiglasses.app.data
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
class BackendRepository(private var baseUrl: String) {
|
||||
private var api: ApiService = ApiClient.createService(baseUrl)
|
||||
|
||||
fun updateBaseUrl(url: String) {
|
||||
if (url != baseUrl) {
|
||||
baseUrl = url
|
||||
api = ApiClient.createService(baseUrl)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun bindDevice(deviceId: String, userId: String): BindConfirmData {
|
||||
val resp = api.bindConfirm(BindConfirmRequest(deviceId = deviceId, appUserId = userId))
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun healthz(): HealthzData {
|
||||
val resp = api.healthz()
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun createSession(deviceId: String, userId: String): SessionData {
|
||||
val idempotencyKey = "app-${UUID.randomUUID()}"
|
||||
val resp = api.createSession(
|
||||
idempotencyKey = idempotencyKey,
|
||||
request = CreateSessionRequest(deviceId = deviceId, appUserId = userId)
|
||||
)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun stopSession(sessionId: String): StopSessionData {
|
||||
val resp = api.stopSession(sessionId, StopSessionRequest())
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun heartbeat(sessionId: String): HeartbeatData {
|
||||
val resp = api.heartbeat(sessionId, HeartbeatRequest())
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun getDeviceStatus(deviceId: String): DeviceStatusData {
|
||||
val resp = api.getDeviceStatus(deviceId)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun postDemoEvent(deviceId: String, sessionId: String?): EventSavedData {
|
||||
return postEvent(
|
||||
deviceId = deviceId,
|
||||
sessionId = sessionId,
|
||||
eventType = "APP_DEBUG_PING",
|
||||
eventLevel = "INFO",
|
||||
payload = mapOf("source" to "android")
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun postEvent(
|
||||
deviceId: String,
|
||||
sessionId: String?,
|
||||
eventType: String,
|
||||
eventLevel: String = "INFO",
|
||||
payload: Map<String, String> = emptyMap()
|
||||
): EventSavedData {
|
||||
val resp = api.postEvent(
|
||||
ClientEventRequest(
|
||||
sessionId = sessionId,
|
||||
deviceId = deviceId,
|
||||
eventType = eventType,
|
||||
eventLevel = eventLevel,
|
||||
payload = payload
|
||||
)
|
||||
)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun postEventsBatch(events: List<ClientEventRequest>): EventsBatchSavedData {
|
||||
val resp = api.postEventsBatch(ClientEventBatchRequest(events = events))
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun sendMessage(sessionId: String, message: String): ProviderActionData {
|
||||
val resp = api.sendMessage(
|
||||
sessionId = sessionId,
|
||||
request = SessionMessageRequest(message = message)
|
||||
)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun sendVoiceMessage(
|
||||
sessionId: String,
|
||||
pcmBase64: String,
|
||||
sampleRate: Int,
|
||||
durationMs: Int,
|
||||
rms: Int
|
||||
): ProviderActionData {
|
||||
val resp = api.sendMessage(
|
||||
sessionId = sessionId,
|
||||
request = SessionMessageRequest(
|
||||
message = "voice_chunk",
|
||||
messageType = "voice",
|
||||
extra = mapOf(
|
||||
"audio_base64" to pcmBase64,
|
||||
"audio_format" to "pcm_s16le",
|
||||
"sample_rate" to sampleRate.toString(),
|
||||
"channels" to "1",
|
||||
"duration_ms" to durationMs.toString(),
|
||||
"rms" to rms.toString(),
|
||||
"encoding" to "base64"
|
||||
)
|
||||
)
|
||||
)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun sendVisionMessage(
|
||||
sessionId: String,
|
||||
message: String,
|
||||
imageBase64: String,
|
||||
width: Int,
|
||||
height: Int,
|
||||
bytes: Int
|
||||
): ProviderActionData {
|
||||
val resp = api.sendMessage(
|
||||
sessionId = sessionId,
|
||||
request = SessionMessageRequest(
|
||||
message = message,
|
||||
messageType = "text",
|
||||
extra = mapOf(
|
||||
"image_base64" to imageBase64,
|
||||
"imageBase64" to imageBase64,
|
||||
"image" to imageBase64,
|
||||
"resource_base64" to imageBase64,
|
||||
"resourceBase64" to imageBase64,
|
||||
"image_encoding" to "base64",
|
||||
"imageEncoding" to "base64",
|
||||
"encoding" to "base64",
|
||||
"image_format" to "jpeg",
|
||||
"imageFormat" to "jpeg",
|
||||
"mime_type" to "image/jpeg",
|
||||
"mimeType" to "image/jpeg",
|
||||
"image_width" to width.toString(),
|
||||
"imageWidth" to width.toString(),
|
||||
"image_height" to height.toString(),
|
||||
"imageHeight" to height.toString(),
|
||||
"image_bytes" to bytes.toString(),
|
||||
"imageBytes" to bytes.toString(),
|
||||
"resource_type" to "image",
|
||||
"resourceType" to "image",
|
||||
"camera_source" to "android_phone",
|
||||
"multimodal" to "true",
|
||||
"with_vision" to "1"
|
||||
)
|
||||
)
|
||||
)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun switchRole(sessionId: String, sceneId: String, roleId: String): ProviderActionData {
|
||||
val resp = api.switchRole(
|
||||
sessionId = sessionId,
|
||||
request = SwitchRoleRequest(sceneId = sceneId, roleId = roleId)
|
||||
)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun interrupt(
|
||||
sessionId: String,
|
||||
interrupt: Boolean,
|
||||
extra: Map<String, String> = emptyMap()
|
||||
): ProviderActionData {
|
||||
val resp = api.interruptSession(
|
||||
sessionId = sessionId,
|
||||
request = SessionInterruptRequest(interrupt = interrupt, extra = extra)
|
||||
)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun activationQuery(deviceId: String): ActivationQueryData {
|
||||
val resp = api.activationQuery(deviceId = deviceId)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun reloadLicenses(): ReloadLicensesData {
|
||||
val resp = api.reloadLicenses()
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun adminOverview(): AdminOverviewData {
|
||||
val resp = api.adminOverview()
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun appUpdateLatest(currentVersionCode: Int): AppUpdateLatestData {
|
||||
val resp = api.appUpdateLatest(
|
||||
platform = "android",
|
||||
channel = "stable",
|
||||
currentVersionCode = currentVersionCode
|
||||
)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun listDouyinAccounts(): List<DouyinAccountSummary> {
|
||||
val resp = api.listDouyinAccounts()
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun syncDouyinAccount(request: DouyinAccountSyncRequest): DouyinAccountWorkspace {
|
||||
val resp = api.syncDouyinAccount(request)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun getDouyinAccount(accountId: String): DouyinAccountWorkspace {
|
||||
val resp = api.getDouyinAccount(accountId)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun getDouyinWorkspace(accountId: String): DouyinAccountWorkspace {
|
||||
val resp = api.getDouyinWorkspace(accountId)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun listDouyinSnapshots(accountId: String): List<DouyinSnapshotSummary> {
|
||||
val resp = api.listDouyinSnapshots(accountId)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun getDouyinSnapshot(accountId: String, snapshotId: String): DouyinSnapshotDetail {
|
||||
val resp = api.getDouyinSnapshot(accountId, snapshotId)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun getDouyinCreatorFields(accountId: String): DouyinSnapshotDetail {
|
||||
val resp = api.getDouyinCreatorFields(accountId)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun analyzeDouyinAccount(
|
||||
accountId: String,
|
||||
request: DouyinAccountAnalysisRequest
|
||||
): DouyinAnalysisResult {
|
||||
val resp = api.analyzeDouyinAccount(accountId, request)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun listDouyinAnalysisReports(accountId: String): List<DouyinAnalysisReport> {
|
||||
val resp = api.listDouyinAnalysisReports(accountId)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun createDouyinSimilarSearch(
|
||||
request: DouyinSimilarSearchRequest
|
||||
): DouyinSimilaritySearchResult {
|
||||
val resp = api.createDouyinSimilarSearch(request)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun getDouyinSimilarSearch(searchId: String): DouyinSimilaritySearchDetail {
|
||||
val resp = api.getDouyinSimilarSearch(searchId)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun listDouyinBenchmarkLinks(accountId: String): List<DouyinLinkedAccount> {
|
||||
val resp = api.listDouyinBenchmarkLinks(accountId)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
suspend fun createDouyinBenchmarkLinks(
|
||||
accountId: String,
|
||||
request: DouyinBenchmarkLinkRequest
|
||||
): DouyinBenchmarkLinkResult {
|
||||
val resp = api.createDouyinBenchmarkLinks(accountId, request)
|
||||
return resp.data
|
||||
}
|
||||
}
|
||||
@@ -1,540 +0,0 @@
|
||||
package com.aiglasses.app.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
@Serializable
|
||||
data class ApiEnvelope<T>(
|
||||
val code: Int,
|
||||
val message: String,
|
||||
val traceId: String,
|
||||
val data: T
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HealthzData(
|
||||
val status: String = "",
|
||||
val env: String = "",
|
||||
val dbPath: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BindConfirmRequest(
|
||||
val deviceId: String,
|
||||
val deviceSn: String? = null,
|
||||
val deviceModel: String? = null,
|
||||
val deviceFwVer: String? = null,
|
||||
val appUserId: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BindConfirmData(
|
||||
val bindStatus: String,
|
||||
val licenseStatus: String,
|
||||
val licenseKeyMasked: String,
|
||||
val licenseKey: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CreateSessionRequest(
|
||||
val deviceId: String,
|
||||
val appUserId: String,
|
||||
val scene: String = "voice_assistant",
|
||||
val language: String = "zh-CN",
|
||||
val clientTs: Long? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SessionData(
|
||||
val sessionId: String,
|
||||
val provider: String,
|
||||
val cid: String,
|
||||
val token: String,
|
||||
val tokenExpireAt: Long,
|
||||
val wsUrl: String,
|
||||
val heartbeatSec: Int,
|
||||
val appId: String = "",
|
||||
val context: String = "",
|
||||
val realtimeWsUrl: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StopSessionRequest(
|
||||
val reason: String = "user_stop"
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StopSessionData(
|
||||
val sessionStatus: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HeartbeatRequest(
|
||||
val networkType: String? = "wifi",
|
||||
val bleRssi: Int? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HeartbeatData(
|
||||
val sessionStatus: String,
|
||||
val heartbeatAt: Long
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ClientEventRequest(
|
||||
val sessionId: String? = null,
|
||||
val deviceId: String,
|
||||
val eventType: String,
|
||||
val eventLevel: String = "INFO",
|
||||
val payload: Map<String, String> = emptyMap(),
|
||||
val ts: Long? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ClientEventBatchRequest(
|
||||
val events: List<ClientEventRequest> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class EventSavedData(
|
||||
val saved: Boolean
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class EventsBatchSavedData(
|
||||
val saved: Int = 0
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SessionMessageRequest(
|
||||
val message: String,
|
||||
val messageType: String = "text",
|
||||
val messageId: String? = null,
|
||||
val extra: Map<String, String> = emptyMap()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ProviderActionData(
|
||||
val status: String = "UNKNOWN",
|
||||
val detail: String = "",
|
||||
val asrText: String = "",
|
||||
val ttsText: String = "",
|
||||
val audioBase64: String = "",
|
||||
val audioUrl: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SwitchRoleRequest(
|
||||
val sceneId: String,
|
||||
val roleId: String,
|
||||
val extra: Map<String, String> = emptyMap()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SessionInterruptRequest(
|
||||
val interrupt: Boolean = true,
|
||||
val extra: Map<String, String> = emptyMap()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DeviceStatusData(
|
||||
val bindStatus: String,
|
||||
val licenseStatus: String,
|
||||
val activeSessionId: String? = null,
|
||||
val activeSessionStatus: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminStats(
|
||||
val totalDevices: Int = 0,
|
||||
val totalSessions: Int = 0,
|
||||
val runningSessions: Int = 0,
|
||||
val totalLicenses: Int = 0,
|
||||
val usedLicenseQuota: Int = 0
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BaiduInfo(
|
||||
val mode: String = "-",
|
||||
val generateConfigured: Boolean = false,
|
||||
val stopConfigured: Boolean = false,
|
||||
val activationQueryConfigured: Boolean = false
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminOverviewData(
|
||||
val stats: AdminStats = AdminStats(),
|
||||
val baidu: BaiduInfo = BaiduInfo()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ActivationQueryData(
|
||||
val deviceId: String = "",
|
||||
val appId: String = "",
|
||||
val status: String = "UNKNOWN",
|
||||
val detail: String = "",
|
||||
val licenseKeyMasked: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ReloadLicensesData(
|
||||
val inserted: Int = 0
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AppUpdateLatestData(
|
||||
val platform: String = "android",
|
||||
val channel: String = "stable",
|
||||
val hasUpdate: Boolean = false,
|
||||
val latestVersionCode: Int = 0,
|
||||
val latestVersionName: String = "",
|
||||
val minSupportedCode: Int = 0,
|
||||
val downloadUrl: String = "",
|
||||
val apkSha256: String = "",
|
||||
val releaseNotes: String = "",
|
||||
val forceUpdate: Boolean = false,
|
||||
val publishedAt: Long = 0L
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinManualPageCaptureRequest(
|
||||
val url: String = "",
|
||||
val title: String = "",
|
||||
val payload: JsonObject = JsonObject(emptyMap())
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinAccountSyncRequest(
|
||||
@SerialName("profile_url")
|
||||
val profileUrl: String = "",
|
||||
@SerialName("session_cookie")
|
||||
val sessionCookie: String = "",
|
||||
@SerialName("creator_center_urls")
|
||||
val creatorCenterUrls: List<String> = emptyList(),
|
||||
@SerialName("manual_profile_payload")
|
||||
val manualProfilePayload: JsonObject? = null,
|
||||
@SerialName("manual_creator_pages")
|
||||
val manualCreatorPages: List<DouyinManualPageCaptureRequest> = emptyList(),
|
||||
@SerialName("manual_work_payloads")
|
||||
val manualWorkPayloads: List<JsonObject> = emptyList(),
|
||||
@SerialName("discovery_note")
|
||||
val discoveryNote: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinProfileStats(
|
||||
val followers: Double = 0.0,
|
||||
val following: Double = 0.0,
|
||||
val likes: Double = 0.0,
|
||||
val videos: Double = 0.0
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinVideoStats(
|
||||
val play: Double = 0.0,
|
||||
val like: Double = 0.0,
|
||||
val comment: Double = 0.0,
|
||||
val share: Double = 0.0,
|
||||
val collect: Double = 0.0
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinVideoSummaryItem(
|
||||
@SerialName("aweme_id")
|
||||
val awemeId: String = "",
|
||||
val title: String = "",
|
||||
val description: String = "",
|
||||
val tags: List<String> = emptyList(),
|
||||
@SerialName("published_at")
|
||||
val publishedAt: String? = null,
|
||||
val stats: DouyinVideoStats = DouyinVideoStats()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinVideoSummary(
|
||||
val count: Int = 0,
|
||||
@SerialName("top_tags")
|
||||
val topTags: List<String> = emptyList(),
|
||||
@SerialName("avg_play")
|
||||
val avgPlay: Double = 0.0,
|
||||
@SerialName("avg_like")
|
||||
val avgLike: Double = 0.0,
|
||||
@SerialName("avg_comment")
|
||||
val avgComment: Double = 0.0,
|
||||
@SerialName("avg_share")
|
||||
val avgShare: Double = 0.0,
|
||||
val videos: List<DouyinVideoSummaryItem> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinAccountSummary(
|
||||
val id: String = "",
|
||||
val nickname: String = "",
|
||||
val signature: String = "",
|
||||
@SerialName("profile_url")
|
||||
val profileUrl: String = "",
|
||||
@SerialName("avatar_url")
|
||||
val avatarUrl: String = "",
|
||||
@SerialName("sec_uid")
|
||||
val secUid: String = "",
|
||||
@SerialName("douyin_id")
|
||||
val douyinId: String = "",
|
||||
@SerialName("profile_stats")
|
||||
val profileStats: DouyinProfileStats = DouyinProfileStats(),
|
||||
val tags: List<String> = emptyList(),
|
||||
val keywords: List<String> = emptyList(),
|
||||
@SerialName("sync_status")
|
||||
val syncStatus: String = "",
|
||||
@SerialName("video_summary")
|
||||
val videoSummary: DouyinVideoSummary = DouyinVideoSummary()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinSnapshotSummary(
|
||||
val id: String = "",
|
||||
@SerialName("snapshot_type")
|
||||
val snapshotType: String = "",
|
||||
@SerialName("source_url")
|
||||
val sourceUrl: String = "",
|
||||
@SerialName("field_count")
|
||||
val fieldCount: Int = 0,
|
||||
@SerialName("collected_at")
|
||||
val collectedAt: String = "",
|
||||
val summary: JsonObject = JsonObject(emptyMap())
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinModelProfileSummary(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
@SerialName("model_name")
|
||||
val modelName: String = "",
|
||||
@SerialName("base_url")
|
||||
val baseUrl: String = "",
|
||||
@SerialName("is_default")
|
||||
val isDefault: Boolean = false
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinAnalysisSuggestion(
|
||||
val id: String = "",
|
||||
@SerialName("model_profile_id")
|
||||
val modelProfileId: String = "",
|
||||
@SerialName("model_label")
|
||||
val modelLabel: String = "",
|
||||
val status: String = "",
|
||||
@SerialName("suggestion_text")
|
||||
val suggestionText: String = "",
|
||||
@SerialName("parsed_json")
|
||||
val parsedJson: JsonElement = JsonObject(emptyMap())
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinAnalysisReport(
|
||||
val id: String = "",
|
||||
@SerialName("focus_text")
|
||||
val focusText: String = "",
|
||||
@SerialName("model_profile_ids")
|
||||
val modelProfileIds: List<String> = emptyList(),
|
||||
@SerialName("linked_account_ids")
|
||||
val linkedAccountIds: List<String> = emptyList(),
|
||||
@SerialName("created_at")
|
||||
val createdAt: String = "",
|
||||
val suggestions: List<DouyinAnalysisSuggestion> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinSimilaritySearchPreview(
|
||||
val id: String = "",
|
||||
val keywords: List<String> = emptyList(),
|
||||
@SerialName("created_at")
|
||||
val createdAt: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinLinkedAccount(
|
||||
@SerialName("relation_id")
|
||||
val relationId: String = "",
|
||||
@SerialName("relation_type")
|
||||
val relationType: String = "",
|
||||
val note: String = "",
|
||||
@SerialName("search_id")
|
||||
val searchId: String = "",
|
||||
@SerialName("created_at")
|
||||
val createdAt: String = "",
|
||||
@SerialName("target_account_id")
|
||||
val targetAccountId: String? = null,
|
||||
@SerialName("target_profile_url")
|
||||
val targetProfileUrl: String = "",
|
||||
@SerialName("target_nickname")
|
||||
val targetNickname: String = "",
|
||||
@SerialName("target_signature")
|
||||
val targetSignature: String = "",
|
||||
@SerialName("target_profile_stats")
|
||||
val targetProfileStats: DouyinProfileStats = DouyinProfileStats(),
|
||||
@SerialName("target_tags")
|
||||
val targetTags: List<String> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinAccountWorkspace(
|
||||
val account: DouyinAccountSummary = DouyinAccountSummary(),
|
||||
@SerialName("latest_public_snapshot")
|
||||
val latestPublicSnapshot: DouyinSnapshotSummary? = null,
|
||||
@SerialName("latest_creator_snapshot")
|
||||
val latestCreatorSnapshot: DouyinSnapshotSummary? = null,
|
||||
@SerialName("linked_accounts")
|
||||
val linkedAccounts: List<DouyinLinkedAccount> = emptyList(),
|
||||
@SerialName("recent_reports")
|
||||
val recentReports: List<DouyinAnalysisReport> = emptyList(),
|
||||
@SerialName("recent_similarity_searches")
|
||||
val recentSimilaritySearches: List<DouyinSimilaritySearchPreview> = emptyList(),
|
||||
@SerialName("available_model_profiles")
|
||||
val availableModelProfiles: List<DouyinModelProfileSummary> = emptyList(),
|
||||
@SerialName("sync_errors")
|
||||
val syncErrors: List<String> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinAccountAnalysisRequest(
|
||||
@SerialName("model_profile_ids")
|
||||
val modelProfileIds: List<String> = emptyList(),
|
||||
@SerialName("linked_account_ids")
|
||||
val linkedAccountIds: List<String> = emptyList(),
|
||||
@SerialName("include_linked_accounts")
|
||||
val includeLinkedAccounts: Boolean = true,
|
||||
@SerialName("include_recent_similar_candidates")
|
||||
val includeRecentSimilarCandidates: Boolean = true,
|
||||
@SerialName("max_videos")
|
||||
val maxVideos: Int = 12,
|
||||
@SerialName("extra_focus")
|
||||
val extraFocus: String = "",
|
||||
val temperature: Double = 0.35
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinAnalysisResult(
|
||||
@SerialName("report_id")
|
||||
val reportId: String = "",
|
||||
@SerialName("created_at")
|
||||
val createdAt: String = "",
|
||||
val context: JsonElement = JsonObject(emptyMap()),
|
||||
val suggestions: List<DouyinAnalysisSuggestion> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinSimilarSearchRequest(
|
||||
@SerialName("source_account_id")
|
||||
val sourceAccountId: String? = null,
|
||||
@SerialName("profile_url")
|
||||
val profileUrl: String? = null,
|
||||
@SerialName("candidate_urls")
|
||||
val candidateUrls: List<String> = emptyList(),
|
||||
@SerialName("seed_linked_accounts")
|
||||
val seedLinkedAccounts: Boolean = true,
|
||||
@SerialName("search_public_pages")
|
||||
val searchPublicPages: Boolean = true,
|
||||
@SerialName("model_profile_id")
|
||||
val modelProfileId: String? = null,
|
||||
@SerialName("max_candidates")
|
||||
val maxCandidates: Int = 10,
|
||||
@SerialName("extra_requirements")
|
||||
val extraRequirements: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinSimilarCandidate(
|
||||
val id: String = "",
|
||||
@SerialName("candidate_account_id")
|
||||
val candidateAccountId: String? = null,
|
||||
@SerialName("candidate_profile_url")
|
||||
val candidateProfileUrl: String = "",
|
||||
@SerialName("candidate_nickname")
|
||||
val candidateNickname: String = "",
|
||||
@SerialName("heuristic_score")
|
||||
val heuristicScore: Double = 0.0,
|
||||
@SerialName("agent_score")
|
||||
val agentScore: Double = 0.0,
|
||||
@SerialName("rationale_text")
|
||||
val rationaleText: String = "",
|
||||
val dimensions: JsonElement = JsonObject(emptyMap()),
|
||||
@SerialName("rank_index")
|
||||
val rankIndex: Int = 0
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinSimilaritySearchResult(
|
||||
@SerialName("search_id")
|
||||
val searchId: String = "",
|
||||
@SerialName("source_account")
|
||||
val sourceAccount: DouyinAccountSummary = DouyinAccountSummary(),
|
||||
@SerialName("model_profile")
|
||||
val modelProfile: JsonObject = JsonObject(emptyMap()),
|
||||
@SerialName("raw_model_output")
|
||||
val rawModelOutput: String = "",
|
||||
val candidates: List<DouyinSimilarCandidate> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinSimilaritySearchDetail(
|
||||
val id: String = "",
|
||||
@SerialName("source_account_id")
|
||||
val sourceAccountId: String? = null,
|
||||
@SerialName("source_profile_url")
|
||||
val sourceProfileUrl: String = "",
|
||||
val keywords: List<String> = emptyList(),
|
||||
val context: JsonElement = JsonObject(emptyMap()),
|
||||
@SerialName("created_at")
|
||||
val createdAt: String = "",
|
||||
val candidates: List<DouyinSimilarCandidate> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinBenchmarkLinkRequest(
|
||||
@SerialName("target_account_ids")
|
||||
val targetAccountIds: List<String> = emptyList(),
|
||||
@SerialName("target_profile_urls")
|
||||
val targetProfileUrls: List<String> = emptyList(),
|
||||
@SerialName("relation_type")
|
||||
val relationType: String = "benchmark",
|
||||
val note: String = "",
|
||||
@SerialName("search_id")
|
||||
val searchId: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinBenchmarkLinkResult(
|
||||
val saved: Int = 0,
|
||||
@SerialName("relation_ids")
|
||||
val relationIds: List<String> = emptyList(),
|
||||
val links: List<DouyinLinkedAccount> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinSnapshotField(
|
||||
@SerialName("field_path")
|
||||
val fieldPath: String = "",
|
||||
@SerialName("field_type")
|
||||
val fieldType: String = "",
|
||||
@SerialName("field_value_text")
|
||||
val fieldValueText: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DouyinSnapshotDetail(
|
||||
val id: String = "",
|
||||
@SerialName("snapshot_type")
|
||||
val snapshotType: String = "",
|
||||
@SerialName("source_url")
|
||||
val sourceUrl: String = "",
|
||||
@SerialName("field_count")
|
||||
val fieldCount: Int = 0,
|
||||
@SerialName("collected_at")
|
||||
val collectedAt: String = "",
|
||||
val summary: JsonObject = JsonObject(emptyMap()),
|
||||
@SerialName("raw_payload")
|
||||
val rawPayload: JsonElement = JsonObject(emptyMap()),
|
||||
val fields: List<DouyinSnapshotField> = emptyList()
|
||||
)
|
||||
@@ -1,325 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
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
@@ -1,109 +0,0 @@
|
||||
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
|
||||
|
||||
@POST("v2/pipelines/content-source-sync")
|
||||
suspend fun createContentSourceSyncJob(@Body request: ContentSourceSyncRequest): 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
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
package com.aiglasses.app.storyforge
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
|
||||
@Serializable
|
||||
data class RegisterAccountRequest(
|
||||
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 ProjectDto(
|
||||
val id: String,
|
||||
val user_id: String,
|
||||
val name: String,
|
||||
val description: String = "",
|
||||
val created_at: String = "",
|
||||
val updated_at: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KnowledgeBaseDto(
|
||||
val id: String,
|
||||
val user_id: String,
|
||||
val project_id: String = "",
|
||||
val name: String,
|
||||
val description: String = "",
|
||||
val sync_status: String = "pending",
|
||||
val document_count: Int = 0,
|
||||
val linked_assistant_count: Int = 0,
|
||||
val created_at: String = "",
|
||||
val updated_at: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KnowledgeBaseCreateRequest(
|
||||
val name: String,
|
||||
val project_id: String = "",
|
||||
val description: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AssistantDto(
|
||||
val id: String,
|
||||
val user_id: String,
|
||||
val project_id: String = "",
|
||||
val name: String,
|
||||
val description: String = "",
|
||||
val system_prompt: String = "",
|
||||
val generation_goal: String = "",
|
||||
val knowledge_base_ids: List<String> = emptyList(),
|
||||
val config: JsonObject = buildJsonObject { },
|
||||
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 project_id: 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 project_id: String? = null,
|
||||
val model_profile_id: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ExploreVideoLinkRequest(
|
||||
val video_url: String,
|
||||
val title: String? = null,
|
||||
val project_id: String? = null,
|
||||
val knowledge_base_id: String? = null,
|
||||
val assistant_id: String? = null,
|
||||
val analysis_model_profile_id: String? = null,
|
||||
val language: String = "auto"
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ExploreTextRequest(
|
||||
val title: String,
|
||||
val content: String,
|
||||
val project_id: String? = null,
|
||||
val knowledge_base_id: String? = null,
|
||||
val assistant_id: String? = null,
|
||||
val analysis_model_profile_id: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ContentSourceSyncRequest(
|
||||
val project_id: String = "",
|
||||
val knowledge_base_id: String = "",
|
||||
val assistant_id: String = "",
|
||||
val content_source_id: String = "",
|
||||
val platform: String = "",
|
||||
val handle: String = "",
|
||||
val source_url: String = "",
|
||||
val title: String = "",
|
||||
val analysis_model_profile_id: String = "",
|
||||
val language: String = "auto",
|
||||
val max_items: Int = 5,
|
||||
val skip_existing: Boolean = true,
|
||||
val auto_trigger_analysis: Boolean = true
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class JobDto(
|
||||
val id: String,
|
||||
val user_id: String,
|
||||
val project_id: String = "",
|
||||
val parent_job_id: String = "",
|
||||
val assistant_id: String? = null,
|
||||
val knowledge_base_id: String,
|
||||
val content_source_id: String = "",
|
||||
val source_type: String,
|
||||
val line_type: String = "analysis",
|
||||
val workflow_key: String = "",
|
||||
val orchestrator: String = "n8n",
|
||||
val provider_name: String = "",
|
||||
val provider_task_id: String = "",
|
||||
val source_url: String? = null,
|
||||
val title: String,
|
||||
val language: String,
|
||||
val status: String,
|
||||
val transcript_text: String = "",
|
||||
val style_summary: String = "",
|
||||
val upload_status: String = "pending",
|
||||
val error: String = "",
|
||||
val artifacts: JsonObject = buildJsonObject { },
|
||||
val result: JsonObject = buildJsonObject { },
|
||||
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 analysis: JsonObject = buildJsonObject { },
|
||||
val storyboards: JsonArray = buildJsonArray { },
|
||||
val source_artifacts: JsonObject = buildJsonObject { },
|
||||
val analysis_model_profile_id: String = "",
|
||||
val created_at: String = "",
|
||||
val updated_at: String = ""
|
||||
)
|
||||
|
||||
@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 projects: List<ProjectDto> = emptyList(),
|
||||
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
|
||||
)
|
||||
@@ -1,392 +0,0 @@
|
||||
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 createContentSourceSyncJob(
|
||||
platform: String,
|
||||
handle: String,
|
||||
sourceUrl: String,
|
||||
title: String,
|
||||
knowledgeBaseId: String,
|
||||
assistantId: String,
|
||||
analysisModelProfileId: String,
|
||||
maxItems: Int,
|
||||
skipExisting: Boolean,
|
||||
autoTriggerAnalysis: Boolean
|
||||
): JobDto = api().createContentSourceSyncJob(
|
||||
ContentSourceSyncRequest(
|
||||
knowledge_base_id = knowledgeBaseId,
|
||||
assistant_id = assistantId,
|
||||
platform = platform,
|
||||
handle = handle,
|
||||
source_url = sourceUrl,
|
||||
title = title,
|
||||
analysis_model_profile_id = analysisModelProfileId,
|
||||
max_items = maxItems,
|
||||
skip_existing = skipExisting,
|
||||
auto_trigger_analysis = autoTriggerAnalysis
|
||||
)
|
||||
)
|
||||
|
||||
suspend fun uploadVideo(
|
||||
uri: Uri,
|
||||
title: String,
|
||||
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}$""")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,59 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,983 +0,0 @@
|
||||
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 {
|
||||
Overview,
|
||||
Benchmark,
|
||||
Agent,
|
||||
Production,
|
||||
Mine
|
||||
}
|
||||
|
||||
enum class StoryForgeAuthMode {
|
||||
Login,
|
||||
Register
|
||||
}
|
||||
|
||||
enum class ExploreInputMode {
|
||||
ContentSource,
|
||||
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.Overview,
|
||||
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 accountSyncPlatform: String = "抖音",
|
||||
val accountSyncHandle: String = "",
|
||||
val accountSyncUrl: String = "",
|
||||
val accountSyncTitle: String = "",
|
||||
val accountSyncMaxItems: String = "5",
|
||||
val accountSyncSkipExisting: Boolean = true,
|
||||
val accountSyncAutoTriggerAnalysis: Boolean = true,
|
||||
val videoUrl: String = "",
|
||||
val videoTitle: String = "",
|
||||
val textTitle: String = "",
|
||||
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 updateAccountSyncPlatform(value: String) {
|
||||
_state.value = _state.value.copy(accountSyncPlatform = value)
|
||||
}
|
||||
|
||||
fun updateAccountSyncHandle(value: String) {
|
||||
_state.value = _state.value.copy(accountSyncHandle = value)
|
||||
}
|
||||
|
||||
fun updateAccountSyncUrl(value: String) {
|
||||
_state.value = _state.value.copy(accountSyncUrl = value)
|
||||
}
|
||||
|
||||
fun updateAccountSyncTitle(value: String) {
|
||||
_state.value = _state.value.copy(accountSyncTitle = value)
|
||||
}
|
||||
|
||||
fun updateAccountSyncMaxItems(value: String) {
|
||||
val digits = value.filter { it.isDigit() }
|
||||
_state.value = _state.value.copy(accountSyncMaxItems = digits)
|
||||
}
|
||||
|
||||
fun setAccountSyncSkipExisting(value: Boolean) {
|
||||
_state.value = _state.value.copy(accountSyncSkipExisting = value)
|
||||
}
|
||||
|
||||
fun setAccountSyncAutoTriggerAnalysis(value: Boolean) {
|
||||
_state.value = _state.value.copy(accountSyncAutoTriggerAnalysis = value)
|
||||
}
|
||||
|
||||
fun updateVideoTitle(value: String) {
|
||||
_state.value = _state.value.copy(videoTitle = value)
|
||||
}
|
||||
|
||||
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 submitContentSourceSync() {
|
||||
val current = state.value
|
||||
if (current.accountSyncUrl.isBlank()) {
|
||||
setError("请先输入账号主页链接")
|
||||
return
|
||||
}
|
||||
val knowledgeBaseId = selectedKnowledgeBaseIdOrFallback()
|
||||
if (knowledgeBaseId.isBlank()) {
|
||||
setError("请先选择知识库")
|
||||
return
|
||||
}
|
||||
val maxItems = current.accountSyncMaxItems.toIntOrNull()?.coerceIn(1, 20) ?: 5
|
||||
runBusy(message = "正在创建账号同步任务...", task = {
|
||||
repository.createContentSourceSyncJob(
|
||||
platform = current.accountSyncPlatform.trim(),
|
||||
handle = current.accountSyncHandle.trim(),
|
||||
sourceUrl = current.accountSyncUrl.trim(),
|
||||
title = current.accountSyncTitle.trim(),
|
||||
knowledgeBaseId = knowledgeBaseId,
|
||||
assistantId = current.selectedAssistantId,
|
||||
analysisModelProfileId = preferredModelId(),
|
||||
maxItems = maxItems,
|
||||
skipExisting = current.accountSyncSkipExisting,
|
||||
autoTriggerAnalysis = current.accountSyncAutoTriggerAnalysis
|
||||
)
|
||||
}) { job ->
|
||||
appendTimeline("账号同步任务已创建: ${job.title}")
|
||||
_state.value = state.value.copy(
|
||||
accountSyncHandle = "",
|
||||
accountSyncUrl = "",
|
||||
accountSyncTitle = "",
|
||||
accountSyncMaxItems = maxItems.toString()
|
||||
)
|
||||
afterJobCreated(job)
|
||||
}
|
||||
}
|
||||
|
||||
fun submitText() {
|
||||
val current = state.value
|
||||
if (current.textTitle.isBlank() || current.textContent.isBlank()) {
|
||||
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.Benchmark
|
||||
)
|
||||
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 ?: "发生未知错误"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,105 +0,0 @@
|
||||
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(0xFF4E89F5),
|
||||
secondary = Color(0xFF87AEEB),
|
||||
tertiary = Color(0xFF17283A),
|
||||
background = Color(0xFFF2F7FF),
|
||||
surface = Color(0xFFFFFFFF),
|
||||
surfaceVariant = Color(0xFFEAF2FF),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onBackground = Color(0xFF152332),
|
||||
onSurface = Color(0xFF152332),
|
||||
outline = Color(0xFFC9D8EA)
|
||||
)
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = Color(0xFF8CB7FF),
|
||||
secondary = Color(0xFF7EA5DE),
|
||||
tertiary = Color(0xFFE6EEF9),
|
||||
background = Color(0xFF101823),
|
||||
surface = Color(0xFF162131),
|
||||
surfaceVariant = Color(0xFF1D2B3D),
|
||||
onPrimary = Color(0xFF0C1B30),
|
||||
onSecondary = Color(0xFF0C1B30),
|
||||
onBackground = Color(0xFFEAF1FB),
|
||||
onSurface = Color(0xFFEAF1FB),
|
||||
outline = Color(0xFF35506F)
|
||||
)
|
||||
|
||||
private val AppTypography = Typography(
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 30.sp,
|
||||
lineHeight = 36.sp
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 26.sp,
|
||||
lineHeight = 32.sp
|
||||
),
|
||||
headlineSmall = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 26.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 21.sp
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 18.sp
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AIGlassesTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = if (darkTheme) DarkColors else LightColors,
|
||||
typography = AppTypography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -1,559 +0,0 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">StoryForge AI</string>
|
||||
</resources>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.AIGlasses" parent="Theme.Material3.DayNight.NoActionBar" />
|
||||
</resources>
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path
|
||||
name="ota_cache"
|
||||
path="ota/" />
|
||||
</paths>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
</network-security-config>
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
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
BIN
android-app/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
@@ -1,7 +0,0 @@
|
||||
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
249
android-app/gradlew
vendored
@@ -1,249 +0,0 @@
|
||||
#!/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
92
android-app/gradlew.bat
vendored
@@ -1,92 +0,0 @@
|
||||
@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
|
||||
@@ -1,19 +0,0 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "AIGlassesApp"
|
||||
include(":app")
|
||||
|
||||
3
collector-service/.dockerignore
Normal file
3
collector-service/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
.venv311
|
||||
app/__pycache__
|
||||
*.pyc
|
||||
@@ -1,4 +1,5 @@
|
||||
FROM python:3.11-slim
|
||||
ARG BASE_IMAGE=python:3.11-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
|
||||
@@ -1 +1 @@
|
||||
# StoryForge collector-service package
|
||||
"""Collector service source overlay for legacy pyc-backed app."""
|
||||
|
||||
6044
collector-service/app/core_main.py
Normal file
6044
collector-service/app/core_main.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,16 @@
|
||||
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
|
||||
|
||||
@@ -22,9 +27,20 @@ class Database:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.path)
|
||||
conn = sqlite3.connect(self.path, timeout=SQLITE_CONNECT_TIMEOUT_SEC)
|
||||
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
|
||||
@@ -211,6 +227,95 @@ class Database:
|
||||
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,
|
||||
@@ -235,6 +340,14 @@ 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)
|
||||
@@ -265,6 +378,16 @@ class Database:
|
||||
"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():
|
||||
|
||||
2119
collector-service/app/domestic_platform_features.py
Normal file
2119
collector-service/app/domestic_platform_features.py
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -133,6 +133,15 @@ class CutVideoClient:
|
||||
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__(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
8426
collector-service/app/oneliner_features.py
Normal file
8426
collector-service/app/oneliner_features.py
Normal file
File diff suppressed because it is too large
Load Diff
41
collector-service/run_source_overlay.sh
Executable file
41
collector-service/run_source_overlay.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/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"
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
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
|
||||
|
||||
70
deploy/STORYFORGE_PUBLIC_GATEWAY.md
Normal file
70
deploy/STORYFORGE_PUBLIC_GATEWAY.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# 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` 条目。
|
||||
@@ -62,4 +62,4 @@ fi
|
||||
log "post-check: verifying StoryForge collector and n8n"
|
||||
check_url "$COLLECTOR_HEALTH_URL"
|
||||
check_url "$N8N_HEALTH_URL"
|
||||
log "legacy FastGPT runtime cleanup completed"
|
||||
log "legacy runtime cleanup completed"
|
||||
48
deploy/com.storyforge.cloud-bridge.plist.example
Normal file
48
deploy/com.storyforge.cloud-bridge.plist.example
Normal file
@@ -0,0 +1,48 @@
|
||||
<?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>
|
||||
29
deploy/com.storyforge.web-v4.plist.example
Normal file
29
deploy/com.storyforge.web-v4.plist.example
Normal file
@@ -0,0 +1,29 @@
|
||||
<?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>
|
||||
44
deploy/cutover_storyforge_collector_overlay.sh
Executable file
44
deploy/cutover_storyforge_collector_overlay.sh
Executable file
@@ -0,0 +1,44 @@
|
||||
#!/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
|
||||
9
deploy/rollback_storyforge_collector_overlay.sh
Executable file
9
deploy/rollback_storyforge_collector_overlay.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/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
|
||||
4
deploy/storyforge-collector-runtime-fixes.yml
Normal file
4
deploy/storyforge-collector-runtime-fixes.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
services:
|
||||
collector:
|
||||
environment:
|
||||
N8N_BASE_URL: http://n8n:5678
|
||||
6
deploy/storyforge-collector-source-overlay.yml
Normal file
6
deploy/storyforge-collector-source-overlay.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
collector:
|
||||
environment:
|
||||
N8N_BASE_URL: http://n8n:5678
|
||||
volumes:
|
||||
- ${COLLECTOR_APP_OVERLAY_DIR:-/Users/kris/code/StoryForge/collector-service/app}:/app/app:ro
|
||||
34
deploy/storyforge-collector.service.example
Normal file
34
deploy/storyforge-collector.service.example
Normal file
@@ -0,0 +1,34 @@
|
||||
[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
|
||||
16
deploy/storyforge-fnos-cliproxy.compose.yaml
Normal file
16
deploy/storyforge-fnos-cliproxy.compose.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
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"
|
||||
51
deploy/storyforge-fnos-collector.compose.yaml
Normal file
51
deploy/storyforge-fnos-collector.compose.yaml
Normal file
@@ -0,0 +1,51 @@
|
||||
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
|
||||
25
deploy/storyforge-fnos-huobao.compose.yaml
Normal file
25
deploy/storyforge-fnos-huobao.compose.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
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
|
||||
64
deploy/storyforge-fnos-live-recorder.compose.yaml
Normal file
64
deploy/storyforge-fnos-live-recorder.compose.yaml
Normal file
@@ -0,0 +1,64 @@
|
||||
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
|
||||
21
deploy/storyforge-fnos-n8n.compose.yaml
Normal file
21
deploy/storyforge-fnos-n8n.compose.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
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"
|
||||
10
deploy/storyforge-fnos-web-v4.compose.yaml
Normal file
10
deploy/storyforge-fnos-web-v4.compose.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
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
|
||||
27
deploy/storyforge-fnos-web-v4.nginx.conf
Normal file
27
deploy/storyforge-fnos-web-v4.nginx.conf
Normal file
@@ -0,0 +1,27 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
117
deploy/storyforge-hyzq-net-nginx.conf
Normal file
117
deploy/storyforge-hyzq-net-nginx.conf
Normal file
@@ -0,0 +1,117 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
25
deploy/storyforge-server-huobao.compose.yaml
Normal file
25
deploy/storyforge-server-huobao.compose.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
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
|
||||
27
deploy/storyforge-server-n8n.compose.yaml
Normal file
27
deploy/storyforge-server-n8n.compose.yaml
Normal file
@@ -0,0 +1,27 @@
|
||||
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
|
||||
14
deploy/storyforge-web-v4.service.example
Normal file
14
deploy/storyforge-web-v4.service.example
Normal file
@@ -0,0 +1,14 @@
|
||||
[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
|
||||
207
deploy/storyforge-windows-asr-http/app.py
Normal file
207
deploy/storyforge-windows-asr-http/app.py
Normal file
@@ -0,0 +1,207 @@
|
||||
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)
|
||||
19
deploy/storyforge-windows-asr-http/bridge-cloud.ps1
Normal file
19
deploy/storyforge-windows-asr-http/bridge-cloud.ps1
Normal file
@@ -0,0 +1,19 @@
|
||||
$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
|
||||
14
deploy/storyforge-windows-asr-http/launch-asr.ps1
Normal file
14
deploy/storyforge-windows-asr-http/launch-asr.ps1
Normal file
@@ -0,0 +1,14 @@
|
||||
$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
|
||||
22
deploy/storyforge-windows-asr-http/register-tasks.ps1
Normal file
22
deploy/storyforge-windows-asr-http/register-tasks.ps1
Normal file
@@ -0,0 +1,22 @@
|
||||
$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
|
||||
}
|
||||
7
deploy/storyforge-windows-asr-http/requirements.txt
Normal file
7
deploy/storyforge-windows-asr-http/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
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"
|
||||
28
deploy/storyforge-windows-asr-http/run.ps1
Normal file
28
deploy/storyforge-windows-asr-http/run.ps1
Normal file
@@ -0,0 +1,28 @@
|
||||
$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
|
||||
}
|
||||
@@ -8,6 +8,8 @@ services:
|
||||
N8N_PORT: 5678
|
||||
N8N_PROTOCOL: ${N8N_PROTOCOL:-http}
|
||||
WEBHOOK_URL: ${WEBHOOK_URL:-http://127.0.0.1:5670/}
|
||||
STORYFORGE_INTERNAL_BASE_URL: ${STORYFORGE_INTERNAL_BASE_URL:-http://collector:8081}
|
||||
STORYFORGE_ORCHESTRATOR_SECRET: ${ORCHESTRATOR_SHARED_SECRET:-__set_a_strong_shared_secret__}
|
||||
GENERIC_TIMEZONE: ${GENERIC_TIMEZONE:-Asia/Shanghai}
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
N8N_SECURE_COOKIE: ${N8N_SECURE_COOKIE:-false}
|
||||
@@ -28,7 +30,7 @@ services:
|
||||
environment:
|
||||
DATA_DIR: /data/collector
|
||||
DATABASE_PATH: /data/collector/storyforge.db
|
||||
DEFAULT_EXTERNAL_BASE_URL: ${DEFAULT_EXTERNAL_BASE_URL:-https://test.hyzq.net/storyforge}
|
||||
DEFAULT_EXTERNAL_BASE_URL: ${DEFAULT_EXTERNAL_BASE_URL:-https://storyforge.hyzq.net}
|
||||
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:-}
|
||||
@@ -37,7 +39,10 @@ services:
|
||||
N8N_REAL_CUT_WEBHOOK_PATH: ${N8N_REAL_CUT_WEBHOOK_PATH:-/webhook/storyforge-real-cut}
|
||||
N8N_AI_VIDEO_WEBHOOK_PATH: ${N8N_AI_VIDEO_WEBHOOK_PATH:-/webhook/storyforge-ai-video}
|
||||
N8N_CONTENT_SOURCE_SYNC_WEBHOOK_PATH: ${N8N_CONTENT_SOURCE_SYNC_WEBHOOK_PATH:-/webhook/storyforge-content-source-sync}
|
||||
ORCHESTRATOR_SHARED_SECRET: ${ORCHESTRATOR_SHARED_SECRET:-storyforge-local-secret}
|
||||
BOOTSTRAP_SUPERADMIN_USERNAME: ${BOOTSTRAP_SUPERADMIN_USERNAME:-}
|
||||
BOOTSTRAP_SUPERADMIN_PASSWORD: ${BOOTSTRAP_SUPERADMIN_PASSWORD:-}
|
||||
BOOTSTRAP_SUPERADMIN_DISPLAY_NAME: ${BOOTSTRAP_SUPERADMIN_DISPLAY_NAME:-StoryForge Admin}
|
||||
ORCHESTRATOR_SHARED_SECRET: ${ORCHESTRATOR_SHARED_SECRET:-__set_a_strong_shared_secret__}
|
||||
CUTVIDEO_BASE_URL: ${CUTVIDEO_BASE_URL:-}
|
||||
CUTVIDEO_API_KEY: ${CUTVIDEO_API_KEY:-}
|
||||
CUTVIDEO_BASE_CONFIG: ${CUTVIDEO_BASE_CONFIG:-example.job.yaml}
|
||||
@@ -65,6 +70,14 @@ services:
|
||||
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"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
## 结论
|
||||
|
||||
当前应以 `/Users/kris/code/StoryForge-gitea` 作为主工作区继续推进,而不是 `/Users/kris/code/Fastgpt`。后者更像一次不完整的导入快照,前者才是可持续开发的真实仓库。
|
||||
当前应以 `/Users/kris/code/StoryForge-gitea` 作为主工作区继续推进,而不是历史旧导入目录。后者更像一次不完整的导入快照,前者才是可持续开发的真实仓库。
|
||||
|
||||
## 现有功能归位
|
||||
|
||||
@@ -16,9 +16,8 @@
|
||||
- 知识库、智能体、任务管理
|
||||
- 视频链接/上传视频/文本三类入口
|
||||
- 下载器、ffmpeg、whisper.cpp 风格的本地处理调用
|
||||
- Android OTA 查询/发布
|
||||
|
||||
### 2. FastGPT 实际承担的功能
|
||||
### 2. 旧数据集运行链实际承担的功能
|
||||
|
||||
- 仅承担“数据集/文档同步”的外部依赖角色
|
||||
- 代码痕迹集中在:
|
||||
@@ -26,7 +25,7 @@
|
||||
- `docker-compose.yml`
|
||||
- 若干 `fastgpt_*` 字段
|
||||
|
||||
结论:FastGPT 并不是业务内核,适合迁移后整体删除。
|
||||
结论:旧数据集运行链并不是业务内核,适合迁移后整体删除。
|
||||
|
||||
### 3. n8n 适合接管的功能
|
||||
|
||||
@@ -168,8 +167,8 @@
|
||||
|
||||
## 当前已完成迁移面
|
||||
|
||||
- FastGPT 运行时依赖已从 `collector-service` 主代码中剥离
|
||||
- 旧 FastGPT 运行残留容器 `storyforge-fastgpt-plugin / sandbox / pg / minio / redis / mongo` 已于 2026-03-20 实际下线并清理
|
||||
- 旧运行链依赖已从 `collector-service` 主代码中剥离
|
||||
- 旧运行残留容器 `plugin / sandbox / pg / minio / redis / mongo` 已于 2026-03-20 实际下线并清理
|
||||
- 数据库已支持 `project/content_source/job_events`
|
||||
- `collector-service` 已增加:
|
||||
- `n8n` 触发
|
||||
@@ -178,14 +177,13 @@
|
||||
- 内部编排接口
|
||||
- `docker-compose.yml` 已改为 `collector + n8n + cli-proxy-api`
|
||||
- `n8n` 工作流导出文件已纳入仓库
|
||||
- `collector-service` 的 live 运行态已回归到 `StoryForge-gitea` 自身源码构建,不再依赖 `/Users/kris/code/Fastgpt/collector-service/app` 的临时 bind mount
|
||||
- `collector-service` 的 live 运行态已回归到 `StoryForge-gitea` 自身源码构建,不再依赖旧导入目录的临时 bind mount
|
||||
- `collector-service` 现已在 live `8081` 提供 `/v2/douyin/*` 接口,并保留原有 `real-cut / ai-video / content-source-sync` 路由
|
||||
- Android Explore 页已补上“账号同步”入口,可直接创建内容源账号同步任务,并支持平台、主页链接、账号标识、最大抓取条数、跳过已存在、自动触发分析等参数
|
||||
- Android 工作区缺失的 `com.aiglasses.app.data` 数据层已从同源代码补回,当前 `./gradlew :app:compileDebugKotlin` 与 `:app:assembleDebug` 均已通过,并产出 `app-debug.apk`
|
||||
- 曾混入本仓库的 `android-app/` 已确认来自独立 `AI Glasses` 工程叠加,现已从 StoryForge 主仓库边界中拆出,后续不再作为当前主工作区的一部分维护
|
||||
|
||||
## 当前主要风险
|
||||
|
||||
1. 小红书账号级内容源还未做真实平台验证
|
||||
2. `douyin` public 直抓仍受反爬限制,但现在已经有“真实浏览器 + 人工登录 + 自动提取 + 回写现有工作台”的可落地协作链
|
||||
3. `huobao-drama-upstream` 已完成代码迁移并可编译,但 fresh smoke 受外部图片/视频凭证 `403 invalid user` 阻塞
|
||||
4. Android 端目前已能完成 Debug APK 构建,但仍缺少真机安装和功能回归验证
|
||||
4. Android / OTA 旧链路已拆出当前仓库,相关验证和发布不再属于 StoryForge 主线范围
|
||||
|
||||
86
docs/CURRENT_PROJECT_STATE_2026-03-26.md
Normal file
86
docs/CURRENT_PROJECT_STATE_2026-03-26.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# 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`,再检查本机桥接链。
|
||||
130
docs/FNOS_LAN_DELIVERY_RUNBOOK_2026-03-27.md
Normal file
130
docs/FNOS_LAN_DELIVERY_RUNBOOK_2026-03-27.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# 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,再看详细日志
|
||||
@@ -5,7 +5,7 @@
|
||||
## Phase 0: 审计与基线收拢
|
||||
|
||||
- 确认主工作区
|
||||
- 识别 FastGPT 真实职责
|
||||
- 识别旧数据集运行链的真实职责
|
||||
- 识别多用户、多项目需要的主数据模型
|
||||
- 对比 `huobao-drama` 旧改版与 upstream
|
||||
- 审计 `cutvideo` 接口能力
|
||||
@@ -18,7 +18,7 @@
|
||||
- 引入 `content_sources`
|
||||
- 引入 `job_events`
|
||||
- 让 `knowledge_bases / assistants / jobs` 全部 project 化
|
||||
- 去掉 `collector-service` 中的 FastGPT 运行时逻辑
|
||||
- 去掉 `collector-service` 中的旧运行链逻辑
|
||||
- 增加 `agents` 别名接口,统一 Agent 语义
|
||||
|
||||
状态:已完成首版
|
||||
@@ -76,7 +76,7 @@
|
||||
|
||||
状态:已完成 API 级集成
|
||||
|
||||
## Phase 6: 删除 FastGPT 运行依赖
|
||||
## Phase 6: 删除旧运行链依赖
|
||||
|
||||
- 删除代码依赖
|
||||
- 删除 compose 服务
|
||||
|
||||
@@ -15,8 +15,11 @@ cp .env.example .env
|
||||
|
||||
- `N8N_BASE_URL=http://127.0.0.1:5670`,用于你在宿主机单独运行 `collector-service`
|
||||
- `COLLECTOR_N8N_BASE_URL=http://n8n:5678`,用于 Docker 里的 `collector`
|
||||
- `ORCHESTRATOR_SHARED_SECRET=storyforge-local-secret`
|
||||
- `CUTVIDEO_BASE_URL=http://<windows-lan-ip>:7860`
|
||||
- `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 可执行文件时填写
|
||||
@@ -29,10 +32,38 @@ cp .env.example .env
|
||||
|
||||
- 如果你单独重建 `collector`,要确保运行时仍带上 `CUTVIDEO_BASE_URL`,否则容器会退回空值
|
||||
- `collector` 容器不要直接复用宿主机的 `N8N_BASE_URL=http://127.0.0.1:5670`,否则容器内会连回自己并导致 webhook 调度失败
|
||||
- 当前已验证可用的 Windows `cutvideo` 地址是 `http://192.168.31.18:7860`
|
||||
- 当前更稳定的 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
|
||||
@@ -58,17 +89,18 @@ docker compose up -d --build
|
||||
|
||||
导入后:
|
||||
|
||||
- 检查每个 HTTP Request 节点的 `X-Orchestrator-Secret`
|
||||
- 如果你改了 `.env` 的 secret,这里必须同步
|
||||
- 确认 n8n 运行环境里有 `STORYFORGE_INTERNAL_BASE_URL`
|
||||
- 确认 n8n 运行环境里有 `STORYFORGE_ORCHESTRATOR_SECRET`
|
||||
- 导入后的 HTTP Request 节点应从环境变量取值,不需要再逐个手改 secret
|
||||
|
||||
## 4. 登录与审批
|
||||
|
||||
默认超级管理员:
|
||||
首次启动前请先在 `.env` 或运行环境里设置 bootstrap 管理员:
|
||||
|
||||
- 用户名:`kris`
|
||||
- 密码:`Asd123456.`
|
||||
- 用户名:`BOOTSTRAP_SUPERADMIN_USERNAME`
|
||||
- 密码:`BOOTSTRAP_SUPERADMIN_PASSWORD`
|
||||
|
||||
新用户注册后,需要用超级管理员审批。
|
||||
首次启动后,用这组账号登录;新用户注册后,仍然需要超级管理员审批。
|
||||
|
||||
## 5. 内容分析链路验证
|
||||
|
||||
@@ -190,8 +222,8 @@ http://127.0.0.1:3618
|
||||
cd /Users/kris/code/StoryForge-gitea/scripts/douyin-browser-capture
|
||||
npm run capture -- \
|
||||
--profile-url https://www.douyin.com/user/your_account \
|
||||
--storyforge-username kris \
|
||||
--storyforge-password 'Asd123456.'
|
||||
--storyforge-username storyforge-admin \
|
||||
--storyforge-password 'your_admin_password'
|
||||
```
|
||||
|
||||
说明:
|
||||
@@ -231,6 +263,14 @@ npm run capture -- \
|
||||
- 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`
|
||||
@@ -276,9 +316,9 @@ npm run capture -- \
|
||||
- 小红书账号级内容源还未做真实平台验证
|
||||
- `huobao-drama-upstream` 代码已迁移完成,但 fresh 生成仍受外部图片/视频凭证 `403 invalid user` 阻塞
|
||||
|
||||
## 10. 旧 FastGPT 残留清理
|
||||
## 10. 旧运行链残留清理
|
||||
|
||||
- 旧 FastGPT runtime 容器已在 2026-03-20 实际清理完成:
|
||||
- 旧运行链残留容器已在 2026-03-20 实际清理完成:
|
||||
- `storyforge-fastgpt-plugin`
|
||||
- `storyforge-sandbox`
|
||||
- `storyforge-pg`
|
||||
@@ -286,27 +326,23 @@ npm run capture -- \
|
||||
- `storyforge-redis`
|
||||
- `storyforge-mongo`
|
||||
- 清理脚本已纳入仓库:
|
||||
- `/Users/kris/code/StoryForge-gitea/deploy/cleanup_legacy_fastgpt_runtime.sh`
|
||||
- `/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 本地构建
|
||||
## 11. Android 说明
|
||||
|
||||
如果你要在本机重新打 Android 包:
|
||||
`android-app/` 已确认属于独立 `AI Glasses` 工程的叠加目录,现已从当前 StoryForge 主仓库拆出。
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/StoryForge-gitea/android-app
|
||||
./gradlew :app:assembleDebug
|
||||
```
|
||||
当前联调范围只包含:
|
||||
|
||||
当前已验证结果:
|
||||
- `collector-service`
|
||||
- `n8n`
|
||||
- `web/storyforge-web-v4`
|
||||
- `scripts/douyin-browser-capture`
|
||||
|
||||
- `:app:compileDebugKotlin` 通过
|
||||
- `:app:assembleDebug` 通过
|
||||
- APK 输出路径:
|
||||
- `/Users/kris/code/StoryForge-gitea/android-app/app/build/outputs/apk/debug/app-debug.apk`
|
||||
如果后续需要维护 Android / OTA 链路,请转到独立仓库:
|
||||
|
||||
补充说明:
|
||||
|
||||
- 工作区根目录的 `.gitignore` 里保留了通用 `data/` 忽略规则,但已对 Android 源码目录 `android-app/app/src/main/java/com/aiglasses/app/data/` 做了白名单放行,避免误伤客户端代码
|
||||
- Gitea:`https://git.hyzq.site/krisolo/ai-glasses`
|
||||
- 本机工作区:`/Users/kris/code/AI-glasses`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# StoryForge MVP 状态
|
||||
|
||||
日期:2026-03-18
|
||||
更新:2026-03-20
|
||||
更新:2026-03-26
|
||||
|
||||
## 已跑通或已完成代码接通
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
- `user -> project -> assistant / knowledge base / job / content source` 数据模型
|
||||
- 文本 / 视频链接 / 上传视频 三类分析任务创建
|
||||
- 内容源账号同步任务创建与子任务派发
|
||||
- Android Explore 页已补上内容源账号同步入口
|
||||
- Android `com.aiglasses.app.data` 数据层已补回,`compileDebugKotlin` 与 `assembleDebug` 已通过
|
||||
- `n8n` 工作流导入、激活与触发接口
|
||||
- 本地下载器调用
|
||||
- 本地 `ffmpeg` / `whisper` 风格入口封装
|
||||
@@ -21,13 +19,18 @@
|
||||
- `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 调度、首尾帧生成、视频生成与结果回写接口
|
||||
- FastGPT 运行时依赖删除
|
||||
- 旧 FastGPT 运行残留容器已实际下线
|
||||
- 旧运行链依赖删除
|
||||
- 旧运行残留容器已实际下线
|
||||
|
||||
## 已验证的真实任务
|
||||
|
||||
@@ -46,7 +49,6 @@
|
||||
- `douyin` 对标关系:`dyrel_c8df266341e74237b99c880eb4b572d8`
|
||||
- `huobao-upstream` 隔离 smoke 剧本:`drama_id=11` (`http://127.0.0.1:5681`)
|
||||
- `huobao-upstream` 隔离 smoke 启动脚本:`/Users/kris/code/huobao-drama-upstream/scripts/run_storyforge_smoke.sh`
|
||||
- Android Debug APK:`/Users/kris/code/StoryForge-gitea/android-app/app/build/outputs/apk/debug/app-debug.apk`
|
||||
- `douyin` 浏览器采集最小 smoke:`/tmp/storyforge-douyin-capture-smoke/2026-03-20T06-49-37.705Z-storyforge_test_001`
|
||||
- `douyin` 控制台 smoke:`/Users/kris/code/StoryForge-gitea/output/playwright/douyin/control-panel/run-mmyzplxp-cw0o7q/2026-03-20T14-24-13.174Z-storyforge_test_001`
|
||||
- `douyin` 控制台提前继续回归 smoke:`/Users/kris/code/StoryForge-gitea/output/playwright/douyin/control-panel/run-mmyzshsp-c6vdhi/2026-03-20T14-26-27.792Z-storyforge_test_001`
|
||||
@@ -59,7 +61,7 @@
|
||||
- `douyin` 控制台点击流已可用,但它仍然依赖本机可打开 Chromium 的环境,不适合放进纯 Docker 容器内部跑 GUI
|
||||
- `huobao-upstream` 已能全量编译;并且旧改版隔离实例也已重放确认,当前 fresh 生成被外部图片/视频凭证统一返回 `403 invalid user`
|
||||
- `huobao-upstream` 已新增 `HUOBAO_TEXT_* / HUOBAO_IMAGE_* / HUOBAO_VIDEO_*` 运行时覆盖能力,后续补新 key 可直接接管数据库配置
|
||||
- Android Debug 包已可本地构建,但尚未完成真机安装验证
|
||||
- Android / OTA 链路已拆回 `AI Glasses` 独立仓库,不再纳入当前 StoryForge MVP 范围
|
||||
|
||||
## 下一步优先级
|
||||
|
||||
|
||||
125
docs/NEXT_THREAD_HANDOFF_2026-05-02.md
Normal file
125
docs/NEXT_THREAD_HANDOFF_2026-05-02.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# 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.
|
||||
47
docs/PRODUCTION_BASELINE_2026-03-26.md
Normal file
47
docs/PRODUCTION_BASELINE_2026-03-26.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 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` 的完整链路仍依赖外部服务可用性。
|
||||
- 抖音等真实平台采集仍可能受到平台风控影响,需要真实联调确认。
|
||||
42
docs/STORYFORGE_REPO_BOUNDARY_2026-03-26.md
Normal file
42
docs/STORYFORGE_REPO_BOUNDARY_2026-03-26.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 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` 作为统一回归入口。
|
||||
252
docs/STORYFORGE_SPLIT_ASSESSMENT_2026-03-26.md
Normal file
252
docs/STORYFORGE_SPLIT_ASSESSMENT_2026-03-26.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# 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 项目。
|
||||
158
docs/WINDOWS_CUTVIDEO_OPERATIONS_2026-03-27.md
Normal file
158
docs/WINDOWS_CUTVIDEO_OPERATIONS_2026-03-27.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# 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` 在线
|
||||
- 前端工作台应恢复显示 `自动剪辑` 在线
|
||||
@@ -0,0 +1,249 @@
|
||||
# fnOS LAN Delivery Stabilization Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 把 StoryForge 的 fnOS / NAS 局域网交付链做成仓库内一键可复现、可 smoke、可恢复的稳定版本。
|
||||
|
||||
**Architecture:** 继续采用“Windows 运行 cutvideo + fnOS 通过 SSH 隧道暴露 19186/19181 + fnOS collector 默认走 19186 + fnOS Web 默认走 fnOS collector”的交付路径。在仓库内新增统一部署入口、统一 LAN smoke、补齐 healthz 路由可见性与前端提示,并把运维说明统一到同一条主链。
|
||||
|
||||
**Tech Stack:** Bash, Python 3, FastAPI, vanilla JS, Docker Compose, fnOS SSH helpers
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 落地统一部署入口
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/deploy_fnos_storyforge_lan_stack.sh`
|
||||
- Modify: `scripts/deploy_fnos_storyforge_web.sh`
|
||||
- Modify: `scripts/deploy_fnos_storyforge_collector.sh`
|
||||
- Test: `tests/test_production_baseline.py`
|
||||
|
||||
- [ ] **Step 1: 写失败测试,约束统一部署入口存在并串联 tunnel / collector / web**
|
||||
|
||||
```python
|
||||
def test_repo_contains_fnos_lan_stack_deploy_entrypoint(self) -> None:
|
||||
script_path = ROOT / "scripts" / "deploy_fnos_storyforge_lan_stack.sh"
|
||||
self.assertTrue(script_path.exists())
|
||||
content = script_path.read_text(encoding="utf-8")
|
||||
self.assertIn("deploy_fnos_cutvideo_tunnel.sh", content)
|
||||
self.assertIn("deploy_fnos_storyforge_collector.sh", content)
|
||||
self.assertIn("deploy_fnos_storyforge_web.sh", content)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认当前失败**
|
||||
|
||||
Run: `python3 -m unittest tests.test_production_baseline.ProductionBaselineTests.test_repo_contains_fnos_lan_stack_deploy_entrypoint -v`
|
||||
Expected: FAIL with missing `deploy_fnos_storyforge_lan_stack.sh`
|
||||
|
||||
- [ ] **Step 3: 写最小实现**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
bash "$ROOT/scripts/deploy_fnos_cutvideo_tunnel.sh"
|
||||
bash "$ROOT/scripts/deploy_fnos_storyforge_collector.sh"
|
||||
bash "$ROOT/scripts/deploy_fnos_storyforge_web.sh"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `python3 -m unittest tests.test_production_baseline.ProductionBaselineTests.test_repo_contains_fnos_lan_stack_deploy_entrypoint -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: 完成后补脚本语法校验**
|
||||
|
||||
Run: `bash -n scripts/deploy_fnos_storyforge_lan_stack.sh`
|
||||
Expected: exit 0
|
||||
|
||||
### Task 2: 落地统一 LAN smoke
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/smoke_fnos_storyforge_lan.sh`
|
||||
- Modify: `tests/test_production_baseline.py`
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/LAN_E2E_GUIDE_2026-03-18.md`
|
||||
|
||||
- [ ] **Step 1: 写失败测试,约束 LAN smoke 覆盖 web / healthz / auto-session / integrations / tunnel**
|
||||
|
||||
```python
|
||||
def test_repo_contains_fnos_lan_smoke_script(self) -> None:
|
||||
script_path = ROOT / "scripts" / "smoke_fnos_storyforge_lan.sh"
|
||||
self.assertTrue(script_path.exists())
|
||||
content = script_path.read_text(encoding="utf-8")
|
||||
for expected in [
|
||||
"/healthz",
|
||||
"/v2/auth/auto-session",
|
||||
"/v2/integrations/health",
|
||||
"/api/bootstrap",
|
||||
"19181",
|
||||
]:
|
||||
self.assertIn(expected, content)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认当前失败**
|
||||
|
||||
Run: `python3 -m unittest tests.test_production_baseline.ProductionBaselineTests.test_repo_contains_fnos_lan_smoke_script -v`
|
||||
Expected: FAIL with missing `smoke_fnos_storyforge_lan.sh`
|
||||
|
||||
- [ ] **Step 3: 写最小实现**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
FNOS_HOST="${FNOS_HOST:-192.168.31.188}"
|
||||
WEB_PORT="${STORYFORGE_WEB_V4_DEV_PORT:-19192}"
|
||||
COLLECTOR_PORT="${STORYFORGE_COLLECTOR_DEV_PORT:-19193}"
|
||||
CUTVIDEO_PORT="${CUTVIDEO_FORWARD_PORT:-19186}"
|
||||
COMPAT_PORT="${STORYFORGE_COMPAT_FORWARD_PORT:-19181}"
|
||||
```
|
||||
|
||||
继续补:
|
||||
- 访问 `http://$FNOS_HOST:$WEB_PORT/`
|
||||
- 校验 `storyforge-runtime-config.js`
|
||||
- 访问 `http://$FNOS_HOST:$COLLECTOR_PORT/healthz`
|
||||
- `POST /v2/auth/auto-session` 获取 token
|
||||
- 带 token 调 `GET /v2/integrations/health`
|
||||
- 校验 `cutvideo.reachable == true`
|
||||
- 访问 `http://$FNOS_HOST:$CUTVIDEO_PORT/api/bootstrap`
|
||||
- 访问 `http://$FNOS_HOST:$COMPAT_PORT/`
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `python3 -m unittest tests.test_production_baseline.ProductionBaselineTests.test_repo_contains_fnos_lan_smoke_script -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: 完成后做脚本语法校验**
|
||||
|
||||
Run: `bash -n scripts/smoke_fnos_storyforge_lan.sh`
|
||||
Expected: exit 0
|
||||
|
||||
### Task 3: 收口 healthz 与前端依赖文案
|
||||
|
||||
**Files:**
|
||||
- Modify: `collector-service/app/core_main.py`
|
||||
- Modify: `web/storyforge-web-v4/assets/app.js`
|
||||
- Modify: `tests/test_production_baseline.py`
|
||||
|
||||
- [ ] **Step 1: 写失败测试,约束 healthz 暴露局域网路由信息**
|
||||
|
||||
```python
|
||||
def test_healthz_exposes_lan_routing_summary(self) -> None:
|
||||
response = self.client.get("/healthz")
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
payload = response.json()
|
||||
self.assertIn("lanRouting", payload)
|
||||
self.assertIn("cutvideoRouteMode", payload["lanRouting"])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认当前失败**
|
||||
|
||||
Run: `python3 -m unittest tests.test_production_baseline.ProductionBaselineTests.test_healthz_exposes_lan_routing_summary -v`
|
||||
Expected: FAIL because `lanRouting` is absent
|
||||
|
||||
- [ ] **Step 3: 写最小实现**
|
||||
|
||||
```python
|
||||
"lanRouting": {
|
||||
"collectorBaseUrl": DEFAULT_EXTERNAL_BASE_URL,
|
||||
"cutvideoBaseUrl": CUTVIDEO_BASE_URL,
|
||||
"liveRecorderBaseUrl": LIVE_RECORDER_BASE_URL,
|
||||
"cutvideoRouteMode": "fnos_tunnel" if ":19186" in CUTVIDEO_BASE_URL else "direct",
|
||||
}
|
||||
```
|
||||
|
||||
前端同步收口:
|
||||
|
||||
```javascript
|
||||
if (key === "cutvideo" && detail.baseUrl.includes(":19186")) {
|
||||
extra = "当前通过 fnOS NAS SSH 隧道访问 Windows cutvideo。";
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `python3 -m unittest tests.test_production_baseline.ProductionBaselineTests.test_healthz_exposes_lan_routing_summary -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: 做前端语法校验**
|
||||
|
||||
Run: `node --check web/storyforge-web-v4/assets/app.js`
|
||||
Expected: exit 0
|
||||
|
||||
### Task 4: 统一 README / LAN 运维手册并补最终回归
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/LAN_E2E_GUIDE_2026-03-18.md`
|
||||
- Create: `docs/FNOS_LAN_DELIVERY_RUNBOOK_2026-03-27.md`
|
||||
|
||||
- [ ] **Step 1: 更新主入口文档**
|
||||
|
||||
把 README 收口成三条默认命令:
|
||||
|
||||
```bash
|
||||
./scripts/deploy_fnos_cutvideo_tunnel.sh
|
||||
./scripts/deploy_fnos_storyforge_lan_stack.sh
|
||||
./scripts/smoke_fnos_storyforge_lan.sh
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 更新 LAN E2E**
|
||||
|
||||
把 `CUTVIDEO_BASE_URL=http://<windows-lan-ip>:7860` 改成“主链默认使用 `http://192.168.31.188:19186`,Windows 直连仅作自检”。
|
||||
|
||||
- [ ] **Step 3: 写新的运维 runbook**
|
||||
|
||||
包含:
|
||||
- 默认端口
|
||||
- 默认路由
|
||||
- 故障分流
|
||||
- fnOS 重启后如何验证 tunnel / web / collector
|
||||
- smoke 命令与预期结果
|
||||
|
||||
- [ ] **Step 4: 跑最终验证**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
bash -n scripts/deploy_fnos_storyforge_lan_stack.sh
|
||||
bash -n scripts/smoke_fnos_storyforge_lan.sh
|
||||
python3 -m unittest tests.test_production_baseline -v
|
||||
node --check web/storyforge-web-v4/assets/app.js
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected:
|
||||
- scripts syntax all pass
|
||||
- unittest pass
|
||||
- JS syntax pass
|
||||
- `git diff --check` clean
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md docs/LAN_E2E_GUIDE_2026-03-18.md docs/FNOS_LAN_DELIVERY_RUNBOOK_2026-03-27.md docs/superpowers/plans/2026-03-27-fnos-lan-delivery-stabilization.md scripts/deploy_fnos_storyforge_lan_stack.sh scripts/smoke_fnos_storyforge_lan.sh tests/test_production_baseline.py collector-service/app/core_main.py web/storyforge-web-v4/assets/app.js
|
||||
git commit -m "feat: stabilize fnos lan delivery flow"
|
||||
```
|
||||
|
||||
## Self-Review
|
||||
|
||||
### Spec coverage
|
||||
|
||||
- 统一部署入口:Task 1 覆盖
|
||||
- LAN smoke:Task 2 覆盖
|
||||
- 前后端状态收口:Task 3 覆盖
|
||||
- 文档与运维统一:Task 4 覆盖
|
||||
|
||||
### Placeholder scan
|
||||
|
||||
- 没有保留 TBD / TODO / “后续补”
|
||||
- 每个任务都给了明确文件和验证命令
|
||||
|
||||
### Type consistency
|
||||
|
||||
- 统一使用 `deploy_fnos_storyforge_lan_stack.sh`
|
||||
- 统一使用 `smoke_fnos_storyforge_lan.sh`
|
||||
- `healthz` 新字段统一命名为 `lanRouting`
|
||||
692
docs/superpowers/plans/2026-03-28-homepage-workbench-redesign.md
Normal file
692
docs/superpowers/plans/2026-03-28-homepage-workbench-redesign.md
Normal file
@@ -0,0 +1,692 @@
|
||||
# Homepage Workbench Redesign Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rebuild the StoryForge homepage into the approved human-first `v6` structure while preserving the current visual language, reducing text density, surfacing `1 主 2 次` actions first, and moving system governance entry points into an explicit admin workbench flow.
|
||||
|
||||
**Architecture:** Keep the existing static-script frontend architecture, but pull homepage-specific rendering into a dedicated browser module so the dashboard layout can be tested without dragging the entire `app.js` file into every change. The existing `renderDashboardScreen()` function becomes an orchestrator: it gathers runtime data, delegates HTML generation to a dedicated homepage renderer, and wires click handlers through the existing global action system and quick-action modal.
|
||||
|
||||
**Tech Stack:** Vanilla browser JS (IIFE modules on `window`), HTML string rendering, CSS in `assets/styles.css`, Python baseline tests, Node built-in test runner for homepage markup contracts.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extract Homepage Rendering Into a Dedicated Module
|
||||
|
||||
**Files:**
|
||||
- Create: `web/storyforge-web-v4/assets/storyforge-dashboard-home.js`
|
||||
- Create: `web/storyforge-web-v4/tests/dashboard-home.test.mjs`
|
||||
- Modify: `web/storyforge-web-v4/index.html`
|
||||
- Modify: `web/storyforge-web-v4/assets/app.js`
|
||||
|
||||
- [ ] **Step 1: Write the failing homepage renderer test**
|
||||
|
||||
Create `web/storyforge-web-v4/tests/dashboard-home.test.mjs`:
|
||||
|
||||
```js
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
|
||||
const ROOT = path.resolve(process.cwd(), "web/storyforge-web-v4");
|
||||
|
||||
function loadHomepageModule() {
|
||||
const source = fs.readFileSync(path.join(ROOT, "assets/storyforge-dashboard-home.js"), "utf8");
|
||||
const context = {
|
||||
window: {},
|
||||
console,
|
||||
escapeHtml: (value) => String(value ?? ""),
|
||||
formatNumber: (value) => String(value ?? 0),
|
||||
safeArray: (value) => Array.isArray(value) ? value : [],
|
||||
button: (label, action, tone = "secondary") =>
|
||||
`<button class="btn btn-${tone}" data-action="${action}">${label}</button>`
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(source, context);
|
||||
return context.window.StoryForgeDashboardHome;
|
||||
}
|
||||
|
||||
test("homepage v6 puts actions before overview and uses 1-primary-2-secondary structure", () => {
|
||||
const mod = loadHomepageModule();
|
||||
const html = mod.renderDashboardHome({
|
||||
title: "项目总台",
|
||||
workspaceLabel: "Kris",
|
||||
currentProjectName: "品牌增长实验室",
|
||||
summaryTabs: [
|
||||
{ key: "project_progress", label: "项目进度", value: "3 / 5", hint: "2 项可继续推进", active: true },
|
||||
{ key: "focus_accounts", label: "重点账号 / 对标", value: "2 个", hint: "1 个缺高分分析", active: false },
|
||||
{ key: "production_jobs", label: "生产任务", value: "4 条", hint: "1 条待确认", active: false }
|
||||
],
|
||||
primaryAction: {
|
||||
title: "先补抖音重点对标的高分作品分析",
|
||||
reason: "最近有新作品,但还没形成高分样本。",
|
||||
badges: ["最优先", "预计 10 分钟判断", "关联:重点账号"]
|
||||
},
|
||||
secondaryActions: [
|
||||
{ title: "确认一个待执行的生产计划", reason: "素材和结论都在,只差最后确认。" },
|
||||
{ title: "更新重点账号的跟踪摘要", reason: "有新动态,但不值得占据大块首页空间。" }
|
||||
],
|
||||
overviewDetail: {
|
||||
title: "当前阶段",
|
||||
body: "这里只展示当前 tab 的核心状态。"
|
||||
}
|
||||
});
|
||||
|
||||
assert.ok(html.includes("今天先做什么"));
|
||||
assert.ok(html.includes("项目概览"));
|
||||
assert.ok(html.indexOf("今天先做什么") < html.indexOf("项目概览"));
|
||||
assert.match(html, /先补抖音重点对标的高分作品分析/);
|
||||
assert.match(html, /确认一个待执行的生产计划/);
|
||||
assert.match(html, /更新重点账号的跟踪摘要/);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the new test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/StoryForge-gitea
|
||||
node --test web/storyforge-web-v4/tests/dashboard-home.test.mjs
|
||||
```
|
||||
|
||||
Expected: FAIL with `ENOENT` for `storyforge-dashboard-home.js`.
|
||||
|
||||
- [ ] **Step 3: Create the dedicated homepage renderer module**
|
||||
|
||||
Create `web/storyforge-web-v4/assets/storyforge-dashboard-home.js`:
|
||||
|
||||
```js
|
||||
(function () {
|
||||
function defaultEscapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function renderTags(items, escapeHtml) {
|
||||
return (items || []).map((item) => `<span class="tag">${escapeHtml(item)}</span>`).join("");
|
||||
}
|
||||
|
||||
function renderSecondaryAction(item, index, escapeHtml) {
|
||||
return `
|
||||
<div class="dashboard-action-secondary">
|
||||
<div class="dashboard-action-index">${index + 2}</div>
|
||||
<div>
|
||||
<h5>${escapeHtml(item.title)}</h5>
|
||||
<p>${escapeHtml(item.reason)}</p>
|
||||
</div>
|
||||
<div class="dashboard-action-buttons">
|
||||
<button class="btn btn-ghost" data-action="${escapeHtml(item.reasonAction || "open-action-reason")}">原因</button>
|
||||
<button class="btn btn-secondary" data-action="${escapeHtml(item.goAction || "goto-production")}">${escapeHtml(item.goLabel || "去处理")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDashboardHome(model, helpers = {}) {
|
||||
const escapeHtml = helpers.escapeHtml || defaultEscapeHtml;
|
||||
return `
|
||||
<div class="dashboard-home">
|
||||
<div class="dashboard-context-row">
|
||||
<div class="dashboard-context-left">
|
||||
<div class="dashboard-context-chip">
|
||||
<strong>当前工作区</strong><span>${escapeHtml(model.workspaceLabel)}</span>
|
||||
</div>
|
||||
<button class="dashboard-context-chip" data-action="open-dashboard-project-switcher">
|
||||
<strong>当前项目</strong><span>${escapeHtml(model.currentProjectName)}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dashboard-context-right">
|
||||
${model.contextLinks.map((item) => `
|
||||
<button class="dashboard-context-chip" data-action="${escapeHtml(item.action)}">
|
||||
<span>${escapeHtml(item.label)}</span><strong>${escapeHtml(item.value)}</strong>
|
||||
</button>
|
||||
`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel pad dashboard-priority-panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h3>今天先做什么</h3>
|
||||
<div class="panel-subtitle">先做决定,再看细节。</div>
|
||||
</div>
|
||||
<span class="tag blue">${escapeHtml(model.actionSourceLabel)}</span>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-action-primary">
|
||||
<div>
|
||||
<h4>${escapeHtml(model.primaryAction.title)}</h4>
|
||||
<p>${escapeHtml(model.primaryAction.reason)}</p>
|
||||
<div class="task-meta">${renderTags(model.primaryAction.badges, escapeHtml)}</div>
|
||||
</div>
|
||||
<div class="dashboard-action-buttons">
|
||||
<button class="btn btn-ghost" data-action="open-action-reason">查看原因</button>
|
||||
<button class="btn btn-secondary" data-action="${escapeHtml(model.primaryAction.goAction)}">${escapeHtml(model.primaryAction.goLabel)}</button>
|
||||
<button class="btn btn-primary" data-action="open-oneliner">${escapeHtml(model.primaryAction.agentLabel)}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-action-secondary-list">
|
||||
${model.secondaryActions.map((item, index) => renderSecondaryAction(item, index, escapeHtml)).join("")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel pad dashboard-overview-panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h3>项目概览</h3>
|
||||
<div class="panel-subtitle">按需展开,不抢首页第一优先级。</div>
|
||||
</div>
|
||||
<span class="tag">${escapeHtml(model.activeTabLabel)}</span>
|
||||
</div>
|
||||
<div class="dashboard-overview-tabs">
|
||||
${model.summaryTabs.map((item) => `
|
||||
<button class="dashboard-overview-tab ${item.active ? "is-active" : ""}" data-action="select-dashboard-tab" data-dashboard-tab="${escapeHtml(item.key)}">
|
||||
<small>${escapeHtml(item.label)}</small>
|
||||
<strong>${escapeHtml(item.value)}</strong>
|
||||
<span>${escapeHtml(item.hint)}</span>
|
||||
</button>
|
||||
`).join("")}
|
||||
</div>
|
||||
<div class="dashboard-overview-body">${model.overviewBodyHtml}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
window.StoryForgeDashboardHome = {
|
||||
renderDashboardHome
|
||||
};
|
||||
})();
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire the new module into the page**
|
||||
|
||||
Modify `web/storyforge-web-v4/index.html`:
|
||||
|
||||
```html
|
||||
<script src="./assets/storyforge-dashboard-home.js"></script>
|
||||
<script src="./assets/app.js"></script>
|
||||
```
|
||||
|
||||
Modify `web/storyforge-web-v4/assets/app.js` near `renderDashboardScreen()`:
|
||||
|
||||
```js
|
||||
const dashboardHomeRenderer = window.StoryForgeDashboardHome;
|
||||
|
||||
function renderDashboardScreen() {
|
||||
// existing auth/loading guards stay in place
|
||||
const homeModel = buildDashboardHomeModel();
|
||||
return screenShell(
|
||||
"项目总台",
|
||||
"先做最能推进当前项目的事。",
|
||||
`${button("新建项目", "create-project")} ${button("导入主页", "open-import-homepage")} ${button("创建 Agent", "open-create-assistant", "primary")}`,
|
||||
dashboardHomeRenderer.renderDashboardHome(homeModel, { escapeHtml })
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Re-run the renderer test and syntax checks**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/StoryForge-gitea
|
||||
node --test web/storyforge-web-v4/tests/dashboard-home.test.mjs
|
||||
node --check web/storyforge-web-v4/assets/storyforge-dashboard-home.js
|
||||
node --check web/storyforge-web-v4/assets/app.js
|
||||
```
|
||||
|
||||
Expected: all PASS with the Node test showing `ok 1`.
|
||||
|
||||
- [ ] **Step 6: Commit the extraction**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/StoryForge-gitea
|
||||
git add web/storyforge-web-v4/assets/storyforge-dashboard-home.js web/storyforge-web-v4/tests/dashboard-home.test.mjs web/storyforge-web-v4/index.html web/storyforge-web-v4/assets/app.js
|
||||
git commit -m "feat: extract homepage dashboard renderer"
|
||||
```
|
||||
|
||||
### Task 2: Implement Human-First Dashboard Data Model and 1-Primary-2-Secondary Actions
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/storyforge-web-v4/assets/storyforge-dashboard-home.js`
|
||||
- Modify: `web/storyforge-web-v4/assets/app.js`
|
||||
- Modify: `web/storyforge-web-v4/tests/dashboard-home.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Add failing tests for homepage model generation**
|
||||
|
||||
Append to `web/storyforge-web-v4/tests/dashboard-home.test.mjs`:
|
||||
|
||||
```js
|
||||
test("homepage model builds one primary action, two secondary actions, and a rule fallback label", () => {
|
||||
const mod = loadHomepageModule();
|
||||
assert.equal(typeof mod.createDashboardHomeModel, "function");
|
||||
|
||||
const model = mod.createDashboardHomeModel({
|
||||
workspaceLabel: "Kris",
|
||||
currentProjectName: "品牌增长实验室",
|
||||
trackedAccountsCount: 2,
|
||||
assistantCount: 1,
|
||||
jobCount: 4,
|
||||
actionSourceLabel: "规则推荐",
|
||||
dashboardOverviewTab: "project_progress"
|
||||
});
|
||||
|
||||
assert.equal(model.actionSourceLabel, "规则推荐");
|
||||
assert.equal(model.secondaryActions.length, 2);
|
||||
assert.match(model.primaryAction.title, /高分作品分析|继续补高分对标/);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the targeted Node tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/StoryForge-gitea
|
||||
node --test web/storyforge-web-v4/tests/dashboard-home.test.mjs
|
||||
```
|
||||
|
||||
Expected: FAIL because the renderer does not yet expose the full `contextLinks` / `actionSourceLabel` model consistently.
|
||||
|
||||
- [ ] **Step 3: Add a reusable homepage model builder in `storyforge-dashboard-home.js`**
|
||||
|
||||
Modify `web/storyforge-web-v4/assets/storyforge-dashboard-home.js`:
|
||||
|
||||
```js
|
||||
function createDashboardHomeModel(raw) {
|
||||
const trackedAccountsCount = Number(raw.trackedAccountsCount || 0);
|
||||
const assistantCount = Number(raw.assistantCount || 0);
|
||||
const jobCount = Number(raw.jobCount || 0);
|
||||
|
||||
const actions = [];
|
||||
if (trackedAccountsCount > 0) {
|
||||
actions.push({
|
||||
title: "先补抖音重点对标的高分作品分析",
|
||||
reason: "最近有新作品,但还没形成高分样本。",
|
||||
badges: ["最优先", "预计 10 分钟判断", "关联:重点账号"],
|
||||
goAction: "goto-discovery",
|
||||
goLabel: "去找对标",
|
||||
agentLabel: "交给主 Agent"
|
||||
});
|
||||
}
|
||||
if (jobCount > 0) {
|
||||
actions.push({
|
||||
title: "确认一个待执行的生产计划",
|
||||
reason: "素材和结论都在,只差最后确认。",
|
||||
goAction: "goto-production",
|
||||
goLabel: "去处理"
|
||||
});
|
||||
}
|
||||
actions.push({
|
||||
title: "更新重点账号的跟踪摘要",
|
||||
reason: "有新动态,但不值得占据大块首页空间。",
|
||||
goAction: "goto-tracking",
|
||||
goLabel: "去处理"
|
||||
});
|
||||
while (actions.length < 3) {
|
||||
actions.push({
|
||||
title: "继续补高分对标并安排生产",
|
||||
reason: "当前项目没有更多高优先动作时,保持主流程推进。",
|
||||
goAction: "goto-production",
|
||||
goLabel: "去处理"
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceLabel: raw.workspaceLabel,
|
||||
currentProjectName: raw.currentProjectName,
|
||||
actionSourceLabel: raw.actionSourceLabel,
|
||||
contextLinks: [
|
||||
{ label: "账号", value: String(trackedAccountsCount), action: "goto-owned" },
|
||||
{ label: "任务", value: String(jobCount), action: "goto-production" },
|
||||
{ label: "Agent", value: String(assistantCount), action: "goto-playbook" }
|
||||
],
|
||||
primaryAction: actions[0],
|
||||
secondaryActions: actions.slice(1, 3)
|
||||
};
|
||||
}
|
||||
|
||||
window.StoryForgeDashboardHome = {
|
||||
createDashboardHomeModel,
|
||||
renderDashboardHome
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add dashboard-specific state and wire the model builder from `app.js`**
|
||||
|
||||
Modify `web/storyforge-web-v4/assets/app.js` state setup:
|
||||
|
||||
```js
|
||||
const appState = {
|
||||
// existing fields...
|
||||
dashboardOverviewTab: "project_progress",
|
||||
dashboardActionReason: null
|
||||
};
|
||||
```
|
||||
|
||||
Build the raw dashboard inputs in `web/storyforge-web-v4/assets/app.js`:
|
||||
|
||||
```js
|
||||
function getDashboardActionSourceLabel() {
|
||||
return appState.onelinerProfile ? "主 Agent 优先推荐" : "规则推荐";
|
||||
}
|
||||
|
||||
function buildDashboardHomeModel() {
|
||||
const project = getSelectedProject();
|
||||
const stats = project ? getProjectStats(project.id) : { assistants: [], jobs: [], sources: [], knowledgeBases: [] };
|
||||
const trackedAccounts = getTrackingAccounts();
|
||||
const baseModel = window.StoryForgeDashboardHome.createDashboardHomeModel({
|
||||
workspaceLabel: appState.me?.display_name || appState.me?.username || "当前工作区",
|
||||
currentProjectName: project?.name || "还没有项目",
|
||||
trackedAccountsCount: trackedAccounts.length || appState.accounts.length,
|
||||
assistantCount: stats.assistants.length,
|
||||
jobCount: stats.jobs.length,
|
||||
actionSourceLabel: getDashboardActionSourceLabel(),
|
||||
dashboardOverviewTab: appState.dashboardOverviewTab
|
||||
});
|
||||
return {
|
||||
...baseModel,
|
||||
summaryTabs: buildDashboardOverviewTabs(project, stats),
|
||||
activeTabLabel: dashboardTabLabel(appState.dashboardOverviewTab),
|
||||
overviewBodyHtml: renderDashboardOverviewBody(appState.dashboardOverviewTab, { project, stats, trackedAccounts })
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Re-run tests and syntax checks**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/StoryForge-gitea
|
||||
node --test web/storyforge-web-v4/tests/dashboard-home.test.mjs
|
||||
node --check web/storyforge-web-v4/assets/storyforge-dashboard-home.js
|
||||
node --check web/storyforge-web-v4/assets/app.js
|
||||
```
|
||||
|
||||
Expected: PASS with no missing-field errors.
|
||||
|
||||
- [ ] **Step 6: Commit the action hierarchy work**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/StoryForge-gitea
|
||||
git add web/storyforge-web-v4/assets/storyforge-dashboard-home.js web/storyforge-web-v4/assets/app.js web/storyforge-web-v4/tests/dashboard-home.test.mjs
|
||||
git commit -m "feat: redesign dashboard actions for human-first flow"
|
||||
```
|
||||
|
||||
### Task 3: Implement Overview Tabs, Project Switcher, and Admin Workbench Entry
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/storyforge-web-v4/index.html`
|
||||
- Modify: `web/storyforge-web-v4/assets/app.js`
|
||||
- Modify: `web/storyforge-web-v4/assets/storyforge-dashboard-home.js`
|
||||
- Modify: `web/storyforge-web-v4/tests/dashboard-home.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Add failing tests for overview tab buttons and admin entry**
|
||||
|
||||
Append to `web/storyforge-web-v4/tests/dashboard-home.test.mjs`:
|
||||
|
||||
```js
|
||||
test("homepage overview uses tab buttons and does not render legacy repeated sections", () => {
|
||||
const mod = loadHomepageModule();
|
||||
const html = mod.renderDashboardHome({
|
||||
workspaceLabel: "Kris",
|
||||
currentProjectName: "品牌增长实验室",
|
||||
contextLinks: [],
|
||||
actionSourceLabel: "主 Agent 优先推荐",
|
||||
primaryAction: { title: "A", reason: "B", badges: [], goAction: "x", goLabel: "去处理", agentLabel: "交给主 Agent" },
|
||||
secondaryActions: [],
|
||||
summaryTabs: [
|
||||
{ key: "project_progress", label: "项目进度", value: "3 / 5", hint: "2 项可继续推进", active: true }
|
||||
],
|
||||
activeTabLabel: "项目进度",
|
||||
overviewBodyHtml: "<section>tab body</section>"
|
||||
});
|
||||
|
||||
assert.ok(html.includes('data-action="select-dashboard-tab"'));
|
||||
assert.ok(!html.includes("当前项目推进详情"));
|
||||
assert.ok(!html.includes("重点账号 / 对标</h3><div class=\"panel-subtitle\">右栏保留"));
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the Node test and verify the new assertions fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/StoryForge-gitea
|
||||
node --test web/storyforge-web-v4/tests/dashboard-home.test.mjs
|
||||
```
|
||||
|
||||
Expected: FAIL because the overview renderer and admin entry are not complete yet.
|
||||
|
||||
- [ ] **Step 3: Implement overview-tab state and project switcher reuse**
|
||||
|
||||
Modify `web/storyforge-web-v4/assets/app.js`:
|
||||
|
||||
```js
|
||||
function dashboardTabLabel(value) {
|
||||
return ({
|
||||
project_progress: "项目进度",
|
||||
focus_accounts: "重点账号 / 对标",
|
||||
production_jobs: "生产任务"
|
||||
})[value] || "项目进度";
|
||||
}
|
||||
|
||||
function buildDashboardOverviewTabs(project, stats) {
|
||||
return [
|
||||
{ key: "project_progress", label: "项目进度", value: "3 / 5", hint: "2 项可继续推进", active: appState.dashboardOverviewTab === "project_progress" },
|
||||
{ key: "focus_accounts", label: "重点账号 / 对标", value: formatNumber(getTrackingAccounts().length), hint: "重点对象", active: appState.dashboardOverviewTab === "focus_accounts" },
|
||||
{ key: "production_jobs", label: "生产任务", value: formatNumber(stats.jobs.length), hint: "当前项目任务", active: appState.dashboardOverviewTab === "production_jobs" }
|
||||
];
|
||||
}
|
||||
|
||||
function openDashboardProjectSwitcher() {
|
||||
openActionModal({
|
||||
title: "切换当前项目",
|
||||
description: "首页上下文与动作区会随当前项目一起切换。",
|
||||
submitLabel: "切换项目",
|
||||
fields: [
|
||||
{ name: "projectId", label: "当前项目", type: "select", value: getSelectedProject()?.id || "", options: getProjectOptions() }
|
||||
],
|
||||
onSubmit: async (payload) => {
|
||||
appState.selectedProjectId = payload.projectId;
|
||||
await loadAgentControlSurfaces(appState.selectedProjectId || "");
|
||||
renderAll();
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Add click handling in `web/storyforge-web-v4/assets/app.js`:
|
||||
|
||||
```js
|
||||
if (name === "select-dashboard-tab") {
|
||||
appState.dashboardOverviewTab = action.dataset.dashboardTab || "project_progress";
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
if (name === "open-dashboard-project-switcher") {
|
||||
openDashboardProjectSwitcher();
|
||||
return;
|
||||
}
|
||||
if (name === "goto-owned") {
|
||||
setScreen("owned");
|
||||
return;
|
||||
}
|
||||
if (name === "goto-tracking") {
|
||||
setScreen("tracking");
|
||||
return;
|
||||
}
|
||||
if (name === "goto-playbook") {
|
||||
setScreen("playbook");
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the explicit admin workbench entry and screen**
|
||||
|
||||
Modify `web/storyforge-web-v4/index.html` sidebar:
|
||||
|
||||
```html
|
||||
<button class="nav-item hidden" data-screen-target="admin-workbench" data-role-gate="super_admin">
|
||||
<span class="icon">⚙</span>
|
||||
<span>管理员配置台</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
Modify `web/storyforge-web-v4/assets/app.js`:
|
||||
|
||||
```js
|
||||
function syncRoleGatedNav() {
|
||||
document.querySelectorAll("[data-role-gate]").forEach((element) => {
|
||||
const gate = element.getAttribute("data-role-gate");
|
||||
const visible = gate === "super_admin" ? isSuperAdmin() : true;
|
||||
element.classList.toggle("hidden", !visible);
|
||||
});
|
||||
}
|
||||
|
||||
function renderAdminWorkbenchScreen() {
|
||||
if (!isSuperAdmin()) {
|
||||
return screenShell("管理员配置台", "仅超级管理员可见。", "", renderEmptyState("无权限", "请使用超级管理员账号访问。"));
|
||||
}
|
||||
return screenShell(
|
||||
"管理员配置台",
|
||||
"系统级依赖、存储、平台 Agent 与策略治理。",
|
||||
"",
|
||||
`
|
||||
${renderIntegrationOverviewPanel()}
|
||||
${renderStorageStatusPanel()}
|
||||
${renderPlatformAgentPanel()}
|
||||
${renderAdminOpsOverviewPanel()}
|
||||
${renderAdminFixRunsPanel()}
|
||||
`
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Call `syncRoleGatedNav()` inside `renderAll()` after session/role state has updated.
|
||||
|
||||
- [ ] **Step 5: Re-run targeted tests and syntax checks**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/StoryForge-gitea
|
||||
node --test web/storyforge-web-v4/tests/dashboard-home.test.mjs
|
||||
node --check web/storyforge-web-v4/assets/storyforge-dashboard-home.js
|
||||
node --check web/storyforge-web-v4/assets/app.js
|
||||
```
|
||||
|
||||
Expected: PASS, and homepage markup no longer contains legacy repeated panels.
|
||||
|
||||
- [ ] **Step 6: Commit the overview/admin interaction work**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/StoryForge-gitea
|
||||
git add web/storyforge-web-v4/index.html web/storyforge-web-v4/assets/app.js web/storyforge-web-v4/assets/storyforge-dashboard-home.js web/storyforge-web-v4/tests/dashboard-home.test.mjs
|
||||
git commit -m "feat: add dashboard tab flow and admin workbench entry"
|
||||
```
|
||||
|
||||
### Task 4: Add Styles, Docs, and Regression Coverage
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/storyforge-web-v4/assets/styles.css`
|
||||
- Modify: `web/storyforge-web-v4/README.md`
|
||||
- Modify: `scripts/check_repo_baseline.sh`
|
||||
- Modify: `tests/test_production_baseline.py`
|
||||
|
||||
- [ ] **Step 1: Add a failing baseline regression test for the homepage redesign wiring**
|
||||
|
||||
Append to `tests/test_production_baseline.py`:
|
||||
|
||||
```python
|
||||
def test_baseline_script_covers_homepage_dashboard_node_test(self) -> None:
|
||||
script = (ROOT / "scripts" / "check_repo_baseline.sh").read_text(encoding="utf-8")
|
||||
self.assertIn("dashboard-home.test.mjs", script)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the Python regression test and verify the current branch fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/StoryForge-gitea
|
||||
python3 -m unittest tests.test_production_baseline.ProductionBaselineTests.test_baseline_script_covers_homepage_dashboard_node_test -v
|
||||
```
|
||||
|
||||
Expected: FAIL before `scripts/check_repo_baseline.sh` is updated to run the homepage Node test.
|
||||
|
||||
- [ ] **Step 3: Add the new CSS and update docs/baseline script**
|
||||
|
||||
Modify `web/storyforge-web-v4/assets/styles.css` with homepage-specific classes:
|
||||
|
||||
```css
|
||||
.dashboard-context-row { display:flex; justify-content:space-between; gap:16px; flex-wrap:wrap; }
|
||||
.dashboard-context-chip { display:flex; align-items:center; gap:8px; border:1px solid var(--line); border-radius:14px; padding:10px 12px; background:var(--panel-soft); }
|
||||
.dashboard-priority-panel { display:grid; gap:12px; }
|
||||
.dashboard-action-primary { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:16px; align-items:center; }
|
||||
.dashboard-action-secondary-list { display:grid; gap:10px; }
|
||||
.dashboard-overview-tabs { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
|
||||
.dashboard-overview-tab.is-active { border-color: var(--accent); background: var(--accent-soft); }
|
||||
```
|
||||
|
||||
Modify `web/storyforge-web-v4/README.md`:
|
||||
|
||||
```md
|
||||
- 首页已切到“人类决策优先”结构:
|
||||
- 先显示当前项目与今日动作
|
||||
- 再显示项目概览 tab
|
||||
- 管理员配置台通过独立导航进入
|
||||
```
|
||||
|
||||
Modify `scripts/check_repo_baseline.sh`:
|
||||
|
||||
```sh
|
||||
echo "[5/5] validate homepage dashboard tests"
|
||||
node --test web/storyforge-web-v4/tests/dashboard-home.test.mjs
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the full redesign verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/StoryForge-gitea
|
||||
python3 -m unittest tests.test_platform_contracts tests.test_production_baseline -v
|
||||
node --test web/storyforge-web-v4/tests/dashboard-home.test.mjs
|
||||
node --check web/storyforge-web-v4/assets/storyforge-dashboard-home.js
|
||||
node --check web/storyforge-web-v4/assets/app.js
|
||||
bash scripts/check_repo_baseline.sh
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- Python tests PASS
|
||||
- Node homepage test PASS
|
||||
- `baseline checks passed`
|
||||
- `git diff --check` returns no output
|
||||
|
||||
- [ ] **Step 5: Commit the styling and regression coverage**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/StoryForge-gitea
|
||||
git add web/storyforge-web-v4/assets/styles.css web/storyforge-web-v4/README.md scripts/check_repo_baseline.sh tests/test_production_baseline.py
|
||||
git commit -m "test: cover homepage dashboard redesign"
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user