view.dart 20 KB

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