Skip to content

feat(genai): genai_primitives parallel input/output path (#181)#363

Open
DenisovAV wants to merge 21 commits into
mainfrom
feat/181-genai-primitives
Open

feat(genai): genai_primitives parallel input/output path (#181)#363
DenisovAV wants to merge 21 commits into
mainfrom
feat/181-genai-primitives

Conversation

@DenisovAV

Copy link
Copy Markdown
Owner

Adds a parallel genai_primitives path per the agreed #181 course, running alongside the existing Message API (untouched).

New public API

import 'package:flutter_gemma/genai.dart'; — an extension on InferenceChat:

  • sendMessage(ChatMessage) / sendMessageStream(ChatMessage)
  • generateContent(List<ChatMessage>) / generateContentStream(List<ChatMessage>)

All return a role:model ChatMessage whose parts carry text + tool calls + thinking together. Both stage each message via the existing addQueryChunk (buffers without generating) then generate once.

Design

  • Zero engine changes. litertlm / mediapipe / android / ios / hook untouched. The only edit to an existing file is a 5-line Mutex field in chat.dart; the old Message / addQueryChunk path is fully intact — both paths work side by side.
  • Input converter ChatMessage → List<Message> (1→N; tool results become sibling messages), output converter ModelResponse → ChatMessage, a conditional-import LinkPart reader (data/file/http, wasm-safe), a capability guard (no silent image/audio/tool drop), and a per-chat mutex with terminal-path release on the stream variants.
  • generateContent is stateful (stages into this chat's history, then generates once) — isolation = a fresh model.createChat(). This is on-device reality, not firebase's stateless model.
  • genai_primitives + http become direct core deps, re-exported from a side barrel (lib/genai.dart, not the main barrel) so a 0.x bump only touches the opt-in surface.

Verification

  • 22 unit tests (converters, capability guard, every throw path)
  • 10 integration tests on a real model (macOS, gemma-4-E2B) — all green: every method × text / vision / audio / thinking / tool round-trip / multi-turn
  • flutter build web --wasm ✓, APK release ✓, flutter analyze clean

Closes #181.

@snapsl snapsl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

textBuffer.write(text);
case DataPart(:final bytes, :final mimeType):
_routeMedia(bytes, mimeType, images, () => audio, (v) => audio = v);
case LinkPart(:final url, :final mimeType):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

link is not required to contain media. We could also support other types in the future.

/// Wrap the generate stream, mapping each event to a ChatMessage and holding
/// the chat mutex until a terminal event (done/error/cancel).
Stream<ChatMessage> _lockedStream(Future<void> Function() stage) {
late StreamController<ChatMessage> controller;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

final

Uint8List bytes,
String? mimeType,
List<Uint8List> images,
Uint8List? Function() getAudio,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unnecessary getAudio and setAudio

hooks: ^2.0.0
code_assets: ^1.2.0
crypto: ^3.0.6
genai_primitives: ^0.2.3

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pin version?

final msg = ChatMessage(
role: ChatMessageRole.user,
parts: [
DataPart(Uint8List.fromList([1, 2]), mimeType: 'application/pdf'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could be supported in future :)

messages.insert(
0,
Message(
text: textBuffer.toString(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ChatMessage.text contains the text of all Textparts. No need to parse it.

);
}
final parts = message.parts;
if (parts.isEmpty) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Create an empty Message?

///
/// One ChatMessage may yield >1 Message (tool results become sibling messages).
/// Async because LinkPart resolution may need I/O. Throws — never silently drops.
Future<List<Message>> messagesFromChatMessage(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. Strictly speaking, this could change the order of the parts compared to the order of the messages, since the final message is inserted at first, followed by the tool messages.

import 'genai_output_converter.dart';

/// Guard: a model-role ChatMessage is output, not a sendMessage input.
void rejectModelRole(ChatMessage message) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reject model + system so there is no checking required later on?

/// A side barrel (NOT re-exported from `flutter_gemma.dart`) so genai_primitives
/// 0.x churn stays contained. Import `package:flutter_gemma/genai.dart` to use
/// [ChatMessage] with [InferenceChat.sendMessage] / [generateContent].
library;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do the converters need to be exported?

DenisovAV added a commit that referenced this pull request Jul 12, 2026
DenisovAV added 15 commits July 12, 2026 16:47
#181)

- _lockedStream: a subscriber cancelling during the onListen await (before
  sub is assigned) triggered onCancel while onListen was suspended, releasing
  the mutex early; onListen then resumed and started a second concurrent turn
  with an un-cancellable subscription. Guard with a cancelled flag checked
  after stage() — bail before attaching the subscription. Verified with an
  isolated StreamController race repro.
- integration test: generateContent multi-turn now closes chat.session (was
  the one of 10 tests that leaked).
@DenisovAV
DenisovAV force-pushed the feat/181-genai-primitives branch from a788575 to c68dfb8 Compare July 12, 2026 14:48
onCancel released genaiLock even while onListen was still queued on acquire()
or inside stage(), freeing another turn's critical section (package:mutex is
ownership-blind) — letting a second turn interleave into the shared session,
or permanently wedging the chat. Lock is now owned by onListen: release()
guards on lockHeld, and onCancel only tears down once generation is running.
Also route a throw in the chunk-mapping into cleanup (was escaping to the Zone
and leaking the lock), and log stopGeneration teardown failures.
- keep preamble text alongside a tool call in the batch fold (was silently
  dropped, diverging from the streaming path)
- link_reader: reject an empty 2xx body (blank media) and bound the fetch with
  a timeout so a hung server can't wedge the chat mutex
- throw on a tool result carried by a model-role message (symmetric with the
  tool-call-on-user guard)
- mark InferenceChat.genaiLock @internal — external acquire/release deadlocks
  the chat
- document that callId mirrors toolName (flutter_gemma's tool protocol is
  name-keyed)
…ives

# Conflicts:
#	CLAUDE.md
#	packages/flutter_gemma/CHANGELOG.md
#	packages/flutter_gemma/DESKTOP_SUPPORT.md
#	website/content/docs/migration.md
- Guard onCancel teardown on !released: Dart fires onCancel after a normal
  or errored 'done' too, and the generating-only guard issued a spurious
  stopGeneration() on the shared session that could cancel the next
  back-to-back turn.
- Scope LinkPart to bytes-only: the inference layer no longer fetches URLs
  or reads files (SSRF / arbitrary local-file read); LinkPart throws with
  guidance to resolve links caller-side into a DataPart. Removes link_reader
  and the http dependency; link handling belongs in flutter_gemma_agent.
- Regression tests: no stopGeneration after a successful stream, back-to-back
  turn isolation, LinkPart throws.
- migration.md core pin -> ^1.3.1.
@DenisovAV

Copy link
Copy Markdown
Owner Author

Thanks for the review, @snapsl — heads-up that the PR has moved a lot since (34 commits), and your comments drove a good chunk of it. Status:

Addressed: ChatMessage.text used directly (no re-parsing); getAudio/setAudio folded into _routeMedia; doc added on messagesFromChatMessages; converters no longer exported (genai.dart only show GenAiChat); model and system roles rejected up front.

LinkPart — removed entirely (your "not required to contain media" + File.fromUri notes): you were right a link isn't necessarily media, and since an on-device model needs the actual bytes and we can't safely resolve an arbitrary URL/file (SSRF / local-file read), I dropped link fetching. LinkPart now throws with guidance to resolve it caller-side into a DataPart; URL/web handling lives in flutter_gemma_agent behind a permission gate. link_reader + the http dep are gone — so your two link_reader comments are now moot.

Chain-of-thoughts with tools: yes — chatMessageFromParts coalesces ThinkingPart → TextPart → ToolPart.call… into one model turn, preamble preserved.

Kept deliberately: empty-parts ChatMessage throws (caller misuse); part-order documented; genai_primitives: ^0.2.3 intentional caret.

Also fixed a concurrency bug a later pass missed (a post-completion onCancel could stop the next back-to-back turn). Since your LGTM predates all of this, a fresh look is very welcome if you have a minute — no worries if not.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Consider using genai_primitives.

2 participants