aiGptInput.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582
  1. <script setup lang="ts">
  2. import { ref, computed, watch } from "vue";
  3. import { useBasicLayout } from "@/hooks/useBasicLayout";
  4. import { t } from "@/locales";
  5. import {
  6. NInput,
  7. NButton,
  8. useMessage,
  9. NImage,
  10. NTooltip,
  11. NAutoComplete,
  12. NTag,
  13. NPopover,
  14. NModal,
  15. NDropdown,
  16. } from "naive-ui";
  17. import { SvgIcon, PromptStore } from "@/components/common";
  18. import {
  19. canVisionModel,
  20. GptUploader,
  21. mlog,
  22. upImg,
  23. getFileFromClipboard,
  24. isFileMp3,
  25. countTokens,
  26. checkDisableGpt4,
  27. Recognition,
  28. chatSetting,
  29. } from "@/api";
  30. import { gptConfigStore, homeStore, useChatStore } from "@/store";
  31. import { AutoCompleteOptions } from "naive-ui/es/auto-complete/src/interface";
  32. import { RenderLabel } from "naive-ui/es/_internal/select-menu/src/interface";
  33. import { useRoute } from "vue-router";
  34. import aiModel from "@/views/mj/aiModel.vue";
  35. import AiMic from "./aiMic.vue";
  36. import { useIconRender } from "@/hooks/useIconRender";
  37. import { Console } from "console";
  38. const { iconRender } = useIconRender();
  39. //import FormData from 'form-data'
  40. const route = useRoute();
  41. const chatStore = useChatStore();
  42. const emit = defineEmits(["update:modelValue", "export", "handleClear"]);
  43. const props = defineProps<{
  44. modelValue: string;
  45. disabled?: boolean;
  46. searchOptions?: AutoCompleteOptions;
  47. renderOption?: RenderLabel;
  48. }>();
  49. const fsRef = ref();
  50. const st = ref<{
  51. fileBase64: string[];
  52. isLoad: number;
  53. isShow: boolean;
  54. showMic: boolean;
  55. micStart: boolean;
  56. }>({
  57. fileBase64: [],
  58. isLoad: 0,
  59. isShow: false,
  60. showMic: false,
  61. micStart: false,
  62. });
  63. const { isMobile } = useBasicLayout();
  64. const placeholder = computed(() => {
  65. if (isMobile.value) return t("chat.placeholderMobile");
  66. return t("chat.placeholder"); //可输入说点什么,也可贴截图或拖拽文件
  67. });
  68. const { uuid } = route.params as { uuid: string };
  69. const uuid1 = chatStore.active;
  70. const chatSet = new chatSetting(uuid1 == null ? 1002 : uuid1);
  71. const nGptStore = ref(chatSet.getGptConfig());
  72. const dataSources = computed(() => chatStore.getChatByUuid(+uuid));
  73. watch(
  74. () => gptConfigStore.myData,
  75. () => (nGptStore.value = chatSet.getGptConfig()),
  76. { deep: true }
  77. );
  78. watch(
  79. () => homeStore.myData.act,
  80. (n) => n == "saveChat" && (nGptStore.value = chatSet.getGptConfig()),
  81. { deep: true }
  82. );
  83. const handleSubmit = () => {
  84. if (mvalue.value == "") return;
  85. if (checkDisableGpt4(gptConfigStore.myData.model)) {
  86. ms.error(t("mj.disableGpt4"));
  87. return false;
  88. }
  89. if (homeStore.myData.isLoader) {
  90. return;
  91. }
  92. let obj = {
  93. prompt: mvalue.value,
  94. fileBase64: st.value.fileBase64,
  95. };
  96. homeStore.setMyData({ act: "gpt.submit", actData: obj });
  97. mvalue.value = "";
  98. st.value.fileBase64 = [];
  99. return false;
  100. };
  101. const ms = useMessage();
  102. const mvalue = computed({
  103. get() {
  104. return props.modelValue;
  105. },
  106. set(value) {
  107. emit("update:modelValue", value);
  108. },
  109. });
  110. function selectFile(input: any) {
  111. const file = input.target.files[0];
  112. upFile(file);
  113. }
  114. const myToken = ref({ remain: 0, modelTokens: "4k" });
  115. const funt = async () => {
  116. const d = await countTokens(
  117. dataSources.value,
  118. mvalue.value,
  119. chatStore.active ?? 1002
  120. );
  121. myToken.value = d;
  122. return d;
  123. };
  124. watch(() => mvalue.value, funt);
  125. watch(() => dataSources.value, funt);
  126. watch(() => gptConfigStore.myData, funt, { deep: true });
  127. watch(() => homeStore.myData.isLoader, funt, { deep: true });
  128. funt();
  129. const upFile = (file: any) => {
  130. if (!canVisionModel(gptConfigStore.myData.model)) {
  131. if (isFileMp3(file.name)) {
  132. mlog("mp3", file);
  133. // const formData = new FormData( );
  134. // formData.append('file', file);
  135. // formData.append('model', 'whisper-1');
  136. // GptUploader('/v1/audio/transcriptions',formData).then(r=>{
  137. // mlog('语音识别成功', r );
  138. // }).catch(e=>ms.error('上传失败:'+ ( e.message?? JSON.stringify(e)) ));
  139. homeStore.setMyData({
  140. act: "gpt.whisper",
  141. actData: { file, prompt: "whisper" },
  142. });
  143. return;
  144. } else {
  145. upImg(file)
  146. .then((d) => {
  147. fsRef.value.value = "";
  148. if (st.value.fileBase64.findIndex((v) => v == d) > -1) {
  149. ms.error(t("mj.noReUpload")); //'不能重复上传'
  150. return;
  151. }
  152. st.value.fileBase64.push(d);
  153. })
  154. .catch((e) => ms.error(e));
  155. }
  156. } else {
  157. const formData = new FormData();
  158. //const file = input.target.files[0];
  159. formData.append("file", file);
  160. ms.info(t("mj.uploading"));
  161. st.value.isLoad = 1;
  162. GptUploader("/v1/upload", formData)
  163. .then((r) => {
  164. //mlog('上传成功', r);
  165. st.value.isLoad = 0;
  166. if (r.url) {
  167. ms.info(t("mj.uploadSuccess"));
  168. if (r.url.indexOf("http") > -1) {
  169. st.value.fileBase64.push(r.url);
  170. } else {
  171. st.value.fileBase64.push(location.origin + r.url);
  172. }
  173. } else if (r.error) ms.error(r.error);
  174. })
  175. .catch((e) => {
  176. st.value.isLoad = 0;
  177. ms.error(t("mj.uploadFail") + (e.message ?? JSON.stringify(e)));
  178. });
  179. }
  180. };
  181. function handleEnter(event: KeyboardEvent) {
  182. if (!isMobile.value) {
  183. if (event.key === "Enter" && !event.shiftKey) {
  184. event.preventDefault();
  185. handleSubmit();
  186. }
  187. } else {
  188. if (event.key === "Enter" && event.ctrlKey) {
  189. event.preventDefault();
  190. handleSubmit();
  191. }
  192. }
  193. }
  194. const acceptData = computed(() => {
  195. if (canVisionModel(gptConfigStore.myData.model)) return "*/*";
  196. return "image/jpeg, image/jpg, image/png, image/gif, .mp3, .mp4, .mpeg, .mpga, .m4a, .wav, .webm";
  197. });
  198. const drop = (e: DragEvent) => {
  199. e.preventDefault();
  200. e.stopPropagation();
  201. if (!e.dataTransfer || e.dataTransfer.files.length == 0) return;
  202. const files = e.dataTransfer.files;
  203. upFile(files[0]);
  204. //mlog('drop', files);
  205. };
  206. const paste = (e: ClipboardEvent) => {
  207. let rz = getFileFromClipboard(e);
  208. if (rz.length > 0) upFile(rz[0]);
  209. };
  210. const sendMic = (e: any) => {
  211. mlog("sendMic", e);
  212. st.value.showMic = false;
  213. let du = "whisper.wav"; // (e.stat && e.stat.duration)?(e.stat.duration.toFixed(2)+'s'):'whisper.wav';
  214. const file = new File([e.blob], du, { type: "audio/wav" });
  215. homeStore.setMyData({
  216. act: "gpt.whisper",
  217. actData: { file, prompt: "whisper", duration: e.stat?.duration },
  218. });
  219. };
  220. //语音识别ASR
  221. const goASR = () => {
  222. const olod = mvalue.value;
  223. const rec = new Recognition();
  224. let rz = "";
  225. rec
  226. .setListener((r: string) => {
  227. //mlog('result ', r );
  228. rz = r;
  229. mvalue.value = r;
  230. st.value.micStart = true;
  231. })
  232. .setOnEnd(() => {
  233. //mlog('rec end');
  234. mvalue.value = olod + rz;
  235. ms.info(t("mj.micRecEnd"));
  236. st.value.micStart = false;
  237. })
  238. .setOpt({
  239. timeOut: 2000,
  240. onStart: () => {
  241. ms.info(t("mj.micRec"));
  242. st.value.micStart = true;
  243. },
  244. })
  245. .start();
  246. };
  247. const drOption = [
  248. {
  249. label: t("mj.micWhisper"),
  250. key: "whisper",
  251. icon: iconRender({ icon: "ri:openai-fill" }),
  252. },
  253. {
  254. label: t("mj.micAsr"),
  255. icon: iconRender({ icon: "ri:chrome-line" }),
  256. key: "asr",
  257. },
  258. ];
  259. const handleSelectASR = (key: string | number) => {
  260. if (key == "asr") goASR();
  261. if (key == "whisper") st.value.showMic = true;
  262. };
  263. const show = ref(false);
  264. function handleExport() {
  265. emit("export");
  266. }
  267. function handleClear() {
  268. emit("handleClear");
  269. }
  270. </script>
  271. <template>
  272. <div v-if="st.showMic" class="myinputs flex justify-center items-center">
  273. <AiMic @cancel="st.showMic = false" @send="sendMic" />
  274. </div>
  275. <div v-else>
  276. <div
  277. class="flex items-base justify-start pb-1 flex-wrap-reverse"
  278. v-if="st.fileBase64.length > 0"
  279. style="margin: 0 40px;"
  280. >
  281. <div
  282. class="w-[60px] h-[60px] rounded-sm bg-slate-50 mr-1 mt-1 text-red-300 relative group"
  283. v-for="(v, ii) in st.fileBase64"
  284. >
  285. <NImage :src="v" object-fit="cover" class="w-full h-full">
  286. <template #placeholder>
  287. <a
  288. class="w-full h-full flex items-center justify-center text-neutral-500"
  289. :href="v"
  290. target="_blank"
  291. >
  292. <SvgIcon icon="mdi:download" />{{ $t("mj.attr1") }} {{ ii + 1 }}
  293. </a>
  294. </template>
  295. </NImage>
  296. <SvgIcon
  297. icon="mdi:close"
  298. class="hidden group-hover:block absolute top-[-5px] right-[-5px] rounded-full bg-red-300 text-white cursor-pointer"
  299. @click="st.fileBase64.splice(st.fileBase64.indexOf(v), 1)"
  300. ></SvgIcon>
  301. </div>
  302. </div>
  303. <div
  304. class="myinputs"
  305. :class="[!isMobile ? 'chat-footer' : '']"
  306. @drop="drop"
  307. @paste="paste"
  308. >
  309. <div class="top-bar" v-if="!isMobile">
  310. <div class="left" v-if="st">
  311. <div
  312. v-if="homeStore.myData.local != 'draw'"
  313. class="chage-model-select"
  314. @click="st.isShow = true"
  315. >
  316. <template v-if="nGptStore.gpts">
  317. <SvgIcon icon="ri:apps-fill" />
  318. <span class="line-clamp-1 overflow-hidden">{{
  319. nGptStore.gpts.name
  320. }}</span>
  321. </template>
  322. <template v-else>
  323. <SvgIcon icon="heroicons:sparkles" />
  324. <span>{{
  325. nGptStore.modelLabel ? nGptStore.modelLabel : "gpt-4o-mini"
  326. }}</span>
  327. </template>
  328. <SvgIcon icon="icon-park-outline:right" />
  329. </div>
  330. <n-dropdown
  331. trigger="hover"
  332. :options="drOption"
  333. @select="handleSelectASR"
  334. >
  335. <div class="relative; w-[22px]" style="margin: 0 25px">
  336. <div
  337. class="absolute bottom-[14px] left-[31px]"
  338. v-if="st.micStart"
  339. >
  340. <span class="relative flex h-3 w-3">
  341. <span
  342. class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75"
  343. ></span>
  344. <span
  345. class="relative inline-flex rounded-full h-3 w-3 bg-red-400"
  346. ></span>
  347. </span>
  348. </div>
  349. <!-- <SvgIcon icon="bi:mic" class="absolute bottom-[10px] left-[55px] cursor-pointer" @click="goASR()"></SvgIcon> -->
  350. <IconSvg icon="voice" width="22px" height="22px"></IconSvg>
  351. </div>
  352. </n-dropdown>
  353. <n-tooltip trigger="hover">
  354. <template #trigger>
  355. <SvgIcon
  356. icon="line-md:uploading-loop"
  357. class="absolute bottom-[10px] left-[8px] cursor-pointer"
  358. v-if="st.isLoad == 1"
  359. ></SvgIcon>
  360. <IconSvg
  361. icon="upload"
  362. @click="fsRef.click()"
  363. v-else
  364. width="22px"
  365. height="22px"
  366. ></IconSvg>
  367. </template>
  368. <div
  369. v-if="canVisionModel(gptConfigStore.myData.model)"
  370. v-html="$t('mj.upPdf')"
  371. ></div>
  372. <div v-else v-html="$t('mj.upImg')"></div>
  373. </n-tooltip>
  374. <IconSvg
  375. @click="handleExport"
  376. icon="screenshot"
  377. width="22px"
  378. height="22px"
  379. ></IconSvg>
  380. </div>
  381. <IconSvg
  382. @click="handleClear"
  383. class="right"
  384. icon="clear"
  385. width="28px"
  386. height="22px"
  387. ></IconSvg>
  388. <!-- <div @click="show = true">
  389. {{ $t('store.siderButton') }}
  390. </div> -->
  391. </div>
  392. <input
  393. type="file"
  394. id="fileInput"
  395. @change="selectFile"
  396. class="hidden"
  397. ref="fsRef"
  398. :accept="acceptData"
  399. />
  400. <div class="w-full relative">
  401. <div class="absolute bottom-0 right-0 z-1" v-if="isMobile">
  402. <NPopover trigger="hover">
  403. <template #trigger>
  404. <NTag
  405. type="info"
  406. round
  407. size="small"
  408. style="cursor: pointer"
  409. :bordered="false"
  410. >
  411. <div class="opacity-60 flex">
  412. <SvgIcon icon="material-symbols:token-outline" />
  413. {{ $t("mj.remain") }}{{ myToken.remain }}/{{
  414. myToken.modelTokens
  415. }}
  416. </div>
  417. </NTag>
  418. </template>
  419. <div class="w-[300px]">
  420. {{ $t("mj.tokenInfo1") }}
  421. <p class="py-1" v-text="$t('mj.tokenInfo2')"></p>
  422. <p class="text-right">
  423. <NButton @click="st.isShow = true" type="info" size="small">{{
  424. $t("setting.setting")
  425. }}</NButton>
  426. </p>
  427. </div>
  428. </NPopover>
  429. </div>
  430. </div>
  431. <NAutoComplete
  432. v-model:value="mvalue"
  433. :options="searchOptions"
  434. :render-label="renderOption"
  435. :class="[!isMobile ? 'chat-input' : '']"
  436. >
  437. <template #default="{ handleInput, handleBlur, handleFocus }">
  438. <NInput
  439. ref="inputRef"
  440. v-model:value="mvalue"
  441. type="textarea"
  442. :placeholder="placeholder"
  443. rows="3"
  444. :autosize="{ minRows: 3, maxRows: 3 }"
  445. @input="handleInput"
  446. @focus="handleFocus"
  447. @blur="handleBlur"
  448. @keypress="handleEnter"
  449. >
  450. <template #prefix v-if="isMobile">
  451. <div class="relative; w-[22px]">
  452. <n-tooltip trigger="hover">
  453. <template #trigger>
  454. <SvgIcon
  455. icon="line-md:uploading-loop"
  456. class="absolute bottom-[10px] left-[8px] cursor-pointer"
  457. v-if="st.isLoad == 1"
  458. ></SvgIcon>
  459. <SvgIcon
  460. icon="ri:attachment-line"
  461. class="absolute bottom-[10px] left-[8px] cursor-pointer"
  462. @click="fsRef.click()"
  463. v-else
  464. ></SvgIcon>
  465. </template>
  466. <div
  467. v-if="canVisionModel(gptConfigStore.myData.model)"
  468. v-html="$t('mj.upPdf')"
  469. ></div>
  470. <div v-else v-html="$t('mj.upImg')"></div>
  471. </n-tooltip>
  472. </div>
  473. <!-- <div class=" relative; w-[22px]">
  474. <SvgIcon icon="bi:mic" class="absolute bottom-[10px] left-[30px] cursor-pointer" @click="st.showMic=true"></SvgIcon>
  475. </div> -->
  476. <n-dropdown
  477. trigger="hover"
  478. :options="drOption"
  479. @select="handleSelectASR"
  480. >
  481. <div class="relative; w-[22px]">
  482. <div
  483. class="absolute bottom-[14px] left-[31px]"
  484. v-if="st.micStart"
  485. >
  486. <span class="relative flex h-3 w-3">
  487. <span
  488. class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75"
  489. ></span>
  490. <span
  491. class="relative inline-flex rounded-full h-3 w-3 bg-red-400"
  492. ></span>
  493. </span>
  494. </div>
  495. <!-- <SvgIcon icon="bi:mic" class="absolute bottom-[10px] left-[55px] cursor-pointer" @click="goASR()"></SvgIcon> -->
  496. <SvgIcon
  497. icon="bi:mic"
  498. class="absolute bottom-[10px] left-[30px] cursor-pointer"
  499. ></SvgIcon>
  500. </div>
  501. </n-dropdown>
  502. </template>
  503. <template #suffix v-if="isMobile">
  504. <div class="relative; w-[40px]">
  505. <div class="absolute bottom-[-3px] right-[0px]">
  506. <NButton
  507. type="primary"
  508. :disabled="disabled || homeStore.myData.isLoader"
  509. @click="handleSubmit"
  510. >
  511. <template #icon>
  512. <span class="dark:text-black">
  513. <SvgIcon
  514. icon="ri:stop-circle-line"
  515. v-if="homeStore.myData.isLoader"
  516. />
  517. <SvgIcon icon="ri:send-plane-fill" v-else />
  518. </span>
  519. </template>
  520. </NButton>
  521. </div>
  522. </div>
  523. </template>
  524. </NInput>
  525. </template>
  526. </NAutoComplete>
  527. <div class="send" @click="handleSubmit" v-if="!isMobile">
  528. <IconSvg icon="send" width="16px" height="15px"></IconSvg>
  529. |
  530. <IconSvg icon="money" width="14px" height="24px"></IconSvg>
  531. <NPopover trigger="hover">
  532. <template #trigger>
  533. {{ myToken.modelTokens }}
  534. </template>
  535. <div class="w-[300px]">
  536. {{ $t("mj.tokenInfo1") }}
  537. <p class="py-1" v-text="$t('mj.tokenInfo2')"></p>
  538. <p class="text-right">
  539. <NButton @click="st.isShow = true" type="info" size="small">{{
  540. $t("setting.setting")
  541. }}</NButton>
  542. </p>
  543. </div>
  544. </NPopover>
  545. </div>
  546. <!-- translate-y-[-8px] -->
  547. </div>
  548. </div>
  549. <NModal
  550. v-model:show="st.isShow"
  551. preset="card"
  552. :title="$t('mjchat.modelChange')"
  553. class="!max-w-[620px]"
  554. @close="st.isShow = false"
  555. >
  556. <aiModel @close="st.isShow = false" />
  557. </NModal>
  558. <PromptStore v-model:visible="show"></PromptStore>
  559. <!-- <n-drawer v-model:show="st.showMic" :width="420" :on-update:show="onShowFun">
  560. <n-drawer-content title="录音" closable>
  561. <AiMic />
  562. </n-drawer-content>
  563. </n-drawer> -->
  564. </template>
  565. <style >
  566. .myinputs .n-input .n-input-wrapper {
  567. @apply items-stretch;
  568. }
  569. </style>