Browse Source

fix: 会话闸门跨线程锁泄漏、错误轮次过程丢失;产物永久保留与展示优化

- 会话并发闸门 ReentrantLock → Semaphore(聊天与研判同修):tryLock 在订阅线程、
  doFinally 在上游事件线程解锁,跨线程 unlock 抛 IllegalMonitorStateException 导致
  闸门永久不释放,上一轮结束后第二条消息一直被误判「该会话正在回复中」
- 错误轮次落库随附深度思考 + 工具调用 + 产物元数据(此前只存一行错误文本,
  刷新后整段过程与产物从聊天记录消失);完整回答已落库后才出错的不重复携带
- 产物文件永久保留:CSV 清理 retain-days 默认 7→0、Python 产出图 LRU 默认 20→0(≤0 跳过清理)
- 工具「输出」位常显产物文件卡片(不折叠,行点击只展开入参),不再渲染数据表
- 产物预览弹窗改用 BasicModal「全屏/还原」(与 AgentFormModal 同款);
  列宽按表头自适应保证列名完整展示,列多时横向滚动
- 移除产物栏「定位到消息」
- spring-boot:run 工作目录固定为仓库根:PathConst 按 user.dir 解析 QingJian,
  之前误落 ai-server/ 导致产物写分裂、历史产物预览 404
cc 19 hours ago
parent
commit
d5cd5fdfb0

+ 160 - 0
ai-electron/frontend/src/ai/components/ArtifactFileCard.vue

@@ -0,0 +1,160 @@
+<template>
+  <!-- 工具产出的产物文件卡片:常显在工具行下方,不随「入参/输出」折叠 -->
+  <div class="ai-artifact-card">
+    <span class="ai-artifact-card__icon">
+      <Icon icon="mdi:file-delimited-outline" :size="15" />
+    </span>
+    <span class="ai-artifact-card__name" :title="artifact.fileName">{{ artifact.fileName }}</span>
+    <span v-if="metaText" class="ai-artifact-card__meta">{{ metaText }}</span>
+    <span class="ai-artifact-card__sp"></span>
+    <button type="button" title="预览前 200 行" @click="onPreview">
+      <Icon icon="mdi:eye-outline" :size="14" />
+    </button>
+    <button type="button" title="下载完整 CSV" @click="onDownload">
+      <Icon icon="mdi:download-outline" :size="14" />
+    </button>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  /**
+   * 产物文件卡片(工具输出 → CSV 全量文件)
+   *
+   * 展示口径与产物栏一致:文件名 + 行数 · 大小 + 预览/下载。
+   * 卡片常显在工具调用行下方 —— 产物是工具结果的一部分,历史会话回查时
+   * 不应该还藏在「入参/输出」的折叠层里。
+   *
+   * 数值字段兼容两种形态:后端全局配置把 Long 序列化成字符串(防雪花 ID 精度丢失),
+   * `totalRows` / `sizeBytes` 可能是 number 也可能是数字字符串,这里统一归一。
+   */
+  import { computed } from 'vue';
+  import Icon from '@/core/components/Icon/src/Icon.vue';
+  import { useMessage } from '@/core/hooks/web/useMessage';
+  import type { ArtifactMeta } from '../api/types';
+  import { downloadArtifactFile } from '../api/artifactApi';
+  import { openArtifactPreview } from '../utils/artifacts';
+
+  const props = withDefaults(
+    defineProps<{
+      /** 产物元数据(工具结果 JSON 里的 artifact 字段) */
+      artifact: ArtifactMeta & Record<string, any>;
+      /** 所属消息 ID(预览记录定位用;时间线详情里可为空) */
+      messageId?: string;
+      /** 产生它的工具名(预览记录用) */
+      toolName?: string;
+    }>(),
+    { messageId: '', toolName: '' },
+  );
+
+  const { createMessage } = useMessage();
+
+  function toCount(value: unknown): number | undefined {
+    if (typeof value === 'number' && Number.isFinite(value)) return value;
+    if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) {
+      return Number(value);
+    }
+    return undefined;
+  }
+
+  const metaText = computed(() => {
+    const rows = toCount(props.artifact.totalRows);
+    const bytes = toCount(props.artifact.sizeBytes);
+    const parts: string[] = [];
+    if (rows !== undefined) parts.push(`${rows.toLocaleString()} 行`);
+    if (bytes !== undefined && bytes > 0) parts.push(formatSize(bytes));
+    return parts.join(' · ');
+  });
+
+  function formatSize(bytes: number): string {
+    if (bytes < 1024) return `${bytes} B`;
+    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+    return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+  }
+
+  /** 预览/下载用的会话产物对象(消息归属 + 归一化数值字段) */
+  const sessionArtifact = computed(() => ({
+    ...props.artifact,
+    totalRows: toCount(props.artifact.totalRows),
+    sizeBytes: toCount(props.artifact.sizeBytes),
+    messageId: props.messageId,
+    toolName: props.toolName,
+  }));
+
+  function onPreview() {
+    openArtifactPreview(sessionArtifact.value);
+  }
+
+  async function onDownload() {
+    try {
+      await downloadArtifactFile({ id: props.artifact.id, fileName: props.artifact.fileName });
+      createMessage.success(`已开始下载 ${props.artifact.fileName}`);
+    } catch (err: any) {
+      createMessage.error(err?.message || '下载失败');
+    }
+  }
+</script>
+
+<style lang="less" scoped>
+  .ai-artifact-card {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    margin: 2px 0 var(--ai-sp-2);
+    padding: 7px 8px;
+    font-size: 12.5px;
+    color: var(--ai-text);
+    background: var(--ai-bg-subtle);
+    border: 1px solid var(--ai-border);
+    border-radius: 8px;
+
+    &__icon {
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      flex: 0 0 auto;
+      width: 26px;
+      height: 26px;
+      color: var(--ai-primary);
+      background: var(--ai-primary-soft);
+      border-radius: 6px;
+    }
+
+    &__name {
+      overflow: hidden;
+      min-width: 0;
+      font-weight: 600;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+
+    &__meta {
+      flex: 0 0 auto;
+      font-variant-numeric: tabular-nums;
+      color: var(--ai-text-2);
+    }
+
+    &__sp {
+      flex: 1 1 auto;
+    }
+
+    button {
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      flex: 0 0 auto;
+      width: 24px;
+      height: 24px;
+      padding: 0;
+      color: var(--ai-text-2);
+      background: transparent;
+      border: none;
+      border-radius: 6px;
+      cursor: pointer;
+
+      &:hover {
+        color: var(--ai-primary);
+        background: var(--ai-bg-page);
+      }
+    }
+  }
+</style>

+ 1 - 10
ai-electron/frontend/src/ai/components/ArtifactPanel.vue

@@ -59,9 +59,6 @@
               <button type="button" title="下载完整 CSV" @click="onDownload(a)">
                 <Icon icon="mdi:download-outline" :size="15" />
               </button>
-              <button type="button" title="定位到消息" @click="onLocate(a)">
-                <Icon icon="mdi:arrow-top-left-bold-outline" :size="15" />
-              </button>
             </span>
           </li>
         </ul>
@@ -81,7 +78,7 @@
    * 实时流与刷新恢复天然一致,也不会出现「面板状态和消息不一致」的同步问题。
    *
    * 交互对齐业内产物栏(Claude Artifacts / Manus):
-   * 可收起(右缘触发条 + 角标)、可拖宽、首次产出自动展开、点击可预览/下载/定位回消息。
+   * 可收起(右缘触发条 + 角标)、可拖宽、首次产出自动展开、点击可预览/下载。
    */
   import { computed, ref, watch } from 'vue';
   import Icon from '@/core/components/Icon/src/Icon.vue';
@@ -92,7 +89,6 @@
   import { toolLabel } from '../utils/constants';
 
   const props = defineProps<{ artifacts: SessionArtifact[] }>();
-  const emit = defineEmits<{ locate: [messageId: string] }>();
 
   const { createMessage } = useMessage();
 
@@ -157,11 +153,6 @@
     }
   }
 
