Forráskód Böngészése

[New]新增流式sse解析

zhipeng 1 éve
szülő
commit
551b3e3400

+ 14 - 11
android/app/src/main/AndroidManifest.xml

@@ -1,29 +1,32 @@
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
     package="com.atmob.elec_asst">
+
+    <uses-permission android:name="android.permission.INTERNET" />
+
     <application
-        android:label="electronic_assistant"
         android:name="${applicationName}"
-        android:icon="@mipmap/ic_launcher">
+        android:icon="@mipmap/ic_launcher"
+        android:label="electronic_assistant"
+        android:networkSecurityConfig="@xml/network_security_config">
         <activity
             android:name=".MainActivity"
+            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
             android:exported="true"
+            android:hardwareAccelerated="true"
             android:launchMode="singleTop"
             android:taskAffinity=""
             android:theme="@style/LaunchTheme"
-            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
-            android:hardwareAccelerated="true"
             android:windowSoftInputMode="adjustResize">
             <!-- Specifies an Android theme to apply to this Activity as soon as
                  the Android process has started. This theme is visible to the user
                  while the Flutter UI initializes. After that, this theme continues
                  to determine the Window background behind the Flutter UI. -->
             <meta-data
-              android:name="io.flutter.embedding.android.NormalTheme"
-              android:resource="@style/NormalTheme"
-              />
+                android:name="io.flutter.embedding.android.NormalTheme"
+                android:resource="@style/NormalTheme" />
             <intent-filter>
-                <action android:name="android.intent.action.MAIN"/>
-                <category android:name="android.intent.category.LAUNCHER"/>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
             </intent-filter>
         </activity>
         <!-- Don't delete the meta-data below.
@@ -39,8 +42,8 @@
          In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
     <queries>
         <intent>
-            <action android:name="android.intent.action.PROCESS_TEXT"/>
-            <data android:mimeType="text/plain"/>
+            <action android:name="android.intent.action.PROCESS_TEXT" />
+            <data android:mimeType="text/plain" />
         </intent>
     </queries>
 </manifest>

+ 48 - 0
android/app/src/main/res/xml/network_security_config.xml

@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="utf-8"?>
+<network-security-config xmlns:tools="http://schemas.android.com/tools"
+    tools:ignore="MissingDefaultResource">
+
+    <!-- For AdColony, this permits all cleartext traffic: -->
+    <base-config cleartextTrafficPermitted="true">
+        <trust-anchors>
+            <certificates src="system" />
+        </trust-anchors>
+    </base-config>
+    <!-- End AdColony section -->
+
+    <domain-config cleartextTrafficPermitted="true">
+
+        <!-- For Meta Audience Network, this permits cleartext traffic to localhost: -->
+        <domain includeSubdomains="true">127.0.0.1</domain>
+        <!-- End Meta Audience Network section -->
+
+    </domain-config>
+
+    <domain-config cleartextTrafficPermitted="true">
+        <domain includeSubdomains="true">192.168.10.230</domain>
+    </domain-config>
+
+    <domain-config cleartextTrafficPermitted="true">
+        <domain includeSubdomains="true">cdn.atmob.com</domain>
+    </domain-config>
+
+    <domain-config cleartextTrafficPermitted="true">
+        <domain includeSubdomains="true">test-micro.atmob.com</domain>
+    </domain-config>
+
+    <domain-config cleartextTrafficPermitted="true">
+        <domain includeSubdomains="true">api-img-sh.fengkongcloud.com</domain>
+    </domain-config>
+
+    <domain-config cleartextTrafficPermitted="true">
+        <domain includeSubdomains="true">api-text-gz.fengkongcloud.com</domain>
+    </domain-config>
+
+    <domain-config cleartextTrafficPermitted="true">
+        <domain includeSubdomains="true">global-chatbotai-cdn.atmob.com</domain>
+    </domain-config>
+
+    <domain-config cleartextTrafficPermitted="true">
+        <domain includeSubdomains="true">cdn.supercleaner.club</domain>
+    </domain-config>
+</network-security-config>

BIN
assets/images/icon_stream_chat_progressing.webp


+ 7 - 1
lib/data/api/atmob_api.dart

@@ -4,12 +4,14 @@ import 'package:electronic_assistant/base/base_response.dart';
 import 'package:electronic_assistant/data/api/network_module.dart';
 import 'package:electronic_assistant/data/api/request/agenda_request.dart';
 import 'package:electronic_assistant/data/api/request/agenda_status_request.dart';
