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
3 changes: 2 additions & 1 deletion demo/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -881,7 +881,8 @@ class GalleryScreen extends StatelessWidget {
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF9893A8),
// #9893A8 on white was ~3:1 at 12px; this passes AA (~7:1).
color: Color(0xFF5B5570),
letterSpacing: 0.2,
),
),
Expand Down
Binary file modified demo/screenshots/element_branch.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/screenshots/element_confirmation.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions packages/flutter_ai_client/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## 0.3.1

- Fix: settle dangling tool calls before `submit`/`regenerate`/`editMessage`. A
turn interrupted mid agent-loop (`stop()`, a `maxSteps` cutoff, or a
transcript rehydrated from storage) left a `ToolCallPart` with no matching
`ToolResultPart`; providers reject such a history, so every later `submit`
failed with a request error. The controller now inserts a synthesized error
result immediately after each affected assistant message — in place, not
appended, since providers require the result in the following turn.
Thanks to @HeZeBang for the report and the fix. (#149)

## 0.3.0

- Add `UseChatController.load(AiConversation)` — swaps the transcript in place
Expand Down
66 changes: 59 additions & 7 deletions packages/flutter_ai_client/lib/src/use_chat_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,9 @@ class UseChatController extends ChangeNotifier {
_capture = _Capture.reset; // a new user turn starts a fresh branch set
_step = 0;
_toolCallCounts.clear();
_processor.reset(_processor.conversation.append(userMessage));
_processor.reset(
_settleDanglingToolCalls(_processor.conversation).append(userMessage),
);
_status = ChatStatus.submitted;
_scheduleNotify();
return _beginTurn();
Expand All @@ -263,7 +265,10 @@ class UseChatController extends ChangeNotifier {
_step = 0;
_toolCallCounts.clear();
_processor.reset(
_processor.conversation.copyWith(messages: all.sublist(0, lastUser + 1)),
_settleDanglingToolCalls(
_processor.conversation
.copyWith(messages: all.sublist(0, lastUser + 1)),
),
);
_status = ChatStatus.submitted;
_scheduleNotify();
Expand Down Expand Up @@ -308,11 +313,13 @@ class UseChatController extends ChangeNotifier {
_step = 0;
_toolCallCounts.clear();
_processor.reset(
_processor.conversation.copyWith(
messages: [
...all.sublist(0, index),
original.copyWith(parts: parts, status: AiMessageStatus.complete),
],
_settleDanglingToolCalls(
_processor.conversation.copyWith(
messages: [
...all.sublist(0, index),
original.copyWith(parts: parts, status: AiMessageStatus.complete),
],
),
),
);
_status = ChatStatus.submitted;
Expand Down Expand Up @@ -655,6 +662,51 @@ class UseChatController extends ChangeNotifier {
_dispatch();
}

/// Settles tool calls left unanswered anywhere in the transcript — a turn
/// stopped/replaced mid agent-loop, `maxSteps` cutting a loop short, or a
/// dirty transcript rehydrated from storage — by inserting a synthesized
/// error [ToolResultPart] message directly after each affected assistant
/// message. Providers reject a history containing a tool call with no
/// result in the immediately-following turn (so the fix-up must be inserted
/// in place, not appended at the end), and without it every subsequent
/// [submit] on the conversation fails with a request error.
AiConversation _settleDanglingToolCalls(AiConversation conversation) {
final msgs = conversation.messages;
final answered = <String>{
for (final m in msgs)
for (final p in m.parts)
if (p is ToolResultPart) p.toolCallId,
};
var changed = false;
final out = <AiMessage>[];
for (final m in msgs) {
out.add(m);
if (m.role != AiRole.assistant) continue;
final dangling = m.parts
.whereType<ToolCallPart>()
.where((c) => !answered.contains(c.toolCallId))
.toList();
if (dangling.isEmpty) continue;
changed = true;
out.add(
AiMessage(
id: _newId(),
role: AiRole.tool,
parts: <AiPart>[
for (final call in dangling)
ToolResultPart(
toolCallId: call.toolCallId,
isError: true,
result: 'Cancelled: the turn was interrupted before this tool '
'call produced a result.',
),
],
),
);
}
return changed ? conversation.copyWith(messages: out) : conversation;
}

/// Tool calls in the latest assistant message that have no matching
/// [ToolResultPart] anywhere in the transcript yet.
/// A stable identity for a tool call — name plus JSON-encoded args — used to
Expand Down
2 changes: 1 addition & 1 deletion packages/flutter_ai_client/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: flutter_ai_client
description: "Provider-agnostic chat controller for flutter_ai: wraps any LlmProvider with optimistic send, cancellation, regeneration, and frame-batched streaming."
version: 0.3.0
version: 0.3.1
repository: https://github.com/ananmouaz/flutter_ai/tree/main/packages/flutter_ai_client
issue_tracker: https://github.com/ananmouaz/flutter_ai/issues
homepage: https://github.com/ananmouaz/flutter_ai
Expand Down
97 changes: 97 additions & 0 deletions packages/flutter_ai_client/test/use_chat_controller_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,103 @@ void main() {
hasLength(1),
);
});

test('submit settles dangling tool calls with synthesized error results',
() async {
// Turn 1 ends with an unanswered tool call (manual mode, never
// executed). Submitting a new user message must first append error
// results for it — providers reject a history containing a tool call
// with no following result.
final provider = _ToolThenTextProvider();
final controller = UseChatController(
provider: provider,
scheduler: syncScheduler,
idGenerator: seqIds(),
);
addTearDown(controller.dispose);

await controller.sendText('weather?');
await controller.sendText('never mind');

final messages = controller.messages;
// user, assistant(tool call), tool(synthesized error), user, assistant.
expect(messages.map((m) => m.role).toList(), [
AiRole.user,
AiRole.assistant,
AiRole.tool,
AiRole.user,
AiRole.assistant,
]);
final settled = messages[2].parts.whereType<ToolResultPart>().single;
expect(settled.toolCallId, 'c1');
expect(settled.isError, isTrue);
});

test('submit settles dangling tool calls buried mid-history', () async {
// A rehydrated transcript where an interrupted agent loop left an
// unanswered tool call in the MIDDLE of the history (later turns
// completed normally). The settle must insert the synthesized result
// directly after the affected assistant message — providers require the
// result in the immediately-following turn, so appending at the end
// would not fix the request.
const dirty = AiConversation(
id: 'thread-dirty',
messages: [
AiMessage(id: 'u1', role: AiRole.user, parts: [TextPart('q1')]),
AiMessage(
id: 'a1',
role: AiRole.assistant,
parts: [
ToolCallPart(
toolCallId: 'c9',
toolName: 'get_week_schedule',
args: {},
),
],
status: AiMessageStatus.complete,
),
AiMessage(id: 'u2', role: AiRole.user, parts: [TextPart('q2')]),
AiMessage(
id: 'a2',
role: AiRole.assistant,
parts: [TextPart('answer 2')],
status: AiMessageStatus.complete,
),
],
);
final provider = ScriptedProvider(const [
MessageStarted(messageId: 'a3', role: AiRole.assistant),
TextDelta(messageId: 'a3', delta: 'answer 3'),
MessageFinished(messageId: 'a3', reason: FinishReason.stop),
]);
final controller = UseChatController(
provider: provider,
scheduler: syncScheduler,
idGenerator: seqIds(),
initial: dirty,
);
addTearDown(controller.dispose);

await controller.sendText('q3');

final messages = controller.messages;
// u1, a1(tool call), tool(synthesized), u2, a2, u3, a3.
expect(messages.map((m) => m.role).toList(), [
AiRole.user,
AiRole.assistant,
AiRole.tool,
AiRole.user,
AiRole.assistant,
AiRole.user,
AiRole.assistant,
]);
final settled = messages[2].parts.whereType<ToolResultPart>().single;
expect(settled.toolCallId, 'c9');
expect(settled.isError, isTrue);
// The provider must have been sent the settled history too.
final sent = provider.lastConversation!.messages;
expect(sent[2].role, AiRole.tool);
});
});

group('threads', () {
Expand Down
32 changes: 32 additions & 0 deletions packages/flutter_ai_elements/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
# Changelog

## 0.3.0

Accessibility, internationalization, and cross-fork compile fixes.

- Fix: `aiLightHaptic` compiles against both upstream Flutter and the OHOS
Flutter fork. The exhaustive `TargetPlatform` switch could not cover both
enums at once (the fork adds `TargetPlatform.ohos`), so it is now an
iOS/Android allowlist. Thanks to @HeZeBang. (#149)
- Fix: strings that bypassed `AiLocalizations` are now localizable — tool-call
section labels (`toolArguments`/`toolResult`/`toolError`), the context meter
(`contextLabel`), source expand/collapse (`moreSources`/`showLess`), live
voice statuses (`liveConnecting`/`liveListening`/`liveThinking`/
`liveSpeaking`), the empty-state headline (`emptyStateTitle`), the inline
citation and model-selector semantics labels (`citation`/`selectModel`), and
the attachment remove badge (`removeAttachment`). (#128)
- Fix: touch targets below the 44px platform minimum — the attachment remove
badge and `AiBranch`'s navigation arrows now use `InkResponse` over a 44px
box, which also gives them focus traversal and keyboard activation.
`AiModelSelector`'s trigger moves from `GestureDetector` to `InkWell` for the
same reason. (#128)
- Fix: the caution-tone confirm button painted white on amber (~2.2:1). The
label color is now derived from the fill's brightness, and the buttons use a
`minHeight` instead of a fixed 40px height so labels are not clipped at large
text scales. (#128)
- Fix: RTL gaps — the streaming loader alignment, the "New chat" label, and the
Markdown blockquote's border/padding are now directional. (#128)
- **Potentially source-breaking:** `AiChainOfThought.title`,
`AiContextMeter.label`, `AiEmptyState.title`, and `AiComposer.hintText` are
now `String?` and default to the matching `AiLocalizations` value. Passing a
string still works; only reading one of these fields as a non-nullable
`String` needs a change.

## 0.2.0

Extensibility release — driven by dogfooding a full Gemini-clone app on the
Expand Down
64 changes: 64 additions & 0 deletions packages/flutter_ai_elements/lib/src/l10n/ai_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,20 @@ class AiLocalizations {
this.loading = 'Loading',
this.you = 'You',
this.assistant = 'Assistant',
this.toolArguments = 'Arguments',
this.toolResult = 'Result',
this.toolError = 'Error',
this.contextLabel = 'Context',
this.showLess = 'Show less',
this.moreSources = _defaultMoreSources,
this.removeAttachment = 'Remove attachment',
this.liveConnecting = 'Connecting…',
this.liveListening = 'Listening',
this.liveThinking = 'Thinking…',
this.liveSpeaking = 'Speaking',
this.emptyStateTitle = 'Start the conversation',
this.citation = _defaultCitation,
this.selectModel = _defaultSelectModel,
});

/// Copy-to-clipboard action.
Expand Down Expand Up @@ -138,6 +152,56 @@ class AiLocalizations {
/// Avatar accessibility label for the assistant.
final String assistant;

/// Section title for a tool call's arguments.
final String toolArguments;

/// Section title for a tool call's successful result.
final String toolResult;

/// Section title for a tool call's error result.
final String toolError;

/// Leading label on the context-window meter.
final String contextLabel;

/// Collapse action on a list that was expanded (e.g. sources).
final String showLess;

/// Expand action on a collapsed source list, given the hidden chip count —
/// a callback rather than a string so translations can decline the number.
final String Function(int hiddenCount) moreSources;

/// Accessibility label on the remove badge of a staged attachment.
final String removeAttachment;

/// Live-voice status while the session is connecting.
final String liveConnecting;

/// Live-voice status while the mic is open.
final String liveListening;

/// Live-voice status while the model is generating.
final String liveThinking;

/// Live-voice status while the model is talking.
final String liveSpeaking;

/// Default headline on the empty conversation state.
final String emptyStateTitle;

/// Accessibility label for an inline citation badge, given its number.
final String Function(int number) citation;

/// Accessibility label for the model selector, given the current model.
final String Function(String modelLabel) selectModel;

static String _defaultMoreSources(int hiddenCount) => '+$hiddenCount more';

static String _defaultCitation(int number) => 'Citation $number';

static String _defaultSelectModel(String modelLabel) =>
'Select model, $modelLabel';

/// The nearest [AiLocalizations]. Resolution order: an [AiLocalizationsScope]
/// in the tree (the simplest way to override — no delegate wiring), then a
/// `Localizations` delegate, then the English defaults.
Expand Down
21 changes: 14 additions & 7 deletions packages/flutter_ai_elements/lib/src/widgets/ai_branch.dart
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,21 @@ class _Arrow extends StatelessWidget {
button: true,
enabled: onTap != null,
label: label,
child: GestureDetector(
// InkResponse over a 44px box: the arrow paints at 18px, but branch
// navigation needs a platform-minimum touch target — plus focus
// traversal and keyboard activation, which a GestureDetector lacks.
child: InkResponse(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
icon,
size: 18,
color: onTap == null ? color?.withValues(alpha: 0.3) : color,
radius: 22,
child: SizedBox(
width: 44,
height: 44,
child: Center(
child: Icon(
icon,
size: 18,
color: onTap == null ? color?.withValues(alpha: 0.3) : color,
),
),
),
),
Expand Down
Loading
Loading