Parcourir la source

feat: CSV 产物文件名带具体工具名,聊天卡片显示工具中文标签

- ArtifactService 沿调用栈识别当前 @Tool 名称,文件名由「域标签_具体工具名_时间戳_随机.csv」组成;磁盘 id 仍保持 ASCII,下载/展示名同步带工具名。- execute_sql / query_table_data 这类域标识即工具名的场景不重复拼接。- 前端产物卡片增加工具中文名 chip(复用 TOOL_LABELS),历史产物也能看出是哪个工具输出的。- 新增 ArtifactServiceTest 用例,验证同域不同 @Tool 产出的 CSV 文件名可区分。
cc il y a 2 jours
Parent
commit
90e6730229

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

@@ -5,6 +5,7 @@
       <Icon icon="mdi:file-delimited-outline" :size="15" />
     </span>
     <span class="ai-artifact-card__name" :title="artifact.fileName">{{ artifact.fileName }}</span>
+    <span v-if="toolNameText" class="ai-artifact-card__tool" :title="props.toolName">{{ toolNameText }}</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">
@@ -33,6 +34,7 @@
   import type { ArtifactMeta } from '../api/types';
   import { downloadArtifactFile } from '../api/artifactApi';
   import { openArtifactPreview } from '../utils/artifacts';
+  import { toolLabel } from '../utils/constants';
 
   const props = withDefaults(
     defineProps<{
@@ -48,6 +50,9 @@
 
   const { createMessage } = useMessage();
 
+  /** 工具中文名:文件名里是具体 @Tool 名(英文),这里补一个可读中文标签 */
+  const toolNameText = computed(() => (props.toolName ? toolLabel(props.toolName) : ''));
+
   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))) {
@@ -133,6 +138,21 @@
       color: var(--ai-text-2);
     }
 
+    /* 工具中文名 chip:文件名负责区分具体工具,这里补可读名称 */
+    &__tool {
+      flex: 0 0 auto;
+      max-width: 140px;
+      overflow: hidden;
+      padding: 1px 6px;
+      font-size: 10.5px;
+      line-height: 16px;
+      color: var(--ai-primary);
+      white-space: nowrap;
+      text-overflow: ellipsis;
+      background: var(--ai-primary-soft);
+      border-radius: 4px;
+    }
+
     &__sp {
       flex: 1 1 auto;
     }

+ 69 - 12
ai-server/src/main/java/com/zsjz/ai/module/agent/artifact/ArtifactService.java

@@ -5,6 +5,7 @@ import com.zsjz.ai.common.context.CaseContextHolder;
 import com.zsjz.ai.common.exception.ServerException;
 import com.zsjz.ai.common.utils.FieldLabels;
 import com.zsjz.ai.module.agent.config.ArtifactProperties;
+import io.agentscope.core.tool.Tool;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.csv.CSVFormat;
@@ -15,6 +16,7 @@ import org.springframework.stereotype.Service;
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.Reader;
+import java.lang.reflect.Method;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -39,7 +41,7 @@ import java.util.stream.Stream;
  * 回给模型与前端的只有 {@code artifact} 元数据(id/行数/大小/列名)。
  *
  * <h3>目录与安全</h3>