+import 'package:electronic_assistant/data/api/request/chat_history_request.dart';
 import 'package:electronic_assistant/data/api/request/login_request.dart';
 import 'package:electronic_assistant/data/api/request/talk_delete_request.dart';
 import 'package:electronic_assistant/data/api/request/talk_rename_request.dart';
 import 'package:electronic_assistant/data/api/request/user_info_update_request.dart';
 import 'package:electronic_assistant/data/api/request/verification_code_request.dart';
 import 'package:electronic_assistant/data/api/response/agenda_response.dart';
+import 'package:electronic_assistant/data/api/response/chat_history_response.dart';
 import 'package:electronic_assistant/data/api/response/home_info_response.dart';
 import 'package:electronic_assistant/data/api/response/login_response.dart';
 import 'package:electronic_assistant/data/consts/constants.dart';
@@ -47,6 +49,10 @@ abstract class AtmobApi {
 
   @POST("/project/secretary/v1/agenda/complete")
   Future<BaseResponse> agendaFinish(@Body() AgendaStatusRequest request);
+
+  @POST("/project/secretary/v1/chat/page")
+  Future<BaseResponse<ChatHistoryResponse>> chatHistory(
+      @Body() ChatHistoryRequest request);
 }
 
-final atmobApi = AtmobApi(defaultDio, baseUrl: Constants.baseUrl);
+final atmobApi = AtmobApi(defaultDio, baseUrl: Constants.baseUrl);

+ 21 - 0
lib/data/api/request/chat_history_request.dart

@@ -0,0 +1,21 @@
+import 'package:electronic_assistant/base/app_base_request.dart';
+import 'package:json_annotation/json_annotation.dart';
+
+part 'chat_history_request.g.dart';
+
+@JsonSerializable()
+class ChatHistoryRequest extends AppBaseRequest {
+  @JsonKey(name: 'page')
+  int pageCount;
+
+  @JsonKey(name: 'pageSize')
+  int pageSize;
+
+  @JsonKey(name: 'id')
+  int? lastId;
+
+  ChatHistoryRequest(this.pageCount, this.pageSize, this.lastId);
+
+  @override
+  Map<String, dynamic> toJson() => _$ChatHistoryRequestToJson(this);
+}

+ 16 - 0
lib/data/api/response/chat_history_response.dart

@@ -0,0 +1,16 @@
+import 'package:json_annotation/json_annotation.dart';
+
+import '../../bean/chat_item.dart';
+
+part 'chat_history_response.g.dart';
+
+@JsonSerializable()
+class ChatHistoryResponse {
+  @JsonKey(name: 'list')
+  List<ChatItem> chatItems;
+
+  ChatHistoryResponse({this.chatItems = const []});
+
+  factory ChatHistoryResponse.fromJson(Map<String, dynamic> json) =>
+      _$ChatHistoryResponseFromJson(json);
+}

+ 31 - 0
lib/data/bean/chat_item.dart

@@ -0,0 +1,31 @@
+import 'package:json_annotation/json_annotation.dart';
+
+part 'chat_item.g.dart';
+
+@JsonSerializable()
+class ChatItem {
+  @JsonKey(name: "id")
+  final int id;
+
+  @JsonKey(name: "chatId")
+  final String conversationId;
+
+  @JsonKey(name: "role")
+  final String role;
+
+  @JsonKey(name: "content")
+  String content;
+
+  @JsonKey(name: "createTime")
+  final String createTime;
+
+  ChatItem({
+    required this.id,
+    required this.conversationId,
+    required this.role,
+    required this.content,
+    required this.createTime,
+  });
+
+  factory ChatItem.fromJson(Map<String, dynamic> json) => _$ChatItemFromJson(json);
+}

+ 23 - 0
lib/data/bean/progressing_chat_item.dart

@@ -0,0 +1,23 @@
+import 'package:electronic_assistant/data/bean/chat_item.dart';
+import 'package:get/get.dart';
+
+class ProgressingChatItem extends ChatItem {
+  final RxString streamContent = "".obs;
+
+  final RxBool isFinished = false.obs;
+
+  final RxBool isFailed = false.obs;
+
+  final RxString error = "".obs;
+
+  ProgressingChatItem(
+      {required super.id,
+      required super.conversationId,
+      required super.role,
+      required super.content,
+      required super.createTime});
+
+  void append(String content) {
+    streamContent.value += content;
+  }
+}

+ 52 - 0
lib/data/bean/stream_chat_origin_data.dart

