view.dart 20 KB

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