feat: 支持 Agent 并发批测与洞察导出

This commit is contained in:
2026-08-13 17:25:22 +08:00
parent 3faecab6ac
commit f952d6dc58
22 changed files with 666 additions and 44 deletions

View File

@@ -15,6 +15,8 @@ const props = defineProps<{
const fileInput = ref<HTMLInputElement | null>(null);
const selectedFile = ref<File | null>(null);
const taskName = ref("");
const executionMode = ref<"sequential" | "concurrent">("sequential");
const concurrencyLimit = ref(5);
const creating = ref(false);
const loading = ref(false);
const exportingId = ref<number | null>(null);
@@ -81,7 +83,12 @@ async function createJob() {
if (!String(props.config.modelId || "")) return ElMessage.warning("请先在调试配置中选择模型");
creating.value = true;
try {
const created = await api.createAgentBatch(selectedFile.value, { ...props.config, name: taskName.value.trim() });
const created = await api.createAgentBatch(selectedFile.value, {
...props.config,
name: taskName.value.trim(),
executionMode: executionMode.value,
concurrencyLimit: executionMode.value === "sequential" ? 1 : concurrencyLimit.value,
});
ElMessage.success(`已创建批量测试任务,共 ${created.totalCount} 个问题`);
clearFile();
taskName.value = "";
@@ -154,6 +161,10 @@ function statusType(status: string) {
return "warning";
}
function executionLabel(job: AgentBatchJob) {
return job.executionMode === "concurrent" ? `并发 · ${job.concurrencyLimit}` : "逐条串行";
}
function formatDate(value?: string | null) {
return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "—";
}
@@ -170,7 +181,7 @@ function errorMessage(error: unknown, fallback: string) {
<template>
<div class="agent-batch-panel">
<div class="agent-section-intro">
<div><h3>Excel 批量测试</h3><p>使用当前编辑框中的提示词调试模型知识库和生成参数创建配置快照后台逐条生成答案</p></div>
<div><h3>Excel 批量测试</h3><p>普通管理员仅看到自己的任务超级管理员可统一审计创建时会固化当前提示词模型知识库和生成参数</p></div>
<el-button @click="downloadTemplate">下载导入模板</el-button>
</div>
@@ -180,6 +191,14 @@ function errorMessage(error: unknown, fallback: string) {
<span><small>知识库</small><strong>{{ knowledgeSummary }}</strong></span>
<span><small>单次上限</small><strong>200 个问题</strong></span>
</div>
<div class="agent-batch-mode-setting">
<div class="agent-batch-mode-copy"><strong>执行方式</strong><span>串行适合日常回归并发适合压测吞吐与稳定性并发数会被系统安全上限约束</span></div>
<el-radio-group v-model="executionMode" class="agent-batch-mode-options">
<el-radio-button value="sequential">逐条串行</el-radio-button>
<el-radio-button value="concurrent">并发测试</el-radio-button>
</el-radio-group>
<label v-if="executionMode === 'concurrent'" class="agent-batch-concurrency"><span>并发数</span><el-input-number v-model="concurrencyLimit" :min="2" :max="10" controls-position="right" /></label>
</div>
<div class="agent-batch-upload-row">
<label class="agent-batch-name"><span>任务名称</span><input v-model="taskName" maxlength="160" placeholder="例如8月主提示词回归测试" /></label>
<label class="agent-batch-file" :class="{ selected: selectedFile }">
@@ -197,6 +216,7 @@ function errorMessage(error: unknown, fallback: string) {
<el-table-column label="任务" min-width="190"><template #default="{ row }"><strong>{{ row.name }}</strong><small class="agent-batch-cell-note">#{{ row.id }} · {{ formatDate(row.createdAt) }}</small></template></el-table-column>
<el-table-column label="配置快照" min-width="180"><template #default="{ row }"><span>{{ row.modelName }}</span><small class="agent-batch-cell-note">{{ row.knowledgeNames.join('、') }}</small></template></el-table-column>
<el-table-column label="进度" min-width="190"><template #default="{ row }"><el-progress :percentage="row.progress" :status="row.status === 'completed' ? 'success' : undefined" /><small class="agent-batch-cell-note">{{ row.processedCount }}/{{ row.totalCount }} · 成功 {{ row.successCount }} · 失败 {{ row.failedCount }}</small></template></el-table-column>
<el-table-column label="执行方式" width="118"><template #default="{ row }"><el-tag type="info">{{ executionLabel(row) }}</el-tag></template></el-table-column>
<el-table-column label="状态" width="108"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)">查看</el-button><el-button link type="primary" :loading="exportingId === row.id" @click="exportJob(row)">导出</el-button><el-button v-if="['pending','running'].includes(row.status)" link type="danger" @click="cancelJob(row)">取消</el-button></template></el-table-column>
</el-table>
@@ -207,7 +227,7 @@ function errorMessage(error: unknown, fallback: string) {
<div v-loading="detailLoading" class="agent-batch-detail">
<template v-if="selectedJob">
<div class="agent-batch-detail-head">
<div><strong>{{ selectedJob.name }}</strong><span>{{ selectedJob.modelName }} · {{ selectedJob.knowledgeNames.join('、') }}</span></div>
<div><strong>{{ selectedJob.name }}</strong><span>{{ selectedJob.modelName }} · {{ selectedJob.knowledgeNames.join('、') }} · {{ executionLabel(selectedJob) }}</span></div>
<el-tag :type="statusType(selectedJob.status)">{{ statusLabel(selectedJob.status) }}</el-tag>
</div>
<el-table :data="selectedJob.items.items" stripe max-height="520">

View File

@@ -122,7 +122,7 @@ async function load() {
loading.value = true;
try {
const [promptResult, modelRows, knowledgeRows, formalConfig] = await Promise.all([
api.prompt(), api.models(), api.knowledgeOptions(), api.agentRuntimeConfig(),
api.prompt(), api.agentModelOptions(), api.agentKnowledgeOptions(), api.agentRuntimeConfig(),
]);
applyPrompt(promptResult);
models.value = modelRows;

View File

@@ -14,6 +14,7 @@ import ChatDetailDrawer from "./ChatDetailDrawer.vue";
type RecordTab = "chats" | "questionInsights" | "aiLogs" | "operationLogs";
const loading = ref(false);
const insightExporting = ref(false);
const activeTab = ref<RecordTab>("chats");
const chats = ref<ChatRecord[]>([]);
const ssoClients = ref<SsoClientItem[]>([]);
@@ -175,6 +176,32 @@ async function resetInsightFilters() {
});
await refreshInsights();
}
async function exportInsights() {
const dateFrom = formatDateTime(insightFilters.dateFrom, "start");
const dateTo = formatDateTime(insightFilters.dateTo, "end");
if (dateFrom && dateTo && new Date(dateFrom.replace(" ", "T")) > new Date(dateTo.replace(" ", "T"))) {
return ElMessage.warning("开始时间不能晚于结束时间");
}
insightExporting.value = true;
try {
const fromLabel = insightFilters.dateFrom.slice(0, 10).replaceAll("-", "") || "全部";
const toLabel = insightFilters.dateTo.slice(0, 10).replaceAll("-", "") || "全部";
await api.exportQuestionInsights(
{
dateFrom,
dateTo,
minCount: insightFilters.minCount,
maxMessages: insightFilters.maxMessages,
},
`问题洞察_${fromLabel}_${toLabel}.xlsx`,
);
ElMessage.success("问题洞察已导出");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "问题洞察导出失败");
} finally {
insightExporting.value = false;
}
}
function openChat(sessionId: number) {
detailSessionId.value = sessionId;
chatDetailOpen.value = true;
@@ -355,6 +382,7 @@ function formatMoney(value?: number | null, currency = "CNY") {
@click="refreshInsights"
>刷新并统计</el-button
><el-button @click="resetInsightFilters">重置</el-button>
<el-button :loading="insightExporting" @click="exportInsights">导出洞察</el-button>
</div>
</div>
<p class="question-insight-help">

View File

@@ -20,6 +20,8 @@ import {
ElOption,
ElPagination,
ElProgress,
ElRadioButton,
ElRadioGroup,
ElSelect,
ElSlider,
ElSwitch,
@@ -54,6 +56,8 @@ import "element-plus/theme-chalk/el-option.css";
import "element-plus/theme-chalk/el-overlay.css";
import "element-plus/theme-chalk/el-pagination.css";
import "element-plus/theme-chalk/el-progress.css";
import "element-plus/theme-chalk/el-radio-button.css";
import "element-plus/theme-chalk/el-radio-group.css";
import "element-plus/theme-chalk/el-popper.css";
import "element-plus/theme-chalk/el-scrollbar.css";
import "element-plus/theme-chalk/el-select.css";
@@ -95,6 +99,8 @@ app.config.globalProperties.$ELEMENT = { locale: zhCn };
ElOption,
ElPagination,
ElProgress,
ElRadioButton,
ElRadioGroup,
ElSelect,
ElSlider,
ElSwitch,
@@ -108,6 +114,9 @@ app.config.globalProperties.$ELEMENT = { locale: zhCn };
].forEach((component) => {
app.use(component);
});
// Element Plus 2.11 exposes radio group/button without an installer; register them explicitly.
app.component("ElRadioButton", ElRadioButton);
app.component("ElRadioGroup", ElRadioGroup);
app.directive("loading", vLoading);
app.mount("#app");

View File

@@ -261,6 +261,8 @@ export const api = {
agentRuntimeConfig: () => request<AgentRuntimeConfig>("/admin/agent/runtime-config"),
saveAgentRuntimeConfig: (payload: AgentGenerationConfig) =>
request<AgentRuntimeConfig>("/admin/agent/runtime-config", { method: "PUT", body: JSON.stringify(payload) }),
agentKnowledgeOptions: () => request<KnowledgeItem[]>("/admin/agent/knowledge-options"),
agentModelOptions: () => request<ModelItem[]>("/admin/agent/model-options"),
downloadAgentBatchTemplate: () => download("/admin/agent/batch/template", "Agent批量测试导入模板.xlsx"),
createAgentBatch: (file: File, config: Record<string, unknown>) => {
const formData = new FormData();
@@ -334,6 +336,8 @@ export const api = {
request<QuestionInsightRefreshResult>(`/admin/question-insights/refresh${queryString(query)}`, {
method: "POST",
}),
exportQuestionInsights: (query: { dateFrom?: string; dateTo?: string; minCount?: number; maxMessages?: number } = {}, filename = "问题洞察.xlsx") =>
download(`/admin/question-insights/export${queryString(query)}`, filename),
retrievalLogs: (query: { page?: number; pageSize?: number } = {}) => request<PageResult<RetrievalLogItem>>(`/admin/retrieval-log/list${queryString(query)}`),
estimateRetrievalCleanup: (before: string) => request<{ before: string; estimatedCount: number }>("/admin/retrieval-log/cleanup/estimate", { method: "POST", body: JSON.stringify({ before }) }),
cleanupRetrievalLogs: (before: string) => request<{ before: string; deleted: number }>("/admin/retrieval-log/cleanup", { method: "POST", body: JSON.stringify({ before }) }),

View File

@@ -1061,6 +1061,14 @@ textarea {
.agent-batch-config-summary small { margin-bottom: 4px; color: #7a8c85; font-size: 11px; }
.agent-batch-config-summary strong { overflow: hidden; color: #2b4038; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.agent-batch-upload-row { display: flex; align-items: flex-end; gap: 10px; flex-wrap: wrap; }
.agent-batch-mode-setting { display: flex; align-items: center; gap: 14px; padding: 12px; border: 1px solid #dfe8e5; border-radius: 9px; background: #fff; }
.agent-batch-mode-copy { display: grid; min-width: 240px; flex: 1; gap: 3px; }
.agent-batch-mode-copy strong { color: #2b4038; font-size: 13px; }
.agent-batch-mode-copy span { color: #71827c; font-size: 11px; line-height: 1.5; }
.agent-batch-mode-options { flex: none; }
.agent-batch-concurrency { display: flex; align-items: center; gap: 8px; color: #536860; font-size: 12px; }
.agent-batch-concurrency > span { white-space: nowrap; }
.agent-batch-concurrency .el-input-number { width: 112px; }
.agent-batch-name { display: grid; gap: 6px; width: 220px; color: #536860; font-size: 12px; }
.agent-batch-name input { height: 40px; padding: 0 12px; border: 1px solid #d8e2df; border-radius: 6px; outline: none; background: #fff; color: #263832; }
.agent-batch-name input:focus { border-color: #2f9479; box-shadow: 0 0 0 2px rgba(47, 148, 121, .12); }
@@ -2349,6 +2357,11 @@ textarea {
.agent-batch-config-summary { grid-template-columns: 1fr; }
.agent-batch-name, .agent-batch-file { width: 100%; min-width: 0; }
.agent-batch-mode-setting { align-items: stretch; flex-direction: column; }
.agent-batch-mode-copy { min-width: 0; }
.agent-batch-mode-options { display: flex; }
.agent-batch-mode-options .el-radio-button { flex: 1; }
.agent-batch-mode-options .el-radio-button__inner { width: 100%; }
.agent-batch-upload-row > .el-button { flex: 1; }
}

View File

@@ -404,6 +404,8 @@ export interface AgentBatchJob {
successCount: number;
failedCount: number;
progress: number;
executionMode: "sequential" | "concurrent";
concurrencyLimit: number;
modelId: number;
modelName: string;
knowledgeIds: number[];