Ver Fonte

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

lxh há 3 dias atrás
pai
commit
0ed32fcf2f
42 ficheiros alterados com 1753 adições e 533 exclusões
  1. BIN
      src/assets/images/fire.png
  2. 7 0
      src/assets/images/warning.svg
  3. 177 0
      src/views/vent/bundleSpy/bundleSpyTable/bundleSpy-table.data.ts
  4. 93 76
      src/views/vent/bundleSpy/bundleSpyTable/index.vue
  5. 361 0
      src/views/vent/testChart/FiberTempChart.vue
  6. 46 0
      src/views/vent/testChart/index.vue
  7. 16 36
      src/views/ventAI/dataPicker/api.ts
  8. 68 114
      src/views/ventAI/manageAssistent/api/chat.ts
  9. 2 0
      src/views/ventAI/manageAssistent/api/index.ts
  10. 10 2
      src/views/ventAI/manageAssistent/api/model.ts
  11. 6 6
      src/views/ventAI/manageAssistent/api/report.ts
  12. 58 12
      src/views/ventAI/manageAssistent/api/schedule.ts
  13. 89 11
      src/views/ventAI/manageAssistent/api/session.ts
  14. 20 5
      src/views/ventAI/manageAssistent/api/skill.ts
  15. 70 0
      src/views/ventAI/manageAssistent/api/streamPost.ts
  16. 20 9
      src/views/ventAI/manageAssistent/api/subagent.ts
  17. 1 1
      src/views/ventAI/manageAssistent/api/usage.ts
  18. 84 17
      src/views/ventAI/manageAssistent/components/AiAssistantModal.vue
  19. 95 17
      src/views/ventAI/manageAssistent/components/chat/ChatInputArea.vue
  20. 23 9
      src/views/ventAI/manageAssistent/components/chat/ChatMessages.vue
  21. 2 8
      src/views/ventAI/manageAssistent/components/chat/Input/SessionPopup.vue
  22. 2 7
      src/views/ventAI/manageAssistent/components/chat/Input/SkillPopup.vue
  23. 24 3
      src/views/ventAI/manageAssistent/components/chat/Input/ThinkLevelPopup.vue
  24. 47 0
      src/views/ventAI/manageAssistent/components/common/EmptyState.vue
  25. 2 8
      src/views/ventAI/manageAssistent/components/modals/CreateSubAgentModal.vue
  26. 2 7
      src/views/ventAI/manageAssistent/components/panels/FileListPanel.vue
  27. 2 7
      src/views/ventAI/manageAssistent/components/panels/RightPanelTabs.vue
  28. 7 11
      src/views/ventAI/manageAssistent/components/panels/ScheduleDetailPanel.vue
  29. 11 20
      src/views/ventAI/manageAssistent/components/panels/SchedulePanel.vue
  30. 16 3
      src/views/ventAI/manageAssistent/components/panels/SkillPanel.vue
  31. 22 6
      src/views/ventAI/manageAssistent/components/panels/SubAgentPanel.vue
  32. 15 26
      src/views/ventAI/manageAssistent/components/panels/TaskListPanel.vue
  33. 24 17
      src/views/ventAI/manageAssistent/hooks/useChatFiles.ts
  34. 9 14
      src/views/ventAI/manageAssistent/hooks/useChatSend.ts
  35. 76 66
      src/views/ventAI/manageAssistent/hooks/useChatTasks.ts
  36. 89 0
      src/views/ventAI/manageAssistent/hooks/useFileDrop.ts
  37. 108 0
      src/views/ventAI/manageAssistent/hooks/useInputHistory.ts
  38. 4 14
      src/views/ventAI/manageAssistent/hooks/useMentionPopups.ts
  39. 16 1
      src/views/ventAI/manageAssistent/lib/clipboard.ts
  40. 1 0
      src/views/ventAI/manageAssistent/lib/index.ts
  41. 16 0
      src/views/ventAI/manageAssistent/lib/userInput.ts
  42. 12 0
      src/views/ventAI/manageAssistent/types/domain.ts

BIN
src/assets/images/fire.png


+ 7 - 0
src/assets/images/warning.svg

@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="116.151" height="98.266" viewBox="0 0 116.151 98.266">
+  <g id="组_16762" data-name="组 16762" transform="translate(-1300.513 -641.814)">
+    <path id="路径_58007" data-name="路径 58007" d="M87.69,80.753H5.16a5.132,5.132,0,0,1-4.475-2.54,4.977,4.977,0,0,1,.05-5.088C3.262,68.62,39.74,6.393,42.02,2.588a5.151,5.151,0,0,1,8.932,0c3.114,5.29,39.387,67.271,41.19,70.379a5.005,5.005,0,0,1,.139,5.206,5.161,5.161,0,0,1-4.592,2.58Z" transform="translate(1312.108 652.496)" fill="none" stroke="#fe3430" stroke-width="1" opacity="0.3"/>
+    <path id="路径_58006" data-name="路径 58006" d="M76.989,71.967H4.532a4.493,4.493,0,0,1-3.885-6.8C2.865,61.154,34.891,5.7,36.894,2.307a4.489,4.489,0,0,1,7.842,0C47.469,7.021,79.316,62.259,80.9,65.028a4.518,4.518,0,0,1-3.909,6.939ZM40.826,21.607a4.8,4.8,0,0,0-4.8,4.8l2.4,21.583h4.8l2.4-21.583a4.8,4.8,0,0,0-4.8-4.8Zm0,31.175a4.8,4.8,0,1,0,4.8,4.8A4.8,4.8,0,0,0,40.826,52.782Z" transform="translate(1317.989 657.996)" fill="#fe332f"/>
+    <path id="路径_58008" data-name="路径 58008" d="M107.549,96.268H6.326A6.333,6.333,0,0,1,.838,93.241,5.791,5.791,0,0,1,.9,87.175C4,81.8,48.738,7.62,51.536,3.084a6.41,6.41,0,0,1,10.955,0c3.819,6.307,48.308,80.2,50.519,83.9a5.825,5.825,0,0,1,.171,6.206,6.37,6.37,0,0,1-5.632,3.076Z" transform="translate(1301.583 642.811)" fill="none" stroke="#fe3430" stroke-width="2" opacity="0.7"/>
+  </g>
+</svg>

+ 177 - 0
src/views/vent/bundleSpy/bundleSpyTable/bundleSpy-table.data.ts

@@ -1299,3 +1299,180 @@ export const Ltcolumns: BasicColumn[] = [
     align: 'center',
   },
 ];
+
+export const Betcolumns: BasicColumn[] = [
+  {
+    title: '序号',
+    width: 60,
+    align: 'center',
+    dataIndex: 'xh',
+    key: 'xh',
+  },
+  {
+    title: '采样地点',
+    dataIndex: 'jcdd',
+    key: 'jcdd',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'CO(%)',
+    dataIndex: 'co_ave',
+    key: 'co_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'CO₂(%)',
+    dataIndex: 'co2_ave',
+    key: 'co2_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'CH₄(%)',
+    dataIndex: 'ch4_ave',
+    key: 'ch4_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'O₂(%)',
+    dataIndex: 'o2_ave',
+    key: 'o2_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'C₂H₆(%)',
+    dataIndex: 'c2h6_ave',
+    key: 'c2h6_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'C₂H₂(%)',
+    dataIndex: 'c2h2_ave',
+    key: 'c2h2_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: '取样分析时间',
+    dataIndex: 'qyfxsj',
+    key: 'qyfxsj',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: '煤自燃阶段',
+    dataIndex: 'internalFireWarnLevel',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: '操作',
+    dataIndex: 'action',
+    width: 100,
+    align: 'center',
+  },
+];
+
+export const Betcolumns2: BasicColumn[] = [
+  {
+    title: '序号',
+    width: 60,
+    align: 'center',
+    dataIndex: 'xh',
+    key: 'xh',
+  },
+  {
+    title: '采样地点',
+    dataIndex: 'jcdd',
+    key: 'jcdd',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: '采空区',
+    dataIndex: 'ckq',
+    key: 'ckq',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'CO(%)',
+    dataIndex: 'co_ave',
+    key: 'co_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'CO₂(%)',
+    dataIndex: 'co2_ave',
+    key: 'co2_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'CH₄(%)',
+    dataIndex: 'ch4_ave',
+    key: 'ch4_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'O₂(%)',
+    dataIndex: 'o2_ave',
+    key: 'o2_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'C₂H₆(%)',
+    dataIndex: 'c2h6_ave',
+    key: 'c2h6_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'C₂H₄(%)',
+    dataIndex: 'c2h4_ave',
+    key: 'c2h4_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'C₂H₂(%)',
+    dataIndex: 'c2h2_ave',
+    key: 'c2h2_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: '取样时间',
+    dataIndex: 'qysj',
+    key: 'qysj',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: '分析时间',
+    dataIndex: 'qyfxsj',
+    key: 'qyfxsj',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: '煤自燃阶段',
+    dataIndex: 'internalFireWarnLevel',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: '操作',
+    dataIndex: 'action',
+    width: 100,
+    align: 'center',
+  },
+];

Diff do ficheiro suprimidas por serem muito extensas
+ 93 - 76
src/views/vent/bundleSpy/bundleSpyTable/index.vue


+ 361 - 0
src/views/vent/testChart/FiberTempChart.vue

@@ -0,0 +1,361 @@
+<template>
+  <div class="fiber-temp-chart-wrap">
+    <div class="chart-header">
+      <div class="chart-title">{{ title }}</div>
+      <div class="chart-header-desc">
+        <div class="desc-item">
+          <span class="color-block normal"></span>
+          <span class="text">正常</span>
+        </div>
+        <div class="desc-item">
+          <span class="color-block alarm"></span>
+          <span class="text">报警</span>
+        </div>
+      </div>
+    </div>
+    <div class="chart-container" ref="chartDomRef"></div>
+  </div>
+</template>
+
+<script setup>
+import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
+import * as echarts from 'echarts';
+import firePng from '@/assets/images/fire.png';
+import warningSVG from '@/assets/images/warning.svg';
+
+const props = defineProps({
+  xAxisData: {
+    type: Array,
+    default: () => [],
+  },
+  seriesData: {
+    type: Array,
+    default: () => [],
+  },
+  maxValue: {
+    type: Number,
+    default: 60,
+  },
+  minValue: {
+    type: Number,
+    default: 20,
+  },
+  height: {
+    type: String,
+    default: '440px',
+  },
+  title: {
+    type: String,
+    default: '光纤测温',
+  },
+});
+
+const emit = defineEmits(['chart-click']);
+
+const chartDomRef = ref(null);
+let chartInstance = null;
+
+function disposeChart() {
+  if (chartInstance) {
+    chartInstance.dispose();
+    chartInstance = null;
+  }
+}
+
+function initChart() {
+  if (!chartDomRef.value) return;
+  disposeChart();
+  chartInstance = echarts.init(chartDomRef.value);
+  renderOption();
+  chartInstance.on('click', (params) => {
+    emit('chart-click', params);
+  });
+  window.addEventListener('resize', handleResize);
+}
+
+function handleResize() {
+  chartInstance?.resize();
+}
+
+function getMarkPointData() {
+  const markPoints = [];
+  const { seriesData, minValue, maxValue } = props;
+  seriesData.forEach((val, idx) => {
+    if (val < minValue || val >= maxValue) {
+      markPoints.push({
+        coord: [idx, val + 15],
+        symbol: `image://${val < minValue ? warningSVG : firePng}`,
+        symbolSize: [60, 60],
+        tooltip: {
+          formatter: `<div style="color:#ff4422;font-weight:bold">异常确认<br/>立即排查</div>`,
+        },
+      });
+    }
+  });
+  return markPoints;
+}
+
+// 获取报警区域的连续数据(包含边界点)
+function getAlarmContinuousData(data, minValue, maxValue) {
+  const result = [];
+  const n = data.length;
+
+  const isAlarm = (val) => val < minValue || val >= maxValue;
+
+  for (let i = 0; i < n; i++) {
+    const val = data[i];
+    if (isAlarm(val)) {
+      // 当前点是报警点,保留原值
+      result.push(val);
+    } else {
+      // 检查相邻点是否有报警点,如果有则保留当前值作为边界
+      const prevAlarm = i > 0 && isAlarm(data[i - 1]);
+      const nextAlarm = i < n - 1 && isAlarm(data[i + 1]);
+
+      if (prevAlarm || nextAlarm) {
+        // 使用实际边界值,与正常折线在切换点保持连续
+        result.push(val);
+      } else {
+        result.push(null);
+      }
+    }
+  }
+  return result;
+}
+
+function getAlarmPointData(data, minValue, maxValue) {
+  return data.map((val) => (val < minValue || val >= maxValue ? val : null));
+}
+
+function renderOption() {
+  if (!chartInstance) return;
+  const { xAxisData, seriesData, minValue, maxValue } = props;
+
+  // 报警填充数据:包含实际边界点,使报警区域连续
+  const alarmFillData = getAlarmContinuousData(seriesData, minValue, maxValue);
+
+  // 报警折线数据:只保留超过上限或低于下限的点
+  const alarmLineData = getAlarmPointData(seriesData, minValue, maxValue);
+
+  // 正常填充数据:仅保留上下限之间的值,报警值设为 null
+  const normalFillData = seriesData.map((val) => (val >= minValue && val < maxValue ? val : null));
+
+  // 正常折线数据:只保留上下限之间的点
+  const normalLineData = seriesData.map((val) => (val >= minValue && val < maxValue ? val : null));
+
+  const option = {
+    backgroundColor: 'transparent',
+    title: { show: false },
+    tooltip: {
+      trigger: 'axis',
+      backgroundColor: 'rgba(0,20,40,0.85)',
+      borderColor: '#2ef3ef',
+      textStyle: { color: '#fff' },
+      formatter: function (params) {
+        const dataIndex = params[0]?.dataIndex;
+        if (dataIndex !== undefined && seriesData[dataIndex] !== undefined) {
+          const val = seriesData[dataIndex];
+          const isAlarm = val < minValue || val >= maxValue;
+          return `<div style="font-weight:bold;${isAlarm ? 'color:#ff8800' : 'color:#35d9e5'}">
+            ${xAxisData[dataIndex] || ''}<br/>
+            温度:${val}℃${isAlarm ? ' 超限' : ''}
+          </div>`;
+        }
+        return '';
+      },
+    },
+    legend: { show: false },
+    grid: {
+      left: '4%',
+      right: '3%',
+      top: '18%',
+      bottom: '18%',
+    },
+    xAxis: {
+      type: 'category',
+      data: xAxisData,
+      axisLine: { lineStyle: { color: '#2ef3ef' } },
+      axisLabel: { color: '#ffffff', fontSize: 14 },
+      splitLine: { show: true, lineStyle: { color: 'rgba(46,243,239,0.15)' } },
+    },
+    yAxis: {
+      type: 'value',
+      name: '温度℃',
+      nameTextStyle: { color: '#fff' },
+      min: 0,
+      max: 130,
+      axisLine: { lineStyle: { color: '#2ef3ef' } },
+      axisLabel: { color: '#ffffff' },
+      splitLine: { lineStyle: { color: 'rgba(46,243,239,0.15)' } },
+    },
+    dataZoom: [
+      {
+        type: 'slider',
+        show: true,
+        height: 20,
+        bottom: 8,
+        backgroundColor: '#152c47',
+        fillerColor: '#2ef3ef66',
+        handleStyle: { color: '#2ef3ef' },
+        textStyle: { color: '#ffffff' },
+        start: 0,
+        end: 30,
+        zoomLock: false,
+      },
+      { type: 'inside' },
+    ],
+    series: [
+      // 正常填充(青色)
+      {
+        name: '正常填充',
+        type: 'line',
+        smooth: true,
+        data: normalFillData,
+        symbol: 'none',
+        connectNulls: false,
+        lineStyle: { width: 0 },
+        tooltip: { show: false },
+        areaStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: 'rgba(53,217,229,0.35)' },
+            { offset: 1, color: 'rgba(53,217,229,0.05)' },
+          ]),
+        },
+        z: 1,
+      },
+      // 报警填充(橙色)- 包含边界点,形成连续填充
+      {
+        name: '报警填充',
+        type: 'line',
+        smooth: true,
+        data: alarmFillData,
+        symbol: 'none',
+        connectNulls: false,
+        lineStyle: { width: 0 },
+        tooltip: { show: false },
+        areaStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: 'rgba(255,136,0,0.5)' },
+            { offset: 1, color: 'rgba(255,136,0,0.05)' },
+          ]),
+        },
+        z: 2,
+      },
+      // 正常折线(青色)
+      {
+        name: '正常测温',
+        type: 'line',
+        smooth: true,
+        data: normalLineData,
+        symbol: 'circle',
+        symbolSize: 8,
+        connectNulls: false,
+        lineStyle: { width: 3, color: '#35d9e5' },
+        itemStyle: { color: '#35d9e5' },
+        z: 3,
+      },
+      // 报警折线(橙色)- 包含边界点,形成连续折线
+      {
+        name: '报警测温',
+        type: 'line',
+        smooth: true,
+        data: alarmLineData,
+        symbol: 'circle',
+        symbolSize: 10,
+        connectNulls: false,
+        lineStyle: { width: 3, color: '#ff8800' },
+        itemStyle: {
+          color: function (params) {
+            const val = params.data;
+            // 边界点(等于阈值)不显示圆点
+            if (val === minValue || val === maxValue) return 'transparent';
+            return '#ff8800';
+          },
+        },
+        markPoint: {
+          data: getMarkPointData(),
+          offset: [0, -100],
+        },
+        markLine: {
+          silent: true,
+          lineStyle: { type: 'dashed', width: 2, color: '#ff2222' },
+          label: { show: true, position: 'end', fontSize: 12, color: '#ff2222' },
+          data: [
+            { yAxis: minValue, label: { formatter: '下限阈值' } },
+            { yAxis: maxValue, label: { formatter: '上限阈值' } },
+          ],
+        },
+        z: 4,
+      },
+    ],
+  };
+
+  chartInstance.setOption(option, true);
+}
+
+watch(
+  () => [props.xAxisData, props.seriesData, props.minValue, props.maxValue],
+  () => {
+    nextTick(() => renderOption());
+  },
+  { deep: true }
+);
+
+onMounted(() => {
+  nextTick(() => initChart());
+});
+
+onBeforeUnmount(() => {
+  disposeChart();
+  window.removeEventListener('resize', handleResize);
+});
+</script>
+
+<style scoped>
+.fiber-temp-chart-wrap {
+  width: 100%;
+  height: v-bind(height);
+  display: flex;
+  flex-direction: column;
+}
+.chart-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 10px 16px;
+}
+.chart-title {
+  color: #ffffff;
+  font-size: 24px;
+  font-weight: bold;
+}
+.chart-header-desc {
+  display: flex;
+  gap: 32px;
+  align-items: center;
+}
+.desc-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.color-block {
+  width: 32px;
+  height: 12px;
+  border-radius: 2px;
+}
+.color-block.normal {
+  background-color: #35d9e5;
+}
+.color-block.alarm {
+  background-color: #ff8800;
+}
+.text {
+  color: #ffffff;
+  font-size: 16px;
+}
+.chart-container {
+  flex: 1;
+  width: 100%;
+}
+</style>