@@ -0,0 +1,52 @@
+/*
+ * {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-4o-mini", "system_fingerprint": "fp_44709d6fcb", "choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}]}
+ */
+
+import 'package:json_annotation/json_annotation.dart';
+
+part 'stream_chat_origin_data.g.dart';
+
+@JsonSerializable()
+class StreamChatOriginData {
+  @JsonKey(name: "choices")
+  final List<Choices>? choices;
+
+  StreamChatOriginData({
+    required this.choices,
+  });
+
+  factory StreamChatOriginData.fromJson(Map<String, dynamic> json) => _$StreamChatOriginDataFromJson(json);
+}
+
+@JsonSerializable()
+class Choices {
+  @JsonKey(name: "index")
+  final int? index;
+  @JsonKey(name: "delta")
+  final Delta? delta;
+  @JsonKey(name: "finishReason")
+  final String? finishReason;
+
+  Choices({
+    required this.index,
+    required this.delta,
+    required this.finishReason,
+  });
+
+  factory Choices.fromJson(Map<String, dynamic> json) => _$ChoicesFromJson(json);
+}
+
+@JsonSerializable()
+class Delta {
+  @JsonKey(name: "role")
+  final String? role;
+  @JsonKey(name: "content")
+  final String? content;
+
+  Delta({
+    required this.role,
+    required this.content,
+  });
+
+  factory Delta.fromJson(Map<String, dynamic> json) => _$DeltaFromJson(json);
+}

+ 10 - 0
lib/data/repositories/chat_repository.dart

@@ -1,7 +1,10 @@
 import 'dart:convert';
 
 import 'package:electronic_assistant/base/base_response.dart';
+import 'package:electronic_assistant/data/api/atmob_api.dart';
+import 'package:electronic_assistant/data/api/request/chat_history_request.dart';
 import 'package:electronic_assistant/data/api/request/chat_request.dart';
+import 'package:electronic_assistant/data/bean/chat_item.dart';
 import 'package:electronic_assistant/utils/http_handler.dart';
 import 'package:electronic_assistant/utils/sse_parse_util.dart';
 
@@ -31,6 +34,13 @@ class ChatRepository {
       throw Exception('Invalid content type');
     }).then((stream) => SSEParseUtil.parse(stream));
   }
+
+  Future<List<ChatItem>> chatHistory(int? lastId) {
+    return atmobApi
+        .chatHistory(ChatHistoryRequest(1, 20, lastId))
+        .then(HttpHandler.handle(true))
+        .then((response) => response.chatItems);
+  }
 }
 
 final chatRepository = ChatRepository._();

+ 107 - 11
lib/module/chat/controller.dart

@@ -1,24 +1,44 @@
+import 'dart:convert';
+
 import 'package:electronic_assistant/base/base_controller.dart';
+import 'package:electronic_assistant/data/bean/chat_item.dart';
+import 'package:electronic_assistant/data/bean/progressing_chat_item.dart';
+import 'package:electronic_assistant/data/bean/stream_chat_origin_data.dart';
 import 'package:electronic_assistant/data/repositories/chat_repository.dart';
 import 'package:electronic_assistant/module/chat/start/view.dart';
 import 'package:electronic_assistant/resource/colors.gen.dart';
-import 'package:flutter/cupertino.dart';
 import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+import 'package:pull_to_refresh/pull_to_refresh.dart';
+
+import '../../utils/http_handler.dart';
 
 class ChatController extends BaseController {
+  final RefreshController refreshController = RefreshController();
+  final ScrollController listScrollController = ScrollController();
+  final TextEditingController inputController = TextEditingController();
+
+  final RxList chatItems = [].obs;
+
   var isOpenStart = false;
 
   @override
-  void onInit() {
-    super.onInit();
+  void onReady() {
+    super.onReady();
+    refreshController.requestLoading();
+  }
 
-    chatRepository.streamChat("以《黑神话:悟空》为题,写一篇小说").then((stream) {
-      stream.listen((event) {
-        debugPrint(
-            "id: ${event.id}, event: ${event.event}, data: ${event.data}");
-      });
-    }).catchError((e) {
-      debugPrint("error: $e");
+  void loadMoreHistory() {
+    bool isEmpty = chatItems.isEmpty;
+
+    chatRepository
+        .chatHistory(chatItems.isEmpty ? null : chatItems.last.id)
+        .then((value) => chatItems.addAll(value))
+        .whenComplete(() => refreshController.loadComplete())
+        .whenComplete(() {
+      if (isEmpty) {
+        scrollToBottom();
+      }
     });
   }
 
@@ -31,11 +51,87 @@ class ChatController extends BaseController {
           barrierColor: ColorName.black55,
           backgroundColor: ColorName.transparent,
           builder: (BuildContext context) {
-            return ChatStartPage();
+            return const ChatStartPage();
           },
         );
       });
       isOpenStart = true;
     }
   }
