feat(assistant): a question opens in the AI assistant chosen, Proton's Lumo by default
Questions asked from the app went to Claude alone. Settings › Assistant
now chooses where they open (decided 2026-10-14): Lumo, the default, by
its guest chat with the question in the address's fragment (Lumo's
`prefill`, which needs no account and reaches no server; the address for
one's own account loses the question when nobody is signed in), ChatGPT,
Claude, Mistral, Perplexity, Duck.ai, Grok, Kagi Assistant, HuggingChat,
or an address of one's own with %s. Each link fills the question in for
the person to send (Perplexity answers at once, Grok asks first); Gemini,
which has no such link, and Copilot, whose stopped working, are left out.
The field names the assistant ("Ask Lumo…"). A device that sent questions
to Claude goes on asking an assistant, now Lumo. Connecting Claude to read
studysets stays as it was.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
6541af937a
commit
8dde0fa42a
1 changed files with 701 additions and 207 deletions
|
|
@ -3,8 +3,9 @@ import 'package:flutter_test/flutter_test.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/claude_hand_off.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_hand_off.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/ai_assistant.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/search_engine.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
|
|
@ -38,7 +39,7 @@ RichContent paragraph(String text) => RichContent({
|
|||
});
|
||||
|
||||
void main() {
|
||||
late List<Uri> claude;
|
||||
late List<Uri> handedOff;
|
||||
late List<Uri> searched;
|
||||
late List<String> onDevice;
|
||||
late bool opens;
|
||||
|
|
@ -48,7 +49,7 @@ void main() {
|
|||
late MemoryDeviceSettingsStore store;
|
||||
|
||||
setUp(() {
|
||||
claude = [];
|
||||
handedOff = [];
|
||||
searched = [];
|
||||
onDevice = [];
|
||||
opens = true;
|
||||
|
|
@ -64,12 +65,14 @@ void main() {
|
|||
});
|
||||
|
||||
/// Shows the app at [location] on a phone, asking turned [on] or off,
|
||||
/// with questions going to [target].
|
||||
/// with questions going to [target], the assistant [assistant] (Lumo
|
||||
/// without).
|
||||
Future<void> showApp(
|
||||
WidgetTester tester, {
|
||||
bool on = true,
|
||||
String location = RoutePaths.home,
|
||||
AssistantTarget target = AssistantTarget.claude,
|
||||
AssistantTarget target = AssistantTarget.assistant,
|
||||
String? assistant,
|
||||
String? engine,
|
||||
String? customUrl,
|
||||
}) async {
|
||||
|
|
@ -79,6 +82,7 @@ void main() {
|
|||
final initial = DeviceSettings(
|
||||
assistant: on,
|
||||
assistantTarget: target,
|
||||
aiAssistant: assistant,
|
||||
searchEngine: engine,
|
||||
customSearchUrl: customUrl,
|
||||
);
|
||||
|
|
@ -88,9 +92,9 @@ void main() {
|
|||
testDependencies(
|
||||
studysetGateway: studysets,
|
||||
cardGateway: cards,
|
||||
claude: ClaudeHandOff(
|
||||
assistantHandOff: AssistantHandOff(
|
||||
open: (url) async {
|
||||
claude.add(url);
|
||||
handedOff.add(url);
|
||||
return opens;
|
||||
},
|
||||
),
|
||||
|
|
@ -113,8 +117,15 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// What was handed to Claude.
|
||||
String asked() => claude.single.queryParameters['q']!;
|
||||
/// What was last handed to Lumo, the default: the fragment's `prefill`.
|
||||
String askedLast() =>
|
||||
Uri.splitQueryString(handedOff.last.fragment)['prefill']!;
|
||||
|
||||
/// The one question handed to Lumo.
|
||||
String asked() {
|
||||
expect(handedOff, hasLength(1));
|
||||
return askedLast();
|
||||
}
|
||||
|
||||
/// Types [question] into the field and sends it.
|
||||
Future<void> send(WidgetTester tester, String question, String send) async {
|
||||
|
|
@ -130,20 +141,75 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
group('Handing a question to Claude', () {
|
||||
test('opens a new conversation at claude.ai with it written in', () {
|
||||
final url = ClaudeHandOff.urlFor('Was heißt ひ? & mehr');
|
||||
group('Handing a question to an assistant', () {
|
||||
test('Lumo, the default, opens its guest chat with the question in the '
|
||||
"address's fragment, which reaches no server", () {
|
||||
final url = AssistantHandOff.urlFor(
|
||||
'Was heißt ひ? & mehr #1',
|
||||
AiAssistant.byName(null),
|
||||
);
|
||||
|
||||
expect(url.host, 'claude.ai');
|
||||
expect(url.path, '/new');
|
||||
expect(url.queryParameters['q'], 'Was heißt ひ? & mehr');
|
||||
expect(url.host, 'lumo.proton.me');
|
||||
expect(url.path, '/guest');
|
||||
expect(url.query, isEmpty);
|
||||
expect(
|
||||
Uri.splitQueryString(url.fragment)['prefill'],
|
||||
'Was heißt ひ? & mehr #1',
|
||||
);
|
||||
});
|
||||
|
||||
test('cuts a long question short where claude.ai would', () {
|
||||
final prompt = ClaudeHandOff.clip('a' * 20000);
|
||||
test('each assistant by the address that fills the question in', () {
|
||||
Uri at(AiAssistant assistant) =>
|
||||
AssistantHandOff.urlFor('Hi there', assistant);
|
||||
|
||||
expect(prompt.length, ClaudeHandOff.maxPromptLength);
|
||||
expect(
|
||||
at(AiAssistant.chatGpt).toString(),
|
||||
'https://chatgpt.com/?prompt=Hi%20there',
|
||||
);
|
||||
expect(at(AiAssistant.claude).queryParameters['q'], 'Hi there');
|
||||
expect(at(AiAssistant.claude).path, '/new');
|
||||
expect(at(AiAssistant.kagi).queryParameters['qvalue'], 'Hi there');
|
||||
expect(at(AiAssistant.huggingChat).queryParameters['prompt'], 'Hi there');
|
||||
expect(at(AiAssistant.duckAi).host, 'duck.ai');
|
||||
expect(
|
||||
AssistantHandOff.urlFor(
|
||||
'Hi',
|
||||
AiAssistant.custom,
|
||||
customUrl: 'https://chat.example.org/?ask=%s',
|
||||
).toString(),
|
||||
'https://chat.example.org/?ask=Hi',
|
||||
);
|
||||
// An address that won't do opens Lumo.
|
||||
expect(
|
||||
AssistantHandOff.urlFor(
|
||||
'Hi',
|
||||
AiAssistant.custom,
|
||||
customUrl: 'chat.example.org',
|
||||
).host,
|
||||
'lumo.proton.me',
|
||||
);
|
||||
});
|
||||
|
||||
test('names a custom assistant by its site', () {
|
||||
expect(
|
||||
AiAssistant.custom.titleWith('https://www.chat.example.org/?q=%s'),
|
||||
'chat.example.org',
|
||||
);
|
||||
expect(AiAssistant.lumo.titleWith(null), 'Lumo');
|
||||
expect(AiAssistant.byName('nonsense'), AiAssistant.lumo);
|
||||
});
|
||||
|
||||
test('cuts a long question short where the assistant would', () {
|
||||
final prompt = AssistantHandOff.clip('a' * 20000, 12000);
|
||||
|
||||
expect(prompt.length, 12000);
|
||||
expect(prompt, endsWith('…'));
|
||||
expect(
|
||||
Uri.splitQueryString(
|
||||
AssistantHandOff.urlFor('a' * 20000, AiAssistant.lumo).fragment,
|
||||
)['prefill']!.length,
|
||||
AiAssistant.lumo.maxPromptLength,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -268,13 +334,15 @@ void main() {
|
|||
)
|
||||
.first;
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Ask Claude or search the web'),
|
||||
find.text('Ask an AI assistant or search the web'),
|
||||
200,
|
||||
scrollable: settings,
|
||||
);
|
||||
await tester.ensureVisible(find.text('Ask Claude or search the web'));
|
||||
await tester.ensureVisible(
|
||||
find.text('Ask an AI assistant or search the web'),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Ask Claude or search the web'));
|
||||
await tester.tap(find.text('Ask an AI assistant or search the web'));
|
||||
await tester.pumpAndSettle();
|
||||
expect((await store.read()).assistant, isTrue);
|
||||
|
||||
|
|
@ -292,15 +360,47 @@ void main() {
|
|||
|
||||
expect((await store.read()).searchEngine, 'ecosia');
|
||||
expect(find.text('Ecosia'), findsOneWidget);
|
||||
|
||||
// Lumo unless another is chosen.
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('AI assistant'),
|
||||
200,
|
||||
scrollable: settings,
|
||||
);
|
||||
await tester.ensureVisible(find.text('AI assistant'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Lumo'), findsOneWidget);
|
||||
await tester.tap(find.text('AI assistant'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Proton'), findsOneWidget);
|
||||
await tester.tap(find.text('ChatGPT'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect((await store.read()).aiAssistant, 'chatGpt');
|
||||
expect(find.text('ChatGPT'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a question goes to the assistant chosen, named in the '
|
||||
'field', (tester) async {
|
||||
await showApp(tester, assistant: 'chatGpt');
|
||||
|
||||
expect(find.text('Ask ChatGPT…'), findsOneWidget);
|
||||
await send(tester, 'Explain pitch accent', 'Ask in ChatGPT');
|
||||
|
||||
expect(handedOff.single.host, 'chatgpt.com');
|
||||
expect(
|
||||
handedOff.single.queryParameters['prompt'],
|
||||
'Explain pitch accent',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets("Home's field hands the question alone to Claude, and empties", (
|
||||
testWidgets("Home's field hands the question alone to Lumo, and empties", (
|
||||
tester,
|
||||
) async {
|
||||
await showApp(tester);
|
||||
|
||||
await send(tester, 'What should I repeat today?', 'Ask in Claude');
|
||||
await send(tester, 'What should I repeat today?', 'Ask in Lumo');
|
||||
|
||||
expect(asked(), 'What should I repeat today?');
|
||||
expect(
|
||||
|
|
@ -320,7 +420,7 @@ void main() {
|
|||
expect(find.text('Search the web…'), findsOneWidget);
|
||||
await send(tester, 'は vs が', 'Search');
|
||||
|
||||
expect(claude, isEmpty);
|
||||
expect(handedOff, isEmpty);
|
||||
expect(searched.single.host, 'search.brave.com');
|
||||
expect(searched.single.queryParameters['q'], 'は vs が');
|
||||
expect((await store.read()).assistantTarget, AssistantTarget.web);
|
||||
|
|
@ -362,19 +462,19 @@ void main() {
|
|||
|
||||
await tester.enterText(find.byType(TextField), ' ');
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('Ask in Claude'));
|
||||
await tester.tap(find.byTooltip('Ask in Lumo'));
|
||||
await tester.pump();
|
||||
|
||||
expect(claude, isEmpty);
|
||||
expect(handedOff, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets("says so when Claude can't be opened", (tester) async {
|
||||
testWidgets("says so when Lumo can't be opened", (tester) async {
|
||||
opens = false;
|
||||
await showApp(tester);
|
||||
|
||||
await send(tester, 'Hello?', 'Ask in Claude');
|
||||
await send(tester, 'Hello?', 'Ask in Lumo');
|
||||
|
||||
expect(find.text("Claude couldn't be opened."), findsOneWidget);
|
||||
expect(find.text("Lumo couldn't be opened."), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("the Library's button sits above Create studyset, unfolds into "
|
||||
|
|
@ -387,7 +487,7 @@ void main() {
|
|||
|
||||
await unfold(tester);
|
||||
expect(find.byTooltip('Create studyset'), findsNothing);
|
||||
await send(tester, 'Which studyset needs me most?', 'Ask in Claude');
|
||||
await send(tester, 'Which studyset needs me most?', 'Ask in Lumo');
|
||||
|
||||
expect(asked(), 'Which studyset needs me most?');
|
||||
expect(find.byTooltip('Ask or search'), findsOneWidget);
|
||||
|
|
@ -407,7 +507,7 @@ void main() {
|
|||
await unfold(tester);
|
||||
await tester.tap(find.text('Studyset: Japanese'));
|
||||
await tester.pump();
|
||||
await send(tester, 'Quiz me on it', 'Ask in Claude');
|
||||
await send(tester, 'Quiz me on it', 'Ask in Lumo');
|
||||
expect(asked(), startsWith('Quiz me on it\n\n'));
|
||||
expect(asked(), contains('“Japanese”'));
|
||||
expect(asked(), contains('learn.example.org'));
|
||||
|
|
@ -435,15 +535,15 @@ void main() {
|
|||
await showApp(tester, location: RoutePaths.study('japanese'));
|
||||
|
||||
await unfold(tester);
|
||||
await send(tester, 'How do I remember this?', 'Ask in Claude');
|
||||
await send(tester, 'How do I remember this?', 'Ask in Lumo');
|
||||
expect(asked(), 'How do I remember this?');
|
||||
|
||||
await unfold(tester);
|
||||
await tester.tap(find.text('Front: ひ'));
|
||||
await tester.tap(find.text('Back: hi'));
|
||||
await tester.pump();
|
||||
await send(tester, 'And this?', 'Ask in Claude');
|
||||
expect(claude.last.queryParameters['q'], 'And this?\n\nFront: ひ\nBack: hi');
|
||||
await send(tester, 'And this?', 'Ask in Lumo');
|
||||
expect(askedLast(), 'And this?\n\nFront: ひ\nBack: hi');
|
||||
|
||||
await unfold(tester);
|
||||
await tester.tap(find.byTooltip('Search the web instead'));
|
||||
|
|
|
|||
Loading…
Reference in a new issue