feat(learn): the assistant's field laid out as Claude's, with files attached and questions spoken, and no Check (ADR 0021, D67)

The question's text stands above its buttons: + attaching files, who
answers as a chip (Effort now a submenu of it), the conversations kept,
and the microphone while nothing is written, which gives way to the
button sending. What goes along is one row above the text, opening a
sheet with how far the assistant looks and the parts to send. A
conversation's follow-up has the same + and microphone.

Files are read on the device: an image goes as itself to an assistant
that reads images, a PDF as its pages' text, a text file as its text,
each cut at 20,000 characters and labelled by its name after the
question.

Voice is the device's own speech recognition (speech_to_text: Android,
Apple, Windows; none on Linux), in the app's language, the microphone
asked for once tapped. The privacy page says that Android's recogniser
may send the sound to Google or the phone's maker.

The field's Check goes, with its LanguageTool sheet (amending ADR 0024);
the editors' underlining and Why? stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-02 05:50:36 +01:00
commit 447e00c52a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,152 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_attachments.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_files.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_prompt.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
import '../../../support/fake_pdf_engine.dart';
void main() {
late FakePdfEngine pdfs;
late AssistantAttachments attachments;
setUp(() {
pdfs = FakePdfEngine();
attachments = AssistantAttachments(pdfs);
});
Uint8List bytes(String text) => Uint8List.fromList(utf8.encode(text));
test('a text file goes as its text, under its name', () async {
final read = await attachments.read((
name: 'notes.md',
bytes: bytes('# て-form\nJoins clauses.'),
), images: false);
expect(read.refused, isNull);
expect(read.part!.kind, AssistantPartKind.file);
expect(read.part!.name, 'notes.md');
expect(read.part!.text, '# て-form\nJoins clauses.');
expect(read.part!.image, isNull);
});
test("a PDF goes as its pages' text, as PDFium reads it", () async {
pdfs.texts
..[1] = 'Chapter one.'
..[2] = ' '
..[3] = 'Chapter three.';
final read = await attachments.read((
name: 'Grammar.PDF',
bytes: bytes('%PDF-1.7 pages:3'),
), images: false);
expect(read.part!.text, 'Chapter one.\n\nChapter three.');
});
test(
"a file the app doesn't read, or binary named as text, is refused",
() async {
expect(
(await attachments.read((
name: 'song.mp3',
bytes: bytes('ID3'),
), images: true)).refused,
AttachmentRefusal.notRead,
);
expect(
(await attachments.read((
name: 'broken.pdf',
bytes: bytes('not a pdf'),
), images: true)).refused,
AttachmentRefusal.notRead,
);
expect(
(await attachments.read((
name: 'data.txt',
bytes: Uint8List.fromList([0, 1, 2]),
), images: true)).refused,
AttachmentRefusal.notRead,
);
},
);
test('an image goes as itself, only to an assistant reading images, and '
'only as large as providers take', () async {
final photo = Uint8List.fromList([137, 80, 78, 71]);
final read = await attachments.read((
name: 'board.JPG',
bytes: photo,
), images: true);
expect(read.part!.image!.mediaType, 'image/jpeg');
expect(read.part!.image!.bytes, photo);
expect(
(await attachments.read((
name: 'board.png',
bytes: photo,
), images: false)).refused,
AttachmentRefusal.noImages,
);
expect(
(await attachments.read((
name: 'huge.png',
bytes: Uint8List(DeviceAssistantFiles.maxImageBytes + 1),
), images: true)).refused,
AttachmentRefusal.tooLarge,
);
});
test('the picker offers images only where the assistant reads them', () {
expect(AssistantAttachments.extensionsFor(images: true), contains('png'));
expect(
AssistantAttachments.extensionsFor(images: false),
isNot(contains('png')),
);
expect(AssistantAttachments.extensionsFor(images: false), contains('pdf'));
});
test('an attached file goes after the question, labelled by its name, a '
'long one cut, and is read back from the kept turn', () {
final long = AssistantPart(
kind: AssistantPartKind.file,
text: 'x' * 30000,
name: 'long.txt',
);
final image = LlmImage(
mediaType: 'image/png',
bytes: Uint8List.fromList(const [137, 80, 78, 71]),
);
final message = questionMessage('What is this?', [
long,
AssistantPart(
kind: AssistantPartKind.file,
text: '',
name: 'board.png',
image: image,
),
]);
final texts = [
for (final LlmText(:text) in message.parts.whereType<LlmText>()) text,
];
expect(texts[1], startsWith('\n\nThe attached file “long.txt”:\nxxx'));
expect(texts[1].length, lessThan(AssistantPart.maxFileLength + 50));
expect(texts[2], '\n\nThe attached file “board.png”:\n');
expect(message.parts.last, image);
final asked = askedIn(withoutImages([message]).single);
expect(asked.question, 'What is this?');
expect(
[for (final part in asked.parts) part.name],
[
'long.txt',
'board.png',
],
);
expect(asked.parts.first.kind, AssistantPartKind.file);
});
}