+
+  sendMessage() {
+    if (inputController.text.isEmpty) {
+      return;
+    }
+    String chatContent = inputController.text;
+    inputController.clear();
+
+    chatItems.insert(
+        0,
+        ChatItem(
+            id: chatItems.isEmpty ? 0 : chatItems.last.id + 1,
+            conversationId:
+                chatItems.isEmpty ? "" : chatItems.last.conversationId,
+            role: "user",
+            content: chatContent,
+            createTime: DateTime.now().toString()));
+
+    ProgressingChatItem progressingChatItem = ProgressingChatItem(
+      id: chatItems.last.id + 1,
+      conversationId: chatItems.last.conversationId,
+      role: "assistant",
+      content: "",
+      createTime: DateTime.now().toString(),
+    );
+    chatItems.insert(0, progressingChatItem);
+
+    scrollToBottom();
+
+    chatRepository.streamChat(chatContent).then((stream) {
+      stream.listen((event) {
+        try {
+          Map<String, dynamic> json = jsonDecode(event.data);
+          if (json.isEmpty) {
+            return;
+          }
+          StreamChatOriginData data = StreamChatOriginData.fromJson(json);
+          if (data.choices == null || data.choices!.isEmpty) {
+            return;
+          }
+          Delta? delta = data.choices![0].delta;
+          if (delta == null) {
+            return;
+          }
+          progressingChatItem.append(delta.content ?? "");
+        } catch (ignore) {}
+      }, onDone: () {
+        progressingChatItem.content = progressingChatItem.streamContent.value;
+        progressingChatItem.isFinished.value = true;
+      }, onError: (error) {
+        progressingChatItem.isFailed.value = true;
+        progressingChatItem.error.value = "网络错误,请检查网络连接";
+        debugPrint("error: $error");
+        debugPrintStack();
+      });
+    }).catchError((error) {
+      progressingChatItem.isFailed.value = true;
+      if (error is ServerErrorException) {
+        progressingChatItem.error.value = error.message ?? "服务出错,请稍后再试";
+      } else {
+        progressingChatItem.error.value = "网络错误,请检查网络连接";
+        debugPrint("error: $error");
+        debugPrintStack();
+      }
+    });
+  }
+
+  void scrollToBottom() {
+    Future.delayed(const Duration(milliseconds: 300), () {
+      listScrollController.animateTo(
+        0,
+        duration: const Duration(milliseconds: 300),
+        curve: Curves.easeOut,
+      );
+    });
+  }
 }

+ 146 - 15
lib/module/chat/view.dart

@@ -1,13 +1,16 @@
 import 'package:electronic_assistant/base/base_page.dart';
+import 'package:electronic_assistant/data/bean/chat_item.dart';
 import 'package:electronic_assistant/module/chat/controller.dart';
-import 'package:electronic_assistant/module/chat/start/view.dart';
 import 'package:electronic_assistant/resource/colors.gen.dart';
 import 'package:electronic_assistant/utils/expand.dart';
 import 'package:flutter/cupertino.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_screenutil/flutter_screenutil.dart';
+import 'package:get/get.dart';
+import 'package:pull_to_refresh/pull_to_refresh.dart';
 