-  function onLocate(a: SessionArtifact): void {
-    seenIds.add(a.id);
-    emit('locate', a.messageId);
-  }
-
   const rowsText = (a: SessionArtifact) => artifactRowsText(a);
   const sizeText = (a: SessionArtifact) => {
     const size = artifactSizeText(a);

+ 67 - 5
ai-electron/frontend/src/ai/components/ArtifactPreviewModal.vue

@@ -1,9 +1,17 @@
 <template>
-  <Modal
+  <!--
+    产物 CSV 预览弹窗(全站唯一实例,挂在智能工作台上)。
+    弹框形态与 AgentFormModal 等一致:BasicModal 右上角自带「全屏/还原 + 关闭」,
+    窗口态按视口限高、内部滚动;表格列宽按表头自适应(列名完整展示),列多时横向滚动。
+  -->
+  <BasicModal
     :open="!!preview"
     :title="preview?.fileName || '产物预览'"
-    width="78vw"
-    :footer="null"
+    :width="'78vw'"
+    :show-ok-btn="false"
+    :show-cancel-btn="false"
+    wrap-class-name="ai-artifact-preview-modal"
+    :body-style="bodyStyle"
     :mask-closable="true"
     @cancel="closeArtifactPreview"
   >
@@ -32,6 +40,7 @@
         <span>{{ error }}</span>
       </div>
 
+      <!-- 表格:列宽按表头自适应(列名完整展示),列多/列宽时横向滚动 -->
       <Table
         v-else
         class="ai-artifact-preview__grid"
@@ -44,7 +53,7 @@
         row-key="key"
       />
     </div>
-  </Modal>
+  </BasicModal>
 </template>
 
 <script lang="ts" setup name="ArtifactPreviewModal">
@@ -56,7 +65,8 @@
    * 总行数来自工具结果里的 artifact 元数据,接口不重复数行。
    */
   import { computed, ref, watch } from 'vue';
-  import { Modal, Table } from 'ant-design-vue';
+  import { Table } from 'ant-design-vue';
+  import { BasicModal } from '@/core/components/Modal';
   import Icon from '@/core/components/Icon/src/Icon.vue';
   import { useMessage } from '@/core/hooks/web/useMessage';
   import { downloadArtifactFile, fetchArtifactPreview } from '../api/artifactApi';
@@ -73,11 +83,34 @@
   const error = ref('');
   const downloading = ref(false);
 
+  /**
+   * 窗口态正文限高 + 内部滚动(兜底):内容一多会把弹框拉出屏幕 ——
+   * 与 AgentFormModal 同一策略;全屏态由全局样式放开限高。
+   */
+  const bodyStyle = computed(() => ({
+    maxHeight: 'calc(100vh - 220px)',
+    overflowY: 'auto' as const,
+  }));
+
+  /**
+   * 列宽按表头文字估宽(中英文混排),下限 90、上限 480:
+   * 列宽不小于表头宽度,列名才能完整展示;单元格超长仍截断(悬停看全文),
+   * 列多/列宽时靠表格横向滚动条看全,不硬挤弹框。
+   */
+  function headerWidth(name: string): number {
+    let w = 24;
+    for (const ch of name || '') {
+      w += /[⺀-鿿＀-￯]/.test(ch) ? 14 : 7.5;
+    }
+    return Math.round(Math.min(Math.max(w, 90), 480));
+  }
+
   const columns = computed(() =>
     (data.value?.columns || []).map((name) => ({
       title: name,
       dataIndex: name,
       key: name,
+      width: headerWidth(name),
       ellipsis: true,
     })),
   );
@@ -195,3 +228,32 @@
     }
   }
 </style>
