249 Commits

Author SHA1 Message Date
kris
65db3cd336 chore: sync storyforge handoff state
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-05-02 17:50:21 +08:00
kris
6f0d944a75 feat: add admin model capability overview
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-07 19:47:21 +08:00
kris
748c30517c feat: clarify admin model capability coverage
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-07 19:22:40 +08:00
kris
9e4f32077e feat: add admin model access center
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-07 19:03:46 +08:00
kris
07680dce4f feat: highlight intake and recorder status in production
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-07 16:23:28 +08:00
kris
4cd9ff77d9 feat: surface intake entries inside production center
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-07 16:17:28 +08:00
kris
4f3ca3f20f feat: surface video recorder and recent ai video engine
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-07 16:13:12 +08:00
kris
f0ce9ed80c feat: remember ai video provider per project
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-07 16:08:18 +08:00
kris
8dce288e3a feat: sync ai video forms with provider selection
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-07 16:05:03 +08:00
kris
8304022641 feat: improve ai video entry and video recorder discoverability
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-07 16:02:12 +08:00
kris
0aaac7998a fix: restore direct actions after quota recommendations
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-07 14:11:30 +08:00
kris
206599551a feat: recommend quota packages from live usage
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-07 13:50:28 +08:00
kris
b31c338120 test: harden integration smoke coverage
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-07 13:42:09 +08:00
kris
b72427eea8 feat: refine assistant sheets with project-aware knowledge bases
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-07 13:41:53 +08:00
kris
670f631475 feat: sync creative agent context with source jobs
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-07 13:34:12 +08:00
kris
3cf56a4db6 feat: sync intake context on project changes
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-07 12:59:13 +08:00
kris
cc58e2f03d feat: refresh live recorder sheet defaults
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-07 12:57:00 +08:00
kris
76affab96b feat: refresh live recorder sheet defaults
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-07 12:44:50 +08:00
kris
b67876a458 feat: refresh creative recommendations on source changes
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-07 09:27:04 +08:00
kris
3b4fdbc70a feat: refresh intake title hints on form changes
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-06 16:43:39 +08:00
kris
02231cfb6e feat: recommend smarter intake titles
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-06 14:44:55 +08:00
kris
332064b088 feat: harden direct discovery fallbacks
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-06 14:36:37 +08:00
kris
f276d6e7fb feat: align main agent intents with direct execution
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-06 13:51:39 +08:00
kris
27801107b5 feat: surface integration deployment locations
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-06 13:32:10 +08:00
kris
022c9e5456 feat: surface asr runtime state in workbench
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-06 12:47:31 +08:00
kris
4acde19ffe fix: restore windows asr gpu runtime
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-06 12:04:25 +08:00
kris
aa3aa9170f test: cover asr transcribe in smoke scripts
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-06 11:39:28 +08:00
kris
f53a4b4461 fix: fallback windows asr to cpu when gpu runtime is missing
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-06 11:29:12 +08:00
kris
4ff7efb61c feat: auto-detect language and prefer gpu for windows asr
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-06 11:14:12 +08:00
kris
7698b5e1e4 feat: route fnos collector through server services
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-06 10:57:25 +08:00
kris
c3e9b7edbc feat: migrate public n8n and huobao to server
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-06 10:34:01 +08:00
kris
a048bd26b1 feat: move asr to windows and disable local model
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-06 10:20:39 +08:00
kris
1395493208 perf: speed up auto-connect workspace bootstrap
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-06 09:30:41 +08:00
kris
0185106932 perf: hydrate workbench after auto-connect
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-06 09:26:21 +08:00
kris
8ecbca9cb0 feat: prefer direct import and tracking updates
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-06 09:16:55 +08:00
kris
16bc9fd5e4 feat: prefer direct benchmark actions when context exists
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-06 09:11:39 +08:00
kris
4546c95e8c fix: restore fnos live recorder deployment
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-06 08:55:56 +08:00
kris
ec35927fa5 feat: tailor live recorder sheet defaults
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-06 07:30:32 +08:00
kris
571be69571 feat: recommend task-aware defaults for creative forms
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 12:31:21 +08:00
kris
d0ae34ae4a feat: recommend smarter defaults for creative sheets
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 12:28:13 +08:00
kris
fc17c8d90b feat: show source job context in creative sheets
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 12:08:20 +08:00
kris
6ef5d94fc6 feat: unify context summaries across creative sheets
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 12:01:52 +08:00
kris
ce5a530427 feat: add context summaries to homepage and tracking sheets
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 11:53:35 +08:00
kris
4adb545e0d feat: improve intake sheet context defaults
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 11:48:05 +08:00
kris
4270c3f586 feat: smart direct-execute homepage import entry
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 09:13:46 +08:00
kris
cb69525c4f feat: direct-execute legacy create-assistant entry
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 08:53:33 +08:00
kris
791c1ac80d feat: smart direct-execute legacy pipeline actions
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 08:51:25 +08:00
kris
70e528614f feat: direct-execute review entry from job detail
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 08:39:43 +08:00
kris
0061909cdf feat: direct-execute copy entry when context exists
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 08:36:14 +08:00
kris
3c50be4f59 feat: direct-execute review entry when context exists
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 08:32:54 +08:00
kris
1ea00ff5e4 feat: direct-execute global pipeline actions
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 08:24:25 +08:00
kris
0b96562e86 feat: land direct copy results and expand live action smoke
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 08:14:33 +08:00
kris
78d90542cc feat: unify import flows and harden sqlite fallback
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 08:02:20 +08:00
kris
696f90b3fe feat: direct-execute pipeline follow-up actions
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 07:45:45 +08:00
kris
10b3c0cf42 feat: direct-execute copy generation from job details
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 07:43:41 +08:00
kris
ad05a4dfbc feat: direct-execute review and playbook quick actions
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 07:36:01 +08:00
kris
de36ce7fe9 feat: direct-execute main agent landing quick actions
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 07:15:28 +08:00
kris
f73d5cd406 feat: unify discovery project handoff actions
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 07:10:29 +08:00
kris
7a75f1cd85 feat: direct-execute discovery selected-account actions
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 07:08:47 +08:00
kris
6a98559e78 test: guard direct action registry in lan smoke
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 06:57:10 +08:00
kris
905c3adabe feat: deepen direct benchmark and analysis actions
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 06:55:13 +08:00
kris
88ccc62c71 fix: preserve oneliner suggested action context
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 06:36:29 +08:00
kris
f320ec1b44 feat: add direct tracking digest actions
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 06:30:53 +08:00
kris
22f6e6e686 feat: add direct tracking pool sync actions
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 06:28:10 +08:00
kris
5dab485e81 feat: add live quota package configurator
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 06:04:33 +08:00
kris
2cb6d6b1aa feat: productize quota packages and recovery guidance
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 06:01:07 +08:00
kris
c61c12127f feat: add seedance2 ai video compatibility
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 05:44:07 +08:00
kris
b78d1eaa51 chore: link integration setup
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 03:37:52 +08:00
kris
968d5715dd chore: clarify integration guidance
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 03:35:54 +08:00
kris
cd4021cf17 chore: polish remaining workbench copy
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 03:30:13 +08:00
kris
5cff65417f feat: add quota package tiers and recovery guidance
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 03:23:10 +08:00
kris
2f74b46324 feat: polish recovery and quota messaging
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-05 03:14:47 +08:00
kris
82394670ed feat: refocus project switching to dashboard workspace
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-04 11:45:58 +08:00
kris
02cdba834d feat: refocus assistant and tracking follow-ups
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-04 11:38:01 +08:00
kris
7888797663 feat: refocus quota and admin ops mutations
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-04 11:34:21 +08:00
kris
6f1e56daca feat: refocus remaining admin governance flows
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-04 10:50:11 +08:00
kris
e910d976f8 feat: refocus governance mutations after save
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-04 10:45:31 +08:00
kris
75142941f2 feat: refocus admin governance mutations
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-04 09:03:40 +08:00
kris
4ef0b3e805 feat: reopen platform agent detail after mutations
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-04 08:49:57 +08:00
kris
fe9a6c7cc1 feat: refine live workbench copy and fallbacks
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-04 08:43:28 +08:00
kris
b272c5edfd feat: preserve requested context in main agent results
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-04 08:40:04 +08:00
kris
3f93d5c088 feat: preserve direct action landing context
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-04 08:27:17 +08:00
kris
ae4d4cd2ad feat: add direct oneliner follow-up actions
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-04 08:22:14 +08:00
kris
294846e603 feat: complete main agent message config tracing
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-04 07:56:20 +08:00
kris
64da9a4e9b feat: jump from agent runs to config history
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-04 07:43:02 +08:00
kris
f68862b981 feat: surface config drift across agent runs
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-04 07:35:32 +08:00
kris
96446a25df feat: tighten playbook and recorder landings
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-04 07:16:24 +08:00
kris
5e38ed39aa feat: tighten main agent landing flows
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-04 07:02:43 +08:00
kris
53b1854c21 feat: tighten main agent execution traceability
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-04 06:23:17 +08:00
kris
895e3f3b13 feat: version platform agent profiles through main agent runs
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-04 05:08:15 +08:00
kris
01ce085f6a feat: continue platform agent executions from recent runs
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-04 04:35:22 +08:00
kris
a76bdb432f feat: surface recent platform agent execution feedback
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-04 04:29:54 +08:00
kris
f890a0ace7 feat: carry platform agent config into main agent runs
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-04-03 15:38:01 +08:00
kris
bbceada4f1 feat: carry oneliner config version into agent runs
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-03-31 04:03:05 +08:00
kris
c14e573152 feat: version oneliner profile configuration
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-03-31 03:32:51 +08:00
kris
1f3631a648 fix: harden main agent governance flows
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-03-31 03:19:28 +08:00
kris
2f98a1735d test: cover live governance surfaces
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-03-31 02:17:31 +08:00
kris
7897ce6c3d test: cover live-first platform routes
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-03-31 01:11:57 +08:00
kris
7a928b5df9 test: guard workbench action coverage
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-03-31 01:07:17 +08:00
kris
3f3c8de949 fix: remove stale missing-capability fallbacks
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-03-30 22:06:05 +08:00
kris
9317d4c0a5 fix: keep workbench failures inside app shell
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-03-30 22:00:51 +08:00
kris
ea643aad63 fix: remove stale workbench platform gating
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-03-30 21:52:37 +08:00
kris
aa2893b392 fix: prefer live workbench endpoints
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-03-30 21:46:24 +08:00
kris
67bddcf4b3 fix: continue live-first workbench reads 2026-03-30 21:02:35 +08:00
kris
f766fea2b9 fix: prefer live workbench reads over stale capability checks
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-03-30 20:51:49 +08:00
kris
b0199a6b85 fix: remove stale backend capability gating
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-03-30 20:49:43 +08:00
kris
b93b32f59d fix: stabilize fnos lan smoke script
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-03-30 20:19:27 +08:00
kris
0f275e25bb chore: add gitea ci workflow and release notes
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-03-30 20:16:24 +08:00
kris
30cc0ca029 fix: avoid stale capability false negatives
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-03-30 20:14:33 +08:00
kris
f2c75755b6 fix: stop false missing-capability warnings
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-03-30 20:12:49 +08:00
kris
f492cb3f83 feat: align async workbench feedback and add ci
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled
2026-03-30 20:07:53 +08:00
kris
c400c1af44 docs: add changelog for gitea release tracking 2026-03-30 17:26:27 +08:00
kris
a53a591c05 feat: sharpen live quota and review workspaces 2026-03-30 16:55:17 +08:00
kris
3f7aa2514d feat: align capability empty states with live collector 2026-03-30 16:50:37 +08:00
kris
5dae80c4ac feat: tighten mobile workbench chrome 2026-03-30 13:51:11 +08:00
kris
1b11929909 feat: simplify mobile discovery and production actions 2026-03-30 13:33:15 +08:00
kris
d88d7c0a5f fix: stabilize mobile drawer and oneliner status 2026-03-30 13:29:09 +08:00
kris
4242b40f5c feat: declutter mobile bottom sheets 2026-03-30 13:01:56 +08:00
kris
fd3d3c8ae5 feat: polish mobile agent and project interactions 2026-03-30 12:54:18 +08:00
kris
ee39fbfaa0 feat: streamline mobile project switching 2026-03-30 12:51:37 +08:00
kris
794de0133e feat: finish mobile task cards for thin pages 2026-03-30 12:46:04 +08:00
kris
1d2bbdf201 feat: streamline mobile oneliner runtime 2026-03-30 12:02:14 +08:00
kris
cb3a4d2755 feat: compact main agent landing notices on mobile 2026-03-30 11:53:17 +08:00
kris
a50d1b00f1 feat: refine mobile project sheets 2026-03-30 11:48:36 +08:00
kris
28863b208e feat: native project flows on mobile 2026-03-30 11:45:06 +08:00
kris
31ebe0431e feat: refine mobile-native discovery and sheets 2026-03-30 01:52:01 +08:00
kris
566e412a3a feat: finish mobile-native workbench flows 2026-03-30 01:37:42 +08:00
kris
1cb6c3e78f feat: add mobile-first production task deck 2026-03-30 01:24:37 +08:00
kris
ae99a4b962 feat: add mobile-first strategy and agent flows 2026-03-30 01:18:48 +08:00
kris
d7c25d1627 feat: streamline mobile discovery focus 2026-03-30 01:13:39 +08:00
kris
62caaa0ab0 feat: improve mobile touch semantics 2026-03-30 01:11:41 +08:00
kris
18351993df feat: strengthen native mobile workbench shell 2026-03-30 01:09:25 +08:00
kris
25a050453e feat: tighten mobile-first workbench chrome 2026-03-30 00:59:07 +08:00
kris
0466f5b672 feat: add mobile focus cards for heavy workbench screens 2026-03-30 00:15:34 +08:00
kris
5ff76ca4ce feat: tighten mobile discovery and production flow summaries 2026-03-30 00:08:34 +08:00
kris
151129ce26 feat: tighten mobile workbench summaries 2026-03-29 23:53:41 +08:00
kris
5ec86ae48a feat: adapt workbench shell for mobile-native navigation 2026-03-29 23:46:28 +08:00
kris
32c28fb7d6 feat: retry main agent runs 2026-03-29 22:59:15 +08:00
kris
568e8091c1 feat: highlight problem main agent runs 2026-03-29 22:51:02 +08:00
kris
2f3ac47439 feat: add main agent run filters 2026-03-29 22:47:10 +08:00
kris
6c9b45ab37 feat: add page-level followups for main agent landings 2026-03-29 22:42:38 +08:00
kris
e65e32e5a8 feat: show recent completed main agent runs 2026-03-29 19:57:14 +08:00
kris
e0486708b7 feat: surface main agent results on landing pages 2026-03-29 19:55:22 +08:00
kris
f13c83a583 feat: structure main agent result cards 2026-03-29 19:51:06 +08:00
kris
e48074e24b feat: add main agent confirmation sheet 2026-03-29 19:43:17 +08:00
kris
f14e773aa3 feat: auto-poll active main agent runs 2026-03-29 19:35:58 +08:00
kris
6c0f40c908 feat: deepen main agent handoff workflow 2026-03-29 19:31:28 +08:00
kris
c83c54053f feat: route main agent results back into workbench 2026-03-29 19:08:30 +08:00
kris
1c5108dcc1 feat: deepen main agent handoff surfaces 2026-03-29 18:55:07 +08:00
kris
d4be3a2ce1 feat: advance main agent runs from homepage handoff 2026-03-29 18:37:57 +08:00
kris
30e37e5ce1 feat: add main agent runtime flow v1 2026-03-29 18:25:39 +08:00
kris
ccbe6ca565 docs: add main agent runtime flow spec 2026-03-29 18:05:30 +08:00
kris
8a133a4f78 feat: surface active governance overrides 2026-03-29 17:45:03 +08:00
kris
f813b6e5c0 fix: tighten governance audit exposure 2026-03-29 17:21:02 +08:00
kris
8bb58be5ff feat: add agent governance audit surfaces 2026-03-29 17:12:44 +08:00
kris
26f86f8484 feat: add oneliner policy history controls 2026-03-29 16:42:12 +08:00
kris
cb17fb0760 feat: add main agent governance foundation 2026-03-29 16:13:50 +08:00
kris
dff369aafd harden oneliner session backend recovery 2026-03-28 09:13:46 +08:00
kris
f05a43fee3 improve auto-connect loading states 2026-03-28 08:58:59 +08:00
kris
693be5bca9 fix project card layout overflow 2026-03-28 07:11:17 +08:00
kris
7bf93e610e refine workbench page usability 2026-03-28 06:32:47 +08:00
kris
17809605da fix: skip creator-fields fetch without creator snapshot 2026-03-28 05:39:42 +08:00
kris
7bec3680fb feat: redesign homepage dashboard workbench 2026-03-28 05:34:31 +08:00
kris
45f6dca984 docs: add homepage workbench redesign spec 2026-03-28 03:33:57 +08:00
kris
b35d653610 feat: stabilize fnos lan delivery flow 2026-03-27 13:49:30 +08:00
kris
32bc94f924 feat: codify fnos cutvideo tunnel routing 2026-03-27 13:13:51 +08:00
kris
195a5e5ff6 docs: add windows cutvideo recovery guide 2026-03-27 00:11:15 +08:00
kris
c65c8e39f3 feat: align fnos collector with lan integrations 2026-03-26 22:40:27 +08:00
kris
2a1e2201fb fix: disable fnos web cache for live debugging 2026-03-26 22:20:34 +08:00
kris
82560d1415 feat: add fnos collector lan deploy 2026-03-26 22:12:42 +08:00
kris
d0673d08a5 feat: add fnos storyforge web dev deploy 2026-03-26 21:14:23 +08:00
kris
ea2d305a3c feat: enable automatic web backend sessions 2026-03-26 20:51:05 +08:00
kris
a30ed8decd feat: harden storyforge production baseline 2026-03-26 19:33:36 +08:00
kris
38b02a9799 feat: finish storyforge workbench and runtime closure 2026-03-26 13:55:06 +08:00
kris
160cece196 feat: finish web workbench multi-platform baseline 2026-03-26 12:28:27 +08:00
kris
8d62da7e91 refactor: split android overlay out of storyforge 2026-03-26 10:41:33 +08:00
kris
dd619448e7 feat: harden storyforge runtime and repo boundary 2026-03-26 09:08:41 +08:00
kris
fa9d6dda09 fix: honor auth modal refresh and submit actions 2026-03-23 18:56:47 +08:00
kris
f27a12ca3d deploy: move collector and db to cloud 2026-03-23 18:32:18 +08:00
kris
17b419f8ef deploy: switch public entry to storyforge.hyzq.net 2026-03-23 18:19:52 +08:00
kris
f093f72ae4 deploy: serve storyforge web directly from cloud host 2026-03-23 18:02:33 +08:00
kris
841985c0d2 feat: expose storyforge through public cloud gateway 2026-03-23 17:52:09 +08:00
kris
aeccea585e chore: restore cleanup script executable bit 2026-03-23 17:26:40 +08:00
kris
fb61f4c1ce docs: remove legacy fastgpt naming 2026-03-23 17:26:30 +08:00
kris
1d9dbfa8a4 refactor: remove fastgpt runtime dependency 2026-03-23 17:06:05 +08:00
kris
71465b3d55 feat: expand oneliner control surfaces and quotas 2026-03-23 16:37:33 +08:00
kris
5f7359c243 feat: expand oneliner orchestration flows 2026-03-23 16:01:24 +08:00
kris
628eeb0d08 feat: extend oneliner actions and ops workspace 2026-03-23 15:43:34 +08:00
kris
6928cb4201 feat: extend oneliner execution workspace 2026-03-23 15:31:36 +08:00
kris
8d54c21786 feat: add oneliner control plane ui 2026-03-23 15:01:15 +08:00
kris
56255688c1 feat: expand nas storage workspace panel 2026-03-23 11:53:21 +08:00
kris
3ecf6c1916 feat: add nas storage status panel 2026-03-23 10:36:23 +08:00
kris
a5f82bd0aa feat: lock live recorder ui to tenant proxy 2026-03-23 09:59:22 +08:00
kris
660f539204 feat: expose multi-platform and recorder controls in web v4 2026-03-23 09:21:15 +08:00
kris
f9e34287db Add fnOS live recorder deployment manifest 2026-03-23 09:08:10 +08:00
kris
941e171455 feat: read supported platforms from live backend 2026-03-23 08:49:32 +08:00
kris
ac7fe786f3 feat: generalize web workbench platform routing 2026-03-23 08:47:24 +08:00
kris
3fe01d2f23 docs: clarify web v4 domestic platform scope 2026-03-23 08:40:30 +08:00
kris
9a753f60d8 feat: align web platforms to domestic rollout 2026-03-23 08:38:51 +08:00
kris
d7132fe932 fix: soften missing douyin video routes in web v4 2026-03-23 07:49:04 +08:00
kris
54afca2bf4 feat: improve web agent controls and capability detection 2026-03-23 07:09:20 +08:00
kris
4ab0b26821 feat: sync live orchestrator and n8n routing 2026-03-23 06:54:01 +08:00
kris
ea6a855890 feat: surface cutvideo upload capability in health ui 2026-03-23 05:21:48 +08:00
kris
042188f954 feat: restore local model gateway via cliproxy 2026-03-22 18:58:55 +08:00
kris
c657db9b38 feat: surface local model health in web ui 2026-03-22 14:18:38 +08:00
kris
652f0c9f79 feat: extend web tracking and integration controls 2026-03-22 14:13:10 +08:00
kris
dab444a83c feat: add reviews and integration health controls 2026-03-22 13:34:41 +08:00
kris
ed5bcaef84 style: optimize mobile discovery and production flows 2026-03-22 12:46:03 +08:00
kris
7500d02730 style: refine responsive topbar and auth sheet 2026-03-22 12:22:17 +08:00
kris
37709d37b7 style: make storyforge web v4 responsive 2026-03-22 12:15:11 +08:00
kris
9ed5f24364 feat: add douyin tracking digest flows 2026-03-22 12:11:15 +08:00
kris
031ba04d4e feat: streamline benchmark intake flows 2026-03-22 11:53:14 +08:00
kris
32dea8e3a6 feat: extend benchmark and job action flows 2026-03-22 11:45:18 +08:00
kris
4106347b67 feat: add job details and benchmark actions to web v4 2026-03-22 11:27:02 +08:00
kris
b75c9e275b feat: add storyforge web v4 action workflows 2026-03-22 11:22:10 +08:00
kris
540be80719 feat: connect storyforge web v4 to live workspace data 2026-03-22 11:10:21 +08:00
kris
fe07a5f212 feat: implement storyforge mobile v4 shell 2026-03-22 10:39:53 +08:00
kris
35c97ffe4d feat: add storyforge mobile v4 html prototype 2026-03-22 10:33:03 +08:00
kris
1851625a53 style: refine storyforge ops ui visual rhythm 2026-03-22 08:39:39 +08:00
kris
66db9e8687 style: unify storyforge ops ui actions 2026-03-22 08:12:07 +08:00
kris
98592168b7 style: simplify storyforge ops ui copy 2026-03-22 08:08:04 +08:00
kris
e771919e4a feat: refine storyforge ops ui information architecture 2026-03-22 08:03:02 +08:00
kris
6899ebba60 feat: add storyforge ops ui prototype and tracking digest 2026-03-22 07:38:49 +08:00
kris
6b3774b543 fix: restore visible auth form for douyin workbench 2026-03-21 05:05:44 +08:00
kris
7171dae91c feat: add dedicated douyin workbench entry 2026-03-21 04:57:18 +08:00
kris
9f921fff94 chore: add business smoke check script 2026-03-21 04:52:20 +08:00
kris
39216d18b4 chore: add reliable local business run scripts 2026-03-21 02:59:52 +08:00
kris
c09a976628 feat: upgrade douyin work list filters and ranking 2026-03-21 02:36:18 +08:00
kris
1fb39e040f fix: compact token auth ui 2026-03-21 02:28:44 +08:00
kris
be94836e3c fix: collapse duplicate douyin analysis history 2026-03-21 02:26:42 +08:00
kris
c4222755b1 feat: deepen douyin commercial workbench 2026-03-21 01:34:46 +08:00
kris
f6462dbccc feat: add douyin workbench results ui 2026-03-21 00:52:23 +08:00
kris
741fe4f983 fix: harden douyin control panel auth inputs 2026-03-20 23:31:27 +08:00
kris
5d9c9cf048 feat: add douyin browser control panel 2026-03-20 22:27:54 +08:00
kris
5c52476a45 perf: streamline douyin browser sync handling 2026-03-20 19:41:31 +08:00
kris
4356c46b9e fix: guard douyin creator-center identity merges 2026-03-20 19:31:29 +08:00
kris
10820595cf fix: harden douyin browser capture persistence 2026-03-20 15:14:12 +08:00
kris
1fa1b586f7 feat: add browser-assisted douyin capture flow 2026-03-20 14:51:22 +08:00
kris
7070c3aa85 feat: restore android build path and update status docs 2026-03-20 14:17:33 +08:00
kris
ac6a8a82df feat: add account sync entry and cleanup legacy runtime 2026-03-20 14:10:30 +08:00
kris
98722a580a docs: record huobao override path and credential blocker 2026-03-20 13:47:20 +08:00
kris
e1010503ae docs: record huobao upstream smoke status 2026-03-20 13:26:45 +08:00
kris
1a055a16c2 docs: record douyin workbench validation 2026-03-20 13:19:35 +08:00
kris
f96a37a236 feat: harden douyin sync diagnostics and manual fallback 2026-03-20 13:18:45 +08:00
kris
a906e0ceda feat: formalize live collector douyin deployment 2026-03-20 13:13:03 +08:00
kris
1c539abc6e feat: add content source sync pipeline and harden asr timeouts 2026-03-20 10:11:04 +08:00
kris
63af810236 feat: auto stage real-cut inputs to cutvideo 2026-03-20 06:57:53 +08:00
kris
b145363111 feat: migrate orchestration to n8n and validate lan mvp 2026-03-18 10:05:00 +08:00
kris
d2074c3518 feat: add studio-workbench concept 2026-03-14 21:35:30 +08:00
190 changed files with 64132 additions and 9272 deletions

