Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 43 additions & 31 deletions ui/src/features/agent/agentActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1843,8 +1843,13 @@ const EXPLICIT_VIDEO_REQUESTS = [
const NEGATED_VIDEO_REQUEST = /\b(?:no|sin|don['’]?t|do\s+not)\b[^.!?\n]{0,32}\b(?:hagas|generes|crees|lances|encoles|hacer|generar|crear|lanzar|encolar|make|create|generate|render|launch|start|queue)\b/i

const EXPLICIT_CANCEL_REQUESTS = [
/\b(?:cancela|cancelad|cancelar|para|parad|det[eé]n|detened)\b[^.!?\n]*\b(?:tarea|trabajo|job|cola|generaci[oó]n|v[ií]deo|video|clip)\b/i,
/\b(?:para|parad|det[eé]n)\b[^.!?\n]*\b(?:lo que est[aá] (?:generando|renderizando|en cola|corriendo))\b/i,
/\b(?:cancela|cancelad|cancelar|parad|det[eé]n|detened)\b[^.!?\n]*\b(?:tarea|trabajo|job|cola|generaci[oó]n|v[ií]deo|video|clip)\b/i,
// "para" is also the Spanish preposition ("pasos para generar un vídeo").
// Treat it as the imperative of parar only at a sentence start and only
// when the object is a job, never media or an infinitive.
/(?:^|[.!?;]\s*)(?:por favor[, ]+)?para\b(?:\s+(?:ya|ahora))?\s+(?:el|la|este|esta|esa)?\s*(?:tarea|trabajo|job|cola|generaci[oó]n)\b/i,
/\b(?:parad|det[eé]n)\b[^.!?\n]*\b(?:lo que est[aá] (?:generando|renderizando|en cola|corriendo))\b/i,
/(?:^|[.!?;]\s*)(?:por favor[, ]+)?para\b[^.!?\n]{0,24}\blo que est[aá] (?:generando|renderizando|en cola|corriendo)\b/i,
/\b(?:cancel|stop|abort)\b[^.!?\n]*\b(?:task|job|queue|generation|video|clip|active)\b/i,
]
const NEGATED_CANCEL_REQUEST = /\b(?:no|sin|don['’]?t|do\s+not)\b[^.!?\n]{0,24}\b(?:cancel|cancela|canceles|pares|detengas|stop|abort)\b/i
Expand All @@ -1870,13 +1875,13 @@ export function isExplicitRetryRequest(request: string): boolean {

export function isExplicitVideoGenerationRequest(request: string): boolean {
const text = request.trim()
if (!text || NEGATED_VIDEO_REQUEST.test(text)) return false
if (!text || NEGATED_VIDEO_REQUEST.test(text) || isHowToGenerateQuestion(text)) return false
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
if (!text || NEGATED_VIDEO_REQUEST.test(text) || isHowToGenerateQuestion(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,
)
Expand All @@ -1900,7 +1905,7 @@ const EXPLICIT_IMAGE_REQUESTS = [

export function isExplicitImageGenerationRequest(request: string): boolean {
const text = request.trim()
if (!text || NEGATED_VIDEO_REQUEST.test(text)) return false
if (!text || NEGATED_VIDEO_REQUEST.test(text) || isHowToGenerateQuestion(text)) return false
if (isExplicitVideoGenerationRequest(text)) return false
return EXPLICIT_IMAGE_REQUESTS.some(pattern => pattern.test(text))
}
Expand All @@ -1920,7 +1925,7 @@ const STUDIO_AUDIO_CONTEXT = [
export function isExplicitAudioGenerationRequest(request: string): boolean {
const text = request.trim()
if (!text || NEGATED_VIDEO_REQUEST.test(text) || MUSIC_VIDEO_CONTEXT.test(text)) return false
if (isExplicitSfxGenerationRequest(text)) return false
if (isHowToGenerateQuestion(text) || isExplicitSfxGenerationRequest(text)) return false
if (!STUDIO_AUDIO_CONTEXT.some(pattern => pattern.test(text))) return false
return EXPLICIT_AUDIO_GENERATION_REQUESTS.some(pattern => pattern.test(text))
}
Expand All @@ -1941,7 +1946,7 @@ const EXPLICIT_3D_REQUESTS = [

export function isExplicit3dGenerationRequest(request: string): boolean {
const text = request.trim()
if (!text || NEGATED_VIDEO_REQUEST.test(text)) return false
if (!text || NEGATED_VIDEO_REQUEST.test(text) || isHowToGenerateQuestion(text)) return false
if (isExplicitVideoGenerationRequest(text) || isExplicitImageGenerationRequest(text) || isExplicitSfxGenerationRequest(text)) return false
return EXPLICIT_3D_REQUESTS.some(pattern => pattern.test(text))
}
Expand Down Expand Up @@ -2009,14 +2014,21 @@ function comicPanelTarget(
const HOW_TO_GENERATE = [
/\b(?:c[oó]mo(?:\s+(?:lo|la|las|los|puedo|se))?\s+(?:genero|generar|lanzo|lanzar|creo|crear|hago|hacer)|how\s+(?:can|do)\s+(?:i|we|you)\s+(?:generate|create|launch|start|make)|how\s+do\s+i\s+(?:generate|create|launch|start|make))\b/i,
/\bhow\s+to\s+(?:generate|create|launch|start|make)\b/i,
/\b(?:explain|describe|tell\s+me|show(?:\s+me)?|can\s+you\s+show\s+me)\b[^.!?\n]{0,96}\bhow\s+(?:to|do(?:\s+i)?)\b/i,
/\b(?:what|which)\s+(?:model|provider)\b[^.!?\n]{0,160}\b(?:generate|create|launch|start|make)\b/i,
/\b(?:explain|describe|tell\s+me|show(?:\s+me)?|can\s+you\s+(?:explain|describe|tell\s+me|show\s+me))\b[^.!?\n]{0,96}\b(?:how\s+(?:to|do(?:\s+i)?)|steps?(?:\s+to)?|(?:what|which)\s+happens)\b/i,
/\b(?:what|which)\s+(?:model|provider|steps?)\b[^.!?\n]{0,160}\b(?:generate|create|launch|start|make)\b/i,
/\b(?:before\s+i\s+(?:generate|create|launch|start|make)|what\s+(?:should|do)\s+i\s+(?:configure|set|know|check|prepare))\b/i,
/\b(?:dime|expl[ií]came|descr[ií]beme|mu[eé]strame)\b[^.!?\n]{0,96}\b(?:c[oó]mo|pasos?(?:\s+para)?)\b/i,
/\b(?:qu[eé])\s+pasos?\b[^.!?\n]{0,160}\b(?:gener|cre|lanz)/i,
/\b(?:antes\s+de\s+(?:generar|crear|lanzar)|qu[eé]\s+(?:debo|deber[ií]a)\s+(?:configurar|saber|preparar))\b/i,
]

export function isHowToGenerateQuestion(request: string): boolean {
const text = request.trim()
if (!text || text.length > 240) return false
return HOW_TO_GENERATE.some(pattern => pattern.test(text))
if (!text) return false
// Classify from the opening window so a long explanation after
// "how do I generate a video?" stays educational. A later command
// after 240 characters is treated as a separate request.
return HOW_TO_GENERATE.some(pattern => pattern.test(text.slice(0, 240)))
}

const LABS_INVENTORY = /(?:¿\s*)?(?:qu[eé]\s+puedes\s+hacer|what\s+can\s+you\s+do)(?:\s+(?:en|in|con|with))?\s+(?:el\s+)?(?:series\s+lab|story\s+lab)/i
Expand Down Expand Up @@ -2145,6 +2157,26 @@ export async function reconcileAgentTurnWithRequest(
}
: candidate
turn = preserveExactEpisodeTitle(turn)
if (isComicLaunchHowQuestion(request, history)) {
return {
reply: [
'No hay un botón llamado **Render page**.',
'El dibujo de las viñetas es **Generate all images** en Comic Director (barra de Comics), o dímelo aquí: **lánzalo**.',
'Las viñetas entran en la **misma GPU**, una detrás de otra, no en paralelo. No es un segundo motor.',
].join('\n\n'),
actions: [{ type: 'open_tab', tab: 'comics' }],
}
}
if (isHowToGenerateQuestion(request)) {
return {
...turn,
actions: turn.actions.filter(action => (
action.type === 'open_tab'
|| action.type === 'open_story_section'
|| action.type === 'open_series_section'
)),
}
}
if (isResumePreparedStudioVideoRequest(request)) {
const existing = turn.actions.find(
(action): action is AgentPrepareVideoAction => action.type === 'prepare_video',
Expand Down Expand Up @@ -2183,29 +2215,9 @@ export async function reconcileAgentTurnWithRequest(
actions: [rhythmic3dWorkflow],
}
}
if (isComicLaunchHowQuestion(request, history)) {
return {
reply: [
'No hay un botón llamado **Render page**.',
'El dibujo de las viñetas es **Generate all images** en Comic Director (barra de Comics), o dímelo aquí: **lánzalo**.',
'Las viñetas entran en la **misma GPU**, una detrás de otra, no en paralelo. No es un segundo motor.',
].join('\n\n'),
actions: [{ type: 'open_tab', tab: 'comics' }],
}
}
if (isLabsInventoryQuestion(request)) {
return { ...turn, actions: [] }
}
if (isHowToGenerateQuestion(request)) {
return {
...turn,
actions: turn.actions.filter(action => (
action.type === 'open_tab'
|| action.type === 'open_story_section'
|| action.type === 'open_series_section'
)),
}
}
if (!requestAuthorizesEditorialCommit(request)) {
const actions = turn.actions.filter(action => !EDITORIAL_COMMIT_TYPES.has(action.type))
if (actions.length !== turn.actions.length) turn = { ...turn, actions }
Expand Down
3 changes: 2 additions & 1 deletion ui/src/features/agent/wizardTurnReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ type Translate = (key: string, options?: Record<string, unknown>) => string

/** Conservative presentation policy, not an authorization or execution classifier. */
function allowsExplanation(request: string): boolean {
const text = request.trim().replace(/^[¿¡]+/, '')
// JS `\b` is ASCII-only. Fold accents so "Qué" / "por qué" keep a word boundary.
const text = request.trim().replace(/^[¿¡]+/, '').normalize('NFD').replace(/[\u0300-\u036f]/g, '')
if (/^(?:hola|hello|hi|gracias|thanks)[\s!.]*$/i.test(text)) return true
// An informational prefix does not erase a later imperative in a mixed turn.
if (/(?:[,;.!?\n]|\b(?:and|then|also|y|luego|despu[eé]s))\s*(?:(?:please|por favor)[,\s]+)?(?:create|generate|make|update|delete|remove|add|save|export|start|retry|run|open|select|crea\w*|genera\w*|haz\w*|actualiza\w*|elimina\w*|borra\w*|a[nñ]ade\w*|guarda\w*|exporta\w*|inicia\w*|reintenta\w*|ejecuta\w*|abre|selecciona\w*)\b/i.test(text)) return false
Expand Down
31 changes: 27 additions & 4 deletions ui/src/features/agent/wizardWorkflowRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,31 @@ export class WizardWorkflowRuntime {
}
}

/**
* A CAS retry builds a merged snapshot, but `advanceUnlocked` keeps mutating
* the live workflow object captured in `owner.collection`. Adopt remote-only
* siblings into that same collection instead of pointing persist at the clone
* — otherwise the next persist in this advance overwrites those siblings.
*/
private adoptPersistMerge(
owner: {
workspace: string
collection: WizardWorkflowCollection
openSequence: number
},
merged: WizardWorkflowCollection,
): void {
const liveById = new Map(owner.collection.workflows.map(workflow => [workflow.workflowId, workflow]))
owner.collection.workflows = merged.workflows.map(workflow => {
const live = liveById.get(workflow.workflowId)
return live && live.updatedAt >= workflow.updatedAt ? live : workflow
})
owner.collection.revision = merged.revision
if (this.ownsOpen(owner.openSequence, owner.workspace)) {
this.collection = owner.collection
}
}

private async persistAndEmit(
workflow: WizardWorkflowRecord,
owner = this.capturePersistOwner(),
Expand Down Expand Up @@ -894,10 +919,8 @@ export class WizardWorkflowRuntime {
lastError = error
const remote = await this.persistence.load(targetWorkspace)
candidate = mergeCollections(candidate, remote)
owner.collection.revision = candidate.revision
if (this.ownsOpen(openSequence, targetWorkspace)) {
this.collection = candidate
}
this.adoptPersistMerge(owner, candidate)
candidate = clone(owner.collection)
}
}
throw lastError instanceof Error ? lastError : new Error('Could not persist Wizard workflow.')
Expand Down
13 changes: 12 additions & 1 deletion ui/src/stores/studioConfigurationSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,18 @@ export function createStudioConfigurationSlice(
setDurationSeconds: requestedSeconds => {
const options = get().modelOptions
const fps = options?.fps ?? 16
const minimum = Math.max(1, (options?.frames_minimum || fps) / fps)
// DramaBox (and similar TTS models) declare duration_slider.min=0 so
// that 0 means "auto-derive from the prompt". The video floor of 1s
// must not rewrite that sentinel — Wizard prepare and the Advanced
// slider both call this setter with 0.
const autoDurationAllowed = Boolean(
options?.audio_only
&& options.duration_slider
&& options.duration_slider.min === 0,
)
const minimum = autoDurationAllowed
? 0
: Math.max(1, (options?.frames_minimum || fps) / fps)
const nativeMaximum = options?.frames_maximum ? options.frames_maximum / fps : null
const maximum = options?.sliding_window || nativeMaximum == null
? Number.POSITIVE_INFINITY
Expand Down
144 changes: 144 additions & 0 deletions ui/tests/agentActions.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1656,6 +1656,37 @@ test('drops cancel_task unless confirm is true and repairs an explicit cancel re
assert.equal(repaired.actions[0].confirm, true)
})

test('Spanish para-preposition does not cancel the active GPU task', async () => {
const { isExplicitCancelRequest, reconcileAgentTurnWithRequest } = await import('../src/features/agent/agentActions.ts')
for (const request of [
'Dime los pasos para generar un vídeo',
'Qué pasos sigo para generar un vídeo',
'Abre Studio para generar un vídeo',
'Necesito ayuda para lanzar el vídeo',
'Ajustes para la generación en Studio',
'Ayuda para la tarea de vídeo',
]) {
assert.equal(isExplicitCancelRequest(request), false, request)
const turn = await reconcileAgentTurnWithRequest(request, { reply: 'Cancelo.', actions: [] })
assert.equal(turn.actions.some(action => action.type === 'cancel_task'), false, request)
}

for (const request of [
'para la generación',
'Para la tarea',
'por favor para ya la cola',
'Para lo que está generando',
'detén el vídeo',
'cancela el vídeo',
]) {
assert.equal(isExplicitCancelRequest(request), true, request)
}
for (const request of ['para la generación', 'Para la tarea', 'por favor para ya la cola', 'Para lo que está generando']) {
const turn = await reconcileAgentTurnWithRequest(request, { reply: 'Vale.', actions: [] })
assert.equal(turn.actions[0].type, 'cancel_task', request)
}
})

test('requires confirmation for retry and resolves an explicit latest failure request', async () => {
const { parseAgentTurn, reconcileAgentTurnWithRequest } = await import('../src/features/agent/agentActions.ts')
const unsigned = parseAgentTurn(JSON.stringify({
Expand Down Expand Up @@ -1754,6 +1785,54 @@ test('repairs an explicit Studio audio request when the model only prepares it',
assert.equal(voice.actions[0].subMode, 'speech')
})

test('educational generate paraphrases do not enqueue Studio generation', async () => {
const {
isHowToGenerateQuestion,
isExplicitVideoGenerationRequest,
isExplicitImageGenerationRequest,
reconcileAgentTurnWithRequest,
} = await import('../src/features/agent/agentActions.ts')

for (const question of [
'Tell me the steps to generate a video in Studio.',
'What steps do I follow to generate a video?',
'Before I generate a video, what should I configure in Studio?',
'Can you explain what happens when you generate a video?',
'Dime los pasos para generar un vídeo',
'Qué pasos sigo para generar un vídeo',
]) {
assert.equal(isHowToGenerateQuestion(question), true, question)
assert.equal(isExplicitVideoGenerationRequest(question), false, question)
const turn = await reconcileAgentTurnWithRequest(question, {
reply: 'I will generate it now.',
actions: [
{ type: 'prepare_video', prompt: 'un mago en la torre' },
{ type: 'start_generation', confirm: true },
],
})
assert.deepEqual(turn.actions.map(action => action.type), [], question)
}

const imageQuestion = 'Tell me the steps to generate an image in Studio.'
assert.equal(isHowToGenerateQuestion(imageQuestion), true)
assert.equal(isExplicitImageGenerationRequest(imageQuestion), false)
const imageTurn = await reconcileAgentTurnWithRequest(imageQuestion, {
reply: 'I will paint it now.',
actions: [
{ type: 'prepare_image', prompt: 'un gato naranja' },
{ type: 'start_generation', confirm: true },
],
})
assert.deepEqual(imageTurn.actions.map(action => action.type), [])

const command = await reconcileAgentTurnWithRequest('Generate a video of a wizard on a tower', {
reply: '¿De qué?',
actions: [],
})
assert.deepEqual(command.actions.map(action => action.type), ['prepare_video', 'start_generation'])
assert.ok(String(command.actions[0].prompt).includes('wizard'))
})

test('keeps Story Lab song generation out of the Studio Audio shortcut', async () => {
const { isExplicitAudioGenerationRequest, reconcileAgentTurnWithRequest } = await import('../src/features/agent/agentActions.ts')
const configure = {
Expand Down Expand Up @@ -1985,6 +2064,71 @@ test('genera el video de un mapache uses the new topic instead of the prepared f
useStore.setState(original)
})

test('how-to launch questions do not queue a prepared Studio video', async () => {
const { useStore } = await import('../src/stores/useStore.ts')
const {
isHowToGenerateQuestion,
isResumePreparedStudioVideoRequest,
isExplicitVideoGenerationRequest,
isExplicitAudioGenerationRequest,
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: 'un mago en la torre' },
savedPromptPerMode: { ...useStore.getState().savedPromptPerMode, video: 'un mago en la torre' },
})

try {
for (const question of [
'How do I launch the video?',
'How do I start the video?',
'Explain how to launch the video',
]) {
assert.equal(isHowToGenerateQuestion(question), true, question)
assert.equal(isResumePreparedStudioVideoRequest(question), false, question)
assert.equal(isExplicitVideoGenerationRequest(question), false, question)
const turn = await reconcileAgentTurnWithRequest(question, {
reply: 'I will launch it.',
actions: [{ type: 'prepare_video', prompt: 'un mago en la torre' }, { type: 'start_generation', confirm: true }],
})
assert.deepEqual(turn.actions.map(action => action.type), [], question)
}

const longVideo = `How do I generate a video? ${'Please explain the workflow in detail. '.repeat(8)}`
assert.ok(longVideo.length > 240)
assert.equal(isHowToGenerateQuestion(longVideo), true)
assert.equal(isExplicitVideoGenerationRequest(longVideo), false)
const longVideoTurn = await reconcileAgentTurnWithRequest(longVideo, { reply: 'Here is how.', actions: [] })
assert.deepEqual(longVideoTurn.actions.map(action => action.type), [])

const longAudio = `How can I generate music in Studio Audio? ${'Please explain the workflow in detail. '.repeat(8)}`
assert.ok(longAudio.length > 240)
assert.equal(isHowToGenerateQuestion(longAudio), true)
assert.equal(isExplicitAudioGenerationRequest(longAudio), false)
const longAudioTurn = await reconcileAgentTurnWithRequest(longAudio, {
reply: 'I will generate it now.',
actions: [
{ type: 'open_tab', tab: 'audio' },
{ type: 'prepare_audio', subMode: 'music', prompt: 'Piano' },
{ type: 'start_generation', confirm: true },
],
})
assert.deepEqual(longAudioTurn.actions.map(action => action.type), ['open_tab'])

const launch = await reconcileAgentTurnWithRequest('lanza el vídeo', { reply: '¿De qué?', actions: [] })
assert.deepEqual(launch.actions.map(action => action.type), ['prepare_video', 'start_generation'])
assert.equal(launch.actions[0].prompt, 'un mago en la torre')
} finally {
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')
Expand Down
Loading
Loading