+
+<style lang="less">
+  /*
+   * 全屏态补齐(弹框挂到 body 下,只能用全局样式),与 AgentFormModal 同一套:
+   * antd 弹框是 position:relative,BasicModal 的 fullscreen 规则里 top/bottom 拉不开高度,
+   * 内容矮于视口时底部会露一条遮罩 —— 显式补高度,正文区放开限高;
+   * 表格体在全屏下吃满剩余高度(覆盖 scroll.y 的内联 maxHeight)。
+   */
+  .ai-artifact-preview-modal.fullscreen-modal .ant-modal {
+    height: 100% !important;
+    margin: 0 !important;
+  }
+
+  .ai-artifact-preview-modal.fullscreen-modal .ant-modal-content {
+    display: flex;
+    flex-direction: column;
+  }
+
+  .ai-artifact-preview-modal.fullscreen-modal .ant-modal-body {
+    flex: 1 1 auto;
+    min-height: 0;
+    max-height: none !important;
+    overflow-y: auto !important;
+  }
+
+  .ai-artifact-preview-modal.fullscreen-modal .ant-table-body {
+    max-height: calc(100vh - 210px) !important;
+  }
+</style>

+ 14 - 1
ai-electron/frontend/src/ai/components/ProcessTimeline.vue

@@ -53,7 +53,12 @@
             </span>
           </button>
 
-          <div v-if="isStepOpen(item.key)" class="ai-trace__detail">
+          <!-- 有产物的工具:「输出」= 产物文件卡片,常显不折叠;行点击只增显「入参」 -->
+          <div v-if="artifactOf(item.step)" class="ai-trace__detail">
+            <ToolCallPanel :step="item.step" :message-id="messageId" :show-input="isStepOpen(item.key)" />
+          </div>
+
+          <div v-else-if="isStepOpen(item.key)" class="ai-trace__detail">
             <ToolCallPanel :step="item.step" :message-id="messageId" />
           </div>
         </template>
@@ -191,6 +196,14 @@
     return thinkTotal.value > 1 ? `深度思考 ${item.index + 1}` : '深度思考';
   }
 
+  /** 工具结果里是否带产物文件(决定「输出」是否常显产物卡片) */
+  function artifactOf(step: ToolStep): boolean {
+    if (!step?.result) return false;
+    const value = parseJsonPayload(step.result);
+    const meta = value && typeof value === 'object' && !Array.isArray(value) ? (value as any).artifact : null;
+    return !!(meta && typeof meta === 'object' && typeof meta.id === 'string' && meta.id);
+  }
+
   /* ---------------- 折叠状态 ---------------- */
 
   /**

+ 21 - 118
ai-electron/frontend/src/ai/components/ToolCallPanel.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="ai-tool-detail">
     <!-- 入参:execute_sql 只把 SQL 抽出来单独看;其余参数仍在「全部参数」里 -->
-    <div v-if="step.input" class="ai-tool-detail__block">
+    <div v-if="step.input && showInput" class="ai-tool-detail__block">
       <div class="ai-tool-detail__label">
         <span>入参</span>
         <span v-if="sqlText" class="ai-tool-detail__meta">SQL</span>
@@ -28,7 +28,7 @@
       <pre v-else class="ai-tool-detail__pre ai-scroll-area"><code>{{ pretty(step.input) }}</code></pre>
     </div>
 
-    <!-- 输出:表格类结果与「产物文件卡片」成对出现(原表格位置就是产物位置) -->
+    <!-- 输出:产物文件卡片就是全量输出(常显、不折叠),不再渲染数据表 -->
     <div v-if="step.result" class="ai-tool-detail__block">
       <div class="ai-tool-detail__label">
         <span>输出</span>
@@ -40,20 +40,12 @@
       </div>
 
       <!-- 产物文件卡片:全量 CSV,可预览前 200 行 / 下载完整文件 -->
-      <div v-if="artifact && !showRaw" class="ai-tool-detail__artifact">
-        <span class="ai-tool-detail__artifact-icon">
-          <Icon icon="mdi:file-delimited-outline" :size="15" />
-        </span>
-        <span class="ai-tool-detail__artifact-name" :title="artifact.fileName">{{ artifact.fileName }}</span>
-        <span v-if="artifactRowsText" class="ai-tool-detail__artifact-meta">{{ artifactRowsText }}</span>
-        <span class="ai-tool-detail__artifact-sp"></span>
-        <button type="button" title="预览前 200 行" @click="onPreview">
-          <Icon icon="mdi:eye-outline" :size="14" />
-        </button>
-        <button type="button" title="下载完整 CSV" @click="onDownload">
-          <Icon icon="mdi:download-outline" :size="14" />
-        </button>
-      </div>
+      <ArtifactFileCard
+        v-if="artifact && !showRaw"
+        :artifact="artifact"
+        :message-id="messageId"
+        :tool-name="step.toolName"
+      />
 
       <a-table
         v-else-if="table && !showRaw"
@@ -70,7 +62,7 @@
       <pre v-else class="ai-tool-detail__pre ai-scroll-area"><code>{{ pretty(step.result) }}</code></pre>
     </div>
 
-    <div v-if="!step.input && !step.result" class="ai-tool-detail__empty">暂无入参 / 输出记录</div>
+    <div v-if="!step.result && !(step.input && showInput)" class="ai-tool-detail__empty">暂无入参 / 输出记录</div>
   </div>
 </template>
 
@@ -96,12 +88,9 @@
   import { computed, ref } from 'vue';
   import { Table } from 'ant-design-vue';
   import type { TableColumnsType, TablePaginationConfig } from 'ant-design-vue';
-  import Icon from '@/core/components/Icon/src/Icon.vue';
-  import { useMessage } from '@/core/hooks/web/useMessage';
   import type { ToolStep } from '../api/types';
   import { parseJsonPayload } from '../utils/parseMessageContent';
-  import { downloadArtifactFile } from '../api/artifactApi';
-  import { openArtifactPreview } from '../utils/artifacts';
+  import ArtifactFileCard from './ArtifactFileCard.vue';
 
   const ATable = Table;
 
@@ -113,14 +102,14 @@
   const props = withDefaults(
     defineProps<{
       step: ToolStep;
-      /** 所属消息 ID(产物预览/定位用;时间线详情里可为空) */
+      /** 所属消息 ID(产物预览记录定位用;时间线详情里可为空) */
       messageId?: string;