+ 46 - 0
src/views/vent/testChart/index.vue

@@ -0,0 +1,46 @@
+<template>
+  <a-card style="background: #0b142b; border: 1px solid #2ef3ef">
+    <FiberTempChart
+      :x-axis-data="xAxisList"
+      :series-data="tempValueList"
+      :max-value="60"
+      :min-value="20"
+      title="光纤测温"
+      height="440px"
+      @chart-click="onChartClick"
+    />
+  </a-card>
+</template>
+<script setup>
+import FiberTempChart from './FiberTempChart.vue';
+const pointData = [
+  { name: '测点01', temp: 24.2 },
+  { name: '测点02', temp: 28.5 },
+  { name: '测点03', temp: 32.1 },
+  { name: '测点04', temp: 30.6 },
+  { name: '测点05', temp: 26.8 },
+  { name: '测点06', temp: 33.4 },
+  { name: '测点07', temp: 26.9 },
+  { name: '测点08', temp: 11.2 },
+  { name: '测点09', temp: 40.5 },
+  { name: '测点10', temp: 45.3 },
+  { name: '测点11', temp: 38.7 },
+  { name: '测点12', temp: 22.6 },
+  { name: '测点13', temp: 51.8 },
+  { name: '测点14', temp: 30.2 },
+  { name: '测点15', temp: 80 }, // 超上限
+  { name: '测点16', temp: 58.1 },
+  { name: '测点17', temp: 48.6 },
+  { name: '测点18', temp: 39.4 },
+  { name: '测点19', temp: 34.7 },
+  { name: '测点20', temp: 19.3 },
+];
+
+// 提取x轴、温度数组
+const xAxisList = pointData.map((item) => item.name);
+const tempValueList = pointData.map((item) => item.temp);
+
+function onChartClick(params) {
+  console.log('点击测点', params);
+}
+</script>

+ 16 - 36
src/views/ventAI/dataPicker/api.ts

@@ -1,9 +1,6 @@
-import { consumeSse } from '/@/views/ventAI/manageAssistent/api/sse';
-// [审计-第四轮 M2] 重放自我方 fix/base-agent-token(M1/M2/M3):复用对话框模块既有的结构化错误解析器,避免两套实现各自演化
-import { throwStreamHttpError } from '/@/views/ventAI/manageAssistent/api/errors';
+// dataPicker/api.ts:点选解读(巷道 / 设备)的 SSE 流式接口。
+import { streamPost } from '/@/views/ventAI/manageAssistent/api/streamPost';
 import type { SSERawEvent } from './types';
