406 lines
13 KiB
JavaScript
406 lines
13 KiB
JavaScript
'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
|
||
};
|
||
}
|