+      /** 是否渲染「入参」块:有产物的工具「输出」常显,入参随行点击展开 */
+      showInput?: boolean;
     }>(),
-    { messageId: '' },
+    { messageId: '', showInput: true },
   );
 
-  const { createMessage } = useMessage();
-
   const showRaw = ref(false);
   const showInputRaw = ref(false);
   const sqlExpanded = ref(false);
@@ -144,41 +133,18 @@
 
   const sqlLineCount = computed(() => (sqlText.value ? sqlText.value.split('\n').length : 0));
 
-  /** 表格类工具的结果已自动导出为 CSV 产物,详情里只给一句指引,不再渲染数据表 */
-  const artifact = computed<any>(() => {
-    const value = payload.value;
-    return value && typeof value === 'object' && !Array.isArray(value) ? (value.artifact ?? null) : null;
-  });
-
-  const artifactRowsText = computed(() =>
-    typeof artifact.value?.totalRows === 'number' ? `${artifact.value.totalRows.toLocaleString()} 行` : '',
-  );
-
-  /** 与本步骤成对的产物对象(预览/下载用) */
-  const sessionArtifact = computed(() => {
-    if (!artifact.value) return null;
-    return { ...artifact.value, messageId: props.messageId, toolName: props.step.toolName };
-  });
-
-  function onPreview() {
-    if (sessionArtifact.value) openArtifactPreview(sessionArtifact.value);
-  }
-
-  async function onDownload() {
-    if (!sessionArtifact.value) return;
-    try {
-      await downloadArtifactFile(sessionArtifact.value);
-      createMessage.success(`已开始下载 ${sessionArtifact.value.fileName}`);
-    } catch (err: any) {
-      createMessage.error(err?.message || '下载失败');
-    }
-  }
-
+  /** 表格类工具的结果已自动导出为 CSV 产物(产物文件就是全量输出),详情里不再渲染数据表 */
   const table = computed<DetailTable | null>(() => {
     if (!props.step.result || payload.value === undefined || artifact.value) return null;
     return buildTable(payload.value);
   });
 
