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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,16 @@ class GenerationException implements Exception {
/// intermediate progress, which the base command's binary running state doesn't
/// model. On success it serializes the slides to Markdown and replaces the
/// shared [DeckDocumentStore].
///
/// A run is bound to the document revision and the file binding it started
/// from. Editing the document or switching decks discards the generated deck
/// and leaves [completionNotice] for the editor to show, because the panel
/// that started the run may already be closed.
class GenerateDeckCommand extends Command1<void, DeckGenerationRequest> {
final GeneratedDeckResultApplier _resultApplier;

final DeckDocumentStore _documentStore;
final int Function()? _bindingRevision;
final DeckGeneratorService? _service;
GenerationProgress _progress = const GenerationProgress(GenerationPhase.idle);

Expand All @@ -44,12 +51,15 @@ class GenerateDeckCommand extends Command1<void, DeckGenerationRequest> {
MemoryDeckLoader? deckLoader,
MemoryAssetCacheStore? assetCacheStore,
DeckGeneratorService? service,
int Function()? bindingRevision,
}) : _resultApplier = GeneratedDeckResultApplier(
documentStore: documentStore,
deckLoader: deckLoader,
assetCacheStore: assetCacheStore,
customizationStore: customizationStore,
),
_documentStore = documentStore,
_bindingRevision = bindingRevision,
_service = service;

void _onProgress(GenerationProgress progress) {
Expand All @@ -63,9 +73,19 @@ class GenerateDeckCommand extends Command1<void, DeckGenerationRequest> {

GenerationProgress get progress => _progress;

/// Non-blocking detail for a completed partial generation.
/// Non-blocking detail for a completed, partial, or discarded generation.
///
/// The editor keeps showing it after the generation panel closes. Call
/// [dismissNotice] once the reader has seen it.
String? get completionNotice => _completionNotice;

/// Clears the notice the editor shows.
void dismissNotice() {
if (_completionNotice == null) return;
_completionNotice = null;
notifyListeners();
}

@override
Future<Result<void>> action(DeckGenerationRequest request) async {
if (_service == null && !EnvConfig.hasGeminiApiKey) {
Expand All @@ -82,6 +102,14 @@ class GenerateDeckCommand extends Command1<void, DeckGenerationRequest> {
_progress = const GenerationProgress(GenerationPhase.generatingOutline);
notifyListeners();

final startDocumentRevision = _documentStore.revision;
final startBindingRevision = _bindingRevision?.call();
bool isCurrentDeck() =>
!_cancelled &&
!_disposed &&
_documentStore.revision == startDocumentRevision &&
_bindingRevision?.call() == startBindingRevision;

try {
final service =
_service ?? DeckGeneratorService(apiKey: EnvConfig.geminiApiKey);
Expand All @@ -105,7 +133,23 @@ class GenerateDeckCommand extends Command1<void, DeckGenerationRequest> {
return const Result.error(GenerationException('Generation cancelled.'));
}

await _resultApplier.apply(result);
final application = await _resultApplier.apply(
result,
isValid: isCurrentDeck,
);
if (!application.published) {
_completionNotice =
'The generated deck was discarded because the document changed '
'while it was being created.';
debugLog.log(
'GENERATE_DECK',
'Discarded ${result.slides.length} generated slides; '
'the editor moved on to newer work.',
);

return const Result.ok(null);
}

if (result.isPartial) {
_completionNotice = result.error;
} else if (result.hasImageFailures) {
Expand All @@ -114,6 +158,19 @@ class GenerateDeckCommand extends Command1<void, DeckGenerationRequest> {
'${result.generatedImages.length} planned artworks; '
'${result.failedImageCount} used a text-first fallback.';
}
if (application.cleanupError case final cleanupError?) {
// The deck is published. Removing the artwork it replaced is a
// separate, non-blocking problem.
debugLog.error(
'GENERATE_DECK',
'Could not remove replaced artwork: $cleanupError',
.current,
);
_completionNotice = [
?_completionNotice,
'Some artwork from the previous deck could not be removed.',
].join(' ');
}
debugLog.log(
'GENERATE_DECK',
'Loaded ${result.slides.length} slides into editor'
Expand All @@ -136,12 +193,6 @@ class GenerateDeckCommand extends Command1<void, DeckGenerationRequest> {
}
}

@override
void clearResult() {
_completionNotice = null;
super.clearResult();
}

@override
void notifyListeners() {
if (!_disposed) super.notifyListeners();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:superdeck_builder/superdeck_builder.dart';
import 'package:superdeck_core/superdeck_core.dart';

Expand All @@ -7,6 +9,27 @@ import '../../../editor/domain/stores/deck_document_store.dart';
import '../core/engine/services/deck_generator_service.dart';
import 'generated_deck_style_mapper.dart';

/// Reports whether the generation that produced a result is still the one the
/// host wants published.
///
/// The applier calls it before it stages artwork, and again after that
/// asynchronous work, immediately before it publishes the deck.
typedef GeneratedDeckApplicationGuard = bool Function();

/// Outcome of one [GeneratedDeckResultApplier.apply] call.
final class GeneratedDeckApplication {
/// Whether the document, preview, and theme received this result.
final bool published;

/// Failure raised while removing artwork that the published deck replaced.
///
/// The deck is published when this is set. Hosts report it separately from a
/// generation failure.
final Object? cleanupError;

const GeneratedDeckApplication({required this.published, this.cleanupError});
}

/// Applies generated decks for one host and evicts artwork from the deck it
/// replaces only after the replacement has been published successfully.
final class GeneratedDeckResultApplier {
Expand All @@ -16,6 +39,10 @@ final class GeneratedDeckResultApplier {
final AssetCacheStore? _assetCacheStore;
final DeckCustomizationStore _customizationStore;
Set<String> _appliedAssetKeys = const {};

/// Serializes application, so two results for one host cannot interleave
/// their artwork writes and their document publication.
Future<void> _queue = Future<void>.value();
GeneratedDeckResultApplier({
required DeckDocumentStore documentStore,
MemoryDeckLoader? deckLoader,
Expand All @@ -26,19 +53,58 @@ final class GeneratedDeckResultApplier {
_assetCacheStore = assetCacheStore,
_customizationStore = customizationStore;

Future<void> apply(DeckGenerationResult result) async {
/// Deletes only the artwork this attempt staged, and keeps every asset key
/// that an earlier attempt already committed.
Future<void> _discardStagedAssets(Set<String> stagedAssetKeys) async {
final cache = _assetCacheStore;
if (cache == null || stagedAssetKeys.isEmpty) return;
for (final assetKey in stagedAssetKeys) {
try {
await cache.delete(assetKey);
} catch (_) {
// An abandoned asset that cannot be deleted must not mask the reason
// the application stopped.
}
}
}

Future<GeneratedDeckApplication> _apply(
DeckGenerationResult result,
GeneratedDeckApplicationGuard isValid,
) async {
const abandoned = GeneratedDeckApplication(published: false);
if (!isValid()) return abandoned;

final cache = _assetCacheStore;
final nextAssetKeys = <String>{};
for (final asset in result.generatedImages) {
final bytes = asset.bytes;
if (bytes == null || bytes.isEmpty) continue;
final cache = _assetCacheStore;
if (cache == null) {
throw StateError(
'Generated artwork cannot be loaded without an asset cache.',
);
final stagedAssetKeys = <String>{};

try {
for (final asset in result.generatedImages) {
final bytes = asset.bytes;
if (bytes == null || bytes.isEmpty) continue;
if (cache == null) {
throw StateError(
'Generated artwork cannot be loaded without an asset cache.',
);
}
await cache.write(asset.assetKey, bytes);
nextAssetKeys.add(asset.assetKey);
if (!_appliedAssetKeys.contains(asset.assetKey)) {
stagedAssetKeys.add(asset.assetKey);
}
}
await cache.write(asset.assetKey, bytes);
nextAssetKeys.add(asset.assetKey);
} catch (_) {
await _discardStagedAssets(stagedAssetKeys);
rethrow;
}

// The asset writes above are asynchronous, so the host can cancel or
// supersede this generation while they run.
if (!isValid()) {
await _discardStagedAssets(stagedAssetKeys);

return abandoned;
}

final markdown = const SlideSerializer().serialize(result.slides);
Expand All @@ -48,12 +114,53 @@ final class GeneratedDeckResultApplier {
_customizationStore.applyGeneratedStyle(theme.toGeneratedDeckStyle());
}

return GeneratedDeckApplication(
published: true,
cleanupError: await _removeObsoleteAssets(nextAssetKeys),
);
}

/// Deletes the artwork the newly published deck no longer references.
///
/// Returns the first deletion failure, which the host reports separately
/// from a generation failure because the deck is already published.
Future<Object?> _removeObsoleteAssets(Set<String> nextAssetKeys) async {
final cache = _assetCacheStore;
if (cache != null) {
for (final assetKey in _appliedAssetKeys.difference(nextAssetKeys)) {
final obsoleteAssetKeys = _appliedAssetKeys.difference(nextAssetKeys);
_appliedAssetKeys = Set.unmodifiable(nextAssetKeys);
if (cache == null) return null;

Object? cleanupError;
for (final assetKey in obsoleteAssetKeys) {
try {
await cache.delete(assetKey);
} catch (error) {
cleanupError ??= error;
}
}
_appliedAssetKeys = Set.unmodifiable(nextAssetKeys);

return cleanupError;
}

/// Publishes [result] unless [isValid] reports that newer work replaced it.
///
/// Returns whether the deck reached the document, the preview, and the
/// theme. Artwork staged by an abandoned attempt is removed again.
Future<GeneratedDeckApplication> apply(
DeckGenerationResult result, {
required GeneratedDeckApplicationGuard isValid,
}) {
final application = Completer<GeneratedDeckApplication>();
// The queue only sequences the work. Failures reach the caller through
// the completer, so one failed application cannot block the next one.
_queue = _queue.then((_) async {
try {
application.complete(await _apply(result, isValid));
} catch (error, stackTrace) {
application.completeError(error, stackTrace);
}
});

return application.future;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,19 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:superdeck/superdeck.dart';
import 'package:superdeck_builder/superdeck_builder.dart';

import '../../../../../core/data/data_sources/memory_asset_cache_store.dart';
import '../../../../../core/data/data_sources/memory_deck_loader.dart';
import '../../../../../core/domain/design/presentation_image_style_catalog.dart';
import '../../../../../core/domain/design/presentation_theme_catalog.dart';
import '../../../../../core/domain/stores/deck_customization_store.dart';
import '../../../../editor/domain/stores/deck_document_store.dart';
import '../../../image_generation/image_generator.dart';
import '../../core/engine/schemas/outline_schema.dart';
import '../../core/engine/services/deck_generation_request.dart';
import '../../core/engine/services/deck_generator_service.dart';
import '../../core/engine/services/generation_progress.dart';
import '../../core/engine/services/generation_trace.dart';
import '../../core/env_config.dart';
import '../../domain/generated_deck_style_mapper.dart';
import '../../domain/generated_deck_result_applier.dart';

/// Debug-only harness for iterating on planning, artwork, and composition
/// without repeating the conversational Wizard intake.
Expand All @@ -44,6 +42,12 @@ class _GenerationLabPageState extends State<GenerationLabPage> {
final _compositionTraces = <GenerationTraceEvent>[];

late final DeckGeneratorService? _service;

/// The lab keeps its own document, so experiments never publish into the
/// editor's document or its file binding.
final _documentStore = DeckDocumentStore(markdown: '');

GeneratedDeckResultApplier? _resultApplier;
_GenerationPreset _preset = _presets.first;
GenerationProgress _progress = const GenerationProgress(GenerationPhase.idle);
DeckPlan? _plan;
Expand Down Expand Up @@ -73,6 +77,17 @@ class _GenerationLabPageState extends State<GenerationLabPage> {
: null);
}

/// Resolves the shared applier on first use, so a lab screen without an
/// application host still renders its configuration guidance.
GeneratedDeckResultApplier _applier() {
return _resultApplier ??= GeneratedDeckResultApplier(
documentStore: _documentStore,
deckLoader: context.read(),
assetCacheStore: context.read<MemoryAssetCacheStore>(),
customizationStore: context.read(),
);
}

void _selectPreset(_GenerationPreset preset) {
if (_runningStage != null || identical(_preset, preset)) return;
setState(() {
Expand Down Expand Up @@ -204,17 +219,7 @@ class _GenerationLabPageState extends State<GenerationLabPage> {
}

Future<void> _applyResult(DeckGenerationResult result) async {
final cache = context.read<MemoryAssetCacheStore>();
final customization = context.read<DeckCustomizationStore>();
final deckLoader = context.read<MemoryDeckLoader>();
for (final asset in result.generatedImages) {
final bytes = asset.bytes;
if (bytes != null && bytes.isNotEmpty) {
await cache.write(asset.assetKey, bytes);
}
}
customization.applyGeneratedStyle(result.theme!.toGeneratedDeckStyle());
deckLoader.updateMarkdown(const SlideSerializer().serialize(result.slides));
await _applier().apply(result, isValid: () => mounted && !_cancelled);
}

bool _finishCancelled(_GenerationLabStage stage, Duration duration) {
Expand Down Expand Up @@ -259,6 +264,7 @@ class _GenerationLabPageState extends State<GenerationLabPage> {
@override
void dispose() {
_cancelled = true;
_documentStore.dispose();
super.dispose();
}

Expand Down
Loading
Loading