-// [审计-第四轮 M1] 重放自我方 fix/base-agent-token(M1/M2/M3):取基座登录态的令牌(与对话链路 manageAssistent/api/chat.ts 同源)
-import { getToken } from '/@/utils/auth';
 
 enum Api {
   clickTun = '/ventAI/api/interpret/click/tun',
@@ -36,41 +33,24 @@ export async function fetchDeviceInterpretation(
   return ssePost(Api.clickDevice, params, onEvent);
 }
 
-/** SSE 流式 POST 请求的通用实现 */
+/**
+ * SSE 流式 POST:请求体、令牌、错误解码、事件消费与中断处理统一交给 api/streamPost。
+ *
+ * [审计-第四轮 M1/M2] 重放自我方 fix/base-agent-token(M1/M2/M3)。本链路原先自造过 fetch + 错误处理,
+ * 收敛后语义由 streamPost 承接,在此留档,避免后续被当成「多余代码」误改:
+ * 1. 令牌是「可选」语义:getToken() 为空时仍照常发请求(TfAgents 侧有 click-anonymous 兜底)。
+ *    禁止加「未登录就拦截/跳登录」的判断(我方 D5 决策)——那会把未登录场景直接变成不可用。
+ *    补令牌的原因:原先是基座里唯一一条匿名调 TfAgents 的路径,消耗只能记到兜底账号,无法按登录人记账。
+ * 2. 非 2xx 复用 throwStreamHttpError 解析响应体 detail,抛出带 status/retryAfter/limitTokens/spentTokens
+ *    的 StreamError(文案由 useDataPicker 的 formatClickError() 渲染);原实现只抛「HTTP错误: 429」,
+ *    把后端给出的原因(额度耗尽等)整个丢掉。
+ * 3. 该接口只把 done / error 当终止事件(与统一对话不同,不含 interrupt)。
+ */
 async function ssePost(
   url: string,
   body: Record<string, string>,
   onEvent: (event: SSERawEvent) => void
 ): Promise<{ thread_id: string; session_id: string }> {
-  const response = await fetch(url, {
-    method: 'POST',
-    headers: {
-      'Content-Type': 'application/json',
-      // [审计-第四轮 M1] 重放自我方 fix/base-agent-token(M1/M2/M3)
-      // 原行为:点选解读不带令牌,是基座里唯一一条「匿名」调 TfAgents 的路径,
-      //         消耗只能记到兜底账号 click-anonymous,无法按登录人记账。
-      // 现行为:补上与对话链路同名的请求头(TfAgents 侧读小写 x-access-token,HTTP 头不区分大小写)。
-      // 为什么:恢复按登录人记账归因。
-      // ⚠️ 令牌是「可选」语义:getToken() 为空时仍照常发请求(TfAgents 侧有 click-anonymous 兜底),
-      //    禁止加「未登录就拦截/跳登录」的判断(我方 D5 决策)——那会把未登录场景直接变成不可用。
-      'X-Access-Token': getToken(),
-    },
-    body: JSON.stringify(body),
-  });
-
-  if (!response.ok) {
-    // [审计-第四轮 M2] 重放自我方 fix/base-agent-token(M1/M2/M3)
-    // 原行为:throw new Error(`HTTP错误: ${response.status}`) —— 用户只看到「HTTP错误: 429」。
-    // 现行为:复用 manageAssistent/api/errors.ts 的 throwStreamHttpError,它会解析响应体的
-    //         detail(字符串或 { code, message, retry_after, limit_tokens, spent_tokens }),
-    //         抛出携带 status/retryAfter/limitTokens/spentTokens 的 StreamError;
-    //         最终文案由 useDataPicker.ts 的 formatClickError() 统一渲染。
-    // 为什么:429/413/404 等错误需要可读文案,且与对话链路共用一套解析器不重复造轮子。
-    await throwStreamHttpError(response);
-  }
-
-  // 该接口只把 done / error 当终止事件(与统一对话不同,不含 interrupt)
-  const { sessionId, threadId } = await consumeSse<SSERawEvent>(response, onEvent, { terminateOn: ['done', 'error'] });
-
+  const { sessionId, threadId } = await streamPost<SSERawEvent>(url, { body, label: '点选解读流式请求', terminateOn: ['done', 'error'] }, onEvent);
   return { thread_id: threadId, session_id: sessionId };
 }

+ 68 - 114
src/views/ventAI/manageAssistent/api/chat.ts

@@ -1,8 +1,8 @@
 // api/chat.ts:对话相关的三个 SSE 流式接口(统一对话 / PDF 审查 / 审批恢复)。
-// 三个入口共用 consumeSse 与 throwStreamHttpError,只是请求体构造方式不同。
-import { getToken } from '/@/utils/auth/index';
-import { consumeSse } from './sse';
-import { throwStreamHttpError } from './errors';
+//
+// 三个入口的差别只有「请求体怎么构造」和「日志前缀」,其余(带令牌、非 2xx 解码、消费事件流、
+// 中断处理、返回值归一)完全一致,统一走 streamPost,避免三份各自演化。
+import { streamPost } from './streamPost';
 import type { SseEvent } from '/@/views/ventAI/manageAssistent/types/index';
 
 enum Api {
@@ -10,53 +10,47 @@ enum Api {
   resume = '/ventAI/api/chat/resume',
 }
 
+/** 一次流式对话的结束信息 */
+export interface ChatStreamResult {
+  /** 终止事件携带的会话 id,未携带时为空串 */
+  session_id: string;
+  /** 解析失败被丢弃的 data 行数;大于 0 表示正文可能不完整 */
+  parse_errors: number;
+}
+
 /**
  * 统一对话接口(SSE流式响应)
  * @param params.message - 用户输入的文本,必填
  * @param params.session_id - 会话唯一标识ID,不传参时服务端自动生成全新会话ID
  * @param params.file - 上传的PDF文件,可选
+ * @param params.mode - 对话权限模式,可选
  * @param onChunk - 流式数据回调函数
- * @returns Promise<{ session_id: string; parse_errors: number }>
- *          parse_errors 大于 0 表示这条流里有事件被丢弃(正文可能不完整)
+ * @returns 会话 id 与丢弃事件计数(parse_errors 大于 0 表示正文可能不完整)
  */
 export const unifiedStream = async (
   params: { message: string; session_id?: string; file?: File; mode?: string; signal?: AbortSignal },
   onChunk: (data: SseEvent) => void
-): Promise<{ session_id: string; parse_errors: number }> => {
-  try {
-    const formData = new FormData();
-    formData.append('message', params.message);
-    if (params.session_id) {
-      formData.append('session_id', params.session_id);
-    }
-    if (params.file) {
-      formData.append('file', params.file);
-    }
-    if (params.mode) {
-      formData.append('mode', params.mode);
-    }
-
-    const response = await fetch(Api.unified, {
-      method: 'POST',
-      body: formData,
-      headers: { 'X-Access-Token': getToken() },
-      signal: params.signal,
-    });
-
-    if (!response.ok) {
-      await throwStreamHttpError(response);
-    }
-
-    const { sessionId, parseErrors } = await consumeSse<SseEvent>(response, onChunk);
-
-    // parseErrors > 0 表示这条流里有事件被丢弃,正文可能不完整
-    return { session_id: sessionId, parse_errors: parseErrors };
-  } catch (error) {
-    if ((error as Error)?.name !== 'AbortError') {
-      console.error('统一对话流式请求失败:', error);
-    }
-    throw error;
+): Promise<ChatStreamResult> => {
+  const formData = new FormData();
+  // message 一律提交(允许空串):纯附件发送时 message 为空是既有契约,
+  // 不能因为「空值就跳过 append」把请求体变成另一种形状
+  formData.append('message', params.message);
+  if (params.session_id) {
+    formData.append('session_id', params.session_id);
   }
+  if (params.file) {
+    formData.append('file', params.file);
+  }
+  if (params.mode) {
+    formData.append('mode', params.mode);
+  }
+
+  const { sessionId, parseErrors } = await streamPost<SseEvent>(
+    Api.unified,
+    { body: formData, label: '统一对话流式请求', signal: params.signal },
+    onChunk
+  );
+  return { session_id: sessionId, parse_errors: parseErrors };
 };
 
 /**
@@ -64,48 +58,32 @@ export const unifiedStream = async (
  * @param params.file - 配风计划PDF文件,必填
  * @param params.session_id - 会话唯一标识ID,可选
  * @param params.message - 附加消息/审查指令,可选
+ * @param params.mode - 对话权限模式,可选
  * @param onChunk - 流式数据回调函数
- * @returns Promise<{ session_id: string; parse_errors: number }>
- *          parse_errors 大于 0 表示这条流里有事件被丢弃(正文可能不完整)
+ * @returns 会话 id 与丢弃事件计数(parse_errors 大于 0 表示正文可能不完整)
  */
 export const reviewPdfStream = async (
   params: { file: File; session_id?: string; message?: string; mode?: string; signal?: AbortSignal },
   onChunk: (data: SseEvent) => void
-): Promise<{ session_id: string; parse_errors: number }> => {
-  try {
-    const formData = new FormData();
-    formData.append('file', params.file);
-    if (params.session_id) {
-      formData.append('session_id', params.session_id);
-    }
-    if (params.message) {
-      formData.append('message', params.message);
-    }
-    if (params.mode) {
-      formData.append('mode', params.mode);
-    }
-
-    const response = await fetch(Api.unified, {
-      method: 'POST',
-      body: formData,
-      headers: { 'X-Access-Token': getToken() },
-      signal: params.signal,
-    });
-
-    if (!response.ok) {
-      await throwStreamHttpError(response);
-    }
-
-    const { sessionId, parseErrors } = await consumeSse<SseEvent>(response, onChunk);
-
-    // parseErrors > 0 表示这条流里有事件被丢弃,正文可能不完整
-    return { session_id: sessionId, parse_errors: parseErrors };
-  } catch (error) {
-    if ((error as Error)?.name !== 'AbortError') {
-      console.error('PDF审查流式请求失败:', error);
-    }
-    throw error;
+): Promise<ChatStreamResult> => {
+  const formData = new FormData();
+  formData.append('file', params.file);
+  if (params.session_id) {
+    formData.append('session_id', params.session_id);
   }
+  if (params.message) {
+    formData.append('message', params.message);
+  }
+  if (params.mode) {
+    formData.append('mode', params.mode);
+  }
+
+  const { sessionId, parseErrors } = await streamPost<SseEvent>(
+    Api.unified,
+    { body: formData, label: 'PDF审查流式请求', signal: params.signal },
+    onChunk
+  );
+  return { session_id: sessionId, parse_errors: parseErrors };
 };
 
 /**
@@ -115,47 +93,23 @@ export const reviewPdfStream = async (
  * @param params.status - ask_user响应状态:"answered"(默认,消费answers)/ "cancelled"(取消)/ "error"(出错)
  * @param params.answers - ask_user答案列表,与中断事件ask_user.questions一一对应(status=answered时必填)
  * @param onChunk - 流式数据回调函数
- * @returns Promise<{ session_id: string; parse_errors: number }>
- *          parse_errors 大于 0 表示这条流里有事件被丢弃(正文可能不完整)
+ * @returns 会话 id 与丢弃事件计数(parse_errors 大于 0 表示正文可能不完整)
  */
 export const chatResumeStream = async (
   params: { session_id: string; thread_id?: string; status?: string; answers?: string[]; signal?: AbortSignal },
   onChunk: (data: SseEvent) => void
-): Promise<{ session_id: string; parse_errors: number }> => {
-  try {
-    const body: Record<string, any> = {
-      session_id: params.session_id,
-      status: params.status || 'answered',
-    };
-    if (params.thread_id) {
-      body.thread_id = params.thread_id;
-    }
-    if (params.answers) {
-      body.answers = params.answers;
-    }
-
-    const response = await fetch(Api.resume, {
-      method: 'POST',
-      body: JSON.stringify(body),
-      headers: {
-        'Content-Type': 'application/json',
-        'X-Access-Token': getToken(),
-      },
-      signal: params.signal,
-    });
-
-    if (!response.ok) {
-      await throwStreamHttpError(response);
-    }
-
-    const { sessionId, parseErrors } = await consumeSse<SseEvent>(response, onChunk);
-
-    // parseErrors > 0 表示这条流里有事件被丢弃,正文可能不完整
-    return { session_id: sessionId, parse_errors: parseErrors };
-  } catch (error) {
-    if ((error as Error)?.name !== 'AbortError') {
-      console.error('恢复会话流式请求失败:', error);
-    }
-    throw error;
+): Promise<ChatStreamResult> => {
+  const body: Record<string, unknown> = {
+    session_id: params.session_id,
+    status: params.status || 'answered',
+  };
+  if (params.thread_id) {
+    body.thread_id = params.thread_id;
+  }
+  if (params.answers) {
+    body.answers = params.answers;
   }
+
+  const { sessionId, parseErrors } = await streamPost<SseEvent>(Api.resume, { body, label: '恢复会话流式请求', signal: params.signal }, onChunk);
+  return { session_id: sessionId, parse_errors: parseErrors };
 };

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

@@ -1,6 +1,8 @@
 // api barrel:对外保持单一入口,调用方按 `.../api` 引入即可,不必关心内部按域拆分。
 // 各域相互独立:新增接口请放进对应域文件,不要再堆成一个大文件。
 export * from './errors';
+export * from './sse';
+export * from './streamPost';
 export * from './chat';
 export * from './session';
 export * from './user';

+ 10 - 2
src/views/ventAI/manageAssistent/api/model.ts

@@ -6,13 +6,21 @@ enum Api {
   thinking = '/ventAI/api/model/thinking',
 }
 
+/** 模型信息(GET /model 实测形状):当前模型、思考级别取值与可选级别 */
+export interface ModelInfoResponse {
+  current: string;
+  thinking_level: string;
+  thinking_levels: string[];
+}
+
 /**
  * 查询模型信息
  */
-export const getModelMsg = () => defHttp.get({ url: Api.model }, { isTransformResponse: false });
+export const getModelMsg = (): Promise<ModelInfoResponse> => defHttp.get<ModelInfoResponse>({ url: Api.model }, { isTransformResponse: false });
 
 /**
  * 切换模型思考深度
  * @param level - 思考级别 off/high/highest
  */
-export const switchThinking = (level: string) => defHttp.post({ url: Api.thinking, data: { level } }, { isTransformResponse: false });
+export const switchThinking = (level: string): Promise<unknown> =>
+  defHttp.post<unknown>({ url: Api.thinking, data: { level } }, { isTransformResponse: false });

+ 6 - 6
src/views/ventAI/manageAssistent/api/report.ts

@@ -33,16 +33,16 @@ export const buildReportDownloadUrl = (urlOrFilename: string) => {
  * @param filename - 文件名,如 report_20260810_150000.md
  * @param content - 文本内容
  */
-export const saveReportText = (filename: string, content: string) =>
-  defHttp.put({ url: `${Api.saveReport}${encodeURIComponent(filename)}`, data: { content } }, { isTransformResponse: false });
+export const saveReportText = (filename: string, content: string): Promise<unknown> =>
+  defHttp.put<unknown>({ url: `${Api.saveReport}${encodeURIComponent(filename)}`, data: { content } }, { isTransformResponse: false });
 
 /**
  * 覆盖保存报告(二进制,如 xlsx)
  * @param filename - 文件名
  * @param data - ArrayBuffer / Blob / Uint8Array
  */
-export const saveReportBinary = (filename: string, data: ArrayBuffer | Blob | Uint8Array) =>
-  defHttp.put(
+export const saveReportBinary = (filename: string, data: ArrayBuffer | Blob | Uint8Array): Promise<unknown> =>
+  defHttp.put<unknown>(
     {
       url: `${Api.saveReport}${encodeURIComponent(filename)}`,
       data,
@@ -56,5 +56,5 @@ export const saveReportBinary = (filename: string, data: ArrayBuffer | Blob | Ui
  * @param filename - docx 文件名
  * @param instruction - 修改要求(自然语言)
  */
-export const editWordReport = (filename: string, instruction: string) =>
-  defHttp.post({ url: Api.editWord, data: { filename, instruction } }, { isTransformResponse: false });
+export const editWordReport = (filename: string, instruction: string): Promise<unknown> =>
+  defHttp.post<unknown>({ url: Api.editWord, data: { filename, instruction } }, { isTransformResponse: false });

+ 58 - 12
src/views/ventAI/manageAssistent/api/schedule.ts

@@ -1,10 +1,51 @@
 // api/schedule.ts:定时任务(增删改查 / 启停 / 立即执行 / 执行记录)。
+//
+// 响应形状均已对真实响应核实(2026-10):
+//   GET /schedules            → { tasks, total }
+//   GET /schedules/{id}       → { task }
+//   GET /schedules/{id}/runs  → { runs, task_id, total },runs[] 元素为
+//                               { id, task_id, status, started_at, finished_at, duration_ms, input, output, error }
+// 命令类接口(增改删 / 启停 / 立即执行)的响应体没有调用方读取,声明为 unknown。
 import { defHttp } from '/@/utils/http/axios/index';
+import type { ScheduleTask } from '/@/views/ventAI/manageAssistent/types/index';
 
 enum Api {
   schedules = '/ventAI/api/schedules',
 }
 
+export interface ScheduleListResponse {
+  tasks: ScheduleTask[];
+  total: number;
+}
+
+/**
+ * 一次执行记录(GET /schedules/{id}/runs 响应的 runs[] 元素)。
+ * 仍在执行中的记录只会填到 started_at,后续字段按可选处理(面板渲染也一律先判空)。
+ */
+export interface ScheduleRun {
+  id: number;
+  task_id: number;
+  /** running / success / failed / skipped(见 SchedulePanel 的 runStatusLabel) */
+  status: string;
+  started_at: string;
+  finished_at?: string;
+  duration_ms?: number;
+  input?: string;
+  output?: string;
+  error?: string;
+}
+
+export interface ScheduleRunsResponse {
+  runs: ScheduleRun[];
+  task_id: number;
+  total: number;
+}
+
+/** 单条定时任务详情(GET /schedules/{id}):外层是 { task } 而不是裸对象,已对真实响应核实 */
+export interface ScheduleDetailResponse {
+  task: ScheduleTask;
+}
+
 /**
  * 定时任务接口
  */
@@ -14,25 +55,30 @@ export const createSchedule = (data: {
   cron_expr?: string;
   prompt: string;
   notify_webhook?: string;
-}) => defHttp.post({ url: Api.schedules, data }, { isTransformResponse: false });
+}): Promise<unknown> => defHttp.post<unknown>({ url: Api.schedules, data }, { isTransformResponse: false });
 
-export const getScheduleList = (include_disabled = true) =>
-  defHttp.get({ url: Api.schedules, params: { include_disabled } }, { joinParamsToUrl: true, isTransformResponse: false });
+export const getScheduleList = (include_disabled = true): Promise<ScheduleListResponse> =>
+  defHttp.get<ScheduleListResponse>({ url: Api.schedules, params: { include_disabled } }, { joinParamsToUrl: true, isTransformResponse: false });
 
-export const getScheduleDetail = (task_id: number) => defHttp.get({ url: `${Api.schedules}/${task_id}` }, { isTransformResponse: false });
+export const getScheduleDetail = (task_id: number): Promise<ScheduleDetailResponse> =>
+  defHttp.get<ScheduleDetailResponse>({ url: `${Api.schedules}/${task_id}` }, { isTransformResponse: false });
 
 export const updateSchedule = (
   task_id: number,
-  data: { name?: string; schedule?: Record<string, any>; cron_expr?: string; prompt?: string; notify_webhook?: string }
-) => defHttp.put({ url: `${Api.schedules}/${task_id}`, data }, { isTransformResponse: false });
+  data: { name?: string; schedule?: Record<string, unknown>; cron_expr?: string; prompt?: string; notify_webhook?: string }
+): Promise<unknown> => defHttp.put<unknown>({ url: `${Api.schedules}/${task_id}`, data }, { isTransformResponse: false });
 
-export const pauseSchedule = (task_id: number) => defHttp.post({ url: `${Api.schedules}/${task_id}/pause` }, { isTransformResponse: false });
+export const pauseSchedule = (task_id: number): Promise<unknown> =>
+  defHttp.post<unknown>({ url: `${Api.schedules}/${task_id}/pause` }, { isTransformResponse: false });
 
-export const resumeSchedule = (task_id: number) => defHttp.post({ url: `${Api.schedules}/${task_id}/resume` }, { isTransformResponse: false });
+export const resumeSchedule = (task_id: number): Promise<unknown> =>
+  defHttp.post<unknown>({ url: `${Api.schedules}/${task_id}/resume` }, { isTransformResponse: false });
 
-export const deleteSchedule = (id: number) => defHttp.delete({ url: `${Api.schedules}/${id}` }, { isTransformResponse: false });
+export const deleteSchedule = (id: number): Promise<unknown> =>
+  defHttp.delete<unknown>({ url: `${Api.schedules}/${id}` }, { isTransformResponse: false });
 
-export const getScheduleRuns = (task_id: number, limit = 50) =>
-  defHttp.get({ url: `${Api.schedules}/${task_id}/runs`, params: { limit } }, { joinParamsToUrl: true, isTransformResponse: false });
+export const getScheduleRuns = (task_id: number, limit = 50): Promise<ScheduleRunsResponse> =>
+  defHttp.get<ScheduleRunsResponse>({ url: `${Api.schedules}/${task_id}/runs`, params: { limit } }, { joinParamsToUrl: true, isTransformResponse: false });
 
-export const runScheduleNow = (task_id: number) => defHttp.post({ url: `${Api.schedules}/${task_id}/run` }, { isTransformResponse: false });
+export const runScheduleNow = (task_id: number): Promise<unknown> =>
+  defHttp.post<unknown>({ url: `${Api.schedules}/${task_id}/run` }, { isTransformResponse: false });

+ 89 - 11
src/views/ventAI/manageAssistent/api/session.ts

@@ -1,5 +1,9 @@
 // api/session.ts:会话(历史列表 / 详情 / 标题 / 置顶 / 权限模式 / 上下文用量)与消息删除。
+//
+// 响应形状均已对真实响应采样核实(2026-10);命令类接口(删除/置顶/改标题/改模式)的响应体没有
+// 调用方读取,故如实声明为 unknown,不用 any 也不假造一个没人验证过的外壳。
 import { defHttp } from '/@/utils/http/axios/index';
+import type { CitationItem, CitationsData } from '/@/views/ventAI/manageAssistent/types/index';
 
 // 统一用枚举寻址,避免同一域里模板串与枚举两套写法混用
 enum Api {
@@ -8,59 +12,133 @@ enum Api {
   messages = '/ventAI/api/messages/',
 }
 
+/** 会话列表项(GET /sessions 响应的 sessions[] 元素) */
+export interface SessionListItem {
+  session_id: string;
+  title: string;
+  user_name: string;
+  sort_order: number;
+  created_at: string;
+  updated_at: string;
+  message_count: number;
+}
+
+export interface SessionListResponse {
+  sessions: SessionListItem[];
+  total: number;
+}
+
+/**
+ * 历史消息(GET /chat/history/{id} 响应的 messages[] 元素)。
+ *
+ * role 为 'user' / 'assistant'。字段形状已对真实响应采样核实,另外保留两个「旧格式兼容」字段:
+ * type 与 tool_calls,当前后端不再返回,但历史数据里存在,归一化逻辑(transformHistoryToMessages)
+ * 仍然要吃它们,故如实声明为可选,而不是让消费端各自 as any。
+ */
+export interface HistoryMessage {
+  id: number;
+  session_id: string;
+  role: string;
+  content: string;
+  created_at: string;
+  /** 旧格式标记:'user_message' / 'model_thinking' */
+  type?: string;
+  /** 历史接口返回扁平数组,SSE 事件返回 { items, model_only },两种都要容 */
+  citations: CitationItem[] | CitationsData | null;
+  report_url: string | null;
+  report_filename: string | null;
+  /** 后端会返回但前端未消费,如实声明为 unknown 而不是 any */
+  tool_calls: unknown;
+}
+
+export interface SessionHistoryResponse {
+  session_id: string;
+  total: number;
+  messages: HistoryMessage[];
+}
+
+/** 会话权限模式(GET /sessions/{id}/mode) */
+export interface SessionModeResponse {
+  session_id: string;
+  mode: string;
+}
+
+/** 上下文容量(GET /sessions/{id}/context) */
+export interface SessionContextResponse {
+  session_id: string;
+  current_usage: number;
+  total_limit: number;
+  usage_pct: number;
+  updated_at: string;
+  breakdown: {
+    messages: number;
+    mcp: number;
+    skills: number;
+    system_prompt: number;
+    other: number;
+  };
+}
+
 /**
  * 历史会话接口
  */
-export const getHistoryList = (params) => defHttp.get({ url: Api.sessions, params }, { joinParamsToUrl: true, isTransformResponse: false });
+export const getHistoryList = (params: Record<string, unknown>): Promise<SessionListResponse> =>
+  defHttp.get<SessionListResponse>({ url: Api.sessions, params }, { joinParamsToUrl: true, isTransformResponse: false });
 
 /**
  * 获取历史会话详情
  * @param session_id - 会话ID
  */
-export const getDetail = (session_id: string) => defHttp.get({ url: Api.chatHistory + session_id }, { isTransformResponse: false });
+export const getDetail = (session_id: string): Promise<SessionHistoryResponse> =>
+  defHttp.get<SessionHistoryResponse>({ url: Api.chatHistory + session_id }, { isTransformResponse: false });
 
 /**
  * 删除会话
  * @param session_id - 会话ID
  */
-export const deleteSession = (session_id: string) => defHttp.delete({ url: `${Api.sessions}/${session_id}` }, { isTransformResponse: false });
+export const deleteSession = (session_id: string): Promise<unknown> =>
+  defHttp.delete<unknown>({ url: `${Api.sessions}/${session_id}` }, { isTransformResponse: false });
 
 /**
  * 删除单条消息(按消息主键 id)
  * 删除后服务端会清空该会话的内存线程,下轮对话从数据库重建最近 40 条历史
  * @param message_id - 消息主键 id(来自历史详情 messages[].id)
  */
-export const deleteMessage = (message_id: number) => defHttp.delete({ url: Api.messages + message_id }, { isTransformResponse: false });
+export const deleteMessage = (message_id: number): Promise<unknown> =>
+  defHttp.delete<unknown>({ url: Api.messages + message_id }, { isTransformResponse: false });
 
 /**
  * 置顶会话
  * @param session_id - 会话ID
  */
-export const pinSession = (session_id: string) => defHttp.put({ url: `${Api.sessions}/${session_id}/pin` }, { isTransformResponse: false });
+export const pinSession = (session_id: string): Promise<unknown> =>
+  defHttp.put<unknown>({ url: `${Api.sessions}/${session_id}/pin` }, { isTransformResponse: false });
 
 /**
  * 编辑会话名称
  * @param session_id - 会话ID
  * @param title - 新标题
  */
-export const editSessionTitle = (session_id: string, title: string) =>
-  defHttp.put({ url: `${Api.sessions}/${session_id}/title`, data: { title } }, { isTransformResponse: false });
+export const editSessionTitle = (session_id: string, title: string): Promise<unknown> =>
+  defHttp.put<unknown>({ url: `${Api.sessions}/${session_id}/title`, data: { title } }, { isTransformResponse: false });
 
 /**
  * 获取对话权限模式
  * @param session_id - 会话ID
  */
-export const getSessionMode = (session_id: string) => defHttp.get({ url: `${Api.sessions}/${session_id}/mode` }, { isTransformResponse: false });
+export const getSessionMode = (session_id: string): Promise<SessionModeResponse> =>
+  defHttp.get<SessionModeResponse>({ url: `${Api.sessions}/${session_id}/mode` }, { isTransformResponse: false });
 
 /**
  * 设置对话权限模式
  * @param session_id - 会话ID
  */
-export const putSessionMode = (session_id: string, mode: string) =>
-  defHttp.put({ url: `${Api.sessions}/${session_id}/mode`, data: { mode } }, { isTransformResponse: false });
+export const putSessionMode = (session_id: string, mode: string): Promise<unknown> =>
+  defHttp.put<unknown>({ url: `${Api.sessions}/${session_id}/mode`, data: { mode } }, { isTransformResponse: false });
 
 /**
  * 上下文用量查询
  * @param session_id - 会话ID
  */
-export const getContext = (session_id: string) => defHttp.get({ url: `${Api.sessions}/${session_id}/context` }, { isTransformResponse: false });
+export const getContext = (session_id: string): Promise<SessionContextResponse> =>
+  defHttp.get<SessionContextResponse>({ url: `${Api.sessions}/${session_id}/context` }, { isTransformResponse: false });

+ 20 - 5
src/views/ventAI/manageAssistent/api/skill.ts

@@ -1,33 +1,48 @@
 // api/skill.ts:模型技能(列表 / 详情 / 启停 / 上传)。
+//
+// 列表项形状与 types/domain.ts 的 Skill 一致,直接复用该类型,不再各写一份。
 import { defHttp } from '/@/utils/http/axios/index';
+import type { Skill } from '/@/views/ventAI/manageAssistent/types/index';
 
 enum Api {
   skills = '/ventAI/api/skills',
 }
 
+export interface SkillListResponse {
+  skills: Skill[];
+}
+
+/** 技能详情(GET /skills/{name}):SKILL.md 正文 */
+export interface SkillDetailResponse {
+  name: string;
+  content: string;
+}
+
 /**
  * 查询模型技能
  */
-export const getSkills = () => defHttp.get({ url: Api.skills }, { isTransformResponse: false });
+export const getSkills = (): Promise<SkillListResponse> => defHttp.get<SkillListResponse>({ url: Api.skills }, { isTransformResponse: false });
 
 /**
  * 查询模型技能详情
  */
-export const getSkillDetails = (name: string) => defHttp.get({ url: `${Api.skills}/${name}` }, { isTransformResponse: false });
+export const getSkillDetails = (name: string): Promise<SkillDetailResponse> =>
+  defHttp.get<SkillDetailResponse>({ url: `${Api.skills}/${name}` }, { isTransformResponse: false });
 
 /**
  * 启用/禁用模型技能
  */
-export const toggleSkill = (name: string) => defHttp.post({ url: `${Api.skills}/${name}/toggle` }, { isTransformResponse: false });
+export const toggleSkill = (name: string): Promise<unknown> =>
+  defHttp.post<unknown>({ url: `${Api.skills}/${name}/toggle` }, { isTransformResponse: false });
 
 /**
  * 上传新技能
  * @param file - zip附件
  */
-export const uploadSkill = (file: File) => {
+export const uploadSkill = (file: File): Promise<unknown> => {
   const formData = new FormData();
   formData.append('file', file);
-  return defHttp.post(
+  return defHttp.post<unknown>(
     { url: `${Api.skills}/upload`, data: formData, headers: { 'Content-Type': 'multipart/form-data' } },
     { isTransformResponse: false }
   );

+ 70 - 0
src/views/ventAI/manageAssistent/api/streamPost.ts

@@ -0,0 +1,70 @@
+// api/streamPost.ts:SSE 流式请求的发起与收尾。
+//
+// 与 sse.ts 的分工:sse.ts 只负责「怎么把字节流解析成事件」,本文件负责「怎么发起这次请求」——
+// 带令牌、非 2xx 解码成 StreamError、消费事件流、把用户主动中断当成正常结果、把结果归一成
+// { sessionId, threadId, parseErrors }。对话链路与点选解读链路共用这一份,避免各自演化。
+import { getToken } from '/@/utils/auth/index';
+import { consumeSse } from './sse';
+import type { SseConsumeResult, SseEventLike } from './sse';
+import { throwStreamHttpError } from './errors';
+
+export interface StreamPostOptions {
+  /**
+   * 请求体。FormData 走 multipart(不手写 Content-Type:boundary 必须由浏览器生成),
+   * 其余对象按 JSON 提交。
+   */
+  body: FormData | Record<string, unknown>;
+  /** 出错日志前缀,如「统一对话流式请求」 */
+  label: string;
+  signal?: AbortSignal;
+  /** 终止事件类型,默认 done / error / interrupt(见 sse.ts) */
+  terminateOn?: readonly string[];
+}
+
+/**
+ * 发起一次 SSE 流式 POST 并消费到终止事件。
+ * 用户主动中断(AbortError)不打日志:那是正常操作,不是异常。
+ *
+ * @param url - 接口地址(相对路径,由 dev 代理或同源网关转发)
+ * @param options - 请求体 / 日志前缀 / 中断信号 / 终止事件类型
+ * @param onChunk - 每个事件回调
+ */
+export const streamPost = async <E extends SseEventLike>(
+  url: string,
+  options: StreamPostOptions,
+  onChunk: (data: E) => void
+): Promise<SseConsumeResult> => {
+  let payload: BodyInit;
+  let contentType: Record<string, string> = {};
+  if (options.body instanceof FormData) {
+    payload = options.body;
+  } else {
+    payload = JSON.stringify(options.body);
+    contentType = { 'Content-Type': 'application/json' };
+  }
+
+  try {
+    const response = await fetch(url, {
+      method: 'POST',
+      body: payload,
+      headers: {
+        // 令牌是「可选」语义:getToken() 为空时仍照常发请求(后端有兜底账号)。
+        // 不要改成「未登录就拦截」——那会把未登录场景直接变成不可用。
+        'X-Access-Token': getToken(),
+        ...contentType,
+      },
+      signal: options.signal,
+    });
+
+    if (!response.ok) {
+      await throwStreamHttpError(response);
+    }
+
+    return await consumeSse<E>(response, onChunk, { terminateOn: options.terminateOn });
+  } catch (error) {
+    if ((error as Error)?.name !== 'AbortError') {
+      console.error(`${options.label}失败:`, error);
+    }
+    throw error;
+  }
+};

+ 20 - 9
src/views/ventAI/manageAssistent/api/subagent.ts

@@ -6,22 +6,30 @@ enum Api {
   subagents = '/ventAI/api/subagents',
 }
 
+/** 子智能体列表响应(GET /subagents):tool_infos 项形状统一为 { name, display_name, exists } */
+export interface SubAgentListResponse {
+  subagents: SubAgent[];
+}
+
+/** 工具注册表响应(GET /subagents/tools):响应中不存在 models 字段 */
+export interface SubAgentToolsResponse {
+  groups: SubAgentToolGroup[];
+}
+
 /**
  * 获取子智能体列表。
- * 契约已对真实响应核实:{ subagents: SubAgent[] };tool_infos 项形状统一为 { name, display_name, exists }。
  * 响应外壳在此拆掉,调用方直接拿到数组,不再各自猜 subagents / data / 裸数组。
  */
 export const getSubAgents = async (): Promise<SubAgent[]> => {
-  const res = await defHttp.get({ url: Api.subagents }, { isTransformResponse: false });
+  const res = await defHttp.get<SubAgentListResponse>({ url: Api.subagents }, { isTransformResponse: false });
   return res?.subagents ?? [];
 };
 
 /**
  * 获取子智能体表单可选用的工具注册表。
- * 契约已对真实响应核实:{ groups: SubAgentToolGroup[] };响应中不存在 models 字段。
  */
 export const getSubAgentTools = async (): Promise<SubAgentToolGroup[]> => {
-  const res = await defHttp.get({ url: `${Api.subagents}/tools` }, { isTransformResponse: false });
+  const res = await defHttp.get<SubAgentToolsResponse>({ url: `${Api.subagents}/tools` }, { isTransformResponse: false });
   return res?.groups ?? [];
 };
 
@@ -35,12 +43,13 @@ export const createSubAgent = (data: {
   tools?: string[];
   model?: string;
   enabled?: boolean;
-}) => defHttp.post({ url: Api.subagents, data }, { isTransformResponse: false });
+}): Promise<unknown> => defHttp.post<unknown>({ url: Api.subagents, data }, { isTransformResponse: false });
 
 /**
  * 获取子智能体详情(当前无调用方,保留以镜像后端接口)
  */
-export const getSubAgentDetail = (name: string) => defHttp.get({ url: `${Api.subagents}/${name}` }, { isTransformResponse: false });
+export const getSubAgentDetail = (name: string): Promise<SubAgent> =>
+  defHttp.get<SubAgent>({ url: `${Api.subagents}/${name}` }, { isTransformResponse: false });
 
 /**
  * 更新子智能体
@@ -48,14 +57,16 @@ export const getSubAgentDetail = (name: string) => defHttp.get({ url: `${Api.sub
 export const updateSubAgent = (
   name: string,
   data: { description?: string; system_prompt?: string; tools?: string[]; model?: string; enabled?: boolean }
-) => defHttp.put({ url: `${Api.subagents}/${name}`, data }, { isTransformResponse: false });
+): Promise<unknown> => defHttp.put<unknown>({ url: `${Api.subagents}/${name}`, data }, { isTransformResponse: false });
 
 /**
  * 删除子智能体
  */
-export const deleteSubAgent = (name: string) => defHttp.delete({ url: `${Api.subagents}/${name}` }, { isTransformResponse: false });
+export const deleteSubAgent = (name: string): Promise<unknown> =>
+  defHttp.delete<unknown>({ url: `${Api.subagents}/${name}` }, { isTransformResponse: false });
 
 /**
  * 启用/禁用子智能体
  */
-export const toggleSubAgent = (name: string) => defHttp.post({ url: `${Api.subagents}/${name}/toggle` }, { isTransformResponse: false });
+export const toggleSubAgent = (name: string): Promise<unknown> =>
+  defHttp.post<unknown>({ url: `${Api.subagents}/${name}/toggle` }, { isTransformResponse: false });

+ 1 - 1
src/views/ventAI/manageAssistent/api/usage.ts

@@ -26,4 +26,4 @@ export interface TodayUsage {
 /**
  * 查询今日用量与额度
  */
-export const getTodayUsage = () => defHttp.get({ url: Api.usageToday }, { isTransformResponse: false });
+export const getTodayUsage = (): Promise<TodayUsage> => defHttp.get<TodayUsage>({ url: Api.usageToday }, { isTransformResponse: false });

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

@@ -21,13 +21,17 @@
     <div class="collapse-btn" @click="togglePanel">
       <div :class="['collapse-icon', { collapsed: panelCollapsed }]"></div>
     </div>
-    <div class="ai-container">
+    <div class="ai-container" @dragenter="onDragEnter" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
+      <!-- 拖拽经过时的「可放置」提示。pointer-events: none 不能去掉:遮罩一旦能命中,
+           它自己会连续触发 dragenter/dragleave,反馈会持续闪断 -->
+      <div v-if="isDragOver" class="drop-overlay">
+        <div class="drop-overlay-text">松开鼠标添加附件</div>
+      </div>
       <!-- 左侧任务列表 -->
       <div :class="['task-panel-wrapper', { collapsed: panelCollapsed }]">
         <TaskListPanel
           :taskList="taskList"
           :currentTaskId="currentTaskId"
-          :isAdmin="isAdmin"
           @switch-task="switchTask"
           @create-task="createNewTask"
           @delete-task="handleDeleteTask"
@@ -36,9 +40,8 @@
           @toggle-sub-agents="toggleSubAgentPanel"
           @toggle-schedules="toggleSchedulePanel"
         />
-        <!-- [审计-第四轮 M3] 重放自我方 fix/base-agent-token(M1/M2/M3)::isAdmin 透传给左侧任务面板,
-             由面板内部过滤掉「技能」「子智能体」两个管理入口(按钮渲染在 TaskListPanel 的 optionItems 里)。
-             注意「定时任务」不属于全局变更,保持对所有人开放。 -->
+        <!-- 「技能」「子智能体」两个入口对所有角色都显示:角色差异收敛在面板内部
+             (非管理员只读:隐藏创建/编辑/删除、禁用启停开关),而不是把入口整个藏掉。 -->
       </div>
 
       <!-- 右侧内容区:根据 activePanel 切换显示 -->
@@ -74,6 +77,7 @@
           <ChatMessages
             ref="chatMessagesRef"
             :messages="messages"
+            :history-loading="isCurrentHistoryLoading"
             @file-preview="openFilePreview"
             @download-word="downloadWordFile"
             @open-file-preview="handleOpenFilePreview"
@@ -116,6 +120,7 @@
             @stop="stopStreaming"
             @file-upload="triggerFileUpload"
             @file-change="handleFileUpload"
+            @file-add="handleFileAdded"
             @remove-pending-file="removePendingFile"
             @pending-file-preview="openFilePreview"
             @think-level-change="handleThinkLevelChange"
@@ -143,14 +148,14 @@
         </Transition>
       </template>
 
-      <!-- 技能面板 -->
+      <!-- 技能面板:非管理员只读(面板内部按 isAdmin 隐藏/禁用写操作) -->
       <div v-if="activePanel === 'skill'" class="right-panel full">
-        <SkillPanel @close="activePanel = 'chat'" />
+        <SkillPanel :is-admin="isAdmin" @close="activePanel = 'chat'" />
       </div>
 
-      <!-- 子智能体面板 -->
+      <!-- 子智能体面板:非管理员只读 -->
       <div v-if="activePanel === 'sub-agents'" class="right-panel full">
-        <SubAgentPanel @close="activePanel = 'chat'" />
+        <SubAgentPanel :is-admin="isAdmin" @close="activePanel = 'chat'" />
       </div>
 
       <!-- 定时任务面板 -->
@@ -183,7 +188,7 @@
   import SchedulePanel from '/@/views/ventAI/manageAssistent/components/panels/SchedulePanel.vue';
   import RightPanelTabs from '/@/views/ventAI/manageAssistent/components/panels/RightPanelTabs.vue';
   import { getScheduleList } from '/@/views/ventAI/manageAssistent/api';
-  import type { Message, Task } from '/@/views/ventAI/manageAssistent/types/index';
+  import type { Message, ScheduleTask, Task } from '/@/views/ventAI/manageAssistent/types/index';
   import { buildCapsuleFiles, buildCapsuleLinks, buildCapsuleAgents } from '/@/views/ventAI/manageAssistent/lib/capsule';
   import { useChatStream } from '../hooks/useChatStream';
   import { useChatTasks } from '../hooks/useChatTasks';
@@ -193,6 +198,7 @@
   import { useChatSend } from '../hooks/useChatSend';
   import { useChatScroll } from '../hooks/useChatScroll';
   import { useChatFiles } from '../hooks/useChatFiles';
+  import { useFileDrop } from '../hooks/useFileDrop';
   import { useChatQuota } from '../hooks/useChatQuota';
   import { useChatExport } from '../hooks/useChatExport';
 
@@ -238,6 +244,8 @@
     currentTask,
     messages,
     historyLoadedTasks,
+    historyLoadingTaskIds,
+    setHistoryLoading,
     getDefaultMessages,
     transformHistoryToMessages,
     fetchSessionList,
@@ -246,6 +254,10 @@
     setCurrentSessionId,
   } = useChatTasks();
 
+  // 当前任务的历史是否正在拉取:切换任务时新任务的 messages 要等接口回来才填充,
+  // 消息区据此显示加载态,避免中间先闪一屏空会话缺省图。
+  const isCurrentHistoryLoading = computed(() => historyLoadingTaskIds.value.includes(currentTaskId.value));
+
   // —— 右侧面板/标签页/全屏相关职责已下沉到 useChatPanels ——
   const {
     showFileList,
@@ -390,12 +402,13 @@
   const chatInputRef = ref<InstanceType<typeof ChatInputArea>>();
   const chatMessagesRef = ref<InstanceType<typeof ChatMessages>>();
 
-  const scheduleListForDetail = ref<any[]>([]);
+  const scheduleListForDetail = ref<ScheduleTask[]>([]);
 
   const fetchScheduleListForDetail = async () => {
     try {
       const res = await getScheduleList();
-      scheduleListForDetail.value = res?.tasks || res?.data || (Array.isArray(res) ? res : []);
+      // getScheduleList 已按真实契约标注 { tasks, total }
+      scheduleListForDetail.value = res?.tasks ?? [];
     } catch (e) {
       console.error('获取定时任务列表失败:', e);
       scheduleListForDetail.value = [];
@@ -470,7 +483,7 @@
     const task = currentTask.value;
     const tab = task?.rightPanels?.tabs.find((t) => t.type === 'schedule');
     if (tab?.type === 'schedule') {
-      const fresh = scheduleListForDetail.value.find((s: any) => s.id === tab.task.id);
+      const fresh = scheduleListForDetail.value.find((s) => s.id === tab.task.id);
       if (fresh) tab.task = fresh;
     }
     schedulePanelRef.value?.fetchScheduleList?.();
@@ -486,7 +499,24 @@
 
   // —— 以下四组职责已下沉到 composables(额度/导出/滚动/文件),组件只做接线 ——
   const { scrollToBottom, scrollToBottomThrottled } = useChatScroll(() => chatMessagesRef.value);
-  const { pendingFile, activeFileReader, triggerFileUpload, handleFileUpload, removePendingFile } = useChatFiles(() => chatInputRef.value);
+  const { pendingFile, activeFileReader, triggerFileUpload, handleFileUpload, addFile, removePendingFile } = useChatFiles(() => chatInputRef.value);
+
+  // 拖拽 / 粘贴进来的文件与「选择文件」共用同一条入队路径:校验与读取分流都在 addFile 内
+  const handleFileAdded = (file: File) => {
+    addFile(file);
+  };
+
+  const handleDropFiles = (files: File[]) => {
+    // 待发附件是单值,多文件拖入时取第一个并说明,避免用户以为全都加上了
+    if (files.length > 1) message.warning('一次只能添加一个附件,已取第一个文件');
+    addFile(files[0]);
+  };
+
+  const { isDragOver, dragHandlers } = useFileDrop({
+    onFiles: handleDropFiles,
+    isVisible: () => props.visible,
+  });
+  const { onDragEnter, onDragOver, onDragLeave, onDrop } = dragHandlers;
   const {
     usageToday,
     fetchTodayUsage,
@@ -537,9 +567,11 @@
       // 流式中的任务不拉历史(避免覆盖在途消息),但必须继续走完下面的初始化:
       // currentTaskId 已经切过来了,提前 return 会让上下文用量/模式/进度条/滚动全部停在上一个任务上。
       if (!newTask.isStreaming) {
+        // 标记加载中:消息区据 isCurrentHistoryLoading 显示加载态,而不是先闪一屏空会话
+        setHistoryLoading(taskId, true);
         try {
           const res = await getDetail(newTask.sessionId);
-          const messages = res?.messages || (Array.isArray(res) ? res : res?.data);
+          const messages = res?.messages;
           if (Array.isArray(messages)) {
             newTask.messages = transformHistoryToMessages(messages);
           }
@@ -548,6 +580,8 @@
           // 拉不到历史时界面会是空白会话,容易被误认为「对话丢了」,必须给出提示
           console.error('加载历史会话失败:', error);
           message.error('加载历史会话失败,请稍后重试');
+        } finally {
+          setHistoryLoading(taskId, false);
         }
       }
     }
@@ -567,7 +601,7 @@
     syncTodoBarFromMessages();
 
     // 切换任务时检测是否为定时任务会话:自动打开/关闭定时任务详情标签
-    const scheduleItem = newTask.sessionId ? scheduleListForDetail.value.find((s: any) => s.feedback_session_id === newTask.sessionId) : undefined;
+    const scheduleItem = newTask.sessionId ? scheduleListForDetail.value.find((s) => s.feedback_session_id === newTask.sessionId) : undefined;
     const panelState = getPanelState(newTask);
     const existingScheduleTab = panelState.tabs.find((t) => t.type === 'schedule');
     if (scheduleItem) {
@@ -819,9 +853,10 @@
 
         // 如果是历史任务,加载其消息
         if (targetTask.sessionId && !historyLoadedTasks.has(targetTask.id)) {
+          setHistoryLoading(targetTask.id, true);
           try {
             const res = await getDetail(targetTask.sessionId);
-            const messages = res?.messages || (Array.isArray(res) ? res : res?.data);
+            const messages = res?.messages;
             if (Array.isArray(messages)) {
               targetTask.messages = transformHistoryToMessages(messages);
             }
@@ -829,6 +864,8 @@
           } catch (error) {
             console.error('加载历史会话失败:', error);
             message.error('加载历史会话失败,请稍后重试');
+          } finally {
+            setHistoryLoading(targetTask.id, false);
           }
         }
 
@@ -872,12 +909,42 @@
 </script>
 
 <style scoped lang="less">
+  @import '../styles/tokens.less';
+
   .ai-container {
     display: flex;
     height: 700px;
     gap: 10px;
     overflow: hidden;
     font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+    /* 拖拽提示遮罩的定位基准 */
+    position: relative;
+  }
+
+  /* 拖拽上传提示:z-index 低于弹框的收起/最大化按钮(10),拖拽时窗口控件仍保持可见 */
+  .drop-overlay {
+    position: absolute;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    left: 0;
+    z-index: 5;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    pointer-events: none;
+    background: @color-primary-a10;
+    border: 2px dashed @color-primary-a50;
+    border-radius: 4px;
+  }
+
+  .drop-overlay-text {
+    padding: 8px 20px;
+    border-radius: 6px;
+    background: @color-panel-bg;
+    border: 1px solid @color-panel-border;
+    color: @color-text;
+    font-size: 13px;
   }
 
   .collapse-btn {

+ 95 - 17
src/views/ventAI/manageAssistent/components/chat/ChatInputArea.vue

@@ -39,12 +39,13 @@
       class="chat-input"
       placeholder="请输入你的问题"
       :auto-size="{ minRows: 3, maxRows: 6 }"
-      @keydown="handleSessionKeydown"
+      @keydown="handleInputKeydown"
       @keydown.enter="handleEnterKey"
       @keydown.backspace="handleBackspace"
       @compositionstart="handleCompositionStart"
       @compositionend="handleCompositionEnd"
       @blur="handleCompositionEnd"
+      @paste="handlePaste"
     />
 
     <div class="input-actions">
@@ -78,16 +79,12 @@
         <ContextRing :used="contextUsed" :max="contextMax" :percent="contextPercent" :breakdown="contextBreakdown" />
         <div class="action-divider"></div>
         <UsageRing :limit="usageLimit" :spent="usageSpent" :remaining="usageRemaining" :by-model="usageByModel" />
-        <!-- [审计-第四轮 M3] 重放自我方 fix/base-agent-token(M1/M2/M3):思考级别切换控件对非管理员隐藏。
-             原行为:任何角色都能看到并使用「思考级别」切换(TfAgents 已将其列为全局变更端点)。
-             现行为:ThinkLevelPopup 连同它前面的 .action-divider 一起包 v-if="isAdmin"(注意必须连分隔线
-                     一起隐藏,否则非管理员会残留一根竖线);后面的 .action-divider 保留,发送按钮布局不变。
-             为什么:入口可见性收敛到管理员;不改 ThinkLevelPopup 组件内部(可能被别处复用)。
+        <!-- 思考级别对非管理员改为「可见但只读」:控件本身还承担「当前是什么级别」的信息,
+             藏掉就看不到了;因此保留显示、由组件内部拦住展开与切换(不会触发切换接口)。
+             控件常驻后它前面的 .action-divider 也常驻,不会再出现「隐藏控件残留一根竖线」的问题。
              思考级别的"读取"(getModelMsg)不受影响,对所有人依旧可用。 -->
-        <template v-if="isAdmin">
-          <div class="action-divider"></div>
-          <ThinkLevelPopup :initial-level="initialThinkLevel" @change="emit('think-level-change', $event)" />
-        </template>
+        <div class="action-divider"></div>
+        <ThinkLevelPopup :initial-level="initialThinkLevel" :disabled="!isAdmin" @change="emit('think-level-change', $event)" />
         <div class="action-divider"></div>
         <div class="btn-send" :class="{ disabled: !streaming && (!modelValue.trim() || loading) }" @click="handleSendClick">
           <div class="send-icon" :class="{ 'stop-icon': streaming }"></div>
@@ -117,10 +114,10 @@
 </template>
 
 <script setup lang="ts">
-  import { ref } from 'vue';
+  import { computed, nextTick, ref, watch } from 'vue';
   import { onClickOutside } from '@vueuse/core';
   import { CloseOutlined } from '@ant-design/icons-vue';
-  import { formatFileSize, getFileIconVar } from '/@/views/ventAI/manageAssistent/lib/index';
+  import { formatFileSize, getClipboardFile, getFileIconVar, getUserInputText } from '/@/views/ventAI/manageAssistent/lib/index';
   import ContextRing from '/@/views/ventAI/manageAssistent/components/chat/Input/ContextRing.vue';
   import UsageRing from '/@/views/ventAI/manageAssistent/components/chat/Input/UsageRing.vue';
   import ModePopup from '/@/views/ventAI/manageAssistent/components/chat/Input/ModePopup.vue';
@@ -128,6 +125,7 @@
   import SessionPopup from '/@/views/ventAI/manageAssistent/components/chat/Input/SessionPopup.vue';
   import SkillPopup from '/@/views/ventAI/manageAssistent/components/chat/Input/SkillPopup.vue';
   import { useMentionPopups } from '/@/views/ventAI/manageAssistent/hooks/useMentionPopups';
+  import { useInputHistory } from '/@/views/ventAI/manageAssistent/hooks/useInputHistory';
   import type { UsageByModel } from '/@/views/ventAI/manageAssistent/api';
   import type { AttachedFile, Task } from '/@/views/ventAI/manageAssistent/types/index';
 
@@ -178,6 +176,8 @@
     (e: 'stop'): void;
     (e: 'file-upload'): void;
     (e: 'file-change', event: Event): void;
+    // 拖拽 / 粘贴进来的文件(已从事件里取出,输入区只负责转交)
+    (e: 'file-add', file: File): void;
     (e: 'remove-pending-file'): void;
     (e: 'pending-file-preview', file: AttachedFile): void;
     (e: 'think-level-change', levelKey: string): void;
@@ -208,6 +208,15 @@
   // 会话列表弹框
   const textareaRef = ref<InstanceType<any>>();
 
+  // a-textarea 的根节点就是原生 textarea(换行/自动高度等场景下由内部包装时回去找一层),
+  // 焦点、光标位置、光标归位都基于同一个取法,避免各处各写一份 $el 解析
+  const getTextareaEl = (): HTMLTextAreaElement | undefined => {
+    const root = textareaRef.value?.$el;
+    if (!root) return undefined;
+    if (root instanceof HTMLTextAreaElement) return root;
+    return root.querySelector?.('textarea') || undefined;
+  };
+
   // —— 会话/技能引用弹层已下沉到 useMentionPopups(模板仍在本组件,状态与交互逻辑在 composable) ——
   const {
     showSessionPopup,
@@ -241,8 +250,7 @@
       showPopup.value = false;
     },
     focusTextarea: () => {
-      const el = textareaRef.value?.$el?.querySelector('textarea') || textareaRef.value?.$el;
-      if (el) el.focus();
+      getTextareaEl()?.focus();
     },
     setSelectedSessions: (sessions) => emit('update:selectedSessions', sessions),
     setSelectedSkill: (skill) => emit('update:selectedSkill', skill),
@@ -260,9 +268,79 @@
     composing = false;
   };
 
+  // —— ↑/↓ 历史回溯:历史取当前会话里已发送的用户输入(原始文本,不含注入的引用 HTML) ——
+  const userInputHistory = computed(() => {
+    const task = (props.taskList || []).find((t) => t.id === props.currentTaskId);
+    if (!task) return [];
+    return task.messages
+      .filter((m) => m.type === 'user')
+      .map((m) => getUserInputText(m))
+      .filter((text) => !!text);
+  });
+
+  // 冷启动判据:光标在文本最开头(或输入框为空)才认 ↑。多行草稿里 ↑ 仍归 textarea 移动光标。
+  // 取不到元素时不拦,宁可让它走历史,也不要变成按了没反应的死键。
+  const isCaretAtStart = () => {
+    const el = getTextareaEl();
+    if (!el) return true;
+    return el.selectionStart === 0 && el.selectionEnd === 0;
+  };
+
+  const moveCaretToEnd = () => {
+    // 回填的值要先经父组件 v-model 写回 textarea,必须等一帧再定位光标,否则长度还是旧值
+    nextTick(() => {
+      const el = getTextareaEl();
+      if (!el) return;
+      const end = el.value.length;
+      el.setSelectionRange(end, end);
+    });
+  };
+
+  const {
+    handleKeydown: handleHistoryKeydown,
+    notifyValueChanged: notifyHistoryValueChanged,
+    reset: resetInputHistory,
+  } = useInputHistory({
+    getHistory: () => userInputHistory.value,
+    getModelValue: () => props.modelValue,
+    setModelValue: (value) => emit('update:modelValue', value),
+    isComposing: () => composing,
+    isPopupOpen: () => showSessionPopup.value || showSkillPopup.value,
+    isCaretAtStart,
+    moveCaretToEnd,
+  });
+
+  // 切会话后历史整条换掉,回看位置不再有意义
+  watch(
+    () => props.currentTaskId,
+    () => resetInputHistory()
+  );
+
+  /**
+   * 方向键有两个归属:会话/技能弹层打开时归弹层列表导航,否则归输入框历史回溯。
+   * 合成一个入口显式定序(弹层优先),而不是在同一元素上挂两个 @keydown 依赖编译器的合并顺序。
+   */
+  const handleInputKeydown = (e: KeyboardEvent) => {
+    handleSessionKeydown(e);
+    handleHistoryKeydown(e);
+  };
+
   const handleTextareaInput = (val: string) => {
     emit('update:modelValue', val);
     syncPopups(val);
+    // 用户手动改过内容就退出历史回看,否则后续 ↑ 会把他刚改的字覆盖掉
+    notifyHistoryValueChanged(val);
+  };
+
+  /**
+   * 粘贴上传:剪贴板里带文件(截图、复制的文件)时按附件处理,
+   * 图文混排的判定收敛在 lib/clipboard.getClipboardFile 内(有文本就不接管)。
+   */
+  const handlePaste = (e: ClipboardEvent) => {
+    const file = getClipboardFile(e.clipboardData);
+    if (!file) return;
+    e.preventDefault();
+    emit('file-add', file);
   };
 
   const handleEnterKey = (e: KeyboardEvent) => {
@@ -275,13 +353,13 @@
     // 其余情况沿用「回车不插入换行」:不能发送(内容为空 / 生成中)时也保持这一行为
     e.preventDefault();
     if (consumeJustSelected()) return;
-    if (props.modelValue.trim() && !props.loading) {
-      emit('send');
-    }
+    handleSend();
   };
 
   const handleSend = () => {
     if (props.modelValue.trim() && !props.loading) {
+      // 发送后输入框会被清空,历史回看位置一并作废
+      resetInputHistory();
       emit('send');
     }
   };

+ 23 - 9
src/views/ventAI/manageAssistent/components/chat/ChatMessages.vue

@@ -10,8 +10,13 @@
     />
 
     <div class="chat-messages" ref="messagesRef">
+      <!-- 历史加载中:切换任务后新任务的 messages 要等接口返回才填充,
+           这里必须先占位,否则会先闪一屏「新对话缺省图」再突然出现内容 -->
+      <div v-if="messages.length === 0 && historyLoading" class="history-loading">
+        <a-spin size="large" />
+      </div>
       <!-- 新对话缺省图 -->
-      <div v-if="messages.length === 0" class="welcome-empty">
+      <div v-else-if="messages.length === 0" class="welcome-empty">
         <img :src="getCssUrl('--img-chat-welcome-img')" class="welcome-img" />
       </div>
       <div
@@ -166,6 +171,7 @@
     getTableIcons,
     copyToClipboard,
     flashCopySuccess,
+    getUserInputText,
   } from '/@/views/ventAI/manageAssistent/lib/index';
   import type { AgentGroup } from '/@/views/ventAI/manageAssistent/lib/index';
   import type { AgentDetailData, AskUserQuestion, Message, AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
@@ -175,6 +181,8 @@
 
   const props = defineProps<{
     messages: Message[];
+    /** 当前任务的历史是否正在拉取:为真且暂无消息时显示加载态,而不是空会话缺省图 */
+    historyLoading?: boolean;
   }>();
 
   const emit = defineEmits<{
@@ -194,13 +202,6 @@
   const editingKey = ref('');
   const editDraft = ref('');
 
-  // 剥离注入的技能/会话引用 <img> HTML,用于编辑时回填原始文本
-  const stripInjectedHtml = (content: string): string =>
-    (content || '')
-      .replace(/\[<img[^>]*>[^\]]*\]/g, '')
-      .replace(/<img[^>]*>/g, '')
-      .trim();
-
   // ===== 多回答折叠:重新生成产生的连续多条 AI 消息在视觉上合并为一条,可通过 prev/next 切换查看 =====
   interface AnswerGroup {
     indices: number[];
@@ -343,7 +344,7 @@
   const startEdit = (index: number) => {
     const msg = props.messages[index];
     editingKey.value = getMessageKey(msg);
-    editDraft.value = msg.rawInput || stripInjectedHtml(msg.content);
+    editDraft.value = getUserInputText(msg);
   };
 
   const cancelEdit = () => {
@@ -547,6 +548,19 @@
       }
     }
 
+    /* 历史加载中的占位:与缺省图同样居中,避免两态之间出现位置跳动。
+       antd 3.x 的点阵是固定像素(large 为 32px 框 + 14px 点),不随 font-size 缩放,
+       因此整体放大用 transform,既不动 antd 内部类名,比例也不会走样。 */
+    .history-loading {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%) scale(2.5);
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+
     .message-wrapper {
       // margin-bottom: 20px;
 

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

@@ -23,7 +23,7 @@
         <div class="session-item-icon"></div>
         <span class="session-item-name">{{ task.name }}</span>
       </div>
-      <div v-if="list.length === 0" class="session-empty">暂无匹配会话</div>
+      <EmptyState v-if="list.length === 0" description="暂无匹配会话" />
     </div>
     <div class="session-popup-footer">
       <span v-if="selectedCount >= 5" class="max-hint">最多选择5个会话</span>
@@ -35,6 +35,7 @@
 <script setup lang="ts">
   import { computed, ref } from 'vue';
   import { onClickOutside } from '@vueuse/core';
+  import EmptyState from '/@/views/ventAI/manageAssistent/components/common/EmptyState.vue';
 
   interface SessionOption {
     id: string;
@@ -168,14 +169,7 @@
         }
       }
 
-      .session-empty {
-        text-align: center;
-        color: #8b949e;
-        font-size: 13px;
-        padding: 20px 0;
-      }
     }
-
     .session-popup-footer {
       padding: 8px 14px;
       font-size: 12px;

+ 2 - 7
src/views/ventAI/manageAssistent/components/chat/Input/SkillPopup.vue

@@ -22,7 +22,7 @@
         <div class="skill-item-icon"></div>
         <span class="skill-item-name">{{ skill.display_name }}</span>
       </div>
-      <div v-if="list.length === 0" class="skill-empty">{{ loadFailed ? '技能列表加载失败,请稍后重试' : '暂无匹配技能' }}</div>
+      <EmptyState v-if="list.length === 0" :description="loadFailed ? '技能列表加载失败,请稍后重试' : '暂无匹配技能'" />
     </div>
     <div class="skill-popup-footer">
       <span>点击选择技能(仅可选1个)</span>
@@ -31,6 +31,7 @@
 </template>
 
 <script setup lang="ts">
+  import EmptyState from '/@/views/ventAI/manageAssistent/components/common/EmptyState.vue';
   import { ref } from 'vue';
   import { onClickOutside } from '@vueuse/core';
 
@@ -156,12 +157,6 @@
         }
       }
 
-      .skill-empty {
-        text-align: center;
-        color: #8b949e;
-        font-size: 13px;
-        padding: 20px 0;
-      }
     }
 
     .skill-popup-footer {

+ 24 - 3
src/views/ventAI/manageAssistent/components/chat/Input/ThinkLevelPopup.vue

@@ -1,8 +1,8 @@
 <template>
   <!-- 思考级别选择器:关闭 / 高 / 最高 -->
   <div class="btn-think-wrapper" ref="wrapperRef">
-    <a-tooltip title="思考级别">
-      <div class="btn-think" @click="togglePopup">
+    <a-tooltip :title="props.disabled ? '思考级别(只读:仅管理员可修改)' : '思考级别'">
+      <div class="btn-think" :class="{ 'is-readonly': props.disabled }" @click="togglePopup">
         <div class="btn-icon think-icon"></div>
         <span class="btn-text">{{ selected.label }}</span>
         <div class="btn-arrow"></div>
@@ -32,8 +32,14 @@
 
   /**
    * 思考级别选择器。选中项是本组件状态(仅供展示),切换时通过 change 事件通知父组件调接口。
+   *
+   * disabled 用于「非管理员只读」:控件仍然显示(当前级别本身是需要被看到的状态),
+   * 但不展开菜单、不派发 change,因此不会触发切换接口。
    */
-  const props = withDefaults(defineProps<{ initialLevel?: string }>(), { initialLevel: 'off' });
+  const props = withDefaults(defineProps<{ initialLevel?: string; disabled?: boolean }>(), {
+    initialLevel: 'off',
+    disabled: false,
+  });
   const emit = defineEmits<{
     (e: 'change', key: string): void;
   }>();
@@ -58,10 +64,14 @@
   );
 
   const togglePopup = () => {
+    // 只读:不展开菜单(展示当前级别即可,不允许改)
+    if (props.disabled) return;
     showPopup.value = !showPopup.value;
   };
 
   const selectItem = (item: ThinkLevelOption) => {
+    // 只读:不派发 change,避免触发切换接口
+    if (props.disabled) return;
     selected.value = item;
     showPopup.value = false;
     emit('change', item.key);
@@ -118,6 +128,17 @@
       border-color: #3a8fd4;
       background: rgba(10, 132, 255, 0.1);
     }
+
+    /* 只读态:保留可读的当前级别,但不给出可点击的反馈 */
+    &.is-readonly {
+      cursor: not-allowed;
+      opacity: 0.6;
+
+      &:hover {
+        border-color: transparent;
+        background: transparent;
+      }
+    }
   }
   .think-popup {
     position: absolute;

+ 47 - 0
src/views/ventAI/manageAssistent/components/common/EmptyState.vue

@@ -0,0 +1,47 @@
+<template>
+  <div :class="['empty-state', `empty-state-${size}`]">{{ description }}</div>
+</template>
+
+<script setup lang="ts">
+  /**
+   * 空态占位:数据为空时的一行居中提示。
+   *
+   * 统一前模块内有 7 处各写一份的同类样式(字号 12/13px、颜色三种灰、内边距 8/16/20px 各不相同),
+   * 这里收敛为两档尺寸:
+   * - md(默认):面板级空态,13px / 次级文字色 / 上下 20px
+   * - sm:列表与弹层内的紧凑空态,12px / 更弱的文字色 / 上下 12px
+   *
+   * 颜色取自 styles/tokens.less 的既有基线值(@color-text-muted=#8b949e、@color-text-dim=#6a7a8a),
+   * 对原来用这两个色的位置是等值替换。
+   *
+   * 不覆盖的几种情况(语义不同,刻意保留各自实现):
+   * - 新对话缺省图(是图片,不是文字空态);
+   * - 预览面板的"不支持此格式"(图标 + 主文案 + 副文案三级结构);
+   * - 子智能体详情里的"继承主 Agent 全部工具"(行内斜体说明,非居中占位);
+   * - 各处已有的 a-empty(用的是组件库现成件,本就统一)。
+   */
+  withDefaults(defineProps<{ description: string; size?: 'md' | 'sm' }>(), { size: 'md' });
+</script>
+
+<style scoped lang="less">
+  @import '../../styles/tokens.less';
+
+  .empty-state {
+    /* width:100% 是必需的:本组件会被放进 flex 容器(如工具标签区),
+       不写宽度时块级元素会退化成文字宽度,既不再撑满一行、也失去居中的视觉位置 */
+    width: 100%;
+    text-align: center;
+  }
+
+  .empty-state-md {
+    padding: 20px 0;
+    font-size: 13px;
+    color: @color-text-muted;
+  }
+
+  .empty-state-sm {
+    padding: 12px 0;
+    font-size: 12px;
+    color: @color-text-dim;
+  }
+</style>

+ 2 - 8
src/views/ventAI/manageAssistent/components/modals/CreateSubAgentModal.vue

@@ -65,7 +65,7 @@
                   <span class="tool-tag-name">{{ tool.display_name }}</span>
                   <div v-if="form.tools.includes(tool.name)" class="tool-tag-check"></div>
                 </div>
-                <div v-if="filteredActiveTools.length === 0" class="tools-empty-hint">无匹配工具</div>
+                <EmptyState v-if="filteredActiveTools.length === 0" size="sm" description="无匹配工具" />
               </div>
             </div>
           </div>
@@ -103,6 +103,7 @@
 </template>
 
 <script setup lang="ts">
+  import EmptyState from '/@/views/ventAI/manageAssistent/components/common/EmptyState.vue';
   import { ref, reactive, computed, watch } from 'vue';
   import { createSubAgent, updateSubAgent, getSubAgentTools, extractApiErrorMessage } from '/@/views/ventAI/manageAssistent/api';
   import { message } from 'ant-design-vue';
@@ -536,13 +537,6 @@
     }
   }
 
-  .tools-empty-hint {
-    font-size: 12px;
-    color: #6a7a8a;
-    padding: 20px 0;
-    text-align: center;
-    width: 100%;
-  }
 
   .tools-selected-summary {
     display: flex;

+ 2 - 7
src/views/ventAI/manageAssistent/components/panels/FileListPanel.vue

@@ -12,12 +12,13 @@
           <div class="file-size-small">{{ formatFileSize(file.size) }}</div>
         </div>
       </div>
-      <div v-if="files.length === 0" class="empty-file-list">暂无附件</div>
+      <EmptyState v-if="files.length === 0" description="暂无附件" />
     </div>
   </div>
 </template>
 
 <script setup lang="ts">
+  import EmptyState from '/@/views/ventAI/manageAssistent/components/common/EmptyState.vue';
   import { CloseOutlined } from '@ant-design/icons-vue';
   import { formatFileSize, getFileIconVar } from '/@/views/ventAI/manageAssistent/lib/index';
   import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
@@ -122,12 +123,6 @@
         }
       }
 
-      .empty-file-list {
-        padding: 20px;
-        text-align: center;
-        color: #8b949e;
-        font-size: 12px;
-      }
     }
   }
 </style>

+ 2 - 7
src/views/ventAI/manageAssistent/components/panels/RightPanelTabs.vue

@@ -25,7 +25,7 @@
               ></div>
               <span class="tab-list-item-title">{{ tab.title }}</span>
             </div>
-            <div v-if="filteredTabs.length === 0" class="tab-list-empty">暂无匹配标签页</div>
+            <EmptyState v-if="filteredTabs.length === 0" size="sm" description="暂无匹配标签页" />
           </div>
         </div>
       </div>
@@ -78,6 +78,7 @@
 </template>
 
 <script setup lang="ts">
+  import EmptyState from '/@/views/ventAI/manageAssistent/components/common/EmptyState.vue';
   import { computed, nextTick, ref } from 'vue';
   import { onClickOutside, useResizeObserver } from '@vueuse/core';
   import FilePreviewPanel from '/@/views/ventAI/manageAssistent/components/resource/FilePreviewPanel.vue';
@@ -337,12 +338,6 @@
               }
             }
 
-            .tab-list-empty {
-              padding: 16px 0;
-              color: #6b7684;
-              text-align: center;
-              font-size: 12px;
-            }
           }
         }
       }