+  /** 结果里的产物元数据:有产物时输出以产物文件为准,不再渲染数据表 */
+  const artifact = computed<any>(() => {
+    const value = payload.value;
+    return value && typeof value === 'object' && !Array.isArray(value) ? (value.artifact ?? null) : null;
+  });
+
   function buildTable(value: any): DetailTable | null {
     // 历史消息里的旧表格载荷(columns+rows):resultId 已随翻页下线,渲染成原始 JSON 即可
     if (value && typeof value === 'object' && Array.isArray(value.columns) && Array.isArray(value.rows)) {
@@ -352,69 +318,6 @@
       font-size: 12px;
     }
 
-    /* 与工具结果成对的产物文件卡片(原表格位置) */
-    &__artifact {
-      display: flex;
-      align-items: center;
-      gap: 8px;
-      padding: 7px 8px;
-      font-size: 12.5px;
-      color: var(--ai-text);
-      background: var(--ai-bg-subtle);
-      border: 1px solid var(--ai-border);
-      border-radius: 8px;
-
-      &-icon {
-        display: inline-flex;
-        align-items: center;
-        justify-content: center;
-        flex: 0 0 auto;
-        width: 26px;
-        height: 26px;
-        color: var(--ai-primary);
-        background: var(--ai-primary-soft);
-        border-radius: 6px;
-      }
-
-      &-name {
-        overflow: hidden;
-        min-width: 0;
-        font-weight: 600;
-        white-space: nowrap;
-        text-overflow: ellipsis;
-      }
-
-      &-meta {
-        flex: 0 0 auto;
-        font-variant-numeric: tabular-nums;
-        color: var(--ai-text-2);
-      }
-
-      &-sp {
-        flex: 1 1 auto;
-      }
-
-      button {
-        display: inline-flex;
-        align-items: center;
-        justify-content: center;
-        flex: 0 0 auto;
-        width: 24px;
-        height: 24px;
-        padding: 0;
-        color: var(--ai-text-2);
-        background: transparent;
-        border: none;
-        border-radius: 6px;
-        cursor: pointer;
-
-        &:hover {
-          color: var(--ai-primary);
-          background: var(--ai-bg-page);
-        }
-      }
-    }
-
     &__empty {
       font-size: 11px;
       color: var(--ai-text-2);

+ 1 - 1
ai-electron/frontend/src/ai/utils/artifacts.ts

@@ -13,7 +13,7 @@ import { ref } from 'vue';
 import type { ArtifactMeta } from '../api/types';
 import type { ChatMessage } from '../store/chatStream';
 
-/** 会话内产物:元数据 + 来源消息(用于「定位到消息」) */
+/** 会话内产物:元数据 + 来源消息(预览记录归属用) */
 export interface SessionArtifact extends ArtifactMeta {
   /** 产生它的消息 ID */
   messageId: string;

+ 1 - 7
ai-electron/frontend/src/ai/views/aiWorkbench/index.vue

@@ -75,7 +75,7 @@
       </main>
 
       <!-- 右栏:本会话产物(工具结果导出的 CSV)。可收起/拖宽,首次产出自动展开 -->
-      <ArtifactPanel v-if="view === 'chat'" :artifacts="artifacts" @locate="locateMessage" />
+      <ArtifactPanel v-if="view === 'chat'" :artifacts="artifacts" />
     </div>
 
     <!-- 产物预览弹窗:全局唯一实例,消息卡片与产物栏共用 -->
@@ -178,12 +178,6 @@
     panelRef.value?.focus();
   }
 
-  /** 产物栏「定位到消息」:把产出它的消息滚到视口中间 */
-  function locateMessage(messageId: string) {
-    const el = document.getElementById(`ai-msg-${messageId}`);
-    el?.scrollIntoView({ block: 'center', behavior: 'smooth' });
-  }
-
   /**
    * 左栏会话列表在三个视图下都常驻,所以「选会话 / 新建会话」必须先把主区切回对话视图:
    * 否则在模型管理或插件页点会话,选中的会话在别处看不见,用户会以为点击没生效。

+ 8 - 0
ai-server/pom.xml

@@ -293,6 +293,14 @@
             <plugin>
                 <groupId>org.springframework.boot</groupId>
                 <artifactId>spring-boot-maven-plugin</artifactId>
+                <configuration>
+                    <!--
+                        运行期工作目录固定为仓库根:PathConst 以 user.dir 为根解析 QingJian/ 资产与
+                        ./data/files,默认的模块目录(ai-server/)会让产物/附件写进 ai-server/QingJian
+                        与既有数据分裂(历史产物预览 404)。见 QUICK_START §3.3/§5。
+                    -->
+                    <workingDirectory>${project.parent.basedir}</workingDirectory>
+                </configuration>
             </plugin>
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>

+ 3 - 2
ai-server/src/main/java/com/zsjz/ai/module/agent/artifact/ArtifactService.java

@@ -46,7 +46,8 @@ import java.util.stream.Stream;
  *
  * <p>产物元数据不建表:随 {@code agent_message.tool_events} 持久化,
  * 前端从消息的 toolSteps 里解析即可(实时与刷新恢复同一条路径)。
- * 文件按 {@code artifact.retain-days} 天清理,在下一次导出时顺带执行。
+ * 文件默认<b>永久保留</b>({@code artifact.retain-days} = 0,历史会话随时可预览/下载);
+ * 仅当运维显式配置 {@code >0} 时,超期文件才在下一次导出时顺带清理。
  */
 @Slf4j
 @Service
@@ -341,7 +342,7 @@ public class ArtifactService {
         return meta;
     }
 
-    /** 清理超期产物(保留天数 = artifact.retain-days),失败只记日志 */
+    /** 清理超期产物(保留天数 = artifact.retain-days;≤0 = 永久保留,直接跳过),失败只记日志 */
     private void cleanupExpired(Path dir) {
         int days = props.getRetainDays();
         if (days <= 0 || !Files.isDirectory(dir)) {

+ 5 - 2
ai-server/src/main/java/com/zsjz/ai/module/agent/config/ArtifactProperties.java

@@ -35,6 +35,9 @@ public class ArtifactProperties {
     /** 预览接口单次返回的最大行数 */
     private int previewLimitMax = 1_000;
 
-    /** 产物文件保留天数,超期文件在下一次导出时顺带清理 */
-    private int retainDays = 7;
+    /**
+     * 产物文件保留天数;{@code 0} = 永久保留(默认)——历史会话随时要能预览/下载产物,
+     * 文件不许过期。{@code >0} 时超期文件在下一次导出时顺带清理(仅运维特殊需要才设)。
+     */
+    private int retainDays = 0;
 }

+ 22 - 17
ai-server/src/main/java/com/zsjz/ai/module/agent/insight/service/impl/InsightServiceImpl.java

@@ -68,7 +68,7 @@ import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
-import java.util.concurrent.locks.ReentrantLock;
+import java.util.concurrent.Semaphore;
 
 /**
  * 对象关系分析 AI 研判服务实现。
@@ -129,12 +129,17 @@ public class InsightServiceImpl implements InsightService {
     private final TransactionTemplate transactionTemplate;
 
     /**
-     * 会话级互斥锁(第一道保险)。
+     * 会话级互斥闸门(第一道保险)。
      *
-     * <p>第二道是 DB 的 {@code status} 状态位:应用锁只能防本进程,
+     * <p>第二道是 DB 的 {@code status} 状态位:应用闸门只能防本进程,
      * 状态位保证「同一会话同时只有一个流」在多实例部署下也成立。
+     *
+     * <p>必须用 {@link Semaphore} 而不是 {@code ReentrantLock}:抢占发生在订阅线程(Tomcat 请求线程),
+     * 释放发生在 {@code doFinally}(Reactor 在上游事件线程回调),ReentrantLock 跨线程 unlock 会抛
+     * IllegalMonitorStateException,闸门永久不释放 —— 表现为上一轮分析明明已结束,
+     * 追问仍被误判成「该分析正在进行中」。
      */
-    private final ConcurrentHashMap<String, ReentrantLock> sessionLocks = new ConcurrentHashMap<>();
+    private final ConcurrentHashMap<String, Semaphore> sessionLocks = new ConcurrentHashMap<>();
 
     /**
      * createSession 的按案件互斥(P2-25):锁对象本身无状态,条目极少(每案件一个 Object),
@@ -245,15 +250,15 @@ public class InsightServiceImpl implements InsightService {
         // 消息由 fk_aim_session 的 ON DELETE CASCADE 连带清理
         sessionMapper.deleteById(session.getId());
         agentFactory.evict(session.getId());
-        // 锁清理只在「未被持有」时做:流式进行中删会话时,无条件 remove 会让新请求
-        // computeIfAbsent 出第二把锁,两把锁互不互斥(应用锁防线形同虚设)。
-        // 持有中的锁条目极小,宁可留在 map 里也不破坏互斥(内存影响可忽略)。
-        ReentrantLock held = sessionLocks.get(lockKey(session.getId()));
-        if (held != null && held.tryLock()) {
+        // 闸门清理只在「未被占用」时做:流式进行中删会话时,无条件 remove 会让新请求
+        // computeIfAbsent 出第二个闸门,两个闸门互不互斥(应用闸门防线形同虚设)。
+        // 占用中的条目极小,宁可留在 map 里也不破坏互斥(内存影响可忽略)。
+        Semaphore held = sessionLocks.get(lockKey(session.getId()));
+        if (held != null && held.tryAcquire()) {
             try {
                 sessionLocks.remove(lockKey(session.getId()));
             } finally {
-                held.unlock();
+                held.release();
             }
         }
         log.info("删除研判会话: id={}, caseId={}", id, caseId);
@@ -328,28 +333,28 @@ public class InsightServiceImpl implements InsightService {
         return Flux.defer(() -> {
             AgentInsightSession session = requireSession(dto.getSessionId(), dto.getCaseId());
             String key = lockKey(session.getId());
-            ReentrantLock lock = sessionLocks.computeIfAbsent(key, k -> new ReentrantLock());
-            boolean locked;
+            Semaphore gate = sessionLocks.computeIfAbsent(key, k -> new Semaphore(1));
+            boolean acquired;
             try {
-                locked = lock.tryLock(properties.getSessionLockTimeoutMs(), TimeUnit.MILLISECONDS);
+                acquired = gate.tryAcquire(properties.getSessionLockTimeoutMs(), TimeUnit.MILLISECONDS);
             } catch (InterruptedException e) {
                 Thread.currentThread().interrupt();
                 return Flux.just(sse("error", Map.of("type", "error", "error", "请求被中断,请重试")));
             }
-            if (!locked) {
+            if (!acquired) {
                 return Flux.just(sse("error", Map.of("type", "error", "error", "该分析正在进行中,请稍候")));
             }
             // 第二道保险:状态位抢占失败同样视为并发
             if (sessionMapper.acquireStreamLock(session.getId()) == 0) {
-                lock.unlock();
+                gate.release();
                 return Flux.just(sse("error", Map.of("type", "error", "error", "该分析正在进行中,请稍候")));
             }
             return doStream(session, dto)
                     .doFinally(signal -> {
-                        // 无论正常结束、异常还是被取消,都必须复位状态位与锁,
+                        // 无论正常结束、异常还是被取消,都必须复位状态位与闸门,
                         // 否则会话会永久卡在 STREAMING,后续追问全部 409
                         sessionMapper.releaseStreamLock(session.getId());
-                        lock.unlock();
+                        gate.release();
                     });
         }).onErrorResume(ex -> {
             String errorMsg = ex.getMessage() != null ? ex.getMessage() : "未知错误";

+ 6 - 1
ai-server/src/main/java/com/zsjz/ai/module/agent/python/PythonExecutor.java

@@ -342,13 +342,18 @@ public class PythonExecutor {
      * 若恰好排在最旧一侧会被误删,前端取图 404(P2-23)
      */
     private void evictOldExecs(Path pyOutRoot) {
+        int max = etlProperties.getPython().getMaxHistoryExecs();
+        if (max <= 0) {
+            // 0 = 永久保留:产物图要能在历史会话里长期回看,不做 LRU 清理
+            return;
+        }
         try (Stream<Path> dirs = Files.list(pyOutRoot)) {
             List<Path> execDirs = dirs
                     .filter(p -> !ACTIVE_EXEC_IDS.contains(p.getFileName().toString()))
                     .sorted(Comparator.comparingLong(
                             p -> p.toFile().lastModified()))
                     .toList();
-            int excess = execDirs.size() - etlProperties.getPython().getMaxHistoryExecs();
+            int excess = execDirs.size() - max;
             for (int i = 0; i < excess; i++) {
                 deleteRecursively(execDirs.get(i));
             }

+ 5 - 2
ai-server/src/main/java/com/zsjz/ai/module/agent/python/PythonProperties.java

@@ -25,8 +25,11 @@ public class PythonProperties {
     /** 单次执行最多捕获图片数 */
     private int maxImages = 4;
 
-    /** 图片输出目录保留最近执行数(LRU 清理) */
-    private int maxHistoryExecs = 20;
+    /**
+     * 图片输出目录保留最近执行数(LRU 清理);{@code 0} = 永久保留(默认)——
+     * Python 分析产出图要能在历史会话里长期回看,不许被 LRU 清掉。
+     */
+    private int maxHistoryExecs = 0;
 
     /**
      * 是否为每次执行导出一份案件库快照给 Python 连接。

+ 62 - 24
ai-server/src/main/java/com/zsjz/ai/module/agent/service/impl/AgentChatServiceImpl.java

@@ -44,7 +44,7 @@ import java.util.*;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
-import java.util.concurrent.locks.ReentrantLock;
+import java.util.concurrent.Semaphore;
 import java.util.stream.Collectors;
 
 /**
@@ -70,14 +70,19 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
     private static final String DEFAULT_TITLE = "新对话";
 
     /**
-     * 会话级流互斥锁:同一会话同一时刻只允许一条 AI 流(对照研判链路 {@code InsightServiceImpl.sessionLocks})。
+     * 会话级流互斥闸门:同一会话同一时刻只允许一条 AI 流(对照研判链路 {@code InsightServiceImpl.sessionLocks})。
      *
      * <p>双标签页/双击重试对同一会话并发发消息时,两条流会交错写同一 Agent 记忆槽
      * {@code ("default", sessionId)} 与同一工作区 transcript 文件,产生记忆污染。
-     * 非阻塞 tryLock:抢不到立即返回错误帧,不排队不阻塞请求线程。
+     * 非阻塞 tryAcquire:抢不到立即返回错误帧,不排队不阻塞请求线程。
      * 进程内闸门(单实例部署假设);多实例部署时需再补 DB 状态位。
+     *
+     * <p>必须用 {@link Semaphore} 而不是 {@code ReentrantLock}:抢占发生在订阅线程(Tomcat 请求线程),
+     * 释放发生在 {@code doFinally}(Reactor 在上游事件线程回调),ReentrantLock 跨线程 unlock 会抛
+     * IllegalMonitorStateException,闸门永久不释放 —— 表现为第一条对话结束后,
+     * 之后每条消息都误报「该会话正在回复中」。
      */
-    private final ConcurrentHashMap<Long, ReentrantLock> streamLocks = new ConcurrentHashMap<>();
+    private final ConcurrentHashMap<Long, Semaphore> streamLocks = new ConcurrentHashMap<>();
 
     private final AgentChatSessionMapper chatSessionMapper;
     private final AgentMessageMapper messageMapper;
@@ -301,14 +306,17 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
             return Flux.just(sse("error", Map.of("type", "error", "error", "消息内容不能为空")));
         }
         // defer:订阅时才解析会话/构建 agent,异常统一转 error 帧
-        return Flux.defer(() -> doStream(req))
+        // 过程累积器随请求创建并下传:出错时错误消息要把「深度思考 + 工具调用 + 产物元数据」一并落库,
+        // 否则出错轮次刷新后只剩一行错误提示,本轮跑过的思考/工具/产物全部消失
+        StreamProcess process = new StreamProcess();
+        return Flux.defer(() -> doStream(req, process))
                 .onErrorResume(ex -> {
                     String errorMsg = ex.getMessage() != null ? ex.getMessage() : "未知错误";
                     log.warn("Chat stream error: sessionKey={}, ", req.getSessionKey(), ex);
-                    // 错误信息持久化:确保刷新后仍可见
+                    // 错误信息持久化:确保刷新后仍可见(含本轮已产生的过程数据)
                     Long sessionId = parseSessionId(req.getSessionKey());
                     if (sessionId != null) {
-                        Mono.fromRunnable(() -> persistErrorMessage(sessionId, errorMsg))
+                        Mono.fromRunnable(() -> persistErrorMessage(sessionId, errorMsg, process))
                                 .subscribeOn(Schedulers.boundedElastic())
                                 .subscribe();
                     }
@@ -324,28 +332,28 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
      * 视为动态切换模型并回写会话。
      * 记忆槽位 = (运行时用户标识, sessionId),与 agent 实例/模型无关,切换模型后记忆自动延续。
      */
-    private Flux<ServerSentEvent<String>> doStream(ChatRequestDTO req) {
+    private Flux<ServerSentEvent<String>> doStream(ChatRequestDTO req, StreamProcess process) {
         Long sessionId = parseSessionId(req.getSessionKey());
         if (sessionId == null) {
             throw new ServerException(400, "缺少有效会话ID(sessionKey)");
         }
         // 会话级防并发双流:同一会话同时只允许一条 AI 流。
-        // 非阻塞抢锁,抢不到立即错误帧;锁在流终态(完成/异常/取消)由 doFinally 释放,
+        // 非阻塞抢闸门,抢不到立即错误帧;闸门在流终态(完成/异常/取消)由 doFinally 释放,
         // doStreamLocked 同步抛异常(404/400 等)时由 catch 释放
-        ReentrantLock lock = streamLocks.computeIfAbsent(sessionId, k -> new ReentrantLock());
-        if (!lock.tryLock()) {
+        Semaphore gate = streamLocks.computeIfAbsent(sessionId, k -> new Semaphore(1));
+        if (!gate.tryAcquire()) {
             return Flux.just(sse("error", Map.of("type", "error", "error", "该会话正在回复中,请等待当前回复完成后再发送")));
         }
         try {
-            return doStreamLocked(sessionId, req)
-                    .doFinally(signal -> lock.unlock());
+            return doStreamLocked(sessionId, req, process)
+                    .doFinally(signal -> gate.release());
         } catch (RuntimeException e) {
-            lock.unlock();
+            gate.release();
             throw e;
         }
     }
 
-    private Flux<ServerSentEvent<String>> doStreamLocked(Long sessionId, ChatRequestDTO req) {
+    private Flux<ServerSentEvent<String>> doStreamLocked(Long sessionId, ChatRequestDTO req, StreamProcess process) {
         AgentChatSession session = requireSessionInOpenCase(requireSession(sessionId));
 
         // 附件:归属校验 + 注入块(内联全文 / 表格 Python 直读指引 / RAG 片段)。
@@ -406,16 +414,17 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
         long startMs = System.currentTimeMillis();
         String sessionKey = String.valueOf(sessionId);
 
-        // 工具调用状态聚合(流内累积,随流结束释放)
-        Map<String, StringBuilder> toolInputs = new ConcurrentHashMap<>();
-        Map<String, StringBuilder> toolResults = new ConcurrentHashMap<>();
-        Map<String, String> toolNames = new ConcurrentHashMap<>();
+        // 工具调用状态聚合(流内累积,随流结束释放)—— 统一放在 StreamProcess 里,
+        // 出错时 persistErrorMessage 要用同一份数据落库,保住本轮的工具调用与产物元数据
+        Map<String, StringBuilder> toolInputs = process.toolInputs;
+        Map<String, StringBuilder> toolResults = process.toolResults;
+        Map<String, String> toolNames = process.toolNames;
 
         // 流中断追踪:用户主动中止时,若 AgentResultEvent 尚未到达,将已累积的文本保存
-        AtomicReference<StringBuilder> accumulatedText = new AtomicReference<>(new StringBuilder());
+        AtomicReference<StringBuilder> accumulatedText = process.text;
         // 模型思考过程累积(reasoning_content / thinking):随流结束后写入消息 metadata
-        AtomicReference<StringBuilder> accumulatedThinking = new AtomicReference<>(new StringBuilder());
-        AtomicBoolean resultPersisted = new AtomicBoolean(false);
+        AtomicReference<StringBuilder> accumulatedThinking = process.thinking;
+        AtomicBoolean resultPersisted = process.resultPersisted;
 
         return events.concatMap(event -> {
             List<ServerSentEvent<String>> frames = new ArrayList<>();
@@ -651,16 +660,28 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
 
     /**
      * 持久化错误信息为 assistant 消息(messageType=error),确保刷新后仍可见。
+     *
+     * <p>本轮已产生的「深度思考 + 工具调用(含产物元数据)」必须一并落库:
+     * 出错前的过程是真实发生过的,只存一行错误提示会让刷新后的时间线
+     * 「深度思考后调用工具」整段消失、产物文件也无从回溯(历史会话看不到)。
      */
-    private void persistErrorMessage(Long sessionId, String errorMsg) {
+    private void persistErrorMessage(Long sessionId, String errorMsg, StreamProcess process) {
         try {
+            // 完整回答已落库时(AgentResultEvent 先到、后置环节才出错)不再重复携带过程数据,
+            // 避免同轮的时间线/产物在历史里出现两份
+            boolean processLost = process != null && !process.resultPersisted.get();
+            String toolEventsJson = processLost
+                    ? buildToolEventsJson(process.toolInputs, process.toolResults, process.toolNames) : null;
+            String thinkingJson = processLost
+                    ? buildThinkingMetadata(process.thinking.get().toString()) : null;
             AgentMessage msg = new AgentMessage();
             msg.setSessionId(sessionId);
             msg.setRole("assistant");
             msg.setContent("⚠️ " + errorMsg);
             msg.setContentType("text");
             msg.setMessageType("error");
-            msg.setMetadata("{\"error\": true}");
+            msg.setMetadata(mergeThinkingMetadata("{\"error\": true}", thinkingJson));
+            msg.setToolEvents(toolEventsJson);
             msg.setStarred(false);
             msg.setCreateAt(LocalDateTime.now());
             messageMapper.insert(msg);
@@ -871,4 +892,21 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
         return ServerSentEvent.<String>builder().event(eventType).data(json).build();
     }
 
+    /**
+     * 一轮流式对话的过程累积器:工具调用聚合 + 正文/思考累积。
+     *
+     * <p>生命周期 = 一次 {@link #stream} 请求:正常结束随 assistant 消息落库(AgentResultEvent),
+     * 用户中止随 interrupted 消息落库,<b>中途出错也要随错误消息落库</b> ——
+     * 三处共用同一份数据,任何终态都不丢过程记录。条目极少、随请求释放,不额外清理。
+     */
+    private static final class StreamProcess {
+        final Map<String, StringBuilder> toolInputs = new ConcurrentHashMap<>();
+        final Map<String, StringBuilder> toolResults = new ConcurrentHashMap<>();
+        final Map<String, String> toolNames = new ConcurrentHashMap<>();
+        final AtomicReference<StringBuilder> text = new AtomicReference<>(new StringBuilder());
+        final AtomicReference<StringBuilder> thinking = new AtomicReference<>(new StringBuilder());
+        /** 完整回答是否已落库(落库后出错不再重复携带过程数据) */
+        final AtomicBoolean resultPersisted = new AtomicBoolean(false);
+    }
+
 }