Explorar o código

Merge branch 'master' of http://39.97.59.228:8013/hrx/mky-vent-base

lxh hai 1 día
pai
achega
890a30ec8f
Modificáronse 42 ficheiros con 7297 adicións e 270 borrados
  1. 125 123
      src/views/vent/deviceManager/comment/warningTabel/ctrlTable.vue
  2. 3 0
      src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpHome.vue
  3. 3 0
      src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpHomeBD.vue
  4. 3 0
      src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpHomeBet.vue
  5. 3 0
      src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpHomeCC.vue
  6. 3 0
      src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpHomeCopy.vue
  7. 4334 0
      src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpSVG.vue
  8. 69 0
      src/views/vent/monitorManager/gasPumpMonitor/gasPump.data.ts
  9. 3 24
      src/views/vent/monitorManager/gasPumpMonitor/gasPump.threejs.under.ts
  10. 48 11
      src/views/vent/monitorManager/gasPumpMonitor/index.vue
  11. 12 2
      src/views/vent/monitorManager/groutMonitor/components/groutHome.vue
  12. 12 2
      src/views/vent/monitorManager/groutMonitor/components/groutHomeHjt.vue
  13. 13 2
      src/views/vent/monitorManager/groutMonitor/components/groutHomeJj.vue
  14. 13 2
      src/views/vent/monitorManager/groutMonitor/components/groutHomehlg.vue
  15. 13 2
      src/views/vent/monitorManager/groutMonitor/components/groutHomelt.vue
  16. 1501 0
      src/views/vent/monitorManager/groutMonitor/components/zhijiangSVG.vue
  17. 29 0
      src/views/vent/monitorManager/groutMonitor/grout.data.ts
  18. 11 3
      src/views/vent/monitorManager/groutMonitor/index.vue
  19. 9 3
      src/views/ventAI/dataPicker/components/DataPickerModal.vue
  20. 91 0
      src/views/ventAI/manageAssistent/api/cad.ts
  21. 1 0
      src/views/ventAI/manageAssistent/api/index.ts
  22. 17 0
      src/views/ventAI/manageAssistent/components/AiAssistantModal.vue
  23. 3 0
      src/views/ventAI/manageAssistent/components/chat/ChatHeader.vue
  24. 48 20
      src/views/ventAI/manageAssistent/components/chat/ChatMessages.vue
  25. 10 1
      src/views/ventAI/manageAssistent/components/panels/RightPanelTabs.vue
  26. 2 2
      src/views/ventAI/manageAssistent/components/resource/CadPreview.vue
  27. 315 0
      src/views/ventAI/manageAssistent/components/resource/DwgPreview.vue
  28. 5 0
      src/views/ventAI/manageAssistent/components/resource/FilePreviewPanel.vue
  29. 8 2
      src/views/ventAI/manageAssistent/components/resource/ResourceCapsule.vue
  30. 220 0
      src/views/ventAI/manageAssistent/components/resource/WebPreviewPanel.vue
  31. 7 0
      src/views/ventAI/manageAssistent/hooks/useChatApproval.ts
  32. 17 2
      src/views/ventAI/manageAssistent/hooks/useChatFiles.ts
  33. 18 0
      src/views/ventAI/manageAssistent/hooks/useChatPanels.ts
  34. 161 62
      src/views/ventAI/manageAssistent/hooks/useChatSend.ts
  35. 58 2
      src/views/ventAI/manageAssistent/hooks/useChatTasks.ts
  36. 5 2
      src/views/ventAI/manageAssistent/lib/fileIcon.ts
  37. 4 2
      src/views/ventAI/manageAssistent/lib/filePreview.ts
  38. 2 0
      src/views/ventAI/manageAssistent/lib/index.ts
  39. 47 0
      src/views/ventAI/manageAssistent/lib/turnRows.ts
  40. 35 0
      src/views/ventAI/manageAssistent/lib/webLink.ts
  41. 13 0
      src/views/ventAI/manageAssistent/types/chat.ts
  42. 3 1
      src/views/ventAI/manageAssistent/types/panel.ts

+ 125 - 123
src/views/vent/deviceManager/comment/warningTabel/ctrlTable.vue

@@ -32,155 +32,157 @@
 </template>
 
 <script lang="ts" setup>
-import { ref, onMounted } from 'vue';
-import { ctrlColumns, ctrlFormSchemas } from './warning.data';
-import { getCtrlDevicePointList, addCtrlDevicePoint, editCtrlDevicePoint, getCtrlDevicePointPage, deleteDevicePonit } from './warning.api';
-import BaseModal from './BaseModalCtrl.vue';
-import { useModal } from '/@/components/Modal';
+  import { ref, onMounted } from 'vue';
+  import { ctrlColumns, ctrlFormSchemas } from './warning.data';
+  import { getCtrlDevicePointList, addCtrlDevicePoint, editCtrlDevicePoint, getCtrlDevicePointPage, deleteDevicePonit } from './warning.api';
+  import BaseModal from './BaseModalCtrl.vue';
+  import { useModal } from '/@/components/Modal';
 
-const emit = defineEmits(['register']);
+  const emit = defineEmits(['register']);
 
-const props = defineProps({
-  deviceId: { type: String },
-  deviceType: { type: String },
-});
+  const props = defineProps({
+    deviceId: { type: String },
+    deviceType: { type: String },
+  });
 
-const formSchemas = ctrlFormSchemas;
-const activeID = ref('');
-const dataSource = ref<any[]>([]);
-const ctrlList = ref<any[]>([]);
-const deviceType = ref('');
-const editRecord = ref<any>(null);
-function selectWarning(id) {
-  activeID.value = id;
-  getTableData(id);
-}
-async function getControlDevicePointList() {
-  const param = {
-    pageNo: 1,
-    pageSize: 1000,
-    devicetype: props.deviceType,
-    valuetype: 10,
-  };
-  const res = await getCtrlDevicePointList(param);
-  ctrlList.value = res.records || [];
-}
-const [register, { openModal, closeModal }] = useModal();
+  const formSchemas = ctrlFormSchemas;
+  const activeID = ref('');
+  const dataSource = ref<any[]>([]);
+  const ctrlList = ref<any[]>([]);
+  const deviceType = ref('');
+  const editRecord = ref<any>(null);
+  function selectWarning(id) {
+    activeID.value = id;
+    getTableData(id);
+  }
+  async function getControlDevicePointList() {
+    const param = {
+      pageNo: 1,
+      pageSize: 1000,
+      devicetype: props.deviceType,
+      valuetype: 10,
+    };
+    const res = await getCtrlDevicePointList(param);
+    ctrlList.value = res.records || [];
+  }
+  const [register, { openModal, closeModal }] = useModal();
 
