diff --git a/ui/src/features/agent/agentActions.ts b/ui/src/features/agent/agentActions.ts index afcee475..571f47d6 100644 --- a/ui/src/features/agent/agentActions.ts +++ b/ui/src/features/agent/agentActions.ts @@ -1697,6 +1697,23 @@ export function isExplicitVideoGenerationRequest(request: string): boolean { return EXPLICIT_VIDEO_REQUESTS.some(pattern => pattern.test(text)) } +export function isResumePreparedStudioVideoRequest(request: string): boolean { + const text = request.trim() + if (!text || NEGATED_VIDEO_REQUEST.test(text)) return false + const match = text.match( + /\b(?:genera|generad|lanza|lanzad|encola|encolad|env[ií]a|enviad|start|queue|launch)\b[^.!?\n]{0,24}\b(?:el|la|este|esta|the)\s+(?:video|v[ií]deo|clip)\b(.*)$/i, + ) + if (!match) return false + const leftover = match[1] + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[¿?¡!.,:;]/g, ' ') + .replace(/\b(?:por favor|please|ya|ahora|now)\b/g, ' ') + .replace(/\s+/g, '') + return leftover.length === 0 +} + const EXPLICIT_IMAGE_REQUESTS = [ /\b(?:hazme|hacedme|generame|genérame|creame|créame)\b[^.!?\n]*\b(?:imagen|im[aá]genes|foto|fotos|retrato|ilustraci[oó]n)\b/i, /\b(?:haz|haced|genera|generad|crea|cread|lanza|lanzad|encola|encolad)\b[^.!?\n]*\b(?:imagen|im[aá]genes|foto|fotos|retrato|ilustraci[oó]n)\b/i, @@ -1843,6 +1860,33 @@ export async function reconcileAgentTurnWithRequest( history: ExampleConversation[] = [], ): Promise { const { maybeExampleTurn } = await import('./agentExamples') + if (isResumePreparedStudioVideoRequest(request)) { + const existing = turn.actions.find( + (action): action is AgentPrepareVideoAction => action.type === 'prepare_video', + ) + const state = useStore.getState() + const studioPrompt = state.generationMode === 'video' + ? String(state.params.prompt || '').trim() + : String(state.savedPromptPerMode?.video || '').trim() + const prompt = existing?.prompt.trim() || studioPrompt + if (!prompt) { + return { + reply: 'Studio no tiene un prompt preparado. Dime la escena y la encolo.', + actions: [{ type: 'open_tab', tab: 'studio' }], + } + } + const prepare: AgentPrepareVideoAction = existing || { + type: 'prepare_video', + prompt, + durationSeconds: state.generationMode === 'video' ? state.durationSeconds : undefined, + resolutionPreset: state.generationMode === 'video' ? state.resolutionPreset : undefined, + aspectRatio: state.generationMode === 'video' ? state.aspectRatio : undefined, + } + return { + reply: 'Lanzo a la cola el vídeo ya preparado en Studio. 🪄', + actions: [prepare, { type: 'start_generation', confirm: true }], + } + } const exampleTurn = maybeExampleTurn(request, turn, history) if (exampleTurn) return exampleTurn const rhythmic3dWorkflow = turn.actions.find( diff --git a/ui/tests/agentActions.test.mjs b/ui/tests/agentActions.test.mjs index 8d6934d1..273e6c3b 100644 --- a/ui/tests/agentActions.test.mjs +++ b/ui/tests/agentActions.test.mjs @@ -1447,3 +1447,88 @@ test('a video example fills a real prompt instead of asking, and a topical video assert.deepEqual(topical.actions.map(action => action.type), ['prepare_video', 'start_generation']) assert.ok(topical.actions[0].prompt.includes('mapache')) }) + +test('genera el video reuses a prepared Studio prompt instead of asking for a topic', async () => { + const { useStore } = await import('../src/stores/useStore.ts') + const { reconcileAgentTurnWithRequest } = await import('../src/features/agent/agentActions.ts') + const original = { + generationMode: useStore.getState().generationMode, + params: useStore.getState().params, + durationSeconds: useStore.getState().durationSeconds, + savedPromptPerMode: useStore.getState().savedPromptPerMode, + } + useStore.setState({ + generationMode: 'video', + params: { ...useStore.getState().params, prompt: 'Sheldon Cooper cuenta un chiste en su salón, bata verde, Bazinga.' }, + durationSeconds: 5.2, + }) + const resumed = await reconcileAgentTurnWithRequest('genera el video', { reply: '¿De qué?', actions: [] }) + assert.deepEqual(resumed.actions.map(action => action.type), ['prepare_video', 'start_generation']) + assert.equal(resumed.actions[0].prompt.includes('Sheldon'), true) + assert.equal(resumed.actions[0].modelType, undefined) + assert.equal(resumed.actions[1].confirm, true) + + useStore.setState({ + generationMode: 'video', + params: { ...useStore.getState().params, prompt: '' }, + savedPromptPerMode: { ...useStore.getState().savedPromptPerMode, video: '' }, + }) + const asked = await reconcileAgentTurnWithRequest('genera el video', { reply: '¿De qué?', actions: [] }) + assert.equal(asked.actions[0].type, 'open_tab') + assert.equal(asked.actions.some(action => action.type === 'start_generation'), false) + useStore.setState(original) +}) + +test('genera el video de un mapache uses the new topic instead of the prepared form', async () => { + const { useStore } = await import('../src/stores/useStore.ts') + const { reconcileAgentTurnWithRequest } = await import('../src/features/agent/agentActions.ts') + const original = { + generationMode: useStore.getState().generationMode, + params: useStore.getState().params, + savedPromptPerMode: useStore.getState().savedPromptPerMode, + } + useStore.setState({ + generationMode: 'video', + params: { ...useStore.getState().params, prompt: 'Sheldon Cooper cuenta un chiste en su salón, bata verde, Bazinga.' }, + }) + const topical = await reconcileAgentTurnWithRequest('genera el video de un mapache', { reply: '¿De qué?', actions: [] }) + assert.deepEqual(topical.actions.map(action => action.type), ['prepare_video', 'start_generation']) + assert.ok(topical.actions[0].prompt.includes('mapache')) + assert.equal(topical.actions[0].prompt.includes('Sheldon'), false) + + const example = await reconcileAgentTurnWithRequest('genera el video de ejemplo', { reply: '¿De qué?', actions: [] }) + assert.deepEqual(example.actions.map(action => action.type), ['prepare_video', 'start_generation']) + assert.ok(example.actions[0].prompt.length > 40) + assert.equal(example.actions[0].prompt.includes('genera el video'), false) + assert.equal(example.actions[0].prompt.includes('Sheldon'), false) + useStore.setState(original) +}) + +test('genera el video does not copy an incompatible I2V, audio or 3D model as T2V', async () => { + const { useStore } = await import('../src/stores/useStore.ts') + const { reconcileAgentTurnWithRequest } = await import('../src/features/agent/agentActions.ts') + const original = { + generationMode: useStore.getState().generationMode, + params: useStore.getState().params, + savedPromptPerMode: useStore.getState().savedPromptPerMode, + } + const prepared = 'Sheldon Cooper cuenta un chiste en su salón, bata verde, Bazinga.' + + for (const [generationMode, modelType] of [ + ['image', 'image-only-model'], + ['audio', 'ace_step_music'], + ['model3d', 'hunyuan3d'], + ['video', 'i2v-only-model'], + ]) { + useStore.setState({ + generationMode, + params: { ...useStore.getState().params, model_type: modelType, prompt: generationMode === 'video' ? prepared : 'otro modo' }, + savedPromptPerMode: { ...useStore.getState().savedPromptPerMode, video: prepared }, + }) + const turn = await reconcileAgentTurnWithRequest('genera el video', { reply: '¿De qué?', actions: [] }) + assert.deepEqual(turn.actions.map(action => action.type), ['prepare_video', 'start_generation']) + assert.equal(turn.actions[0].modelType, undefined, `${generationMode}/${modelType} must not be copied as T2V`) + assert.equal(turn.actions[0].prompt.includes('Sheldon'), true) + } + useStore.setState(original) +})