返回 AiToEarn
draftBoxConfigStore.ts
根目录 / project / aitoearn-web / src / store / draft-box / draftBoxConfigStore.ts
1 import type { DraftContentType, VideoModelType } from '@/api/ai/ai.types'
2 import type { MaterialGenerationParams } from '@/api/materials/material.types'
3 import type { PlatType } from '@/app/config/platConfig'
4 import isEqual from 'lodash/isEqual'
5 import { stripCaptionPromptSystemRequirement, stripDraftPromptLimits } from '@/components/draft-box/utils/promptLimits'
6 import { generateUUID } from '@/utils/common'
7 import { getOssUrl } from '@/utils/oss'
8 import { createPersistStore } from '@/utils/storage/createPersistStore'
9
10 /** 可序列化的媒体信息(持久化用) */
11 export interface IPersistedMedia {
12 id: string
13 url: string
14 type: 'image' | 'video' | 'audio'
15 name?: string
16 /** 媒体时长,用于恢复后的时长校验 */
17 duration?: number
18 }
19
20 export type ModelSelectionMode = 'single' | 'multiple'
21
22 export interface VideoModelParams {
23 resolution?: string
24 duration?: number
25 aspectRatio?: string
26 }
27
28 /** 单个草稿箱的生成配置 */
29 export interface DraftBoxConfig {
30 aspectRatio: string
31 duration: number
32 resolution: string
33 quantity: number
34 modelType: VideoModelType
35 selectedVideoModels: VideoModelType[]
36 videoModelSelectionMode: ModelSelectionMode
37 videoModelResolutions: Record<string, string>
38 videoModelParams: Record<string, VideoModelParams>
39 contentType: DraftContentType
40 imageModel: string
41 selectedImageModels: string[]
42 imageModelSelectionMode: ModelSelectionMode
43 imageCount: number
44 imageSize: string
45 selectedPlatforms: PlatType[]
46 /** 用户上传的媒体持久化 */
47 persistedMedias: IPersistedMedia[]
48 /** AI 生成描述 */
49 promptValue: string
50 /** 是否为草稿模式(true=生成完整草稿,false=仅生成视频/图片) */
51 isDraftMode: boolean
52 /** 文案生成提示词,用于标题、描述和话题 */
53 captionPrompt: string
54 /** 文案要求区域是否展开 */
55 captionPromptOpen: boolean
56 /** 系统内置文案要求,默认根据平台限制动态生成,支持用户覆盖 */
57 captionSystemPrompt: string
58 /** 最近一次系统内置文案要求默认值,用于判断是否跟随动态默认值更新 */
59 captionSystemPromptDefault: string
60 }
61
62 /** 默认配置(与原 systemStore 一致) */
63 const DEFAULT_CONFIG: DraftBoxConfig = {
64 aspectRatio: '9:16',
65 duration: 8,
66 resolution: '',
67 quantity: 1,
68 modelType: '' as VideoModelType,
69 selectedVideoModels: [],
70 videoModelSelectionMode: 'single',
71 videoModelResolutions: {},
72 videoModelParams: {},
73 contentType: 'video',
74 imageModel: 'nb2',
75 selectedImageModels: [],
76 imageModelSelectionMode: 'single',
77 imageCount: 3,
78 imageSize: '1K',
79 selectedPlatforms: [],
80 persistedMedias: [],
81 promptValue: '',
82 isDraftMode: true,
83 captionPrompt: '',
84 captionPromptOpen: true,
85 captionSystemPrompt: '',
86 captionSystemPromptDefault: '',
87 }
88
89 interface IDraftBoxConfigStore {
90 configs: Record<string, DraftBoxConfig>
91 }
92
93 const state: IDraftBoxConfigStore = {
94 configs: {},
95 }
96
97 const pendingConfigUpdates = new Map<string, Partial<DraftBoxConfig>>()
98 let pendingFlushScheduled = false
99
100 function normalizePersistedMedias(params: MaterialGenerationParams): IPersistedMedia[] {
101 const dedupedMedias = new Map<string, IPersistedMedia>()
102
103 params.imageUrls?.forEach((url) => {
104 const normalizedUrl = getOssUrl(url)
105 dedupedMedias.set(`image:${normalizedUrl}`, {
106 id: generateUUID(),
107 url: normalizedUrl,
108 type: 'image',
109 })
110 })
111
112 params.videoUrls?.forEach((url) => {
113 const normalizedUrl = getOssUrl(url)
114 dedupedMedias.set(`video:${normalizedUrl}`, {
115 id: generateUUID(),
116 url: normalizedUrl,
117 type: 'video',
118 })
119 })
120
121 params.audioUrls?.forEach((url) => {
122 const normalizedUrl = getOssUrl(url)
123 dedupedMedias.set(`audio:${normalizedUrl}`, {
124 id: generateUUID(),
125 url: normalizedUrl,
126 type: 'audio',
127 })
128 })
129
130 return [...dedupedMedias.values()]
131 }
132
133 function inferContentType(params: MaterialGenerationParams, current: DraftBoxConfig): DraftContentType {
134 if (params.imageModel || params.imageCount || params.imageSize) {
135 return 'image_text'
136 }
137
138 if (params.model || params.resolution || params.videoUrls?.length || params.audioUrls?.length) {
139 return 'video'
140 }
141
142 return current.contentType
143 }
144
145 function queueConfigUpdate(
146 get: () => IDraftBoxConfigStore,
147 set: (
148 partial:
149 | IDraftBoxConfigStore
150 | Partial<IDraftBoxConfigStore>
151 | ((state: IDraftBoxConfigStore) => IDraftBoxConfigStore | Partial<IDraftBoxConfigStore>),
152 ) => void,
153 groupId: string,
154 partial: Partial<DraftBoxConfig>,
155 ) {
156 const configs = get().configs
157 const current = configs[groupId] ?? { ...DEFAULT_CONFIG }
158 const pendingPartial = pendingConfigUpdates.get(groupId) ?? {}
159 const mergedCurrent = { ...current, ...pendingPartial }
160 const hasChanges = Object.entries(partial).some(([key, value]) => {
161 return !isEqual(mergedCurrent[key as keyof DraftBoxConfig], value)
162 })
163
164 if (!hasChanges) {
165 return
166 }
167
168 pendingConfigUpdates.set(groupId, { ...pendingPartial, ...partial })
169
170 if (pendingFlushScheduled) {
171 return
172 }
173
174 pendingFlushScheduled = true
175 queueMicrotask(() => {
176 pendingFlushScheduled = false
177
178 const latestConfigs = get().configs
179 let hasAnyConfigChanged = false
180 const nextConfigs = { ...latestConfigs }
181
182 pendingConfigUpdates.forEach((queuedPartial, queuedGroupId) => {
183 const latestCurrent = latestConfigs[queuedGroupId] ?? { ...DEFAULT_CONFIG }
184 const queuedHasChanges = Object.entries(queuedPartial).some(([key, value]) => {
185 return !isEqual(latestCurrent[key as keyof DraftBoxConfig], value)
186 })
187
188 if (!queuedHasChanges) {
189 return
190 }
191 nextConfigs[queuedGroupId] = { ...latestCurrent, ...queuedPartial }
192 hasAnyConfigChanged = true
193 })
194
195 pendingConfigUpdates.clear()
196
197 if (!hasAnyConfigChanged) {
198 return
199 }
200
201 set({
202 configs: nextConfigs,
203 })
204 })
205 }
206
207 export const useDraftBoxConfigStore = createPersistStore(
208 { ...state },
209 (set, get) => ({
210 /** 获取指定 groupId 的配置,不存在时返回默认值 */
211 getConfig(groupId: string): DraftBoxConfig {
212 return get().configs[groupId] ?? { ...DEFAULT_CONFIG }
213 },
214
215 /** 合并更新指定 groupId 的配置 */
216 updateConfig(groupId: string, partial: Partial<DraftBoxConfig>) {
217 queueConfigUpdate(get, set, groupId, partial)
218 },
219
220 /** 将历史生成参数回填到当前草稿箱输入配置 */
221 applyGenerationParams(groupId: string, params: MaterialGenerationParams) {
222 const current = get().configs[groupId] ?? { ...DEFAULT_CONFIG }
223 const nextContentType = inferContentType(params, current)
224 const nextDraftMode = params.draftType
225 ? params.draftType === 'draft'
226 : current.isDraftMode
227 const nextCaptionPrompt = stripCaptionPromptSystemRequirement(params.captionPrompt ?? '')
228
229 queueConfigUpdate(get, set, groupId, {
230 promptValue: params.prompt ? stripDraftPromptLimits(params.prompt) : '',
231 aspectRatio: params.aspectRatio ?? current.aspectRatio,
232 duration: params.duration ?? current.duration,
233 resolution: nextContentType === 'video'
234 ? (params.resolution ?? current.resolution)
235 : current.resolution,
236 contentType: nextContentType,
237 modelType: nextContentType === 'video'
238 ? (params.model ?? current.modelType)
239 : current.modelType,
240 selectedVideoModels: nextContentType === 'video' && params.model
241 ? [params.model]
242 : current.selectedVideoModels,
243 videoModelResolutions: nextContentType === 'video' && params.model && params.resolution
244 ? { ...current.videoModelResolutions, [params.model]: params.resolution }
245 : current.videoModelResolutions,
246 videoModelParams: nextContentType === 'video' && params.model
247 ? {
248 ...current.videoModelParams,
249 [params.model]: {
250 resolution: params.resolution ?? current.videoModelParams[params.model]?.resolution,
251 duration: params.duration ?? current.videoModelParams[params.model]?.duration,
252 aspectRatio: params.aspectRatio ?? current.videoModelParams[params.model]?.aspectRatio,
253 },
254 }
255 : current.videoModelParams,
256 imageModel: nextContentType === 'image_text'
257 ? (params.imageModel ?? current.imageModel)
258 : current.imageModel,
259 selectedImageModels: nextContentType === 'image_text' && params.imageModel
260 ? [params.imageModel]
261 : current.selectedImageModels,
262 imageCount: nextContentType === 'image_text'
263 ? (params.imageCount ?? current.imageCount)
264 : current.imageCount,
265 imageSize: nextContentType === 'image_text'
266 ? (params.imageSize ?? current.imageSize)
267 : current.imageSize,
268 selectedPlatforms: params.platforms ? [...params.platforms] : [],
269 persistedMedias: normalizePersistedMedias(params),
270 isDraftMode: nextDraftMode,
271 captionPrompt: nextCaptionPrompt,
272 captionPromptOpen: nextDraftMode ? (Boolean(nextCaptionPrompt) || current.captionPromptOpen) : false,
273 })
274 },
275
276 /** 追加持久化媒体,按 type + url 去重,保留原有顺序 */
277 appendPersistedMedias(groupId: string, medias: IPersistedMedia[]) {
278 const current = get().configs[groupId] ?? { ...DEFAULT_CONFIG }
279 const existingMedias = current.persistedMedias ?? []
280 if (medias.length === 0) {
281 return { added: 0, total: existingMedias.length }
282 }
283
284 const mergedMedias = [...existingMedias]
285 const existingKeys = new Set(existingMedias.map(media => `${media.type}:${media.url}`))
286 let added = 0
287
288 medias.forEach((media) => {
289 const mediaKey = `${media.type}:${media.url}`
290 if (existingKeys.has(mediaKey)) {
291 return
292 }
293
294 mergedMedias.push(media)
295 existingKeys.add(mediaKey)
296 added += 1
297 })
298
299 if (added === 0) {
300 return { added: 0, total: mergedMedias.length }
301 }
302
303 set({
304 configs: {
305 ...get().configs,
306 [groupId]: {
307 ...current,
308 persistedMedias: mergedMedias,
309 },
310 },
311 })
312
313 return { added, total: mergedMedias.length }
314 },
315
316 /** 重置指定 groupId 的配置为默认值 */
317 resetConfig(groupId: string) {
318 set({
319 configs: {
320 ...get().configs,
321 [groupId]: { ...DEFAULT_CONFIG },
322 },
323 })
324 },
325 }),
326 {
327 name: 'DraftBoxConfig',
328 version: 13,
329 migrate(persistedState: any, version: number) {
330 if (version < 2) {
331 // v1 → v2: 给已有配置补 persistedMedias 字段
332 const configs = persistedState.configs ?? {}
333 for (const key of Object.keys(configs)) {
334 if (!configs[key].persistedMedias) {
335 configs[key].persistedMedias = []
336 }
337 }
338 persistedState.configs = configs
339 }
340 if (version < 4) {
341 // v3 → v4: 给已有配置补 promptValue 字段
342 const configs = persistedState.configs ?? {}
343 for (const key of Object.keys(configs)) {
344 if (configs[key].promptValue === undefined) {
345 configs[key].promptValue = ''
346 }
347 }
348 persistedState.configs = configs
349 }
350 if (version < 6) {
351 // v5 → v6: 给已有配置补 isDraftMode 字段
352 const configs = persistedState.configs ?? {}
353 for (const key of Object.keys(configs)) {
354 if (configs[key].isDraftMode === undefined) {
355 configs[key].isDraftMode = true
356 }
357 }
358 persistedState.configs = configs
359 }
360 if (version < 8) {
361 // v7 → v8: 给已有配置补 resolution 字段
362 const configs = persistedState.configs ?? {}
363 for (const key of Object.keys(configs)) {
364 if (configs[key].resolution === undefined) {
365 configs[key].resolution = ''
366 }
367 }
368 persistedState.configs = configs
369 }
370 if (version < 9) {
371 // v8 → v9: 给已有配置补 captionPrompt 字段
372 const configs = persistedState.configs ?? {}
373 for (const key of Object.keys(configs)) {
374 if (configs[key].captionPrompt === undefined) {
375 configs[key].captionPrompt = ''
376 }
377 }
378 persistedState.configs = configs
379 }
380 if (version < 10) {
381 // v9 → v10: 从旧单模型字段迁移到多模型选择
382 const configs = persistedState.configs ?? {}
383 for (const key of Object.keys(configs)) {
384 if (!Array.isArray(configs[key].selectedVideoModels)) {
385 configs[key].selectedVideoModels = configs[key].modelType ? [configs[key].modelType] : []
386 }
387 if (!Array.isArray(configs[key].selectedImageModels)) {
388 configs[key].selectedImageModels = configs[key].imageModel ? [configs[key].imageModel] : []
389 }
390 }
391 persistedState.configs = configs
392 }
393 if (version < 11) {
394 // v10 → v11: 给已有配置补文案要求展开和系统内置文案要求字段
395 const configs = persistedState.configs ?? {}
396 for (const key of Object.keys(configs)) {
397 if (configs[key].captionPromptOpen === undefined) {
398 configs[key].captionPromptOpen = true
399 }
400 if (configs[key].captionSystemPrompt === undefined) {
401 configs[key].captionSystemPrompt = ''
402 }
403 if (configs[key].captionSystemPromptDefault === undefined) {
404 configs[key].captionSystemPromptDefault = ''
405 }
406 }
407 persistedState.configs = configs
408 }
409 if (version < 12) {
410 // v11 → v12: 按当前配置白名单清理废弃字段
411 const currentConfigKeys = new Set(Object.keys(DEFAULT_CONFIG))
412 const configs = persistedState.configs ?? {}
413 for (const key of Object.keys(configs)) {
414 configs[key] = {
415 ...DEFAULT_CONFIG,
416 ...Object.fromEntries(
417 Object.entries(configs[key]).filter(([configKey]) => currentConfigKeys.has(configKey)),
418 ),
419 }
420 }
421 persistedState.configs = configs
422 }
423 if (version < 13) {
424 // v12 → v13: 给视频多选补按模型隔离的分辨率、时长和比例参数
425 const configs = persistedState.configs ?? {}
426 for (const key of Object.keys(configs)) {
427 if (configs[key].videoModelSelectionMode === undefined) {
428 configs[key].videoModelSelectionMode = 'single'
429 }
430 if (configs[key].imageModelSelectionMode === undefined) {
431 configs[key].imageModelSelectionMode = 'single'
432 }
433 if (configs[key].videoModelResolutions === undefined) {
434 configs[key].videoModelResolutions = configs[key].modelType && configs[key].resolution
435 ? { [configs[key].modelType]: configs[key].resolution }
436 : {}
437 }
438 const modelType = configs[key].modelType
439 const existingParams = configs[key].videoModelParams ?? {}
440 configs[key].videoModelParams = modelType && existingParams[modelType] === undefined
441 ? {
442 ...existingParams,
443 [modelType]: {
444 resolution: configs[key].videoModelResolutions?.[modelType] ?? configs[key].resolution,
445 duration: configs[key].duration,
446 aspectRatio: configs[key].aspectRatio,
447 },
448 }
449 : existingParams
450 }
451 persistedState.configs = configs
452 }
453 return persistedState
454 },
455 },
456 'indexedDB',
457 )
458
458 lines TYPESCRIPT