View File

@@ -2,15 +2,46 @@ DEFAULT_EXTERNAL_BASE_URL=http://test.hyzq.net:8081
LOCAL_OPENAI_BASE_URL=http://127.0.0.1:8317/v1
LOCAL_OPENAI_MODEL=GLM-5
LOCAL_OPENAI_API_KEY=
FASTGPT_BASE_URL=http://127.0.0.1:3000
FASTGPT_DATASET_API_KEY=
# Host-side collector runs can keep using N8N_BASE_URL.
N8N_BASE_URL=http://127.0.0.1:5670
# Dockerized collector should use the internal n8n service address.
COLLECTOR_N8N_BASE_URL=http://n8n:5678
BOOTSTRAP_SUPERADMIN_USERNAME=storyforge-admin
BOOTSTRAP_SUPERADMIN_PASSWORD=__set_a_strong_password__
BOOTSTRAP_SUPERADMIN_DISPLAY_NAME=StoryForge Admin
WEB_AUTOLOGIN_ENABLED=0
WEB_AUTOLOGIN_ACCOUNT_USERNAME=
WEB_AUTOLOGIN_USERNAME=
WEB_AUTOLOGIN_PASSWORD=
N8N_ANALYSIS_WEBHOOK_PATH=/webhook/storyforge-analysis
N8N_REAL_CUT_WEBHOOK_PATH=/webhook/storyforge-real-cut
N8N_AI_VIDEO_WEBHOOK_PATH=/webhook/storyforge-ai-video
N8N_CONTENT_SOURCE_SYNC_WEBHOOK_PATH=/webhook/storyforge-content-source-sync
ORCHESTRATOR_SHARED_SECRET=__set_a_strong_shared_secret__
STORYFORGE_INTERNAL_BASE_URL=http://collector:8081
CUTVIDEO_BASE_URL=
CUTVIDEO_API_KEY=
CUTVIDEO_BASE_CONFIG=example.job.yaml
CUTVIDEO_POLL_INTERVAL_SEC=10
CUTVIDEO_MAX_WAIT_SEC=1800
CUTVIDEO_UPLOAD_TIMEOUT_SEC=1800
HUOBAO_BASE_URL=http://127.0.0.1:5678
HUOBAO_POLL_INTERVAL_SEC=10
HUOBAO_MAX_WAIT_SEC=900
YTDLP_BIN=yt-dlp
FFMPEG_BIN=ffmpeg
WHISPER_BIN=
WHISPER_MODEL=./data/collector/models/ggml-base.en.bin
POSTGRES_DB=fastgpt
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin
ASR_HTTP_BASE_URL=
ASR_HTTP_TRANSCRIBE_PATH=/transcribe
ASR_HTTP_FIELD_NAME=wav
ASR_HTTP_TIMEOUT_SEC=120
N8N_IMAGE=docker.n8n.io/n8nio/n8n:latest
WEBHOOK_URL=http://127.0.0.1:5670/
GENERIC_TIMEZONE=Asia/Shanghai
TZ=Asia/Shanghai
CLIPROXY_IMAGE=storyforge/cli-proxy-api:patched
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
View 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
View 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
View File

@@ -20,6 +20,8 @@ build/
.kotlin/
**/.gradle/
**/.kotlin/
node_modules/
**/node_modules/
# Runtime data and artifacts
data/
@@ -29,3 +31,8 @@ output/
# macOS / editors
.idea/
.vscode/
# Local agent/browser scratch state
.playwright-cli/
.superpowers/
.tmp-previews*/

