| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594 |
- <template>
- <div class="ai-composer">
- <div class="ai-composer__box" :class="{ 'is-focus': focused }">
- <a-textarea
- ref="inputRef"
- v-model:value="draft"
- class="ai-composer__input"
- :auto-size="{ minRows: 2, maxRows: 8 }"
- :maxlength="4000"
- :disabled="disabled"
- placeholder="用自然语言描述要分析的内容,例如:查询近 30 天通话次数最多的 10 个号码"
- @focus="focused = true"
- @blur="focused = false"
- @keydown="onKeydown"
- />
- <!-- 附件 chips:选择即上传,上传中显进度、失败可移除 -->
- <div v-if="attachments.length" class="ai-composer__atts">
- <span v-for="att in attachments" :key="att.localId" class="ai-att" :class="{ 'is-failed': att.failed }">
- <Icon :icon="att.icon" :size="13" />
- <span class="ai-att__name" :title="att.name">{{ att.name }}</span>
- <span class="ai-att__meta">{{ att.meta }}</span>
- <span v-if="att.uploading" class="ai-att__progress">{{ att.progress }}%</span>
- <Icon
- v-else
- class="ai-att__remove"
- icon="mdi:close-circle-outline"
- :size="13"
- @click="removeAttachment(att.localId)"
- />
- </span>
- </div>
- <div class="ai-composer__bar">
- <!-- 附件:选择即上传;支持 txt/md/json/pdf/doc/docx/csv/xls/xlsx,图片音视频不支持 -->
- <button
- class="ai-composer__tpl"
- type="button"
- title="添加附件(文档/表格,只针对本次提问)"
- :disabled="disabled || streaming"
- @click="pickFile"
- >
- <Icon icon="mdi:paperclip" :size="13" />
- 附件
- </button>
- <input
- ref="fileRef"
- class="ai-composer__file"
- type="file"
- accept=".txt,.md,.json,.pdf,.doc,.docx,.csv,.xls,.xlsx"
- @change="onFilePicked"
- />
- <!-- 分析模板库:选模板填入输入框(只填不发) -->
- <button class="ai-composer__tpl" type="button" title="分析模板库" @click="emit('templates')">
- <Icon icon="mdi:bookmark-multiple-outline" :size="13" />
- 模板
- </button>
- <!-- 研判方案 chip(只读):草稿态显示将绑定的研判方案,会话已建则显示绑定者 -->
- <span v-if="expertName" class="ai-composer__expert" :title="expertTitle">
- <Icon icon="mdi:brain" :size="14" />
- <span class="ai-composer__expert-name">{{ expertName }}</span>
- </span>
- <span class="ai-composer__spacer"></span>
- <!-- 模型入口收成一颗紧凑 chip:模型不常改,不值得占 200px 的宽度 -->
- <a-dropdown :trigger="['click']" placement="topLeft" :disabled="disabled || streaming">
- <button class="ai-composer__model" type="button" :disabled="disabled || streaming">
- <Icon icon="mdi:chip" :size="14" />
- <span class="ai-composer__model-name">{{ currentModelName }}</span>
- <span class="ai-composer__model-caret">›</span>
- </button>
- <template #overlay>
- <a-menu :selected-keys="selectedKeys" @click="onPickModel">
- <a-menu-item v-for="opt in modelOptions" :key="String(opt.value)">{{ opt.label }}</a-menu-item>
- </a-menu>
- </template>
- </a-dropdown>
- <!-- 发送 / 停止:同一个位置、同一个尺寸,流式切换时不产生跳动 -->
- <button
- v-if="streaming"
- class="ai-composer__send is-stop"
- type="button"
- aria-label="停止生成"
- @click="emit('stop')"
- >
- <Icon icon="mdi:stop" :size="16" />
- </button>
- <button
- v-else
- class="ai-composer__send"
- type="button"
- aria-label="发送"
- :disabled="disabled || !canSend"
- @click="submit"
- >
- <Icon icon="mdi:arrow-up" :size="16" />
- </button>
- </div>
- </div>
- <p class="ai-composer__hint">Enter 发送 · Shift+Enter 换行</p>
- </div>
- </template>
- <script lang="ts" setup>
- /**
- * 输入区
- *
- * 会话创建后模型绑定在服务端(session.modelId),这里切换模型会随本轮请求下发 modelId,
- * 后端回写会话绑定,因此"换模型"只需切换选择器、无需新建会话。
- *
- * 布局:整体是一个圆角容器(输入框 + 底部操作行),操作行左侧是附件、模板、研判方案,
- * 弹性空白把模型 chip 与圆形发送按钮推到右侧贴邻,键位提示移到容器外下方 ——
- * 它属于"使用说明",不该挤占输入区的横向空间。
- */
- import { computed, nextTick, ref, watch } from 'vue';
- import { Dropdown, Menu, MenuItem, Textarea } from 'ant-design-vue';
- import Icon from '@/core/components/Icon/src/Icon.vue';
- import { useUserStore } from '@/core/store/modules/case';
- import { uploadAttachment } from '../api/attachmentApi';
- import type { AiModel, AttachmentBadge, AttachmentVO } from '../api/types';
- import { useChatStreamStore } from '../store/chatStream';
- const ATextarea = Textarea;
- const ADropdown = Dropdown;
- const AMenu = Menu;
- const AMenuItem = MenuItem;
- const props = withDefaults(
- defineProps<{
- models?: AiModel[];
- /** 模型ID(雪花 ID 字符串,禁止转 number) */
- modelId?: string | null;
- /** 当前研判方案名(只读展示) */
- expertName?: string;
- /** 是否已有会话(false = 草稿态,第一条消息发送时才建会话并绑定) */
- sessionBound?: boolean;
- disabled?: boolean;
- streaming?: boolean;
- }>(),
- { models: () => [], modelId: null, expertName: '', sessionBound: true, disabled: false, streaming: false },
- );
- /** chip 悬浮说明:草稿态是「将绑定」,已有会话是「已绑定、不可更换」 */
- const expertTitle = computed(() =>
- props.sessionBound
- ? '该会话已绑定此研判方案,不可更换(要换请新建会话)'
- : '发送第一条消息后将绑定该研判方案,绑定后不可更换',
- );
- const emit = defineEmits<{
- send: [payload: { text: string; attachmentIds: string[]; badges: AttachmentBadge[] }];
- stop: [];
- 'update:modelId': [id: string];
- /** 打开分析模板抽屉 */
- templates: [];
- }>();
- const draft = ref('');
- const focused = ref(false);
- const inputRef = ref<any>();
- const fileRef = ref<HTMLInputElement>();
- const userStore = useUserStore();
- const chatStore = useChatStreamStore();
- /* ---------------- 附件(选择即上传) ---------------- */
- interface AttChip {
- localId: number;
- /** 上传成功后的附件 id(雪花字符串,原样回传后端,禁止 Number() 转换) */
- id?: string;
- name: string;
- meta: string;
- icon: string;
- uploading: boolean;
- progress: number;
- failed: boolean;
- /** 上传返回的完整摘要(发送时带作徽标) */
- vo?: AttachmentVO;
- }
- const attachments = ref<AttChip[]>([]);
- let attSeq = 0;
- function pickFile() {
- fileRef.value?.click();
- }
- async function onFilePicked(e: Event) {
- const input = e.target as HTMLInputElement;
- const file = input.files?.[0];
- // 先清空再取值,保证连续选同一文件也能触发 change
- input.value = '';
- if (!file) return;
- const chip: AttChip = {
- localId: ++attSeq,
- name: file.name,
- meta: formatSize(file.size),
- icon: iconFor(file.name),
- uploading: true,
- progress: 0,
- failed: false,
- };
- attachments.value.push(chip);
- try {
- const vo = await uploadAttachment(
- file,
- { workspaceId: String(userStore.getCaseInfo?.id ?? ''), sessionId: chatStore.activeSessionId ?? undefined },
- (p) => {
- chip.progress = p;
- },
- );
- if (vo.status !== 'READY') {
- throw new Error('附件处理失败,请重试');
- }
- chip.id = vo.id;
- chip.vo = vo;
- chip.uploading = false;
- chip.meta = metaOf(vo);
- chip.icon = iconFor(vo.name);
- } catch (err: any) {
- chip.uploading = false;
- chip.failed = true;
- chip.meta = err?.response?.data?.message || err?.message || '上传失败,点击 × 移除';
- }
- }
- function removeAttachment(localId: number) {
- attachments.value = attachments.value.filter((a) => a.localId !== localId);
- }
- function metaOf(vo: AttachmentVO) {
- const base =
- vo.kind === 'TABLE' ? `${formatSize(vo.size)} · ${vo.rows} 行` : `${formatSize(vo.size)} · ${vo.chars} 字`;
- const extra = vo.indexedChunks > 0 ? ` · 已索引 ${vo.indexedChunks} 片` : vo.truncated ? ' · 已截断' : '';
- return base + extra;
- }
- function iconFor(name: string) {
- const ext = (name.split('.').pop() || '').toLowerCase();
- if (['xls', 'xlsx', 'csv'].includes(ext)) return 'mdi:file-table-outline';
- if (['pdf'].includes(ext)) return 'mdi:file-pdf-box';
- if (['doc', 'docx'].includes(ext)) return 'mdi:file-word-outline';
- return 'mdi:file-document-outline';
- }
- function formatSize(bytes: number) {
- if (bytes < 1024) return `${bytes} B`;
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
- }
- const modelOptions = computed(() =>
- (props.models || []).map((m) => ({
- value: m.id,
- label: `${m.name || m.modelId || m.id}${m.defaultModel ? '(默认)' : ''}`,
- })),
- );
- const currentModelName = computed(() => {
- const hit = modelOptions.value.find((o) => String(o.value) === String(props.modelId ?? ''));
- return hit?.label.replace('(默认)', '') || '默认模型';
- });
- const selectedKeys = computed(() => (props.modelId ? [String(props.modelId)] : []));
- const canSend = computed(() => !!draft.value.trim() && !attachments.value.some((a) => a.uploading));
- /**
- * Menu 的 key 一律是字符串 —— 雪花 ID 绝不能在这里做 Number() 转换,
- * 19 位数字超过 Number.MAX_SAFE_INTEGER,末位会被抹掉,传回后端就成了另一个模型。
- */
- function onPickModel({ key }: { key: string | number }) {
- const id = String(key);
- if (id) emit('update:modelId', id);
- }
- function submit() {
- const text = draft.value.trim();
- if (!text || props.disabled || props.streaming) return;
- // 上传中的附件不随发(canSend 已拦截);失败的自动剔除,只发成功的
- const atts = attachments.value.filter((a) => !a.failed && !a.uploading && a.id != null && a.vo);
- draft.value = '';
- attachments.value = [];
- emit('send', {
- text,
- attachmentIds: atts.map((a) => a.id!),
- badges: atts.map((a) => a.vo!),
- });
- }
- /**
- * Enter 发送 / Shift+Enter 换行。
- *
- * 输入法状态必须取当前这次 keydown 事件本身:antd 先派发 pressEnter 再派发 keydown,
- * 若改在 pressEnter 里读上一次按键留下的状态,中文输入法「回车确认候选词」之后的那次
- * 回车会沿用旧的组合中标记,被当成候选词确认而只换行、不发送。
- */
- function onKeydown(e: KeyboardEvent) {
- if (e.key !== 'Enter') return;
- // 中文输入法组合中的回车是确认候选词,不能当发送
- if (e.isComposing || e.keyCode === 229) return;
- // Shift+Enter 由 textarea 默认换行
- if (e.shiftKey) return;
- e.preventDefault();
- submit();
- }
- /** 供外部(建议词、历史追问)回填并聚焦 */
- function fill(text: string) {
- draft.value = text;
- nextTick(() => inputRef.value?.focus?.());
- }
- /** 供外部(新建会话后)只聚焦、不动草稿 */
- function focus() {
- nextTick(() => inputRef.value?.focus?.());
- }
- defineExpose({ fill, focus });
- watch(
- () => props.disabled,
- (v) => {
- if (!v) nextTick(() => inputRef.value?.focus?.());
- },
- );
- </script>
- <style lang="less" scoped>
- .ai-composer {
- flex: 0 0 auto;
- padding: 12px 24px 14px;
- background: #fff;
- border-top: 1px solid #eff2f7;
- }
- /*
- * 宽度与消息内容列严格对齐:.ai-column 自身左右各留 24px 内边距,
- * 这里的外层容器也有同样的内边距,所以 box 要再减掉这一对 padding,
- * 否则输入框会比正文宽出 48px,视觉上"左边不齐"。
- */
- .ai-composer__box {
- max-width: calc(var(--ai-content-max) - var(--ai-sp-5) * 2);
- margin: 0 auto;
- padding: 10px 12px 8px;
- background: #fafbfe;
- border: 1px solid #e7ebf3;
- border-radius: 14px;
- box-shadow: none;
- transition:
- border-color 0.16s ease-out,
- box-shadow 0.16s ease-out;
- &.is-focus {
- background: #fff;
- border-color: #2a50ec;
- box-shadow: 0 0 0 3px rgb(42 80 236 / 10%);
- }
- }
- .ai-composer__input {
- border: 0 !important;
- box-shadow: none !important;
- background: transparent !important;
- padding: 0 2px !important;
- font-size: 14px;
- line-height: 1.7;
- resize: none;
- }
- .ai-composer__bar {
- display: flex;
- align-items: center;
- gap: var(--ai-sp-2);
- margin-top: 6px;
- }
- /* 研判方案 chip(只读,无 hover/点击态) */
- .ai-composer__expert {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- max-width: 200px;
- height: 26px;
- padding: 0 10px;
- overflow: hidden;
- font-size: 12px;
- color: var(--ai-text-2);
- white-space: nowrap;
- background: var(--ai-bg-subtle);
- border: 1px solid var(--ai-border);
- border-radius: 13px;
- &-name {
- overflow: hidden;
- text-overflow: ellipsis;
- }
- }
- /* 模型 chip */
- .ai-composer__model {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- max-width: 220px;
- height: 26px;
- padding: 0 10px;
- font-size: 12px;
- color: var(--ai-text-2);
- background: var(--ai-bg-subtle);
- border: 1px solid transparent;
- border-radius: 13px;
- cursor: pointer;
- transition:
- color 0.15s ease-out,
- border-color 0.15s ease-out;
- &:hover:not(:disabled) {
- color: var(--ai-text);
- border-color: var(--ai-border-strong);
- }
- &:disabled {
- cursor: not-allowed;
- opacity: 0.6;
- }
- &:focus-visible {
- outline: 2px solid var(--ai-primary);
- outline-offset: 2px;
- }
- }
- .ai-composer__model-name {
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
- /* › 默认朝右,转 90° 变成"可展开"的向下指示 */
- .ai-composer__model-caret {
- font-size: 13px;
- line-height: 1;
- transform: rotate(90deg);
- }
- /* 分析模板库入口:与模型 chip 同形但更轻 */
- .ai-composer__tpl {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- height: 26px;
- padding: 0 10px;
- font-size: 12px;
- color: var(--ai-text-2);
- background: transparent;
- border: 1px solid var(--ai-border);
- border-radius: 13px;
- cursor: pointer;
- transition:
- color 0.15s ease-out,
- border-color 0.15s ease-out;
- &:hover:not(:disabled) {
- color: var(--ai-primary);
- border-color: var(--ai-primary);
- }
- &:disabled {
- cursor: not-allowed;
- opacity: 0.5;
- }
- }
- /* 附件 chips:textarea 与操作行之间一行小胶囊 */
- .ai-composer__atts {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- padding: 4px 2px 0;
- .ai-att {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- max-width: 260px;
- height: 24px;
- padding: 0 8px;
- font-size: 11.5px;
- color: var(--ai-text);
- background: var(--ai-bg-subtle);
- border: 1px solid var(--ai-border);
- border-radius: 12px;
- &.is-failed {
- color: @error-color;
- border-color: fade(@error-color, 35%);
- }
- &__name {
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
- &__meta {
- color: var(--ai-text-2);
- white-space: nowrap;
- }
- &__progress {
- color: var(--ai-primary);
- white-space: nowrap;
- }
- &__remove {
- cursor: pointer;
- &:hover {
- color: @error-color;
- }
- }
- }
- }
- /* 文件选择用按钮触发,原生 input 隐藏 */
- .ai-composer__file {
- display: none;
- }
- .ai-composer__spacer {
- flex: 1 1 auto;
- }
- /* 圆形发送 / 停止按钮 */
- .ai-composer__send {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- flex: 0 0 auto;
- width: 32px;
- height: 32px;
- color: #fff;
- background: @primary-color;
- border: 0;
- border-radius: 50%;
- cursor: pointer;
- transition:
- filter 0.15s ease-out,
- opacity 0.15s ease-out;
- &:hover:not(:disabled) {
- filter: brightness(0.92);
- }
- &:disabled {
- cursor: not-allowed;
- opacity: 0.45;
- }
- &:focus-visible {
- outline: 2px solid @primary-color;
- outline-offset: 2px;
- }
- &.is-stop {
- background: @error-color;
- }
- }
- /* 键位提示:容器外的说明文字 */
- .ai-composer__hint {
- max-width: calc(var(--ai-content-max) - var(--ai-sp-5) * 2);
- margin: 6px auto 0;
- font-size: 11px;
- text-align: center;
- color: #98a2b3;
- }
- @media (max-width: 900px) {
- .ai-composer {
- padding: var(--ai-sp-3);
- }
- .ai-composer__hint {
- display: none;
- }
- }
- </style>
|