ChatComposer.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. <template>
  2. <div class="ai-composer">
  3. <div class="ai-composer__box" :class="{ 'is-focus': focused }">
  4. <a-textarea
  5. ref="inputRef"
  6. v-model:value="draft"
  7. class="ai-composer__input"
  8. :auto-size="{ minRows: 2, maxRows: 8 }"
  9. :maxlength="4000"
  10. :disabled="disabled"
  11. placeholder="用自然语言描述要分析的内容,例如:查询近 30 天通话次数最多的 10 个号码"
  12. @focus="focused = true"
  13. @blur="focused = false"
  14. @keydown="onKeydown"
  15. />
  16. <!-- 附件 chips:选择即上传,上传中显进度、失败可移除 -->
  17. <div v-if="attachments.length" class="ai-composer__atts">
  18. <span v-for="att in attachments" :key="att.localId" class="ai-att" :class="{ 'is-failed': att.failed }">
  19. <Icon :icon="att.icon" :size="13" />
  20. <span class="ai-att__name" :title="att.name">{{ att.name }}</span>
  21. <span class="ai-att__meta">{{ att.meta }}</span>
  22. <span v-if="att.uploading" class="ai-att__progress">{{ att.progress }}%</span>
  23. <Icon
  24. v-else
  25. class="ai-att__remove"
  26. icon="mdi:close-circle-outline"
  27. :size="13"
  28. @click="removeAttachment(att.localId)"
  29. />
  30. </span>
  31. </div>
  32. <div class="ai-composer__bar">
  33. <!-- 附件:选择即上传;支持 txt/md/json/pdf/doc/docx/csv/xls/xlsx,图片音视频不支持 -->
  34. <button
  35. class="ai-composer__tpl"
  36. type="button"
  37. title="添加附件(文档/表格,只针对本次提问)"
  38. :disabled="disabled || streaming"
  39. @click="pickFile"
  40. >
  41. <Icon icon="mdi:paperclip" :size="13" />
  42. 附件
  43. </button>
  44. <input
  45. ref="fileRef"
  46. class="ai-composer__file"
  47. type="file"
  48. accept=".txt,.md,.json,.pdf,.doc,.docx,.csv,.xls,.xlsx"
  49. @change="onFilePicked"
  50. />
  51. <!-- 分析模板库:选模板填入输入框(只填不发) -->
  52. <button class="ai-composer__tpl" type="button" title="分析模板库" @click="emit('templates')">
  53. <Icon icon="mdi:bookmark-multiple-outline" :size="13" />
  54. 模板
  55. </button>
  56. <!-- 研判方案 chip(只读):草稿态显示将绑定的研判方案,会话已建则显示绑定者 -->
  57. <span v-if="expertName" class="ai-composer__expert" :title="expertTitle">
  58. <Icon icon="mdi:brain" :size="14" />
  59. <span class="ai-composer__expert-name">{{ expertName }}</span>
  60. </span>
  61. <span class="ai-composer__spacer"></span>
  62. <!-- 模型入口收成一颗紧凑 chip:模型不常改,不值得占 200px 的宽度 -->
  63. <a-dropdown :trigger="['click']" placement="topLeft" :disabled="disabled || streaming">
  64. <button class="ai-composer__model" type="button" :disabled="disabled || streaming">
  65. <Icon icon="mdi:chip" :size="14" />
  66. <span class="ai-composer__model-name">{{ currentModelName }}</span>
  67. <span class="ai-composer__model-caret">›</span>
  68. </button>
  69. <template #overlay>
  70. <a-menu :selected-keys="selectedKeys" @click="onPickModel">
  71. <a-menu-item v-for="opt in modelOptions" :key="String(opt.value)">{{ opt.label }}</a-menu-item>
  72. </a-menu>
  73. </template>
  74. </a-dropdown>
  75. <!-- 发送 / 停止:同一个位置、同一个尺寸,流式切换时不产生跳动 -->
  76. <button
  77. v-if="streaming"
  78. class="ai-composer__send is-stop"
  79. type="button"
  80. aria-label="停止生成"
  81. @click="emit('stop')"
  82. >
  83. <Icon icon="mdi:stop" :size="16" />
  84. </button>
  85. <button
  86. v-else
  87. class="ai-composer__send"
  88. type="button"
  89. aria-label="发送"
  90. :disabled="disabled || !canSend"
  91. @click="submit"
  92. >
  93. <Icon icon="mdi:arrow-up" :size="16" />
  94. </button>
  95. </div>
  96. </div>
  97. <p class="ai-composer__hint">Enter 发送 · Shift+Enter 换行</p>
  98. </div>
  99. </template>
  100. <script lang="ts" setup>
  101. /**
  102. * 输入区
  103. *
  104. * 会话创建后模型绑定在服务端(session.modelId),这里切换模型会随本轮请求下发 modelId,
  105. * 后端回写会话绑定,因此"换模型"只需切换选择器、无需新建会话。
  106. *
  107. * 布局:整体是一个圆角容器(输入框 + 底部操作行),操作行左侧是附件、模板、研判方案,
  108. * 弹性空白把模型 chip 与圆形发送按钮推到右侧贴邻,键位提示移到容器外下方 ——
  109. * 它属于"使用说明",不该挤占输入区的横向空间。
  110. */
  111. import { computed, nextTick, ref, watch } from 'vue';
  112. import { Dropdown, Menu, MenuItem, Textarea } from 'ant-design-vue';
  113. import Icon from '@/core/components/Icon/src/Icon.vue';
  114. import { useUserStore } from '@/core/store/modules/case';
  115. import { uploadAttachment } from '../api/attachmentApi';
  116. import type { AiModel, AttachmentBadge, AttachmentVO } from '../api/types';
  117. import { useChatStreamStore } from '../store/chatStream';
  118. const ATextarea = Textarea;
  119. const ADropdown = Dropdown;
  120. const AMenu = Menu;
  121. const AMenuItem = MenuItem;
  122. const props = withDefaults(
  123. defineProps<{
  124. models?: AiModel[];
  125. /** 模型ID(雪花 ID 字符串,禁止转 number) */
  126. modelId?: string | null;
  127. /** 当前研判方案名(只读展示) */
  128. expertName?: string;
  129. /** 是否已有会话(false = 草稿态,第一条消息发送时才建会话并绑定) */
  130. sessionBound?: boolean;
  131. disabled?: boolean;
  132. streaming?: boolean;
  133. }>(),
  134. { models: () => [], modelId: null, expertName: '', sessionBound: true, disabled: false, streaming: false },
  135. );
  136. /** chip 悬浮说明:草稿态是「将绑定」,已有会话是「已绑定、不可更换」 */
  137. const expertTitle = computed(() =>
  138. props.sessionBound
  139. ? '该会话已绑定此研判方案,不可更换(要换请新建会话)'
  140. : '发送第一条消息后将绑定该研判方案,绑定后不可更换',
  141. );
  142. const emit = defineEmits<{
  143. send: [payload: { text: string; attachmentIds: string[]; badges: AttachmentBadge[] }];
  144. stop: [];
  145. 'update:modelId': [id: string];
  146. /** 打开分析模板抽屉 */
  147. templates: [];
  148. }>();
  149. const draft = ref('');
  150. const focused = ref(false);
  151. const inputRef = ref<any>();
  152. const fileRef = ref<HTMLInputElement>();
  153. const userStore = useUserStore();
  154. const chatStore = useChatStreamStore();
  155. /* ---------------- 附件(选择即上传) ---------------- */
  156. interface AttChip {
  157. localId: number;
  158. /** 上传成功后的附件 id(雪花字符串,原样回传后端,禁止 Number() 转换) */
  159. id?: string;
  160. name: string;
  161. meta: string;
  162. icon: string;
  163. uploading: boolean;
  164. progress: number;
  165. failed: boolean;
  166. /** 上传返回的完整摘要(发送时带作徽标) */
  167. vo?: AttachmentVO;
  168. }
  169. const attachments = ref<AttChip[]>([]);
  170. let attSeq = 0;
  171. function pickFile() {
  172. fileRef.value?.click();
  173. }
  174. async function onFilePicked(e: Event) {
  175. const input = e.target as HTMLInputElement;
  176. const file = input.files?.[0];
  177. // 先清空再取值,保证连续选同一文件也能触发 change
  178. input.value = '';
  179. if (!file) return;
  180. const chip: AttChip = {
  181. localId: ++attSeq,
  182. name: file.name,
  183. meta: formatSize(file.size),
  184. icon: iconFor(file.name),
  185. uploading: true,
  186. progress: 0,
  187. failed: false,
  188. };
  189. attachments.value.push(chip);
  190. try {
  191. const vo = await uploadAttachment(
  192. file,
  193. { workspaceId: String(userStore.getCaseInfo?.id ?? ''), sessionId: chatStore.activeSessionId ?? undefined },
  194. (p) => {
  195. chip.progress = p;
  196. },
  197. );
  198. if (vo.status !== 'READY') {
  199. throw new Error('附件处理失败,请重试');
  200. }
  201. chip.id = vo.id;
  202. chip.vo = vo;
  203. chip.uploading = false;
  204. chip.meta = metaOf(vo);
  205. chip.icon = iconFor(vo.name);
  206. } catch (err: any) {
  207. chip.uploading = false;
  208. chip.failed = true;
  209. chip.meta = err?.response?.data?.message || err?.message || '上传失败,点击 × 移除';
  210. }
  211. }
  212. function removeAttachment(localId: number) {
  213. attachments.value = attachments.value.filter((a) => a.localId !== localId);
  214. }
  215. function metaOf(vo: AttachmentVO) {
  216. const base =
  217. vo.kind === 'TABLE' ? `${formatSize(vo.size)} · ${vo.rows} 行` : `${formatSize(vo.size)} · ${vo.chars} 字`;
  218. const extra = vo.indexedChunks > 0 ? ` · 已索引 ${vo.indexedChunks} 片` : vo.truncated ? ' · 已截断' : '';
  219. return base + extra;
  220. }
  221. function iconFor(name: string) {
  222. const ext = (name.split('.').pop() || '').toLowerCase();
  223. if (['xls', 'xlsx', 'csv'].includes(ext)) return 'mdi:file-table-outline';
  224. if (['pdf'].includes(ext)) return 'mdi:file-pdf-box';
  225. if (['doc', 'docx'].includes(ext)) return 'mdi:file-word-outline';
  226. return 'mdi:file-document-outline';
  227. }
  228. function formatSize(bytes: number) {
  229. if (bytes < 1024) return `${bytes} B`;
  230. if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  231. return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
  232. }
  233. const modelOptions = computed(() =>
  234. (props.models || []).map((m) => ({
  235. value: m.id,
  236. label: `${m.name || m.modelId || m.id}${m.defaultModel ? '(默认)' : ''}`,
  237. })),
  238. );
  239. const currentModelName = computed(() => {
  240. const hit = modelOptions.value.find((o) => String(o.value) === String(props.modelId ?? ''));
  241. return hit?.label.replace('(默认)', '') || '默认模型';
  242. });
  243. const selectedKeys = computed(() => (props.modelId ? [String(props.modelId)] : []));
  244. const canSend = computed(() => !!draft.value.trim() && !attachments.value.some((a) => a.uploading));
  245. /**
  246. * Menu 的 key 一律是字符串 —— 雪花 ID 绝不能在这里做 Number() 转换,
  247. * 19 位数字超过 Number.MAX_SAFE_INTEGER,末位会被抹掉,传回后端就成了另一个模型。
  248. */
  249. function onPickModel({ key }: { key: string | number }) {
  250. const id = String(key);
  251. if (id) emit('update:modelId', id);
  252. }
  253. function submit() {
  254. const text = draft.value.trim();
  255. if (!text || props.disabled || props.streaming) return;
  256. // 上传中的附件不随发(canSend 已拦截);失败的自动剔除,只发成功的
  257. const atts = attachments.value.filter((a) => !a.failed && !a.uploading && a.id != null && a.vo);
  258. draft.value = '';
  259. attachments.value = [];
  260. emit('send', {
  261. text,
  262. attachmentIds: atts.map((a) => a.id!),
  263. badges: atts.map((a) => a.vo!),
  264. });
  265. }
  266. /**
  267. * Enter 发送 / Shift+Enter 换行。
  268. *
  269. * 输入法状态必须取当前这次 keydown 事件本身:antd 先派发 pressEnter 再派发 keydown,
  270. * 若改在 pressEnter 里读上一次按键留下的状态,中文输入法「回车确认候选词」之后的那次
  271. * 回车会沿用旧的组合中标记,被当成候选词确认而只换行、不发送。
  272. */
  273. function onKeydown(e: KeyboardEvent) {
  274. if (e.key !== 'Enter') return;
  275. // 中文输入法组合中的回车是确认候选词,不能当发送
  276. if (e.isComposing || e.keyCode === 229) return;
  277. // Shift+Enter 由 textarea 默认换行
  278. if (e.shiftKey) return;
  279. e.preventDefault();
  280. submit();
  281. }
  282. /** 供外部(建议词、历史追问)回填并聚焦 */
  283. function fill(text: string) {
  284. draft.value = text;
  285. nextTick(() => inputRef.value?.focus?.());
  286. }
  287. /** 供外部(新建会话后)只聚焦、不动草稿 */
  288. function focus() {
  289. nextTick(() => inputRef.value?.focus?.());
  290. }
  291. defineExpose({ fill, focus });
  292. watch(
  293. () => props.disabled,
  294. (v) => {
  295. if (!v) nextTick(() => inputRef.value?.focus?.());
  296. },
  297. );
  298. </script>
  299. <style lang="less" scoped>
  300. .ai-composer {
  301. flex: 0 0 auto;
  302. padding: 12px 24px 14px;
  303. background: #fff;
  304. border-top: 1px solid #eff2f7;
  305. }
  306. /*
  307. * 宽度与消息内容列严格对齐:.ai-column 自身左右各留 24px 内边距,
  308. * 这里的外层容器也有同样的内边距,所以 box 要再减掉这一对 padding,
  309. * 否则输入框会比正文宽出 48px,视觉上"左边不齐"。
  310. */
  311. .ai-composer__box {
  312. max-width: calc(var(--ai-content-max) - var(--ai-sp-5) * 2);
  313. margin: 0 auto;
  314. padding: 10px 12px 8px;
  315. background: #fafbfe;
  316. border: 1px solid #e7ebf3;
  317. border-radius: 14px;
  318. box-shadow: none;
  319. transition:
  320. border-color 0.16s ease-out,
  321. box-shadow 0.16s ease-out;
  322. &.is-focus {
  323. background: #fff;
  324. border-color: #2a50ec;
  325. box-shadow: 0 0 0 3px rgb(42 80 236 / 10%);
  326. }
  327. }
  328. .ai-composer__input {
  329. border: 0 !important;
  330. box-shadow: none !important;
  331. background: transparent !important;
  332. padding: 0 2px !important;
  333. font-size: 14px;
  334. line-height: 1.7;
  335. resize: none;
  336. }
  337. .ai-composer__bar {
  338. display: flex;
  339. align-items: center;
  340. gap: var(--ai-sp-2);
  341. margin-top: 6px;
  342. }
  343. /* 研判方案 chip(只读,无 hover/点击态) */
  344. .ai-composer__expert {
  345. display: inline-flex;
  346. align-items: center;
  347. gap: 6px;
  348. max-width: 200px;
  349. height: 26px;
  350. padding: 0 10px;
  351. overflow: hidden;
  352. font-size: 12px;
  353. color: var(--ai-text-2);
  354. white-space: nowrap;
  355. background: var(--ai-bg-subtle);
  356. border: 1px solid var(--ai-border);
  357. border-radius: 13px;
  358. &-name {
  359. overflow: hidden;
  360. text-overflow: ellipsis;
  361. }
  362. }
  363. /* 模型 chip */
  364. .ai-composer__model {
  365. display: inline-flex;
  366. align-items: center;
  367. gap: 6px;
  368. max-width: 220px;
  369. height: 26px;
  370. padding: 0 10px;
  371. font-size: 12px;
  372. color: var(--ai-text-2);
  373. background: var(--ai-bg-subtle);
  374. border: 1px solid transparent;
  375. border-radius: 13px;
  376. cursor: pointer;
  377. transition:
  378. color 0.15s ease-out,
  379. border-color 0.15s ease-out;
  380. &:hover:not(:disabled) {
  381. color: var(--ai-text);
  382. border-color: var(--ai-border-strong);
  383. }
  384. &:disabled {
  385. cursor: not-allowed;
  386. opacity: 0.6;
  387. }
  388. &:focus-visible {
  389. outline: 2px solid var(--ai-primary);
  390. outline-offset: 2px;
  391. }
  392. }
  393. .ai-composer__model-name {
  394. overflow: hidden;
  395. white-space: nowrap;
  396. text-overflow: ellipsis;
  397. }
  398. /* › 默认朝右,转 90° 变成"可展开"的向下指示 */
  399. .ai-composer__model-caret {
  400. font-size: 13px;
  401. line-height: 1;
  402. transform: rotate(90deg);
  403. }
  404. /* 分析模板库入口:与模型 chip 同形但更轻 */
  405. .ai-composer__tpl {
  406. display: inline-flex;
  407. align-items: center;
  408. gap: 5px;
  409. height: 26px;
  410. padding: 0 10px;
  411. font-size: 12px;
  412. color: var(--ai-text-2);
  413. background: transparent;
  414. border: 1px solid var(--ai-border);
  415. border-radius: 13px;
  416. cursor: pointer;
  417. transition:
  418. color 0.15s ease-out,
  419. border-color 0.15s ease-out;
  420. &:hover:not(:disabled) {
  421. color: var(--ai-primary);
  422. border-color: var(--ai-primary);
  423. }
  424. &:disabled {
  425. cursor: not-allowed;
  426. opacity: 0.5;
  427. }
  428. }
  429. /* 附件 chips:textarea 与操作行之间一行小胶囊 */
  430. .ai-composer__atts {
  431. display: flex;
  432. flex-wrap: wrap;
  433. gap: 6px;
  434. padding: 4px 2px 0;
  435. .ai-att {
  436. display: inline-flex;
  437. align-items: center;
  438. gap: 5px;
  439. max-width: 260px;
  440. height: 24px;
  441. padding: 0 8px;
  442. font-size: 11.5px;
  443. color: var(--ai-text);
  444. background: var(--ai-bg-subtle);
  445. border: 1px solid var(--ai-border);
  446. border-radius: 12px;
  447. &.is-failed {
  448. color: @error-color;
  449. border-color: fade(@error-color, 35%);
  450. }
  451. &__name {
  452. overflow: hidden;
  453. white-space: nowrap;
  454. text-overflow: ellipsis;
  455. }
  456. &__meta {
  457. color: var(--ai-text-2);
  458. white-space: nowrap;
  459. }
  460. &__progress {
  461. color: var(--ai-primary);
  462. white-space: nowrap;
  463. }
  464. &__remove {
  465. cursor: pointer;
  466. &:hover {
  467. color: @error-color;
  468. }
  469. }
  470. }
  471. }
  472. /* 文件选择用按钮触发,原生 input 隐藏 */
  473. .ai-composer__file {
  474. display: none;
  475. }
  476. .ai-composer__spacer {
  477. flex: 1 1 auto;
  478. }
  479. /* 圆形发送 / 停止按钮 */
  480. .ai-composer__send {
  481. display: inline-flex;
  482. align-items: center;
  483. justify-content: center;
  484. flex: 0 0 auto;
  485. width: 32px;
  486. height: 32px;
  487. color: #fff;
  488. background: @primary-color;
  489. border: 0;
  490. border-radius: 50%;
  491. cursor: pointer;
  492. transition:
  493. filter 0.15s ease-out,
  494. opacity 0.15s ease-out;
  495. &:hover:not(:disabled) {
  496. filter: brightness(0.92);
  497. }
  498. &:disabled {
  499. cursor: not-allowed;
  500. opacity: 0.45;
  501. }
  502. &:focus-visible {
  503. outline: 2px solid @primary-color;
  504. outline-offset: 2px;
  505. }
  506. &.is-stop {
  507. background: @error-color;
  508. }
  509. }
  510. /* 键位提示:容器外的说明文字 */
  511. .ai-composer__hint {
  512. max-width: calc(var(--ai-content-max) - var(--ai-sp-5) * 2);
  513. margin: 6px auto 0;
  514. font-size: 11px;
  515. text-align: center;
  516. color: #98a2b3;
  517. }
  518. @media (max-width: 900px) {
  519. .ai-composer {
  520. padding: var(--ai-sp-3);
  521. }
  522. .ai-composer__hint {
  523. display: none;
  524. }
  525. }
  526. </style>