722
CHANGELOG.md Normal file
View 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` 这类信息会在打开表单时直接可见,并保留跳到火山配置状态的入口。
- `生产中心` 现在把 `导入主页 / 导入作品 / 导入文本 / 上传视频` 这批接入入口统一接进了顶部动作区和生产队列工作流卡,不用离开生产中心也能开始接入素材。
- `生产中心``生产队列` 首屏现在会直接显示 `接入与录制` 概况:最近内容源同步条数、录制源数量、录制服务状态,以及一组直达 `导入主页 / 导入作品 / 视频录制` 的动作。

View File

@@ -13,6 +13,5 @@
- 使用 `whisper.cpp` 转写,若环境未就绪则保留原始素材并进入降级流程
6. collector-service 调用本机 OpenAI 兼容模型提炼文案风格
7. 结果写入用户自己的知识库文档
8.果配置了 `FASTGPT_DATASET_API_KEY`
- 同步到 FastGPT 数据集
8.有需要,可继续同步到租户自己的外部知识系统
9. 文案助手生成时按知识库关联关系取素材,结合提示词输出文案

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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 上的本地管理接口

201
README.md
View File

@@ -2,39 +2,216 @@
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 后端,提供登录、审批、素材导入、知识库、智能体和 OTA
- `docker-compose.yml`:本地 FastGPT / collector / 基础依赖编排
- `collector-service/`FastAPI 后端负责用户体系、项目、Agent、任务、内容分析和对外能力接入
- `web/storyforge-web-v4/`:正式 Web 工作台,承接多平台运营、对标、跟踪、生产和复盘入口
- `n8n/`:工作流导出文件,作为流程编排中枢
- `docker-compose.yml`:本地 `collector + n8n + cli-proxy-api` 编排
- `Common/`:项目约束和架构说明
- `data/collector/`SQLite、任务文件、下载产物
- `docs/`:审计、实施计划、联调说明、当前 MVP 状态
## Android
## CI
仓库里的最小 CI workflow 同时放在 [`.github/workflows/ci.yml`](/Users/kris/code/StoryForge-gitea/.github/workflows/ci.yml) 和 [`.gitea/workflows/ci.yml`](/Users/kris/code/StoryForge-gitea/.gitea/workflows/ci.yml),这样 GitHub Actions 和 Gitea Actions 都会在 `push``pull_request``workflow_dispatch` 时运行基线检查、后端单元测试和 Web Node 测试。
## 产品手册
- [新媒体运营中台产品逻辑手册](./docs/PRODUCT_LOGIC_NEW_MEDIA_OPERATING_SYSTEM_2026-03-22.md)
- [新媒体运营平台 UI 参考包](./output/ui/new-media-ops-reference-2026-03-22/README.md)
- [Web V4 UI 原型](./output/ui/storyforge-web-v4-html-prototype-2026-03-22/README.md)
- [Web V4 前端骨架](./web/storyforge-web-v4/README.md)(国内平台 UI 承载,当前已接上 `douyin / xiaohongshu / bilibili / kuaishou / wechat_video` 统一工作台)
- [Mobile V4 UI 原型](./output/ui/storyforge-mobile-v4-html-prototype-2026-03-22/README.md)(仅 UI 原型,不代表当前仓库承载 Android 工程)
## Douyin Browser Capture
```bash
cd /Users/kris/code/StoryForge/android-app
./gradlew assembleDebug
cd /Users/kris/code/StoryForge-gitea
./scripts/start_douyin_workbench.sh
```
业务页:
```text
http://127.0.0.1:3618/workbench
```
完整采集控制台:
```text
http://127.0.0.1:3618
```
常用脚本:
```bash
./scripts/start_douyin_workbench.sh
./scripts/status_douyin_workbench.sh
./scripts/stop_douyin_workbench.sh
./scripts/cleanup_debug_ui.sh
```
如果第一次使用,还需要先安装浏览器依赖:
```bash
cd /Users/kris/code/StoryForge-gitea/scripts/douyin-browser-capture
npm install
npx playwright install chromium
```
当前本地页面已经拆成两个入口:
- `/workbench`:业务优先的 `Douyin Workbench`,可直接查看账号列表、商业化账号分析、快照详情、相似账号和对标关系
- `/`:完整浏览器辅助采集控制台,同时保留工作台能力
- 作品工作台支持按 `高分作品 / 最新作品 / 全部作品` 切换,并可按综合分、受欢迎程度、商业价值、发布时间、播放、点赞、分享、评论排序
- 作品列表支持 `视频 / 图文` 类型筛选,并可直接打开原作品链接
- 高分作品支持自动化分析,每条作品卡片下都会展示商业判断、复刻计划、运营动作和风险提醒
或者继续用命令行:
```bash
cd /Users/kris/code/StoryForge-gitea/scripts/douyin-browser-capture
npm run capture -- \
--profile-url https://www.douyin.com/user/your_account \
--storyforge-username storyforge-admin \
--storyforge-password 'your_admin_password'
```
说明:
- 这是“真实浏览器 + 人工登录/过挑战 + 自动提取 + 回写 StoryForge”的辅助采集工具
- 默认输出到 `output/playwright/douyin/`
- 本地控制台模式会把每次运行保存到 `output/playwright/douyin/control-panel/`
- 控制台支持“开始采集 -> 浏览器登录 -> 网页点继续 -> 自动同步”的点击式流程
- 详细说明见 `scripts/douyin-browser-capture/README.md`
## Collector Service
```bash
cd /Users/kris/code/StoryForge/collector-service
cd /Users/kris/code/StoryForge-gitea/collector-service
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 8081 --reload
```
默认会创建最高权限账号:
## Docker Compose
- `kris`
- `Asd123456.`
```bash
cd /Users/kris/code/StoryForge-gitea
cp .env.example .env
docker compose up -d --build
```
首次启动前,至少补齐这些配置:
```bash
ORCHESTRATOR_SHARED_SECRET=your_strong_shared_secret
BOOTSTRAP_SUPERADMIN_USERNAME=storyforge-admin
BOOTSTRAP_SUPERADMIN_PASSWORD=your_strong_admin_password
```
如果希望 Web 端打开后直接自动建会话,不让用户手动输入账号密码,再额外打开:
```bash
WEB_AUTOLOGIN_ENABLED=1
WEB_AUTOLOGIN_ACCOUNT_USERNAME=your_existing_approved_username
```
推荐直接指定一个已经存在且已审批通过的账号用户名,服务端会直接为该账号签发自动会话,不需要额外保存该账号密码。
如果你更希望复用 bootstrap 超级管理员口令,或者切到专门账号,也可以继续走密码模式:
```bash
WEB_AUTOLOGIN_USERNAME=your_autologin_username
WEB_AUTOLOGIN_PASSWORD=your_autologin_password
```
如果要让本机模型网关 `cli-proxy-api` 自动提供 `GLM-5`,建议在启动前确保本机环境里存在:
```bash
export DASHSCOPE_API_KEY=your_dashscope_key
```
或者把它写进本地 `.env``./scripts/start_business.sh` 会自动生成 `data/cliproxyapi/config.yaml` 并把 `glm-5 -> GLM-5` 映射到本机网关。
如果 `collector` 跑在 Docker 里,建议保留:
```bash
COLLECTOR_N8N_BASE_URL=http://n8n:5678
```
如果你单独在宿主机启动 `collector-service`,它读取的仍然是:
```bash
N8N_BASE_URL=http://127.0.0.1:5670
```
默认会启动:
- `collector-service``http://127.0.0.1:8081`
- `n8n``http://127.0.0.1:5670`
- `cli-proxy-api``http://127.0.0.1:8317`
- 公网入口:`https://storyforge.hyzq.net/`
公网维护常用脚本:
```bash
./scripts/smoke_public_storyforge.sh
./scripts/deploy_public_storyforge.sh
```
首次启动时,如果数据库里还没有 `super_admin``collector-service` 会按
`BOOTSTRAP_SUPERADMIN_USERNAME / BOOTSTRAP_SUPERADMIN_PASSWORD / BOOTSTRAP_SUPERADMIN_DISPLAY_NAME`
创建最高权限账号。未配置时不会再自动写入默认口令账号。
如果开启了 `WEB_AUTOLOGIN_ENABLED=1`,前端会在启动时直接请求 `/v2/auth/auto-session` 自动建会话,不再显示用户名 / 密码 / token 输入流程。推荐优先使用 `WEB_AUTOLOGIN_ACCOUNT_USERNAME`,只在必须时才使用 `WEB_AUTOLOGIN_USERNAME / WEB_AUTOLOGIN_PASSWORD`
## 当前架构
- `collector-service` 负责:
- 用户账号、多项目、多 Agent、多任务、多内容源数据边界
- 调用下载器、本地 ASR、本机 OpenAI 兼容模型
- 调用 Windows `cutvideo``huobao-drama`
- 持久化任务、分镜、分析结果、事件日志
- `n8n` 负责:
- 触发 `analysis_pipeline`
- 触发 `content_source_sync_pipeline`
- 触发 `real_cut_pipeline`
- 触发 `ai_video_pipeline`
- 历史旧运行链已完成移除,当前运行时只保留 StoryForge 自身服务与外部执行引擎
- 当前公网接入采用“云服务器 HTTPS 入口 + 云服务器本地 collector + 本地桥接执行引擎”模式:
- `https://storyforge.hyzq.net/` 由云服务器 `nginx` 提供 HTTPS 入口
- `/` 静态页由云服务器本地 `StoryForge Web V4` 直出
- `/v2/*``/openapi.json``/healthz` 反向代理到云服务器本地 `collector-service`
- 业务数据库已上云,当前路径为云服务器本地 `storyforge.db`
- `n8n / cutvideo / huobao / 本机模型 / ASR / NAS 录制` 继续由本机和局域网执行链提供,并通过受控桥接暴露给云上的 `collector-service`
## 说明
- 新注册账号默认 `pending`
- 主管理员审批后才可使用核心业务接口
- 素材入口支持文字、视频链接、视频上传
- 可选对接本机 OpenAI 兼容模型服务和 FastGPT 数据集 API
- 支持 `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 校验整条链路。

View File

@@ -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` 进行隔离。

View File

