功能集成
除了 LlmChatView
自动提供的功能外,还有许多集成点使您的应用能够与其他功能无缝融合,以提供额外的功能。
- 欢迎消息:向用户显示初始问候语。
- 建议提示:向用户提供预定义提示以指导交互。
- 系统指令:向 LLM 提供特定输入以影响其响应。
- 禁用附件和音频输入:移除聊天 UI 中可选的部分。
- 管理取消或错误行为:更改用户取消或 LLM 错误行为。
- 管理历史记录:每个 LLM 提供程序都允许管理聊天历史记录,这对于清除、动态更改以及在会话之间存储历史记录非常有用。
- 聊天序列化/反序列化:在应用会话之间存储和检索对话。
- 自定义响应组件:引入专门的 UI 组件来呈现 LLM 响应。
- 自定义样式:定义独特的视觉样式,使聊天外观与整个应用匹配。
- 无 UI 聊天:直接与 LLM 提供程序交互,而不影响用户的当前聊天会话。
- 自定义 LLM 提供程序:构建您自己的 LLM 提供程序,以便将聊天与您自己的模型后端集成。
- 重新路由提示:调试、记录或重新路由发送给提供程序的消息,以追踪问题或动态路由提示。
欢迎消息
#聊天视图允许您提供自定义的欢迎消息,为用户设置上下文。
您可以通过设置 `welcomeMessage` 参数来初始化带有欢迎消息的 `LlmChatView`。
class ChatPage extends StatelessWidget {
const ChatPage({super.key});
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text(App.title)),
body: LlmChatView(
welcomeMessage: 'Hello and welcome to the Flutter AI Toolkit!',
provider: GeminiProvider(
model: GenerativeModel(
model: 'gemini-2.0-flash',
apiKey: geminiApiKey,
),
),
),
);
}
要查看设置欢迎消息的完整示例,请参阅欢迎示例。
建议提示
#您可以提供一组建议提示,让用户了解聊天会话已针对哪些内容进行了优化。
建议仅在没有现有聊天历史记录时显示。单击其中一个会将文本复制到用户的提示编辑区。要设置建议列表,请使用 `suggestions` 参数构造 `LlmChatView`。
class ChatPage extends StatelessWidget {
const ChatPage({super.key});
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text(App.title)),
body: LlmChatView(
suggestions: [
'I\'m a Star Wars fan. What should I wear for Halloween?',
'I\'m allergic to peanuts. What candy should I avoid at Halloween?',
'What\'s the difference between a pumpkin and a squash?',
],
provider: GeminiProvider(
model: GenerativeModel(
model: 'gemini-2.0-flash',
apiKey: geminiApiKey,
),
),
),
);
}
要查看为用户设置建议的完整示例,请参阅建议示例。
LLM 指令
#为了根据您的应用需求优化 LLM 的响应,您需要为其提供指令。例如,食谱示例应用使用 `GenerativeModel` 类的 `systemInstructions` 参数来调整 LLM,使其专注于根据用户的指令提供食谱。
class _HomePageState extends State<HomePage> {
...
// create a new provider with the given history and the current settings
LlmProvider _createProvider([List<ChatMessage>? history]) => GeminiProvider(
history: history,
...,
model: GenerativeModel(
model: 'gemini-2.0-flash',
apiKey: geminiApiKey,
...,
systemInstruction: Content.system('''
You are a helpful assistant that generates recipes based on the ingredients and
instructions provided as well as my food preferences, which are as follows:
${Settings.foodPreferences.isEmpty ? 'I don\'t have any food preferences' : Settings.foodPreferences}
You should keep things casual and friendly. You may generate multiple recipes in a single response, but only if asked. ...
''',
),
),
);
...
}
系统指令的设置对于每个提供程序都是独有的;`GeminiProvider` 和 `VertexProvider` 都允许您通过 `systemInstruction` 参数提供它们。
请注意,在这种情况下,我们将用户偏好作为传递给 `LlmChatView` 构造函数的 LLM 提供程序创建过程的一部分引入。每次用户更改偏好时,我们都会在创建过程中设置指令。食谱应用允许用户使用脚手架上的抽屉更改他们的食物偏好。
每当用户更改他们的食物偏好时,食谱应用都会创建一个新模型来使用新的偏好。
class _HomePageState extends State<HomePage> {
...
void _onSettingsSave() => setState(() {
// move the history over from the old provider to the new one
final history = _provider.history.toList();
_provider = _createProvider(history);
});
}
禁用附件和音频输入
#如果您想禁用附件(**+** 按钮)或音频输入(麦克风按钮),您可以通过 `LlmChatView` 构造函数的 `enableAttachments` 和 `enableVoiceNotes` 参数来实现。
class ChatPage extends StatelessWidget {
const ChatPage({super.key});
@override
Widget build(BuildContext context) {
// ...
return Scaffold(
appBar: AppBar(title: const Text('Restricted Chat')),
body: LlmChatView(
// ...
enableAttachments: false,
enableVoiceNotes: false,
),
);
}
}
这两个标志默认都为 `true`。
管理取消或错误行为
#默认情况下,当用户取消 LLM 请求时,LLM 的响应将附加字符串“CANCEL”,并弹出一个消息提示用户已取消请求。同样,如果发生 LLM 错误(例如网络连接断开),LLM 的响应将附加字符串“ERROR”,并弹出一个包含错误详细信息的警报对话框。
您可以使用 `LlmChatView` 的 `cancelMessage`、`errorMessage`、`onCancelCallback` 和 `onErrorCallback` 参数覆盖取消和错误行为。例如,以下代码替换了默认的取消处理行为。
class ChatPage extends StatelessWidget {
// ...
void _onCancel(BuildContext context) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Chat cancelled')));
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text(App.title)),
body: LlmChatView(
// ...
onCancelCallback: _onCancel,
cancelMessage: 'Request cancelled',
),
);
}
您可以覆盖其中任何或所有参数,`LlmChatView` 将对您未覆盖的任何内容使用其默认值。
管理历史记录
#可插入聊天视图的定义所有 LLM 提供程序的标准接口包括获取和设置提供程序历史记录的功能。
abstract class LlmProvider implements Listenable {
Stream<String> generateStream(
String prompt, {
Iterable<Attachment> attachments,
});
Stream<String> sendMessageStream(
String prompt, {
Iterable<Attachment> attachments,
});
Iterable<ChatMessage> get history;
set history(Iterable<ChatMessage> history);
}
当提供程序的历史记录更改时,它会调用 `Listenable` 基类公开的 `notifyListener` 方法。这意味着您可以手动使用 `add` 和 `remove` 方法订阅/取消订阅,或使用它来构造 `ListenableBuilder` 类的实例。
`generateStream` 方法调用底层 LLM,而不影响历史记录。调用 `sendMessageStream` 方法会在响应完成时向提供程序的历史记录添加两条新消息(一条用于用户消息,一条用于 LLM 响应),从而更改历史记录。聊天视图在处理用户的聊天提示时使用 `sendMessageStream`,在处理用户的语音输入时使用 `generateStream`。
要查看或设置历史记录,您可以访问 `history` 属性。
void _clearHistory() => _provider.history = [];
访问提供程序历史记录的能力在重新创建提供程序同时保持历史记录时也很有用。
class _HomePageState extends State<HomePage> {
...
void _onSettingsSave() => setState(() {
// move the history over from the old provider to the new one
final history = _provider.history.toList();
_provider = _createProvider(history);
});
}
`_createProvider` 方法创建一个新的提供程序,其中包含来自先前提供程序的历史记录*和*新的用户偏好设置。这对用户来说是无缝的;他们可以继续聊天,但现在 LLM 会根据他们新的食物偏好给出响应。例如:
class _HomePageState extends State<HomePage> {
...
// create a new provider with the given history and the current settings
LlmProvider _createProvider([List<ChatMessage>? history]) =>
GeminiProvider(
history: history,
...
);
...
}
要查看历史记录的实际应用,请参阅食谱示例应用和历史记录示例应用。
聊天序列化/反序列化
#要在应用会话之间保存和恢复聊天历史记录,需要能够序列化和反序列化每个用户提示(包括附件)和每个 LLM 响应。两种类型的消息(用户提示和 LLM 响应)都在 `ChatMessage` 类中公开。序列化可以通过使用每个 `ChatMessage` 实例的 `toJson` 方法来完成。
Future<void> _saveHistory() async {
// get the latest history
final history = _provider.history.toList();
// write the new messages
for (var i = 0; i != history.length; ++i) {
// skip if the file already exists
final file = await _messageFile(i);
if (file.existsSync()) continue;
// write the new message to disk
final map = history[i].toJson();
final json = JsonEncoder.withIndent(' ').convert(map);
await file.writeAsString(json);
}
}
同样,要反序列化,请使用 `ChatMessage` 类的静态 `fromJson` 方法。
Future<void> _loadHistory() async {
// read the history from disk
final history = <ChatMessage>[];
for (var i = 0;; ++i) {
final file = await _messageFile(i);
if (!file.existsSync()) break;
final map = jsonDecode(await file.readAsString());
history.add(ChatMessage.fromJson(map));
}
// set the history on the controller
_provider.history = history;
}
为了确保序列化时的快速周转,我们建议只写入每个用户消息一次。否则,用户每次都必须等待您的应用写入每条消息,而面对二进制附件,这可能需要一段时间。
要查看实际应用,请参阅历史记录示例应用。
自定义响应组件
#默认情况下,聊天视图显示的 LLM 响应是 Markdown 格式的。但是,在某些情况下,您可能希望创建一个自定义组件来显示 LLM 响应,该组件是特定于您的应用并与其集成的。例如,当用户在食谱示例应用中请求食谱时,LLM 响应会用于创建一个组件,该组件专门用于显示食谱,就像应用的其他部分一样,并提供一个**添加**按钮,以防用户想将食谱添加到他们的数据库中。
这可以通过设置 `LlmChatView` 构造函数的 `responseBuilder` 参数来完成。
LlmChatView(
provider: _provider,
welcomeMessage: _welcomeMessage,
responseBuilder: (context, response) => RecipeResponseView(
response,
),
),
在这个特定的示例中,`RecipeReponseView` 组件是使用 LLM 提供程序的响应文本构建的,并使用该文本来实现其 `build` 方法。
class RecipeResponseView extends StatelessWidget {
const RecipeResponseView(this.response, {super.key});
final String response;
@override
Widget build(BuildContext context) {
final children = <Widget>[];
String? finalText;
// created with the response from the LLM as the response streams in, so
// many not be a complete response yet
try {
final map = jsonDecode(response);
final recipesWithText = map['recipes'] as List<dynamic>;
finalText = map['text'] as String?;
for (final recipeWithText in recipesWithText) {
// extract the text before the recipe
final text = recipeWithText['text'] as String?;
if (text != null && text.isNotEmpty) {
children.add(MarkdownBody(data: text));
}
// extract the recipe
final json = recipeWithText['recipe'] as Map<String, dynamic>;
final recipe = Recipe.fromJson(json);
children.add(const Gap(16));
children.add(Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(recipe.title, style: Theme.of(context).textTheme.titleLarge),
Text(recipe.description),
RecipeContentView(recipe: recipe),
],
));
// add a button to add the recipe to the list
children.add(const Gap(16));
children.add(OutlinedButton(
onPressed: () => RecipeRepository.addNewRecipe(recipe),
child: const Text('Add Recipe'),
));
children.add(const Gap(16));
}
} catch (e) {
debugPrint('Error parsing response: $e');
}
...
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
);
}
}
此代码解析文本以从 LLM 中提取介绍性文本和食谱,并将它们与一个**添加食谱**按钮捆绑在一起,以取代 Markdown 显示。
请注意,我们正在将 LLM 响应解析为 JSON。通常将提供程序设置为 JSON 模式,并提供一个模式来限制其响应的格式,以确保我们得到可以解析的内容。每个提供程序都以自己的方式公开此功能,但 `GeminiProvider` 和 `VertexProvider` 类都通过 `GenerationConfig` 对象启用此功能,食谱示例使用方式如下:
class _HomePageState extends State<HomePage> {
...
// create a new provider with the given history and the current settings
LlmProvider _createProvider([List<ChatMessage>? history]) => GeminiProvider(
...
model: GenerativeModel(
...
generationConfig: GenerationConfig(
responseMimeType: 'application/json',
responseSchema: Schema(...),
systemInstruction: Content.system('''
...
Generate each response in JSON format
with the following schema, including one or more "text" and "recipe" pairs as
well as any trailing text commentary you care to provide:
{
"recipes": [
{
"text": "Any commentary you care to provide about the recipe.",
"recipe":
{
"title": "Recipe Title",
"description": "Recipe Description",
"ingredients": ["Ingredient 1", "Ingredient 2", "Ingredient 3"],
"instructions": ["Instruction 1", "Instruction 2", "Instruction 3"]
}
}
],
"text": "any final commentary you care to provide",
}
''',
),
),
);
...
}
此代码通过将 `responseMimeType` 参数设置为 `'application/json'` 并将 `responseSchema` 参数设置为 `Schema` 类的一个实例来初始化 `GenerationConfig` 对象,该实例定义了您准备解析的 JSON 的结构。此外,最好在系统指令中也请求 JSON 并提供该 JSON 模式的描述,我们在这里已经这样做了。
要查看实际应用,请参阅食谱示例应用。
自定义样式
#聊天视图开箱即用,带有一组默认样式,适用于背景、文本字段、按钮、图标、建议等。您可以通过使用 `LlmChatView` 构造函数的 `style` 参数设置自己的样式来完全自定义这些样式。
LlmChatView(
provider: GeminiProvider(...),
style: LlmChatViewStyle(...),
),
例如,自定义样式示例应用使用此功能实现了一个万圣节主题的应用。
有关 `LlmChatViewStyle` 类中可用样式的完整列表,请参阅参考文档。要查看自定义样式的实际应用,除了自定义样式示例外,还可以查看深色模式示例和演示应用。
无 UI 聊天
#您不必使用聊天视图来访问底层提供程序的功能。除了能够通过其提供的任何专有接口简单地调用它之外,您还可以将其与LlmProvider 接口一起使用。
例如,食谱示例应用在编辑食谱的页面上提供了一个魔法按钮。该按钮的目的是根据您当前的食物偏好更新数据库中现有的食谱。按下该按钮,您可以预览推荐的更改并决定是否应用它们。
编辑食谱页面没有使用应用聊天部分使用的相同提供程序(这会在用户的聊天历史记录中插入虚假的用户消息和 LLM 响应),而是创建自己的提供程序并直接使用它。
class _EditRecipePageState extends State<EditRecipePage> {
...
final _provider = GeminiProvider(...);
...
Future<void> _onMagic() async {
final stream = _provider.sendMessageStream(
'Generate a modified version of this recipe based on my food preferences: '
'${_ingredientsController.text}\n\n${_instructionsController.text}',
);
var response = await stream.join();
final json = jsonDecode(response);
try {
final modifications = json['modifications'];
final recipe = Recipe.fromJson(json['recipe']);
if (!context.mounted) return;
final accept = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(recipe.title),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Modifications:'),
const Gap(16),
Text(_wrapText(modifications)),
],
),
actions: [
TextButton(
onPressed: () => context.pop(true),
child: const Text('Accept'),
),
TextButton(
onPressed: () => context.pop(false),
child: const Text('Reject'),
),
],
),
);
...
} catch (ex) {
...
}
}
}
}
对 `sendMessageStream` 的调用会在提供程序的历史记录中创建条目,但由于它未与聊天视图关联,因此不会显示。如果方便,您也可以通过调用 `generateStream` 来实现相同的目的,这允许您重用现有提供程序而不影响聊天历史记录。
要查看实际应用,请参阅食谱示例的编辑食谱页面。
重新路由提示
#如果您想调试、记录或操作聊天视图与底层提供程序之间的连接,可以通过实现 LlmStreamGenerator
函数来完成。然后将该函数作为 `messageSender` 参数传递给 `LlmChatView`。
class ChatPage extends StatelessWidget {
final _provider = GeminiProvider(...);
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text(App.title)),
body: LlmChatView(
provider: _provider,
messageSender: _logMessage,
),
);
Stream<String> _logMessage(
String prompt, {
required Iterable<Attachment> attachments,
}) async* {
// log the message and attachments
debugPrint('# Sending Message');
debugPrint('## Prompt\n$prompt');
debugPrint('## Attachments\n${attachments.map((a) => a.toString())}');
// forward the message on to the provider
final response = _provider.sendMessageStream(
prompt,
attachments: attachments,
);
// log the response
final text = await response.join();
debugPrint('## Response\n$text');
// return it
yield text;
}
}
此示例记录了用户提示和 LLM 响应的来回过程。当提供一个函数作为 `messageSender` 时,您有责任调用底层提供程序。如果您不这样做,它将无法接收到消息。此功能允许您执行高级操作,例如动态路由到提供程序或检索增强生成 (RAG)。
要查看实际应用,请参阅日志记录示例应用。