-function handleOpen(flag?, record?) {
-  console.log(record, 'record');
-  deviceType.value = props.deviceType;
-  editRecord.value = flag == 'update' ? record : null;
-  if (record) {
-    if (flag == 'update') {
-      openModal(true, {
-        isUpdate: true,
-        record,
-      });
+  function handleOpen(flag?, record?) {
+    console.log(record, 'record');
+    deviceType.value = props.deviceType;
+    editRecord.value = flag == 'update' ? record : null;
+    if (record) {
+      if (flag == 'update') {
+        openModal(true, {
+          isUpdate: true,
+          record,
+        });
+      } else {
+        openModal(true, {
+          isUpdate: false,
+          record,
+        });
+      }
     } else {
       openModal(true, {
         isUpdate: false,
         record,
       });
     }
-  } else {
-    openModal(true, {
-      isUpdate: false,
-      record,
-    });
   }
-}
 
-async function onSubmit(flag, values) {
-  const currentItem = ctrlList.value.find((item) => item.id == activeID.value);
-  if (currentItem) {
-    values.monitorCode = currentItem.valuecode;
-    values.monitorId = currentItem.id;
-    values.monitorName = currentItem.valuename;
-  }
-  if (flag == 'update') {
-    await editCtrlDevicePoint({ id: editRecord.value?.id, ...editRecord.value, ...values });
-  } else {
-    await addCtrlDevicePoint({ ...values });
+  async function onSubmit(flag, values) {
+    const currentItem = ctrlList.value.find((item) => item.id == activeID.value);
+    if (currentItem) {
+      values.monitorCode = currentItem.valuecode;
+      values.monitorId = currentItem.id;
+      values.monitorName = currentItem.valuename;
+    }
+    if (flag == 'update') {
+      await editCtrlDevicePoint({ id: editRecord.value?.id, ...editRecord.value, ...values });
+    } else {
+      await addCtrlDevicePoint({ ...values });
+    }
+    editRecord.value = null;
+    closeModal();
+    await getTableData(activeID.value);
   }
-  editRecord.value = null;
-  closeModal();
-  await getTableData(activeID.value);
-}
 
-async function handleDelete(record) {
-  await deleteDevicePonit({ id: record.id });
-  getTableData(activeID.value);
-}
+  async function handleDelete(record) {
+    await deleteDevicePonit({ id: record.id });
+    getTableData(activeID.value);
+  }
 
-async function getTableData(id?: string) {
-  const param = {
-    pageNo: 1,
-    pageSize: 1000,
-    monitorId: id,
-    deviceId: props.deviceId,
-  };
-  const res = await getCtrlDevicePointPage(param);
-  dataSource.value = res.records || [];
-}
+  async function getTableData(id?: string) {
+    if (!id) return;
 
-onMounted(async () => {
-  await getControlDevicePointList();
-  if (ctrlList.value.length > 0) {
-    activeID.value = ctrlList.value[0].id;
+    const param = {
+      pageNo: 1,
+      pageSize: 1000,
+      monitorId: id,
+      deviceId: props.deviceId,
+    };
+    const res = await getCtrlDevicePointPage(param);
+    dataSource.value = res.records || [];
   }
-  await getTableData(activeID.value);
-});
+
+  onMounted(async () => {
+    await getControlDevicePointList();
+    if (ctrlList.value.length > 0) {
+      activeID.value = ctrlList.value[0].id;
+    }
+    await getTableData(activeID.value);
+  });
 </script>
 
 <style lang="less" scoped>
-@ventSpace: zxm;
+  @ventSpace: zxm;
 
-.warning-device-box {
-  width: 100%;
-  height: 600px;
-  overflow-y: auto;
-  display: flex;
-  .warning-box {
-    width: 200px;
-    height: 100%;
+  .warning-device-box {
+    width: 100%;
+    height: 600px;
     overflow-y: auto;
-    background: #ffffff11;
-    padding: 5px;
-    border-radius: 5px;
+    display: flex;
+    .warning-box {
+      width: 200px;
+      height: 100%;
+      overflow-y: auto;
+      background: #ffffff11;
+      padding: 5px;
+      border-radius: 5px;
 
-    .link-item {
-      position: relative;
-      cursor: pointer;
-      line-height: 30px;
-      padding-left: 30px;
-      color: #fff;
-      span:hover {
-        color: #89ffff;
+      .link-item {
+        position: relative;
+        cursor: pointer;
+        line-height: 30px;
+        padding-left: 30px;
+        color: #fff;
+        span:hover {
+          color: #89ffff;
+        }
+        &::after {
+          content: '';
+          position: absolute;
+          display: block;
+          width: 8px;
+          height: 8px;
+          top: 12px;
+          left: 10px;
+          transform: rotateZ(45deg) skew(10deg, 10deg);
+          background: #45d3fd;
+        }
       }
-      &::after {
-        content: '';
-        position: absolute;
-        display: block;
-        width: 8px;
-        height: 8px;
-        top: 12px;
-        left: 10px;
-        transform: rotateZ(45deg) skew(10deg, 10deg);
-        background: #45d3fd;
+      .active-device-title {
+        color: aqua;
       }
     }
-    .active-device-title {
-      color: aqua;
+    .device-box {
+      flex: 1;
+      margin-left: 10px;
     }
-  }
-  .device-box {
-    flex: 1;
-    margin-left: 10px;
-  }
 
-  .action-link {
-    color: #00e7ff;
+    .action-link {
+      color: #00e7ff;
+    }
   }
-}
-</style>
+</style>

+ 3 - 0
src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpHome.vue

@@ -242,6 +242,8 @@
   import { useCamera } from '/@/hooks/system/useCamera';
 
   const globalConfig = inject('globalConfig');
+  /** 二维模型的动画入口(页面 provide;三维模式下 modelRef 为空,调用即 no-op) */
+  const animate2D = inject<((data?: any) => void) | null>('animateGasPump2D', null);
 
   const props = defineProps({
     deviceId: {
@@ -299,6 +301,7 @@
               const data = await getDataSource(props.deviceId);
               selectData.value = data;
               playAnimate(data);
+              animate2D?.(data); // 二维模型:同一份监测数据
               // Object.assign(selectData, data);
             }
             if (timer) {

+ 3 - 0
src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpHomeBD.vue

@@ -69,6 +69,8 @@
   const { configs: rawConfigs, fetchConfigs } = useInitConfigs();
 
   const globalConfig = inject<any>('globalConfig');
+  /** 二维模型的动画入口(页面 provide;三维模式下 modelRef 为空,调用即 no-op) */
+  const animate2D = inject<((data?: any) => void) | null>('animateGasPump2D', null);
 
   const props = defineProps<{ deviceId: string; deviceType: string }>();
   // const [register, { openModal }] = useModal();
@@ -93,6 +95,7 @@
             const data = await getDataSource();
             selectData.value = data;
             playAnimate(data);
+            animate2D?.(data); // 二维模型:同一份监测数据
             // Object.assign(selectData, data);
             if (timer) {
               timer = null;

+ 3 - 0
src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpHomeBet.vue

@@ -180,6 +180,8 @@
   import { get } from 'lodash-es';
 
   const globalConfig = inject('globalConfig');
+  /** 二维模型的动画入口(页面 provide;三维模式下 modelRef 为空,调用即 no-op) */
+  const animate2D = inject<((data?: any) => void) | null>('animateGasPump2D', null);
 
   const props = defineProps({
     deviceId: {
@@ -243,6 +245,7 @@
               const data = await getDataSource(props.deviceId);
               selectData.value = data;
               playAnimate(data);
+              animate2D?.(data); // 二维模型:同一份监测数据
               // Object.assign(selectData, data);
             }
             if (timer) {

+ 3 - 0
src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpHomeCC.vue

@@ -213,6 +213,8 @@
   import ListItem from '@/views/vent/gas/components/list/listItem.vue';
 
   const globalConfig = inject('globalConfig');
+  /** 二维模型的动画入口(页面 provide;三维模式下 modelRef 为空,调用即 no-op) */
+  const animate2D = inject<((data?: any) => void) | null>('animateGasPump2D', null);
 
   const props = defineProps({
     deviceId: {
@@ -254,6 +256,7 @@
               const data = await getDataSource(props.deviceId);
               selectData.value = data;
               playAnimate(data);
+              animate2D?.(data); // 二维模型:同一份监测数据
             }
             if (timer) {
               timer = null;

+ 3 - 0
src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpHomeCopy.vue

@@ -73,6 +73,8 @@
   import { set } from 'lodash-es';
 
   const globalConfig = inject<any>('globalConfig');
+  /** 二维模型的动画入口(页面 provide;三维模式下 modelRef 为空,调用即 no-op) */
+  const animate2D = inject<((data?: any) => void) | null>('animateGasPump2D', null);
 
   const props = defineProps({
     deviceId: {
@@ -113,6 +115,7 @@
               const data = await getDataSource();
               selectData.value = data;
               playAnimate(data);
+              animate2D?.(data); // 二维模型:同一份监测数据
               // Object.assign(selectData, data);
             }
             if (timer) {

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 4334 - 0
src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpSVG.vue


+ 69 - 0
src/views/vent/monitorManager/gasPumpMonitor/gasPump.data.ts

@@ -590,3 +590,72 @@ export interface devicePropertyType {
   children: deviceChildType[];
   list: propertyListType[];
 }
+
+/** 二维 SVG 模型组件缓存(用于切换模型时不闪烁) */
+const componentsCaches = new Map<string, any>();
+
+/**
+ * 获取瓦斯抽采泵模型组件
+ *
+ * @param is2DModel 是否为二维模型;三维模型由 index.vue 的 mountedThree 负责,此处仅返回二维组件
+ * @param type 站型(页面 dataSource 里的 deviceType,如 pump_over / pump_under / pump_22m2pq)
+ */
+export function getModelComponent(is2DModel: boolean = false, type: string = '') {
+  if (!is2DModel) return null;
+
+  const getComponent = (name) => {
+    if (!componentsCaches.has(name)) {
+      componentsCaches.set(name, import(`./components/${name}.vue`));
+    }
+    return componentsCaches.get(name);
+  };
+
+  // @ts-ignore
+  return defineAsyncComponent(() => {
+    switch (type) {
+      // case 'pump_over':
+      // case 'pump_under':
+      // 目前地上/井下共用同一套二维场景,将来要分开时按站型指到不同组件即可
+      default:
+        return getComponent('gasPumpSVG');
+    }
+  });
+}
+
+/**
+ * 判断某台泵是否运行(与三维 gasPump.threejs.under.ts 的 isPumpRunning 同一套口径)
+ *
+ * 已注入 runSignals 时(布尔台两站)按泵运行状态点位判定:Number(data[code]) === 1;
+ * 未注入时(寸草塔1/2、保德等未接入该机制的站型)回落到进气/出气阀门开度,
+ * 避免这些站的流动动画因缺少运行点位而全部不动。
+ *
+ * @param data 子组件轮询得到的监测数据(与传给三维 playAnimate 的同一份)
+ * @param pumpNo 泵号(1 / 2)
+ * @param runSignals 该站的"泵号 → 运行点位"表,null 表示未接入
+ * @param tags 回落口径用的标签表(getModelMonitorTags() 的返回值)
+ */
+export function isPumpRunning(
+  data: any,
+  pumpNo: number,
+  runSignals: Record<number, { code: string }> | null,
+  tags: { domId: string; code: string }[]
+): boolean {
+  if (!data) return false;
+
+  const code = runSignals?.[pumpNo]?.code;
+  if (code) {
+    const raw = data[code];
+    if (raw === undefined || raw === null || raw === '') return false;
+    const value = Number(raw);
+    return Number.isFinite(value) && value === 1;
+  }
+
+  // 回落到旧的阀门开度口径:该泵的任一进气/出气阀门开启即认为有流动
+  const valvePrefixes = pumpNo === 1 ? ['jqf1', 'cqf1'] : ['jqf2', 'cqf2'];
+  return valvePrefixes.some((domId) => {
+    const tag = tags?.find((e: any) => e.domId === domId);
+    if (!tag) return false;
+    const value = Number(data[tag.code]);
+    return Number.isFinite(value) && value > 0;
+  });
+}

+ 3 - 24
src/views/vent/monitorManager/gasPumpMonitor/gasPump.threejs.under.ts

@@ -1,6 +1,6 @@
 import * as THREE from 'three';
 import { CSS3DSprite } from 'three/examples/jsm/renderers/CSS3DRenderer.js';
-import { getModelMonitorTags } from './gasPump.data';
+import { getModelMonitorTags, isPumpRunning } from './gasPump.data';
 import ArrowFlow from '../../comment/threejs/ArrowFlow';
 // import * as dat from 'dat.gui';
 // const gui = new dat.GUI();
@@ -117,31 +117,10 @@ class gasPumpUnder {
   }
 
   /**
-   * 判断某台泵是否运行。
-   *
-   * 已注入 runSignals 时(布尔台两站)按泵运行状态判定;
-   * 未注入时(寸草塔1/2、保德等未接入该机制的站型)保持历史上的阀门口径回落,
-   * 避免这些站的箭头因本次改动而全部不再流动。
+   * 判断某台泵是否运行:口径抽到 gasPump.data.ts 的 isPumpRunning,二维模型共用同一份判定。
    */
   private isPumpRunning(data: any, pumpNo: number): boolean {
-    if (!data) return false;
-
-    const code = this.runSignals?.[pumpNo]?.code;
-    if (code) {
-      const raw = data[code];
-      if (raw === undefined || raw === null || raw === '') return false;
-      const value = Number(raw);
-      return Number.isFinite(value) && value === 1;
-    }
-
-    // 回落到旧的阀门开度口径:该泵的任一进气/出气阀门开启即认为有流动
-    const valvePrefixes = pumpNo === 1 ? ['jqf1', 'cqf1'] : ['jqf2', 'cqf2'];
-    return valvePrefixes.some((domId) => {
-      const tag = this.modelMonitorTags.find((e) => e.domId === domId);
-      if (!tag) return false;
-      const value = Number(data[tag.code]);
-      return Number.isFinite(value) && value > 0;
-    });
+    return isPumpRunning(data, pumpNo, this.runSignals, this.modelMonitorTags);
   }
 
   /** 根据各泵运行状态控制动画:泵运行时该泵所接的水、气四路管道全部流动 */

+ 48 - 11
src/views/vent/monitorManager/gasPumpMonitor/index.vue

@@ -1,10 +1,18 @@
 <template>
   <div class="bg" style="width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; overflow: hidden">
     <a-spin :spinning="loading" />
-    <div id="gasPump3D" v-show="activeKey == 'monitor'" style="width: 100%; height: 100%; position: absolute; overflow: hidden"> </div>
+    <!-- 二维 SVG 模型(瓦斯抽采泵站,按泵运行状态点亮管路) -->
+    <component v-if="globalConfig?.is2DModel" ref="modelRef" :is="modelComponent" :device-type="currentDeviceType" style="z-index: 2" />
+    <!-- 三维模型容器(由 index.vue 的 mountedThree 初始化) -->
+    <div v-else id="gasPump3D" v-show="activeKey == 'monitor'" style="width: 100%; height: 100%; position: absolute; overflow: hidden"> </div>
     <div
       id="gas3DCSS"
-      v-show="activeKey == 'monitor' && !loading && (currentDeviceType.startsWith('pump_under') || currentDeviceType == 'pump_n12m2pq')"
+      v-show="
+        !globalConfig?.is2DModel &&
+        activeKey == 'monitor' &&
+        !loading &&
+        (currentDeviceType.startsWith('pump_under') || currentDeviceType == 'pump_n12m2pq')
+      "
       style="width: 100%; height: 100%; top: 0; left: 0; position: absolute; overflow: hidden; pointer-events: none"
     >
     </div>
@@ -59,10 +67,10 @@
 
 <script setup lang="ts">
   import customHeader from '/@/components/vent/customHeader.vue';
-  import { ref, onMounted, onUnmounted, reactive, toRaw } from 'vue';
+  import { ref, onMounted, onUnmounted, reactive, toRaw, inject, shallowRef, provide } from 'vue';
   import { list } from './gasPump.api';
   import BottomMenu from '/@/views/vent/comment/components/bottomMenu.vue';
-  import { getComponent } from './gasPump.data';
+  import { getComponent, getModelComponent } from './gasPump.data';
   import gasPumpHistory from './components/gasPumpHistory.vue';
   import gasPumpHandleHistoryVue from './components/gasPumpHandleHistory.vue';
   import gasPumpAlarmHistory from './components/gasPumpAlarmHistory.vue';
@@ -75,6 +83,20 @@
   const activeKey = ref('monitor');
   const loading = ref(false);
 
+  /** 全局配置(is2DModel 决定二维 / 三维模型) */
+  const globalConfig: any = inject('globalConfig');
+  /** 二维模型容器(三维由 mountedThree 负责,此处仅二维有值) */
+  const modelRef = ref();
+  // 注意:站型 currentDeviceType 在下面才声明,这里先用默认场景,选中站型后由 getSelectRow 刷新
+  const modelComponent = shallowRef(getModelComponent(globalConfig?.is2DModel));
+
+  /**
+   * 二维模型的动画入口,供子组件(gasPumpHome*)在轮询里调用:
+   * 子组件那边就是 animate2D(data) —— 与三维 playAnimate(data) 用同一份监测数据。
+   * 三维模式下 modelRef 为空,这里自然 no-op。
+   */
+  provide('animateGasPump2D', (data: any) => modelRef.value?.animate?.(data));
+
   const historyTable = ref();
   const alarmHistoryTable = ref();
   const handlerHistoryTable = ref();
@@ -98,7 +120,7 @@
   });
 
   function changeActive(activeValue) {
-    if (activeKey.value == 'monitor') {
+    if (activeKey.value == 'monitor' && !globalConfig?.is2DModel) {
       if (currentDeviceType.value == 'pump_over') {
         setModelType('gasPump');
       } else if (currentDeviceType.value.startsWith('pump_under')) {
@@ -171,18 +193,31 @@
       optionValue.value = currentData['deviceID'];
       currentDeviceType.value = currentData['deviceType'];
       Object.assign(selectData, currentData);
-      if (currentDeviceType.value == 'pump_over') {
-        setModelType('gasPump');
-      } else if (currentDeviceType.value.startsWith('pump_under')) {
-        setModelType('gasPumpUnder');
+      if (globalConfig?.is2DModel) {
+        // 二维:换站型时切到对应场景(当前地上/井下共用一套),并立刻刷新一次流动状态
+        // 换站型时切到对应场景(当前地上/井下共用一套),动画由子组件轮询驱动
+        modelComponent.value = getModelComponent(true, currentDeviceType.value);
       } else {
-        setModelType('gasPumpUnder');
+        if (currentDeviceType.value == 'pump_over') {
+          setModelType('gasPump');
+        } else if (currentDeviceType.value.startsWith('pump_under')) {
+          setModelType('gasPumpUnder');
+        } else {
+          setModelType('gasPumpUnder');
+        }
       }
     }
   }
 
   onMounted(async () => {
     if (currentRoute.value && currentRoute.value['query'] && currentRoute.value['query']['id']) optionValue.value = currentRoute.value['query']['id'];
+    // 二维模式:不初始化三维,只加载数据;动画由子组件(gasPumpHome*)轮询时调 animate2D(data) 驱动
+    if (globalConfig?.is2DModel) {
+      await getSysDataSource();
+      await getDeviceList();
+      getSelectRow(optionValue.value);
+      return;
+    }
     mountedThree().then(async () => {
       await getSysDataSource();
       await getDeviceList();
@@ -191,7 +226,9 @@
   });
 
   onUnmounted(() => {
-    destroy();
+    if (!globalConfig?.is2DModel) {
+      destroy();
+    }
   });
 </script>
 <style lang="less" scoped>

+ 12 - 2
src/views/vent/monitorManager/groutMonitor/components/groutHome.vue

@@ -150,7 +150,7 @@
 </template>
 
 <script setup lang="ts">
-  import { onBeforeMount, ref, onMounted, onUnmounted, shallowRef, ComponentOptions, reactive } from 'vue';
+  import { onBeforeMount, ref, onMounted, onUnmounted, shallowRef, ComponentOptions, reactive, inject } from 'vue';
   import { mountedThree, destroy, setModelType } from '../grout.threejs';
   import { ScrollBoard as DvScrollBoard } from '@kjgl77/datav-vue3';
   import ventBox1 from '/@/components/vent/ventBox1.vue';
@@ -171,6 +171,8 @@
     },
   });
   const left: string = '0px';
+  /** 全局配置(is2DModel 为 true 时使用二维 SVG 模型,不初始化三维) */
+  const globalConfig: any = inject('globalConfig');
   const currentModal = shallowRef<Nullable<ComponentOptions>>(null); //模态框
   const modalVisible = ref<Boolean>(false); // 模态框是否可见
   const loading = ref(false);
@@ -264,6 +266,12 @@
 
   onMounted(() => {
     loading.value = true;
+    // 二维模式:不初始化三维(二维 SVG 由 index.vue 渲染,容器 #grout3D 不渲染)
+    if (globalConfig?.is2DModel) {
+      loading.value = false;
+      timer = null;
+      return;
+    }
     mountedThree().then(async () => {
       // await setModelType('groutBase');
       await setModelType('bertaiBase');
@@ -273,7 +281,9 @@
     });
   });
   onUnmounted(() => {
-    destroy();
+    if (!globalConfig?.is2DModel) {
+      destroy();
+    }
     if (timer) {
       clearTimeout(timer);
       timer = undefined;

+ 12 - 2
src/views/vent/monitorManager/groutMonitor/components/groutHomeHjt.vue

@@ -47,7 +47,7 @@
 </template>
 
 <script setup lang="ts">
-  import { ref, onMounted, onUnmounted, watch, onBeforeUnmount } from 'vue';
+  import { ref, onMounted, onUnmounted, watch, onBeforeUnmount, inject } from 'vue';
   import { mountedThree, destroy, setModelType } from '../grout.threejs';
   import { list } from '../grout.api';
   import { useCamera } from '/@/hooks/system/useCamera';
@@ -63,6 +63,9 @@
     },
   });
 
+  /** 全局配置(is2DModel 为 true 时使用二维 SVG 模型,不初始化三维) */
+  const globalConfig: any = inject('globalConfig');
+
   const loading = ref(false);
   const monitorData = ref({ InputFlux: undefined });
   const playerRef = ref();
@@ -116,6 +119,11 @@
     timer = null;
     getMonitor(true);
     loading.value = true;
+    // 二维模式:不初始化三维(二维 SVG 由 index.vue 渲染,容器 #grout3D 不渲染)
+    if (globalConfig?.is2DModel) {
+      loading.value = false;
+      return;
+    }
     mountedThree().then(async () => {
       await setModelType('bertaiBase');
       loading.value = false;
@@ -125,7 +133,9 @@
     removeCamera(playerRef);
   });
   onUnmounted(() => {
-    destroy();
+    if (!globalConfig?.is2DModel) {
+      destroy();
+    }
     if (timer) {
       clearTimeout(timer);
       timer = undefined;

+ 13 - 2
src/views/vent/monitorManager/groutMonitor/components/groutHomeJj.vue

@@ -36,7 +36,7 @@
 </template>
 
 <script setup lang="ts">
-  import { watch, ref, onMounted, onUnmounted, reactive, onBeforeUnmount } from 'vue';
+  import { watch, ref, onMounted, onUnmounted, reactive, onBeforeUnmount, inject } from 'vue';
   import { mountedThree, destroy, setModelType } from '../grout.threejs';
   import { deviceMonitorList } from '../grout.data';
   import { list } from '../grout.api';
@@ -53,6 +53,9 @@
     },
   });
 
+  /** 全局配置(is2DModel 为 true 时使用二维 SVG 模型,不初始化三维) */
+  const globalConfig: any = inject('globalConfig');
+
   const loading = ref(false);
   const monitorData = ref({});
   // 监测数据
@@ -109,6 +112,12 @@
 
   onMounted(() => {
     loading.value = true;
+    // 二维模式:不初始化三维(二维 SVG 由 index.vue 渲染,容器 #grout3D 不渲染)
+    if (globalConfig?.is2DModel) {
+      loading.value = false;
+      timer = null;
+      return;
+    }
     mountedThree().then(async () => {
       // await setModelType('groutBase');
       await setModelType('bertaiBase');
@@ -121,7 +130,9 @@
     removeCamera(playerRef);
   });
   onUnmounted(() => {
-    destroy();
+    if (!globalConfig?.is2DModel) {
+      destroy();
+    }
     if (timer) {
       clearTimeout(timer);
       timer = undefined;

+ 13 - 2
src/views/vent/monitorManager/groutMonitor/components/groutHomehlg.vue

@@ -70,7 +70,7 @@
 </template>
 
 <script setup lang="ts">
-  import { watch, ref, onMounted, onUnmounted, onBeforeUnmount } from 'vue';
+  import { watch, ref, onMounted, onUnmounted, onBeforeUnmount, inject } from 'vue';
   import ventBox1 from '/@/components/vent/ventBox1.vue';
   import { mountedThree, destroy, setModelType } from '../grout.threejs';
   import { formatNum } from '/@/utils/ventutil';
@@ -91,6 +91,9 @@
 
   let monitorDataGroupNum = ref(1);
 
+  /** 全局配置(is2DModel 为 true 时使用二维 SVG 模型,不初始化三维) */
+  const globalConfig: any = inject('globalConfig');
+
   const loading = ref(false);
   const monitorData = ref({});
 
@@ -149,6 +152,12 @@
       if (props.deviceId) await getCamera(props.deviceId, playerRef, renderPlayer, 'pulping');
     });
     loading.value = true;
+    // 二维模式:不初始化三维(二维 SVG 由 index.vue 渲染,容器 #grout3D 不渲染)
+    if (globalConfig?.is2DModel) {
+      loading.value = false;
+      timer = null;
+      return;
+    }
     mountedThree().then(async () => {
       // await setModelType('groutBase');
       await setModelType('bertaiBase');
@@ -162,7 +171,9 @@
   });
 
   onUnmounted(() => {
-    destroy();
+    if (!globalConfig?.is2DModel) {
+      destroy();
+    }
     if (timer) {
       clearTimeout(timer);
       timer = undefined;

+ 13 - 2
src/views/vent/monitorManager/groutMonitor/components/groutHomelt.vue

@@ -100,7 +100,7 @@
 </template>
 
 <script setup lang="ts">
-  import { watch, ref, onMounted, onUnmounted, onBeforeUnmount } from 'vue';
+  import { watch, ref, onMounted, onUnmounted, onBeforeUnmount, inject } from 'vue';
   import ventBox1 from '/@/components/vent/ventBox1.vue';
   import { mountedThree, destroy, setModelType } from '../grout.threejs';
   import { formatNum } from '/@/utils/ventutil';
@@ -121,6 +121,9 @@
 
   let monitorDataGroupNum = ref(1);
 
+  /** 全局配置(is2DModel 为 true 时使用二维 SVG 模型,不初始化三维) */
+  const globalConfig: any = inject('globalConfig');
+
   const loading = ref(false);
   const monitorData = ref({});
 
@@ -178,6 +181,12 @@
       if (props.deviceId) await getCamera(props.deviceId, playerRef, renderPlayer, 'pulping');
     });
     loading.value = true;
+    // 二维模式:不初始化三维(二维 SVG 由 index.vue 渲染,容器 #grout3D 不渲染)
+    if (globalConfig?.is2DModel) {
+      loading.value = false;
+      timer = null;
+      return;
+    }
     mountedThree().then(async () => {
       // await setModelType('groutBase');
       await setModelType('bertaiBase');
@@ -189,7 +198,9 @@
     removeCamera(playerRef);
   });
   onUnmounted(() => {
-    destroy();
+    if (!globalConfig?.is2DModel) {
+      destroy();
+    }
     if (timer) {
       clearTimeout(timer);
       timer = undefined;

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1501 - 0
src/views/vent/monitorManager/groutMonitor/components/zhijiangSVG.vue


+ 29 - 0
src/views/vent/monitorManager/groutMonitor/grout.data.ts

@@ -764,3 +764,32 @@ export function getMonitorComponent() {
       return groutHome;
   }
 }
+
+/** 二维 SVG 模型组件缓存(用于切换模型时不闪烁) */
+const componentsCaches = new Map<string, any>();
+
+/**
+ * 获取制浆模型组件
+ *
+ * @param is2DModel 是否为二维模型;三维模型由 groutHome*.vue 的 mountedThree 负责,此处仅返回二维组件
+ * @param type 模型类型(与页面 strtype 对应,如 sys_pulping)
+ */
+export function getModelComponent(is2DModel: boolean = false, type: string = '') {
+  if (!is2DModel) return null;
+
+  const getComponent = (name) => {
+    if (!componentsCaches.has(name)) {
+      componentsCaches.set(name, import(`./components/${name}.vue`));
+    }
+    return componentsCaches.get(name);
+  };
+
+  // @ts-ignore
+  return defineAsyncComponent(() => {
+    switch (type) {
+      // case 'sys_pulping':
+      default:
+        return getComponent('zhijiangSVG');
+    }
+  });
+}

+ 11 - 3
src/views/vent/monitorManager/groutMonitor/index.vue

@@ -2,7 +2,10 @@
   <div class="bg"
     style="width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; overflow: hidden">
     <a-spin :spinning="loading" />
-    <div id="grout3D" v-show="!loading" style="width: 100%; height: 100%; position: absolute; overflow: hidden"> </div>
+    <!-- 二维 SVG 模型(制浆工艺流程图:管路流动箭头 + 脉冲元素) -->
+    <component v-if="globalConfig?.is2DModel" :is="modelComponent" style="z-index: 2" />
+    <!-- 三维模型容器(由 groutHome*.vue 的 mountedThree 初始化) -->
+    <div v-else id="grout3D" v-show="!loading" style="width: 100%; height: 100%; position: absolute; overflow: hidden"> </div>
     <!-- <div id="damper3DCSS" v-show="!loading" style="width: 100%; height: 100%; top:0; left: 0; position: absolute; overflow: hidden;">
       <div>
         <div ref="elementContent" class="elementContent">
@@ -35,15 +38,20 @@
 
 <script setup lang="ts">
 import customHeader from '/@/components/vent/customHeader.vue';
-import { ref, onMounted, onUnmounted, reactive, toRaw } from 'vue';
+import { ref, onMounted, onUnmounted, reactive, toRaw, inject, shallowRef } from 'vue';
 import { systemList, getTableList } from './grout.api';
 import BottomMenu from '/@/views/vent/comment/components/bottomMenu.vue';
-import {getMonitorComponent} from './grout.data'
+import { getMonitorComponent, getModelComponent } from './grout.data'
 import groutHistory from './components/groutHistory.vue';
 import groutHandleHistoryVue from './components/groutHandleHistory.vue';
 import groutAlarmHistory from './components/groutAlarmHistory.vue';
 import { useRouter } from 'vue-router';
 
+/** 全局配置(is2DModel 决定二维 / 三维模型) */
+const globalConfig: any = inject('globalConfig');
+/** 模型对应的组件,根据配置分为二维三维;三维由 groutHome*.vue 的 mountedThree 负责,此处仅二维有值 */
+const modelComponent = shallowRef(getModelComponent(globalConfig?.is2DModel, 'sys_pulping'));
+
 const groutHome = getMonitorComponent()
 type DeviceType = { deviceType: string, deviceName: string, datalist: any[] };
 const activeKey = ref('monitor');

+ 9 - 3
src/views/ventAI/dataPicker/components/DataPickerModal.vue

@@ -166,10 +166,14 @@
       box-shadow: inset 0 0 12px #2cb6ff;
       backdrop-filter: blur(30px);
     }
+    // 弹框内容高时的滚动只能发生一次:这里只做 75vh 的高度上限,真正的滚动交给 .di-body。
+    // 两者都设 overflow-y: auto 会出现内外两条滚动条(外层还会把标题滚走)。
     .ant-modal-body {
       padding: 0;
       max-height: 75vh;
-      overflow-y: auto;
+      display: flex;
+      flex-direction: column;
+      overflow: hidden;
     }
     .ant-spin-text {
       color: #c8d6e5;
@@ -209,8 +213,9 @@
   }
   .di-body {
     padding: 18px 24px;
-    min-height: 120px;
-    max-height: 700px;
+    // 唯一滚动容器:随 .ant-modal-body 的 75vh 上限伸缩,min-height: 0 才能被压缩出滚动条
+    flex: 1 1 auto;
+    min-height: 0;
     overflow-y: auto;
   }
   .di-section {
@@ -411,6 +416,7 @@
     gap: 8px;
     padding: 8px 24px 16px;
     border-top: 1px solid rgba(30, 144, 255, 0.1);
+    flex-shrink: 0;
     &__tip {
       font-size: 12px;
       color: #8899aa;

+ 91 - 0
src/views/ventAI/manageAssistent/api/cad.ts

@@ -0,0 +1,91 @@
+// api/cad.ts:DWG 图纸预览所需的「服务端转 SVG」接口。
+//
+// DWG 是专有二进制格式,前端没法本地解析(对比 .dxf 走 lib/dxf.ts 本地渲染),只能把原始文件传给后端转成
+// SVG 再展示。后端给了两个接口,差别只在返回什么:
+//   dwgFileToSVG     —— 只返回生成的 svg 路径,前端自己去静态目录取;
+//   dwgFileToSVGInfo —— 直接返回 svg 正文,不依赖静态目录能否被前端访问。
+import { defHttp } from '/@/utils/http/axios/index';
+import { useGlobSetting } from '/@/hooks/setting';
+
+// 接口地址按后端给的文档写(/Vmodel/ventanalyModel/...);实测「加 /ventanaly-model 服务前缀」和「不加」
+// 两种写法都能打到同一个 handler(见 views/vent/monitorManager/mainFanMonitor/main.api.ts 也是不加前缀的写法)。
+// 请求最终由 uploadFile 拼上网关前缀 /modelreq 发出去,dev 下由 Vite proxy 转发。
+enum Api {
+  dwgToSvg = '/Vmodel/ventanalyModel/dwgFileToSVG',
+  dwgToSvgInfo = '/Vmodel/ventanalyModel/dwgFileToSVGInfo',
+}
+
+/** 转换业务目录:后端据此存放上传的 dwg 与生成的 svg(对应返回里的 temp/dwg、/temp/svg/*.svg) */
+const DWG_BIZ = 'temp/dwg';
+
+export interface DwgToSvgResult {
+  /** 生成的 svg 相对路径,如 /temp/svg/test300K.svg */
+  svgFile: string;
+  /** 上传的 dwg 相对路径,如 temp/dwg/test300K.dwg */
+  dwgFile: string;
+}
+
+export interface DwgToSvgInfoResult extends DwgToSvgResult {
+  /** svg 正文(完整 xml 字符串) */
+  svgInfo: string;
+}
+
+/**
+ * 上传 dwg,返回转好的 svg 路径。
+ * @param file - dwg 文件(文件名会带到后端,用于命名生成物,务必传带名字的 File 而不是裸 Blob)
+ */
+export const dwgToSvg = (file: File): Promise<DwgToSvgResult> => postDwg<DwgToSvgResult>(Api.dwgToSvg, file);
+
+/**
+ * 上传 dwg,返回 svg 正文。
+ * @param file - dwg 文件
+ */
+export const dwgToSvgInfo = (file: File): Promise<DwgToSvgInfoResult> => postDwg<DwgToSvgInfoResult>(Api.dwgToSvgInfo, file);
+
+function postDwg<T>(url: string, file: File): Promise<T> {
+  // 走项目自带的上传方法,**不要**写成 defHttp.post({ data: formData }):
+  // beforeRequestHook 是用 `Object.keys(config.data).length > 0` 判断「有没有 body」的,而 FormData 的
+  // 自有可枚举属性为空,于是它把 FormData 整个换成 params(默认 {})——实测发出去的 body 就是字符串 "{}",
+  // 服务端一个 part 都收不到("Current request is not a multipart request" /
+  // "Required request part 'file' is not present"),只看到 getFile("file") 为 null 后抛出的 NPE。
+  // uploadFile 直接走 axios 实例、不过这个 hook,file 与附加字段(biz)都能正常送达。
+  return defHttp
+    .uploadFile<T>(
+      // uploadFile 内部会把 baseURL 设成网关地址(/modelreq),所以这里传服务前缀之后的完整路径
+      { url, timeout: 120000 }, // 转换是 CPU 密集操作,比普通接口慢得多,单独放宽超时(全局默认 10s)
+      { file, name: 'file', filename: file.name, data: { biz: DWG_BIZ } },
+      { isReturnResponse: true }
+    )
+    .then((res) => unwrapEnvelope<T>(res));
+}
+
+/**
+ * 兜住「业务失败但 HTTP 200」的信封。
+ *
+ * 后端业务失败时返回的是 `{success:false, code:500, result:null}`:此时响应拦截器不会走 reject
+ * (它是按 `data.result || data.success` 进分支的,两者都为假),而是把整个信封当普通数据返回。
+ * 不在这里判一次,调用方就只能看到「返回结果里没有 svgFile」,把「转换失败」误报成「缺字段」。
+ * 成功时拦截器已经把 result 拆出来了(普通对象,没有 success 字段),这里原样放行。
+ */
+function unwrapEnvelope<T>(res: unknown): T {
+  if (!res || typeof res !== 'object' || !('success' in res)) return res as T;
+  const envelope = res as { success?: boolean; code?: number; message?: string; result?: T };
+  if (envelope.success === false || (typeof envelope.code === 'number' && envelope.code !== 200)) {
+    throw new Error(envelope.message || `DWG 转换接口返回 code=${envelope.code}`);
+  }
+  return envelope.result as T;
+}
+
+/**
+ * 把接口返回的 svg 相对路径拼成浏览器可直接访问的地址:`<网关>/sys/common/static/<路径>`。
+ *
+ * 后端返回的路径带前导斜杠(示例 `/temp/svg/test300K.svg`),直接拼接会拼出 `static//temp/...`,
+ * 部分网关会把它当成非法路径,这里统一去掉再拼。
+ * 静态目录不校验 token,所以可以直接交给 <img> 加载(图片标签没法带 X-Access-Token 头)。
+ */
+export const buildStaticFileUrl = (path: string): string => {
+  const base = useGlobSetting().domainUrl || '';
+  return `${base}/sys/common/static/${String(path || '')
+    .trim()
+    .replace(/^\/+/, '')}`;
+};

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

@@ -12,3 +12,4 @@ export * from './skill';
 export * from './schedule';
 export * from './subagent';
 export * from './report';
+export * from './cad';

+ 17 - 0
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -68,6 +68,7 @@
             @file-preview="openFilePreview"
             @agent-detail-click="handleAgentDetailClick"
             @locate-message="handleLocateMessage"
+            @open-web="openWebTab"
           />
 
           <!-- 附件列表面板 -->
@@ -85,6 +86,7 @@
             @ask-user-cancel="handleAskUserCancel"
             @agent-detail-click="handleAgentDetailClick"
             @open-markdown-tab="openMarkdownPreviewTab"
+            @open-web="openWebTab"
             @delete-message="handleDeleteMessage"
             @regenerate="handleRegenerate"
             @edit-resend="handleEditResend"
@@ -280,6 +282,7 @@
     handleLocateMessage,
     openMarkdownPreviewTab,
     openFilePreview,
+    openWebTab,
     handleOpenFilePreview,
     handleSaveSuccess,
     getCurrentTaskFiles,
@@ -314,6 +317,8 @@
       currentTodos.value = [];
     },
     setCurrentSessionId: (id) => setCurrentSessionId(id),
+    // 延迟求值:useChatSend 在下面才初始化(与其它依赖同一约定,避免 setup 期 TDZ)
+    hydrateMessageIds: (sessionId, msgs) => hydrateSendMessageIds(sessionId, msgs),
   });
 
   // —— 发送/流式管线已下沉到 useChatSend(handleSendMessage 作为输入框编排留在组件里) ——
@@ -328,6 +333,7 @@
     sendWithAttachment,
     handleRegenerate,
     handleEditResend,
+    hydrateMessageIds: hydrateSendMessageIds,
   } = useChatSend({
     getTaskList: () => taskList.value,
     getCurrentTaskId: () => currentTaskId.value,
@@ -389,6 +395,15 @@
       onOk: async () => {
         try {
           await deleteMessage(msg.id!);
+          // 被中断拆成两段的回答里,前一段也是后端单独一条记录,一并删除;失败只记日志不阻断本地移除
+          // ——主行已删,界面上这条消息本就该消失(残留的前段刷新后会单独出现,属可接受的退化)
+          for (const absorbedId of msg.absorbedRowIds ?? []) {
+            try {
+              await deleteMessage(absorbedId);
+            } catch (e) {
+              console.error('删除被中断段落失败:', e);
+            }
+          }
           message.success('消息已删除');
           if (ownerTask) {
             ownerTask.messages = ownerTask.messages.filter((m) => m !== msg);
@@ -748,6 +763,8 @@
           type: 'ai',
           content: isStreamError ? error.message : '抱歉,请求失败,请稍后重试。',
           time: dayjs().format('HH:mm'),
+          // 这轮请求失败了,后端没有对应记录:标记为仅前端消息,回填主键时跳过
+          localOnly: true,
         });
       }
     } finally {

+ 3 - 0
src/views/ventAI/manageAssistent/components/chat/ChatHeader.vue

@@ -42,6 +42,7 @@
       @file-preview="emit('file-preview', $event)"
       @agent-detail-click="emit('agent-detail-click', $event)"
       @locate-message="emit('locate-message', $event)"
+      @open-web="emit('open-web', $event)"
     />
 
     <!-- 会话导出 -->
@@ -102,6 +103,8 @@
     'agent-detail-click': [data: { messageIndex: number; agent: string; agentId?: string }];
     // 胶囊面板中点击"定位到消息",交给父组件滚动消息列表
     'locate-message': [messageIndex: number];
+    // 胶囊面板中点击站内链接,交给父组件在右侧面板用 iframe 打开
+    'open-web': [payload: { url: string; title?: string }];
   }>();
 
   // 导出气泡状态

+ 48 - 20
src/views/ventAI/manageAssistent/components/chat/ChatMessages.vue

@@ -49,7 +49,7 @@
                   !message.isLoading
                 )
               "
-              @click="handleFootnoteClick($event, index)"
+              @click="handleMessageTextClick($event, index)"
             ></div>
 
             <!-- 数据来源卡片 -->
@@ -122,7 +122,11 @@
               </div>
             </template>
             <template v-else>
-              <div class="message-text" v-html="renderMarkdownCached(message.content, tableIcons)"></div>
+              <div
+                class="message-text"
+                v-html="renderMarkdownCached(message.content, tableIcons)"
+                @click="handleMessageTextClick($event, index)"
+              ></div>
               <div v-if="message.attachedFile" class="attached-file-card" @click="emit('file-preview', message.attachedFile)">
                 <div class="file-icon-card" :style="{ backgroundImage: getFileIconVar(message.attachedFile.name) }"></div>
                 <div class="file-info-card">
@@ -172,6 +176,8 @@
     copyToClipboard,
     flashCopySuccess,
     getUserInputText,
+    panelOpenableUrl,
+    webTabTitle,
   } 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';
@@ -193,6 +199,7 @@
     (e: 'ask-user-cancel'): void;
     (e: 'agent-detail-click', data: AgentDetailData): void;
     (e: 'open-markdown-tab', payload: { title: string; content: string }): void;
+    (e: 'open-web', payload: { url: string; title?: string }): void;
     (e: 'delete-message', message: Message): void;
     (e: 'regenerate'): void;
     (e: 'edit-resend', index: number, newContent: string): void;
@@ -207,13 +214,16 @@
     indices: number[];
   }
 
-  // 将连续的 AI 消息划分为若干“回答组”
+  // 将「同一次提问的多个回答」划分为若干回答组:一条回答连同它后面**由重新生成追加的**回答算一组
+  // (见 Message.isRegeneratedAnswer)。不能按「连续 AI 消息」推断分组 —— 删掉中间的提问后,上下两条
+  // 互不相干的回答会变成连续消息,那样会被错误归成一组、用切换器藏起一条。
   const answerGroups = computed<AnswerGroup[]>(() => {
     const groups: AnswerGroup[] = [];
     let current: AnswerGroup | null = null;
     props.messages.forEach((msg, index) => {
       if (msg.type === 'ai') {
-        if (!current) {
+        // 组内除首条外都必须带「重新生成」标记,否则另起一组
+        if (!current || !msg.isRegeneratedAnswer) {
           current = { indices: [] };
           groups.push(current);
         }
@@ -247,25 +257,21 @@
     return Math.min(Math.max(val, 0), groupLen - 1);
   };
 
-  // 末尾回答组内是否存在正在生成(isLoading)的消息
+  // 末尾回答组内是否存在正在生成(isLoading)的消息(按回答组判定,与分组规则保持一致)
   const lastAnswerGroupHasLoading = computed(() => {
     const msgs = props.messages;
     if (!msgs.length || msgs[msgs.length - 1].type !== 'ai') return false;
-    let start = msgs.length - 1;
-    while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
-    for (let i = start; i < msgs.length; i++) {
-      if (msgs[i].isLoading) return true;
-    }
-    return false;
+    const lastMeta = aiGroupMeta.value.get(msgs.length - 1);
+    if (!lastMeta) return false;
+    const group = answerGroups.value.find((g) => getMessageKey(msgs[g.indices[0]]) === lastMeta.groupKey);
+    return !!group && group.indices.some((i) => msgs[i].isLoading);
   });
 
-  // 新回答开始生成(false → true)时,自动切换到最新一条正在生成的消息
+  // 新回答开始生成(false → true)时,自动切换到最新一条正在生成的消息所属的回答组
   watch(lastAnswerGroupHasLoading, (loading, wasLoading) => {
     if (loading && !wasLoading) {
-      const msgs = props.messages;
-      let start = msgs.length - 1;
-      while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
-      delete activeAnswerOffset[getMessageKey(msgs[start])];
+      const lastMeta = aiGroupMeta.value.get(props.messages.length - 1);
+      if (lastMeta) delete activeAnswerOffset[lastMeta.groupKey];
     }
   });
 
@@ -318,14 +324,14 @@
     if (cur < meta.groupLen - 1) activeAnswerOffset[meta.groupKey] = cur + 1;
   };
 
-  // 该消息是否属于“末尾连续 AI 组”:重新生成按钮只对最新一轮回答(含折叠组内任意一条)显示
+  // 该消息是否属于末尾回答组:重新生成按钮只对最新一轮回答(含回答组内任意一条)显示
   const isInLastAnswerGroup = (index: number): boolean => {
     const msgs = props.messages;
     if (!msgs.length || msgs[msgs.length - 1].type !== 'ai') return false;
     if (msgs[index]?.type !== 'ai') return false;
-    let start = msgs.length - 1;
-    while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
-    return index >= start;
+    const meta = aiGroupMeta.value.get(index);
+    const lastMeta = aiGroupMeta.value.get(msgs.length - 1);
+    return !!meta && !!lastMeta && meta.groupKey === lastMeta.groupKey;
   };
 
   const canRegenerate = (index: number, message: Message): boolean => isInLastAnswerGroup(index) && !message.isLoading && !message.isPendingApproval;
@@ -471,6 +477,28 @@
     }
   };
 
+  /**
+   * 消息正文的点击分发:http(s) 链接一律改在右侧面板打开(不区分站内站外,判定见 lib/webLink.ts),
+   * 按住 Ctrl/Cmd/Shift 或点非 http 协议链接时保留原行为(新窗口)。
+   * 没点到链接时按原逻辑处理脚注跳转。
+   * 正文是 v-html 渲染出来的,只能靠容器上的事件委托。
+   */
+  const handleMessageTextClick = (e: Event, msgIndex: number) => {
+    const anchor = (e.target as HTMLElement).closest('a[href]') as HTMLAnchorElement | null;
+    if (anchor) {
+      // 组合键留给用户的习惯操作(浏览器里 Ctrl/Cmd+点击就是「新标签页打开」)
+      const ke = e as MouseEvent;
+      if (ke.ctrlKey || ke.metaKey || ke.shiftKey) return;
+      const url = panelOpenableUrl(anchor.getAttribute('href'));
+      if (url) {
+        e.preventDefault();
+        emit('open-web', { url, title: webTabTitle(url, anchor.textContent || '') });
+      }
+      return;
+    }
+    handleFootnoteClick(e, msgIndex);
+  };
+
   const copyMessageContent = async (content: string, messageKey: string) => {
     if (!content) return;
     await copyToClipboard(content);

+ 10 - 1
src/views/ventAI/manageAssistent/components/panels/RightPanelTabs.vue

@@ -73,6 +73,7 @@
         @updated="emit('schedule-updated')"
         @open-session="handleOpenSession"
       />
+      <WebPreviewPanel v-else-if="activeTab?.type === 'web'" :url="activeTab.url" />
     </div>
   </div>
 </template>
@@ -82,6 +83,7 @@
   import { computed, nextTick, ref } from 'vue';
   import { onClickOutside, useResizeObserver } from '@vueuse/core';
   import FilePreviewPanel from '/@/views/ventAI/manageAssistent/components/resource/FilePreviewPanel.vue';
+  import WebPreviewPanel from '/@/views/ventAI/manageAssistent/components/resource/WebPreviewPanel.vue';
   import AgentDetailPanel from './AgentDetailPanel.vue';
   import ScheduleDetailPanel from './ScheduleDetailPanel.vue';
   import { getFileIconVar } from '/@/views/ventAI/manageAssistent/lib/index';
@@ -327,6 +329,10 @@
                 background-image: var(--img-chat-options-schedule-icon);
               }
 
+              .tab-icon-web {
+                background-image: var(--img-chat-link-icon);
+              }
+
               .tab-list-item-title {
                 flex: 1;
                 color: #c9d1d9;
@@ -337,7 +343,6 @@
                 font-size: 12px;
               }
             }
-
           }
         }
       }
@@ -442,6 +447,10 @@
       .panel-tab-schedule .panel-tab-icon {
         background-image: var(--img-chat-options-schedule-icon);
       }
+
+      .panel-tab-web .panel-tab-icon {
+        background-image: var(--img-chat-link-icon);
+      }
     }
 
     .panel-tab-content {

+ 2 - 2
src/views/ventAI/manageAssistent/components/resource/CadPreview.vue

@@ -24,8 +24,8 @@
    * 右侧面板的 CAD 预览:本地解析 DXF 并在 canvas 上绘制(可滚轮缩放、拖拽平移),不依赖任何后端服务。
    *
    * 当前只处理 .dxf——面板仅在 isDxfFile 命中时才渲染本组件。
-   * .dwg(专有二进制,需服务端转换)与 .mxweb(依赖 mxcad 及其配套服务)暂不预览,
-   * 由 fetchPreviewFile 返回 null 走"回退为下载"的老路径。
+   * .dwg(专有二进制,需上传后端转 SVG)见 components/resource/DwgPreview.vue;
+   * .mxweb(依赖 mxcad 及其配套服务)暂不预览,由 fetchPreviewFile 返回 null 走"回退为下载"的老路径。
    */
   const props = defineProps<{ file: AttachedFile }>();
 

+ 315 - 0
src/views/ventAI/manageAssistent/components/resource/DwgPreview.vue

@@ -0,0 +1,315 @@
+<template>
+  <div class="dwg-preview">
+    <!-- 转换中:上传原始 dwg + 后端转 svg 期间没有可展示的内容 -->
+    <div v-if="loading" class="dwg-hint">{{ loadingText }}</div>
+    <!-- 转换/加载失败:给一次重试入口(重试绕开缓存重新上传转换) -->
+    <div v-else-if="error" class="dwg-hint dwg-hint-error">
+      <span>{{ error }}</span>
+      <a-button size="small" type="link" @click="load(true)">重试</a-button>
+    </div>
+    <div v-else ref="stageRef" class="dwg-stage" @wheel.prevent="onWheel" @mousedown="onMouseDown" @dblclick="resetView">
+      <img class="dwg-svg" :src="svgSrc" :style="svgStyle" alt="DWG 图纸预览" draggable="false" @error="onSvgError" />
+      <!-- 工具栏不吃 mousedown:否则从按钮上按下会连带触发舞台的拖拽平移 -->
+      <div class="dwg-toolbar" @mousedown.stop>
+        <span class="dwg-stat">{{ zoomText }}</span>
+        <a-button size="small" type="link" @click="resetView">适应窗口</a-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
+  import { getToken } from '/@/utils/auth/index';
+  import { buildStaticFileUrl, dwgToSvg, dwgToSvgInfo } from '/@/views/ventAI/manageAssistent/api';
+  import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
+
+  /**
+   * 右侧面板的 DWG 预览。
+   *
+   * DWG 是专有二进制格式,前端解析不了(对比 .dxf:明文,见 CadPreview 本地解析渲染),
+   * 只能「取出原始字节 → 上传后端转 SVG → 当作图片展示」。两个接口分工:
+   *   1) dwgFileToSVG:上传 + 转换,拿到生成的 svg 路径(转换结果落在服务端,重开面板不用重转);
+   *   2) dwgFileToSVGInfo:转换 + 直接返回 svg 正文,仅在 svg 内容拿不到/不可用时兜底。
+   *
+   * svg 必须自己取回来造 blob 地址再交给 <img>,不能把静态地址直接塞进 img src:
+   * 静态目录的响应头是 `Content-Type: application/force-download` + `X-Content-Type-Options: nosniff`,
+   * 浏览器据此判定"不是图片"而拒绝渲染(实测 <img> 直接 error,同样的字节换成 blob 地址就能显示)。
+   *
+   * 转换发生在预览面板而不是附件/卡片点击时:附件加进来、AI 给了下载链接,都不代表用户要打开图纸,
+   * 万一不看就白传一次几百 KB 到几 MB 的文件。
+   */
+  const props = defineProps<{ file: AttachedFile }>();
+
+  const stageRef = ref<HTMLElement | null>(null);
+  const loading = ref(false);
+  const loadingText = ref('');
+  const error = ref('');
+  const svgSrc = ref('');
+  const view = ref({ k: 1, tx: 0, ty: 0 });
+
+  const zoomText = computed(() => `${Math.round(view.value.k * 100)}%`);
+  const svgStyle = computed(() => {
+    const { k, tx, ty } = view.value;
+    return { transform: `translate(${tx}px, ${ty}px) scale(${k})` };
+  });
+
+  // 已转换出的 svg 静态地址按文件缓存:面板实例会随标签切换重建(RightPanelTabs 没加 :key),
+  // 同一份图纸关掉再打开很常见,每次重开都重新上传转换一次没有意义——本次会话内转换结果是稳定的。
+  const svgUrlCache = new Map<string, string>();
+
+  // 当前文件的上传体:转 SVG(首次与兜底)都要用,留着避免重复构造
+  let uploadSource: File | null = null;
+  // 交给 <img> 的 blob 地址(svg 正文所在),换文件/卸载时必须释放
+  let svgBlobUrl = '';
+  // 请求序号:换文件或重试后,在途请求的响应回来时状态已经属于另一个文件,必须丢弃
+  let loadToken = 0;
+  // svg 兜底只做一次,否则失败会在两个接口之间来回触发
+  let fallbackTried = false;
+
+  const releaseSvgBlob = () => {
+    if (svgBlobUrl) {
+      URL.revokeObjectURL(svgBlobUrl);
+      svgBlobUrl = '';
+    }
+  };
+
+  const resetView = () => {
+    view.value = { k: 1, tx: 0, ty: 0 };
+  };
+
+  /** 把 svg 正文渲染出来:必须走 blob 地址(原因见文件头注释),空内容按失败处理 */
+  const applySvgText = (raw: string) => {
+    const svg = (raw || '').trim();
+    if (!svg) throw new Error('svg 内容为空');
+    releaseSvgBlob();
+    svgBlobUrl = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
+    svgSrc.value = svgBlobUrl;
+    resetView();
+  };
+
+  /** 上传用的 File:本地上传的附件直接用原始文件(字节最保真),远端预览对象只有字节时按原文件名重建(后端用它命名生成物) */
+  const toUploadFile = (file: AttachedFile): File | null => {
+    if (file.originalFile) return file.originalFile;
+    if (file.arrayBuffer) return new File([file.arrayBuffer], file.name || 'preview.dwg', { type: 'application/octet-stream' });
+    return null;
+  };
+
+  const load = async (force = false) => {
+    const token = ++loadToken;
+    const file = props.file;
+    releaseSvgBlob();
+    fallbackTried = false;
+    error.value = '';
+    // 上一次转换可能还停在 loading(请求在途时被换文件/重试抢占),这里统一复位:
+    // 命中缓存的分支不会再把它置回 false,漏了这一行就会永远卡在「正在转换图纸」。
+    loading.value = false;
+    resetView();
+
+    // 缓存的是转换产物地址而非 svg 正文:正文 1MB 上下,多份图纸攒在内存里不值当,
+    // 省下的只该是「重新上传 + 重新转换」这笔真正昂贵的开销。
+    const cachedUrl = force ? '' : svgUrlCache.get(file.id);
+    if (cachedUrl) {
+      svgSrc.value = '';
+      await loadSvgFromUrl(cachedUrl, token);
+      return;
+    }
+
+    svgSrc.value = '';
+    uploadSource = toUploadFile(file);
+    if (!uploadSource) {
+      error.value = '未取到图纸内容,无法预览';
+      return;
+    }
+
+    loading.value = true;
+    loadingText.value = '正在转换图纸,请稍候…';
+    try {
+      const result = await dwgToSvg(uploadSource);
+      if (token !== loadToken) return;
+      const url = result?.svgFile ? buildStaticFileUrl(result.svgFile) : '';
+      if (!url) throw new Error('转换结果缺少 svg 地址');
+      svgUrlCache.set(file.id, url);
+      loading.value = false;
+      await loadSvgFromUrl(url, token);
+    } catch (err) {
+      if (token !== loadToken) return;
+      console.error('DWG 转 SVG 失败:', err);
+      error.value = '图纸转换失败,请稍后重试';
+      loading.value = false;
+    }
+  };
+
+  /** 按转换产物地址取回 svg 正文再渲染(取不回来就换接口兜底) */
+  const loadSvgFromUrl = async (url: string, token: number) => {
+    loading.value = true;
+    loadingText.value = '正在加载图纸,请稍候…';
+    try {
+      const resp = await fetch(url, { headers: { 'X-Access-Token': getToken() } });
+      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+      const svg = await resp.text();
+      if (token !== loadToken) return;
+      applySvgText(svg);
+    } catch (err) {
+      if (token !== loadToken) return;
+      console.error('取回 DWG 转换结果失败:', err);
+      const ok = await loadSvgFromInfo(token);
+      if (token !== loadToken) return;
+      if (!ok) error.value = '图纸加载失败,请稍后重试';
+    } finally {
+      if (token === loadToken) loading.value = false;
+    }
+  };
+
+  /** 兜底:换「转换并返回 svg 正文」的接口再拿一次;成功返回 true */
+  const loadSvgFromInfo = async (token: number) => {
+    if (!uploadSource) return false;
+    try {
+      const result = await dwgToSvgInfo(uploadSource);
+      if (token !== loadToken) return false;
+      applySvgText(result?.svgInfo || '');
+      // 已经用过内容接口了,后面 <img> 再失败就别再回来重试同一条路
+      fallbackTried = true;
+      return true;
+    } catch (err) {
+      if (token === loadToken) console.error('DWG 转 SVG 内容失败:', err);
+      return false;
+    }
+  };
+
+  /** <img> 渲染失败(取回的字节其实不是有效 svg)时再兜一次 */
+  const onSvgError = async () => {
+    if (fallbackTried) {
+      error.value = '图纸加载失败,请稍后重试';
+      return;
+    }
+    const token = loadToken;
+    loading.value = true;
+    loadingText.value = '正在重新获取图纸内容,请稍候…';
+    const ok = await loadSvgFromInfo(token);
+    if (token !== loadToken) return;
+    if (!ok) error.value = '图纸加载失败,请稍后重试';
+    loading.value = false;
+  };
+
+  // 平移/缩放:与 DXF 预览(CadPreview)保持同一套操作——滚轮缩放、按住拖拽、双击复位
+  let dragging = false;
+  let lastX = 0;
+  let lastY = 0;
+
+  const onMouseMove = (ev: MouseEvent) => {
+    if (!dragging) return;
+    const v = view.value;
+    view.value = { ...v, tx: v.tx + (ev.clientX - lastX), ty: v.ty + (ev.clientY - lastY) };
+    lastX = ev.clientX;
+    lastY = ev.clientY;
+  };
+
+  const onMouseUp = () => {
+    dragging = false;
+    window.removeEventListener('mousemove', onMouseMove);
+    window.removeEventListener('mouseup', onMouseUp);
+  };
+
+  const onMouseDown = (ev: MouseEvent) => {
+    dragging = true;
+    lastX = ev.clientX;
+    lastY = ev.clientY;
+    window.addEventListener('mousemove', onMouseMove);
+    window.addEventListener('mouseup', onMouseUp);
+  };
+
+  const onWheel = (ev: WheelEvent) => {
+    const stage = stageRef.value;
+    if (!stage) return;
+    const rect = stage.getBoundingClientRect();
+    // 变换原点在图片中心(即舞台中心),所以光标位置先换算成「相对中心」的坐标
+    const px = ev.clientX - rect.left - rect.width / 2;
+    const py = ev.clientY - rect.top - rect.height / 2;
+    const v = view.value;
+    const k = Math.min(Math.max(v.k * (ev.deltaY < 0 ? 1.15 : 1 / 1.15), 0.05), 200);
+    // 以光标为不动点:q = c·k + t → t' = q - (q - t)·k'/k
+    const ratio = k / v.k;
+    view.value = { k, tx: px - (px - v.tx) * ratio, ty: py - (py - v.ty) * ratio };
+  };
+
+  onMounted(() => {
+    void load();
+  });
+
+  // 面板实例会在多个文件标签之间复用,换文件必须重新走一遍转换
+  watch(
+    () => props.file,
+    () => {
+      void load();
+    }
+  );
+
+  onBeforeUnmount(() => {
+    loadToken++; // 在途响应作废,避免卸载后还去改已销毁实例的状态
+    onMouseUp();
+    releaseSvgBlob();
+  });
+</script>
+
+<style scoped lang="less">
+  .dwg-preview {
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    min-height: 0;
+  }
+
+  .dwg-stage {
+    position: relative;
+    flex: 1;
+    min-height: 320px;
+    overflow: hidden;
+    // 后端转出的 svg 是透明底,图元里绝大多数是黑线(#000000,AutoCAD 默认色,实测 1MB 图纸里占六成以上),
+    // 所以必须配白底——不像 DXF 预览那样画在深色画布上,否则主体线条几乎看不见。
+    background: #fff;
+    cursor: grab;
+    user-select: none;
+
+    &:active {
+      cursor: grabbing;
+    }
+  }
+
+  // 后端生成的 svg 根节点带宽高 100% 和 viewBox(preserveAspectRatio 默认 xMidYMid meet),
+  // 图片框铺满舞台时图形本身就已是「适应窗口」,缩放/平移只在这个基础上叠加 transform
+  .dwg-svg {
+    position: absolute;
+    inset: 0;
+    width: 100%;
+    height: 100%;
+    transform-origin: center center;
+  }
+
+  .dwg-toolbar {
+    position: absolute;
+    right: 8px;
+    bottom: 8px;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+
+  .dwg-stat {
+    font-size: 12px;
+    // 面板其余部分是深色,这里的文字压在白底舞台上,取更深的灰
+    color: #606a76;
+  }
+
+  .dwg-hint {
+    padding: 12px 16px;
+    font-size: 13px;
+    color: #8b949e;
+  }
+
+  .dwg-hint-error {
+    display: flex;
+    align-items: center;
+    gap: 4px;
+  }
+</style>

+ 5 - 0
src/views/ventAI/manageAssistent/components/resource/FilePreviewPanel.vue

@@ -90,6 +90,9 @@
         <div v-else-if="isDxfFile(file.name)" class="cad-preview-wrapper">
           <CadPreview :file="file" />
         </div>
+        <div v-else-if="isDwgFile(file.name)" class="cad-preview-wrapper">
+          <DwgPreview :file="file" />
+        </div>
         <div v-else-if="isTextFile(file.name)" class="text-preview">
           <pre>{{ file.content || '文件内容加载中...' }}</pre>
         </div>
@@ -117,6 +120,7 @@
     isMdFile,
     isTextFile,
     isDxfFile,
+    isDwgFile,
     getFileExtension,
     getFileIconVar,
     renderMarkdown,
@@ -128,6 +132,7 @@
   } from '/@/views/ventAI/manageAssistent/lib/index';
   import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
   import CadPreview from '/@/views/ventAI/manageAssistent/components/resource/CadPreview.vue';
+  import DwgPreview from '/@/views/ventAI/manageAssistent/components/resource/DwgPreview.vue';
 
   // [perf-base-v1] 卡2c: VueOfficeDocx 改异步加载,仅打开 docx 预览时才拉取 office-preview chunk
   const VueOfficeDocx = defineAsyncComponent(() => import('@vue-office/docx/lib/v3/vue-office-docx.mjs'));

+ 8 - 2
src/views/ventAI/manageAssistent/components/resource/ResourceCapsule.vue

@@ -140,7 +140,7 @@
   import { CloseOutlined } from '@ant-design/icons-vue';
   import { onClickOutside, onKeyStroke } from '@vueuse/core';
   import { message } from 'ant-design-vue';
-  import { formatFileSize, getFileIconVar, fetchPreviewFile } from '/@/views/ventAI/manageAssistent/lib/index';
+  import { formatFileSize, getFileIconVar, fetchPreviewFile, panelOpenableUrl, webTabTitle } from '/@/views/ventAI/manageAssistent/lib/index';
   import type { AttachedFile, CapsuleFileItem, CapsuleLinkItem, CapsuleAgentItem } from '/@/views/ventAI/manageAssistent/types/index';
 
   const props = defineProps<{
@@ -155,6 +155,7 @@
     (e: 'file-preview', file: AttachedFile): void;
     (e: 'agent-detail-click', data: { messageIndex: number; agent: string; agentId?: string }): void;
     (e: 'locate-message', messageIndex: number): void;
+    (e: 'open-web', payload: { url: string; title?: string }): void;
   }>();
 
   const wrapRef = ref<HTMLElement>();
@@ -286,9 +287,14 @@
     }
   };
 
-  // 与消息正文中 markdown 链接的渲染行为一致:新标签页打开
+  // http(s) 链接一律在右侧面板打开(不区分站内站外),非 http 协议或非法地址仍按原行为新标签页打开
   const openLink = (link: CapsuleLinkItem) => {
     if (!link.href) return;
+    const url = panelOpenableUrl(link.href);
+    if (url) {
+      emit('open-web', { url, title: webTabTitle(url, link.title) });
+      return;
+    }
     window.open(link.href, '_blank', 'noopener,noreferrer');
   };
 

+ 220 - 0
src/views/ventAI/manageAssistent/components/resource/WebPreviewPanel.vue

@@ -0,0 +1,220 @@
+<template>
+  <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>
+    </div>
+    <!--
+      舞台:iframe 按选定分辨率(默认 1280×720)作为「真实视口」渲染,再整体等比缩放到面板里显示。
+      内网系统基本是按固定设计稿尺寸做的、不响应式,直接铺满窄面板会被挤变形,所以要这样缩。
+      只缩不放(放大会糊),四周留白居中;选「自适应面板」时退回铺满、不缩放。
+    -->
+    <div ref="stageRef" class="web-stage">
+      <div v-if="loading" class="web-loading">正在加载页面…</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>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
+  import { message } from 'ant-design-vue';
+  import { copyToClipboard } from '/@/views/ventAI/manageAssistent/lib/index';
+
+  /**
+   * 右侧面板的网页预览:http(s) 地址直接 iframe 嵌入(判定见 lib/webLink.ts,当前不区分站内站外)。
+   *
+   * 不加 sandbox:地址多为内网系统,加了(尤其不带 allow-same-origin 时)会让它变成不透明来源,
+   * 登录态、下载、弹窗等能力都受影响。要收紧的话给 iframe 加
+   * sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads" 即可。
+   */
+  const props = defineProps<{ url: string }>();
+
+  const frameRef = ref<HTMLIFrameElement>();
+  const stageRef = ref<HTMLElement>();
+  const loading = ref(true);
+
+  // 预设视口尺寸:内网系统按设计稿分辨率渲染才不会变形
+  const SIZE_PRESETS = [
+    { value: '1280x720', label: '1280 × 720', width: 1280, height: 720 },
+    { value: '1440x900', label: '1440 × 900', width: 1440, height: 900 },
+    { value: '1920x1080', label: '1920 × 1080', width: 1920, height: 1080 },
+    { value: 'fit', label: '自适应面板', width: 0, height: 0 },
+  ];
+  const sizeOptions = SIZE_PRESETS.map((p) => ({ value: p.value, label: p.label }));
+  const sizeKey = ref('1280x720');
+  const preset = computed(() => SIZE_PRESETS.find((p) => p.value === sizeKey.value) || SIZE_PRESETS[0]);
+
+  // 舞台可用尺寸:缩放比要跟着面板大小变(全屏/收起面板时都要重算)
+  const stageSize = ref({ width: 0, height: 0 });
+  const scale = computed(() => {
+    const p = preset.value;
+    const { width, height } = stageSize.value;
+    if (!p.width || !p.height || !width || !height) return 1;
+    return Math.min(1, width / p.width, height / p.height);
+  });
+  const viewportStyle = computed(() => {
+    const p = preset.value;
+    if (!p.width || !p.height) return { top: '0', left: '0', width: '100%', height: '100%', transform: 'none' };
+    return {
+      top: '50%',
+      left: '50%',
+      width: `${p.width}px`,
+      height: `${p.height}px`,
+      transform: `translate(-50%, -50%) scale(${scale.value})`,
+    };
+  });
+
+  let resizeObserver: ResizeObserver | null = null;
+  onMounted(() => {
+    const el = stageRef.value;
+    if (!el) return;
+    const measure = () => {
+      stageSize.value = { width: el.clientWidth, height: el.clientHeight };
+    };
+    resizeObserver = new ResizeObserver(measure);
+    resizeObserver.observe(el);
+    measure();
+  });
+
+  onBeforeUnmount(() => {
+    resizeObserver?.disconnect();
+    resizeObserver = null;
+  });
+
+  // 标签栏里显示短路径,完整地址放在 title 上
+  const displayUrl = computed(() => {
+    try {
+      const u = new URL(props.url, window.location.href);
+      return u.pathname + u.search || u.href;
+    } catch {
+      return props.url;
+    }
+  });
+
+  // 换地址(同一标签页复用实例时)重新进加载态
+  watch(
+    () => props.url,
+    () => {
+      loading.value = true;
+    }
+  );
+
+  /** iframe 里当前真实地址:可读时用它;跳到读不到的页面(跨域)时退回标签原始地址 */
+  const currentHref = (): string => {
+    try {
+      return frameRef.value?.contentWindow?.location.href || props.url;
+    } catch {
+      return props.url;
+    }
+  };
+
+  const reload = () => {
+    const win = frameRef.value?.contentWindow;
+    try {
+      // 刷新 iframe 内的当前页,而不是回到打开时的初始地址
+      loading.value = true;
+      win?.location.reload();
+    } catch {
+      // 跨域读不到时退回重新加载 src
+      if (frameRef.value) frameRef.value.src = props.url;
+    }
+  };
+
+  const openInBrowser = () => {
+    window.open(currentHref(), '_blank', 'noopener,noreferrer');
+  };
+
+  const copyUrl = () => {
+    copyToClipboard(currentHref()).then(() => message.success('链接已复制'));
+  };
+</script>
+
+<style scoped lang="less">
+  .web-preview-panel {
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    min-height: 0;
+  }
+
+  .web-toolbar {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    flex-shrink: 0;
+    padding: 8px 10px;
+    border-bottom: 1px solid rgb(63 80 106 / 25%);
+
+    .web-url {
+      flex: 1;
+      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%);
+    }
+
+    .web-size-select {
+      width: 116px;
+      flex-shrink: 0;
+    }
+
+    .web-actions {
+      display: flex;
+      align-items: center;
+      gap: 2px;
+      flex-shrink: 0;
+    }
+  }
+
+  .web-stage {
+    position: relative;
+    flex: 1;
+    min-height: 0;
+    overflow: hidden;
+    // 缩放后四周的留白底色,跟面板区分开,看起来像「一块屏幕」
+    background: rgb(0 0 0 / 22%);
+  }
+
+  .web-viewport {
+    position: absolute;
+    transform-origin: center center;
+    overflow: hidden;
+    background: #fff;
+    box-shadow: 0 6px 22px rgb(0 0 0 / 40%);
+
+    &.is-loading {
+      visibility: hidden;
+    }
+  }
+
+  .web-frame {
+    display: block;
+    width: 100%;
+    height: 100%;
+    border: none;
+  }
+
+  .web-loading {
+    position: absolute;
+    inset: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 13px;
+    color: #8b949e;
+  }
+</style>

+ 7 - 0
src/views/ventAI/manageAssistent/hooks/useChatApproval.ts

@@ -20,6 +20,8 @@ export interface ChatApprovalDeps {
   refreshUsage: () => void;
   resetTodos: () => void;
   setCurrentSessionId: (sessionId: string) => void;
+  /** 补齐消息主键:续跑完成后的回答同样依赖它才有删除入口,同时把这一轮被中断的前半段挂到消息上 */
+  hydrateMessageIds: (sessionId: string, msgs: Message[]) => Promise<void>;
 }
 
 export const useChatApproval = (deps: ChatApprovalDeps) => {
@@ -34,6 +36,7 @@ export const useChatApproval = (deps: ChatApprovalDeps) => {
     refreshUsage,
     resetTodos,
     setCurrentSessionId,
+    hydrateMessageIds,
   } = deps;
   // 按任务维度的状态存储
   const taskPendingApprovals = ref<Map<string, { sessionId: string; threadId: string }>>(new Map());
@@ -140,6 +143,10 @@ export const useChatApproval = (deps: ChatApprovalDeps) => {
       if (result.session_id) {
         setCurrentSessionId(result.session_id);
       }
+
+      // 续跑完成,这一轮的回答刚由后端持久化:补主键(删除按钮依赖它),
+      // 并把本次打断落库的前半段挂到这条消息上(删除时一起删,详见 hydrateMessageIds)
+      await hydrateMessageIds(sessionId, task.messages);
     } catch (error) {
       if ((error as Error)?.name === 'AbortError') {
         lastMsg.isLoading = false;

+ 17 - 2
src/views/ventAI/manageAssistent/hooks/useChatFiles.ts

@@ -2,7 +2,16 @@ import { ref } from 'vue';
 import dayjs from 'dayjs';
 import { message } from 'ant-design-vue';
 import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
-import { isAudioFile, isCsvFile, isDxfFile, isExcelFile, isTextFile, isVideoFile, isWordFile } from '/@/views/ventAI/manageAssistent/lib/index';
+import {
+  isAudioFile,
+  isCsvFile,
+  isDwgFile,
+  isDxfFile,
+  isExcelFile,
+  isTextFile,
+  isVideoFile,
+  isWordFile,
+} from '/@/views/ventAI/manageAssistent/lib/index';
 import type ChatInputArea from '/@/views/ventAI/manageAssistent/components/chat/ChatInputArea.vue';
 
 /**
@@ -52,7 +61,13 @@ export const useChatFiles = (getChatInput: () => InstanceType<typeof ChatInputAr
       // 读取方式只判断一次:onload 与 readAs* 两处各写一遍条件容易走偏。
       // .dxf 必须在此显式分流到文本:否则会落到 dataURL 分支,附件里只有 preview、拿不到 content,
       // DXF 预览面板只能显示空白。
-      const readMode = isTextFile(name) || isCsvFile(name) || isDxfFile(name) ? 'text' : isWordFile(name) || isExcelFile(name) ? 'binary' : 'dataurl';
+      // .dwg 同样要显式分流到二进制:预览面板得拿原始字节上传后端转 SVG,存成 base64 的 dataURL 既白占内存又没法直接用。
+      const readMode =
+        isTextFile(name) || isCsvFile(name) || isDxfFile(name)
+          ? 'text'
+          : isWordFile(name) || isExcelFile(name) || isDwgFile(name)
+            ? 'binary'
+            : 'dataurl';
 
       reader.onload = (e) => {
         activeFileReader.value = null;

+ 18 - 0
src/views/ventAI/manageAssistent/hooks/useChatPanels.ts

@@ -1,5 +1,6 @@
 import { computed, nextTick, ref, watch } from 'vue';
 import { applyWrapStyles } from '../lib/modalWrap';
+import { webTabTitle } from '../lib/webLink';
 import type { AgentDetailData, AttachedFile, Message, RightPanelState, RightPanelTab, Task } from '/@/views/ventAI/manageAssistent/types/index';
 
 /**
@@ -175,6 +176,22 @@ export const useChatPanels = (deps: ChatPanelsDeps) => {
     }
   };
 
+  /**
+   * 在右侧面板用 iframe 打开链接,同一地址复用同一个标签。
+   * 只接受已经过判定可进面板的地址(见 lib/webLink.ts 的 panelOpenableUrl),非 http(s) 的不要走这里。
+   */
+  const openWebTab = (payload: { url: string; title?: string }) => {
+    const task = getCurrentTask();
+    if (!task || !payload?.url) return;
+    const state = getPanelState(task);
+    const existing = state.tabs.find((t) => t.type === 'web' && t.url === payload.url);
+    if (existing) {
+      state.activeTabId = existing.id;
+      return;
+    }
+    openTab({ id: nextTabId(), type: 'web', title: webTabTitle(payload.url, payload.title), url: payload.url });
+  };
+
   const getCurrentTaskFiles = (): AttachedFile[] => {
     return getCurrentTask()?.attachedFiles || [];
   };
@@ -259,6 +276,7 @@ export const useChatPanels = (deps: ChatPanelsDeps) => {
     handleLocateMessage,
     openMarkdownPreviewTab,
     openFilePreview,
+    openWebTab,
     handleOpenFilePreview,
     handleSaveSuccess,
     getCurrentTaskFiles,

+ 161 - 62
src/views/ventAI/manageAssistent/hooks/useChatSend.ts

@@ -4,7 +4,7 @@ import { message } from 'ant-design-vue';
 import { unifiedStream, reviewPdfStream, getDetail, deleteMessage } from '/@/views/ventAI/manageAssistent/api';
 import type { HistoryMessage } from '/@/views/ventAI/manageAssistent/api';
 import type { AttachedFile, Message, SseEvent, Task } from '/@/views/ventAI/manageAssistent/types/index';
-import { getCssUrl, getUserInputText, normalizeLineBreaks, stripInjectedRefs } from '/@/views/ventAI/manageAssistent/lib/index';
+import { getCssUrl, getUserInputText, stripInjectedRefs } from '/@/views/ventAI/manageAssistent/lib/index';
 
 /**
  * 发送与流式管线:推送一条空 AI 消息 → 建 AbortController → 走 SSE(纯文本 / PDF 审查)→ 处理
@@ -85,6 +85,41 @@ export const useChatSend = (deps: ChatSendDeps) => {
 
   const getSessionRefIconUrl = () => getCssUrl('--img-chat-popup-session-icon');
 
+  // 把「刚开出来的空白任务」迁移到后端会话:写 sessionId、任务 id 换成会话 id、AbortController 与
+  // 审批状态跟着搬、列表去重,并把该会话标记为已加载。
+  //
+  // 正常结束与「停止生成」共用这一份:停止生成时后端会话同样已经建好(事件里带着 session_id),
+  // 不迁移的话任务拿不到 sessionId,下一次提问会再开一个新会话 —— 前面那条提问留在旧会话里,
+  // 上下文就断了(现象:侧边栏多出一个只有提问、没有回答的会话)。
+  //
+  // fallbackName 只在没有拿到后端标题时用来命名(传空串表示不命名)。
+  const migrateTaskToSession = (originTaskId: string, sessionId: string, fallbackName: string) => {
+    const task = getTaskList().find((t) => t.id === originTaskId);
+    if (!task || task.sessionId || !sessionId) return;
+    task.sessionId = sessionId;
+    // 流式期间若列表已用后端标题归并过该任务,则保留后端标题;否则用首条消息命名
+    if (task.pendingSessionId !== sessionId && fallbackName) {
+      task.name = fallbackName.substring(0, 20) + (fallbackName.length > 20 ? '...' : '');
+    }
+    task.pendingSessionId = undefined;
+    if (task.id !== sessionId) {
+      // 将 AbortController 从旧 ID 迁移到新 ID
+      const controller = taskAbortControllers.get(originTaskId);
+      if (controller) {
+        setTaskAbortController(originTaskId, null);
+        setTaskAbortController(sessionId, controller);
+      }
+      setTaskList(getTaskList().filter((t) => t.id !== sessionId));
+      task.id = sessionId;
+      historyLoadedTasks.add(sessionId);
+      // 迁移审批状态 key(任务 ID 变了,审批 Map 的 key 也要跟着变)
+      migrateTaskApproval(originTaskId, sessionId);
+    }
+    if (getCurrentTaskId() === originTaskId) {
+      setCurrentTaskId(sessionId);
+    }
+  };
+
   // targetTaskId:回答写进哪个任务。默认当前任务;重新生成与编辑重发是在若干次 await 之后才调到这里,
   // 必须显式传入发起任务,否则请求期间切走后新回答会落到别的会话上。
   const streamAiAnswer = async (userInput: string, file?: AttachedFile, targetTaskId?: string) => {
@@ -92,6 +127,10 @@ export const useChatSend = (deps: ChatSendDeps) => {
     const originTask = getTaskList().find((t) => t.id === originTaskId);
     if (!originTask) return;
     const now = dayjs();
+    // 追加时前一条已经是回答 → 这是「重新生成」产生的同一次提问的又一个回答(正常发送会先追加
+    // 用户消息、编辑重发会先截断再追加用户消息,都到不了这里)。打标供回答分组使用,
+    // 分组只认这个标记,不再按「连续 AI 消息」推断(见 Message.isRegeneratedAnswer 的说明)
+    const isRegeneratedAnswer = originTask.messages.length > 0 && originTask.messages[originTask.messages.length - 1].type === 'ai';
     const newMsg: Message = {
       type: 'ai',
       content: '',
@@ -100,6 +139,7 @@ export const useChatSend = (deps: ChatSendDeps) => {
       isLoading: true,
       thinkingSteps: [],
       generateStartTime: Date.now(),
+      isRegeneratedAnswer,
     };
     originTask.messages.push(newMsg);
     // 从数组里读回这一条:任务状态是 reactive 的,push 进去的原始对象由代理包裹,
@@ -114,7 +154,13 @@ export const useChatSend = (deps: ChatSendDeps) => {
     const controller = new AbortController();
     setTaskAbortController(originTaskId, controller);
 
+    // 服务端每条事件都带 session_id(连第一条 agent_start 都有)。正常结束时下面用返回值里的,
+    // 但用户中途「停止生成」时流是被 abort 的、拿不到返回值,先把事件里出现的 id 记下来,
+    // 中断后才能把任务锚定到后端已建好的这个会话上(否则下一次提问会另起会话,上下文断掉)。
+    let eventSessionId = '';
+
     const onChunk = (data: SseEvent) => {
+      if (!eventSessionId && data.session_id) eventSessionId = data.session_id;
       handleStreamEvent(data, aiMsg, originTaskId);
       if (getCurrentTaskId() === originTaskId) scrollToBottomThrottled();
     };
@@ -142,34 +188,12 @@ export const useChatSend = (deps: ChatSendDeps) => {
             onChunk
           );
 
-      if (result.session_id) {
-        const originTask = getTaskList().find((t) => t.id === originTaskId);
-        if (originTask && !originTask.sessionId) {
-          originTask.sessionId = result.session_id;
-          // 流式期间若列表已用后端标题归并过该任务,则保留后端标题;否则用首条消息命名
-          if (originTask.pendingSessionId !== result.session_id) {
-            const taskName = userInput || (file ? `文件:${file.name}` : '');
-            originTask.name = taskName.substring(0, 20) + (taskName.length > 20 ? '...' : '');
-          }
-          originTask.pendingSessionId = undefined;
-          if (originTask.id !== result.session_id) {
-            // 将 AbortController 从旧 ID 迁移到新 ID
-            const controller = taskAbortControllers.get(originTaskId);
-            if (controller) {
-              setTaskAbortController(originTaskId, null);
-              setTaskAbortController(result.session_id, controller);
-            }
-            setTaskList(getTaskList().filter((t) => t.id !== result.session_id));
-            originTask.id = result.session_id;
-            historyLoadedTasks.add(result.session_id);
-            // 迁移审批状态 key(任务 ID 变了,审批 Map 的 key 也要跟着变)
-            migrateTaskApproval(originTaskId, result.session_id);
-          }
-          if (getCurrentTaskId() === originTaskId) {
-            setCurrentTaskId(result.session_id);
-          }
-        }
-      }
+      // 结果里的 session_id 与事件里捕获的是同一个,前者缺失时用后者兜底
+      migrateTaskToSession(originTaskId, result.session_id || eventSessionId, userInput || (file ? `文件:${file.name}` : ''));
+
+      // 本轮问答刚由后端持久化,补一次数据库主键:删除按钮依赖它,不补的话刚发出的消息没有删除
+      // 入口(旧实现只在重新生成/编辑重发里补,正常发送出来的消息要刷新页面才补上)。
+      await hydrateMessageIds(originTask?.sessionId || '', originTask?.messages || []);
     } catch (error) {
       // 用户主动停止生成
       if ((error as Error)?.name === 'AbortError') {
@@ -180,6 +204,12 @@ export const useChatSend = (deps: ChatSendDeps) => {
         if (!aiMsg.content) {
           aiMsg.content = '已停止生成。';
         }
+        // 这一轮被中断,后端只落了用户消息(回答在正常结束时才落库),本地这条气泡没有对应记录
+        aiMsg.localOnly = true;
+        // 中断同样要把任务锚定到后端已建好的会话上,否则下一次提问会另起一个会话、上下文断掉
+        migrateTaskToSession(originTaskId, eventSessionId, userInput || (file ? `文件:${file.name}` : ''));
+        // 停下的这一轮里用户消息已经落库,补主键以便删除/编辑;占位气泡是 localOnly,配对时会被跳过
+        await hydrateMessageIds(originTask?.sessionId || '', originTask?.messages || []);
         return;
       }
       throw error;
@@ -244,9 +274,24 @@ export const useChatSend = (deps: ChatSendDeps) => {
   // 「当前会话 / 当前任务」,请求期间用户切走就会把别的会话的消息当成目标,回填出的 id 会让
   // 后续的删除打到另一条会话上。
   //
-  // 两边条数必须严格一致才回填。早期实现用「长度差」做尾部对齐,一旦本地存在仅前端的消息
-  // (例如发送失败的报错气泡,后端没有对应行),长度差为负,之后每条未回填的消息都会拿到
-  // 前一条的 id —— 删除按钮据此调 DELETE /messages/{id},删掉的是用户根本没想删的那一行。
+  // 两步配对,原则都是「宁可少补(那一条暂时没有删除按钮),不能补错(删除会删到别的行)」:
+  //
+  // 一、按「角色 + 文本」精确配对。条数不一致(本地多出仅前端的报错气泡,或后端还留着重发
+  //     产生的重复行)也能把对得上的逐条填上;只认文本完全一致的记录,同文本多条按先后顺序
+  //     一一消费。
+  // 二、剩下的没配上,且两边条数一致、角色顺序完全一致时,按位置补。
+  //     文本对不上是常态而不是异常:子智能体的输出在流式时被路由进思考卡片,主回答才进气泡,
+  //     而后端落库时把两段拼成同一条记录 —— 气泡文本只是整条记录的后半截,按文本永远配不上,
+  //     表现为「刚发出的 AI 消息没有删除按钮,刷新页面才出现」。条数与角色序列一致时,两条序列
+  //     就是同一串问答,正文差异不影响一一对应关系。
+  //
+  // 两步都跳过 localOnly 的消息(停止生成留下的占位气泡、发送失败的报错气泡):后端没有对应
+  // 记录,留在序列里会把后面的消息顶偏一位,让原本配得上的也整批配不上。被中断拆开的那一轮
+  // (见 lib/turnRows.ts)在 transformHistoryToMessages 里已合成一条记录,这里只需处理主键升级:
+  // 消息若还拿着「前半段」的主键,换成整轮回答那一行的,前半段转进 absorbedRowIds 备删除。
+  //
+  // 早期实现要么按条数严格相等整批回填(长度差为负时会整体放弃),要么用长度差做尾部对齐
+  // (每条未回填的消息都拿到前一条的 id,删除会删掉用户根本没想删的那一行),都已被这两种取代。
   const hydrateMessageIds = async (sessionId: string, msgs: Message[]) => {
     if (!sessionId || !msgs.length) return;
     try {
@@ -254,38 +299,82 @@ export const useChatSend = (deps: ChatSendDeps) => {
       // getDetail 已按真实契约标注 { messages, ... },不再猜 messages / data / 裸数组三种外壳
       const raw = res?.messages;
       if (!Array.isArray(raw)) return;
-      // 用 transformHistoryToMessages 归一化,避免 model_thinking 等记录导致条数与前端不对齐
+      // 用 transformHistoryToMessages 归一化,避免 model_thinking 等记录影响配对
       const history = transformHistoryToMessages(raw);
-      const ids = history.map((m) => m.id).filter((id): id is number => id != null);
-      if (ids.length !== msgs.length) return;
-      for (let i = 0; i < msgs.length; i++) {
-        if (msgs[i].id == null) msgs[i].id = ids[i];
+
+      const normalize = (text: string) => (text || '').replace(/\s+/g, ' ').trim();
+      const keyOf = (type: Message['type'], text: string) => `${type}::${normalize(text)}`;
+
+      // 被并入某条回答的前半段(计划/澄清段)的主键按「归到哪条回答」记账,命中时一并挂到消息上
+      const assignAbsorbedIds = (msg: Message | undefined, ids: number[]) => {
+        if (!msg || !ids.length) return;
+        const merged = [...(msg.absorbedRowIds || [])];
+        for (const id of ids) {
+          if (!merged.includes(id)) merged.push(id);
+        }
+        if (merged.length) msg.absorbedRowIds = merged;
+      };
+
+      // 后端记录按 key 分桶,桶内保持先后顺序(同文本出现多次时按顺序对应)
+      const absorbedIdsById = new Map<number, number[]>();
+      const pool = new Map<string, number[]>();
+      for (const item of history) {
+        if (item.id == null) continue;
+        const key = keyOf(item.type, item.content);
+        const bucket = pool.get(key);
+        if (bucket) bucket.push(item.id);
+        else pool.set(key, [item.id]);
+        if (item.absorbedRowIds?.length) absorbedIdsById.set(item.id, item.absorbedRowIds);
       }
-    } catch (e) {
-      console.error('补齐消息 id 失败:', e);
-    }
-  };
 
-  // 重新生成后,删除后端因重发而产生的重复用户消息(保留第一次,形成「一次提问、多次回答」)。
-  // sessionId 同样由调用方锚定传入:若这里读「当前会话」,重新生成期间切走就会去别的会话里
-  // 按内容匹配并删除 user 记录,可能删掉一条与本次操作无关的正常消息。
-  const removeDuplicateRegenerateUserMessage = async (sessionId: string, expectedContent: string) => {
-    if (!sessionId) return;
-    try {
-      const res = await getDetail(sessionId);
-      const raw = res?.messages;
-      if (!Array.isArray(raw)) return;
+      // 已发出去的主键:消息上本来就有的(历史加载回填的)与已并入的前半段都算,
+      // 同一条后端记录不能既当消息主键又被领走两次
+      const assigned = new Set<number>();
+      for (const msg of msgs) {
+        if (msg.id != null) assigned.add(msg.id);
+        for (const absorbedId of msg.absorbedRowIds || []) assigned.add(absorbedId);
+      }
+
+      for (const msg of msgs) {
+        // localOnly(停止生成的占位气泡、发送失败的报错气泡)后端没有记录,不参与配对
+        if (msg.id != null || msg.localOnly) continue;
+        // 用户消息要拿「真正发出去的那段文本」比对:气泡里的 content 含注入的引用图标 HTML,
+        // 后端存的是纯文本(与编辑重发、重新生成取原始输入用的是同一个函数)
+        const text = msg.type === 'user' ? getUserInputText(msg) : msg.content;
+        const bucket = pool.get(keyOf(msg.type, text));
+        if (!bucket) continue;
+        // 桶内已被占用的主键(同文本的另一条已经被前面的消息领走)直接跳过
+        while (bucket.length && assigned.has(bucket[0])) bucket.shift();
+        if (!bucket.length) continue;
+        const id = bucket.shift() as number;
+        msg.id = id;
+        assigned.add(id);
+        assignAbsorbedIds(msg, absorbedIdsById.get(id) || []);
+      }
 
-      // 保留第一条 user(原始提问),删除其余与重发内容一致的重复 user,避免多次重新生成残留
-      const userMsgs = raw.filter((m) => m?.role === 'user' || m?.type === 'user_message');
-      const duplicates = userMsgs.filter(
-        (m, idx) => idx > 0 && normalizeLineBreaks(m?.content || '').trim() === expectedContent.trim() && m?.id != null
-      );
-      for (const dup of duplicates) {
-        await deleteMessage(dup.id);
+      // 位置配对同样剔除 localOnly:它们会把后面的消息整体顶偏一位,导致本可配上的也配不上
+      const pairable = msgs.filter((m) => !m.localOnly);
+      const sameShape = history.length === pairable.length && history.every((h, i) => h.type === pairable[i].type);
+      if (!sameShape) return;
+      for (let i = 0; i < pairable.length; i++) {
+        const src = history[i];
+        const msg = pairable[i];
+        // 主键升级:这条消息此前拿到的是被中断那一轮的前半段(先落库时按位置配上的),现在整轮回答
+        // 也落库、两段已合成一条记录 —— 主键换成整轮的,旧主键转进 absorbedRowIds(删除时一起删)
+        if (msg.id != null && src.absorbedRowIds?.includes(msg.id) && src.id != null && !assigned.has(src.id)) {
+          msg.id = src.id;
+          assigned.add(src.id);
+          assignAbsorbedIds(msg, src.absorbedRowIds);
+          continue;
+        }
+        if (msg.id == null && src.id != null && !assigned.has(src.id)) {
+          msg.id = src.id;
+          assigned.add(src.id);
+        }
+        assignAbsorbedIds(msg, src.absorbedRowIds || []);
       }
     } catch (e) {
-      console.error('删除重发产生的重复用户消息失败:', e);
+      console.error('补齐消息 id 失败:', e);
     }
   };
 
@@ -317,10 +406,12 @@ export const useChatSend = (deps: ChatSendDeps) => {
     const attachedFile = userMsg.attachedFile;
 
     // 不删除旧 AI 消息、不动用户提问,直接重发同一条用户输入重新生成。
-    // 以下三步都在 await 之后,一律显式锚定发起任务:用「当前任务/当前会话」会在切走后作用于别的会话
+    // 重发时后端会把同一句提问再收一遍、为它再写一条用户记录 —— 这条记录**不删除**:
+    // 它是「同一次提问的又一个回答」唯一可持久的依据,留着(加载时会被识别为重复提问,
+    // 不渲染成气泡,见 transformHistoryToMessages),刷新后重新生成的回答才能依旧折叠。
+    // 前端本次会话内的折叠则靠 streamAiAnswer 给新回答打的 isRegeneratedAnswer 标记。
+    // 以下两步都在 await 之后,一律显式锚定发起任务:用「当前任务/当前会话」会在切走后作用于别的会话
     await streamAiAnswer(userInput, attachedFile, task.id);
-    // 删除重发产生的重复用户消息,形成「一次提问、多次回答」
-    await removeDuplicateRegenerateUserMessage(task.sessionId, userInput);
     // 回填新生成消息的 id,使折叠组内各条消息都能正常显示删除按钮
     await hydrateMessageIds(task.sessionId, task.messages);
   };
@@ -354,6 +445,15 @@ export const useChatSend = (deps: ChatSendDeps) => {
       } catch (e) {
         console.error('删除消息失败:', e);
       }
+      // 这一轮在被中断时拆成了两段落库,被并入的其它行也属于这条消息,一并删掉,
+      // 否则截断后残留一条孤立的前段
+      for (const absorbedId of m.absorbedRowIds ?? []) {
+        try {
+          await deleteMessage(absorbedId);
+        } catch (e) {
+          console.error('删除被中断段落失败:', e);
+        }
+      }
     }
     // 本地截断到编辑位置(原地截断,始终操作同一个任务的消息数组)
     msgs.splice(index);
@@ -383,7 +483,6 @@ export const useChatSend = (deps: ChatSendDeps) => {
     sendWithAttachment,
     stripInjectedRefs,
     hydrateMessageIds,
-    removeDuplicateRegenerateUserMessage,
     handleRegenerate,
     handleEditResend,
   };

+ 58 - 2
src/views/ventAI/manageAssistent/hooks/useChatTasks.ts

@@ -4,7 +4,7 @@ import { message } from 'ant-design-vue';
 import { buildReportDownloadUrl, editSessionTitle, getHistoryList, pinSession } from '/@/views/ventAI/manageAssistent/api';
 import type { Message, Task } from '/@/views/ventAI/manageAssistent/types/index';
 import type { HistoryMessage } from '/@/views/ventAI/manageAssistent/api';
-import { normalizeLineBreaks } from '/@/views/ventAI/manageAssistent/lib/index';
+import { normalizeLineBreaks, isSplitTurnPair } from '/@/views/ventAI/manageAssistent/lib/index';
 
 /**
  * 会话(任务)状态与列表:taskList / currentTaskId / 当前会话消息,以及列表拉取、置顶、标题、
@@ -160,11 +160,37 @@ export const useChatTasks = () => {
   const transformHistoryToMessages = (data: HistoryMessage[]): Message[] => {
     const messages: Message[] = [];
 
+    // 重新生成时,后端会把同一句提问再收一遍,为它再写一条用户记录(前端不再删除它,见 useChatSend
+    // 里对这段清理的说明)。加载时把它识别出来:不渲染成气泡,并把紧随其后的回答标成「同一次提问的
+    // 又一个回答」、把这条记录的主键并进那条回答 —— 刷新后重新生成的多个回答依旧折叠成一条可切换的
+    // 消息,删除时也能把它一起带走。
+    //
+    // 「内容与上一句提问完全相同」是这条重复记录唯一可持久化的特征(历史接口没有别的区分字段)。
+    // 删掉中间某条提问造成的两条相邻回答不满足它(那些提问内容各不相同),因此不会被误认。
+    let lastUserContent: string | null = null;
+    let markNextAnswerAsFollowUp = false;
+    let absorbNextAnswerRowId: number | null = null;
+
     for (const item of data) {
       const normalizedContent = normalizeLineBreaks(item.content);
       const time = item.created_at ? dayjs(item.created_at).format('HH:mm') : '';
       const createdAt = item.created_at || '';
 
+      const isUserRow = item.role === 'user' || item.type === 'user_message';
+      const trimmed = normalizedContent.trim();
+      const isDuplicateQuestion = isUserRow && lastUserContent !== null && trimmed === lastUserContent;
+      if (isUserRow) lastUserContent = trimmed;
+      if (isDuplicateQuestion) {
+        markNextAnswerAsFollowUp = true;
+        absorbNextAnswerRowId = item.id ?? null;
+        continue;
+      }
+      // 打标只作用于紧随其后的那一条回答;中间夹了别的行就作废,避免标到别轮的回答上
+      if (item.role !== 'assistant') {
+        markNextAnswerAsFollowUp = false;
+        absorbNextAnswerRowId = null;
+      }
+
       // 新格式:role 字段 (user/assistant)
       if (item.role === 'user') {
         messages.push({ id: item.id, type: 'user', content: normalizedContent, time, createdAt });
@@ -182,6 +208,15 @@ export const useChatTasks = () => {
           // null 与 undefined 对调用方等价(均按「没有引用」处理),不赋值以免把 Message 上的可选字段写成 null
           msg.citations = rawCitations;
         }
+        // 上一条是重新生成的重复提问:这条回答是同一次提问的又一个回答,并接管那条记录
+        if (markNextAnswerAsFollowUp) {
+          msg.isRegeneratedAnswer = true;
+          markNextAnswerAsFollowUp = false;
+        }
+        if (absorbNextAnswerRowId != null) {
+          msg.absorbedRowIds = [absorbNextAnswerRowId];
+          absorbNextAnswerRowId = null;
+        }
         messages.push(msg);
       }
       // 兼容旧格式:type 字段
@@ -198,7 +233,28 @@ export const useChatTasks = () => {
       }
     }
 
-    return messages;
+    // 同一轮被中断拆成两段的助手行合成一条(判定与依据见 lib/turnRows.ts):一轮问答在用户视角只有
+    // 一条 AI 消息,后端却是两条记录(中断时先落一段,批准/补充回答后再落一段)。合并后的正文
+    // 是两段的拼接 —— 与流式过程中这条消息实际显示的内容一致;主键取后一段(整轮回答落库的那条),
+    // 前一段的主键记进 absorbedRowIds,删除时一起删。判不出来就维持两条消息。
+    const merged: Message[] = [];
+    for (let i = 0; i < messages.length; i++) {
+      const msg = messages[i];
+      if (isSplitTurnPair(messages, i)) {
+        const answer = messages[i + 1];
+        const absorbed = [...(msg.absorbedRowIds || [])];
+        if (msg.id != null) absorbed.push(msg.id);
+        answer.content = msg.content + answer.content;
+        answer.absorbedRowIds = [...absorbed, ...(answer.absorbedRowIds || [])];
+        // 时间取前一段的:这条消息在流式里就是提问后立刻出现的,时间应对齐提问时刻
+        answer.time = msg.time;
+        answer.createdAt = msg.createdAt;
+        continue;
+      }
+      merged.push(msg);
+    }
+
+    return merged;
   };
   return {
     taskList,

+ 5 - 2
src/views/ventAI/manageAssistent/lib/fileIcon.ts

@@ -18,10 +18,13 @@ export const isExcelFile = (filename: string): boolean => /\.(xls|xlsx)$/i.test(
 export const isCsvFile = (filename: string): boolean => /\.csv$/i.test(filename);
 
 // —— CAD ——
-// 当前只支持 .dxf(明文交换格式,可本地解析渲染);
-// .dwg(专有二进制,需服务端转换)与 .mxweb(依赖 mxcad 及其配套服务)暂不预览,由 fetchPreviewFile 返回 null 回退为下载。
+// .dxf:明文交换格式,前端本地解析渲染(components/resource/CadPreview.vue);
+// .dwg:专有二进制,前端解析不了,预览时上传后端转成 SVG 再展示(components/resource/DwgPreview.vue);
+// .mxweb:依赖 mxcad 及其配套服务,暂不预览,由 fetchPreviewFile 返回 null 回退为下载。
 export const isDxfFile = (filename: string): boolean => /\.dxf$/i.test(filename);
 
+export const isDwgFile = (filename: string): boolean => /\.dwg$/i.test(filename);
+
 export const isTextFile = (filename: string): boolean =>
   /\.(txt|md|json|csv|xml|yaml|yml|log|ini|toml|cfg|conf|env|gitignore|dockerignore|editorconfig|prettierrc|eslintrc|babelrc|properties|sh|bat|ps1|cmd|bash|zsh|fish|sql|graphql|proto|js|ts|jsx|tsx|vue|css|scss|sass|less|html|htm|svelte|py|java|kt|kts|c|cpp|h|hpp|cc|cxx|cs|go|rs|rb|php|swift|m|mm|r|R|lua|pl|pm|hs|ex|exs|erl|clj|scala|dart|zig|nim|v|sol|tf|hcl|gradle|cmake|makefile|mk)$/i.test(
     filename

+ 4 - 2
src/views/ventAI/manageAssistent/lib/filePreview.ts

@@ -1,7 +1,7 @@
 // lib/filePreview.ts:由 lib/utils.ts 按职责拆分(实现未改)
 import { getToken } from '/@/utils/auth/index';
 import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/resource';
-import { isImageFile, isPdfFile, isWordFile, isExcelFile } from './fileIcon';
+import { isImageFile, isPdfFile, isWordFile, isExcelFile, isDwgFile } from './fileIcon';
 
 export const fetchPreviewFile = async (url: string, filename: string): Promise<AttachedFile | null> => {
   const resp = await fetch(url, { headers: { 'X-Access-Token': getToken() } });
@@ -25,7 +25,9 @@ export const fetchPreviewFile = async (url: string, filename: string): Promise<A
     const content = await resp.text();
     return { id, name: filename, size: content.length, type: mimeMap[ext], content, uploadTime };
   }
-  if (isWordFile(filename) || isExcelFile(filename)) {
+  // 二进制文件都按字节取回:word/excel 由预览面板本地渲染,.dwg 是二进制图纸前端解析不了、
+  // 要由预览面板上传后端转 SVG(components/resource/DwgPreview.vue),这里只负责把原始字节取回来。
+  if (isWordFile(filename) || isExcelFile(filename) || isDwgFile(filename)) {
     const arrayBuffer = await resp.arrayBuffer();
     return { id, name: filename, size: arrayBuffer.byteLength, type: 'application/octet-stream', arrayBuffer, uploadTime };
   }

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

@@ -9,3 +9,5 @@ export * from './messageKey';
 export * from './clipboard';
 export * from './dxf';
 export * from './userInput';
+export * from './turnRows';
+export * from './webLink';

+ 47 - 0
src/views/ventAI/manageAssistent/lib/turnRows.ts

@@ -0,0 +1,47 @@
+// lib/turnRows.ts:识别「同一轮问答被中断拆成两段」的后端记录。
+//
+// 计划模式/审批(ask_user)下,一次追问会落两行助手记录:被打断时先落一行(计划或澄清前的前半段),
+// 用户批准(或补充回答)后再落一行(执行结果)。两行合起来才是用户视角的一次回答 —— 而历史接口把它们
+// 原样返回,前端不做归并就会显示成「一次提问、两个回答」,回答切换器上多出一个其实是计划前半段的项。
+//
+// 判定全部基于结构,不依赖模型措辞(实测过同一轮的正文开头写法各异:「【计划模式 · 待审批】」、
+// 「**执行计划(提交审批)**」、「我先做一点环境探查…」,靠正文特征必然漏判):
+//
+//   1) 前一行是提问:这一对是某轮问答的头两条,而不是没有问答的连排(定时任务通知就是连排);
+//   2) 两行助手行主键相邻(后 = 前 + 1):同一轮的两段是背靠背写的;「重新生成」产生的多条回答之间
+//      必然夹着后端为同一句提问再写的那条用户记录(内容与上一句提问相同,前端不再删除它,
+//      见 useChatSend 中重新生成那一段的说明),主键不可能相邻 —— 这正是把「中断两段」和
+//      「另一次回答」区分开的地方;
+//   3) 后一行不是调度器写的一轮通知:通知是定时任务单独跑的一轮,只是恰好紧跟在问答后面落库
+//      (实测 task-7 的 654/655 就是这样,中间还夹着 32 分钟)。
+//
+// 判不出来就不并(维持两条消息的旧表现):宁可少并,不能把两次不同的回答粘成一条 ——
+// 粘错会让删除一次删掉多于一条记录。更稳的做法是后端给这类记录加标记(例如中断段落库时带上
+// type='interrupted'),前端即可改成确定性判定,无需依赖主键相邻这一条。
+import type { Message } from '/@/views/ventAI/manageAssistent/types/index';
+
+/** 调度器写入的定时任务通知(模板文案,后端生成,不随模型措辞变化) */
+export const isScheduledNoticeContent = (content: string): boolean => {
+  const text = (content || '').trimStart();
+  return text.startsWith('⏰') || text.includes('定时任务完成通知');
+};
+
+/**
+ * messages 中下标 index 与 index+1 是否是同一轮被中断拆成的两段。
+ * 入参用前端消息序列(transformHistoryToMessages 的产物,type 已归一为 'user' / 'ai')。
+ */
+export const isSplitTurnPair = (messages: Message[], index: number): boolean => {
+  const first = messages[index];
+  const second = messages[index + 1];
+  if (!first || !second) return false;
+  if (first.type !== 'ai' || second.type !== 'ai') return false;
+  if (first.id == null || second.id == null) return false;
+  // 背靠背落库 → 主键相邻(这条同时排除了「重新生成的另一个回答」,见文件头说明)
+  if (Number(second.id) !== Number(first.id) + 1) return false;
+  // 这一对必须是某轮问答的头两条
+  const prev = messages[index - 1];
+  if (!prev || prev.type !== 'user') return false;
+  // 后一段不能是定时任务通知(它自成一"轮")
+  if (isScheduledNoticeContent(second.content)) return false;
+  return true;
+};

+ 35 - 0
src/views/ventAI/manageAssistent/lib/webLink.ts

@@ -0,0 +1,35 @@
+// lib/webLink.ts:判断「给个链接能不能丢进右侧面板 iframe 打开」,以及生成标签标题。
+//
+// 当前不区分站内站外:只要是 http(s) 地址就放行(后端返回的内网系统地址多为同域不同端口,
+// 按同源判定会被排除,先用「全放开」)—— 具体见需求:先不做限制,后续再收紧。
+// 非 http(s) 协议(mailto/tel/javascript/data/blob)和页内锚点(#xxx)无法进 iframe,仍返回 null。
+//
+// 注意:外链能否真的被嵌由对方站点决定(X-Frame-Options: DENY/SAMEORIGIN 或 CSP frame-ancestors 会被
+// 浏览器直接拒绝,面板里只会一片空白,且跨域读不到内容、前端无法可靠识别),所以面板里始终保留
+// 「新窗口打开」兜底。以后要收紧成白名单/同源,只改这里的判定即可。
+
+/** 取出可放进面板的绝对地址;http(s) 以外的协议、页内锚点、空链接返回 null */
+export const panelOpenableUrl = (href?: string | null): string | null => {
+  const raw = (href || '').trim();
+  if (!raw || raw.startsWith('#')) return null;
+  try {
+    const url = new URL(raw, window.location.href);
+    if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
+    return url.href;
+  } catch {
+    // 非法 URL
+    return null;
+  }
+};
+
+/** 面板标签标题:优先用链接文字,没有就用路径(去掉域名,整条 URL 会把标签栏挤爆) */
+export const webTabTitle = (href: string, text?: string): string => {
+  const label = (text || '').replace(/\s+/g, ' ').trim();
+  if (label) return label;
+  try {
+    const url = new URL(href, window.location.href);
+    return url.pathname + url.search || url.host;
+  } catch {
+    return href;
+  }
+};

+ 13 - 0
src/views/ventAI/manageAssistent/types/chat.ts

@@ -71,6 +71,19 @@ export interface Message {
   durationMs?: number;
   baseDurationMs?: number;
   generateStartTime?: number;
+  // 仅前端存在的消息:服务端没有对应记录(停止生成留下的「已停止生成。」占位、发送失败的错误气泡)。
+  // 回填主键时按形状配对必须跳过它,否则多出的一条会把位置顶偏,整批消息配不上或配错行。
+  localOnly?: boolean;
+  // 被中断拆开的那一轮(或重新生成时的重复提问记录)里,除本条之外还落在后端的记录主键。
+  // 一轮问答在后端可能是多条记录(中断的前段 + 回答段;重新生成时后端为同一句提问再写的用户记录),
+  // 前端合成一条消息(见 transformHistoryToMessages)。删除这条消息时要连这些行一起删,
+  // 只删回答行的话,刷新后前段/重复提问会以一条孤立消息的形式重新出现。
+  absorbedRowIds?: number[];
+  // 「同一次提问的又一个回答」:来源有两个 —— 前端重新生成追加回答时打标(本次会话内),
+  // 或历史加载时识别到「内容与上一句提问相同的重复提问记录」(刷新后依然成立)。
+  // 回答分组(切换器)只认这个标记,不再按「连续 AI 消息」推断 —— 删掉中间的提问后,
+  // 上下两条互不相干的回答会变成连续消息,按相邻推断会把它们错误地归成一组、用切换器藏起一条。
+  isRegeneratedAnswer?: boolean;
 }
 
 export interface Task {

+ 3 - 1
src/views/ventAI/manageAssistent/types/panel.ts

@@ -12,7 +12,9 @@ export interface AgentDetailData {
 export type RightPanelTab =
   | { id: string; type: 'file'; title: string; file: AttachedFile; editable: boolean }
   | { id: string; type: 'agent'; title: string; messageIndex: number; agent: string; agentId?: string }
-  | { id: string; type: 'schedule'; title: string; task: ScheduleTask };
+  | { id: string; type: 'schedule'; title: string; task: ScheduleTask }
+  // 站内页面:url 一定是同源绝对地址(判定见 lib/webLink.ts),内容区用 iframe 嵌
+  | { id: string; type: 'web'; title: string; url: string };
 
 export interface RightPanelState {
   tabs: RightPanelTab[];

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio