|
|
@@ -39,12 +39,13 @@
|
|
|
class="chat-input"
|
|
|
placeholder="请输入你的问题"
|
|
|
:auto-size="{ minRows: 3, maxRows: 6 }"
|
|
|
- @keydown="handleSessionKeydown"
|
|
|
+ @keydown="handleInputKeydown"
|
|
|
@keydown.enter="handleEnterKey"
|
|
|
@keydown.backspace="handleBackspace"
|
|
|
@compositionstart="handleCompositionStart"
|
|
|
@compositionend="handleCompositionEnd"
|
|
|
@blur="handleCompositionEnd"
|
|
|
+ @paste="handlePaste"
|
|
|
/>
|
|
|
|
|
|
<div class="input-actions">
|
|
|
@@ -117,10 +118,10 @@
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
- import { ref } from 'vue';
|
|
|
+ import { computed, nextTick, ref, watch } from 'vue';
|
|
|
import { onClickOutside } from '@vueuse/core';
|
|
|
import { CloseOutlined } from '@ant-design/icons-vue';
|
|
|
- import { formatFileSize, getFileIconVar } from '/@/views/ventAI/manageAssistent/lib/index';
|
|
|
+ import { formatFileSize, getClipboardFile, getFileIconVar, getUserInputText } from '/@/views/ventAI/manageAssistent/lib/index';
|
|
|
import ContextRing from '/@/views/ventAI/manageAssistent/components/chat/Input/ContextRing.vue';
|
|
|
import UsageRing from '/@/views/ventAI/manageAssistent/components/chat/Input/UsageRing.vue';
|
|
|
import ModePopup from '/@/views/ventAI/manageAssistent/components/chat/Input/ModePopup.vue';
|
|
|
@@ -128,6 +129,7 @@
|
|
|
import SessionPopup from '/@/views/ventAI/manageAssistent/components/chat/Input/SessionPopup.vue';
|
|
|
import SkillPopup from '/@/views/ventAI/manageAssistent/components/chat/Input/SkillPopup.vue';
|
|
|
import { useMentionPopups } from '/@/views/ventAI/manageAssistent/hooks/useMentionPopups';
|
|
|
+ import { useInputHistory } from '/@/views/ventAI/manageAssistent/hooks/useInputHistory';
|
|
|
import type { UsageByModel } from '/@/views/ventAI/manageAssistent/api';
|
|
|
import type { AttachedFile, Task } from '/@/views/ventAI/manageAssistent/types/index';
|
|
|
|
|
|
@@ -178,6 +180,8 @@
|
|
|
(e: 'stop'): void;
|
|
|
(e: 'file-upload'): void;
|
|
|
(e: 'file-change', event: Event): void;
|
|
|
+ // 拖拽 / 粘贴进来的文件(已从事件里取出,输入区只负责转交)
|
|
|
+ (e: 'file-add', file: File): void;
|
|
|
(e: 'remove-pending-file'): void;
|
|
|
(e: 'pending-file-preview', file: AttachedFile): void;
|
|
|
(e: 'think-level-change', levelKey: string): void;
|
|
|
@@ -208,6 +212,15 @@
|
|
|
// 会话列表弹框
|
|
|
const textareaRef = ref<InstanceType<any>>();
|
|
|
|
|
|
+ // a-textarea 的根节点就是原生 textarea(换行/自动高度等场景下由内部包装时回去找一层),
|
|
|
+ // 焦点、光标位置、光标归位都基于同一个取法,避免各处各写一份 $el 解析
|
|
|
+ const getTextareaEl = (): HTMLTextAreaElement | undefined => {
|
|
|
+ const root = textareaRef.value?.$el;
|
|
|
+ if (!root) return undefined;
|
|
|
+ if (root instanceof HTMLTextAreaElement) return root;
|
|
|
+ return root.querySelector?.('textarea') || undefined;
|
|
|
+ };
|
|
|
+
|
|
|
// —— 会话/技能引用弹层已下沉到 useMentionPopups(模板仍在本组件,状态与交互逻辑在 composable) ——
|
|
|
const {
|
|
|
showSessionPopup,
|
|
|
@@ -241,8 +254,7 @@
|
|
|
showPopup.value = false;
|
|
|
},
|
|
|
focusTextarea: () => {
|
|
|
- const el = textareaRef.value?.$el?.querySelector('textarea') || textareaRef.value?.$el;
|
|
|
- if (el) el.focus();
|
|
|
+ getTextareaEl()?.focus();
|
|
|
},
|
|
|
setSelectedSessions: (sessions) => emit('update:selectedSessions', sessions),
|
|
|
setSelectedSkill: (skill) => emit('update:selectedSkill', skill),
|
|
|
@@ -260,9 +272,79 @@
|
|
|
composing = false;
|
|
|
};
|
|
|
|
|
|
+ // —— ↑/↓ 历史回溯:历史取当前会话里已发送的用户输入(原始文本,不含注入的引用 HTML) ——
|
|
|
+ const userInputHistory = computed(() => {
|
|
|
+ const task = (props.taskList || []).find((t) => t.id === props.currentTaskId);
|
|
|
+ if (!task) return [];
|
|
|
+ return task.messages
|
|
|
+ .filter((m) => m.type === 'user')
|
|
|
+ .map((m) => getUserInputText(m))
|
|
|
+ .filter((text) => !!text);
|
|
|
+ });
|
|
|
+
|
|
|
+ // 冷启动判据:光标在文本最开头(或输入框为空)才认 ↑。多行草稿里 ↑ 仍归 textarea 移动光标。
|
|
|
+ // 取不到元素时不拦,宁可让它走历史,也不要变成按了没反应的死键。
|
|
|
+ const isCaretAtStart = () => {
|
|
|
+ const el = getTextareaEl();
|
|
|
+ if (!el) return true;
|
|
|
+ return el.selectionStart === 0 && el.selectionEnd === 0;
|
|
|
+ };
|
|
|
+
|
|
|
+ const moveCaretToEnd = () => {
|
|
|
+ // 回填的值要先经父组件 v-model 写回 textarea,必须等一帧再定位光标,否则长度还是旧值
|
|
|
+ nextTick(() => {
|
|
|
+ const el = getTextareaEl();
|
|
|
+ if (!el) return;
|
|
|
+ const end = el.value.length;
|
|
|
+ el.setSelectionRange(end, end);
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ const {
|
|
|
+ handleKeydown: handleHistoryKeydown,
|
|
|
+ notifyValueChanged: notifyHistoryValueChanged,
|
|
|
+ reset: resetInputHistory,
|
|
|
+ } = useInputHistory({
|
|
|
+ getHistory: () => userInputHistory.value,
|
|
|
+ getModelValue: () => props.modelValue,
|
|
|
+ setModelValue: (value) => emit('update:modelValue', value),
|
|
|
+ isComposing: () => composing,
|
|
|
+ isPopupOpen: () => showSessionPopup.value || showSkillPopup.value,
|
|
|
+ isCaretAtStart,
|
|
|
+ moveCaretToEnd,
|
|
|
+ });
|
|
|
+
|
|
|
+ // 切会话后历史整条换掉,回看位置不再有意义
|
|
|
+ watch(
|
|
|
+ () => props.currentTaskId,
|
|
|
+ () => resetInputHistory()
|
|
|
+ );
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 方向键有两个归属:会话/技能弹层打开时归弹层列表导航,否则归输入框历史回溯。
|
|
|
+ * 合成一个入口显式定序(弹层优先),而不是在同一元素上挂两个 @keydown 依赖编译器的合并顺序。
|
|
|
+ */
|
|
|
+ const handleInputKeydown = (e: KeyboardEvent) => {
|
|
|
+ handleSessionKeydown(e);
|
|
|
+ handleHistoryKeydown(e);
|
|
|
+ };
|
|
|
+
|
|
|
const handleTextareaInput = (val: string) => {
|
|
|
emit('update:modelValue', val);
|
|
|
syncPopups(val);
|
|
|
+ // 用户手动改过内容就退出历史回看,否则后续 ↑ 会把他刚改的字覆盖掉
|
|
|
+ notifyHistoryValueChanged(val);
|
|
|
+ };
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 粘贴上传:剪贴板里带文件(截图、复制的文件)时按附件处理,
|
|
|
+ * 图文混排的判定收敛在 lib/clipboard.getClipboardFile 内(有文本就不接管)。
|
|
|
+ */
|
|
|
+ const handlePaste = (e: ClipboardEvent) => {
|
|
|
+ const file = getClipboardFile(e.clipboardData);
|
|
|
+ if (!file) return;
|
|
|
+ e.preventDefault();
|
|
|
+ emit('file-add', file);
|
|
|
};
|
|
|
|
|
|
const handleEnterKey = (e: KeyboardEvent) => {
|
|
|
@@ -275,13 +357,13 @@
|
|
|
// 其余情况沿用「回车不插入换行」:不能发送(内容为空 / 生成中)时也保持这一行为
|
|
|
e.preventDefault();
|
|
|
if (consumeJustSelected()) return;
|
|
|
- if (props.modelValue.trim() && !props.loading) {
|
|
|
- emit('send');
|
|
|
- }
|
|
|
+ handleSend();
|
|
|
};
|
|
|
|
|
|
const handleSend = () => {
|
|
|
if (props.modelValue.trim() && !props.loading) {
|
|
|
+ // 发送后输入框会被清空,历史回看位置一并作废
|
|
|
+ resetInputHistory();
|
|
|
emit('send');
|
|
|
}
|
|
|
};
|