feat(genai): genai_primitives parallel input/output path (#181)#363
feat(genai): genai_primitives parallel input/output path (#181)#363DenisovAV wants to merge 21 commits into
Conversation
| textBuffer.write(text); | ||
| case DataPart(:final bytes, :final mimeType): | ||
| _routeMedia(bytes, mimeType, images, () => audio, (v) => audio = v); | ||
| case LinkPart(:final url, :final mimeType): |
There was a problem hiding this comment.
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; |
| Uint8List bytes, | ||
| String? mimeType, | ||
| List<Uint8List> images, | ||
| Uint8List? Function() getAudio, |
There was a problem hiding this comment.
unnecessary getAudio and setAudio
| hooks: ^2.0.0 | ||
| code_assets: ^1.2.0 | ||
| crypto: ^3.0.6 | ||
| genai_primitives: ^0.2.3 |
| final msg = ChatMessage( | ||
| role: ChatMessageRole.user, | ||
| parts: [ | ||
| DataPart(Uint8List.fromList([1, 2]), mimeType: 'application/pdf'), |
There was a problem hiding this comment.
could be supported in future :)
| messages.insert( | ||
| 0, | ||
| Message( | ||
| text: textBuffer.toString(), |
There was a problem hiding this comment.
ChatMessage.text contains the text of all Textparts. No need to parse it.
| ); | ||
| } | ||
| final parts = message.parts; | ||
| if (parts.isEmpty) { |
| /// | ||
| /// 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( |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
Do the converters need to be exported?
… re-input, simplify converters (#181)
#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).
… re-input, simplify converters (#181)
a788575 to
c68dfb8
Compare
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.
|
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: LinkPart — removed entirely (your "not required to contain media" + Chain-of-thoughts with tools: yes — Kept deliberately: empty-parts Also fixed a concurrency bug a later pass missed (a post-completion |
Adds a parallel genai_primitives path per the agreed #181 course, running alongside the existing
MessageAPI (untouched).New public API
import 'package:flutter_gemma/genai.dart';— an extension onInferenceChat:sendMessage(ChatMessage)/sendMessageStream(ChatMessage)generateContent(List<ChatMessage>)/generateContentStream(List<ChatMessage>)All return a role:model
ChatMessagewhose parts carry text + tool calls + thinking together. Both stage each message via the existingaddQueryChunk(buffers without generating) then generate once.Design
Mutexfield inchat.dart; the oldMessage/addQueryChunkpath is fully intact — both paths work side by side.ChatMessage → List<Message>(1→N; tool results become sibling messages), output converterModelResponse → 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.generateContentis stateful (stages into this chat's history, then generates once) — isolation = a freshmodel.createChat(). This is on-device reality, not firebase's stateless model.genai_primitives+httpbecome 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
flutter build web --wasm✓, APK release ✓,flutter analyzecleanCloses #181.