feat(SynthDataDetail): add chunk/synthesis data management with edit/delete & UI enhancements (#139)

* feat(synthesis): add evaluation task creation functionality and UI enhancements

* feat(synthesis): implement synthesis data management features including loading, editing, and deleting

* feat(synthesis): add endpoints for deleting and updating synthesis data and chunks

* fix: Correctly extract file values from selectedFilesMap in AddDataDialog
This commit is contained in:
Dallas98
2025-12-09 09:59:40 +08:00
committed by GitHub
parent cf20299af4
commit 015e738a7f
6 changed files with 659 additions and 90 deletions

View File

@@ -1,8 +1,16 @@
import { useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router";
import { Badge, Button, Empty, List, Pagination, Spin, Typography } from "antd";
import { Badge, Button, Empty, List, Pagination, Spin, Typography, Popconfirm, message, Dropdown, Input } from "antd";
import type { PaginationProps } from "antd";
import { queryChunksByFileUsingGet, querySynthesisDataByChunkUsingGet, querySynthesisTaskByIdUsingGet } from "@/pages/SynthesisTask/synthesis-api";
import { MoreOutlined, EditOutlined, DeleteOutlined } from "@ant-design/icons";
import {
queryChunksByFileUsingGet,
querySynthesisDataByChunkUsingGet,
querySynthesisTaskByIdUsingGet,
deleteChunkWithDataUsingDelete,
batchDeleteSynthesisDataUsingDelete,
updateSynthesisDataUsingPatch,
} from "@/pages/SynthesisTask/synthesis-api";
import { formatDateTime } from "@/utils/unit";
interface LocationState {
@@ -107,6 +115,24 @@ export default function SynthDataDetail() {
fetchChunks(page, pageSize || 10);
};
// 删除当前选中的 Chunk 及其合成数据
const handleDeleteCurrentChunk = async () => {
if (!selectedChunkId) return;
try {
const res = await deleteChunkWithDataUsingDelete(selectedChunkId);
if (res?.data?.code === 200 || res?.code === 200) {
message.success("删除成功");
} else {
message.success("删除成功");
}
setSelectedChunkId(null);
fetchChunks(1, chunkPagination.size);
} catch (error) {
console.error("Failed to delete chunk", error);
message.error("删除失败,请稍后重试");
}
};
// 加载选中 chunk 的所有合成数据
const fetchSynthData = async (chunkId: string) => {
setDataLoading(true);
@@ -137,6 +163,80 @@ export default function SynthDataDetail() {
return Object.entries(data || {});
};
// 单条合成数据删除
const handleDeleteSingleSynthesisData = async (dataId: string) => {
try {
await batchDeleteSynthesisDataUsingDelete({ ids: [dataId] });
message.success("删除成功");
if (selectedChunkId) {
fetchSynthData(selectedChunkId);
}
} catch (error) {
console.error("Failed to delete synthesis data", error);
message.error("删除失败,请稍后重试");
}
};
// 编辑状态:仅编辑各个 key 的 value
const [editingId, setEditingId] = useState<string | null>(null);
const [editingMap, setEditingMap] = useState<Record<string, string>>({});
const startEdit = (item: SynthesisDataItem) => {
setEditingId(item.id);
const map: Record<string, string> = {};
Object.entries(item.data || {}).forEach(([k, v]) => {
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
map[k] = String(v);
} else {
map[k] = JSON.stringify(v);
}
});
setEditingMap(map);
};
const cancelEdit = () => {
setEditingId(null);
setEditingMap({});
};
const handleSaveEdit = async (item: SynthesisDataItem) => {
if (editingId !== item.id) return;
try {
const newData: Record<string, unknown> = { ...item.data };
Object.entries(editingMap).forEach(([k, v]) => {
const original = item.data?.[k];
if (typeof original === "object" && original !== null) {
try {
newData[k] = JSON.parse(v);
} catch {
newData[k] = v;
}
} else if (typeof original === "number") {
const n = Number(v);
newData[k] = Number.isNaN(n) ? v : n;
} else if (typeof original === "boolean") {
if (v === "true" || v === "false") {
newData[k] = v === "true";
} else {
newData[k] = v;
}
} else {
newData[k] = v;
}
});
await updateSynthesisDataUsingPatch(item.id, { data: newData });
message.success("保存成功");
cancelEdit();
if (selectedChunkId) {
fetchSynthData(selectedChunkId);
}
} catch (error) {
console.error("Failed to update synthesis data", error);
message.error("保存失败,请稍后重试");
}
};
return (
<div className="p-4 bg-white rounded-lg h-full flex flex-col overflow-hidden">
{/* 顶部信息和返回 */}
@@ -198,23 +298,63 @@ export default function SynthDataDetail() {
return (
<List.Item
className={
"cursor-pointer px-3 py-2 !border-0 " +
"px-3 py-2 !border-0 " +
(active ? "bg-blue-50" : "hover:bg-gray-50")
}
onClick={() => setSelectedChunkId(item.id)}
>
<div className="flex flex-col gap-1 w-full">
<div className="flex items-center justify-between text-xs">
<div
className="flex items-center justify-between text-xs cursor-pointer"
onClick={() => setSelectedChunkId(item.id)}
>
<span className="font-medium">Chunk #{item.chunk_index}</span>
<Badge
color={active ? "blue" : "default"}
text={active ? "当前" : ""}
/>
</div>
{/* 展示 chunk 全部内容,不截断 */}
<div className="text-xs text-gray-600 whitespace-pre-wrap break-words">
<div
className="text-xs text-gray-600 whitespace-pre-wrap break-words cursor-pointer"
onClick={() => setSelectedChunkId(item.id)}
>
{item.chunk_content}
</div>
<div className="flex justify-end mt-1">
<Dropdown
menu={{
items: [
{
key: "delete-chunk",
danger: true,
label: (
<Popconfirm
title="确认删除该 Chunk 及其合成数据?"
onConfirm={() => {
setSelectedChunkId(item.id);
handleDeleteCurrentChunk();
}}
okText="删除"
cancelText="取消"
>
<span className="flex items-center gap-1">
<DeleteOutlined />
Chunk
</span>
</Popconfirm>
),
},
],
}}
trigger={["click"]}
>
<Button
size="small"
type="text"
shape="circle"
icon={<MoreOutlined />}
/>
</Dropdown>
</div>
</div>
</List.Item>
);
@@ -256,38 +396,127 @@ export default function SynthDataDetail() {
<Empty description="该 Chunk 暂无合成数据" style={{ marginTop: 40 }} />
) : (
<div className="space-y-4">
{synthDataList.map((item, index) => (
<div
key={item.id || index}
className="border border-gray-100 rounded-md p-3 bg-white shadow-sm/50"
>
<div className="mb-2 text-xs text-gray-500 flex justify-between">
<span> {index + 1}</span>
<span>ID{item.id}</span>
</div>
{/* 淡化表格样式的 key-value 展示 */}
<div className="w-full border border-gray-100 rounded-md overflow-hidden">
{getDataEntries(item.data).map(([key, value], rowIdx) => (
<div
key={key + rowIdx}
className={
"grid grid-cols-[120px,1fr] text-xs " +
(rowIdx % 2 === 0 ? "bg-gray-50/60" : "bg-white")
}
{synthDataList.map((item, index) => {
const isEditing = editingId === item.id;
return (
<div
key={item.id || index}
className="border border-gray-100 rounded-md p-3 bg-white shadow-sm/50 relative"
>
<div className="mb-2 text-xs text-gray-500 flex justify-between">
<span> {index + 1}</span>
<span>ID{item.id}</span>
</div>
{/* 右下角更多操作按钮:编辑 & 删除 */}
<div className="absolute bottom-2 right-2 flex gap-1">
<Dropdown
menu={{
items: [
{
key: "edit-data",
label: (
<span className="flex items-center gap-1">
<EditOutlined />
</span>
),
onClick: (info) => {
info.domEvent.stopPropagation();
startEdit(item);
},
},
{
key: "delete-data",
danger: true,
label: (
<Popconfirm
title="确认删除该条合成数据?"
onConfirm={(e) => {
e?.stopPropagation();
handleDeleteSingleSynthesisData(item.id);
}}
okText="删除"
cancelText="取消"
>
<span className="flex items-center gap-1">
<DeleteOutlined />
</span>
</Popconfirm>
),
},
],
}}
trigger={["click"]}
>
<div className="px-3 py-2 border-r border-gray-100 font-medium text-gray-600 break-words">
{key}
</div>
<div className="px-3 py-2 text-gray-700 whitespace-pre-wrap break-words">
{typeof value === "string" || typeof value === "number"
<Button
size="small"
type="text"
shape="circle"
icon={<MoreOutlined />}
onClick={(e) => e.stopPropagation()}
/>
</Dropdown>
</div>
{/* 表格形式的 key-value 展示 + 可编辑 value */}
<div className="w-full border border-gray-100 rounded-md overflow-hidden mt-2">
{getDataEntries(item.data).map(([key, value], rowIdx) => {
const displayValue =
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
? String(value)
: JSON.stringify(value, null, 2)}
</div>
: JSON.stringify(value, null, 2);
return (
<div
key={key + rowIdx}
className={
"grid grid-cols-[120px,1fr] text-xs " +
(rowIdx % 2 === 0 ? "bg-gray-50/60" : "bg-white")
}
>
<div className="px-3 py-2 border-r border-gray-100 font-medium text-gray-600 break-words">
{key}
</div>
<div className="px-3 py-2 text-gray-700 whitespace-pre-wrap break-words">
{isEditing ? (
<Input.TextArea
value={editingMap[key] ?? displayValue}
onChange={(e) => {
const v = e.target.value;
setEditingMap((prev) => ({ ...prev, [key]: v }));
}}
autoSize={{ minRows: 1, maxRows: 4 }}
/>
) : (
displayValue
)}
</div>
</div>
);
})}
</div>
{isEditing && (
<div className="flex justify-end gap-2 mt-2">
<Button size="small" onClick={cancelEdit}>
</Button>
<Button
size="small"
type="primary"
onClick={() => handleSaveEdit(item)}
>
</Button>
</div>
))}
)}
</div>
</div>
))}
);
})}
</div>
)}
</div>

View File

@@ -1,15 +1,12 @@
import { useState, useEffect, ElementType } from "react";
import { Card, Button, Badge, Table, Modal, message, Tooltip } from "antd";
import { useState, useEffect, useCallback } from "react";
import { Card, Button, Table, Modal, message, Tooltip, Form, Input, Select } from "antd";
import {
Plus,
ArrowUp,
ArrowDown,
Pause,
Play,
CheckCircle,
Sparkles,
} from "lucide-react";
import { DeleteOutlined, EyeOutlined } from "@ant-design/icons";
import { FolderOpenOutlined, DeleteOutlined, EyeOutlined, ExperimentOutlined } from "@ant-design/icons";
import { Link, useNavigate } from "react-router";
import { SearchControls } from "@/components/SearchControls";
import { formatDateTime } from "@/utils/unit";
@@ -19,6 +16,9 @@ import {
archiveSynthesisTaskToDatasetUsingPost,
} from "@/pages/SynthesisTask/synthesis-api";
import { createDatasetUsingPost } from "@/pages/DataManagement/dataset.api";
import { createEvaluationTaskUsingPost } from "@/pages/DataEvaluation/evaluation.api";
import { queryModelListUsingGet } from "@/pages/SettingsPage/settings.apis";
import { ModelI } from "@/pages/SettingsPage/ModelAccess";
interface SynthesisTask {
id: string;
@@ -50,6 +50,11 @@ interface SynthesisTask {
updated_by?: string;
}
interface SynthesisDataItem {
id: string;
[key: string]: any;
}
export default function SynthesisTaskTab() {
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState("");
@@ -61,6 +66,18 @@ export default function SynthesisTaskTab() {
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [evalModalVisible, setEvalModalVisible] = useState(false);
const [currentEvalTask, setCurrentEvalTask] = useState<SynthesisTask | null>(null);
const [evalLoading, setEvalLoading] = useState(false);
const [models, setModels] = useState<ModelI[]>([]);
const [modelLoading, setModelLoading] = useState(false);
const [evalForm] = Form.useForm();
// 合成数据相关状态
const [activeChunkId, setActiveChunkId] = useState<string | null>(null);
const [synthesisData, setSynthesisData] = useState<SynthesisDataItem[]>([]);
const [selectedDataIds, setSelectedDataIds] = useState<string[]>([]);
// 获取任务列表
const loadTasks = async () => {
@@ -94,18 +111,6 @@ export default function SynthesisTaskTab() {
// eslint-disable-next-line
}, [searchQuery, filterStatus, page, pageSize]);
// 状态徽章
const getStatusBadge = (status: string) => {
const statusConfig: Record<string, { label: string; color: string; icon: ElementType }> = {
pending: { label: "等待中", color: "#F59E0B", icon: Pause },
running: { label: "运行中", color: "#3B82F6", icon: Play },
completed: { label: "已完成", color: "#10B981", icon: CheckCircle },
failed: { label: "失败", color: "#EF4444", icon: Pause },
paused: { label: "已暂停", color: "#E5E7EB", icon: Pause },
};
return statusConfig[status] ?? statusConfig["pending"];
};
// 类型映射
const typeMap: Record<string, string> = {
QA: "问答对生成",
@@ -176,19 +181,28 @@ export default function SynthesisTaskTab() {
key: "actions",
fixed: "right" as const,
render: (_: unknown, task: SynthesisTask) => (
<div className="flex items-center justify-center gap-1">
<div className="flex items-center justify-start gap-1">
<Tooltip title="查看详情">
<Button
onClick={() => navigate(`/data/synthesis/task/${task.id}`)}
className="hover:bg-blue-50 p-1 h-7 w-7"
className="hover:bg-blue-50 p-1 h-7 w-7 flex items-center justify-center"
type="text"
icon={<EyeOutlined />}
/>
</Tooltip>
<Tooltip title="归档到数据集">
<Tooltip title="立即评估">
<Button
type="text"
className="hover:bg-green-50 p-1 h-7 w-7"
className="hover:bg-purple-50 p-1 h-7 w-7 flex items-center justify-center text-purple-600"
icon={<ExperimentOutlined />}
onClick={() => openEvalModal(task)}
/>
</Tooltip>
<Tooltip title="留用合成数据到数据集">
<Button
type="text"
className="hover:bg-green-50 p-1 h-7 w-7 flex items-center justify-center text-green-600"
icon={<FolderOpenOutlined />}
onClick={() => {
Modal.confirm({
title: "确认归档该合成任务?",
@@ -198,15 +212,13 @@ export default function SynthesisTaskTab() {
onOk: () => handleArchiveTask(task),
});
}}
>
</Button>
/>
</Tooltip>
<Tooltip title="删除任务">
<Button
danger
type="text"
className="hover:bg-red-50 p-1 h-7 w-7"
className="hover:bg-red-50 p-1 h-7 w-7 flex items-center justify-center"
icon={<DeleteOutlined />}
onClick={() => {
Modal.confirm({
@@ -237,14 +249,21 @@ export default function SynthesisTaskTab() {
try {
// 1. 创建目标数据集(使用简单的默认命名 + 随机后缀,可后续扩展为弹窗自定义)
const randomSuffix = Math.random().toString(36).slice(2, 8);
const datasetReq = {
const datasetReq: {
name: string;
description: string;
datasetType: string;
category: string;
format: string;
status: string;
} = {
name: `${task.name}-合成数据留用${randomSuffix}`,
description: `由合成任务 ${task.id} 留用生成`,
datasetType: "TEXT",
category: "SYNTHESIS",
format: "JSONL",
status: "DRAFT",
} as any;
};
const datasetRes = await createDatasetUsingPost(datasetReq);
const datasetId = datasetRes?.data?.id;
if (!datasetId) {
@@ -264,6 +283,88 @@ export default function SynthesisTaskTab() {
}
};
const openEvalModal = (task: SynthesisTask) => {
setCurrentEvalTask(task);
setEvalModalVisible(true);
evalForm.setFieldsValue({
name: `${task.name}-数据评估`,
taskType: task.synthesis_type || "QA",
evalMethod: "AUTO",
});
// 懒加载模型列表
if (!models.length) {
loadModels();
}
};
const loadModels = async () => {
try {
setModelLoading(true);
const { data } = await queryModelListUsingGet({ page: 0, size: 1000 });
setModels(data?.content || []);
} catch (e) {
console.error(e);
message.error("获取模型列表失败");
} finally {
setModelLoading(false);
}
};
const chatModelOptions = models
.filter((m) => m.type === "CHAT")
.map((m) => ({
label: `${m.modelName} (${m.provider})`,
value: m.id,
}));
const handleCreateEvaluation = async () => {
if (!currentEvalTask) return;
try {
const values = await evalForm.validateFields();
setEvalLoading(true);
const taskType = currentEvalTask.synthesis_type || "QA";
const payload = {
name: values.name,
taskType,
evalMethod: values.evalMethod,
sourceType: "SYNTHESIS",
sourceId: currentEvalTask.id,
sourceName: currentEvalTask.name,
evalConfig: {
modelId: values.modelId,
dimensions: [
{
dimension: "问题是否独立",
description:
"仅分析问题,问题的主体和客体都比较明确,即使有省略,也符合语言习惯。在不需要补充其他信息的情况下不会引起疑惑。",
},
{
dimension: "语法是否错误",
description:
"问题为疑问句,答案为陈述句; 不存在词语搭配不当的情况;连接词和标点符号不存在错用情况;逻辑混乱的情况不存在;语法结构都正确且完整。",
},
{
dimension: "回答是否有针对性",
description:
"回答应对问题中的所有疑问点提供正面、直接的回答,不应引起疑惑。同时,答案不应有任何内容的遗漏,需构成一个完整的陈述。",
},
],
},
};
await createEvaluationTaskUsingPost(payload);
message.success("评估任务创建成功");
setEvalModalVisible(false);
setCurrentEvalTask(null);
evalForm.resetFields();
} catch (error) {
const err = error as { errorFields?: unknown; response?: { data?: { message?: string } } };
if (err?.errorFields) return; // 表单校验错误
message.error(err?.response?.data?.message || "评估任务创建失败");
} finally {
setEvalLoading(false);
}
};
return (
<div className="space-y-4">
{/* 搜索和筛选 */}
@@ -333,6 +434,85 @@ export default function SynthesisTaskTab() {
}}
/>
</Card>
<Modal
title="创建评估任务"
open={evalModalVisible}
onCancel={() => {
setEvalModalVisible(false);
setCurrentEvalTask(null);
evalForm.resetFields();
}}
onOk={handleCreateEvaluation}
confirmLoading={evalLoading}
okText="开始评估"
cancelText="取消"
>
<Form
form={evalForm}
layout="vertical"
initialValues={{
evalMethod: "AUTO",
}}
>
<Form.Item
label="评估任务名称"
name="name"
rules={[{ required: true, message: "请输入评估任务名称" }]}
>
<Input placeholder="例如:数据评估" />
</Form.Item>
<Form.Item
label="任务类型"
name="taskType"
>
<Select
disabled
options={[
{
label:
currentEvalTask?.synthesis_type === "COT"
? "COT评估"
: "QA评估",
value: currentEvalTask?.synthesis_type || "QA",
},
]}
/>
</Form.Item>
<Form.Item
label="评估方式"
name="evalMethod"
rules={[{ required: true, message: "请选择评估方式" }]}
>
<Select
options={[
{ label: "模型自动评估", value: "AUTO" },
]}
/>
</Form.Item>
<Form.Item
label="评估模型"
name="modelId"
rules={[{ required: true, message: "请选择评估模型" }]}
>
<Select
placeholder={modelLoading ? "加载模型中..." : "请选择用于评估的模型"}
loading={modelLoading}
options={chatModelOptions}
showSearch
optionFilterProp="label"
/>
</Form.Item>
{currentEvalTask && (
<Form.Item label="评估对象">
<div className="text-xs text-gray-500">
SYNTHESIS<br />
{currentEvalTask.name}
</div>
</Form.Item>
)}
</Form>
</Modal>
</div>
);
}

View File

@@ -18,7 +18,14 @@ export function querySynthesisTasksUsingGet(params: {
status?: string;
name?: string;
}) {
return get(`/api/synthesis/gen/tasks`, params);
const searchParams = new URLSearchParams();
if (params.page !== undefined) searchParams.append("page", String(params.page));
if (params.page_size !== undefined) searchParams.append("page_size", String(params.page_size));
if (params.synthesis_type) searchParams.append("synthesis_type", params.synthesis_type);
if (params.status) searchParams.append("status", params.status);
if (params.name) searchParams.append("name", params.name);
const qs = searchParams.toString();
return get(`/api/synthesis/gen/tasks${qs ? `?${qs}` : ""}`);
}
// 删除整个数据合成任务
@@ -28,12 +35,20 @@ export function deleteSynthesisTaskByIdUsingDelete(taskId: string) {
// 分页查询某个任务下的文件任务列表
export function querySynthesisFileTasksUsingGet(taskId: string, params: { page?: number; page_size?: number }) {
return get(`/api/synthesis/gen/task/${taskId}/files`, params);
const searchParams = new URLSearchParams();
if (params.page !== undefined) searchParams.append("page", String(params.page));
if (params.page_size !== undefined) searchParams.append("page_size", String(params.page_size));
const qs = searchParams.toString();
return get(`/api/synthesis/gen/task/${taskId}/files${qs ? `?${qs}` : ""}`);
}
// 根据文件任务 ID 分页查询 chunk 记录
export function queryChunksByFileUsingGet(fileId: string, params: { page?: number; page_size?: number }) {
return get(`/api/synthesis/gen/file/${fileId}/chunks`, params);
const searchParams = new URLSearchParams();
if (params.page !== undefined) searchParams.append("page", String(params.page));
if (params.page_size !== undefined) searchParams.append("page_size", String(params.page_size));
const qs = searchParams.toString();
return get(`/api/synthesis/gen/file/${fileId}/chunks${qs ? `?${qs}` : ""}`);
}
// 根据 chunk ID 查询所有合成结果数据
@@ -43,10 +58,35 @@ export function querySynthesisDataByChunkUsingGet(chunkId: string) {
// 获取不同合成类型对应的 Prompt
export function getPromptByTypeUsingGet(synthType: string) {
return get(`/api/synthesis/gen/prompt`, { synth_type: synthType });
const searchParams = new URLSearchParams();
searchParams.append("synth_type", synthType);
const qs = searchParams.toString();
return get(`/api/synthesis/gen/prompt${qs ? `?${qs}` : ""}`);
}
// 将合成任务数据归档到已存在的数据集中
export function archiveSynthesisTaskToDatasetUsingPost(taskId: string, datasetId: string) {
return post(`/api/synthesis/gen/task/${taskId}/export-dataset/${datasetId}`);
}
// ---------------- 数据记录级别:chunk 与 synthesis data ----------------
// 根据 chunkId 删除单个 chunk 及其下所有合成数据
export function deleteChunkWithDataUsingDelete(chunkId: string) {
return del(`/api/synthesis/gen/chunk/${chunkId}`);
}
// 删除某个 chunk 下的所有合成数据,返回删除条数
export function deleteSynthesisDataByChunkUsingDelete(chunkId: string) {
return del(`/api/synthesis/gen/chunk/${chunkId}/data`);
}
// 批量删除合成数据记录
export function batchDeleteSynthesisDataUsingDelete(body: { ids: string[] }) {
return del(`/api/synthesis/gen/data/batch`, null, { body: JSON.stringify(body) });
}
// 更新单条合成数据的完整 JSON 内容
export function updateSynthesisDataUsingPatch(dataId: string, body: { data: Record<string, unknown> }) {
return post(`/api/synthesis/gen/data/${dataId}`, body, { method: "PATCH" });
}