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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
50 changes: 50 additions & 0 deletions 03-human-in-the-loop/apps/calaverita_app/lib/app/app.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import 'package:app_ui/app_ui.dart';
import 'package:assistant_presentation/assistant_presentation.dart';
import 'package:calaverita_app/app/app_config.dart';
import 'package:calaverita_data/calaverita_data.dart';
import 'package:calaverita_presentation/calaverita_presentation.dart';
import 'package:copilotkit_flutter/copilotkit_flutter.dart';

/// Composes the calaverita and assistant features into an app.
class App extends StatelessWidget {
const App({required this.skullArt, super.key});

/// The SVGs of the skull, loaded at startup.
final SkullArt skullArt;

static const _suggestions = [
'Ana is a pilot, make it sarcastic',
'Give the skull a sombrero and teal color',
'Write a calaverita for Mario, a chef',
];

@override
Widget build(BuildContext context) {
return CopilotKit(
runtimeUrl: AppConfig.runtimeUrl,
headers: AppConfig.headers,
child: MaterialApp(
title: 'Calaverita Generator',
debugShowCheckedModeBanner: false,
theme: AppTheme.light,
// One agent for the whole screen: the details and the stage read
// its shared state, and the chat talks to it.
home: CopilotAgentScope(
agentId: AppConfig.agentId,
child: CalaveritaModule(
calaveritasRepository: const TemplateCalaveritasRepository(),
skullArt: skullArt,
// The tool sits around the chat, so the cards of its calls draw
// there.
trailingPanel: const ShowCalaveritaTool(
child: ChatModule(
agentId: AppConfig.agentId,
suggestions: _suggestions,
),
),
),
),
),
);
}
}
56 changes: 56 additions & 0 deletions 03-human-in-the-loop/apps/calaverita_app/lib/app/app_config.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import 'dart:math';

