472 lines
13 KiB
JavaScript
472 lines
13 KiB
JavaScript
|
|
'use client';
|
|||
|
|
|
|||
|
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|||
|
|
import { useRouter } from 'next/navigation';
|
|||
|
|
import { useAtomValue } from 'jotai/index';
|
|||
|
|
import { selectedModelInfoAtom } from '@/lib/store';
|
|||
|
|
import axios from 'axios';
|
|||
|
|
import { toast } from 'sonner';
|
|||
|
|
import i18n from '@/lib/i18n';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 数据集详情页面业务逻辑 Hook
|
|||
|
|
*/
|
|||
|
|
export default function useDatasetDetails(projectId, datasetId) {
|
|||
|
|
const router = useRouter();
|
|||
|
|
const [datasets, setDatasets] = useState([]);
|
|||
|
|
const [currentDataset, setCurrentDataset] = useState(null);
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
const [editingAnswer, setEditingAnswer] = useState(false);
|
|||
|
|
const [editingCot, setEditingCot] = useState(false);
|
|||
|
|
const [editingQuestion, setEditingQuestion] = useState(false);
|
|||
|
|
const [answerValue, setAnswerValue] = useState('');
|
|||
|
|
const [cotValue, setCotValue] = useState('');
|
|||
|
|
const [questionValue, setQuestionValue] = useState('');
|
|||
|
|
const [snackbar, setSnackbar] = useState({
|
|||
|
|
open: false,
|
|||
|
|
message: '',
|
|||
|
|
severity: 'success'
|
|||
|
|
});
|
|||
|
|
const [confirming, setConfirming] = useState(false);
|
|||
|
|
const [unconfirming, setUnconfirming] = useState(false);
|
|||
|
|
const [optimizeDialog, setOptimizeDialog] = useState({
|
|||
|
|
open: false,
|
|||
|
|
loading: false
|
|||
|
|
});
|
|||
|
|
const [viewDialogOpen, setViewDialogOpen] = useState(false);
|
|||
|
|
const [viewChunk, setViewChunk] = useState(null);
|
|||
|
|
const [datasetsAllCount, setDatasetsAllCount] = useState(0);
|
|||
|
|
const [datasetsConfirmCount, setDatasetsConfirmCount] = useState(0);
|
|||
|
|
const [answerTokens, setAnswerTokens] = useState(0);
|
|||
|
|
const [cotTokens, setCotTokens] = useState(0);
|
|||
|
|
const model = useAtomValue(selectedModelInfoAtom);
|
|||
|
|
const [shortcutsEnabled, setShortcutsEnabled] = useState(() => {
|
|||
|
|
const storedValue = localStorage.getItem('shortcutsEnabled');
|
|||
|
|
return storedValue !== null ? storedValue === 'true' : false;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 输入环境判断,避免在输入框/可编辑区域误触快捷键
|
|||
|
|
const isEditableTarget = el => {
|
|||
|
|
if (!el) return false;
|
|||
|
|
const tag = el.tagName?.toLowerCase();
|
|||
|
|
if (tag && ['input', 'textarea', 'select'].includes(tag)) return true;
|
|||
|
|
if (el.isContentEditable) return true;
|
|||
|
|
// 兼容嵌套的可编辑区域与常见富文本编辑器
|
|||
|
|
return !!el.closest?.('[contenteditable="true"], .ProseMirror, .ql-editor');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 简单节流,避免连续触发
|
|||
|
|
const lastShortcutRef = useRef(0);
|
|||
|
|
|
|||
|
|
// 异步获取Token数量
|
|||
|
|
const fetchTokenCount = async () => {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/projects/${projectId}/datasets/${datasetId}/token-count`);
|
|||
|
|
if (response.ok) {
|
|||
|
|
const data = await response.json();
|
|||
|
|
if (data.answerTokens !== undefined) {
|
|||
|
|
setAnswerTokens(data.answerTokens);
|
|||
|
|
}
|
|||
|
|
if (data.cotTokens !== undefined) {
|
|||
|
|
setCotTokens(data.cotTokens);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('获取Token数量失败:', error);
|
|||
|
|
// Token加载失败不阻塞主界面或显示错误提示
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 获取数据集详情
|
|||
|
|
const fetchDatasets = async () => {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/projects/${projectId}/datasets/${datasetId}`);
|
|||
|
|
if (!response.ok) throw new Error('获取数据集详情失败');
|
|||
|
|
const data = await response.json();
|
|||
|
|
setCurrentDataset(data.datasets);
|
|||
|
|
setCotValue(data.datasets?.cot);
|
|||
|
|
setAnswerValue(data.datasets?.answer);
|
|||
|
|
setQuestionValue(data.datasets?.question);
|
|||
|
|
setDatasetsAllCount(data.total);
|
|||
|
|
setDatasetsConfirmCount(data.confirmedCount);
|
|||
|
|
|
|||
|
|
// 数据加载完成后,异步获取Token数量
|
|||
|
|
fetchTokenCount();
|
|||
|
|
} catch (error) {
|
|||
|
|
setSnackbar({
|
|||
|
|
open: true,
|
|||
|
|
message: error.message,
|
|||
|
|
severity: 'error'
|
|||
|
|
});
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 确认并保存数据集
|
|||
|
|
const handleConfirm = async () => {
|
|||
|
|
try {
|
|||
|
|
setConfirming(true);
|
|||
|
|
const response = await fetch(`/api/projects/${projectId}/datasets?id=${datasetId}`, {
|
|||
|
|
method: 'PATCH',
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/json'
|
|||
|
|
},
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
confirmed: true
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
throw new Error('操作失败');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setCurrentDataset(prev => ({ ...prev, confirmed: true }));
|
|||
|
|
|
|||
|
|
setSnackbar({
|
|||
|
|
open: true,
|
|||
|
|
message: '操作成功',
|
|||
|
|
severity: 'success'
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 导航到下一个数据集
|
|||
|
|
handleNavigate('next');
|
|||
|
|
} catch (error) {
|
|||
|
|
setSnackbar({
|
|||
|
|
open: true,
|
|||
|
|
message: error.message || '操作失败',
|
|||
|
|
severity: 'error'
|
|||
|
|
});
|
|||
|
|
} finally {
|
|||
|
|
setConfirming(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 取消确认数据集
|
|||
|
|
const handleUnconfirm = async () => {
|
|||
|
|
try {
|
|||
|
|
setUnconfirming(true);
|
|||
|
|
const response = await fetch(`/api/projects/${projectId}/datasets?id=${datasetId}`, {
|
|||
|
|
method: 'PATCH',
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/json'
|
|||
|
|
},
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
confirmed: false
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
throw new Error('操作失败');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setCurrentDataset(prev => ({ ...prev, confirmed: false }));
|
|||
|
|
|
|||
|
|
setSnackbar({
|
|||
|
|
open: true,
|
|||
|
|
message: '已取消确认',
|
|||
|
|
severity: 'success'
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
setSnackbar({
|
|||
|
|
open: true,
|
|||
|
|
message: error.message || '取消确认失败',
|
|||
|
|
severity: 'error'
|
|||
|
|
});
|
|||
|
|
} finally {
|
|||
|
|
setUnconfirming(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 导航到其他数据集
|
|||
|
|
const handleNavigate = async direction => {
|
|||
|
|
const response = await axios.get(`/api/projects/${projectId}/datasets/${datasetId}?operateType=${direction}`);
|
|||
|
|
if (response.data) {
|
|||
|
|
router.push(`/projects/${projectId}/datasets/${response.data.id}`);
|
|||
|
|
} else {
|
|||
|
|
toast.warning(`已经是${direction === 'next' ? '最后' : '第'}一条数据了`);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 保存编辑
|
|||
|
|
const handleSave = async (field, value) => {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/projects/${projectId}/datasets?id=${datasetId}`, {
|
|||
|
|
method: 'PATCH',
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/json'
|
|||
|
|
},
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
[field]: value
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
throw new Error('保存失败');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const data = await response.json();
|
|||
|
|
setCurrentDataset(prev => ({ ...prev, [field]: value }));
|
|||
|
|
|
|||
|
|
setSnackbar({
|
|||
|
|
open: true,
|
|||
|
|
message: '保存成功',
|
|||
|
|
severity: 'success'
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 重置编辑状态
|
|||
|
|
if (field === 'answer') setEditingAnswer(false);
|
|||
|
|
if (field === 'cot') setEditingCot(false);
|
|||
|
|
if (field === 'question') setEditingQuestion(false);
|
|||
|
|
} catch (error) {
|
|||
|
|
setSnackbar({
|
|||
|
|
open: true,
|
|||
|
|
message: error.message || '保存失败',
|
|||
|
|
severity: 'error'
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 删除数据集
|
|||
|
|
const handleDelete = async () => {
|
|||
|
|
if (!confirm('确定要删除这条数据吗?此操作不可撤销。')) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// 尝试获取下一个数据集,在删除前先确保有可导航的目标
|
|||
|
|
const nextResponse = await axios.get(`/api/projects/${projectId}/datasets/${datasetId}?operateType=next`);
|
|||
|
|
const hasNextDataset = !!nextResponse.data;
|
|||
|
|
const nextDatasetId = hasNextDataset ? nextResponse.data.id : null;
|
|||
|
|
|
|||
|
|
// 删除当前数据集
|
|||
|
|
const deleteResponse = await fetch(`/api/projects/${projectId}/datasets?id=${datasetId}`, {
|
|||
|
|
method: 'DELETE'
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!deleteResponse.ok) {
|
|||
|
|
throw new Error('删除失败');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 导航逻辑:有下一个就跳转下一个,没有则返回列表页
|
|||
|
|
if (hasNextDataset) {
|
|||
|
|
router.push(`/projects/${projectId}/datasets/${nextDatasetId}`);
|
|||
|
|
} else {
|
|||
|
|
// 没有更多数据集,返回列表页面
|
|||
|
|
router.push(`/projects/${projectId}/datasets`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
toast.success('删除成功');
|
|||
|
|
} catch (error) {
|
|||
|
|
setSnackbar({
|
|||
|
|
open: true,
|
|||
|
|
message: error.message || '删除失败',
|
|||
|
|
severity: 'error'
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 优化对话框相关操作
|
|||
|
|
const handleOpenOptimizeDialog = () => {
|
|||
|
|
setOptimizeDialog({
|
|||
|
|
open: true,
|
|||
|
|
loading: false
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleCloseOptimizeDialog = () => {
|
|||
|
|
setOptimizeDialog(prev => {
|
|||
|
|
// 如果正在优化,不允许关闭
|
|||
|
|
if (prev.loading) {
|
|||
|
|
return prev;
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
open: false,
|
|||
|
|
loading: false
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 优化操作
|
|||
|
|
const handleOptimize = async advice => {
|
|||
|
|
if (!model) {
|
|||
|
|
setSnackbar({
|
|||
|
|
open: true,
|
|||
|
|
message: '请先选择模型,可以在顶部导航栏选择',
|
|||
|
|
severity: 'error'
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 立即关闭对话框,并设置优化中状态
|
|||
|
|
setOptimizeDialog(prev => {
|
|||
|
|
const newState = {
|
|||
|
|
open: false,
|
|||
|
|
loading: true
|
|||
|
|
};
|
|||
|
|
return newState;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
toast.info('已开始优化,请稍候...');
|
|||
|
|
|
|||
|
|
// 异步后台处理,不等待结果
|
|||
|
|
(async () => {
|
|||
|
|
try {
|
|||
|
|
const language = i18n.language === 'zh-CN' ? '中文' : 'en';
|
|||
|
|
const response = await fetch(`/api/projects/${projectId}/datasets/optimize`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/json'
|
|||
|
|
},
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
datasetId,
|
|||
|
|
model,
|
|||
|
|
advice,
|
|||
|
|
language
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
const errorData = await response.json();
|
|||
|
|
throw new Error(errorData.error || '优化失败');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 优化成功后,重新查询数据以获取最新状态
|
|||
|
|
await fetchDatasets();
|
|||
|
|
// 优化可能改变了文本内容,重新获取Token计数
|
|||
|
|
fetchTokenCount();
|
|||
|
|
|
|||
|
|
toast.success('AI智能优化成功');
|
|||
|
|
} catch (error) {
|
|||
|
|
toast.error(error.message);
|
|||
|
|
} finally {
|
|||
|
|
setOptimizeDialog({
|
|||
|
|
open: false,
|
|||
|
|
loading: false
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
})();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 查看文本块详情
|
|||
|
|
const handleViewChunk = async chunkContent => {
|
|||
|
|
try {
|
|||
|
|
setViewChunk(chunkContent);
|
|||
|
|
setViewDialogOpen(true);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('查看文本块出错', error);
|
|||
|
|
setSnackbar({
|
|||
|
|
open: true,
|
|||
|
|
message: error.message,
|
|||
|
|
severity: 'error'
|
|||
|
|
});
|
|||
|
|
setViewDialogOpen(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 关闭文本块详情对话框
|
|||
|
|
const handleCloseViewDialog = () => {
|
|||
|
|
setViewDialogOpen(false);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 初始化和快捷键事件
|
|||
|
|
useEffect(() => {
|
|||
|
|
fetchDatasets();
|
|||
|
|
}, [projectId, datasetId]);
|
|||
|
|
|
|||
|
|
// 快捷键状态变化
|
|||
|
|
useEffect(() => {
|
|||
|
|
localStorage.setItem('shortcutsEnabled', shortcutsEnabled);
|
|||
|
|
}, [shortcutsEnabled]);
|
|||
|
|
|
|||
|
|
// 监听键盘事件
|
|||
|
|
useEffect(() => {
|
|||
|
|
const handleKeyDown = event => {
|
|||
|
|
if (!shortcutsEnabled) return;
|
|||
|
|
|
|||
|
|
// 在输入框或可编辑区域时不触发
|
|||
|
|
const activeEl = typeof document !== 'undefined' ? document.activeElement : null;
|
|||
|
|
if (isEditableTarget(event.target) || isEditableTarget(activeEl)) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 仅要求 Shift 修饰键,降低误触且更简单
|
|||
|
|
if (!event.shiftKey) return;
|
|||
|
|
|
|||
|
|
// 简单节流,过滤极短时间内重复触发
|
|||
|
|
const now = Date.now();
|
|||
|
|
if (now - (lastShortcutRef.current || 0) < 250) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
lastShortcutRef.current = now;
|
|||
|
|
|
|||
|
|
switch (event.key) {
|
|||
|
|
case 'ArrowLeft': // 上一个(Shift + ArrowLeft)
|
|||
|
|
event.preventDefault();
|
|||
|
|
handleNavigate('prev');
|
|||
|
|
break;
|
|||
|
|
case 'ArrowRight': // 下一个(Shift + ArrowRight)
|
|||
|
|
event.preventDefault();
|
|||
|
|
handleNavigate('next');
|
|||
|
|
break;
|
|||
|
|
case 'y': // 确认(Shift + Y)
|
|||
|
|
case 'Y':
|
|||
|
|
if (!confirming && currentDataset && !currentDataset.confirmed) {
|
|||
|
|
event.preventDefault();
|
|||
|
|
handleConfirm();
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
case 'd': // 删除(Shift + D)
|
|||
|
|
case 'D':
|
|||
|
|
event.preventDefault();
|
|||
|
|
handleDelete();
|
|||
|
|
break;
|
|||
|
|
default:
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
window.addEventListener('keydown', handleKeyDown);
|
|||
|
|
return () => {
|
|||
|
|
window.removeEventListener('keydown', handleKeyDown);
|
|||
|
|
};
|
|||
|
|
}, [shortcutsEnabled, confirming, currentDataset]);
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
loading,
|
|||
|
|
currentDataset,
|
|||
|
|
answerValue,
|
|||
|
|
cotValue,
|
|||
|
|
questionValue,
|
|||
|
|
editingAnswer,
|
|||
|
|
editingCot,
|
|||
|
|
editingQuestion,
|
|||
|
|
confirming,
|
|||
|
|
unconfirming,
|
|||
|
|
snackbar,
|
|||
|
|
optimizeDialog,
|
|||
|
|
viewDialogOpen,
|
|||
|
|
viewChunk,
|
|||
|
|
datasetsAllCount,
|
|||
|
|
datasetsConfirmCount,
|
|||
|
|
answerTokens,
|
|||
|
|
cotTokens,
|
|||
|
|
shortcutsEnabled,
|
|||
|
|
setShortcutsEnabled,
|
|||
|
|
setSnackbar,
|
|||
|
|
setAnswerValue,
|
|||
|
|
setCotValue,
|
|||
|
|
setQuestionValue,
|
|||
|
|
setEditingAnswer,
|
|||
|
|
setEditingCot,
|
|||
|
|
setEditingQuestion,
|
|||
|
|
handleNavigate,
|
|||
|
|
handleConfirm,
|
|||
|
|
handleUnconfirm,
|
|||
|
|
handleSave,
|
|||
|
|
handleDelete,
|
|||
|
|
handleOpenOptimizeDialog,
|
|||
|
|
handleCloseOptimizeDialog,
|
|||
|
|
handleOptimize,
|
|||
|
|
handleViewChunk,
|
|||
|
|
handleCloseViewDialog
|
|||
|
|
};
|
|||
|
|
}
|