Ver código fonte

[Mod 0000]AI助手调整附件上传逻辑以及修改web标签样式

wangkeyi 1 dia atrás
pai
commit
379958ec1f

+ 9 - 0
src/views/ventAI/manageAssistent/api/errors.ts

@@ -40,6 +40,15 @@ export const throwStreamHttpError = async (response: Response): Promise<never> =
     if (body?.detail) {
       if (typeof body.detail === 'string') {
         message = body.detail;
+      } else if (Array.isArray(body.detail)) {
+        // FastAPI 参数校验错误:detail 是数组(例如 message 缺失时返回 422)。
+        // 只取第一条并拼上字段位置,避免界面上只剩「HTTP错误: 422」。
+        const first = body.detail[0] as { msg?: string; loc?: unknown[] } | undefined;
+        if (first?.msg) {
+          const loc = Array.isArray(first.loc) ? first.loc.join('.') : '';
+          const reason = first.msg === 'Field required' ? '缺少必填字段' : first.msg;
+          message = `请求参数校验失败:${loc ? `${reason}(${loc})` : reason}`;
+        }
       } else {
         detail = body.detail as ApiErrorDetail;
         message = detail.message || message;

+ 25 - 4
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -694,6 +694,14 @@
     const hasText = inputMessage.value.trim();
     const hasFile = pendingFile.value;
 
+    // 发送前置快照:请求失败时按这份内容回填输入区(文本 + 附件)。
+    // 必须在 createNewTask 之前取:createNewTask -> switchTask 会清空输入区。
+    // 输入框清空会触发输入区内部把 # 会话 / $ 技能引用一起清掉,这里只回填用户能直接看到的两项;
+    // 引用标签需要用户重新选择(避免父组件状态与输入区内部状态不同步)。
+    const draftText = inputMessage.value;
+    const draftFile = pendingFile.value;
+
+    // 后端 message 为必填字段,纯附件不能单独发送,必须有文字
     if (!hasText) {
       message.warning('请输入消息内容');
       return;
@@ -707,7 +715,15 @@
     // 否则 push 出去的消息会落在视图之外的临时数组里,之后再也不会显示
     if (!currentTask.value) {
       await createNewTask();
-      if (!currentTask.value) return;
+      if (!currentTask.value) {
+        // 建任务失败时也把草稿还回去,避免空白任务创建异常把用户输入吞掉
+        inputMessage.value = draftText;
+        pendingFile.value = draftFile;
+        return;
+      }
+      // createNewTask -> switchTask 会清空输入区,把刚捕获的草稿放回去再组装请求
+      inputMessage.value = draftText;
+      pendingFile.value = draftFile;
     }
 
     const sessionPrefix = selectedSessions.value.length > 0 ? selectedSessions.value.map((s) => `#session:${s.id}`).join(' ') + ' ' : '';
@@ -725,8 +741,6 @@
       displayContent = `[<img src="${iconUrl}" class="session-ref-icon" /> 引用 ${selectedSessions.value.length} 个会话:${names}] ${displayContent}`;
     }
 
-    const fileToSend = pendingFile.value;
-
     inputMessage.value = '';
     pendingFile.value = null;
     selectedSessions.value = [];
@@ -741,7 +755,7 @@
       chatMessagesRef.value?.forceScrollToBottom();
 
       if (hasFile) {
-        await sendWithAttachment(fileToSend!, userInput, displayContent);
+        await sendWithAttachment(draftFile!, userInput, displayContent);
       } else {
         await sendTextOnly(userInput, displayContent);
       }
@@ -766,6 +780,13 @@
           // 这轮请求失败了,后端没有对应记录:标记为仅前端消息,回填主键时跳过
           localOnly: true,
         });