+ 7 - 11
src/views/ventAI/manageAssistent/components/panels/ScheduleDetailPanel.vue

@@ -73,7 +73,7 @@
               <div v-if="run.error" class="history-error">{{ run.error }}</div>
             </div>
           </template>
-          <div v-else class="history-empty">暂无执行记录</div>
+          <EmptyState v-else size="sm" description="暂无执行记录" />
         </div>
         <div v-else class="detail-history-loading">
           <a-spin size="small" />
@@ -86,12 +86,14 @@
 </template>
 
 <script setup lang="ts">
+  import EmptyState from '/@/views/ventAI/manageAssistent/components/common/EmptyState.vue';
   import { ref, watch } from 'vue';
   import { onClickOutside } from '@vueuse/core';
   import { message, Modal } from 'ant-design-vue';
   import { getScheduleRuns, pauseSchedule, resumeSchedule, deleteSchedule } from '/@/views/ventAI/manageAssistent/api';
   import CreateScheduleModal from '/@/views/ventAI/manageAssistent/components/modals/CreateScheduleModal.vue';
   import type { ScheduleTask } from '/@/views/ventAI/manageAssistent/types/index';
+  import type { ScheduleRun } from '/@/views/ventAI/manageAssistent/api';
 
   const props = defineProps<{ task: ScheduleTask | null; hideHeader?: boolean }>();
   const emit = defineEmits<{
@@ -187,7 +189,7 @@
     emit('updated');
   };
 
