view.dart 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574
  1. import 'package:electronic_assistant/base/base_page.dart';
  2. import 'package:electronic_assistant/data/bean/agenda.dart';
  3. import 'package:electronic_assistant/data/bean/chat_item.dart';
  4. import 'package:electronic_assistant/data/bean/file_chat_item.dart';
  5. import 'package:electronic_assistant/data/bean/reference_chat_item.dart';
  6. import 'package:electronic_assistant/module/browser/view.dart';
  7. import 'package:electronic_assistant/module/chat/controller.dart';
  8. import 'package:electronic_assistant/resource/colors.gen.dart';
  9. import 'package:electronic_assistant/utils/expand.dart';
  10. import 'package:flutter/cupertino.dart';
  11. import 'package:flutter/material.dart';
  12. import 'package:flutter/services.dart';
  13. import 'package:flutter_screenutil/flutter_screenutil.dart';
  14. import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
  15. import 'package:get/get.dart';
  16. import 'package:lottie/lottie.dart';
  17. import 'package:markdown/markdown.dart' as md;
  18. import 'package:pull_to_refresh/pull_to_refresh.dart';
  19. import '../../data/bean/progressing_chat_item.dart';
  20. import '../../data/bean/talks.dart';
  21. import '../../resource/assets.gen.dart';
  22. import '../../router/app_pages.dart';
  23. enum ChatFromType {
  24. fromMain,
  25. fromTalkDetail,
  26. fromAnalysisBtn,
  27. fromTalkExample,
  28. fromMine,
  29. unknown
  30. }
  31. class ChatPage extends BasePage<ChatController> {
  32. const ChatPage({super.key});
  33. static start(ChatFromType fromType) {
  34. Get.toNamed(RoutePath.chat, arguments: [fromType]);
  35. }
  36. static startByTalk(ChatFromType fromType, TalkBean talkInfo,
  37. {Agenda? agenda}) {
  38. Get.toNamed(RoutePath.chat, arguments: [fromType, talkInfo, agenda]);
  39. }
  40. static startByTalkId(ChatFromType fromType, String talkId, {Agenda? agenda}) {
  41. Get.toNamed(RoutePath.chat, arguments: [fromType, talkId, agenda]);
  42. }
  43. @override
  44. bool immersive() {
  45. return true;
  46. }
  47. @override
  48. Color navigationBarColor() {
  49. return "#F6F6F6".color;
  50. }
  51. @override
  52. Widget buildBody(BuildContext context) {
  53. // 第一次启动时弹出定制窗口
  54. return Stack(
  55. children: [
  56. _buildBackgroundGradient(),
  57. _buildTopGradient(),
  58. Scaffold(
  59. backgroundColor: Colors.transparent,
  60. appBar: AppBar(
  61. leading: IconButton(
  62. icon: SizedBox(
  63. width: 24.w,
  64. height: 24.w,
  65. child: Assets.images.iconBack.image()),
  66. onPressed: () {
  67. Get.back();
  68. },
  69. ),
  70. scrolledUnderElevation: 0,
  71. backgroundColor: Colors.transparent,
  72. systemOverlayStyle: SystemUiOverlayStyle.dark,
  73. centerTitle: true,
  74. title: IntrinsicWidth(
  75. child: Row(
  76. mainAxisAlignment: MainAxisAlignment.center,
  77. children: [
  78. Image(
  79. image: Assets.images.iconChatXiaoTin.provider(),
  80. width: 28.w,
  81. height: 28.w),
  82. Container(
  83. margin: EdgeInsets.only(left: 6.w),
  84. child: Text('聊天',
  85. style: TextStyle(
  86. fontSize: 16.w,
  87. fontWeight: FontWeight.bold,
  88. color: ColorName.primaryTextColor))),
  89. ],
  90. ),
  91. ),
  92. ),
  93. body: buildBodyContent(context),
  94. )
  95. ],
  96. );
  97. }
  98. Widget buildBodyContent(BuildContext context) {
  99. return Column(
  100. children: [
  101. Expanded(
  102. child: Container(
  103. padding: EdgeInsets.symmetric(horizontal: 12.w),
  104. child: Obx(() {
  105. return NotificationListener<ScrollNotification>(
  106. onNotification: (scrollNotification) {
  107. if (scrollNotification is ScrollStartNotification) {
  108. FocusScope.of(context).unfocus();
  109. }
  110. return false;
  111. },
  112. child: SmartRefresher(
  113. controller: controller.refreshController,
  114. footer: CustomFooter(
  115. loadStyle: LoadStyle.ShowWhenLoading,
  116. builder: (context, mode) {
  117. if (mode == LoadStatus.loading ||
  118. mode == LoadStatus.canLoading) {
  119. return const SizedBox(
  120. height: 60.0,
  121. child: SizedBox(
  122. height: 20.0,
  123. width: 20.0,
  124. child: CupertinoActivityIndicator(),
  125. ),
  126. );
  127. } else {
  128. return Container();
  129. }
  130. },
  131. ),
  132. enablePullDown: false,
  133. enablePullUp: true,
  134. onLoading: controller.loadMoreHistory,
  135. onRefresh: controller.loadMoreHistory,
  136. child: ListView.builder(
  137. reverse: true,
  138. controller: controller.listScrollController,
  139. itemBuilder: _chatItemBuilder,
  140. itemCount: controller.chatItems.length),
  141. ),
  142. );
  143. }),
  144. )),
  145. Container(
  146. margin: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
  147. width: 1.sw,
  148. decoration: BoxDecoration(
  149. color: Colors.white,
  150. borderRadius: BorderRadius.circular(24.w),
  151. boxShadow: const [
  152. BoxShadow(
  153. color: Color(0x4CDDDEE8),
  154. blurRadius: 10,
  155. offset: Offset(0, 4),
  156. spreadRadius: 0,
  157. )
  158. ]),
  159. child: Padding(
  160. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
  161. child: Column(
  162. children: [
  163. Obx(() {
  164. TalkBean? talkInfo = controller.talkInfo.value;
  165. if (talkInfo == null) {
  166. return Container();
  167. } else {
  168. return _buildReferenceFile(talkInfo);
  169. }
  170. }),
  171. Row(
  172. crossAxisAlignment: CrossAxisAlignment.end,
  173. children: [
  174. Expanded(
  175. child: Container(
  176. margin: EdgeInsets.only(right: 6.w),
  177. child: CupertinoTextField(
  178. controller: controller.inputController,
  179. padding: EdgeInsets.symmetric(vertical: 3.w),
  180. style: TextStyle(
  181. fontSize: 14.w, color: ColorName.primaryTextColor),
  182. placeholder: '有问题尽管问我~',
  183. placeholderStyle: TextStyle(
  184. fontSize: 14.w, color: const Color(0xFFAFAFAF)),
  185. textCapitalization: TextCapitalization.sentences,
  186. textInputAction: TextInputAction.newline,
  187. cursorColor: ColorName.colorPrimary,
  188. decoration: const BoxDecoration(),
  189. expands: true,
  190. maxLines: null,
  191. minLines: null,
  192. ),
  193. )),
  194. GestureDetector(
  195. onTap: () {
  196. controller.onAddFileClick();
  197. },
  198. child: Image(
  199. image: Assets.images.iconChatAddFile.provider(),
  200. width: 26.w,
  201. height: 26.w),
  202. ),
  203. Container(
  204. margin: EdgeInsets.only(left: 16.w),
  205. child: GestureDetector(
  206. onTap: () {
  207. controller.onSendClick();
  208. },
  209. child: Image(
  210. image: Assets.images.iconChatSend.provider(),
  211. width: 26.w,
  212. height: 26.w),
  213. ),
  214. )
  215. ],
  216. )
  217. ],
  218. ),
  219. ),
  220. ),
  221. ],
  222. );
  223. }
  224. Widget _chatItemBuilder(BuildContext context, int index) {
  225. ChatItem chatItem = controller.chatItems[index];
  226. if (chatItem.role == 'user') {
  227. return _buildUserChatItem(context, chatItem);
  228. } else if (chatItem.role == 'assistant') {
  229. return _buildAssistantChatItem(context, chatItem);
  230. } else {
  231. return Container();
  232. }
  233. }
  234. Widget _buildAssistantChatItem(BuildContext context, ChatItem chatItem) {
  235. ProgressingChatItem? progressingChatItem;
  236. if (chatItem is ProgressingChatItem) {
  237. progressingChatItem = chatItem;
  238. }
  239. return Align(
  240. alignment: Alignment.centerLeft,
  241. child: IntrinsicWidth(
  242. child: progressingChatItem == null
  243. ? _buildAssistantChatItemContent(null, chatItem.content)
  244. : Obx(() {
  245. bool? isStreamStarted = progressingChatItem == null
  246. ? null
  247. : progressingChatItem.streamContent.isNotEmpty ||
  248. progressingChatItem.isFinished.value ||
  249. progressingChatItem.isFailed.value;
  250. return _buildAssistantChatItemContent(
  251. isStreamStarted,
  252. progressingChatItem!.isFailed.value
  253. ? progressingChatItem.error.value
  254. : progressingChatItem.streamContent.value);
  255. }),
  256. ),
  257. );
  258. }
  259. Container _buildAssistantChatItemContent(
  260. bool? isStreamStarted, String content) {
  261. return Container(
  262. padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
  263. margin: EdgeInsets.symmetric(vertical: 10.h),
  264. alignment: Alignment.centerLeft,
  265. constraints: BoxConstraints(
  266. maxWidth: 0.78.sw,
  267. ),
  268. decoration: BoxDecoration(
  269. border: isStreamStarted == null || isStreamStarted == true
  270. ? null
  271. : Border.all(color: ColorName.colorPrimary, width: 1.w),
  272. color: ColorName.white,
  273. borderRadius: BorderRadius.only(
  274. topRight: Radius.circular(20.w),
  275. bottomRight: Radius.circular(20.w),
  276. bottomLeft: Radius.circular(20.w))),
  277. child: isStreamStarted != null && isStreamStarted == false
  278. ? Lottie.asset("assets/anim/anim_chat_response_loading.zip",
  279. width: 46.w, height: 20.w)
  280. : SelectionArea(
  281. child: HtmlWidget(
  282. onTapUrl: (url) {
  283. BrowserPage.start(url);
  284. return true;
  285. },
  286. md.markdownToHtml(content, inlineSyntaxes: [
  287. md.InlineHtmlSyntax(),
  288. md.StrikethroughSyntax(),
  289. md.EmojiSyntax(),
  290. md.ColorSwatchSyntax(),
  291. md.AutolinkExtensionSyntax(),
  292. md.ImageSyntax()
  293. ], blockSyntaxes: [
  294. const md.FencedCodeBlockSyntax(),
  295. const md.HeaderWithIdSyntax(),
  296. const md.SetextHeaderWithIdSyntax(),
  297. const md.UnorderedListWithCheckboxSyntax(),
  298. const md.OrderedListWithCheckboxSyntax(),
  299. const md.FootnoteDefSyntax(),
  300. const md.AlertBlockSyntax(),
  301. ]),
  302. textStyle: TextStyle(
  303. fontSize: 14.w, color: ColorName.primaryTextColor),
  304. ),
  305. ));
  306. }
  307. Widget _buildUserChatItem(BuildContext context, ChatItem chatItem) {
  308. if (chatItem is FileChatItem) {
  309. return _buildUserFileChatItem(context, chatItem);
  310. } else if (chatItem is ReferenceChatItem) {
  311. return _buildUserNormalChatItem(context, chatItem,
  312. referenceTalkTitle: chatItem.talkInfo.title.value);
  313. }
  314. return _buildUserNormalChatItem(context, chatItem,
  315. referenceTalkTitle: chatItem.talkTitle);
  316. }
  317. Widget _buildUserNormalChatItem(BuildContext context, ChatItem chatItem,
  318. {String? referenceTalkTitle}) {
  319. return Align(
  320. alignment: Alignment.centerRight,
  321. child: Column(
  322. crossAxisAlignment: CrossAxisAlignment.end,
  323. children: [
  324. IntrinsicWidth(
  325. child: Container(
  326. padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
  327. margin: referenceTalkTitle == null
  328. ? EdgeInsets.symmetric(vertical: 10.h)
  329. : EdgeInsets.only(top: 10.h),
  330. alignment: Alignment.centerRight,
  331. constraints: BoxConstraints(
  332. maxWidth: 0.78.sw,
  333. ),
  334. decoration: BoxDecoration(
  335. color: ColorName.colorPrimary,
  336. borderRadius: BorderRadius.only(
  337. topLeft: Radius.circular(16.w),
  338. bottomRight: Radius.circular(16.w),
  339. bottomLeft: Radius.circular(16.w))),
  340. child: SelectableText(chatItem.content,
  341. style: TextStyle(fontSize: 14.w, color: ColorName.white)),
  342. ),
  343. ),
  344. if (referenceTalkTitle != null)
  345. Container(
  346. padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
  347. margin: EdgeInsets.only(top: 8.h, bottom: 10.h),
  348. constraints: BoxConstraints(
  349. maxWidth: 0.78.sw,
  350. ),
  351. decoration: BoxDecoration(
  352. color: "#EFEFEF".color,
  353. borderRadius: BorderRadius.all(Radius.circular(10.w))),
  354. child: Row(
  355. children: [
  356. Image(
  357. image: Assets.images.iconReferenceChatArrow.provider(),
  358. width: 16.w,
  359. height: 16.w),
  360. Container(
  361. margin: EdgeInsets.only(right: 2.w, left: 4.w),
  362. child: Image(
  363. image: Assets.images.iconReferenceChatFile.provider(),
  364. width: 16.w,
  365. height: 16.w),
  366. ),
  367. Text(referenceTalkTitle,
  368. style: TextStyle(
  369. fontSize: 12.w,
  370. color: ColorName.secondaryTextColor,
  371. overflow: TextOverflow.ellipsis)),
  372. ],
  373. ),
  374. ),
  375. ],
  376. ),
  377. );
  378. }
  379. Widget _buildUserFileChatItem(BuildContext context, FileChatItem chatItem) {
  380. return Align(
  381. alignment: Alignment.centerRight,
  382. child: Container(
  383. padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 16.h),
  384. margin: EdgeInsets.symmetric(vertical: 10.h),
  385. constraints: BoxConstraints(
  386. maxWidth: 0.56.sw,
  387. ),
  388. decoration: BoxDecoration(
  389. color: ColorName.white,
  390. borderRadius: BorderRadius.only(
  391. topLeft: Radius.circular(16.w),
  392. bottomRight: Radius.circular(16.w),
  393. bottomLeft: Radius.circular(16.w)),
  394. border: Border.all(color: "#ECECEC".color, width: 1.w),
  395. ),
  396. child: Row(
  397. crossAxisAlignment: CrossAxisAlignment.center,
  398. children: [
  399. Container(
  400. margin: EdgeInsets.only(right: 6.w),
  401. child: Image(
  402. image: Assets.images.iconFilesFile.provider(),
  403. width: 30.w,
  404. height: 32.w),
  405. ),
  406. Flexible(
  407. child: Column(
  408. crossAxisAlignment: CrossAxisAlignment.start,
  409. children: [
  410. Text(chatItem.talkInfo.title.value.orEmpty,
  411. maxLines: 1,
  412. style: TextStyle(
  413. fontSize: 14.w,
  414. color: ColorName.primaryTextColor,
  415. fontWeight: FontWeight.bold,
  416. overflow: TextOverflow.ellipsis)),
  417. Text(chatItem.talkInfo.summary.value.orEmpty,
  418. maxLines: 1,
  419. style: TextStyle(
  420. fontSize: 12.w,
  421. color: ColorName.secondaryTextColor,
  422. overflow: TextOverflow.ellipsis)),
  423. ],
  424. ),
  425. ),
  426. ],
  427. ),
  428. ),
  429. );
  430. }
  431. _buildReferenceFile(TalkBean talkInfo) {
  432. if (talkInfo.oversizeFile == true) {
  433. return _buildOverSizeReference(talkInfo);
  434. } else {
  435. return _buildNormalReference(talkInfo);
  436. }
  437. }
  438. Container _buildOverSizeReference(TalkBean talkInfo) {
  439. return Container(
  440. margin: EdgeInsets.only(bottom: 14.h),
  441. padding: EdgeInsets.only(left: 8.w, top: 8.h, right: 10.w, bottom: 8.h),
  442. decoration: BoxDecoration(
  443. borderRadius: BorderRadius.all(Radius.circular(8.w)),
  444. border: Border.all(color: "#F0F0F0".color, width: 1.w),
  445. ),
  446. child: Column(
  447. children: [
  448. Row(
  449. children: [
  450. Text(talkInfo.title.value.orEmpty,
  451. style: TextStyle(
  452. fontWeight: FontWeight.bold,
  453. fontSize: 14.w,
  454. color: ColorName.primaryTextColor)),
  455. const Spacer(),
  456. Container(
  457. margin: EdgeInsets.only(left: 8.w),
  458. child: Image(
  459. image: Assets.images.iconReferenceChatDeleteFile.provider(),
  460. width: 18.w,
  461. height: 18.w),
  462. ),
  463. ],
  464. ),
  465. Container(
  466. margin: EdgeInsets.only(top: 11.h),
  467. child: Row(
  468. children: [
  469. Container(
  470. margin: EdgeInsets.only(right: 2.w),
  471. child: Image(
  472. image: Assets.images.iconReferenceChatFile.provider(),
  473. width: 16.w,
  474. height: 16.w),
  475. ),
  476. Text("谈话·超长内容",
  477. style: TextStyle(
  478. fontSize: 12.w, color: ColorName.tertiaryTextColor)),
  479. ],
  480. ),
  481. )
  482. ],
  483. ),
  484. );
  485. }
  486. _buildNormalReference(TalkBean talkInfo) {
  487. return Container(
  488. margin: EdgeInsets.only(bottom: 14.h),
  489. child: Row(
  490. children: [
  491. Image(
  492. image: Assets.images.iconReferenceChatArrow.provider(),
  493. width: 16.w,
  494. height: 16.w),
  495. Container(
  496. margin: EdgeInsets.only(right: 2.w, left: 4.w),
  497. child: Image(
  498. image: Assets.images.iconReferenceChatFile.provider(),
  499. width: 16.w,
  500. height: 16.w),
  501. ),
  502. Text(talkInfo.title.value.orEmpty,
  503. overflow: TextOverflow.ellipsis,
  504. maxLines: 1,
  505. style: TextStyle(
  506. fontSize: 12.w,
  507. color: ColorName.primaryTextColor,
  508. overflow: TextOverflow.ellipsis)),
  509. const Spacer(),
  510. Container(
  511. margin: EdgeInsets.only(left: 8.w),
  512. color: "#F6F6F6".color,
  513. child: GestureDetector(
  514. onTap: () => controller.onDeleteReference(),
  515. child: Image(
  516. image: Assets.images.iconReferenceChatDeleteFile.provider(),
  517. width: 18.w,
  518. height: 18.w),
  519. ),
  520. ),
  521. ],
  522. ),
  523. );
  524. }
  525. Widget _buildTopGradient() {
  526. return Container(
  527. width: 1.sw,
  528. height: 128.h,
  529. decoration: BoxDecoration(
  530. gradient: LinearGradient(
  531. colors: ['#E8EBFF'.toColor(), '#00E8EBFF'.toColor()],
  532. begin: Alignment.topCenter,
  533. end: Alignment.bottomCenter,
  534. stops: const [0.5, 1.0],
  535. ),
  536. ));
  537. }
  538. Widget _buildBackgroundGradient() {
  539. return Container(
  540. width: 1.sw,
  541. height: 1.sh,
  542. decoration: BoxDecoration(
  543. gradient: LinearGradient(
  544. colors: ['#F2F8F4'.toColor(), '#F6F6F6'.toColor()],
  545. begin: Alignment.topCenter,
  546. end: Alignment.bottomCenter,
  547. stops: const [0, 1.0],
  548. ),
  549. ),
  550. );
  551. }
  552. }