+
+        // 失败回填:只在用户仍停留在发起任务上时恢复,避免把旧会话的草稿塞进正在看的另一个会话。
+        // 输入框/附件已被用户重新填写时不覆盖(宁可保留用户新输入,也不要把旧内容盖上去)。
+        if (currentTask.value === originTask) {
+          if (!inputMessage.value) inputMessage.value = draftText;
+          if (!pendingFile.value) pendingFile.value = draftFile;
+        }
       }
     } finally {
       // 自动滚到底只对「用户仍停在这条任务上」有意义,否则会把正在看的别的会话拽到底部。

+ 256 - 29
src/views/ventAI/manageAssistent/components/resource/WebPreviewPanel.vue

@@ -2,12 +2,35 @@
   <div class="web-preview-panel">
     <!-- 地址栏:面板里已经在浏览的真实地址(同源,可读 location),随时能复制或跳回新窗口 -->
     <div class="web-toolbar">
-      <div class="web-url" :title="url">{{ displayUrl }}</div>
-      <a-select v-model:value="sizeKey" class="web-size-select" size="small" :options="sizeOptions" :dropdown-match-select-width="false" />
-      <div class="web-actions">
-        <a-button size="small" type="link" @click="reload">刷新</a-button>
-        <a-button size="small" type="link" @click="openInBrowser">新窗口打开</a-button>
-        <a-button size="small" type="link" @click="copyUrl">复制链接</a-button>
+      <div class="web-address-row">
+        <div class="web-url" :title="url">
+          <span class="web-url-icon"></span>
+          <span class="web-url-text">{{ displayUrl }}</span>
+        </div>
+      </div>
+      <div class="web-controls-row">
+        <a-select
+          v-model:value="sizeKey"
+          class="web-size-select"
+          size="small"
+          :options="sizeOptions"
+          :dropdown-match-select-width="false"
+          dropdown-class-name="web-size-dropdown"
+        />
+        <div class="web-actions">
+          <button type="button" class="web-action-btn" title="刷新" @click="reload">
+            <span class="web-action-icon web-action-icon-refresh"></span>
+            <span>刷新</span>
+          </button>
+          <button type="button" class="web-action-btn" title="在新窗口打开" @click="openInBrowser">
+            <span class="web-action-icon web-action-icon-export"></span>
+            <span>新窗口</span>
+          </button>
+          <button type="button" class="web-action-btn" title="复制链接" @click="copyUrl">
+            <span class="web-action-icon web-action-icon-copy"></span>
+            <span>复制链接</span>
+          </button>
+        </div>
       </div>
     </div>
     <!--
@@ -16,7 +39,10 @@
       只缩不放(放大会糊),四周留白居中;选「自适应面板」时退回铺满、不缩放。
     -->
     <div ref="stageRef" class="web-stage">
-      <div v-if="loading" class="web-loading">正在加载页面…</div>
+      <div v-if="loading" class="web-loading">
+        <span class="web-loading-spinner"></span>
+        <span>正在加载页面…</span>
+      </div>
       <div class="web-viewport" :class="{ 'is-loading': loading }" :style="viewportStyle">
         <iframe ref="frameRef" class="web-frame" :src="url" frameborder="0" @load="loading = false"></iframe>
       </div>
@@ -90,11 +116,14 @@
     resizeObserver = null;
   });
 
