除了 LlmChatView 自动提供的功能外,还有许多集成点允许您的应用与其他功能无缝融合,以提供额外的功能

  • 欢迎消息:向用户显示初始问候语。
  • 建议提示:向用户提供预定义提示以指导交互。
  • 系统指令:向 LLM 提供特定输入以影响其响应。
  • 禁用附件和音频输入:移除聊天 UI 的可选部分。
  • 管理取消或错误行为:更改用户取消或 LLM 错误行为。
  • 管理历史记录:每个 LLM 提供程序都允许管理聊天历史记录,这对于清除它、动态更改它以及在会话之间存储它非常有用。
  • 聊天序列化/反序列化:在应用会话之间存储和检索对话。
  • 自定义响应小部件:引入专门的 UI 组件来呈现 LLM 响应。
  • 自定义样式:定义独特的视觉样式,使聊天外观与整体应用相匹配。
  • 无 UI 聊天:直接与 LLM 提供程序交互,而不影响用户的当前聊天会话。
  • 自定义 LLM 提供程序:构建您自己的 LLM 提供程序,以将聊天与您自己的模型后端集成。
  • 重新路由提示:调试、记录或重新路由发送给提供程序的消息,以跟踪问题或动态路由提示。

欢迎消息

#

聊天视图允许您提供自定义欢迎消息,为用户设置上下文

Example welcome message

您可以通过设置 welcomeMessage 参数来初始化带有欢迎消息的 LlmChatView

dart
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,
           ),
         ),
       ),
     );
}

要查看设置欢迎消息的完整示例,请查看欢迎示例

建议提示

#

您可以提供一组建议提示,让用户了解聊天会话已针对哪些方面进行了优化

Example suggested prompts

仅当没有现有聊天历史记录时才显示建议。单击其中一个会将文本复制到用户的提示编辑区域。要设置建议列表,请使用 suggestions 参数构造 LlmChatView

dart
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,使其专注于根据用户指令提供食谱

dart
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. ...
''',
          ),
        ),
      );
  ...
}

设置系统指令对于每个提供程序都是独特的;GeminiProviderVertexProvider 都允许您通过 systemInstruction 参数提供它们。

请注意,在这种情况下,我们将用户偏好作为 LLM 提供程序创建的一部分引入,并传递给 LlmChatView 构造函数。每次用户更改偏好时,我们都会将指令作为创建过程的一部分进行设置。食谱应用允许用户使用支架上的抽屉更改他们的食物偏好

Example of refining prompt

每当用户更改他们的食物偏好时,食谱应用都会创建一个新模型来使用新偏好

dart
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 构造函数中的 enableAttachmentsenableVoiceNotes 参数来完成

dart
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”,并弹出一个带有错误详细信息的警报对话框。

您可以使用 LlmChatViewcancelMessageerrorMessageonCancelCallbackonErrorCallback 参数覆盖取消和错误行为。例如,以下代码替换了默认的取消处理行为

dart
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 提供程序的标准接口包括获取和设置提供程序历史记录的功能

dart
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 方法。这意味着您可以手动使用 addremove 方法订阅/取消订阅,或者使用它来构造 ListenableBuilder 类的实例。

generateStream 方法调用底层 LLM,而不影响历史记录。调用 sendMessageStream 方法会在响应完成后,通过向提供程序历史记录添加两条新消息(一条用于用户消息,一条用于 LLM 响应)来更改历史记录。聊天视图在处理用户的聊天提示时使用 sendMessageStream,在处理用户的语音输入时使用 generateStream

要查看或设置历史记录,您可以访问 history 属性

dart
void _clearHistory() => _provider.history = [];

访问提供程序历史记录的能力在重新创建提供程序同时维护历史记录时也很有用

dart
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 会考虑到他们新的食物偏好来提供响应。例如

dart
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 方法来完成。

dart
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 方法

dart
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 响应用于创建一个特定于显示食谱的小部件,就像应用的其余部分一样,并提供一个添加按钮,以防用户希望将食谱添加到他们的数据库中

Add recipe button

这可以通过设置 LlmChatView 构造函数的 responseBuilder 参数来完成

dart
LlmChatView(
  provider: _provider,
  welcomeMessage: _welcomeMessage,
  responseBuilder: (context, response) => RecipeResponseView(
    response,
  ),
),

在这个特定示例中,RecipeReponseView 小部件是使用 LLM 提供程序的响应文本构建的,并使用它来实现其 build 方法

dart
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 模式并提供模式以限制其响应的格式,以确保我们能够解析某些内容。每个提供程序都以自己的方式公开此功能,但 GeminiProviderVertexProvider 类都通过食谱示例使用的 GenerationConfig 对象启用此功能,如下所示

dart
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 参数设置为定义您准备解析的 JSON 结构的 Schema 类实例来初始化 GenerationConfig 对象。此外,最好还在系统指令中请求 JSON 并提供该 JSON 模式的描述,我们已在此处完成此操作。

要查看实际应用,请查看食谱示例应用

自定义样式

#

聊天视图开箱即用,为背景、文本字段、按钮、图标、建议等提供了一组默认样式。您可以通过使用 LlmChatView 构造函数的 style 参数设置您自己的样式来完全自定义这些样式

dart
LlmChatView(
  provider: GeminiProvider(...),
  style: LlmChatViewStyle(...),
),

例如,自定义样式示例应用使用此功能实现了具有万圣节主题的应用

Halloween-themed demo app

有关 LlmChatViewStyle 类中可用样式的完整列表,请查看参考文档。要查看自定义样式的实际应用,除了自定义样式示例外,请查看暗模式示例演示应用

无 UI 聊天

#

您不必使用聊天视图来访问底层提供程序的功能。除了能够简单地调用其提供的任何专有接口之外,您还可以将其与 LlmProvider 接口一起使用。

例如,食谱示例应用在编辑食谱页面上提供了一个魔术按钮。该按钮的目的是使用您当前的食物偏好更新数据库中现有的食谱。按下按钮可以预览推荐的更改并决定是否应用它们

User decides whether to update recipe in database

编辑食谱页面不是使用应用聊天部分使用的相同提供程序(这会将虚假的用户消息和 LLM 响应插入到用户的聊天历史记录中),而是创建自己的提供程序并直接使用它

dart
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 函数的实现来完成。然后将该函数传递给 LlmChatViewmessageSender 参数

dart
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)。

要查看实际应用,请查看日志示例应用