-  const historyList = ref<any[]>([]);
+  const historyList = ref<ScheduleRun[]>([]);
   const historyLoading = ref(false);
   const historyStats = ref<{ total: number; success: number; failed: number } | null>(null);
   // 序号:只采纳最后一次发出的请求的响应(见 fetchHistory 注释)
@@ -202,12 +204,12 @@
     try {
       const res = await getScheduleRuns(taskId);
       if (seq !== historySeq) return;
-      const runs = res?.runs || (Array.isArray(res) ? res : []);
+      const runs = res?.runs ?? [];
       historyList.value = runs;
       historyStats.value = {
         total: res?.total ?? runs.length,
-        success: runs.filter((r: any) => r.status === 'success').length,
-        failed: runs.filter((r: any) => r.status === 'failed').length,
+        success: runs.filter((r) => r.status === 'success').length,
+        failed: runs.filter((r) => r.status === 'failed').length,
       };
     } catch (e) {
       if (seq !== historySeq) return;
@@ -544,12 +546,6 @@
     line-height: 1.6;
   }
 
-  .history-empty {
-    padding: 8px 0;
-    color: #6a7a8a;
-    text-align: center;
-    font-size: 12px;
-  }
 </style>
 
 <style scoped lang="less">

+ 11 - 20
src/views/ventAI/manageAssistent/components/panels/SchedulePanel.vue