@@ -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`

View File

@@ -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")
}

View File

@@ -1,2 +0,0 @@
# Keep default for demo stage.

View File

@@ -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>

View File

@@ -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) }
)
}
}
}
}

View File

@@ -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"))
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -1,106 +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
@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
}

View File

@@ -1,249 +0,0 @@
package com.aiglasses.app.storyforge
import kotlinx.serialization.Serializable
@Serializable
data class RegisterAccountRequest(
val username: String,
val password: String,
val display_name: String
)
@Serializable
data class LoginRequest(
val username: String,
val password: String
)
@Serializable
data class AccountDto(
val id: String,
val username: String,
val display_name: String,
val role: String,
val approval_status: String,
val approved_by: String? = null,
val approved_at: String? = null,
val preferred_analysis_model_id: String = "",
val created_at: String = "",
val updated_at: String = ""
)
@Serializable
data class AuthResponseDto(
val token: String,
val account: AccountDto,
val default_external_base_url: String = ""
)
@Serializable
data class ModelProfileDto(
val id: String,
val owner_account_id: String? = null,
val name: String,
val provider: String,
val base_url: String,
val api_key_masked: String = "",
val model_name: String,
val is_system: Boolean = false,
val is_default: Boolean = false,
val created_at: String = "",
val updated_at: String = ""
)
@Serializable
data class ModelProfileRequest(
val name: String,
val base_url: String,
val api_key: String,
val model_name: String,
val is_default: Boolean = false
)
@Serializable
data class PreferredModelRequest(
val model_profile_id: String
)
@Serializable
data class KnowledgeBaseDto(
val id: String,
val user_id: String,
val name: String,
val description: String = "",
val fastgpt_dataset_id: String? = null,
val sync_status: String = "pending",
val document_count: Int = 0,
val linked_assistant_count: Int = 0,
val created_at: String = "",
val updated_at: String = ""
)
@Serializable
data class KnowledgeBaseCreateRequest(
val name: String,
val description: String = ""
)
@Serializable
data class AssistantDto(
val id: String,
val user_id: String,
val name: String,
val description: String = "",
val system_prompt: String = "",
val generation_goal: String = "",
val knowledge_base_ids: List<String> = emptyList(),
val fastgpt_app_key: String = "",
val model_profile_id: String = "",
val created_at: String = "",
val updated_at: String = ""
)
@Serializable
data class AssistantCreateRequest(
val name: String,
val description: String = "",
val system_prompt: String = "",
val generation_goal: String = "",
val knowledge_base_ids: List<String> = emptyList(),
val fastgpt_app_key: String = "",
val model_profile_id: String = ""
)
@Serializable
data class AssistantUpdateRequest(
val name: String? = null,
val description: String? = null,
val system_prompt: String? = null,
val generation_goal: String? = null,
val knowledge_base_ids: List<String>? = null,
val fastgpt_app_key: String? = null,
val model_profile_id: String? = null
)
@Serializable
data class ExploreVideoLinkRequest(
val video_url: String,
val title: String? = null,
val knowledge_base_id: String? = null,
val assistant_id: String? = null,
val analysis_model_profile_id: String? = null,
val language: String = "auto"
)
@Serializable
data class ExploreTextRequest(
val title: String,
val content: String,
val knowledge_base_id: String? = null,
val assistant_id: String? = null,
val analysis_model_profile_id: String? = null
)
@Serializable
data class JobDto(
val id: String,
val user_id: String,
val assistant_id: String? = null,
val knowledge_base_id: String,
val source_type: String,
val source_url: String? = null,
val title: String,
val language: String,
val status: String,
val transcript_text: String = "",
val style_summary: String = "",
val fastgpt_collection_id: String = "",
val upload_status: String = "pending",
val error: String = "",
val artifacts: Map<String, String> = emptyMap(),
val analysis_model_profile_id: String = "",
val created_at: String = "",
val updated_at: String = ""
)
@Serializable
data class KnowledgeDocumentDto(
val id: String,
val knowledge_base_id: String,
val title: String,
val source_type: String,
val source_url: String = "",
val transcript_text: String = "",
val style_summary: String = "",
val combined_text: String = "",
val fastgpt_collection_id: String = "",
val analysis_model_profile_id: String = "",
val created_at: String = "",
val updated_at: String = ""
)
@Serializable
data class GenerateCopyRequest(
val brief: String,
val platform: String = "抖音",
val audience: String = "创业者",
val extra_requirements: String = "",
val knowledge_base_ids: List<String> = emptyList()
)
@Serializable
data class GenerateCopyResponseDto(
val assistant_id: String,
val knowledge_base_ids: List<String>,
val content: String,
val prompt_excerpt: String,
val used_documents: List<KnowledgeDocumentDto> = emptyList()
)
@Serializable
data class DashboardDto(
val account: AccountDto,
val knowledge_bases: List<KnowledgeBaseDto> = emptyList(),
val assistants: List<AssistantDto> = emptyList(),
val recent_jobs: List<JobDto> = emptyList(),
val model_profiles: List<ModelProfileDto> = emptyList()
)
@Serializable
data class ApprovalDecisionDto(
val saved: Boolean,
val account: AccountDto
)
@Serializable
data class PublishAppUpdateRequest(
val platform: String = "android",
val channel: String = "stable",
val versionCode: Int,
val versionName: String,
val minSupportedCode: Int,
val apkUrl: String,
val apkSha256: String = "",
val notes: String = "",
val forceUpdate: Boolean = false,
val isActive: Boolean = true
)
@Serializable
data class PublishAppUpdateResponseDto(
val saved: Boolean,
val action: String,
val updateId: Int = 0
)
@Serializable
data class AppUpdateLatestDto(
val platform: String = "android",
val channel: String = "stable",
val hasUpdate: Boolean = false,
val latestVersionCode: Int = 0,
val latestVersionName: String = "",
val minSupportedCode: Int = 0,
val downloadUrl: String = "",
val apkSha256: String = "",
val releaseNotes: String = "",
val forceUpdate: Boolean = false,
val publishedAt: Long = 0L
)

View File

@@ -1,366 +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 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}$""")
}
}

View File

@@ -1,827 +0,0 @@
package com.aiglasses.app.storyforge
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
fun StoryForgeScreen(
state: StoryForgeUiState,
vm: StoryForgeViewModel,
onPickVideo: () -> Unit,
onInstallLatestUpdate: () -> Unit
) {
val heroBrush = Brush.linearGradient(
colors = listOf(Color(0xFF0B3C5D), Color(0xFF1F6E5F), Color(0xFFB97524))
)
Scaffold(
bottomBar = {
if (state.isAuthenticated && state.isApproved) {
NavigationBar(modifier = Modifier.navigationBarsPadding()) {
BottomTabItem(label = "探索", tab = StoryForgeTab.Explore, state = state, onSelect = vm::selectTab)
BottomTabItem(label = "生产", tab = StoryForgeTab.Production, state = state, onSelect = vm::selectTab)
BottomTabItem(label = "我的", tab = StoryForgeTab.Mine, state = state, onSelect = vm::selectTab)
}
}
}
) { innerPadding ->
Surface(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(innerPadding)
) {
when {
!state.isAuthenticated -> AuthScreen(state = state, vm = vm, heroBrush = heroBrush)
!state.isApproved -> PendingApprovalScreen(state = state, vm = vm, heroBrush = heroBrush)
else -> AppShell(
state = state,
vm = vm,
heroBrush = heroBrush,
onPickVideo = onPickVideo,
onInstallLatestUpdate = onInstallLatestUpdate
)
}
}
}
}
@Composable
private fun BottomTabItem(
label: String,
tab: StoryForgeTab,
state: StoryForgeUiState,
onSelect: (StoryForgeTab) -> Unit
) {
val selected = state.currentTab == tab
Box(
modifier = Modifier
.clip(RoundedCornerShape(18.dp))
.clickable { onSelect(tab) }
.background(if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent)
.padding(horizontal = 14.dp, vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = label.take(1), fontWeight = FontWeight.Bold)
Text(label, style = MaterialTheme.typography.labelSmall)
}
}
}
@Composable
private fun AuthScreen(
state: StoryForgeUiState,
vm: StoryForgeViewModel,
heroBrush: Brush
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(heroBrush)
.padding(18.dp),
contentAlignment = Alignment.Center
) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
shape = RoundedCornerShape(28.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(22.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("StoryForge AI", style = MaterialTheme.typography.headlineSmall)
Text(
if (state.authMode == StoryForgeAuthMode.Login) "登录账号" else "注册新账号,提交后等待主管理员审批",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
ChoiceRow(
options = listOf("登录" to (state.authMode == StoryForgeAuthMode.Login), "注册" to (state.authMode == StoryForgeAuthMode.Register)),
onSelect = { label -> vm.setAuthMode(if (label == "登录") StoryForgeAuthMode.Login else StoryForgeAuthMode.Register) }
)
OutlinedTextField(
value = state.username,
onValueChange = vm::updateUsername,
modifier = Modifier.fillMaxWidth(),
label = { Text("账号") },
singleLine = true
)
OutlinedTextField(
value = state.password,
onValueChange = vm::updatePassword,
modifier = Modifier.fillMaxWidth(),
label = { Text("密码") },
singleLine = true
)
Button(
onClick = { if (state.authMode == StoryForgeAuthMode.Login) vm.login() else vm.registerAccount() },
enabled = !state.busy,
modifier = Modifier.fillMaxWidth()
) {
if (state.busy) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Text(if (state.authMode == StoryForgeAuthMode.Login) "登录" else "注册")
}
}
if (state.statusMessage.isNotBlank()) {
Text(state.statusMessage, style = MaterialTheme.typography.bodySmall)
}
if (state.errorMessage.isNotBlank()) {
Text(state.errorMessage, color = MaterialTheme.colorScheme.error)
}
}
}
}
}
@Composable
private fun PendingApprovalScreen(
state: StoryForgeUiState,
vm: StoryForgeViewModel,
heroBrush: Brush
) {
val account = state.account
Column(
modifier = Modifier
.fillMaxSize()
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
HeroCard(
title = "等待审批",
subtitle = "${account?.display_name ?: account?.username ?: "当前账号"} 已登录,但尚未通过主管理员审批。",
heroBrush = heroBrush,
badges = listOf(
"审批状态:${account?.approval_status ?: "pending"}",
if (state.resolvedIp.isNotBlank()) "已解析到 ${state.resolvedIp}" else ""
).filter { it.isNotBlank() }
)
SectionCard(title = "当前说明", subtitle = state.statusMessage) {
Text("新注册账号在主管理员通过前,无法访问探索、生产和知识库功能。")
Spacer(modifier = Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = vm::refreshApprovalStatus, enabled = !state.busy) {
Text("刷新审批状态")
}
OutlinedButton(onClick = vm::logout) {
Text("退出登录")
}
}
if (state.errorMessage.isNotBlank()) {
Spacer(modifier = Modifier.height(10.dp))
Text(state.errorMessage, color = MaterialTheme.colorScheme.error)
}
}
}
}
@Composable
private fun AppShell(
state: StoryForgeUiState,
vm: StoryForgeViewModel,
heroBrush: Brush,
onPickVideo: () -> Unit,
onInstallLatestUpdate: () -> Unit
) {
val scroll = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
HeroCard(
title = when (state.currentTab) {
StoryForgeTab.Explore -> "探索素材"
StoryForgeTab.Production -> "生产文案"
StoryForgeTab.Mine -> "我的工作台"
},
subtitle = state.statusMessage,
heroBrush = heroBrush,
badges = listOf(
state.account?.display_name ?: state.account?.username.orEmpty(),
state.account?.role ?: "",
if (state.resolvedIp.isNotBlank()) "IP ${state.resolvedIp}" else ""
).filter { it.isNotBlank() }
)
StatusStrip(state = state, onRefresh = vm::refreshWorkspace)
when (state.currentTab) {
StoryForgeTab.Explore -> ExploreTab(state = state, vm = vm, onPickVideo = onPickVideo)
StoryForgeTab.Production -> ProductionTab(state = state, vm = vm)
StoryForgeTab.Mine -> MineTab(state = state, vm = vm, onInstallLatestUpdate = onInstallLatestUpdate)
}
}
}
@Composable
private fun StatusStrip(state: StoryForgeUiState, onRefresh: () -> Unit) {
SectionCard(title = "连接状态", subtitle = if (state.busy) "正在同步" else "已连接") {
Text(
text = if (state.originalHost.isNotBlank()) {
"外网域名已解析为 ${state.resolvedIp},请求会携带 Host=${state.originalHost}"
} else {
"当前使用地址:${state.baseUrl}"
},
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(onClick = onRefresh) {
Text("刷新")
}
if (state.busy) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
}
if (state.errorMessage.isNotBlank()) {
Text(state.errorMessage, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
}
}
}
@Composable
private fun ExploreTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onPickVideo: () -> Unit) {
SectionCard(title = "素材入口", subtitle = "视频链接、上传视频、输入文字都会转成文本并做风格分析") {
ChoiceRow(
options = listOf(
"视频链接" to (state.exploreInputMode == ExploreInputMode.VideoLink),
"上传视频" to (state.exploreInputMode == ExploreInputMode.UploadVideo),
"输入文字" to (state.exploreInputMode == ExploreInputMode.Text)
),
onSelect = { label ->
vm.setExploreInputMode(
when (label) {
"视频链接" -> ExploreInputMode.VideoLink
"上传视频" -> ExploreInputMode.UploadVideo
else -> ExploreInputMode.Text
}
)
}
)
Spacer(modifier = Modifier.height(12.dp))
KnowledgeBaseSelector(state = state, onSelect = vm::selectKnowledgeBase)
Spacer(modifier = Modifier.height(12.dp))
AssistantSelector(state = state, onSelect = vm::selectAssistant)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "当前分析模型:${state.modelProfiles.firstOrNull { it.id == state.account?.preferred_analysis_model_id }?.name ?: "本机默认模型"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.height(12.dp))
when (state.exploreInputMode) {
ExploreInputMode.VideoLink -> {
OutlinedTextField(
value = state.videoUrl,
onValueChange = vm::updateVideoUrl,
modifier = Modifier.fillMaxWidth(),
label = { Text("短视频链接") },
minLines = 2
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.videoTitle,
onValueChange = vm::updateVideoTitle,
modifier = Modifier.fillMaxWidth(),
label = { Text("素材标题(可选)") },
singleLine = true
)
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = vm::submitVideoLink, enabled = !state.busy) {
Text("提交视频链接")
}
}
ExploreInputMode.UploadVideo -> {
OutlinedTextField(
value = state.videoTitle,
onValueChange = vm::updateVideoTitle,
modifier = Modifier.fillMaxWidth(),
label = { Text("素材标题(可选)") },
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(onClick = onPickVideo) {
Text(if (state.pickedVideoName.isBlank()) "选择视频文件" else "重新选择")
}
Text(
text = if (state.pickedVideoName.isBlank()) "未选择文件" else state.pickedVideoName,
modifier = Modifier.weight(1f),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = vm::submitUploadVideo, enabled = !state.busy && state.pickedVideoName.isNotBlank()) {
Text("上传并开始学习")
}
}
ExploreInputMode.Text -> {
OutlinedTextField(
value = state.textTitle,
onValueChange = vm::updateTextTitle,
modifier = Modifier.fillMaxWidth(),
label = { Text("素材标题") },
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.textContent,
onValueChange = vm::updateTextContent,
modifier = Modifier.fillMaxWidth(),
label = { Text("素材文字") },
minLines = 5
)
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = vm::submitText, enabled = !state.busy) {
Text("分析并沉淀到知识库")
}
}
}
}
state.latestJob?.let { latestJob ->
SectionCard(title = "最新任务", subtitle = latestJob.title) {
KeyValueRow(label = "状态", value = latestJob.status)
KeyValueRow(label = "上传状态", value = latestJob.upload_status)
if (latestJob.transcript_text.isNotBlank()) {
KeyValueBlock(label = "文本转写", value = latestJob.transcript_text)
}
if (latestJob.style_summary.isNotBlank()) {
KeyValueBlock(label = "风格提炼", value = latestJob.style_summary)
}
if (latestJob.error.isNotBlank()) {
Text(latestJob.error, color = MaterialTheme.colorScheme.error)
}
}
}
if (state.documents.isNotEmpty()) {
SectionCard(title = "当前知识库素材", subtitle = "已经沉淀到所选知识库的文本样本") {
state.documents.forEach { document ->
MiniCard(title = document.title, subtitle = document.style_summary.ifBlank { document.transcript_text.take(100) })
Spacer(modifier = Modifier.height(10.dp))
}
}
}
}
@Composable
private fun ProductionTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
SectionCard(title = "智能体列表", subtitle = "一个智能体默认关联一个知识库,也可以关联多个知识库") {
ChoiceRow(
options = state.assistants.map { it.name to (state.selectedAssistantId == it.id) },
onSelect = { label ->
state.assistants.firstOrNull { it.name == label }?.let { vm.selectAssistant(it.id) }
}
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(onClick = vm::startNewAssistant) {
Text("新建智能体")
}
}
SectionCard(title = "编辑智能体", subtitle = "提示词由用户提供,可随时调整模型和知识库绑定") {
OutlinedTextField(
value = state.assistantName,
onValueChange = vm::updateAssistantName,
modifier = Modifier.fillMaxWidth(),
label = { Text("智能体名称") },
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.assistantDescription,
onValueChange = vm::updateAssistantDescription,
modifier = Modifier.fillMaxWidth(),
label = { Text("智能体说明") },
minLines = 2
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.assistantSystemPrompt,
onValueChange = vm::updateAssistantSystemPrompt,
modifier = Modifier.fillMaxWidth(),
label = { Text("系统提示词") },
minLines = 5
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.assistantGenerationGoal,
onValueChange = vm::updateAssistantGenerationGoal,
modifier = Modifier.fillMaxWidth(),
label = { Text("生成目标") },
minLines = 3
)
Spacer(modifier = Modifier.height(12.dp))
Text("选择生成模型", style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.height(8.dp))
ChoiceRow(
options = state.modelProfiles.map { it.name to (state.assistantModelProfileId == it.id) },
onSelect = { label ->
state.modelProfiles.firstOrNull { it.name == label }?.let { vm.updateAssistantModelProfileId(it.id) }
}
)
Spacer(modifier = Modifier.height(12.dp))
Text("选择要关联的知识库", style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.height(8.dp))
ChoiceRow(
options = state.knowledgeBases.map { it.name to state.selectedAssistantKnowledgeBaseIds.contains(it.id) },
onSelect = { label ->
state.knowledgeBases.firstOrNull { it.name == label }?.let { vm.toggleAssistantKnowledgeBase(it.id) }
}
)
Spacer(modifier = Modifier.height(14.dp))
Button(onClick = vm::saveAssistant, enabled = !state.busy) {
Text(if (state.assistantEditorId.isNullOrBlank()) "创建智能体" else "保存智能体配置")
}
}
SectionCard(title = "生成文案", subtitle = "选择智能体后,直接基于关联知识库输出文案") {
OutlinedTextField(
value = state.generationBrief,
onValueChange = vm::updateGenerationBrief,
modifier = Modifier.fillMaxWidth(),
label = { Text("文案需求") },
minLines = 4
)
Spacer(modifier = Modifier.height(10.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = state.generationPlatform,
onValueChange = vm::updateGenerationPlatform,
modifier = Modifier.weight(1f),
label = { Text("平台") },
singleLine = true
)
OutlinedTextField(
value = state.generationAudience,
onValueChange = vm::updateGenerationAudience,
modifier = Modifier.weight(1f),
label = { Text("目标受众") },
singleLine = true
)
}
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.generationExtraRequirements,
onValueChange = vm::updateGenerationExtraRequirements,
modifier = Modifier.fillMaxWidth(),
label = { Text("额外要求") },
minLines = 3
)
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = vm::generateCopy, enabled = !state.generateBusy) {
if (state.generateBusy) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Text("开始生成")
}
}
if (state.generationOutput.isNotBlank()) {
Spacer(modifier = Modifier.height(16.dp))
KeyValueBlock(label = "生成结果", value = state.generationOutput)
}
}
}
@Composable
private fun MineTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onInstallLatestUpdate: () -> Unit) {
SectionCard(title = "我的账号", subtitle = state.account?.display_name ?: state.account?.username.orEmpty()) {
KeyValueRow(label = "用户名", value = state.account?.username ?: "-")
KeyValueRow(label = "角色", value = state.account?.role ?: "-")
KeyValueRow(label = "审批", value = state.account?.approval_status ?: "-")
KeyValueRow(label = "Base URL", value = state.baseUrl)
if (state.resolvedIp.isNotBlank()) {
KeyValueRow(label = "解析 IP", value = state.resolvedIp)
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(onClick = vm::logout) {
Text("退出登录")
}
}
SectionCard(title = "分析模型", subtitle = "探索页默认使用这里选中的模型") {
ChoiceRow(
options = state.modelProfiles.map { it.name to (state.account?.preferred_analysis_model_id == it.id) },
onSelect = { label ->
state.modelProfiles.firstOrNull { it.name == label }?.let { vm.setPreferredModel(it.id) }
}
)
Spacer(modifier = Modifier.height(14.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(14.dp))
OutlinedTextField(
value = state.newModelName,
onValueChange = vm::updateNewModelName,
modifier = Modifier.fillMaxWidth(),
label = { Text("模型别名") },
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.newModelBaseUrl,
onValueChange = vm::updateNewModelBaseUrl,
modifier = Modifier.fillMaxWidth(),
label = { Text("Base URL") },
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.newModelModelName,
onValueChange = vm::updateNewModelModelName,
modifier = Modifier.fillMaxWidth(),
label = { Text("模型名称") },
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.newModelApiKey,
onValueChange = vm::updateNewModelApiKey,
modifier = Modifier.fillMaxWidth(),
label = { Text("API Key") },
minLines = 2
)
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = vm::createModelProfile) {
Text("保存为默认分析模型")
}
}
SectionCard(title = "OTA 更新", subtitle = state.otaStatus.ifBlank { "检查新版本并执行安装" }) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
Button(onClick = vm::checkForUpdates) {
Text("检查更新")
}
OutlinedButton(onClick = onInstallLatestUpdate, enabled = state.otaInfo?.hasUpdate == true) {
Text("安装最新版本")
}
}
state.otaInfo?.let { ota ->
Spacer(modifier = Modifier.height(12.dp))
KeyValueRow(label = "最新版本", value = "${ota.latestVersionName} (${ota.latestVersionCode})")
if (ota.releaseNotes.isNotBlank()) {
KeyValueBlock(label = "更新说明", value = ota.releaseNotes)
}
}
}
if (state.account?.role == "super_admin") {
SectionCard(title = "主管理员审批", subtitle = "新注册账号需要你审批后才能正常使用全部功能") {
if (state.pendingAccounts.isEmpty()) {
Text("当前没有待审批账号")
} else {
state.pendingAccounts.forEach { account ->
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
Column(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(account.display_name, fontWeight = FontWeight.Bold)
Text(account.username, style = MaterialTheme.typography.bodySmall)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = { vm.approveAccount(account.id) }) {
Text("通过")
}
OutlinedButton(onClick = { vm.rejectAccount(account.id) }) {
Text("拒绝")
}
}
}
}
Spacer(modifier = Modifier.height(10.dp))
}
}
}
SectionCard(title = "发布 OTA", subtitle = "主管理员可直接更新在线版本号和下载地址") {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = state.publishVersionCode,
onValueChange = vm::updatePublishVersionCode,
modifier = Modifier.weight(1f),
label = { Text("VersionCode") },
singleLine = true
)
OutlinedTextField(
value = state.publishMinSupportedCode,
onValueChange = vm::updatePublishMinSupportedCode,
modifier = Modifier.weight(1f),
label = { Text("最低支持") },
singleLine = true
)
}
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.publishVersionName,
onValueChange = vm::updatePublishVersionName,
modifier = Modifier.fillMaxWidth(),
label = { Text("VersionName") },
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.publishApkUrl,
onValueChange = vm::updatePublishApkUrl,
modifier = Modifier.fillMaxWidth(),
label = { Text("APK 下载地址") },
minLines = 2
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = state.publishNotes,
onValueChange = vm::updatePublishNotes,
modifier = Modifier.fillMaxWidth(),
label = { Text("更新说明") },
minLines = 3
)
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text("强制更新")
Switch(checked = state.publishForceUpdate, onCheckedChange = vm::setPublishForceUpdate)
}
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = vm::publishUpdate) {
Text("发布 OTA")
}
}
}
SectionCard(title = "最近日志", subtitle = "用于确认审批、解析、任务和 OTA 状态") {
state.timeline.forEach { item ->
Text(item, style = MaterialTheme.typography.bodySmall)
Spacer(modifier = Modifier.height(6.dp))
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ChoiceRow(
options: List<Pair<String, Boolean>>,
onSelect: (String) -> Unit
) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
options.forEach { (label, selected) ->
FilterChip(
selected = selected,
onClick = { onSelect(label) },
label = { Text(label) }
)
}
}
}
@Composable
private fun KnowledgeBaseSelector(state: StoryForgeUiState, onSelect: (String) -> Unit) {
Text("选择知识库", style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.height(8.dp))
ChoiceRow(
options = state.knowledgeBases.map { it.name to (state.selectedKnowledgeBaseId == it.id) },
onSelect = { label ->
state.knowledgeBases.firstOrNull { it.name == label }?.let { onSelect(it.id) }
}
)
}
@Composable
private fun AssistantSelector(state: StoryForgeUiState, onSelect: (String) -> Unit) {
Text("选择关联智能体", style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.height(8.dp))
ChoiceRow(
options = state.assistants.map { it.name to (state.selectedAssistantId == it.id) },
onSelect = { label ->
state.assistants.firstOrNull { it.name == label }?.let { onSelect(it.id) }
}
)
}
@Composable
private fun HeroCard(title: String, subtitle: String, heroBrush: Brush, badges: List<String>) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(28.dp))
.background(heroBrush)
.padding(20.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(title, style = MaterialTheme.typography.headlineLarge, color = Color.White)
Text(subtitle, style = MaterialTheme.typography.bodyLarge, color = Color(0xFFF8F5EF))
if (badges.isNotEmpty()) {
ChoiceRow(options = badges.map { it to true }, onSelect = {})
}
}
}
}
@Composable
private fun SectionCard(title: String, subtitle: String, content: @Composable () -> Unit) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
shape = RoundedCornerShape(22.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(title, style = MaterialTheme.typography.headlineSmall)
if (subtitle.isNotBlank()) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f)
)
}
Spacer(modifier = Modifier.height(6.dp))
content()
}
}
}
@Composable
private fun KeyValueRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(label, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f))
Spacer(modifier = Modifier.width(12.dp))
Text(value, modifier = Modifier.weight(1f), maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
@Composable
private fun KeyValueBlock(label: String, value: String) {
Text(label, style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.height(6.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), RoundedCornerShape(16.dp))
.padding(14.dp)
) {
Text(value)
}
}
@Composable
private fun MiniCard(title: String, subtitle: String) {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
Column(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(title, fontWeight = FontWeight.Bold)
Text(subtitle, maxLines = 4, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall)
}
}
}

View File

@@ -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
}
}
}

View File

@@ -1,907 +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 {
Explore,
Production,
Mine
}
enum class StoryForgeAuthMode {
Login,
Register
}
enum class ExploreInputMode {
VideoLink,
UploadVideo,
Text
}
private const val DEFAULT_SYSTEM_PROMPT = "你是一个擅长学习短视频口播风格的 AI 文案助手,请优先保留素材中的钩子、节奏、转折和行动号召。"
private const val DEFAULT_GENERATION_GOAL = "为不同渠道生成稳定风格的短视频标题、口播脚本和收尾行动号召。"
private fun nextVersionName(current: String): String {
val parts = current.split('.').toMutableList()
val last = parts.lastOrNull()?.toIntOrNull()
if (last != null) {
parts[parts.lastIndex] = (last + 1).toString()
return parts.joinToString(".")
}
return current
}
data class StoryForgeUiState(
val authMode: StoryForgeAuthMode = StoryForgeAuthMode.Login,
val baseUrl: String = BuildConfig.DEFAULT_STORYFORGE_BASE_URL,
val resolvedBaseUrl: String = "",
val resolvedIp: String = "",
val originalHost: String = "",
val isAuthenticated: Boolean = false,
val isApproved: Boolean = false,
val currentTab: StoryForgeTab = StoryForgeTab.Explore,
val busy: Boolean = false,
val generateBusy: Boolean = false,
val statusMessage: String = "准备连接 StoryForge",
val errorMessage: String = "",
val account: AccountDto? = null,
val knowledgeBases: List<KnowledgeBaseDto> = emptyList(),
val assistants: List<AssistantDto> = emptyList(),
val modelProfiles: List<ModelProfileDto> = emptyList(),
val jobs: List<JobDto> = emptyList(),
val documents: List<KnowledgeDocumentDto> = emptyList(),
val selectedKnowledgeBaseId: String = "",
val selectedAssistantId: String = "",
val selectedAssistantKnowledgeBaseIds: Set<String> = emptySet(),
val assistantEditorId: String? = null,
val username: String = "",
val password: String = "",
val createKnowledgeBaseName: String = "",
val createKnowledgeBaseDescription: String = "",
val exploreInputMode: ExploreInputMode = ExploreInputMode.VideoLink,
val videoUrl: String = "",
val videoTitle: String = "",
val textTitle: String = "",
val textContent: String = "",
val pickedVideoName: String = "",
val latestJobId: String = "",
val latestJob: JobDto? = null,
val assistantName: String = "",
val assistantDescription: String = "",
val assistantSystemPrompt: String = DEFAULT_SYSTEM_PROMPT,
val assistantGenerationGoal: String = DEFAULT_GENERATION_GOAL,
val assistantModelProfileId: String = "",
val generationBrief: String = "围绕 AI 创业做一条 60 秒短视频口播文案",
val generationPlatform: String = "抖音",
val generationAudience: String = "创业者",
val generationExtraRequirements: String = "开头结论先行,结尾给一个明确行动建议。",
val generationOutput: String = "",
val generationPromptExcerpt: String = "",
val newModelName: String = "",
val newModelBaseUrl: String = BuildConfig.DEFAULT_LOCAL_MODEL_BASE_URL,
val newModelApiKey: String = "",
val newModelModelName: String = "GLM-5",
val pendingAccounts: List<AccountDto> = emptyList(),
val otaInfo: AppUpdateLatestDto? = null,
val otaStatus: String = "",
val publishVersionCode: String = (BuildConfig.VERSION_CODE + 1).toString(),
val publishVersionName: String = nextVersionName(BuildConfig.VERSION_NAME),
val publishMinSupportedCode: String = BuildConfig.VERSION_CODE.toString(),
val publishApkUrl: String = "",
val publishNotes: String = "",
val publishForceUpdate: Boolean = false,
val timeline: List<String> = listOf("应用已启动,等待连接")
)
class StoryForgeViewModel(application: Application) : AndroidViewModel(application) {
private val repository = StoryForgeRepository(application.applicationContext)
private val _state = MutableStateFlow(StoryForgeUiState(baseUrl = repository.savedSession().baseUrl))
val state: StateFlow<StoryForgeUiState> = _state.asStateFlow()
private var jobPollingJob: Job? = null
private var pickedVideoUri: Uri? = null
init {
restoreSession()
}
fun updateBaseUrl(value: String) {
_state.value = _state.value.copy(baseUrl = value)
repository.saveBaseUrl(value)
}
fun updateUsername(value: String) {
_state.value = _state.value.copy(username = value)
}
fun updatePassword(value: String) {
_state.value = _state.value.copy(password = value)
}
fun setAuthMode(mode: StoryForgeAuthMode) {
_state.value = _state.value.copy(authMode = mode, errorMessage = "")
}
fun selectTab(tab: StoryForgeTab) {
_state.value = _state.value.copy(currentTab = tab)
if (tab == StoryForgeTab.Mine && state.value.account?.role == "super_admin") {
loadPendingAccounts()
}
}
fun updateCreateKnowledgeBaseName(value: String) {
_state.value = _state.value.copy(createKnowledgeBaseName = value)
}
fun updateCreateKnowledgeBaseDescription(value: String) {
_state.value = _state.value.copy(createKnowledgeBaseDescription = value)
}
fun updateVideoUrl(value: String) {
_state.value = _state.value.copy(videoUrl = value)
}
fun updateVideoTitle(value: String) {
_state.value = _state.value.copy(videoTitle = value)
}
fun updateTextTitle(value: String) {
_state.value = _state.value.copy(textTitle = value)
}
fun updateTextContent(value: String) {
_state.value = _state.value.copy(textContent = value)
}
fun setExploreInputMode(mode: ExploreInputMode) {
_state.value = _state.value.copy(exploreInputMode = mode, errorMessage = "")
}
fun setPickedVideo(uri: Uri?, fileName: String) {
pickedVideoUri = uri
_state.value = _state.value.copy(pickedVideoName = fileName)
}
fun selectKnowledgeBase(knowledgeBaseId: String) {
_state.value = _state.value.copy(selectedKnowledgeBaseId = knowledgeBaseId)
refreshDocuments()
}
fun selectAssistant(assistantId: String) {
val assistant = _state.value.assistants.firstOrNull { it.id == assistantId }
_state.value = _state.value.copy(
selectedAssistantId = assistantId,
selectedAssistantKnowledgeBaseIds = assistant?.knowledge_base_ids?.toSet() ?: emptySet(),
assistantEditorId = assistant?.id,
assistantName = assistant?.name.orEmpty(),
assistantDescription = assistant?.description.orEmpty(),
assistantSystemPrompt = assistant?.system_prompt ?: DEFAULT_SYSTEM_PROMPT,
assistantGenerationGoal = assistant?.generation_goal ?: DEFAULT_GENERATION_GOAL,
assistantModelProfileId = assistant?.model_profile_id.orEmpty(),
generationOutput = "",
generationPromptExcerpt = ""
)
}
fun startNewAssistant() {
_state.value = _state.value.copy(
assistantEditorId = null,
assistantName = "",
assistantDescription = "",
assistantSystemPrompt = DEFAULT_SYSTEM_PROMPT,
assistantGenerationGoal = DEFAULT_GENERATION_GOAL,
assistantModelProfileId = preferredModelId(),
selectedAssistantKnowledgeBaseIds = listOfNotNull(state.value.selectedKnowledgeBaseId.takeIf { it.isNotBlank() }).toSet()
)
}
fun toggleAssistantKnowledgeBase(knowledgeBaseId: String) {
val updated = _state.value.selectedAssistantKnowledgeBaseIds.toMutableSet()
if (!updated.add(knowledgeBaseId)) {
updated.remove(knowledgeBaseId)
}
_state.value = _state.value.copy(selectedAssistantKnowledgeBaseIds = updated)
}
fun updateAssistantName(value: String) {
_state.value = _state.value.copy(assistantName = value)
}
fun updateAssistantDescription(value: String) {
_state.value = _state.value.copy(assistantDescription = value)
}
fun updateAssistantSystemPrompt(value: String) {
_state.value = _state.value.copy(assistantSystemPrompt = value)
}
fun updateAssistantGenerationGoal(value: String) {
_state.value = _state.value.copy(assistantGenerationGoal = value)
}
fun updateAssistantModelProfileId(value: String) {
_state.value = _state.value.copy(assistantModelProfileId = value)
}
fun updateGenerationBrief(value: String) {
_state.value = _state.value.copy(generationBrief = value)
}
fun updateGenerationPlatform(value: String) {
_state.value = _state.value.copy(generationPlatform = value)
}
fun updateGenerationAudience(value: String) {
_state.value = _state.value.copy(generationAudience = value)
}
fun updateGenerationExtraRequirements(value: String) {
_state.value = _state.value.copy(generationExtraRequirements = value)
}
fun updateNewModelName(value: String) {
_state.value = _state.value.copy(newModelName = value)
}
fun updateNewModelBaseUrl(value: String) {
_state.value = _state.value.copy(newModelBaseUrl = value)
}
fun updateNewModelApiKey(value: String) {
_state.value = _state.value.copy(newModelApiKey = value)
}
fun updateNewModelModelName(value: String) {
_state.value = _state.value.copy(newModelModelName = value)
}
fun updatePublishVersionCode(value: String) {
_state.value = _state.value.copy(publishVersionCode = value)
}
fun updatePublishVersionName(value: String) {
_state.value = _state.value.copy(publishVersionName = value)
}
fun updatePublishMinSupportedCode(value: String) {
_state.value = _state.value.copy(publishMinSupportedCode = value)
}
fun updatePublishApkUrl(value: String) {
_state.value = _state.value.copy(publishApkUrl = value)
}
fun updatePublishNotes(value: String) {
_state.value = _state.value.copy(publishNotes = value)
}
fun setPublishForceUpdate(value: Boolean) {
_state.value = _state.value.copy(publishForceUpdate = value)
}
fun registerAccount() {
val current = state.value
if (current.username.isBlank() || current.password.isBlank()) {
setError("请填写用户名和密码")
return
}
runBusy(message = "正在提交注册申请...", task = {
repository.register(
baseUrl = current.baseUrl,
username = current.username.trim(),
password = current.password,
displayName = current.username.trim()
)
}) { account ->
appendTimeline("账号 ${account.username} 已注册,等待主管理员审批")
_state.value = _state.value.copy(
authMode = StoryForgeAuthMode.Login,
statusMessage = "注册成功,请等待主管理员审批",
errorMessage = ""
)
}
}
fun login() {
val current = state.value
if (current.username.isBlank() || current.password.isBlank()) {
setError("请先填写用户名和密码")
return
}
runBusy(message = "正在登录 StoryForge...", task = {
repository.login(
baseUrl = current.baseUrl,
username = current.username.trim(),
password = current.password
)
}) { result ->
applyConnection(result.connection)
appendTimeline("账号 ${result.auth.account.username} 登录成功")
val account = result.auth.account
_state.value = _state.value.copy(
isAuthenticated = true,
isApproved = account.approval_status == "approved",
account = account,
statusMessage = if (account.approval_status == "approved") "登录成功,正在同步工作台" else "账号待主管理员审批",
errorMessage = ""
)
if (account.approval_status == "approved") {
refreshWorkspace()
}
}
}
fun refreshApprovalStatus() {
runBusy(message = "正在刷新审批状态...", task = {
repository.me() to repository.currentConnection()
}) { (account, connection) ->
applyConnection(connection)
_state.value = _state.value.copy(
isAuthenticated = true,
isApproved = account.approval_status == "approved",
account = account,
statusMessage = if (account.approval_status == "approved") "审批已通过,正在同步工作台" else "当前账号仍在等待审批",
errorMessage = ""
)
appendTimeline("审批状态更新为 ${account.approval_status}")
if (account.approval_status == "approved") {
refreshWorkspace()
}
}
}
fun logout() {
viewModelScope.launch {
repository.logout()
jobPollingJob?.cancel()
pickedVideoUri = null
appendTimeline("已退出当前账号")
_state.value = StoryForgeUiState(baseUrl = repository.savedSession().baseUrl)
}
}
fun refreshWorkspace() {
viewModelScope.launch {
val current = state.value
_state.value = current.copy(busy = true, errorMessage = "", statusMessage = "正在同步工作台数据...")
runCatching {
val me = repository.me()
val connection = repository.currentConnection()
if (me.approval_status != "approved") {
Triple(me, connection, null)
} else {
Triple(me, connection, repository.dashboard())
}
}.onSuccess { (account, connection, dashboard) ->
applyConnection(connection)
if (dashboard == null) {
_state.value = state.value.copy(
busy = false,
isAuthenticated = true,
isApproved = false,
account = account,
statusMessage = "账号待主管理员审批"
)
} else {
applyDashboard(account, dashboard)
}
}.onFailure { throwable ->
if (throwable is HttpException && throwable.code() == 401) {
repository.logout()
_state.value = StoryForgeUiState(baseUrl = repository.savedSession().baseUrl).copy(
errorMessage = "登录已失效,请重新登录",
statusMessage = "请重新登录 StoryForge"
)
} else {
_state.value = state.value.copy(
busy = false,
errorMessage = throwable.toReadableMessage(),
statusMessage = "同步失败,请检查网络或稍后重试"
)
appendTimeline("同步失败: ${throwable.toReadableMessage()}")
}
}
}
}
fun createKnowledgeBase() {
val current = state.value
if (current.createKnowledgeBaseName.isBlank()) {
setError("请先填写知识库名称")
return
}
runBusy(message = "正在创建知识库...", task = {
repository.createKnowledgeBase(current.createKnowledgeBaseName.trim(), current.createKnowledgeBaseDescription.trim())
}) { knowledgeBase ->
appendTimeline("已创建知识库 ${knowledgeBase.name}")
_state.value = state.value.copy(
createKnowledgeBaseName = "",
createKnowledgeBaseDescription = "",
selectedKnowledgeBaseId = knowledgeBase.id
)
refreshWorkspace()
}
}
fun submitVideoLink() {
val current = state.value
if (current.videoUrl.isBlank()) {
setError("请先输入视频链接")
return
}
val knowledgeBaseId = selectedKnowledgeBaseIdOrFallback()
if (knowledgeBaseId.isBlank()) {
setError("请先选择知识库")
return
}
runBusy(message = "正在提交视频学习任务...", task = {
repository.createVideoLinkJob(
videoUrl = current.videoUrl.trim(),
title = current.videoTitle.trim(),
knowledgeBaseId = knowledgeBaseId,
assistantId = current.selectedAssistantId,
analysisModelProfileId = preferredModelId()
)
}) { job ->
appendTimeline("视频链接任务已创建: ${job.title}")
_state.value = state.value.copy(videoUrl = "", videoTitle = "")
afterJobCreated(job)
}
}
fun submitText() {
val current = state.value
if (current.textTitle.isBlank() || current.textContent.isBlank()) {
setError("请输入素材标题和文字内容")
return
}
val knowledgeBaseId = selectedKnowledgeBaseIdOrFallback()
if (knowledgeBaseId.isBlank()) {
setError("请先选择知识库")
return
}
runBusy(message = "正在提交文字分析任务...", task = {
repository.createTextJob(
title = current.textTitle.trim(),
content = current.textContent.trim(),
knowledgeBaseId = knowledgeBaseId,
assistantId = current.selectedAssistantId,
analysisModelProfileId = preferredModelId()
)
}) { job ->
appendTimeline("文字素材已进入分析队列: ${job.title}")
_state.value = state.value.copy(textTitle = "", textContent = "")
afterJobCreated(job)
}
}
fun submitUploadVideo() {
val current = state.value
val uri = pickedVideoUri
if (uri == null) {
setError("请先选择本地视频文件")
return
}
val knowledgeBaseId = selectedKnowledgeBaseIdOrFallback()
if (knowledgeBaseId.isBlank()) {
setError("请先选择知识库")
return
}
runBusy(message = "正在上传视频并创建学习任务...", task = {
repository.uploadVideo(
uri = uri,
title = current.videoTitle.trim(),
knowledgeBaseId = knowledgeBaseId,
assistantId = current.selectedAssistantId,
analysisModelProfileId = preferredModelId()
)
}) { job ->
appendTimeline("视频上传成功,任务已创建: ${job.title}")
pickedVideoUri = null
_state.value = state.value.copy(videoTitle = "", pickedVideoName = "")
afterJobCreated(job)
}
}
fun saveAssistant() {
val current = state.value
if (current.assistantName.isBlank()) {
setError("请先填写智能体名称")
return
}
if (current.selectedAssistantKnowledgeBaseIds.isEmpty()) {
setError("请至少关联一个知识库")
return
}
val request = AssistantCreateRequest(
name = current.assistantName.trim(),
description = current.assistantDescription.trim(),
system_prompt = current.assistantSystemPrompt.trim(),
generation_goal = current.assistantGenerationGoal.trim(),
knowledge_base_ids = current.selectedAssistantKnowledgeBaseIds.toList(),
model_profile_id = current.assistantModelProfileId.ifBlank { preferredModelId() }
)
if (current.assistantEditorId.isNullOrBlank()) {
runBusy(message = "正在创建智能体...", task = {
repository.createAssistant(request)
}) { assistant ->
appendTimeline("已创建智能体 ${assistant.name}")
_state.value = state.value.copy(selectedAssistantId = assistant.id)
refreshWorkspace()
}
} else {
runBusy(message = "正在保存智能体配置...", task = {
repository.updateAssistant(
current.assistantEditorId,
AssistantUpdateRequest(
name = request.name,
description = request.description,
system_prompt = request.system_prompt,
generation_goal = request.generation_goal,
knowledge_base_ids = request.knowledge_base_ids,
model_profile_id = request.model_profile_id
)
)
}) { assistant ->
appendTimeline("已更新智能体 ${assistant.name}")
_state.value = state.value.copy(selectedAssistantId = assistant.id)
refreshWorkspace()
}
}
}
fun generateCopy() {
val current = state.value
val assistantId = current.selectedAssistantId.ifBlank { current.assistantEditorId.orEmpty() }
if (assistantId.isBlank()) {
setError("请先选择一个智能体")
return
}
if (current.generationBrief.isBlank()) {
setError("请先填写文案需求")
return
}
viewModelScope.launch {
_state.value = state.value.copy(generateBusy = true, errorMessage = "", statusMessage = "正在生成文案,请稍候...")
runCatching {
repository.generateCopy(
assistantId,
GenerateCopyRequest(
brief = current.generationBrief.trim(),
platform = current.generationPlatform.trim(),
audience = current.generationAudience.trim(),
extra_requirements = current.generationExtraRequirements.trim(),
knowledge_base_ids = current.selectedAssistantKnowledgeBaseIds.toList()
)
)
}.onSuccess { result ->
_state.value = state.value.copy(
generateBusy = false,
generationOutput = result.content,
generationPromptExcerpt = result.prompt_excerpt,
statusMessage = "文案生成完成"
)
appendTimeline("智能体已生成一条新文案")
}.onFailure { throwable ->
_state.value = state.value.copy(
generateBusy = false,
errorMessage = throwable.toReadableMessage(),
statusMessage = "文案生成失败"
)
appendTimeline("文案生成失败: ${throwable.toReadableMessage()}")
}
}
}
fun createModelProfile() {
val current = state.value
if (current.newModelName.isBlank() || current.newModelBaseUrl.isBlank() || current.newModelApiKey.isBlank() || current.newModelModelName.isBlank()) {
setError("请完整填写模型名称、Base URL、API Key 和模型名")
return
}
runBusy(message = "正在保存模型配置...", task = {
repository.createModelProfile(
ModelProfileRequest(
name = current.newModelName.trim(),
base_url = current.newModelBaseUrl.trim(),
api_key = current.newModelApiKey.trim(),
model_name = current.newModelModelName.trim(),
is_default = true
)
)
}) { profile ->
appendTimeline("已新增模型配置 ${profile.name}")
_state.value = state.value.copy(
newModelName = "",
newModelApiKey = "",
newModelModelName = current.newModelModelName,
assistantModelProfileId = profile.id
)
refreshWorkspace()
}
}
fun setPreferredModel(modelProfileId: String) {
runBusy(message = "正在切换默认分析模型...", task = {
repository.setPreferredAnalysisModel(modelProfileId)
}) { account ->
_state.value = state.value.copy(account = account)
appendTimeline("已切换默认分析模型")
refreshWorkspace()
}
}
fun loadPendingAccounts() {
if (state.value.account?.role != "super_admin") return
viewModelScope.launch {
runCatching { repository.pendingAccounts() }
.onSuccess { pending ->
_state.value = state.value.copy(pendingAccounts = pending)
}
.onFailure { throwable ->
_state.value = state.value.copy(errorMessage = throwable.toReadableMessage())
}
}
}
fun approveAccount(accountId: String) {
runBusy(message = "正在通过账号审批...", task = {
repository.approveAccount(accountId)
}) {
appendTimeline("已通过一条账号审批")
refreshWorkspace()
}
}
fun rejectAccount(accountId: String) {
runBusy(message = "正在拒绝账号申请...", task = {
repository.rejectAccount(accountId)
}) {
appendTimeline("已拒绝一条账号申请")
refreshWorkspace()
}
}
fun checkForUpdates() {
viewModelScope.launch {
_state.value = state.value.copy(otaStatus = "正在检查更新...")
runCatching { repository.latestUpdate(BuildConfig.VERSION_CODE) }
.onSuccess { latest ->
_state.value = state.value.copy(
otaInfo = latest,
otaStatus = if (latest.hasUpdate) {
"发现新版本 ${latest.latestVersionName} (${latest.latestVersionCode})"
} else {
"当前已经是最新版本"
}
)
appendTimeline("OTA 检查完成")
}
.onFailure { throwable ->
_state.value = state.value.copy(otaStatus = throwable.toReadableMessage(), errorMessage = throwable.toReadableMessage())
}
}
}
fun publishUpdate() {
val current = state.value
val versionCode = current.publishVersionCode.toIntOrNull()
val minSupportedCode = current.publishMinSupportedCode.toIntOrNull()
if (versionCode == null || minSupportedCode == null || current.publishVersionName.isBlank() || current.publishApkUrl.isBlank()) {
setError("请完整填写 OTA 的版本号、最小支持版本、下载地址")
return
}
runBusy(message = "正在发布 OTA 配置...", task = {
repository.publishAppUpdate(
PublishAppUpdateRequest(
versionCode = versionCode,
versionName = current.publishVersionName.trim(),
minSupportedCode = minSupportedCode,
apkUrl = current.publishApkUrl.trim(),
notes = current.publishNotes.trim(),
forceUpdate = current.publishForceUpdate
)
)
}) { response ->
_state.value = state.value.copy(otaStatus = "已发布 OTA: ${response.action}")
appendTimeline("主管理员已发布 OTA ${current.publishVersionName}")
checkForUpdates()
}
}
fun onOtaLog(message: String) {
appendTimeline(message)
_state.value = state.value.copy(otaStatus = message)
}
fun installLatestUpdate(otaUpdater: AppOtaUpdater) {
val latest = state.value.otaInfo
if (latest == null || !latest.hasUpdate || latest.downloadUrl.isBlank()) {
setError("当前没有可安装的更新")
return
}
val started = otaUpdater.downloadAndInstall(
apkUrl = latest.downloadUrl,
versionName = latest.latestVersionName.ifBlank { "${latest.latestVersionCode}" },
expectedSha256 = latest.apkSha256
)
_state.value = state.value.copy(otaStatus = if (started) "OTA 下载已启动" else "OTA 下载启动失败")
}
private fun restoreSession() {
val saved = repository.savedSession()
_state.value = state.value.copy(baseUrl = saved.baseUrl)
if (saved.token.isBlank()) {
viewModelScope.launch {
runCatching { repository.resolveConnection(saved.baseUrl) }
.onSuccess { applyConnection(it) }
}
return
}
refreshWorkspace()
}
private fun refreshDocuments() {
val knowledgeBaseId = state.value.selectedKnowledgeBaseId
if (knowledgeBaseId.isBlank() || !state.value.isApproved) return
viewModelScope.launch {
runCatching { repository.knowledgeDocuments(knowledgeBaseId) }
.onSuccess { documents ->
_state.value = state.value.copy(documents = documents)
}
.onFailure { throwable ->
_state.value = state.value.copy(errorMessage = throwable.toReadableMessage())
}
}
}
private fun afterJobCreated(job: JobDto) {
_state.value = state.value.copy(
latestJob = job,
latestJobId = job.id,
currentTab = StoryForgeTab.Explore
)
refreshWorkspace()
startJobPolling(job.id)
}
private fun startJobPolling(jobId: String) {
jobPollingJob?.cancel()
jobPollingJob = viewModelScope.launch {
repeat(30) {
delay(5000)
runCatching { repository.job(jobId) }
.onSuccess { job ->
_state.value = state.value.copy(latestJob = job, latestJobId = job.id)
if (job.status == "completed" || job.status == "failed") {
appendTimeline("素材任务 ${job.title}${if (job.status == "completed") "完成" else "失败"}")
refreshWorkspace()
return@launch
}
}
}
}
}
private fun applyDashboard(account: AccountDto, dashboard: DashboardDto) {
val selectedKbId = state.value.selectedKnowledgeBaseId.takeIf { id -> dashboard.knowledge_bases.any { it.id == id } }
?: dashboard.knowledge_bases.firstOrNull()?.id.orEmpty()
val selectedAssistantId = state.value.selectedAssistantId.takeIf { id -> dashboard.assistants.any { it.id == id } }
?: dashboard.assistants.firstOrNull()?.id.orEmpty()
val selectedAssistant = dashboard.assistants.firstOrNull { it.id == selectedAssistantId }
_state.value = state.value.copy(
busy = false,
isAuthenticated = true,
isApproved = true,
account = account,
knowledgeBases = dashboard.knowledge_bases,
assistants = dashboard.assistants,
modelProfiles = dashboard.model_profiles,
jobs = dashboard.recent_jobs,
documents = emptyList(),
selectedKnowledgeBaseId = selectedKbId,
selectedAssistantId = selectedAssistantId,
selectedAssistantKnowledgeBaseIds = selectedAssistant?.knowledge_base_ids?.toSet()
?: listOfNotNull(selectedKbId.takeIf { it.isNotBlank() }).toSet(),
assistantEditorId = selectedAssistant?.id,
assistantName = selectedAssistant?.name.orEmpty(),
assistantDescription = selectedAssistant?.description.orEmpty(),
assistantSystemPrompt = selectedAssistant?.system_prompt ?: DEFAULT_SYSTEM_PROMPT,
assistantGenerationGoal = selectedAssistant?.generation_goal ?: DEFAULT_GENERATION_GOAL,
assistantModelProfileId = (selectedAssistant?.model_profile_id ?: "").ifBlank { preferredModelId(dashboard, account) },
latestJob = dashboard.recent_jobs.firstOrNull(),
latestJobId = dashboard.recent_jobs.firstOrNull()?.id.orEmpty(),
pendingAccounts = if (account.role == "super_admin") state.value.pendingAccounts else emptyList(),
statusMessage = "工作台已同步完成",
errorMessage = ""
)
refreshDocuments()
if (account.role == "super_admin") {
loadPendingAccounts()
}
}
private fun preferredModelId(
dashboard: DashboardDto? = null,
account: AccountDto? = state.value.account
): String {
val currentDashboard = dashboard
val accountPreferred = account?.preferred_analysis_model_id.orEmpty()
if (accountPreferred.isNotBlank()) return accountPreferred
val profiles = currentDashboard?.model_profiles ?: state.value.modelProfiles
return profiles.firstOrNull { it.is_default }?.id.orEmpty()
}
private fun selectedKnowledgeBaseIdOrFallback(): String {
return state.value.selectedKnowledgeBaseId.ifBlank {
state.value.knowledgeBases.firstOrNull()?.id.orEmpty()
}
}
private fun applyConnection(connection: StoryForgeConnectionInfo) {
_state.value = state.value.copy(
baseUrl = connection.rawBaseUrl,
resolvedBaseUrl = connection.requestBaseUrl,
resolvedIp = connection.resolvedIp,
originalHost = connection.originalHostHeader
)
}
private fun setError(message: String) {
_state.value = state.value.copy(errorMessage = message, statusMessage = message)
}
private fun appendTimeline(message: String) {
val next = (listOf(message) + state.value.timeline).distinct().take(16)
_state.value = state.value.copy(timeline = next)
}
private fun <T> runBusy(
message: String,
task: suspend () -> T,
onSuccess: (T) -> Unit
) {
viewModelScope.launch {
_state.value = state.value.copy(busy = true, errorMessage = "", statusMessage = message)
runCatching { task() }
.onSuccess { result ->
_state.value = state.value.copy(busy = false, errorMessage = "")
onSuccess(result)
}
.onFailure { throwable ->
_state.value = state.value.copy(
busy = false,
errorMessage = throwable.toReadableMessage(),
statusMessage = throwable.toReadableMessage()
)
appendTimeline(throwable.toReadableMessage())
}
}
}
}
private fun Throwable.toReadableMessage(): String {
if (this is HttpException) {
val body = response()?.errorBody()?.string().orEmpty()
return if (body.isNotBlank()) {
body.take(240)
} else {
"请求失败 (${code()})"
}
}
return message ?: "发生未知错误"
}

View File

@@ -1,74 +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(0xFF0E4B43),
secondary = Color(0xFF9C6427),
tertiary = Color(0xFF2A5B8A),
background = Color(0xFFF7F3EC),
surface = Color(0xFFFFFCF8),
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color(0xFF1A1713),
onSurface = Color(0xFF1A1713)
)
private val DarkColors = darkColorScheme(
primary = Color(0xFF7FD6C7),
secondary = Color(0xFFFFC27A),
tertiary = Color(0xFF98C7FF),
background = Color(0xFF101714),
surface = Color(0xFF18211D),
onPrimary = Color(0xFF062D29),
onSecondary = Color(0xFF4B2B00),
onBackground = Color(0xFFF0E8DB),
onSurface = Color(0xFFF0E8DB)
)
private val AppTypography = Typography(
headlineLarge = TextStyle(
fontFamily = FontFamily.Serif,
fontWeight = FontWeight.Bold,
fontSize = 34.sp,
lineHeight = 40.sp
),
headlineSmall = TextStyle(
fontFamily = FontFamily.Serif,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.SansSerif,
fontSize = 16.sp,
lineHeight = 24.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Medium,
fontSize = 14.sp
)
)
@Composable
fun AIGlassesTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
typography = AppTypography,
content = content
)
}

View File

@@ -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"
}
}

View File

@@ -1,3 +0,0 @@
<resources>
<string name="app_name">StoryForge AI</string>
</resources>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.AIGlasses" parent="Theme.Material3.DayNight.NoActionBar" />
</resources>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="ota_cache"
path="ota/" />
</paths>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>

View File

@@ -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
}

View File

@@ -1,5 +0,0 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official

Binary file not shown.

View File

@@ -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
View File

@@ -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" "$@"

View File

@@ -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

View File

@@ -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")

View File

@@ -0,0 +1,3 @@
.venv311
app/__pycache__
*.pyc

View File

@@ -1,4 +1,9 @@
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 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt ./

View File

@@ -1 +1 @@
# StoryForge collector-service package
"""Collector service source overlay for legacy pyc-backed app."""

File diff suppressed because it is too large Load Diff

View File

@@ -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
@@ -48,6 +64,18 @@ class Database:
with self.session() as conn:
conn.execute(sql, params)
def table_exists(self, name: str) -> bool:
row = self.fetch_one(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
(name,),
)
return bool(row)
def column_exists(self, table: str, column: str) -> bool:
with self.session() as conn:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
return any(row["name"] == column for row in rows)
def init_schema(self) -> None:
schema = """
CREATE TABLE IF NOT EXISTS accounts (
@@ -90,10 +118,10 @@ class Database:
CREATE TABLE IF NOT EXISTS knowledge_bases (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
project_id TEXT,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
fastgpt_dataset_id TEXT,
sync_status TEXT NOT NULL DEFAULT 'pending',
sync_status TEXT NOT NULL DEFAULT 'ready',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE
@@ -108,7 +136,9 @@ class Database:
transcript_text TEXT NOT NULL DEFAULT '',
style_summary TEXT NOT NULL DEFAULT '',
combined_text TEXT NOT NULL DEFAULT '',
fastgpt_collection_id TEXT NOT NULL DEFAULT '',
analysis_json TEXT NOT NULL DEFAULT '{}',
storyboard_json TEXT NOT NULL DEFAULT '[]',
source_artifact_json TEXT NOT NULL DEFAULT '{}',
analysis_model_profile_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
@@ -118,11 +148,12 @@ class Database:
CREATE TABLE IF NOT EXISTS assistants (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
project_id TEXT,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
system_prompt TEXT NOT NULL DEFAULT '',
generation_goal TEXT NOT NULL DEFAULT '',
fastgpt_app_key TEXT NOT NULL DEFAULT '',
config_json TEXT NOT NULL DEFAULT '{}',
model_profile_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
@@ -140,19 +171,27 @@ class Database:
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
project_id TEXT,
parent_job_id TEXT,
assistant_id TEXT,
knowledge_base_id TEXT NOT NULL,
content_source_id TEXT,
source_type TEXT NOT NULL,
line_type TEXT NOT NULL DEFAULT 'analysis',
workflow_key TEXT NOT NULL DEFAULT '',
orchestrator TEXT NOT NULL DEFAULT 'n8n',
provider_name TEXT NOT NULL DEFAULT '',
provider_task_id TEXT NOT NULL DEFAULT '',
source_url TEXT,
title TEXT NOT NULL,
language TEXT NOT NULL DEFAULT 'auto',
status TEXT NOT NULL,
transcript_text TEXT NOT NULL DEFAULT '',
style_summary TEXT NOT NULL DEFAULT '',
fastgpt_collection_id TEXT NOT NULL DEFAULT '',
upload_status TEXT NOT NULL DEFAULT 'pending',
error TEXT NOT NULL DEFAULT '',
artifacts_json TEXT NOT NULL DEFAULT '{}',
result_json TEXT NOT NULL DEFAULT '{}',
analysis_model_profile_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
@@ -161,6 +200,131 @@ class Database:
FOREIGN KEY(knowledge_base_id) REFERENCES knowledge_bases(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS content_sources (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
project_id TEXT,
source_kind TEXT NOT NULL,
platform TEXT NOT NULL DEFAULT '',
handle TEXT NOT NULL DEFAULT '',
source_url TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
local_path TEXT NOT NULL DEFAULT '',
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS publish_reviews (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
project_id TEXT,
source_job_id TEXT,
assistant_id TEXT,
title TEXT NOT NULL,
platform TEXT NOT NULL DEFAULT 'douyin',
content_type TEXT NOT NULL DEFAULT 'video',
publish_url TEXT NOT NULL DEFAULT '',
published_at TEXT NOT NULL DEFAULT '',
metrics_json TEXT NOT NULL DEFAULT '{}',
verdict TEXT NOT NULL DEFAULT '',
highlights TEXT NOT NULL DEFAULT '',
next_actions TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE SET NULL,
FOREIGN KEY(source_job_id) REFERENCES jobs(id) ON DELETE SET NULL,
FOREIGN KEY(assistant_id) REFERENCES assistants(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS live_recorder_sources (
id TEXT PRIMARY KEY,
platform TEXT NOT NULL DEFAULT '',
source_url TEXT NOT NULL,
remote_name TEXT NOT NULL UNIQUE,
title TEXT NOT NULL DEFAULT '',
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(platform, source_url)
);
CREATE TABLE IF NOT EXISTS live_recorder_bindings (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
project_id TEXT,
assistant_id TEXT,
source_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
quality TEXT NOT NULL DEFAULT '原画',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, source_id),
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE SET NULL,
FOREIGN KEY(assistant_id) REFERENCES assistants(id) ON DELETE SET NULL,
FOREIGN KEY(source_id) REFERENCES live_recorder_sources(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS tenant_quota_profiles (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
project_id TEXT NOT NULL DEFAULT '',
monthly_budget_cents INTEGER NOT NULL DEFAULT 0,
storage_limit_bytes INTEGER NOT NULL DEFAULT 0,
analysis_quota INTEGER NOT NULL DEFAULT 0,
copy_quota INTEGER NOT NULL DEFAULT 0,
ai_video_quota INTEGER NOT NULL DEFAULT 0,
real_cut_quota INTEGER NOT NULL DEFAULT 0,
recorder_quota INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
config_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, project_id),
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS tenant_usage_ledger (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
project_id TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
cost_cents INTEGER NOT NULL DEFAULT 0,
reference_type TEXT NOT NULL DEFAULT '',
reference_id TEXT NOT NULL DEFAULT '',
details_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS job_events (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
FOREIGN KEY(job_id) REFERENCES jobs(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS app_updates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
@@ -176,6 +340,124 @@ class Database:
published_at INTEGER NOT NULL,
created_by TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS system_runtime_settings (
key TEXT PRIMARY KEY,
value_json TEXT NOT NULL DEFAULT '{}',
updated_by TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
"""
with self.session() as conn:
conn.executescript(schema)
self.migrate_schema()
def migrate_schema(self) -> None:
table_columns: dict[str, dict[str, str]] = {
"knowledge_bases": {
"project_id": "TEXT",
},
"knowledge_documents": {
"analysis_json": "TEXT NOT NULL DEFAULT '{}'",
"storyboard_json": "TEXT NOT NULL DEFAULT '[]'",
"source_artifact_json": "TEXT NOT NULL DEFAULT '{}'",
},
"assistants": {
"project_id": "TEXT",
"config_json": "TEXT NOT NULL DEFAULT '{}'",
},
"jobs": {
"project_id": "TEXT",
"parent_job_id": "TEXT",
"content_source_id": "TEXT",
"line_type": "TEXT NOT NULL DEFAULT 'analysis'",
"workflow_key": "TEXT NOT NULL DEFAULT ''",
"orchestrator": "TEXT NOT NULL DEFAULT 'n8n'",
"provider_name": "TEXT NOT NULL DEFAULT ''",
"provider_task_id": "TEXT NOT NULL DEFAULT ''",
"result_json": "TEXT NOT NULL DEFAULT '{}'",
},
"platform_agent_profiles": {
"last_run_id": "TEXT NOT NULL DEFAULT ''",
"last_run_status": "TEXT NOT NULL DEFAULT ''",
"last_used_at": "TEXT NOT NULL DEFAULT ''",
"last_intent_key": "TEXT NOT NULL DEFAULT ''",
"last_oneliner_profile_version_no": "INTEGER NOT NULL DEFAULT 0",
"last_platform_profile_version_no": "INTEGER NOT NULL DEFAULT 0",
"last_execution_summary": "TEXT NOT NULL DEFAULT ''",
"last_source_screen": "TEXT NOT NULL DEFAULT ''",
},
}
for table, columns in table_columns.items():
if not self.table_exists(table):
continue
for column, definition in columns.items():
if self.column_exists(table, column):
continue
self.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
self.ensure_default_projects()
def ensure_default_projects(self) -> None:
if not self.table_exists("projects"):
return
accounts = self.fetch_all("SELECT id, username FROM accounts ORDER BY created_at ASC")
for account in accounts:
project = self.fetch_one(
"SELECT * FROM projects WHERE user_id = ? ORDER BY created_at ASC LIMIT 1",
(account["id"],),
)
if not project:
project_id = f"proj_{account['id']}"
now = utc_now()
self.execute(
"""
INSERT INTO projects (id, user_id, name, description, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
project_id,
account["id"],
f"{account['username']} 默认项目",
"系统自动创建的默认项目",
now,
now,
),
)
project = self.fetch_one("SELECT * FROM projects WHERE id = ?", (project_id,))
if not project:
continue
if self.column_exists("knowledge_bases", "project_id"):
self.execute(
"""
UPDATE knowledge_bases
SET project_id = ?
WHERE user_id = ? AND (project_id IS NULL OR project_id = '')
""",
(project["id"], account["id"]),
)
if self.column_exists("assistants", "project_id"):
self.execute(
"""
UPDATE assistants
SET project_id = ?
WHERE user_id = ? AND (project_id IS NULL OR project_id = '')
""",
(project["id"], account["id"]),
)
if self.column_exists("jobs", "project_id"):
self.execute(
"""
UPDATE jobs
SET project_id = ?
WHERE user_id = ? AND (project_id IS NULL OR project_id = '')
""",
(project["id"], account["id"]),
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,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"

View File

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

View File

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

View File

@@ -0,0 +1,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` 条目。