/// The one place that says where the agent lives.
///
/// To use the shared hosted backend, change [defaultRuntimeUrl] (or run with
/// `--dart-define=RUNTIME_URL=<url>`).
class AppConfig {
const AppConfig._();

/// The Copilot Runtime of the `runtime/` folder of this repository.
static const defaultRuntimeUrl = 'http://localhost:8200/api/copilotkit';

/// The Copilot Runtime the app talks to.
static final Uri runtimeUrl = Uri.parse(
const String.fromEnvironment(
'RUNTIME_URL',
defaultValue: defaultRuntimeUrl,
),
);

/// The agent the app chats with.
///
/// A runtime can serve several, so a different backend may use another id:
/// run with `--dart-define=AGENT_ID=<id>`.
static const agentId = String.fromEnvironment(
'AGENT_ID',
defaultValue: 'default',
);

/// The password of the shared backend, when it asks for one.
///
/// Run with `--dart-define=WORKSHOP_CODE=<code>`. A local runtime asks for
/// nothing, so leave it out there.
static const workshopCode = String.fromEnvironment('WORKSHOP_CODE');

/// A random id for this run of the app, so a server can tell sessions apart.
static final String sessionId = _randomId();

/// The headers sent with every run.
///
/// Custom names on purpose: a runtime answers the browser's preflight with
/// `Access-Control-Allow-Headers: *`, which covers them but not
/// `Authorization`. A server that does not know them ignores them.
static Future<Map<String, String>> headers() async => {
'X-Workshop-Session': sessionId,
if (workshopCode.isNotEmpty) 'X-Workshop-Code': workshopCode,
};

static String _randomId() {
final random = Random.secure();
return List.generate(
16,
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
}
}
43 changes: 43 additions & 0 deletions 03-human-in-the-loop/apps/calaverita_app/test/app_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import 'package:calaverita_app/app/app.dart';
import 'package:calaverita_presentation/calaverita_presentation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
Future<void> pumpApp(WidgetTester tester) async {
tester.view
..devicePixelRatio = 1
..physicalSize = const Size(1600, 1000);
addTearDown(tester.view.reset);
await tester.pumpWidget(const App(skullArt: SkullArt.fromTemplates({})));
await tester.pump();
}

testWidgets('shows the details, the stage and the copilot', (tester) async {
await pumpApp(tester);

expect(find.text('Details'), findsOneWidget);
expect(find.text('Copilot'), findsOneWidget);
expect(find.byKey(const Key('calaverita_name_field')), findsOneWidget);
expect(find.text('Write a calaverita for Mario, a chef'), findsOneWidget);
});

testWidgets('writes a calaverita from the details', (tester) async {
await pumpApp(tester);

await tester.enterText(
find.byKey(const Key('calaverita_name_field')),
'Ana',
);
await tester.enterText(
find.byKey(const Key('calaverita_job_field')),
'Designer',
);
await tester.pump();
await tester.tap(find.byKey(const Key('calaverita_generate_button')));
await tester.pump(const Duration(seconds: 1));
await tester.pump();

expect(find.text('Calaverita for Ana'), findsOneWidget);
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import 'package:equatable/equatable.dart';

/// One answer the agent offers in an `askUser` question.
class AskUserOption extends Equatable {
const AskUserOption({required this.id, required this.label, this.hint});

/// Reads an option from JSON, or returns `null` without an id or a label.
static AskUserOption? tryFromJson(Object? json) {
if (json is! Map<String, Object?>) return null;
final id = json['id'];
final label = json['label'];
final hint = json['hint'];
if (id is! String || id.isEmpty || label is! String || label.isEmpty) {
return null;
}
return AskUserOption(
id: id,
label: label,
hint: hint is String && hint.isNotEmpty ? hint : null,
);
}

/// What goes back to the agent when this option is picked.
final String id;

/// What the person reads on the button.
final String label;

/// A short line under the label, if the agent gave one.
final String? hint;

@override
List<Object?> get props => [id, label, hint];
}

/// The arguments of the `askUser` tool: a question and its options.
class AskUserArgs extends Equatable {
const AskUserArgs({this.question = '', this.options = const []});

factory AskUserArgs.fromJson(Map<String, Object?> json) {
final question = json['question'];
final options = json['options'];
return AskUserArgs(
question: question is String ? question : '',
options: options is List<Object?>
? [for (final option in options) ?AskUserOption.tryFromJson(option)]
: const [],
);
}

final String question;
final List<AskUserOption> options;

@override
List<Object?> get props => [question, options];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import 'package:app_ui/app_ui.dart';
import 'package:assistant_presentation/chat/ask_user/ask_user_args.dart';
import 'package:copilotkit_flutter/copilotkit_flutter.dart';

/// The generative UI of an `askUser` call: the question with its options as
/// tiles.
///
/// The tiles answer the call while it waits for the person (`respond` is set),
/// and the card flattens to show the answer once the call is complete.
class AskUserCard extends StatelessWidget {
const AskUserCard({
required this.call,
required this.respond,
this.args,
super.key,
});

/// The call to show.
final ToolCallView call;

/// Answers the call, or `null` unless it waits for the person.
final ToolCallResponder? respond;

/// The arguments of [call], or `null` while they can't be decoded yet.
final AskUserArgs? args;

@override
Widget build(BuildContext context) {
final answered = call.status == ToolCallStatus.complete;
final question = args?.question ?? '';
return AppDecisionCard(
kindLabel: 'Question',
kindIcon: AppIcons.chat,
title: question.isEmpty ? null : question,
loading: question.isEmpty && !answered,
complete: answered,
children: [
for (final option in args?.options ?? const <AskUserOption>[])
AppOptionTile(
label: option.label,
detail: option.hint,
selected: answered && call.result == option.id,
onTap: respond == null ? null : () => respond!(option.id),
),
],
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import 'package:ack/ack.dart';
import 'package:assistant_presentation/chat/ask_user/ask_user_args.dart';
import 'package:assistant_presentation/chat/ask_user/ask_user_card.dart';
import 'package:copilotkit_flutter/copilotkit_flutter.dart';
import 'package:flutter/widgets.dart';

/// Gives the agent an `askUser` tool and draws its calls as an [AskUserCard]
/// in [child], e.g. the chat.
///
/// This is human in the loop: the agent asks, its run waits, and the answer
/// the person taps goes back to the agent as the tool result. The answer is
/// the id of the option.
class AskUserTool extends StatefulWidget {
const AskUserTool({required this.child, super.key});

final Widget child;

@override
State<AskUserTool> createState() => _AskUserToolState();
}

class _AskUserToolState extends State<AskUserTool> {
/// Kept in a field: the SDK compares tools to know whether to register
/// again.
late final _tool = HumanInTheLoopTool<AskUserArgs>(
name: 'askUser',
description:
'Ask the user to pick one of a few options, then wait for the answer. '
'It comes back as the id of the option they picked. Use it for a '
'choice that is theirs to make, one question at a time.',
// Keep in sync with AskUserArgs.
parameters: Ack.object({
'question': Ack.string().describe('The question, one short sentence'),
'options': Ack.list(
Ack.object({
'id': Ack.string().describe('A short id, e.g. playful'),
'label': Ack.string().describe('What the button says'),
'hint': Ack.string().optional().describe('An optional short line'),
}),
).describe('Two to four options'),
}),
decode: AskUserArgs.fromJson,
);

@override
Widget build(BuildContext context) {
return CopilotHumanInTheLoop<AskUserArgs>(
tool: _tool,
builder: (context, call, args, respond) =>
AskUserCard(call: call, args: args, respond: respond),
child: widget.child,
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import 'package:app_ui/app_ui.dart';
import 'package:assistant_presentation/chat/ask_user/ask_user_tool.dart';
import 'package:assistant_presentation/chat/views/activity_line.dart';
import 'package:assistant_presentation/chat/views/chat_panel.dart';
import 'package:copilotkit_flutter/copilotkit_flutter.dart';

/// The chat with the agent [agentId].
///
/// It must sit below a `CopilotKit`, which connects it to the runtime.
class ChatModule extends StatelessWidget {
const ChatModule({
required this.agentId,
this.suggestions = const [],
super.key,
});

/// Id of the agent on the runtime, e.g. `default`.
final String agentId;

/// Messages offered as chips while the chat is empty.
final List<String> suggestions;

@override
Widget build(BuildContext context) {
// The scope shares its agent with any other scope of the same id, so
// this is the agent the rest of the screen reads and edits.
return CopilotAgentScope(
agentId: agentId,
child: CopilotChatProvider(
agentId: agentId,
// The agent's own tool calls (changing the shared state) show as one
// quiet line instead of raw arguments.
child: AskUserTool(
child: ToolRenderer(
name: ToolRenderer.nameFallback,
builder: (context, call) => ActivityLine(call: call),
child: ChatPanel(suggestions: suggestions),
),
),
),
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import 'package:app_ui/app_ui.dart';
import 'package:copilotkit_flutter/copilotkit_flutter.dart';

/// A quiet line for a tool call of the agent that has no card of its own.
class ActivityLine extends StatelessWidget {
const ActivityLine({required this.call, super.key});

final ToolCallView call;

@override
Widget build(BuildContext context) {
final label = switch (call.name) {
'AGUISendStateDelta' || 'AGUISendStateSnapshot' => 'Updated the form',
_ => null,
};
if (label == null) return const SizedBox.shrink();
final p = context.palette;
return Align(
alignment: Alignment.centerLeft,
child: AppChip(
label: label,
icon: AppIcons.wrench,
foreground: p.onNavyMuted,
background: p.onNavyFill,
),
);
}
}
Loading
Loading