@@ -84,12 +84,12 @@
                 <div v-if="run.error" class="history-error">{{ run.error }}</div>
               </div>
             </template>
-            <div v-else class="history-empty">暂无执行记录</div>
+            <EmptyState v-else size="sm" description="暂无执行记录" />
           </div>
         </div>
       </div>
       <template v-if="filteredScheduleList.length === 0">
-        <div v-if="searchKeyword" class="schedule-empty-hint">未找到匹配的任务</div>
+        <EmptyState v-if="searchKeyword" description="未找到匹配的任务" />
         <a-empty v-else description="暂无定时任务" />
       </template>
     </div>
@@ -109,12 +109,14 @@
 </template>
 
 <script setup lang="ts">
+  import EmptyState from '/@/views/ventAI/manageAssistent/components/common/EmptyState.vue';
   import { ref, reactive, computed, onMounted } from 'vue';
   import { PlusOutlined } from '@ant-design/icons-vue';
   import { getScheduleList, deleteSchedule, pauseSchedule, resumeSchedule, getScheduleRuns } from '/@/views/ventAI/manageAssistent/api';
   import { message, Modal } from 'ant-design-vue';
   import CreateScheduleModal from '/@/views/ventAI/manageAssistent/components/modals/CreateScheduleModal.vue';
   import type { ScheduleTask } from '/@/views/ventAI/manageAssistent/types/index';