- * 落盘位置 {@code QingJian/workspace/{caseId}/artifacts/{tool}_{ts}_{uuid8}.csv},
+ * 落盘位置 {@code QingJian/workspace/{caseId}/artifacts/{domain}_{tool}_{ts}_{uuid8}.csv},
  * 与 Python 沙箱产物同级({@code workspace/{caseId}/py-out});路径解析统一走
  * {@link #resolve(String)}:id 只允许安全字符,归一化后必须仍落在当前案件的
  * artifacts 根目录内,跨案件与 {@code ../} 穿越都按 404 处理
@@ -365,25 +367,80 @@ public class ArtifactService {
     private record Names(String id, String fileName) {
     }
 
-    /** 文件名 = {工具}_{时间戳}_{8位随机}.csv */
+    /** 文件名 = {工具域}_{具体工具名}_{时间戳}_{8位随机}.csv */
     private Names buildNames(String toolName) {
+        String domain = safeToolPart(toolName, "query");
+        String specific = currentToolName();
+        String specificSafe = safeToolPart(specific, null);
+        // execute_sql / query_table_data 这类工具:域标识本身就是具体工具名,避免重复
+        boolean appendSpecific = specificSafe != null && !specificSafe.equalsIgnoreCase(domain);
+
+        String uuid = UUID.randomUUID().toString().replace("-", "");
+        String suffix = "_" + LocalDateTime.now().format(ID_TS) + "_"
+                + uuid.substring(0, 8) + ".csv";
+        String idBase = appendSpecific ? domain + "_" + specificSafe : domain;
+        String displayBase = artifactLabel(toolName);
+        if (appendSpecific) {
+            displayBase = displayBase + "_" + specificSafe;
+        }
+        return new Names(idBase + suffix, displayBase + suffix);
+    }
+
+    /**
+     * 当前正在执行的 {@code @Tool} 名称。
+     *
+     * <p>CSV 是工具结果的一部分,文件名只带工具域(call_analysis / trans_analysis)时,
+     * 同一域下多个工具产出的文件无法区分。这里沿调用栈找最近的 {@code @Tool} 方法:
+     * {@code @Tool 方法 → ToolResultTable.store → ArtifactService.export → buildNames}。
+     * 找不到(例如单元测试直接调用 export)时返回 {@code null},文件名退回「域标识」口径。
+     */
+    private static String currentToolName() {
+        try {
+            return StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE)
+                    .walk(frames -> frames
+                            .limit(16)
+                            .map(ArtifactService::toolNameOf)
+                            .filter(name -> name != null && !name.isBlank())
+                            .findFirst()
+                            .orElse(null));
+        } catch (Throwable e) {
+            return null;
+        }
+    }
+
+    /** 从栈帧的「类 + 方法名」找到 @Tool 注解名;重载方法逐个匹配 */
+    private static String toolNameOf(StackWalker.StackFrame frame) {
+        try {
+            for (Method method : frame.getDeclaringClass().getDeclaredMethods()) {
+                if (!method.getName().equals(frame.getMethodName())) {
+                    continue;
+                }
+                Tool tool = method.getAnnotation(Tool.class);
+                if (tool != null) {
+                    return tool.name();
+                }
+            }
+        } catch (Throwable ignored) {
+            // 反射不可用/类不可见时退回域标识
+        }
+        return null;
+    }
+
+    /** 工具名清洗:只保留字母/数字/下划线/连字符,最长 40 字符;空时用 fallback */
+    private static String safeToolPart(String toolName, String fallback) {
+        if (toolName == null) {
+            return fallback;
+        }
         StringBuilder safe = new StringBuilder();
-        String name = toolName == null ? "query" : toolName.trim().toLowerCase(Locale.ROOT);
-        for (int i = 0; i < name.length() && safe.length() < 40; i++) {
-            char c = name.charAt(i);
+        for (int i = 0; i < toolName.length() && safe.length() < 40; i++) {
+            char c = toolName.charAt(i);
             boolean ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
                     || (c >= '0' && c <= '9') || c == '_' || c == '-';
             if (ok) {
                 safe.append(c);
             }
         }
-        if (safe.isEmpty()) {
-            safe.append("query");
-        }
-        String uuid = UUID.randomUUID().toString().replace("-", "");
-        String suffix = "_" + LocalDateTime.now().format(ID_TS) + "_"
-                + uuid.substring(0, 8) + ".csv";
-        return new Names(safe + suffix, artifactLabel(toolName) + suffix);
+        return safe.isEmpty() ? fallback : safe.toString();
     }
 
     /**

+ 31 - 0
ai-server/src/test/java/com/zsjz/ai/module/agent/artifact/ArtifactServiceTest.java

@@ -5,6 +5,7 @@ import com.zsjz.ai.common.constants.PathConst;
 import com.zsjz.ai.common.context.CaseContextHolder;
 import com.zsjz.ai.common.exception.ServerException;
 import com.zsjz.ai.module.agent.config.ArtifactProperties;
+import io.agentscope.core.tool.Tool;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
@@ -142,6 +143,36 @@ class ArtifactServiceTest {
         assertEquals(List.of("本方姓名", "通话时长", "unknownFoo"), preview.get("columns"));
     }
 
+    @Test
+    @DisplayName("文件名带具体 @Tool 名称:同域多个工具的 CSV 可以区分")
+    void fileNameIncludesSpecificToolName() {
+        Map<String, Object> meta = inCase(() -> new FakeArtifactTool(service).run());
+
+        assertNotNull(meta);
+        String id = (String) meta.get("id");
+        assertTrue(id.matches("call_analysis_stat_call_night_detail_\\d{14}_[0-9a-f]{8}\\.csv"),
+                "磁盘 id 应带具体工具名且保持 ASCII: " + id);
+        String fileName = (String) meta.get("fileName");
+        assertTrue(fileName.startsWith("通话分析_stat_call_night_detail_"),
+                "展示/下载名应带具体工具名: " + fileName);
+        track(id);
+    }
+
+    /** 伪造一个 @Tool 方法,验证 ArtifactService 能从调用栈识别具体工具名 */
+    static class FakeArtifactTool {
+
+        private final ArtifactService service;
+
+        FakeArtifactTool(ArtifactService service) {
+            this.service = service;
+        }
+
+        @Tool(name = "stat_call_night_detail", description = "test artifact naming")
+        public Map<String, Object> run() {
+            return service.export("call_analysis", List.of(col("姓名")), List.of(row("张三", null)));
+        }
+    }
+
     @Test
     @DisplayName("老产物(英文表头)预览时现场翻成中文,行键同步换、文件本身不动")
     @SuppressWarnings("unchecked")