-  // 标签栏里显示短路径,完整地址放在 title 上
+  // 地址栏文案:同源页面显示路径,外部页面显示域名 + 路径;完整地址放在 title 上。
+  // 之前只取 pathname,外部站点根路径会显示成孤零零的 "/"。
   const displayUrl = computed(() => {
     try {
       const u = new URL(props.url, window.location.href);
-      return u.pathname + u.search || u.href;
+      const path = `${u.pathname}${u.search}${u.hash}`;
+      if (u.origin === window.location.origin) return path || '/';
+      return `${u.host}${path === '/' ? '' : path}`;
     } catch {
       return props.url;
     }
@@ -144,39 +173,183 @@
     flex-direction: column;
     height: 100%;
     min-height: 0;
+    background: linear-gradient(180deg, rgb(8 37 58 / 98%), rgb(4 22 36 / 98%));
   }
 
   .web-toolbar {
     display: flex;
-    align-items: center;
+    flex-direction: column;
     gap: 8px;
     flex-shrink: 0;
-    padding: 8px 10px;
-    border-bottom: 1px solid rgb(63 80 106 / 25%);
+    padding: 10px 12px;
+    border-bottom: 1px solid rgb(63 80 106 / 35%);
+    background: rgb(2 18 30 / 35%);
+  }
 
-    .web-url {
-      flex: 1;
+  .web-address-row {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+
+  .web-url {
+    display: flex;
+    flex: 1;
+    align-items: center;
+    gap: 8px;
+    min-width: 0;
+    height: 30px;
+    padding: 0 10px;
+    border: 1px solid rgb(63 80 106 / 50%);
+    border-radius: 6px;
+    background: rgb(1 16 27 / 72%);
+    color: #c9d1d9;
+    font-size: 12px;
+    transition:
+      border-color 0.18s,
+      background 0.18s;
+
+    &:hover {
+      border-color: rgb(10 132 255 / 40%);
+      background: rgb(1 20 34 / 90%);
+    }
+
+    .web-url-icon {
+      width: 14px;
+      height: 14px;
+      flex-shrink: 0;
+      background-image: var(--img-chat-link-icon);
+      background-repeat: no-repeat;
+      background-size: 100% 100%;
+      opacity: 0.8;
+    }
+
+    .web-url-text {
       min-width: 0;
-      font-size: 12px;
-      color: #8b949e;
       overflow: hidden;
       text-overflow: ellipsis;
       white-space: nowrap;
-      padding: 2px 8px;
-      border-radius: 4px;
-      background: rgb(255 255 255 / 5%);
+      letter-spacing: 0.2px;
     }
+  }
 
-    .web-size-select {
-      width: 116px;
-      flex-shrink: 0;
+  .web-controls-row {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    flex-wrap: wrap;
+    min-width: 0;
+  }
+
+  .web-size-select {
+    width: 118px;
+    height: 28px;
+    flex-shrink: 0;
+
+    :deep(.zxm-select-selector) {
+      height: 28px !important;
+      padding: 0 9px !important;
+      border-color: rgb(63 80 106 / 50%) !important;
+      border-radius: 6px !important;
+      background: rgb(1 16 27 / 72%) !important;
+      box-shadow: none !important;
+      transition:
+        border-color 0.18s,
+        background 0.18s;
     }
 
-    .web-actions {
-      display: flex;
-      align-items: center;
-      gap: 2px;
+    :deep(.zxm-select-selection-item) {
+      color: #c9d1d9 !important;
+      font-size: 12px;
+      line-height: 26px !important;
+    }
+
+    :deep(.zxm-select-arrow) {
+      color: #8b949e !important;
+    }
+
+    &:hover :deep(.zxm-select-selector) {
+      border-color: rgb(10 132 255 / 40%) !important;
+      background: rgb(1 20 34 / 90%) !important;
+    }
+
+    &.zxm-select-focused :deep(.zxm-select-selector) {
+      border-color: #5cb8ff !important;
+      box-shadow: 0 0 0 2px rgb(10 132 255 / 15%) !important;
+    }
+  }
+
+  .web-actions {
+    display: flex;
+    align-items: center;
+    gap: 4px;
+    margin-left: auto;
+    flex-shrink: 0;
+  }
+
+  .web-action-btn {
+    display: inline-flex;
+    align-items: center;
+    gap: 4px;
+    height: 28px;
+    padding: 0 9px;
+    border: 1px solid transparent;
+    border-radius: 6px;
+    background: transparent;
+    color: #8b949e;
+    font-size: 12px;
+    line-height: 1;
+    white-space: nowrap;
+    cursor: pointer;
+    transition:
+      color 0.18s,
+      background 0.18s,
+      border-color 0.18s;
+
+    .web-action-icon {
+      width: 14px;
+      height: 14px;
       flex-shrink: 0;
+      background-repeat: no-repeat;
+      background-size: 100% 100%;
+      opacity: 0.7;
+      transition: opacity 0.18s;
+    }
+
+    .web-action-icon-refresh {
+      background-image: var(--img-chat-regenerate-icon);
+    }
+
+    .web-action-icon-export {
+      background-image: var(--img-chat-export-icon);
+    }
+
+    .web-action-icon-copy {
+      background-image: var(--img-chat-copy-icon);
+    }
+
+    &:hover {
+      border-color: rgb(10 132 255 / 22%);
+      background: rgb(10 132 255 / 12%);
+      color: #e0e6ed;
+
+      .web-action-icon {
+        opacity: 1;
+      }
+    }
+
+    &:active {
+      background: rgb(10 132 255 / 20%);
+    }
+
+    &:focus-visible {
+      outline: 1px solid #5cb8ff;
+      outline-offset: 1px;
+    }
+
+    /* 鼠标点击后不保留持续高亮,只有键盘聚焦才显示描边 */
+    &:focus:not(:focus-visible) {
+      outline: none;
     }
   }
 
@@ -184,17 +357,24 @@
     position: relative;
     flex: 1;
     min-height: 0;
+    margin: 10px 12px 12px;
     overflow: hidden;
+    border: 1px solid rgb(63 80 106 / 35%);
+    border-radius: 8px;
     // 缩放后四周的留白底色,跟面板区分开,看起来像「一块屏幕」
-    background: rgb(0 0 0 / 22%);
+    background: radial-gradient(ellipse at 50% 0%, rgb(10 132 255 / 8%), transparent 60%), #03111d;
+    box-shadow:
+      inset 0 0 0 1px rgb(255 255 255 / 2%),
+      0 8px 24px rgb(0 0 0 / 25%);
   }
 
   .web-viewport {
     position: absolute;
     transform-origin: center center;
     overflow: hidden;
+    border-radius: 4px;
     background: #fff;
-    box-shadow: 0 6px 22px rgb(0 0 0 / 40%);
+    box-shadow: 0 8px 28px rgb(0 0 0 / 42%);
 
     &.is-loading {
       visibility: hidden;
@@ -211,10 +391,57 @@
   .web-loading {
     position: absolute;
     inset: 0;
+    z-index: 1;
     display: flex;
     align-items: center;
     justify-content: center;
-    font-size: 13px;
+    gap: 8px;
+    background: rgb(3 17 29 / 40%);
     color: #8b949e;
+    font-size: 12px;
+
+    .web-loading-spinner {
+      width: 14px;
+      height: 14px;
+      border: 2px solid rgb(92 184 255 / 20%);
+      border-top-color: #5cb8ff;
+      border-radius: 50%;
+      animation: web-loading-spin 0.8s linear infinite;
+    }
+  }
+
+  @keyframes web-loading-spin {
+    to {
+      transform: rotate(360deg);
+    }
+  }
+</style>
+
+<style lang="less">
+  /* 下拉挂在 body 下,不能用 scoped;与面板深色风格保持一致 */
+  .web-size-dropdown {
+    padding: 4px;
+    border: 1px solid #1a4a6f;
+    border-radius: 6px;
+    background: #0a2a3f;
+    box-shadow: 0 6px 20px rgb(0 0 0 / 42%);
+
+    .zxm-select-item {
+      min-height: 28px;
+      padding: 4px 9px;
+      border-radius: 4px;
+      color: #c9d1d9;
+      font-size: 12px;
+    }
+
+    .zxm-select-item-option-active:not(.zxm-select-item-option-disabled) {
+      background: rgb(10 132 255 / 12%);
+    }
+
+    .zxm-select-item-option-selected:not(.zxm-select-item-option-disabled) {
+      background: rgb(10 132 255 / 18%);
+      color: #e0e6ed;
+      font-weight: 400;
+    }
   }
 </style>

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

@@ -240,9 +240,7 @@ export const useChatSend = (deps: ChatSendDeps) => {
   const sendWithAttachment = async (file: AttachedFile, userInput: string, displayContent?: string) => {
     const originTaskId = getCurrentTaskId();
     const currentTask = getTaskList().find((t) => t.id === originTaskId);
-    if (currentTask) {
-      currentTask.attachedFiles.push(file);
-    } else {
+    if (!currentTask) {
       throw new Error('未找到当前任务');
     }
 
@@ -262,10 +260,16 @@ export const useChatSend = (deps: ChatSendDeps) => {
     // 附件缺原始文件对象(如历史消息回填)时降级为纯文本重发
     if (!file.originalFile) {
       await streamAiAnswer(userInput);
-      return;
+    } else {
+      await streamAiAnswer(userInput, file);
     }
 
-    await streamAiAnswer(userInput, file);
+    // 附件入列放在请求成功之后:发送失败时任务资源里不应残留一个从未发出去的文件
+    //(用户消息上的附件卡不受影响,它随消息一起出现在气泡里)。
+    // 流式期间任务 id 可能已从本地临时 id 迁移为后端 session id,对象引用不变,这里按对象判断存活。
+    if (getTaskList().includes(currentTask) && !currentTask.attachedFiles.some((f) => f.id === file.id)) {
+      currentTask.attachedFiles.push(file);
+    }
   };
 
   // 补齐消息的数据库主键 id(刚流式生成的消息前端没有 id,但后端已持久化)。