first-update
This commit is contained in:
@@ -0,0 +1,405 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* 盲测任务详情和盲测过程管理 Hook
|
||||
*/
|
||||
export default function useBlindTestDetail(projectId, taskId) {
|
||||
// 任务详情
|
||||
const [task, setTask] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 当前题目状态
|
||||
const [currentQuestion, setCurrentQuestion] = useState(null);
|
||||
const [leftAnswer, setLeftAnswer] = useState(null);
|
||||
const [rightAnswer, setRightAnswer] = useState(null);
|
||||
const [isSwapped, setIsSwapped] = useState(false);
|
||||
const [answersLoading, setAnswersLoading] = useState(false);
|
||||
|
||||
// 流式输出状态
|
||||
const [streamingA, setStreamingA] = useState(false);
|
||||
const [streamingB, setStreamingB] = useState(false);
|
||||
const abortControllerRef = useRef(null);
|
||||
const hasAutoLoadedRef = useRef(false);
|
||||
|
||||
// 投票状态
|
||||
const [voting, setVoting] = useState(false);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
|
||||
// 加载任务详情
|
||||
const loadTask = useCallback(
|
||||
async (silent = false) => {
|
||||
if (!projectId || !taskId) return;
|
||||
|
||||
try {
|
||||
if (!silent) setLoading(true);
|
||||
setError('');
|
||||
// 添加时间戳防止缓存
|
||||
const response = await fetch(`/api/projects/${projectId}/blind-test-tasks/${taskId}?t=${Date.now()}`, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
Pragma: 'no-cache',
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
console.log('任务状态更新:', result.data.completedCount, '/', result.data.totalCount);
|
||||
setTask(result.data);
|
||||
// 检查任务是否已完成 (0=进行中, 1=已完成, 2=失败, 3=已中断)
|
||||
if (result.data.status !== 0) {
|
||||
setCompleted(true);
|
||||
}
|
||||
} else {
|
||||
if (!silent) setError(result.error || '加载任务详情失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载任务详情失败:', err);
|
||||
if (!silent) setError('加载任务详情失败');
|
||||
} finally {
|
||||
if (!silent) setLoading(false);
|
||||
}
|
||||
},
|
||||
[projectId, taskId]
|
||||
);
|
||||
|
||||
// 流式获取当前题目和模型回答
|
||||
const fetchCurrentQuestion = useCallback(async () => {
|
||||
if (!projectId || !taskId) return;
|
||||
|
||||
// 取消上一次的请求
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
try {
|
||||
setAnswersLoading(true);
|
||||
setError('');
|
||||
setCurrentQuestion(null);
|
||||
setLeftAnswer({ fullContent: '', content: '', thinking: '', isThinking: false, duration: 0, error: null });
|
||||
setRightAnswer({ fullContent: '', content: '', thinking: '', isThinking: false, duration: 0, error: null });
|
||||
|
||||
// 1. 先获取题目信息
|
||||
const questionRes = await fetch(`/api/projects/${projectId}/blind-test-tasks/${taskId}/question`, {
|
||||
signal: controller.signal,
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (!questionRes.ok) throw new Error('获取题目失败');
|
||||
|
||||
const questionData = await questionRes.json();
|
||||
|
||||
if (questionData.completed) {
|
||||
setCompleted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentQuestion({
|
||||
id: questionData.questionId,
|
||||
question: questionData.question,
|
||||
answer: questionData.answer,
|
||||
index: questionData.questionIndex,
|
||||
total: questionData.totalQuestions
|
||||
});
|
||||
setIsSwapped(questionData.isSwapped);
|
||||
setCompleted(false);
|
||||
|
||||
// 2. 并行调用两个模型的流式接口
|
||||
setStreamingA(true);
|
||||
setStreamingB(true);
|
||||
|
||||
const processStream = async (modelType, setAnswer, setStreaming) => {
|
||||
const modelStartTime = Date.now();
|
||||
try {
|
||||
const streamUrl = `/api/projects/${projectId}/blind-test-tasks/${taskId}/stream-model?model=${modelType}`;
|
||||
const response = await fetch(streamUrl, {
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`模型${modelType}调用失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
let fullContent = '';
|
||||
let currentContent = '';
|
||||
let currentThinking = '';
|
||||
let isInThinking = false;
|
||||
let pendingBuffer = ''; // 用于处理跨 chunk 的标签识别
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
pendingBuffer += chunk;
|
||||
|
||||
// 处理缓冲区中的内容
|
||||
while (pendingBuffer.length > 0) {
|
||||
// 如果正在思考中,寻找结束标签
|
||||
if (isInThinking) {
|
||||
const endTagIndex = pendingBuffer.indexOf('</think>');
|
||||
if (endTagIndex !== -1) {
|
||||
const thinkingPart = pendingBuffer.substring(0, endTagIndex);
|
||||
currentThinking += thinkingPart;
|
||||
fullContent += thinkingPart + '</think>';
|
||||
isInThinking = false;
|
||||
pendingBuffer = pendingBuffer.substring(endTagIndex + 8);
|
||||
continue;
|
||||
} else {
|
||||
// 没有找到结束标签,但可能缓冲区末尾包含了部分结束标签
|
||||
// 保留最后 7 个字符("</think>" 长度为 8)以防被截断
|
||||
const safeLength = Math.max(0, pendingBuffer.length - 7);
|
||||
const processingPart = pendingBuffer.substring(0, safeLength);
|
||||
currentThinking += processingPart;
|
||||
fullContent += processingPart;
|
||||
pendingBuffer = pendingBuffer.substring(safeLength);
|
||||
break; // 等待下一个 chunk
|
||||
}
|
||||
} else {
|
||||
// 不在思考中,寻找开始标签
|
||||
const startTagIndex = pendingBuffer.indexOf('<think>');
|
||||
if (startTagIndex !== -1) {
|
||||
const contentPart = pendingBuffer.substring(0, startTagIndex);
|
||||
currentContent += contentPart;
|
||||
fullContent += contentPart + '<think>';
|
||||
isInThinking = true;
|
||||
pendingBuffer = pendingBuffer.substring(startTagIndex + 7);
|
||||
continue;
|
||||
} else {
|
||||
// 没有找到开始标签,保留最后 6 个字符以防开始标签被截断
|
||||
const safeLength = Math.max(0, pendingBuffer.length - 6);
|
||||
const processingPart = pendingBuffer.substring(0, safeLength);
|
||||
currentContent += processingPart;
|
||||
fullContent += processingPart;
|
||||
pendingBuffer = pendingBuffer.substring(safeLength);
|
||||
break; // 等待下一个 chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAnswer(prev => ({
|
||||
...prev,
|
||||
fullContent,
|
||||
content: currentContent,
|
||||
thinking: currentThinking,
|
||||
isThinking: isInThinking
|
||||
}));
|
||||
}
|
||||
|
||||
const modelDuration = Date.now() - modelStartTime;
|
||||
setAnswer(prev => ({ ...prev, duration: modelDuration }));
|
||||
setStreaming(false);
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
console.error(`模型${modelType}错误:`, err);
|
||||
const modelDuration = Date.now() - modelStartTime;
|
||||
setAnswer(prev => ({
|
||||
...prev,
|
||||
error: err.message,
|
||||
duration: modelDuration
|
||||
}));
|
||||
setStreaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 根据是否交换决定左右对应的模型
|
||||
const leftModel = questionData.isSwapped ? 'B' : 'A';
|
||||
const rightModel = questionData.isSwapped ? 'A' : 'B';
|
||||
|
||||
await Promise.all([
|
||||
processStream(leftModel, setLeftAnswer, setStreamingA),
|
||||
processStream(rightModel, setRightAnswer, setStreamingB)
|
||||
]);
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
console.error('获取题目失败:', err);
|
||||
setError(err.message || '获取当前题目失败');
|
||||
setStreamingA(false);
|
||||
setStreamingB(false);
|
||||
} finally {
|
||||
// 只有当前请求未被取消时才重置loading
|
||||
if (abortControllerRef.current === controller) {
|
||||
setAnswersLoading(false);
|
||||
}
|
||||
}
|
||||
}, [projectId, taskId]);
|
||||
|
||||
// 提交投票
|
||||
const submitVote = useCallback(
|
||||
async vote => {
|
||||
if (!projectId || !taskId || !currentQuestion) return { success: false };
|
||||
|
||||
try {
|
||||
setVoting(true);
|
||||
setError('');
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/blind-test-tasks/${taskId}/vote`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
vote,
|
||||
questionId: currentQuestion.id,
|
||||
isSwapped,
|
||||
// 使用 fullContent 提交,包含思考过程
|
||||
leftAnswer: leftAnswer?.fullContent || leftAnswer?.content || '',
|
||||
rightAnswer: rightAnswer?.fullContent || rightAnswer?.content || ''
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
// 等待任务状态更新(进度条)
|
||||
await loadTask(true);
|
||||
|
||||
if (result.data.isCompleted) {
|
||||
setCompleted(true);
|
||||
} else {
|
||||
// 获取下一题
|
||||
await fetchCurrentQuestion();
|
||||
}
|
||||
return { success: true, data: result.data };
|
||||
} else {
|
||||
setError(result.error || '提交投票失败');
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('提交投票失败:', err);
|
||||
setError('提交投票失败');
|
||||
return { success: false, error: '提交投票失败' };
|
||||
} finally {
|
||||
setVoting(false);
|
||||
}
|
||||
},
|
||||
[projectId, taskId, currentQuestion, isSwapped, leftAnswer, rightAnswer, loadTask, fetchCurrentQuestion]
|
||||
);
|
||||
|
||||
// 中断任务
|
||||
const interruptTask = useCallback(async () => {
|
||||
if (!projectId || !taskId) return false;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/blind-test-tasks/${taskId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'interrupt' })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
setCompleted(true);
|
||||
loadTask();
|
||||
return true;
|
||||
} else {
|
||||
setError(result.error || '中断任务失败');
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('中断任务失败:', err);
|
||||
setError('中断任务失败');
|
||||
return false;
|
||||
}
|
||||
}, [projectId, taskId, loadTask]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadTask();
|
||||
}, [loadTask]);
|
||||
|
||||
// 任务加载完成后,如果任务进行中,自动获取当前题目(只执行一次)
|
||||
useEffect(() => {
|
||||
if (task && task.status === 0 && !completed && !hasAutoLoadedRef.current && projectId && taskId) {
|
||||
hasAutoLoadedRef.current = true;
|
||||
fetchCurrentQuestion();
|
||||
}
|
||||
}, [task, completed, projectId, taskId, fetchCurrentQuestion]);
|
||||
|
||||
// 计算结果统计
|
||||
const getResultStats = useCallback(() => {
|
||||
if (!task?.detail?.results) return null;
|
||||
|
||||
const results = task.detail.results;
|
||||
const totalModelAScore = results.reduce((sum, r) => sum + (r.modelAScore || 0), 0);
|
||||
const totalModelBScore = results.reduce((sum, r) => sum + (r.modelBScore || 0), 0);
|
||||
|
||||
const leftWins = results.filter(r => r.vote === 'left').length;
|
||||
const rightWins = results.filter(r => r.vote === 'right').length;
|
||||
const bothGood = results.filter(r => r.vote === 'both_good').length;
|
||||
const bothBad = results.filter(r => r.vote === 'both_bad').length;
|
||||
|
||||
// 计算实际模型胜出次数(需要考虑 swap)
|
||||
const modelAWins = results.filter(r => {
|
||||
if (r.vote === 'left' && !r.isSwapped) return true;
|
||||
if (r.vote === 'right' && r.isSwapped) return true;
|
||||
return false;
|
||||
}).length;
|
||||
|
||||
const modelBWins = results.filter(r => {
|
||||
if (r.vote === 'left' && r.isSwapped) return true;
|
||||
if (r.vote === 'right' && !r.isSwapped) return true;
|
||||
return false;
|
||||
}).length;
|
||||
|
||||
return {
|
||||
totalQuestions: results.length,
|
||||
modelAScore: totalModelAScore,
|
||||
modelBScore: totalModelBScore,
|
||||
modelAWins,
|
||||
modelBWins,
|
||||
ties: bothGood + bothBad,
|
||||
bothGood,
|
||||
bothBad,
|
||||
leftWins,
|
||||
rightWins
|
||||
};
|
||||
}, [task]);
|
||||
|
||||
// 组件卸载时取消请求
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 任务详情
|
||||
task,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
loadTask,
|
||||
|
||||
// 当前题目状态
|
||||
currentQuestion,
|
||||
leftAnswer,
|
||||
rightAnswer,
|
||||
answersLoading,
|
||||
|
||||
// 流式状态
|
||||
streamingA,
|
||||
streamingB,
|
||||
|
||||
// 投票状态
|
||||
voting,
|
||||
completed,
|
||||
|
||||
// 操作
|
||||
fetchCurrentQuestion,
|
||||
submitVote,
|
||||
interruptTask,
|
||||
|
||||
// 结果统计
|
||||
getResultStats
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user