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
Original file line number Diff line number Diff line change
@@ -1,39 +1,35 @@
import 'dart:async';

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

import '../mappers/deck_markdown_codec.dart';

/// A [DeckLoader] that parses markdown in-memory for live preview.
class MemoryDeckLoader extends DeckLoader {
/// Shared with the deck-edit tools so the preview and the tools can never
/// decode the same markdown differently.
static const _codec = DeckMarkdownCodec();

final _controller = StreamController<SlidesEvent>.broadcast();
bool _disposed = false;
String? _lastLoadedMarkdown;

@override
Stream<SlidesEvent> load() => _controller.stream;

/// Parses the given markdown and emits a [SlidesLoadedEvent].
void updateMarkdown(String markdown) {
if (_disposed || markdown == _lastLoadedMarkdown) return;
_lastLoadedMarkdown = markdown;

try {
final rawSlides = const MarkdownParser().parse(markdown);
final slides = [
for (final raw in rawSlides)
Slide(
key: raw.key,
options: .parse(raw.frontmatter),
sections: const SectionParser().parse(raw.content),
comments: const CommentParser().parse(raw.content),
),
];
final slides = _codec.decode(markdown);
_controller.add(SlidesLoadedEvent(slides));
} catch (e) {
_controller.add(SlidesErrorEvent('$e', error: e));
}
}

@override
Stream<SlidesEvent> load() => _controller.stream;

@override
Future<void> reload() async {}

Expand Down
37 changes: 37 additions & 0 deletions packages/playground/lib/core/data/mappers/deck_markdown_codec.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import 'package:superdeck_builder/superdeck_builder.dart';
import 'package:superdeck_core/superdeck_core.dart';

/// Converts between an editor Markdown document and the canonical slide model.
class DeckMarkdownCodec {
const DeckMarkdownCodec();

/// Parses [markdown] with the same pipeline used by the live preview loader.
List<Slide> decode(String markdown) {
try {
final rawSlides = const MarkdownParser().parse(markdown);

return [
for (final raw in rawSlides)
Slide(
key: raw.key,
options: SlideOptions.parse(raw.frontmatter),
sections: const SectionParser().parse(raw.content),
comments: const CommentParser().parse(raw.content),
),
];
} catch (error, stackTrace) {
Error.throwWithStackTrace(
DeckFormatException(
'Failed to decode deck Markdown: $error',
markdown,
null,
),
stackTrace,
);
}
}

/// Serializes [slides] to canonical SuperDeck Markdown.
String encode(List<Slide> slides) =>
const SlideSerializer().serialize(slides);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import 'dart:convert';

import 'package:superdeck_core/superdeck_core.dart';

/// Strict slide contract exposed to the model. Runtime-only keys are forbidden.
final keylessSlideSchema = Ack.object({
'options': slideOptionsSchema.optional(),
'comments': Ack.list(Ack.string()).optional(),
'sections': Ack.list(sectionBlockSchema),
}, additionalProperties: false);

final getDeckArgumentsSchema = Ack.object({}, additionalProperties: false);

final createSlideArgumentsSchema = Ack.object({
'slide': keylessSlideSchema,
'atIndex': Ack.integer().optional(),
}, additionalProperties: false);

final updateSlideArgumentsSchema = Ack.object({
'index': Ack.integer(),
'slide': keylessSlideSchema,
}, additionalProperties: false);

final deleteSlideArgumentsSchema = Ack.object({
'index': Ack.integer(),
}, additionalProperties: false);

final moveSlideArgumentsSchema = Ack.object({
'fromIndex': Ack.integer(),
'toIndex': Ack.integer(),
}, additionalProperties: false);

final readSlideArgumentsSchema = Ack.object({
'index': Ack.integer(),
}, additionalProperties: false);

/// Validates and constructs a core slide with a private transient key.
Slide parseKeylessSlide(Object? value) {
final validated = keylessSlideSchema.parse(value)!;
final map = Map<String, Object?>.of(validated);

return Slide.fromMap({
'key': 'tool_${generateValueHash(jsonEncode(map))}',
...map,
});
}

/// Serializes [slide] without exposing its runtime key.
Map<String, Object?> slideToKeylessMap(Slide slide) {
return Map<String, Object?>.from(slide.toMap())..remove('key');
}

/// Returns the style-free, key-free deck summary used in tool results.
Map<String, Object?> deckSnapshot(List<Slide> slides) {
return {
'totalSlides': slides.length,
'slides': [
for (final (index, slide) in slides.indexed)
{'index': index, 'title': ?slide.options?.title},
],
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import 'package:ack/ack.dart';
import 'package:ack_json_schema_builder/ack_json_schema_builder.dart';
import 'package:dartantic_ai/dartantic_ai.dart' as dartantic;

import '../domain/deck_tool_error.dart';
import '../domain/deck_tools_service.dart';
import 'deck_tool_schemas.dart';

/// Adapts the typed deck service to the current Dartantic tool API.
final class DeckToolsAdapter {
late final List<dartantic.Tool<Map<String, dynamic>>> tools;

final DeckToolsService _service;

DeckToolsAdapter(DeckToolsService service) : _service = service {
tools = List<dartantic.Tool<Map<String, dynamic>>>.unmodifiable([
_tool(
name: 'getDeck',
description: 'Return the current slide count and slide titles.',
schema: getDeckArgumentsSchema,
call: (_) => _service.getDeck(),
),
_tool(
name: 'createSlide',
description:
'Insert a keyless slide, appending when atIndex is omitted.',
schema: createSlideArgumentsSchema,
call: (input) => _service.createSlide(
parseKeylessSlide(input['slide']),
atIndex: input['atIndex'] as int?,
),
),
_tool(
name: 'updateSlide',
description: 'Replace the slide at a zero-based index.',
schema: updateSlideArgumentsSchema,
call: (input) => _service.updateSlide(
input['index'] as int,
parseKeylessSlide(input['slide']),
),
),
_tool(
name: 'deleteSlide',
description: 'Delete the slide at a zero-based index.',
schema: deleteSlideArgumentsSchema,
call: (input) => _service.deleteSlide(input['index'] as int),
),
_tool(
name: 'moveSlide',
description: 'Move a slide so it ends at the requested final index.',
schema: moveSlideArgumentsSchema,
call: (input) => _service.moveSlide(
input['fromIndex'] as int,
input['toIndex'] as int,
),
),
_tool(
name: 'readSlide',
description: 'Return one keyless slide and a rendered PNG thumbnail.',
schema: readSlideArgumentsSchema,
call: (input) => _service.readSlide(input['index'] as int),
),
// updateStyle is not registered yet: its schema and applier targeted the
// pre-#102 style contract. The service keeps the seam
// (DeckToolsService.updateStyle); the tool returns with the deck-edit UI
// once it is rebuilt on the brand/theme contract.
]);
}

dartantic.Tool<Map<String, dynamic>> _tool({
required String name,
required String description,
required AckSchema schema,
required Future<Map<String, Object?>> Function(Map<String, dynamic> input)
call,
}) {
return dartantic.Tool(
name: name,
description: description,
onCall: (input) => _invoke(schema, input, call),
inputSchema: schema.toJsonSchemaBuilder(),
);
}

Future<Map<String, Object?>> _invoke(
AckSchema schema,
Map<String, dynamic> input,
Future<Map<String, Object?>> Function(Map<String, dynamic>) call,
) async {
try {
schema.parse(input);

return await call(input);
} on AckException {
return _error(.validationFailed, 'The tool arguments are invalid.');
} on DeckToolError catch (error) {
return _error(error.code, error.message);
} catch (_) {
return _error(
.internalError,
'The deck tool could not complete the request.',
);
}
}

Map<String, Object?> _error(DeckToolErrorCode code, String message) {
return {
'error': {'code': code.wireName, 'message': message},
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import 'dart:convert';
import 'dart:typed_data';

import 'package:flutter/widgets.dart';
import 'package:superdeck/superdeck.dart';

import '../ai/deck_tool_schemas.dart';
import '../domain/deck_tool_error.dart';

typedef SlideCaptureCallback =
Future<Uint8List> Function({
required SlideConfiguration slide,
required BuildContext context,
required SlideCaptureQuality quality,
});

/// Reads and captures one slide from a single immutable live configuration list.
final class DeckSlideReader {
final BuildContext _context;

final DeckController _deckController;
final SlideCaptureCallback _capture;
DeckSlideReader({
required BuildContext context,
required DeckController deckController,
SlideCaptureCallback? capture,
}) : _context = context,
_deckController = deckController,
_capture = capture ?? SlideCaptureService().capture;

Future<Map<String, Object?>> read(int index) async {
final snapshot = List<SlideConfiguration>.unmodifiable(
_deckController.slides.value,
);
if (index < 0 || index >= snapshot.length) {
throw DeckToolError(
.slideIndexOutOfRange,
'Slide index $index is outside the live preview.',
);
}
if (!_context.mounted) {
throw const DeckToolError(
.contextUnavailable,
'The deck editing route is no longer mounted.',
);
}

final configuration = snapshot[index];
final Uint8List bytes;
try {
bytes = await _capture(
context: _context,
quality: .thumbnail,
slide: configuration,
);
} catch (error, stackTrace) {
if (!_context.mounted) {
Error.throwWithStackTrace(
DeckToolError(
.contextUnavailable,
'The deck editing route is no longer mounted.',
cause: error,
),
stackTrace,
);
}
Error.throwWithStackTrace(
DeckToolError(
.captureFailed,
'The slide thumbnail could not be rendered.',
cause: error,
),
stackTrace,
);
}
if (bytes.isEmpty) {
throw const DeckToolError(
.captureFailed,
'The slide thumbnail renderer returned no image data.',
);
}

final slides = [for (final item in snapshot) item.slide];
final slide = configuration.slide;

return {
'index': index,
'title': ?slide.options?.title,
'slide': slideToKeylessMap(slide),
'thumbnailBase64': base64Encode(bytes),
'deck': deckSnapshot(slides),
};
}
}
Loading
Loading