+  import type { ScheduleRun } from '/@/views/ventAI/manageAssistent/api';
 
   const emit = defineEmits<{ (e: 'close'): void; (e: 'open-session', sessionId: string, prompt: string): void; (e: 'data-changed'): void }>();
 
@@ -134,7 +136,7 @@
   const showCreateModal = ref(false);
   const editData = ref<ScheduleTask | null>(null);
   const expandedIds = ref(new Set<number>());
-  const historyMap = reactive<Record<number, any[]>>({});
+  const historyMap = reactive<Record<number, ScheduleRun[]>>({});
   const historyLoadingMap = reactive<Record<number, boolean>>({});
   const historyStatsMap = reactive<Record<number, { total: number; success: number; failed: number }>>({});
 
@@ -142,7 +144,8 @@
     loading.value = true;
     try {
       const res = await getScheduleList();
-      scheduleList.value = res?.tasks || res?.data || (Array.isArray(res) ? res : []);
+      // getScheduleList 已按真实契约标注 { tasks, total }
+      scheduleList.value = res?.tasks ?? [];
     } catch (e) {
       // 失败不能只留空列表:用户会把「后端挂了」看成「一条定时任务都没有」
       console.error('获取定时任务列表失败:', e);
@@ -224,12 +227,12 @@
       historyLoadingMap[taskId] = true;
       try {
         const res = await getScheduleRuns(taskId);
-        const runs = res?.runs || (Array.isArray(res) ? res : []);
+        const runs = res?.runs ?? [];
         historyMap[taskId] = runs;
         historyStatsMap[taskId] = {
           total: res?.total ?? runs.length,
-          success: runs.filter((r: any) => r.status === 'success').length,
-          failed: runs.filter((r: any) => r.status === 'failed').length,
+          success: runs.filter((r) => r.status === 'success').length,
+          failed: runs.filter((r) => r.status === 'failed').length,
         };
       } catch (e) {
         console.error('获取执行记录失败:', e);
@@ -363,7 +366,7 @@
         border: 1px solid rgba(63, 80, 106, 0.4) !important;
         border-radius: 6px !important;
         color: #e0e6ed;
-        height: 36px;
+        height: 36px !important;
       }
       :deep(.zxm-select-selection-item) {
         line-height: 34px !important;
@@ -727,17 +730,5 @@
     line-height: 1.6;
   }
 
-  .history-empty {
-    font-size: 12px;
-    color: #6a7a8a;
-    text-align: center;
-    padding: 8px 0;
-  }
 
-  .schedule-empty-hint {
-    text-align: center;
-    color: #8b949e;
-    font-size: 13px;
-    padding: 20px 0;
-  }
 </style>

+ 16 - 3
src/views/ventAI/manageAssistent/components/panels/SkillPanel.vue

@@ -3,7 +3,10 @@
     <div class="skill-panel-header">
       <div class="skill-panel-header-left">
         <span class="skill-panel-title">技能</span>
-        <span class="skill-panel-desc">管理模型技能,启用后可在聊天里通过$skill-name使用</span>
+        <span class="skill-panel-desc">
+          管理模型技能,启用后可在聊天里通过$skill-name使用
+          <template v-if="!props.isAdmin">(只读:仅管理员可修改)</template>
+        </span>
       </div>
       <div class="skill-panel-close" @click="emit('close')">
         <div class="close-icon"></div>
@@ -28,7 +31,9 @@
             <div class="skill-item-icon"></div>
             <span class="skill-name">{{ skill.display_name }}</span>
           </div>
-          <a-switch :checked="skill.enabled" size="small" class="skill-switch" @change="handleToggleSkill(skill)" />
+          <!-- 启用/禁用开关同时承担「当前是否启用」的状态展示,因此非管理员保留但禁用,
+               而不是隐藏——隐藏会让人看不到哪些技能是启用状态 -->
+          <a-switch :checked="skill.enabled" :disabled="!props.isAdmin" size="small" class="skill-switch" @change="handleToggleSkill(skill)" />
         </div>
         <div class="skill-desc">{{ skill.description }}</div>
         <div v-if="skill.agents && skill.agents.length > 0" class="skill-agents">
@@ -62,6 +67,14 @@
   import { message } from 'ant-design-vue';
   import type { Skill } from '/@/views/ventAI/manageAssistent/types/index';
 
+  /**
+   * 是否管理员:决定本面板是「可管理」还是「只读」。
+   *
+   * 可选并默认 false —— 值缺失时按非管理员处理(保守方向:宁可少给编辑入口)。
+   * 注意这只是界面可见性,真正的权限拦截仍在 TfAgents 后端,前端隐藏不等于安全。
+   */
+  const props = withDefaults(defineProps<{ isAdmin?: boolean }>(), { isAdmin: false });
+
   const emit = defineEmits<{ (e: 'close'): void }>();
 
   const skills = ref<Skill[]>([]);
@@ -232,7 +245,7 @@
         border: 1px solid rgba(63, 80, 106, 0.4) !important;
         border-radius: 6px !important;
         color: #e0e6ed;
-        height: 36px;
+        height: 36px !important;
       }
       :deep(.zxm-select-selection-item) {
         line-height: 34px !important;

+ 22 - 6
src/views/ventAI/manageAssistent/components/panels/SubAgentPanel.vue

@@ -3,7 +3,10 @@
     <div class="sub-agent-panel-header">
       <div class="sub-agent-panel-header-left">
         <span class="sub-agent-panel-title">子智能体</span>
-        <span class="sub-agent-panel-desc">管理模型子智能体,启用后可在聊天里使用</span>
+        <span class="sub-agent-panel-desc">
+          管理模型子智能体,启用后可在聊天里使用
+          <template v-if="!props.isAdmin">(只读:仅管理员可修改)</template>
+        </span>
       </div>
       <div class="sub-agent-panel-close" @click="emit('close')">
         <div class="close-icon"></div>
@@ -20,7 +23,8 @@
     <div class="sub-agent-summary">
       <span class="sub-agent-summary-label">子智能体</span>
       <span class="sub-agent-summary-count">{{ filteredAgents.length }}项</span>
-      <a-button type="primary" class="sub-agent-create-btn" @click="handleCreate">
+      <!-- 创建属写操作:非管理员不显示 -->
+      <a-button v-if="props.isAdmin" type="primary" class="sub-agent-create-btn" @click="handleCreate">
         <template #icon><PlusOutlined /></template>
         创建
       </a-button>
@@ -33,9 +37,13 @@
             <span class="sub-agent-name">{{ agent.name }}</span>
           </div>
           <div class="sub-agent-actions">
-            <a-switch :checked="agent.enabled" size="small" class="sub-agent-switch" @change="handleToggle(agent)" />
-            <div class="action-btn edit-btn" @click.stop="handleEdit(agent)" title="编辑"></div>
-            <div class="action-btn delete-btn" @click.stop="handleDelete(agent)" title="删除"></div>
+            <!-- 启用/禁用开关兼具「当前是否启用」的状态展示,非管理员保留但禁用;
+                 编辑/删除是纯写操作,直接不显示 -->
+            <a-switch :checked="agent.enabled" :disabled="!props.isAdmin" size="small" class="sub-agent-switch" @change="handleToggle(agent)" />
+            <template v-if="props.isAdmin">
+              <div class="action-btn edit-btn" @click.stop="handleEdit(agent)" title="编辑"></div>
+              <div class="action-btn delete-btn" @click.stop="handleDelete(agent)" title="删除"></div>
+            </template>
           </div>
         </div>
         <div class="sub-agent-desc">{{ agent.description }}</div>
@@ -91,6 +99,14 @@
   import CreateSubAgentModal from '/@/views/ventAI/manageAssistent/components/modals/CreateSubAgentModal.vue';
   import type { SubAgent } from '/@/views/ventAI/manageAssistent/types/index';
 
+  /**
+   * 是否管理员:决定本面板是「可管理」还是「只读」。
+   *
+   * 可选并默认 false —— 值缺失时按非管理员处理(保守方向:宁可少给编辑入口)。
+   * 注意这只是界面可见性,真正的权限拦截仍在 TfAgents 后端,前端隐藏不等于安全。
+   */
+  const props = withDefaults(defineProps<{ isAdmin?: boolean }>(), { isAdmin: false });
+
   const emit = defineEmits<{ (e: 'close'): void }>();
 
   const agents = ref<SubAgent[]>([]);
@@ -284,7 +300,7 @@
         border: 1px solid rgba(63, 80, 106, 0.4) !important;
         border-radius: 6px !important;
         color: #e0e6ed;
-        height: 36px;
+        height: 36px !important;
       }
       :deep(.zxm-select-selection-item) {
         line-height: 34px !important;

+ 15 - 26
src/views/ventAI/manageAssistent/components/panels/TaskListPanel.vue

@@ -47,22 +47,20 @@
           <div class="task-delete" @click.stop="emit('delete-task', task, index)" title="删除"></div>
         </div>
       </div>
-      <div v-if="searchKeyword && filteredTaskList.length === 0" class="empty-hint"> 未找到匹配的任务 </div>
-      <div v-else-if="filteredTaskList.length === 0" class="empty-hint"> 还没有任务 </div>
+      <EmptyState v-if="searchKeyword && filteredTaskList.length === 0" description="未找到匹配的任务" />
+      <EmptyState v-else-if="filteredTaskList.length === 0" description="还没有任务" />
     </div>
   </div>
 </template>
 
 <script setup lang="ts">
   import { ref, computed } from 'vue';
+  import EmptyState from '/@/views/ventAI/manageAssistent/components/common/EmptyState.vue';
   import type { Task } from '/@/views/ventAI/manageAssistent/types/index';
 
   const props = defineProps<{
     taskList: Task[];
     currentTaskId: string;
-    // [审计-第四轮 M3] 重放自我方 fix/base-agent-token(M1/M2/M3):是否管理员(由 AiAssistantModal 透传 TfAgents 的 is_admin)。
-    // 为可选属性并默认 false:既兼容其它潜在调用方,也保证「值缺失 = 按非管理员处理」的保守语义。
-    isAdmin?: boolean;
   }>();
 
   const emit = defineEmits<{
@@ -101,21 +99,18 @@
     emit('pin-task', task);
   };
 
-  // [审计-第四轮 M3] 重放自我方 fix/base-agent-token(M1/M2/M3):顶部操作区的入口列表。
-  // 原行为:普通 const 数组,恒含「技能」「子智能体」,任何角色都能点进管理面板。
-  // 现行为:改为 computed 并按 isAdmin 过滤 —— 非管理员看不到这两个「全局变更」入口,
-  //         「新建任务 / 定时任务」对所有角色保持开放(定时任务属于个人功能,不在 TfAgents 的收敛范围内)。
-  // 为什么:用 computed(而非普通数组)是为了让 isAdmin 异步返回后能自动重算,无需刷新。
-  const optionItems = computed(() => {
-    const items = [
-      { type: 'add' as OptionType, label: '新建任务' },
-      // { type: 'search' as OptionType, label: '搜索' },
-      { type: 'skill' as OptionType, label: '技能' },
-      { type: 'sub-agents' as OptionType, label: '子智能体' },
-      { type: 'schedules' as OptionType, label: '定时任务' },
-    ];
-    return props.isAdmin ? items : items.filter((item) => item.type !== 'skill' && item.type !== 'sub-agents');
-  });
+  // 顶部操作区的入口列表:四个入口对所有角色都显示。
+  //
+  // 这里曾按 isAdmin 过滤掉「技能」「子智能体」两个入口(第四轮审计 M3),现改为入口一律可见:
+  // 非管理员进去后面板只读(隐藏创建/编辑/删除、禁用启停开关),而不是连看都看不到。
+  // 因此本组件不再需要 isAdmin —— 角色差异全部收敛在面板内部。
+  const optionItems = [
+    { type: 'add' as OptionType, label: '新建任务' },
+    // { type: 'search' as OptionType, label: '搜索' },
+    { type: 'skill' as OptionType, label: '技能' },
+    { type: 'sub-agents' as OptionType, label: '子智能体' },
+    { type: 'schedules' as OptionType, label: '定时任务' },
+  ];
 
   const formatRelativeTime = (dateStr?: string) => {
     if (!dateStr) return '刚刚';
@@ -303,12 +298,6 @@
         }
       }
 