View File

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

View 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>

View 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>

View 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

View 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

View File

@@ -0,0 +1,4 @@
services:
collector:
environment:
N8N_BASE_URL: http://n8n:5678

View 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

View 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

View 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"

View 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

View 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

View 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

View 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"

View 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

View 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;
}
}

View 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;
}
}

View 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

View 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

View 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

View 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)

View 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

View 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

View 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
}

View 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"

View 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
}

View File

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

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

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

View 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`,再检查本机桥接链。

View 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再看详细日志

View File

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

View File

@@ -0,0 +1,348 @@
# StoryForge 本地 / 局域网联调说明
日期2026-03-18
## 1. 准备 `.env`
复制:
```bash
cd /Users/kris/code/StoryForge-gitea
cp .env.example .env
```
至少确认这些变量:
- `N8N_BASE_URL=http://127.0.0.1:5670`,用于你在宿主机单独运行 `collector-service`
- `COLLECTOR_N8N_BASE_URL=http://n8n:5678`,用于 Docker 里的 `collector`
- `ORCHESTRATOR_SHARED_SECRET=your_strong_shared_secret`
- `BOOTSTRAP_SUPERADMIN_USERNAME=storyforge-admin`
- `BOOTSTRAP_SUPERADMIN_PASSWORD=your_strong_admin_password`
- `STORYFORGE_INTERNAL_BASE_URL=http://collector:8081`,用于 Docker 内的 n8n 回调 `collector`
- `CUTVIDEO_BASE_URL=http://192.168.31.188:19186`,默认主链走 NAS SSH 隧道
- `CUTVIDEO_API_KEY=` 如果 Windows 服务启用了鉴权
- `HUOBAO_BASE_URL=http://127.0.0.1:5678`
- `WHISPER_BIN=` 指向你现有本地 ASR 可执行文件时填写
- `ASR_HTTP_BASE_URL=` 如果你已有常驻 ASR 服务,填写它的基地址
- `ASR_HTTP_TRANSCRIBE_PATH=/transcribe`
- `ASR_HTTP_FIELD_NAME=wav`
- `ASR_HTTP_TIMEOUT_SEC=120`
说明:
- 如果你单独重建 `collector`,要确保运行时仍带上 `CUTVIDEO_BASE_URL`,否则容器会退回空值
- `collector` 容器不要直接复用宿主机的 `N8N_BASE_URL=http://127.0.0.1:5670`,否则容器内会连回自己并导致 webhook 调度失败
- 当前更稳定的 NAS 转发地址是 `http://192.168.31.188:19186`
- Windows 直连地址 `http://192.168.31.18:7860` 仅用于主机内自检,不再建议作为 StoryForge 主链默认值
- 只要是 StoryForge 的 fnOS / NAS 联调与交付,优先把 `CUTVIDEO_BASE_URL` 视为 `19186`,把 `7860` 视为 Windows 本机自检口
- 当前已验证可用的本机 HTTP ASR 入口是 `http://host.docker.internal:8088/transcribe`
- 如果你用的是本机 `mac-whisper-service`,建议同时以 `WHISPER_TIMEOUT_MS=120000` 启动,否则长视频会直接 504
推荐先执行:
```bash
./scripts/deploy_fnos_cutvideo_tunnel.sh
./scripts/deploy_fnos_storyforge_lan_stack.sh
./scripts/smoke_fnos_storyforge_lan.sh
```
如果你只想先把底座打通,也可以先跑前两步,再单独 smoke。
它们分别负责:
- 在 fnOS 上生成并持久化 Windows SSH 隧道密钥
- 把 fnOS 公钥写入 Windows OpenSSH 管理员授权文件
- 在 fnOS 上常驻 `19186 -> Windows 127.0.0.1:7860``19181 -> Windows 127.0.0.1:8081`,并写入 `@reboot` 自启动
- 把 StoryForge 的 NAS 侧服务统一切到 `http://192.168.31.188:19186` 的默认主链
- 通过一键 smoke 校验 `cutvideo``collector` 和整条 LAN 交付链路
`cutvideo` 维护补充2026-03-27
- 当前 Windows 主机 SSH 别名是 `shuziren-win`,对应 `192.168.31.18`
- 如果 `http://192.168.31.18:7860/api/bootstrap` 超时,但主机 `22 / 3389 / 5985` 仍可达,优先判断为 `cutvideo` 服务未启动,不是局域网地址失效
- 本次已确认的真实故障是 `D:\ai-code\cutvideo\.venv` 损坏,仍指向已不存在的 `Python311`
- 修复后 `cutvideo` 已改由 Windows 任务计划程序 `\Codex\cutvideo-web` 托管,避免服务随 SSH 会话结束一起退出
- 详细恢复步骤见 [`WINDOWS_CUTVIDEO_OPERATIONS_2026-03-27.md`](/Users/kris/code/StoryForge-gitea/docs/WINDOWS_CUTVIDEO_OPERATIONS_2026-03-27.md)
## 2. 启动基础服务
```bash
cd /Users/kris/code/StoryForge-gitea
docker compose up -d --build
```
检查:
- `collector-service``http://127.0.0.1:8081/healthz`
- `n8n``http://127.0.0.1:5670`
- `cli-proxy-api``http://127.0.0.1:8317`
- 本机 `huobao-drama``http://127.0.0.1:5678/health`
## 3. 导入 n8n workflows
`n8n/workflows/` 导入:
- `storyforge-analysis.json`
- `storyforge-real-cut.json`
- `storyforge-ai-video.json`
- `storyforge-content-source-sync.json`
导入后:
- 确认 n8n 运行环境里有 `STORYFORGE_INTERNAL_BASE_URL`
- 确认 n8n 运行环境里有 `STORYFORGE_ORCHESTRATOR_SECRET`
- 导入后的 HTTP Request 节点应从环境变量取值,不需要再逐个手改 secret
## 4. 登录与审批
首次启动前请先在 `.env` 或运行环境里设置 bootstrap 管理员:
- 用户名:`BOOTSTRAP_SUPERADMIN_USERNAME`
- 密码:`BOOTSTRAP_SUPERADMIN_PASSWORD`
首次启动后,用这组账号登录;新用户注册后,仍然需要超级管理员审批。
## 5. 内容分析链路验证
### 文本
调用 `POST /v2/explore/text`
预期:
- 任务创建成功
- `n8n` webhook 被触发
- 任务最终进入 `completed`
- 知识库文档里出现 transcript / style_summary / analysis / storyboards
已验证样例:
- `job_203bc8e9b20f4b1cbbc6cf7da79e46f4`
### 视频链接
调用 `POST /v2/explore/video-link`
前提:
- `yt-dlp` 可用
- `ffmpeg` 可用
- ASR 可调用
已验证样例:
- `job_bb405e2e878849e38c4bb31f7781e1e3` (`artifacts.asr_backend=http`)
### 上传视频
调用 `POST /v2/explore/upload-video`
预期与视频链接类似,但素材来源为本地上传
## 6. 内容源账号同步验证
调用 `POST /v2/pipelines/content-source-sync`
推荐最小请求体:
```json
{
"source_url": "https://space.bilibili.com/546195/video",
"platform": "bilibili",
"title": "Bilibili Creator Sync Smoke",
"max_items": 2,
"skip_existing": true,
"auto_trigger_analysis": true
}
```
预期:
- 创建一个 `content_source_sync` 父任务
- `n8n` 触发 `content_source_sync_pipeline`
- 父任务写回 `discovered_videos / child_job_ids / queued_job_ids`
- 子任务以 `parent_job_id` 挂到父任务下,并自动进入分析主线
已验证样例:
- 父任务:`job_b02109cf9e8244fbb5b86f184a7c7574`
- 子任务:`job_7f169db61af441f8a7f186d03db2d91c`
- 子任务:`job_28c47774028441378a3974860c375ab7`
## 6.1 `douyin` 账号工作台验证
基础接口:
- `POST /v2/douyin/accounts/sync`
- `POST /v2/douyin/accounts/{account_id}/analysis`
说明:
- `profile_url` 现在支持直接传分享文案,后端会自动提取里面的 URL
- 如果 public 页面命中抖音反爬挑战,接口会返回 `public_profile_anti_bot_challenge`
- 遇到挑战页时,继续可用的路径是 `manual_profile_payload``manual_work_payloads``manual_creator_pages`
已验证样例:
- public 页面 smoke返回 `public_profile_anti_bot_challenge`
- 手工导入账号:`dyacct_c2b62842b228406cb48f05fac16fdfdf`
- 手工账号分析报告:`dyreport_10d6b8d2d52a404192f54a3a05d44546`
- 相似账号搜索:`dysearch_c247b75db0df49429a1d127407fe4486`
- 对标关系:`dyrel_c8df266341e74237b99c880eb4b572d8`
浏览器辅助采集:
```bash
cd /Users/kris/code/StoryForge-gitea/scripts/douyin-browser-capture
npm install
npx playwright install chromium
npm run control-panel
```
浏览器打开:
```text
http://127.0.0.1:3618
```
控制台步骤:
1. 填写抖音主页链接和 StoryForge 账号
2. 如需查看采集结果,不用离开这个页面;下半部分 `Douyin Workbench` 会展示账号列表、Agent 结论、快照详情和对标结果
3. `作品工作台` 支持高分榜、最新榜和全部作品切换,并支持多种排序方式
4. 点击“自动分析高分作品”后,每条高分作品下会补齐商业判断、复刻建议、运营动作和风险提醒
2. 点击 `开始采集`
3. 在弹出的 Chromium 里登录或通过挑战页
4. 回到控制台点击 `已完成登录,继续采集`
5. 等待 `summary.json` 和可选的 `storyforge-sync-response.json`
命令行方式仍然保留:
```bash
cd /Users/kris/code/StoryForge-gitea/scripts/douyin-browser-capture
npm run capture -- \
--profile-url https://www.douyin.com/user/your_account \
--storyforge-username storyforge-admin \
--storyforge-password 'your_admin_password'
```
说明:
- 脚本会打开真实 Chromium 会话,默认复用 `~/.storyforge/douyin-playwright` 登录态
- 如果出现扫码登录、滑块或挑战页,先在浏览器里人工完成,再回终端继续
- 脚本会保存 `profile-bundle.json``storyforge-sync-request.json` 和同步响应
- 当前已完成 headless 最小 smoke输出目录
- `/tmp/storyforge-douyin-capture-smoke/2026-03-20T06-49-37.705Z-storyforge_test_001`
- 当前已完成本地控制台 smoke输出目录
- `/Users/kris/code/StoryForge-gitea/output/playwright/douyin/control-panel/run-mmyzplxp-cw0o7q/2026-03-20T14-24-13.174Z-storyforge_test_001`
- `/Users/kris/code/StoryForge-gitea/output/playwright/douyin/control-panel/run-mmyzshsp-c6vdhi/2026-03-20T14-26-27.792Z-storyforge_test_001`
- 控制台模式已经修复“提前点击继续导致 ready 信号丢失”的竞态,早于等待点按钮也不会卡死
## 7. `cutvideo` 实拍剪辑链路验证
调用 `POST /v2/pipelines/real-cut`
当前 MVP 前提:
- 方式 A直接传 `input_dir`,它必须是 Windows `cutvideo` 机器可访问的目录
- 方式 B`source_job_id``collector-service` 会把 `upload_video` 或已完成的 `video_link` 源素材自动上传到 Windows `cutvideo`,再继续发起任务
- 如果走方式 B大文件上传超时由 `CUTVIDEO_UPLOAD_TIMEOUT_SEC` 控制
预期:
- 任务创建成功
- 如果用了 `source_job_id`,任务 `artifacts.cutvideo_upload` 会记录 Windows staging 结果
- `n8n` 调用 `collector-service` 内部 real-cut step
- 后端记录 `provider_task_id`
- 最终任务写回 `cutvideo_run`
已验证样例:
- `job_5ebd829c3f2144bca5c941183e75bdcd`
- `job_01a6f283cbda42e4ae692b268b811a50` (`source_job_id` 自动 staging本机 `cutvideo` 联调)
- Windows 返回 `task_id=8d8f4a0cd5d9`
- 运行目录 `20260318-093520-Windows cutvideo 联调样例`
补充说明2026-03-27
- `GET /api/bootstrap` 恢复为 `200``GET /api/uploads` 返回 `405 Method Not Allowed`
- 上面的 `405` 是正常现象,说明上传接口存在且只接受 `POST`
- `StoryForge collector``/v2/integrations/health` 已重新识别到 `cutvideo.reachable=true``supports_uploads=true`
- fnOS 局域网调试链现在默认走 `http://192.168.31.188:19186`Windows 机器直接开放 `7860` 仅保留为自检入口
- 如果 UI 里 `自动剪辑` 再次掉线,先按 [`WINDOWS_CUTVIDEO_OPERATIONS_2026-03-27.md`](/Users/kris/code/StoryForge-gitea/docs/WINDOWS_CUTVIDEO_OPERATIONS_2026-03-27.md) 检查 Windows 任务计划程序和 `.venv`
## 8. `huobao-drama` AI 视频链路验证
调用 `POST /v2/pipelines/ai-video`
推荐方式:
- 先完成一个分析任务
- 再把该分析任务的 `source_job_id` 传给 AI 视频任务
预期:
- 创建 drama
- 每个分镜生成首帧、尾帧
- 每个分镜生成视频
- 最终 `job.result.rendered_scenes` 有完整结果
已验证样例:
- `job_01828c40377747cf914b51be360cc333`
- `provider_task_id=10`
- `video.task_id=qvideo-1380265978-1773799215825814468`
- 最终视频已回写到 `job.result.rendered_scenes[0].video.video_url`
补充说明2026-03-20
- `huobao-drama-upstream` 已在隔离目录用复制的旧配置和数据库起过实例,`/health` 正常
- fresh 图片/视频生成请求已能进入 provider 调用,但当前复制出的图片/视频凭证返回 `403 invalid user`
- 同样的 fresh 图片请求已在旧改版隔离实例 `http://127.0.0.1:5682` 上重放,结论一致,所以当前不是 upstream 回归问题
- `huobao-drama-upstream` 现在支持 `HUOBAO_TEXT_* / HUOBAO_IMAGE_* / HUOBAO_VIDEO_*` 运行时覆盖数据库里的 AI 配置
- `huobao-drama-upstream` 已新增 `/Users/kris/code/huobao-drama-upstream/scripts/run_storyforge_smoke.sh`,可自动复制旧目录配置和数据,在默认 `5681` 端口起隔离实例并校验 `/health`
- 如果你要重新验证 upstream fresh 生成,优先给 huobao 进程补这些环境变量,再复跑即可
推荐覆盖字段:
- `HUOBAO_TEXT_PROVIDER / BASE_URL / API_KEY / MODELS`
- `HUOBAO_IMAGE_PROVIDER / BASE_URL / API_KEY / MODELS`
- `HUOBAO_VIDEO_PROVIDER / BASE_URL / API_KEY / MODELS`
- 如需强制指定端点,还可补 `ENDPOINT / QUERY_ENDPOINT`
## 9. 当前已知卡点
- 抖音 public 页面直抓会命中反爬挑战;生产接入仍需要 cookie 或人工页面采集协助
- 小红书账号级内容源还未做真实平台验证
- `huobao-drama-upstream` 代码已迁移完成,但 fresh 生成仍受外部图片/视频凭证 `403 invalid user` 阻塞
## 10. 旧运行链残留清理
- 旧运行链残留容器已在 2026-03-20 实际清理完成:
- `storyforge-fastgpt-plugin`
- `storyforge-sandbox`
- `storyforge-pg`
- `storyforge-minio`
- `storyforge-redis`
- `storyforge-mongo`
- 清理脚本已纳入仓库:
- `/Users/kris/code/StoryForge-gitea/deploy/cleanup_legacy_runtime.sh`
- 脚本会在清理前后校验:
- `http://127.0.0.1:8081/healthz`
- `http://127.0.0.1:5670/healthz`
## 11. Android 说明
`android-app/` 已确认属于独立 `AI Glasses` 工程的叠加目录,现已从当前 StoryForge 主仓库拆出。
当前联调范围只包含:
- `collector-service`
- `n8n`
- `web/storyforge-web-v4`
- `scripts/douyin-browser-capture`
如果后续需要维护 Android / OTA 链路,请转到独立仓库:
- Gitea`https://git.hyzq.site/krisolo/ai-glasses`
- 本机工作区:`/Users/kris/code/AI-glasses`

View File

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

View 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.

View 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` 的完整链路仍依赖外部服务可用性。
- 抖音等真实平台采集仍可能受到平台风控影响,需要真实联调确认。

View File

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

View 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` 作为统一回归入口。

View 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 项目。

View 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` 在线
- 前端工作台应恢复显示 `自动剪辑` 在线

View File

@@ -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 smokeTask 2 覆盖
- 前后端状态收口Task 3 覆盖
- 文档与运维统一Task 4 覆盖
### Placeholder scan
- 没有保留 TBD / TODO / “后续补”
- 每个任务都给了明确文件和验证命令
### Type consistency
- 统一使用 `deploy_fnos_storyforge_lan_stack.sh`
- 统一使用 `smoke_fnos_storyforge_lan.sh`
- `healthz` 新字段统一命名为 `lanRouting`

View 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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