+import '../../data/bean/progressing_chat_item.dart';
 import '../../resource/assets.gen.dart';
 
 class ChatPage extends BasePage<ChatController> {
@@ -20,8 +23,6 @@ class ChatPage extends BasePage<ChatController> {
 
   @override
   Widget buildBody(BuildContext context) {
-    var controller = this.controller;
-
     // 第一次启动时弹出定制窗口
     controller.showStartSheet(context);
 
@@ -71,12 +72,39 @@ class ChatPage extends BasePage<ChatController> {
     return Column(
       children: [
         Expanded(
-            child: Padding(
+            child: Container(
           padding: EdgeInsets.symmetric(horizontal: 12.w),
-          child: AnimatedList(
-            itemBuilder: _chatItemBuilder,
-            initialItemCount: 20,
-          ),
+          child: Obx(() {
+            return SmartRefresher(
+                controller: controller.refreshController,
+                footer: CustomFooter(
+                  loadStyle: LoadStyle.ShowWhenLoading,
+                  builder: (context, mode) {
+                    if (mode == LoadStatus.loading ||
+                        mode == LoadStatus.canLoading) {
+                      return const SizedBox(
+                        height: 60.0,
+                        child: SizedBox(
+                          height: 20.0,
+                          width: 20.0,
+                          child: CupertinoActivityIndicator(),
+                        ),
+                      );
+                    } else {
+                      return Container();
+                    }
+                  },
+                ),
+                enablePullDown: false,
+                enablePullUp: true,
+                onLoading: controller.loadMoreHistory,
+                onRefresh: controller.loadMoreHistory,
+                child: ListView.builder(
+                    reverse: true,
+                    controller: controller.listScrollController,
+                    itemBuilder: _chatItemBuilder,
+                    itemCount: controller.chatItems.length));
+          }),
         )),
         Container(
           margin: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
@@ -103,6 +131,7 @@ class ChatPage extends BasePage<ChatController> {
                         child: Container(
                       margin: EdgeInsets.only(right: 6.w),
                       child: CupertinoTextField(
+                        controller: controller.inputController,
                         padding: EdgeInsets.symmetric(vertical: 3.w),
                         style: TextStyle(
                             fontSize: 14.w, color: ColorName.primaryTextColor),
@@ -124,10 +153,15 @@ class ChatPage extends BasePage<ChatController> {
                         height: 26.w),
                     Container(
                       margin: EdgeInsets.only(left: 16.w),
-                      child: Image(
-                          image: Assets.images.iconChatSend.provider(),
-                          width: 26.w,
-                          height: 26.w),
+                      child: GestureDetector(
+                        onTap: () {
+                          controller.sendMessage();
+                        },
+                        child: Image(
+                            image: Assets.images.iconChatSend.provider(),
+                            width: 26.w,
+                            height: 26.w),
+                      ),
                     )
                   ],
                 )
@@ -139,9 +173,106 @@ class ChatPage extends BasePage<ChatController> {
     );
   }
 
-  Widget _chatItemBuilder(
-      BuildContext context, int index, Animation<double> animation) {
-    return Text('聊天内容 $index');
+  Widget _chatItemBuilder(BuildContext context, int index) {
+    ChatItem chatItem = controller.chatItems[index];
+    if (chatItem.role == 'user') {
+      return _buildUserChatItem(context, chatItem);
+    } else if (chatItem.role == 'assistant') {
+      return _buildAssistantChatItem(context, chatItem);
+    } else {
+      return Container();
+    }
+  }
+
+  Widget _buildAssistantChatItem(BuildContext context, ChatItem chatItem) {
+    ProgressingChatItem? progressingChatItem;
+    if (chatItem is ProgressingChatItem) {
+      progressingChatItem = chatItem;
+    }
+    return Align(
+      alignment: Alignment.centerLeft,
+      child: IntrinsicWidth(
+        child: progressingChatItem == null
+            ? _buildAssistantChatItemContent(null, chatItem.content)
+            : Obx(() {
+                bool? isStreamStarted = progressingChatItem == null
+                    ? null
+                    : progressingChatItem.streamContent.isNotEmpty ||
+                        progressingChatItem.isFinished.value ||
+                        progressingChatItem.isFailed.value;
+                return _buildAssistantChatItemContent(
+                    isStreamStarted,
+                    progressingChatItem!.isFailed.value
+                        ? progressingChatItem.error.value
+                        : progressingChatItem.streamContent.value);
+              }),
+      ),
+    );
+  }
+
+  Container _buildAssistantChatItemContent(
+      bool? isStreamStarted, String content) {
+    return Container(
+      padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
+      margin: EdgeInsets.symmetric(vertical: 10.h),
+      alignment: Alignment.centerLeft,
+      constraints: BoxConstraints(
+        maxWidth: 0.78.sw, // 65% of screen width
+      ),
+      decoration: BoxDecoration(
+          border: isStreamStarted == null || isStreamStarted == true
+              ? null
+              : Border.all(color: ColorName.colorPrimary, width: 1.w),
+          color: ColorName.white,
+          borderRadius: BorderRadius.only(
+              topRight: Radius.circular(20.w),
+              bottomRight: Radius.circular(20.w),
+              bottomLeft: Radius.circular(20.w))),
+      child: isStreamStarted != null && isStreamStarted == false
+          ? Row(
+              children: [
+                Image(
+                    image: Assets.images.iconStreamChatProgressing.provider(),
+                    width: 24.w,
+                    height: 20.w),
+                Padding(
+                  padding: EdgeInsets.only(left: 8.w, right: 8.w),
+                  child: Text(
+                    "正在生成中,请稍后...",
+                    style: TextStyle(
+                        fontSize: 14.w, color: ColorName.tertiaryTextColor),
+                  ),
+                ),
+              ],
+            )
+          : SelectableText(content,
+              style:
+                  TextStyle(fontSize: 14.w, color: ColorName.primaryTextColor)),
+    );
+  }
+
+  Widget _buildUserChatItem(BuildContext context, ChatItem chatItem) {
+    return Align(
+      alignment: Alignment.centerRight,
+      child: IntrinsicWidth(
+        child: Container(
+          padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
+          margin: EdgeInsets.symmetric(vertical: 10.h),
+          alignment: Alignment.centerRight,
+          constraints: BoxConstraints(
+            maxWidth: 0.78.sw, // 65% of screen width
+          ),
+          decoration: BoxDecoration(
+              color: ColorName.colorPrimary,
+              borderRadius: BorderRadius.only(
+                  topLeft: Radius.circular(16.w),
+                  bottomRight: Radius.circular(16.w),
+                  bottomLeft: Radius.circular(16.w))),
+          child: Text(chatItem.content,
+              style: TextStyle(fontSize: 14.w, color: ColorName.white)),
+        ),
+      ),
+    );
   }
 
   Widget buildTopGradient() {

+ 13 - 1
lib/utils/sse_parse_util.dart

@@ -21,13 +21,25 @@ class Message {
       required this.event,
       required this.data,
       required this.retry});
+
+  @override
+  String toString() {
+    return 'Message{id: $id, event: $event, data: $data, retry: $retry}';
+  }
 }
 
 class SSETransformer extends StreamTransformerBase<Uint8List, Message> {
   @override
   Stream<Message> bind(Stream<Uint8List> stream) {
     return Stream.eventTransformed(
-      stream.map((bytes) => utf8.decoder.convert(bytes)),
+      stream.map((bytes) {
+        try {
+          return utf8.decoder.convert(bytes);
+        } catch (e) {
+          debugPrint("$e: $bytes");
+        }
+        return "";
+      }),
       (sink) => SseEventSink(sink),
     );
   }

+ 16 - 8
pubspec.lock

@@ -181,10 +181,10 @@ packages:
     dependency: transitive
     description:
       name: crypto
-      sha256: "1dceb0cf05cb63a7852c11560060e53ec2f182079a16ced6f4395c5b0875baf8"
+      sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27
       url: "https://pub.dev"
     source: hosted
-    version: "3.0.4"
+    version: "3.0.5"
   cupertino_icons:
     dependency: "direct main"
     description:
@@ -266,18 +266,18 @@ packages:
     dependency: transitive
     description:
       name: flutter_gen_core
-      sha256: d8e828ad015a8511624491b78ad8e3f86edb7993528b1613aefbb4ad95947795
+      sha256: "638d518897f1aefc55a24278968027591d50223a6943b6ae9aa576fe1494d99d"
       url: "https://pub.dev"
     source: hosted
-    version: "5.6.0"
+    version: "5.7.0"
   flutter_gen_runner:
     dependency: "direct dev"
     description:
       name: flutter_gen_runner
-      sha256: "931b03f77c164df0a4815aac0efc619a6ac8ec4cada55025119fca4894dada90"
+      sha256: "7f2f02d95e3ec96cf70a1c515700c0dd3ea905af003303a55d6fb081240e6b8a"
       url: "https://pub.dev"
     source: hosted
-    version: "5.6.0"
+    version: "5.7.0"
   flutter_launcher_icons:
     dependency: "direct dev"
     description:
@@ -487,10 +487,10 @@ packages:
     dependency: transitive
     description:
       name: mime
-      sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2"
+      sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a"
       url: "https://pub.dev"
     source: hosted
-    version: "1.0.5"
+    version: "1.0.6"
   mmkv:
     dependency: "direct main"
     description:
@@ -667,6 +667,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.3.0"
+  pull_to_refresh:
+    dependency: "direct main"
+    description:
+      name: pull_to_refresh
+      sha256: bbadd5a931837b57739cf08736bea63167e284e71fb23b218c8c9a6e042aad12
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.0.0"
   retrofit:
     dependency: "direct main"
     description: