فهرست منبع

[Feat 0000] AI助手对话输入框添加附件可拖动上传以及上下键回溯消息

wangkeyi 1 هفته پیش
والد
کامیت
e7aa2357a1

+ 62 - 2
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -21,7 +21,18 @@
     <div class="collapse-btn" @click="togglePanel">
       <div :class="['collapse-icon', { collapsed: panelCollapsed }]"></div>
     </div>
-    <div class="ai-container">
+    <div
+      class="ai-container"
+      @dragenter="onDragEnter"
+      @dragover="onDragOver"
+      @dragleave="onDragLeave"
+      @drop="onDrop"
+    >
+      <!-- 拖拽经过时的「可放置」提示。pointer-events: none 不能去掉:遮罩一旦能命中,
+           它自己会连续触发 dragenter/dragleave,反馈会持续闪断 -->
+      <div v-if="isDragOver" class="drop-overlay">
+        <div class="drop-overlay-text">松开鼠标添加附件</div>
+      </div>
       <!-- 左侧任务列表 -->
       <div :class="['task-panel-wrapper', { collapsed: panelCollapsed }]">
         <TaskListPanel
@@ -116,6 +127,7 @@
             @stop="stopStreaming"
             @file-upload="triggerFileUpload"
             @file-change="handleFileUpload"
+            @file-add="handleFileAdded"
             @remove-pending-file="removePendingFile"
             @pending-file-preview="openFilePreview"
             @think-level-change="handleThinkLevelChange"
@@ -193,6 +205,7 @@
   import { useChatSend } from '../hooks/useChatSend';
   import { useChatScroll } from '../hooks/useChatScroll';
   import { useChatFiles } from '../hooks/useChatFiles';
+  import { useFileDrop } from '../hooks/useFileDrop';
   import { useChatQuota } from '../hooks/useChatQuota';
   import { useChatExport } from '../hooks/useChatExport';
 
@@ -486,7 +499,24 @@
 
   // —— 以下四组职责已下沉到 composables(额度/导出/滚动/文件),组件只做接线 ——
   const { scrollToBottom, scrollToBottomThrottled } = useChatScroll(() => chatMessagesRef.value);
-  const { pendingFile, activeFileReader, triggerFileUpload, handleFileUpload, removePendingFile } = useChatFiles(() => chatInputRef.value);
+  const { pendingFile, activeFileReader, triggerFileUpload, handleFileUpload, addFile, removePendingFile } = useChatFiles(() => chatInputRef.value);
+
+  // 拖拽 / 粘贴进来的文件与「选择文件」共用同一条入队路径:校验与读取分流都在 addFile 内
+  const handleFileAdded = (file: File) => {
+    addFile(file);
+  };
+
+  const handleDropFiles = (files: File[]) => {
+    // 待发附件是单值,多文件拖入时取第一个并说明,避免用户以为全都加上了
+    if (files.length > 1) message.warning('一次只能添加一个附件,已取第一个文件');
+    addFile(files[0]);
+  };
+
+  const { isDragOver, dragHandlers } = useFileDrop({
+    onFiles: handleDropFiles,
+    isVisible: () => props.visible,
+  });
+  const { onDragEnter, onDragOver, onDragLeave, onDrop } = dragHandlers;
   const {
     usageToday,
     fetchTodayUsage,
@@ -872,12 +902,42 @@
 </script>
 
 <style scoped lang="less">
+  @import '../styles/tokens.less';
+
   .ai-container {
     display: flex;
     height: 700px;
     gap: 10px;
     overflow: hidden;
     font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+    /* 拖拽提示遮罩的定位基准 */
+    position: relative;
+  }
+
+  /* 拖拽上传提示:z-index 低于弹框的收起/最大化按钮(10),拖拽时窗口控件仍保持可见 */
+  .drop-overlay {
+    position: absolute;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    left: 0;
+    z-index: 5;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    pointer-events: none;
+    background: @color-primary-a10;
+    border: 2px dashed @color-primary-a50;
+    border-radius: 4px;
+  }
+
+  .drop-overlay-text {
+    padding: 8px 20px;
+    border-radius: 6px;
+    background: @color-panel-bg;
+    border: 1px solid @color-panel-border;
+    color: @color-text;
+    font-size: 13px;
   }
 
   .collapse-btn {

+ 90 - 8
src/views/ventAI/manageAssistent/components/chat/ChatInputArea.vue

@@ -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');
     }
   };

+ 2 - 8
src/views/ventAI/manageAssistent/components/chat/ChatMessages.vue

@@ -166,6 +166,7 @@
     getTableIcons,
     copyToClipboard,
     flashCopySuccess,
+    getUserInputText,
   } from '/@/views/ventAI/manageAssistent/lib/index';
   import type { AgentGroup } from '/@/views/ventAI/manageAssistent/lib/index';
   import type { AgentDetailData, AskUserQuestion, Message, AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
@@ -194,13 +195,6 @@
   const editingKey = ref('');
   const editDraft = ref('');
 
-  // 剥离注入的技能/会话引用 <img> HTML,用于编辑时回填原始文本
-  const stripInjectedHtml = (content: string): string =>
-    (content || '')
-      .replace(/\[<img[^>]*>[^\]]*\]/g, '')
-      .replace(/<img[^>]*>/g, '')
-      .trim();
-
   // ===== 多回答折叠:重新生成产生的连续多条 AI 消息在视觉上合并为一条,可通过 prev/next 切换查看 =====
   interface AnswerGroup {
     indices: number[];
@@ -343,7 +337,7 @@
   const startEdit = (index: number) => {
     const msg = props.messages[index];
     editingKey.value = getMessageKey(msg);
-    editDraft.value = msg.rawInput || stripInjectedHtml(msg.content);
+    editDraft.value = getUserInputText(msg);
   };
 
   const cancelEdit = () => {

+ 25 - 13
src/views/ventAI/manageAssistent/hooks/useChatFiles.ts

@@ -5,7 +5,10 @@ import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
 import { isAudioFile, isCsvFile, isDxfFile, isExcelFile, isTextFile, isVideoFile, isWordFile } from '/@/views/ventAI/manageAssistent/lib/index';
 import type ChatInputArea from '/@/views/ventAI/manageAssistent/components/chat/ChatInputArea.vue';
 
-/** 输入区附件:选择文件 → 按类型读取(文本/二进制/预览)→ 待发送状态 */
+/**
+ * 输入区附件:文件选择 / 拖拽 / 粘贴三条入口统一走 addFile —— 校验 → 按类型读取(文本/二进制/预览)
+ * → 落到待发送状态。三条入口的差别只在「文件从哪来」,校验与分流必须共用一份,否则会各自漂移。
+ */
 export const useChatFiles = (getChatInput: () => InstanceType<typeof ChatInputArea> | undefined) => {
   const pendingFile = ref<AttachedFile | null>(null);
   const activeFileReader = ref<FileReader | null>(null);
@@ -14,28 +17,30 @@ export const useChatFiles = (getChatInput: () => InstanceType<typeof ChatInputAr
     getChatInput()?.fileInputRef?.click();
   };
 
-  const handleFileUpload = async (event: Event) => {
-    const target = event.target as HTMLInputElement;
-    const file = target.files?.[0];
-    if (!file) return;
+  /**
+   * 校验并读入一个文件作为待发送附件。
+   * 待发附件是单值(pendingFile),多选/多文件场景由调用方取第一个后再进来。
+   */
+  const addFile = (file: File): void => {
+    // 剪贴板里取到的文件可能没有文件名(部分浏览器的截图项),补一个仅用于展示与分流的名字
+    const name = file.name || `粘贴附件_${dayjs().format('HHmmss')}`;
 
-    if (isVideoFile(file.name) || isAudioFile(file.name)) {
+    if (isVideoFile(name) || isAudioFile(name)) {
       message.warning('不支持上传音频和视频文件');
-      target.value = '';
       return;
     }
 
     try {
       const newFile: AttachedFile = {
         id: `file-${Date.now()}`,
-        name: file.name,
+        name,
         size: file.size,
         type: file.type,
         uploadTime: dayjs().format('YYYY-MM-DD HH:mm'),
         originalFile: file,
       };
 
-      // Abort previous FileReader if still in progress
+      // 换附件时中断上一次仍在读取的 FileReader
       if (activeFileReader.value) {
         activeFileReader.value.abort();
         activeFileReader.value = null;
@@ -48,9 +53,9 @@ export const useChatFiles = (getChatInput: () => InstanceType<typeof ChatInputAr
       // .dxf 必须在此显式分流到文本:否则会落到 dataURL 分支,附件里只有 preview、拿不到 content,
       // DXF 预览面板只能显示空白。
       const readMode =
-        isTextFile(file.name) || isCsvFile(file.name) || isDxfFile(file.name)
+        isTextFile(name) || isCsvFile(name) || isDxfFile(name)
           ? 'text'
-          : isWordFile(file.name) || isExcelFile(file.name)
+          : isWordFile(name) || isExcelFile(name)
             ? 'binary'
             : 'dataurl';
 
@@ -74,18 +79,25 @@ export const useChatFiles = (getChatInput: () => InstanceType<typeof ChatInputAr
       }
 
       pendingFile.value = newFile;
-      message.success(`文件 "${file.name}" 已添加,点击发送按钮提交`);
+      message.success(`文件 "${name}" 已添加,点击发送按钮提交`);
     } catch (error) {
       console.error('文件处理失败:', error);
       message.error('文件处理失败,请重试');
     }
+  };
 
+  const handleFileUpload = (event: Event) => {
+    const target = event.target as HTMLInputElement;
+    const file = target.files?.[0];
+    // 无论是否取到文件都复位,否则再次选择同一个文件不会再触发 change
     target.value = '';
+    if (!file) return;
+    addFile(file);
   };
 
   const removePendingFile = () => {
     pendingFile.value = null;
   };
 
-  return { pendingFile, activeFileReader, triggerFileUpload, handleFileUpload, removePendingFile };
+  return { pendingFile, activeFileReader, triggerFileUpload, handleFileUpload, addFile, removePendingFile };
 };

+ 2 - 9
src/views/ventAI/manageAssistent/hooks/useChatSend.ts

@@ -3,7 +3,7 @@ import dayjs from 'dayjs';
 import { message } from 'ant-design-vue';
 import { unifiedStream, reviewPdfStream, getDetail, deleteMessage } from '/@/views/ventAI/manageAssistent/api';
 import type { AttachedFile, Message, SseEvent, Task } from '/@/views/ventAI/manageAssistent/types/index';
-import { getCssUrl, normalizeLineBreaks } from '/@/views/ventAI/manageAssistent/lib/index';
+import { getCssUrl, getUserInputText, normalizeLineBreaks, stripInjectedRefs } from '/@/views/ventAI/manageAssistent/lib/index';
 
 /**
  * 发送与流式管线:推送一条空 AI 消息 → 建 AbortController → 走 SSE(纯文本 / PDF 审查)→ 处理
@@ -237,13 +237,6 @@ export const useChatSend = (deps: ChatSendDeps) => {
     await streamAiAnswer(userInput, file);
   };
 
-  // 剥离用户消息里注入的技能/会话引用 <img> HTML(rawInput 缺失时的兜底)
-  const stripInjectedRefs = (content: string): string =>
-    (content || '')
-      .replace(/\[<img[^>]*>[^\]]*\]/g, '')
-      .replace(/<img[^>]*>/g, '')
-      .trim();
-
   // 补齐消息的数据库主键 id(刚流式生成的消息前端没有 id,但后端已持久化)。
   //
   // sessionId 与 msgs 都由调用方在流程开始时锚定后传入:本函数内部有 await,如果这里再去读
@@ -318,7 +311,7 @@ export const useChatSend = (deps: ChatSendDeps) => {
     if (userIdx < 0) return;
 
     const userMsg = msgs[userIdx];
-    const userInput = userMsg.rawInput || stripInjectedRefs(userMsg.content);
+    const userInput = getUserInputText(userMsg);
     const attachedFile = userMsg.attachedFile;
 
     // 不删除旧 AI 消息、不动用户提问,直接重发同一条用户输入重新生成。

+ 89 - 0
src/views/ventAI/manageAssistent/hooks/useFileDrop.ts

@@ -0,0 +1,89 @@
+import { onBeforeUnmount, ref, watch } from 'vue';
+
+/**
+ * 文件拖拽上传:在容器上接收拖入的文件,并在拖拽经过时给出「可放置」反馈。
+ *
+ * 文档级兜底:助手弹框可见期间,对「拖入文件」这一类拖拽统一 preventDefault。弹框是全屏的,
+ * 用户很容易把文件掉在标题栏或留白处——不拦的话浏览器会直接跳去打开本地文件,SPA 状态全部丢失。
+ * 只拦 dataTransfer.types 含 Files 的拖拽:页面内其它拖拽(如树节点排序)不受影响,
+ * 且 preventDefault 只取消浏览器默认行为,不阻断目标元素自己的 drop 处理。
+ *
+ * 反馈遮罩由调用方渲染,注意要设 pointer-events: none,否则遮罩自己会吃掉拖拽事件。
+ */
+export interface FileDropDeps {
+  onFiles: (files: File[]) => void;
+  /** 助手弹框是否可见:不可见时不安装兜底,也不响应拖拽 */
+  isVisible: () => boolean;
+}
+
+export const useFileDrop = (deps: FileDropDeps) => {
+  const isDragOver = ref(false);
+  // 进入/离开按次数配对抵消:拖过子元素时会连续触发 enter/leave,只用布尔量会让反馈闪断
+  let depth = 0;
+
+  const isFileDrag = (e: DragEvent): boolean => Array.from(e.dataTransfer?.types || []).includes('Files');
+
+  const resetDragOver = () => {
+    depth = 0;
+    isDragOver.value = false;
+  };
+
+  const onDragEnter = (e: DragEvent) => {
+    if (!deps.isVisible() || !isFileDrag(e)) return;
+    e.preventDefault();
+    depth += 1;
+    isDragOver.value = true;
+  };
+
+  const onDragOver = (e: DragEvent) => {
+    if (!deps.isVisible() || !isFileDrag(e)) return;
+    // 必须阻止默认行为,否则浏览器不把这里当作可放置区域,drop 永远不会触发
+    e.preventDefault();
+    if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
+    isDragOver.value = true;
+  };
+
+  const onDragLeave = () => {
+    if (!isDragOver.value) return;
+    depth = Math.max(0, depth - 1);
+    if (depth === 0) isDragOver.value = false;
+  };
+
+  const onDrop = (e: DragEvent) => {
+    if (!deps.isVisible() || !isFileDrag(e)) {
+      resetDragOver();
+      return;
+    }
+    e.preventDefault();
+    const files = Array.from(e.dataTransfer?.files || []);
+    resetDragOver();
+    if (files.length) deps.onFiles(files);
+  };
+
+  const guardDocumentDrop = (e: DragEvent) => {
+    if (isFileDrag(e)) e.preventDefault();
+  };
+
+  const bindGuard = () => {
+    document.addEventListener('dragover', guardDocumentDrop);
+    document.addEventListener('drop', guardDocumentDrop);
+  };
+
+  const unbindGuard = () => {
+    document.removeEventListener('dragover', guardDocumentDrop);
+    document.removeEventListener('drop', guardDocumentDrop);
+  };
+
+  watch(
+    () => deps.isVisible(),
+    (visible) => {
+      if (visible) bindGuard();
+      else unbindGuard();
+    },
+    { immediate: true }
+  );
+
+  onBeforeUnmount(unbindGuard);
+
+  return { isDragOver, dragHandlers: { onDragEnter, onDragOver, onDragLeave, onDrop } };
+};

+ 108 - 0
src/views/ventAI/manageAssistent/hooks/useInputHistory.ts

@@ -0,0 +1,108 @@
+import { ref } from 'vue';
+
+/**
+ * 输入框 ↑/↓ 历史回溯:把当前会话里已发送的用户输入当作历史,↑ 逐条回看、↓ 前进,
+ * 越回最新一条之后还原成进入回溯前的草稿。
+ *
+ * 与相邻交互的分工(顺序不能反,否则会互相抢键):
+ * - 会话/技能弹层打开时方向键归弹层(isPopupOpen),本模块直接让位;
+ * - 输入法组合态期间方向键归输入法(isComposing),候选翻页不能被当成历史回溯;
+ * - 冷启动只认「输入框为空」或「光标在首位」,否则多行草稿里正常的光标上下移动会被抢走。
+ * 进入回溯后不再看光标位置:此时框里是历史内容,↑/↓ 一律走历史,来回翻看才连贯。
+ */
+export interface InputHistoryDeps {
+  /** 历史条目,按时间升序(最早 → 最新) */
+  getHistory: () => string[];
+  getModelValue: () => string;
+  setModelValue: (value: string) => void;
+  isComposing: () => boolean;
+  /** 会话/技能弹层是否打开 */
+  isPopupOpen: () => boolean;
+  /** 光标是否在文本最开头(含全选为空的情况) */
+  isCaretAtStart: () => boolean;
+  /** 回填历史后把光标移到末尾,便于继续输入或再翻 */
+  moveCaretToEnd: () => void;
+}
+
+export const useInputHistory = (deps: InputHistoryDeps) => {
+  const browsing = ref(false);
+  // 当前回看到的下标(指向 getHistory() 的结果);-1 表示未进入回溯
+  let cursor = -1;
+  // 进入回溯前的草稿:↓ 越过最新一条时原样还回去,避免用户敲了一半的内容被历史顶掉
+  let draft = '';
+  // 本模块最后一次写入的值,用来区分「程序回填」与「用户手动编辑」
+  let lastRecalled = '';
+
+  const exitBrowsing = () => {
+    browsing.value = false;
+    cursor = -1;
+    draft = '';
+    lastRecalled = '';
+  };
+
+  const reset = () => {
+    exitBrowsing();
+  };
+
+  /** 用户手动改了输入内容:历史回看就此结束,否则后续 ↑ 会把他刚改的字覆盖掉 */
+  const notifyValueChanged = (value: string) => {
+    if (!browsing.value) return;
+    if (value === lastRecalled) return;
+    exitBrowsing();
+  };
+
+  const recall = (value: string) => {
+    lastRecalled = value;
+    deps.setModelValue(value);
+    deps.moveCaretToEnd();
+  };
+
+  const handleKeydown = (e: KeyboardEvent) => {
+    if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
+    // 带修饰键的组合留给系统或其它快捷键(如 Ctrl+↑ 等)
+    if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
+    if (deps.isComposing() || e.isComposing) return;
+    if (deps.isPopupOpen()) return;
+
+    const history = deps.getHistory();
+    if (!history.length) return;
+
+    if (e.key === 'ArrowUp') {
+      if (!browsing.value) {
+        if (!deps.isCaretAtStart()) return;
+        draft = deps.getModelValue();
+        cursor = history.length - 1;
+      } else {
+        // 已经是最早一条:吞掉按键,避免光标跳到开头造成「到底了」的错觉
+        if (cursor <= 0) {
+          e.preventDefault();
+          return;
+        }
+        cursor -= 1;
+      }
+      e.preventDefault();
+      // 历史可能在回看期间被追加(如流结束补齐消息),下标越界时退到最新一条
+      if (cursor > history.length - 1) cursor = history.length - 1;
+      browsing.value = true;
+      recall(history[cursor]);
+      return;
+    }
+
+    // ArrowDown:未进入回溯时归 textarea 自己,正常移动光标
+    if (!browsing.value) return;
+    e.preventDefault();
+    if (cursor >= history.length - 1) {
+      // 越过最新一条:还原草稿并退出回溯
+      const restored = draft;
+      exitBrowsing();
+      lastRecalled = restored;
+      deps.setModelValue(restored);
+      deps.moveCaretToEnd();
+      return;
+    }
+    cursor += 1;
+    recall(history[cursor]);
+  };
+
+  return { browsing, handleKeydown, notifyValueChanged, reset };
+};

+ 16 - 1
src/views/ventAI/manageAssistent/lib/clipboard.ts

@@ -1,7 +1,22 @@
-// lib/clipboard.ts:剪贴板写入与「复制成功」回显。
+// lib/clipboard.ts:剪贴板写入/回显,以及「粘贴进来的内容该不该当附件」的判定。
 // 表格工具栏、markdown 代码块工具栏、消息复制原先各写一份 navigator.clipboard + execCommand
 // 降级分支,这里收敛为唯一实现。
 
+/**
+ * 从剪贴板里取出应当作为附件处理的文件;不该接管时返回 null。
+ *
+ * 只有剪贴板里没有文本时才接管:图文混排的复制内容(如网页里带说明的图片)既有文件也有文本,
+ * 用户多半想要文本,此时交给输入框的默认粘贴行为。截图、从资源管理器复制的文件都没有
+ * text/plain,因此正常命中。
+ */
+export const getClipboardFile = (clipboard: DataTransfer | null | undefined): File | null => {
+  if (!clipboard) return null;
+  const files = Array.from(clipboard.files || []);
+  if (!files.length) return null;
+  if (clipboard.getData('text/plain')) return null;
+  return files[0];
+};
+
 /**
  * 写入剪贴板。
  * navigator.clipboard 只在安全上下文(https / localhost)可用,其余情况降级到 execCommand。

+ 1 - 0
src/views/ventAI/manageAssistent/lib/index.ts

@@ -8,3 +8,4 @@ export * from './filePreview';
 export * from './messageKey';
 export * from './clipboard';
 export * from './dxf';
+export * from './userInput';

+ 16 - 0
src/views/ventAI/manageAssistent/lib/userInput.ts

@@ -0,0 +1,16 @@
+// lib/userInput.ts:用户消息原始输入的还原。
+//
+// 发送时会把「技能/会话引用」以 <img> 形式拼进展示文本(content),原始输入另存在 rawInput。
+// 需要读回用户真正敲进去的那段文字时(编辑重发、重新生成、输入框 ↑ 历史回溯)都要走同一套剥离规则,
+// 原先 useChatSend 与 ChatMessages 各写一份实现,这里收敛为唯一来源。
+import type { Message } from '/@/views/ventAI/manageAssistent/types/index';
+
+/** 剥离用户消息里注入的技能/会话引用 <img> HTML(rawInput 缺失时的兜底) */
+export const stripInjectedRefs = (content: string): string =>
+  (content || '')
+    .replace(/\[<img[^>]*>[^\]]*\]/g, '')
+    .replace(/<img[^>]*>/g, '')
+    .trim();
+
+/** 取用户消息的原始输入:优先 rawInput,缺失时回退到剥离注入 HTML 的展示内容 */
+export const getUserInputText = (msg: Pick<Message, 'rawInput' | 'content'>): string => msg.rawInput || stripInjectedRefs(msg.content);