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
|
||
};
|
||
}
|