feat: add Cancel button to contextual generation dialog and enable interrupt functionality

This commit is contained in:
vegu-ai-tools
2026-04-25 20:12:30 +03:00
parent dab547304e
commit 824b862bb0
3 changed files with 84 additions and 25 deletions

View File

@@ -32,6 +32,7 @@
- "Encryption: Fixed API keys in agent action configs (e.g., OpenAI-compatible TTS, visual backends) being stored in plaintext. The encryption walker now handles the nested AgentActionConfig structure where the key is wrapped in a {value: ...} dict."
- "Agent Config: Stopped persisting unified_api_key fields to config.yaml. These are static reference pointers (e.g., 'openai.api_key') defined in code and never change, so saving them was unnecessary."
- "Contextual Generate: Fixed list-type generation prefilling an empty line after '1.', which caused some LLMs to produce empty lists."
- "Contextual Generate: Added a Cancel button to the generate dialog and moved generation to a background task so the interrupt signal is actually processed mid-generation (previously the websocket receive loop was blocked until the generation finished, defeating the purpose of any cancel)."
- "Narrate Progress: Fixed narration sometimes generating screenplay-style dialogue entries instead of continuous narrative prose."
- "Advance Time: Fixed toolbar time advancement being completely non-functional due to a missing websocket handler. Also fixed invalid ISO 8601 duration strings for the 1 week and 2 weeks options."
- "Prompts UI: Fixed the template preview CodeMirror editor being clipped at the bottom across the Active, group, and LLM prompt template tabs, so the bottom scrollbar is fully visible."

View File

@@ -8,6 +8,7 @@ from talemate.agents.creator.assistant import ContentGenerationContext
from talemate.client.context import ClientContext
from talemate.context import RegenerationContext
from talemate.emit import emit
from talemate.exceptions import GenerationCancelled
from talemate.instance import get_agent
from talemate.load.character_card import analyze_character_card
from talemate.regenerate import ensure_regenerate_allowed, regenerate
@@ -48,35 +49,73 @@ class AssistantPlugin(Plugin):
self.websocket_handler = websocket_handler
async def handle_contextual_generate(self, data: dict):
"""
Run contextual_generate as a background task so the websocket receive loop
stays free to process inbound messages (notably `interrupt`) while the
generation is in flight.
"""
payload = ContentGenerationContext(**data)
creator = get_agent("creator")
if payload.computed_context[0] == "acting_instructions":
content = await creator.determine_character_dialogue_instructions(
self.scene.get_character(payload.character),
instructions=payload.instructions,
async def _run() -> str:
if payload.computed_context[0] == "acting_instructions":
return await creator.determine_character_dialogue_instructions(
self.scene.get_character(payload.character),
instructions=payload.instructions,
)
return await creator.contextual_generate(payload)
# Inlined instead of using Plugin.create_task_done_callback because the
# success branch needs a uid-scoped payload + status emit. If a third
# handler ends up needing this same shape, generalize the helper to
# accept a custom success payload builder rather than copying again.
def _on_done(task: asyncio.Task):
try:
content = task.result()
except GenerationCancelled:
log.warning("contextual_generate cancelled", uid=payload.uid)
self.websocket_handler.queue_put(
{
"type": self.router,
"action": "contextual_generate_cancelled",
"data": {"uid": payload.uid},
}
)
return
except Exception as e:
log.error(
"Error running contextual_generate",
error=traceback.format_exc(),
)
self.websocket_handler.queue_put(
{
"type": self.router,
"action": "contextual_generate_failed",
"data": {"uid": payload.uid, "message": str(e)},
}
)
return
context_type, context_name = payload.computed_context
emit(
"status",
message=f"Generated {context_type}: {context_name}",
status="success",
)
self.websocket_handler.queue_put(
{
"type": self.router,
"action": "contextual_generate_done",
"data": {
"generated_content": content,
"uid": payload.uid,
**payload.model_dump(),
},
}
)
else:
content = await creator.contextual_generate(payload)
context_type, context_name = payload.computed_context
emit(
"status",
message=f"Generated {context_type}: {context_name}",
status="success",
)
self.websocket_handler.queue_put(
{
"type": self.router,
"action": "contextual_generate_done",
"data": {
"generated_content": content,
"uid": payload.uid,
**payload.model_dump(),
},
}
)
task = asyncio.create_task(_run())
task.add_done_callback(_on_done)
async def handle_autocomplete(self, data: dict):
data = ContentGenerationContext(**data)

View File

@@ -55,6 +55,7 @@
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="warning" variant="text" prepend-icon="mdi-stop-circle-outline" @click="cancel" :disabled="!busy">Cancel</v-btn>
<v-btn v-if="withInstructions" color="primary" variant="text" prepend-icon="mdi-auto-fix" @click="generate" :disabled="busy">Generate</v-btn>
</v-card-actions>
</v-card>
@@ -314,9 +315,27 @@ export default {
}));
},
cancel() {
this.getWebsocket().send(JSON.stringify({ type: 'interrupt' }));
},
handleMessage(message) {
if (message.type === "assistant" && message.action === "contextual_generate_cancelled") {
if(message.data.uid !== this.uid)
return;
this.busy = false;
this.dialog = false;
return;
}
if (message.type === "assistant" && message.action === "contextual_generate_failed") {
if(message.data.uid !== this.uid)
return;
this.busy = false;
this.dialog = false;
return;
}
if (message.type === "assistant" && message.action === "contextual_generate_done") {
if(message.data.uid !== this.uid)
return;