-      .empty-hint {
-        text-align: center;
-        color: #8b949e;
-        font-size: 13px;
-        padding: 20px 0;
-      }
     }
 
     @keyframes task-spin {

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

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

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

@@ -2,8 +2,9 @@ import { computed, nextTick } from 'vue';
 import dayjs from 'dayjs';
 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, normalizeLineBreaks } from '/@/views/ventAI/manageAssistent/lib/index';
+import { getCssUrl, getUserInputText, normalizeLineBreaks, stripInjectedRefs } from '/@/views/ventAI/manageAssistent/lib/index';
 
 /**
  * 发送与流式管线:推送一条空 AI 消息 → 建 AbortController → 走 SSE(纯文本 / PDF 审查)→ 处理
@@ -26,7 +27,7 @@ export interface ChatSendDeps {
   resetTodos: () => void;
   migrateTaskApproval: (fromTaskId: string, toTaskId: string) => void;
   historyLoadedTasks: Set<string>;
-  transformHistoryToMessages: (data: Array<Record<string, unknown>>) => Message[];
+  transformHistoryToMessages: (data: HistoryMessage[]) => Message[];
 }
 
 export const useChatSend = (deps: ChatSendDeps) => {
@@ -237,13 +238,6 @@ export const useChatSend = (deps: ChatSendDeps) => {
     await streamAiAnswer(userInput, file);
   };
 
-  // 剥离用户消息里注入的技能/会话引用 <img> HTML(rawInput 缺失时的兜底)
-  const stripInjectedRefs = (content: string): string =>
-    (content || '')
-      .replace(/\[<img[^>]*>[^\]]*\]/g, '')
-      .replace(/<img[^>]*>/g, '')
-      .trim();
-
   // 补齐消息的数据库主键 id(刚流式生成的消息前端没有 id,但后端已持久化)。
   //
   // sessionId 与 msgs 都由调用方在流程开始时锚定后传入:本函数内部有 await,如果这里再去读
@@ -257,7 +251,8 @@ export const useChatSend = (deps: ChatSendDeps) => {
     if (!sessionId || !msgs.length) return;
     try {
       const res = await getDetail(sessionId);
-      const raw = res?.messages || (Array.isArray(res) ? res : res?.data);
+      // getDetail 已按真实契约标注 { messages, ... },不再猜 messages / data / 裸数组三种外壳
+      const raw = res?.messages;
       if (!Array.isArray(raw)) return;
       // 用 transformHistoryToMessages 归一化,避免 model_thinking 等记录导致条数与前端不对齐
       const history = transformHistoryToMessages(raw);
@@ -278,13 +273,13 @@ export const useChatSend = (deps: ChatSendDeps) => {
     if (!sessionId) return;
     try {
       const res = await getDetail(sessionId);
-      const raw = res?.messages || (Array.isArray(res) ? res : res?.data);
+      const raw = res?.messages;
       if (!Array.isArray(raw)) return;
 
       // 保留第一条 user(原始提问),删除其余与重发内容一致的重复 user,避免多次重新生成残留
-      const userMsgs = raw.filter((m: any) => m?.role === 'user' || m?.type === 'user_message');
+      const userMsgs = raw.filter((m) => m?.role === 'user' || m?.type === 'user_message');
       const duplicates = userMsgs.filter(
-        (m: any, idx: number) => idx > 0 && normalizeLineBreaks(m?.content || '').trim() === expectedContent.trim() && m?.id != null
+        (m, idx) => idx > 0 && normalizeLineBreaks(m?.content || '').trim() === expectedContent.trim() && m?.id != null
       );
       for (const dup of duplicates) {
         await deleteMessage(dup.id);
@@ -318,7 +313,7 @@ export const useChatSend = (deps: ChatSendDeps) => {
     if (userIdx < 0) return;
 
     const userMsg = msgs[userIdx];
-    const userInput = userMsg.rawInput || stripInjectedRefs(userMsg.content);
+    const userInput = getUserInputText(userMsg);
     const attachedFile = userMsg.attachedFile;
 
     // 不删除旧 AI 消息、不动用户提问,直接重发同一条用户输入重新生成。

+ 76 - 66
src/views/ventAI/manageAssistent/hooks/useChatTasks.ts

@@ -3,6 +3,7 @@ import dayjs from 'dayjs';
 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';
 
 /**
@@ -22,56 +23,56 @@ export const useChatTasks = () => {
   const fetchSessionList = async () => {
     try {
       const res = await getHistoryList({});
-      const sessions = res?.sessions || res?.data || [];
-      if (Array.isArray(sessions)) {
-        const apiTasks: Task[] = sessions.map((item: { session_id: string; title: string; updated_at: string; sort_order?: number }) => ({
-          id: item.session_id,
-          name: item.title || '未命名会话',
-          sessionId: item.session_id,
-          messages: [],
-          attachedFiles: [],
-          updatedAt: item.updated_at,
-          sortOrder: item.sort_order,
-        }));
-
-        // Build lookup maps for O(1) access
-        const existingTaskMap = new Map(taskList.value.map((t) => [t.id, t]));
-        const apiTaskIds = new Set(apiTasks.map((t) => t.id));
-
-        // 流式中的空白任务:后端已为其创建会话,但前端要等流结束才拿到 session_id。
-        // 该会话在接口里是一条全新记录,归并回空白任务而不是单独插入,避免列表出现重复条目
-        let blankStreamingTask: Task | null = taskList.value.find((t) => !t.sessionId && t.isStreaming) || null;
-
-        const mergedTasks: Task[] = [];
-        for (const apiTask of apiTasks) {
-          if (blankStreamingTask && (blankStreamingTask.pendingSessionId === apiTask.id || !knownApiSessionIds.has(apiTask.id))) {
-            blankStreamingTask.pendingSessionId = apiTask.id;
-            blankStreamingTask.name = apiTask.name;
-            blankStreamingTask.updatedAt = apiTask.updatedAt;
-            blankStreamingTask.sortOrder = apiTask.sortOrder;
-            blankStreamingTask = null;
-            continue;
-          }
-
-          const existing = existingTaskMap.get(apiTask.id);
-          if (existing) {
-            // 原地更新以保持对象引用,避免流式回调持有的任务引用失效(isStreaming 等状态丢失)
-            existing.name = apiTask.name;
-            existing.updatedAt = apiTask.updatedAt;
-            existing.sortOrder = apiTask.sortOrder;
-            mergedTasks.push(existing);
-          } else {
-            mergedTasks.push(apiTask);
-          }
+      // getHistoryList 已按真实契约标注 { sessions, total },不再猜 sessions / data / 裸数组三种外壳
+      const sessions = res?.sessions ?? [];
+
+      const apiTasks: Task[] = sessions.map((item) => ({
+        id: item.session_id,
+        name: item.title || '未命名会话',
+        sessionId: item.session_id,
+        messages: [],
+        attachedFiles: [],
+        updatedAt: item.updated_at,
+        sortOrder: item.sort_order,
+      }));
+
+      // Build lookup maps for O(1) access
+      const existingTaskMap = new Map(taskList.value.map((t) => [t.id, t]));
+      const apiTaskIds = new Set(apiTasks.map((t) => t.id));
+
+      // 流式中的空白任务:后端已为其创建会话,但前端要等流结束才拿到 session_id。
+      // 该会话在接口里是一条全新记录,归并回空白任务而不是单独插入,避免列表出现重复条目
+      let blankStreamingTask: Task | null = taskList.value.find((t) => !t.sessionId && t.isStreaming) || null;
+
+      const mergedTasks: Task[] = [];
+      for (const apiTask of apiTasks) {
+        if (blankStreamingTask && (blankStreamingTask.pendingSessionId === apiTask.id || !knownApiSessionIds.has(apiTask.id))) {
+          blankStreamingTask.pendingSessionId = apiTask.id;
+          blankStreamingTask.name = apiTask.name;
+          blankStreamingTask.updatedAt = apiTask.updatedAt;
+          blankStreamingTask.sortOrder = apiTask.sortOrder;
+          blankStreamingTask = null;
+          continue;
         }
 
-        apiTasks.forEach((apiTask) => knownApiSessionIds.add(apiTask.id));
+        const existing = existingTaskMap.get(apiTask.id);
+        if (existing) {
+          // 原地更新以保持对象引用,避免流式回调持有的任务引用失效(isStreaming 等状态丢失)
+          existing.name = apiTask.name;
+          existing.updatedAt = apiTask.updatedAt;
+          existing.sortOrder = apiTask.sortOrder;
+          mergedTasks.push(existing);
+        } else {
+          mergedTasks.push(apiTask);
+        }
+      }
 
-        const localOnlyTasks = taskList.value.filter((t) => !t.sessionId && !apiTaskIds.has(t.id));
+      apiTasks.forEach((apiTask) => knownApiSessionIds.add(apiTask.id));
 
-        taskList.value = [...localOnlyTasks, ...mergedTasks];
-        return mergedTasks;
-      }
+      const localOnlyTasks = taskList.value.filter((t) => !t.sessionId && !apiTaskIds.has(t.id));
+
+      taskList.value = [...localOnlyTasks, ...mergedTasks];
+      return mergedTasks;
     } catch (error) {
       console.error('获取会话列表失败:', error);
     }
@@ -97,6 +98,21 @@ export const useChatTasks = () => {
   // Non-reactive lookup cache - Vue 3 does not track Set mutations on ref<Set>
   const historyLoadedTasks = new Set<string>();
 
+  // 正在拉取历史的任务 id(前端任务 id)。
+  //
+  // 切换任务时 currentTaskId 立即切过去,而新任务的 messages 要等 getDetail 回来才有内容:
+  // 这段窗口里消息区会落到 messages.length === 0,若不区分「加载中」与「确实没有消息」,
+  // 用户会先看到一屏空会话缺省图再突然出现内容。消息区据此显示加载态。
+  //
+  // 用数组而非 Set:ref<Set> 的 mutation 不被 Vue 追踪(同 historyLoadedTasks 的注释),
+  // 整体替换数组才是可靠的响应式写法。
+  const historyLoadingTaskIds = ref<string[]>([]);
+
+  const setHistoryLoading = (taskId: string, loading: boolean) => {
+    const rest = historyLoadingTaskIds.value.filter((id) => id !== taskId);
+    historyLoadingTaskIds.value = loading ? [...rest, taskId] : rest;
+  };
+
   const setCurrentSessionId = (sessionId: string) => {
     const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
     if (currentTask && currentTask.id !== sessionId) {
@@ -137,18 +153,11 @@ export const useChatTasks = () => {
     return [];
   };
 
-  const transformHistoryToMessages = (
-    data: Array<{
-      id?: number;
-      role?: string;
-      type?: string;
-      content: string;
-      created_at?: string;
-      report_url?: string;
-      report_filename?: string;
-      citations?: any;
-    }>
-  ): Message[] => {
+  /**
+   * 历史消息 → 前端 Message[]。入参直接用接口声明的 HistoryMessage,
+   * 不再在本文件另立一份结构副本:两套形状一旦分叉,接口字段变化就不会在这里报错。
+   */
+  const transformHistoryToMessages = (data: HistoryMessage[]): Message[] => {
     const messages: Message[] = [];
 
     for (const item of data) {
@@ -165,14 +174,13 @@ export const useChatTasks = () => {
           // 后端返回的报告地址前缀不稳定(绝对地址 / 缺 /ventAI 的相对路径),入前端状态前统一归一
           msg.downloadFiles = [{ url: buildReportDownloadUrl(item.report_url), filename: item.report_filename }];
         }
-        if (item.citations) {
-          // 历史接口返回 citations 为扁平数组,SSE 事件返回 { items, model_only },统一归一化
-          const raw = item.citations;
-          if (Array.isArray(raw)) {
-            msg.citations = { items: raw, model_only: raw.length === 0 };
-          } else {
-            msg.citations = raw;
-          }
+        // 历史接口返回 citations 为扁平数组,SSE 事件返回 { items, model_only },统一归一化
+        const rawCitations = item.citations;
+        if (Array.isArray(rawCitations)) {
+          msg.citations = { items: rawCitations, model_only: rawCitations.length === 0 };
+        } else if (rawCitations) {
+          // null 与 undefined 对调用方等价(均按「没有引用」处理),不赋值以免把 Message 上的可选字段写成 null
+          msg.citations = rawCitations;
         }
         messages.push(msg);
       }
@@ -198,6 +206,8 @@ export const useChatTasks = () => {
     currentTask,
     messages,
     historyLoadedTasks,
+    historyLoadingTaskIds,
+    setHistoryLoading,
     getDefaultMessages,
     transformHistoryToMessages,
     fetchSessionList,

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

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

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

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

+ 4 - 14
src/views/ventAI/manageAssistent/hooks/useMentionPopups.ts

@@ -1,6 +1,6 @@
 import { computed, nextTick, onMounted, ref, watch } from 'vue';
 import { getSkills } from '/@/views/ventAI/manageAssistent/api';
-import type { Task } from '/@/views/ventAI/manageAssistent/types/index';
+import type { Skill, Task } from '/@/views/ventAI/manageAssistent/types/index';
 
 /**
  * 输入框的会话/技能引用弹层:`#` 触发会话提及、`$` 触发技能提及,含搜索过滤、方向键导航、
@@ -21,18 +21,8 @@ export interface MentionPopupDeps {
   setSelectedSkill: (skill: { name: string; display_name: string } | null) => void;
 }
 
-interface SkillItem {
-  name: string;
-  display_name: string;
-  path: string;
-  description: string;
-  enabled: boolean;
-  agents: string[];
-  has_skill_md: boolean;
-}
-
 // Module-level cache for skill list to avoid redundant API calls across component mounts
-let skillListCache: SkillItem[] | null = null;
+let skillListCache: Skill[] | null = null;
 let skillListFetchPromise: Promise<void> | null = null;
 
 export const useMentionPopups = (deps: MentionPopupDeps) => {
@@ -61,7 +51,7 @@ export const useMentionPopups = (deps: MentionPopupDeps) => {
   const activeSkillIndex = ref(0);
   const selectedSkill = ref<{ name: string; display_name: string } | null>(null);
   const skillPopupManual = ref(false);
-  const skillList = ref<SkillItem[]>([]);
+  const skillList = ref<Skill[]>([]);
   // 技能列表拉取失败:$ 弹层的空态据此区分「加载失败」与「确实没有技能」,避免失败被显示成空数据
   const skillListLoadFailed = ref(false);
   let skillJustSelected = false;
@@ -220,7 +210,7 @@ export const useMentionPopups = (deps: MentionPopupDeps) => {
     nextTick(() => focusTextarea());
   };
 
-  const handleSelectSkill = (skill: SkillItem) => {
+  const handleSelectSkill = (skill: Skill) => {
     selectedSkill.value = { name: skill.name, display_name: skill.display_name };
     removeSkillSearchTag();
     showSkillPopup.value = false;

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

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

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

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

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

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

+ 12 - 0
src/views/ventAI/manageAssistent/types/domain.ts

@@ -24,6 +24,11 @@ export interface CitationsData {
   message?: string;
 }
 
+/**
+ * 定时任务列表项(GET /schedules 响应的 tasks[] 元素)。
+ * 字段形状已对真实数据核实;末尾几项是接口会返回、前端当前未使用的字段,
+ * 如实声明它们,避免「类型里没有」被误读成「接口没有」。
+ */
 export interface ScheduleTask {
   id: number;
   name: string;
@@ -34,6 +39,13 @@ export interface ScheduleTask {
   next_run_at: string | null;
   feedback_session_id?: string;
   schedule?: { freq: string; human?: string };
+  session_id?: string;
+  task_type?: string;
+  tz?: string;
+  user_id?: string;
+  notify_webhook?: string;
+  last_run_at?: string;
+  created_at?: string;
 }
 
 /** 定时任务创建/编辑表单的形状:比列表项多 notify_webhook,少 cron_human/enabled/next_run_at */

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff