1. 模型对比页面完善,包括bug修复,支持markdown展示

This commit is contained in:
2026-01-22 14:09:25 +08:00
parent f126139bbd
commit d7fa8583f7
6 changed files with 452 additions and 54 deletions

View File

@@ -3,9 +3,11 @@ API 路由包
"""
from .datasets import datasets_bp
from .model_manage import model_manage_bp
from .model_chat import model_chat_bp
# 注册所有蓝图
def register_blueprints(app):
"""注册所有蓝图"""
app.register_blueprint(datasets_bp)
app.register_blueprint(model_manage_bp)
app.register_blueprint(model_chat_bp)

210
src/api/model_chat.py Normal file
View File

@@ -0,0 +1,210 @@
"""
模型对话 API 路由
"""
import os
import pymysql
import yaml
import requests
import concurrent.futures
from flask import Blueprint, request, jsonify
# 获取项目根目录
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# 创建蓝图
model_chat_bp = Blueprint('model_chat', __name__, url_prefix='/api/model-chat')
def get_db_connection():
"""获取数据库连接"""
CONFIG_PATH = os.path.join(PROJECT_ROOT, 'config.yaml')
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
CONFIG = yaml.safe_load(f)
db_config = CONFIG['database']
return pymysql.connect(
host=db_config['host'],
port=db_config['port'],
user=db_config['username'],
password=db_config['password'],
database=db_config['name'],
charset=db_config.get('charset', 'utf8mb4'),
cursorclass=pymysql.cursors.DictCursor
)
def generic_get_by_id(table_name, id_val):
"""按ID查询"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute(f"SELECT * FROM {table_name} WHERE id = %s", (id_val,))
result = cursor.fetchone()
cursor.close()
conn.close()
return result
def call_api_model(model_config, messages, temperature, max_tokens):
"""调用API模型OpenAI兼容格式"""
api_url = model_config.get('api_url')
api_key = model_config.get('api_key')
model_name = model_config.get('model_name', '')
# 构造OpenAI兼容的完整URL
# 支持: https://api.openai.com/v1/chat/completions 或 https://api.example.com/v1
# 如果URL已经包含 /chat/completions 则直接使用,否则追加
if '/chat/completions' in api_url:
full_url = api_url
else:
# 去掉末尾的斜杠,然后追加 /chat/completions
full_url = api_url.rstrip('/') + '/chat/completions'
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {api_key}'
}
payload = {
'model': model_name,
'messages': messages,
'temperature': temperature,
'max_tokens': max_tokens
}
try:
response = requests.post(full_url, headers=headers, json=payload, timeout=120)
response.raise_for_status()
result = response.json()
if 'choices' in result and len(result['choices']) > 0:
return {
'success': True,
'content': result['choices'][0]['message'].get('content', '')
}
return {'success': False, 'error': 'API返回格式异常'}
except requests.exceptions.RequestException as e:
return {'success': False, 'error': str(e)}
def call_local_model(model_config, messages, temperature, max_tokens):
"""调用本地模型通过vLLM OpenAI兼容API"""
api_url = model_config.get('path') # 本地模型path字段存储API地址
model_name = model_config.get('model_name', '')
if not api_url:
return {'success': False, 'error': '本地模型API地址未配置'}
headers = {'Content-Type': 'application/json'}
payload = {
'model': model_name,
'messages': messages,
'temperature': temperature,
'max_tokens': max_tokens
}
try:
response = requests.post(api_url, headers=headers, json=payload, timeout=120)
response.raise_for_status()
result = response.json()
if 'choices' in result and len(result['choices']) > 0:
return {
'success': True,
'content': result['choices'][0]['message'].get('content', '')
}
return {'success': False, 'error': 'API返回格式异常'}
except requests.exceptions.RequestException as e:
return {'success': False, 'error': str(e)}
@model_chat_bp.route('', methods=['POST'])
def model_chat():
"""模型对话接口"""
data = request.json
model_id = data.get('model_id')
system_prompt = data.get('system_prompt', '')
user_question = data.get('user_question')
temperature = data.get('temperature', 0.7)
max_tokens = data.get('max_tokens', 2048)
if not model_id:
return jsonify({'code': 1, 'message': '缺少模型ID'})
if not user_question:
return jsonify({'code': 1, 'message': '缺少用户提问'})
# 获取模型配置
model = generic_get_by_id('model_manage', model_id)
if not model:
return jsonify({'code': 1, 'message': '模型不存在'})
# 构建消息
messages = []
if system_prompt:
messages.append({'role': 'system', 'content': system_prompt})
messages.append({'role': 'user', 'content': user_question})
# 根据模型类型调用
if model.get('model_source') == 'api':
result = call_api_model(model, messages, temperature, max_tokens)
else:
result = call_local_model(model, messages, temperature, max_tokens)
if result.get('success'):
return jsonify({
'code': 0,
'data': {
'model_id': model_id,
'model_name': model.get('name'),
'response': result['content']
}
})
else:
return jsonify({'code': 1, 'message': result.get('error', '调用失败')})
@model_chat_bp.route('/batch', methods=['POST'])
def model_chat_batch():
"""批量模型对话接口(并发调用多个模型)"""
data = request.json
model_ids = data.get('model_ids', [])
system_prompt = data.get('system_prompt', '')
user_question = data.get('user_question')
temperature = data.get('temperature', 0.7)
max_tokens = data.get('max_tokens', 2048)
if not model_ids:
return jsonify({'code': 1, 'message': '缺少模型ID列表'})
if not user_question:
return jsonify({'code': 1, 'message': '缺少用户提问'})
def call_single_model(model_id):
model = generic_get_by_id('model_manage', model_id)
if not model:
return {'model_id': model_id, 'success': False, 'error': '模型不存在'}
messages = []
if system_prompt:
messages.append({'role': 'system', 'content': system_prompt})
messages.append({'role': 'user', 'content': user_question})
if model.get('model_source') == 'api':
result = call_api_model(model, messages, temperature, max_tokens)
else:
result = call_local_model(model, messages, temperature, max_tokens)
return {
'model_id': model_id,
'model_name': model.get('name'),
'success': result.get('success', False),
'response': result.get('content', ''),
'error': result.get('error', '')
}
# 并发调用所有模型
results = []
with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(model_ids), 4)) as executor:
future_to_model = {executor.submit(call_single_model, mid): mid for mid in model_ids}
for future in concurrent.futures.as_completed(future_to_model):
results.append(future.result())
return jsonify({'code': 0, 'data': results})

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>模型调优 - 远光软件微调平台</title>
<script src="../lib/tailwindcss/tailwind.js"></script>
<script src="../lib/marked.min.js"></script>
<link href="../lib/font-awesome/css/font-awesome.min.css" rel="stylesheet">
<style>
.sidebar-section-title {
@@ -568,8 +569,6 @@
// 保存到 sessionStorage当前会话
sessionStorage.setItem('lastPage', defaultPage);
console.log('Page init - URL param:', pageParam, 'saved:', sessionStorage.getItem('lastPage'), 'loading:', defaultPage);
loadPage(defaultPage);
// 更新侧边栏高亮状态
@@ -667,15 +666,15 @@
try {
// 渲染页面
if (config.isExternalPage) {
// 外部页面,直接通过 fetch 加载 HTML
const response = await fetch(`${pageName}.html`);
// 外部页面,直接通过 fetch 加载 HTML(添加时间戳禁用缓存)
const response = await fetch(`${pageName}.html?t=${Date.now()}`);
if (response.ok) {
const html = await response.text();
// 提取脚本内容并执行
const scriptMatch = html.match(/<script>([\s\S]*?)<\/script>/);
// 提取内联脚本内容没有src属性的script标签
const scriptMatch = html.match(/<script\b(?![^>]*\bsrc)[^>]*>([\s\S]*?)<\/script>/);
const scriptContent = scriptMatch ? scriptMatch[1] : '';
// 移除脚本标签后插入HTML
const htmlWithoutScript = html.replace(/<script>[\s\S]*?<\/script>/g, '');
// 移除所有script标签后插入HTML
const htmlWithoutScript = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/g, '');
// 如果有创建配置,添加 Tab 切换和创建按钮
let headerHtml = '';
@@ -710,7 +709,7 @@
container.innerHTML = headerHtml + htmlWithoutScript;
// 执行脚本
if (scriptContent) {
if (scriptContent && scriptContent.trim()) {
try {
eval(scriptContent);
} catch (e) {

View File

@@ -230,6 +230,7 @@
</div>
<script>
console.log('>>> model-compare-chat.html 脚本开始加载');
// 动态获取 API 基础地址
const getApiBase = () => {
const protocol = window.location.protocol;
@@ -263,6 +264,9 @@
// 页面初始化函数
async function initPage() {
console.log('>>> chat initPage 开始执行');
console.log('document.readyState:', document.readyState);
try {
// 获取URL参数
const urlParams = new URLSearchParams(window.location.search);
@@ -281,11 +285,10 @@
}
// 立即初始化(支持通过 fetch 加载的页面)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPage);
} else {
initPage();
}
// 延迟执行,确保 DOM 完全准备好
setTimeout(() => {
initPage().catch(err => console.error('initPage 执行失败:', err));
}, 50);
// 加载对比任务数据
async function loadCompareTask() {
@@ -406,7 +409,7 @@
if (startBtn) startBtn.addEventListener('click', startGeneration);
}
// 开始生成回答 - 跳转到结果页面
// 开始生成回答 - 直接跳转到结果页面由结果页面调用API
function startGeneration() {
const userQuestion = document.getElementById('userQuestion').value.trim();
@@ -419,10 +422,11 @@
return;
}
// 跳转到结果页面
// 直接跳转到结果页面不在这里等待API结果
// 结果页面会自动调用API并显示加载状态
const taskName = compareTaskData?.model_name || '对比任务';
const encodedQuestion = encodeURIComponent(userQuestion);
window.location.href = `main.html?page=model-compare-result&taskId=${compareTaskId}&taskName=${encodeURIComponent(taskName)}&question=${encodedQuestion}`;
window.location.href = `main.html?page=model-compare-result&taskId=${compareTaskId}&taskName=${encodeURIComponent(taskName)}&question=${encodedQuestion}&real=1`;
}
// 返回

View File

@@ -11,6 +11,46 @@
.text-primary { color: #1890ff; }
:root { --primary: #1890ff; }
/* 卡片内容区域滚动条样式 */
.markdown-content {
overflow-y: auto;
max-height: calc(100vh - 280px);
scrollbar-width: thin;
scrollbar-color: #d1d5db transparent;
}
.markdown-content::-webkit-scrollbar {
width: 6px;
}
.markdown-content::-webkit-scrollbar-track {
background: transparent;
}
.markdown-content::-webkit-scrollbar-thumb {
background-color: #d1d5db;
border-radius: 3px;
}
/* Markdown 基础样式 */
.markdown-content h1 { font-size: 1.5em; font-weight: bold; margin: 0.5em 0; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.3em; }
.markdown-content h2 { font-size: 1.25em; font-weight: bold; margin: 0.5em 0; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.2em; }
.markdown-content h3 { font-size: 1.1em; font-weight: bold; margin: 0.4em 0; }
.markdown-content p { margin: 0.5em 0; line-height: 1.6; }
.markdown-content ul, .markdown-content ol { margin: 0.5em 0; padding-left: 2em; }
.markdown-content li { margin: 0.25em 0; }
.markdown-content code { background-color: #f3f4f6; padding: 0.2em 0.4em; border-radius: 4px; font-family: monospace; font-size: 0.9em; }
.markdown-content pre { background-color: #1f2937; color: #f9fafb; padding: 1em; border-radius: 8px; overflow-x: auto; margin: 0.5em 0; }
.markdown-content pre code { background: none; padding: 0; color: inherit; }
.markdown-content blockquote { border-left: 4px solid #1890ff; padding-left: 1em; margin: 0.5em 0; color: #6b7280; }
.markdown-content table { border-collapse: collapse; width: 100%; margin: 0.5em 0; }
.markdown-content th, .markdown-content td { border: 1px solid #d1d5db; padding: 0.5em 1em; text-align: left; }
.markdown-content th { background-color: #f9fafb; font-weight: bold; }
.markdown-content tr:nth-child(even) { background-color: #f9fafb; }
.markdown-content a { color: #1890ff; text-decoration: none; }
.markdown-content a:hover { text-decoration: underline; }
.markdown-content hr { border: none; border-top: 1px solid #e5e7eb; margin: 1em 0; }
.markdown-content strong { font-weight: bold; }
.markdown-content em { font-style: italic; }
.markdown-content del { text-decoration: line-through; color: #9ca3af; }
/* 流式输出光标 */
.typing-cursor::after {
content: '|';
@@ -49,6 +89,7 @@
</div>
<script>
console.log('>>> model-compare-result.html 脚本开始加载');
// 动态获取 API 基础地址
const getApiBase = () => {
const protocol = window.location.protocol;
@@ -73,6 +114,8 @@
let compareTaskId = null;
let taskName = '';
let userQuestion = '';
let chatResults = [];
let useLocalStorageResults = false; // 是否使用localStorage中的结果
// 模拟回复内容
const mockResponses = [
@@ -84,23 +127,90 @@
// 页面初始化
async function initPage() {
const urlParams = new URLSearchParams(window.location.search);
compareTaskId = urlParams.get('taskId');
taskName = urlParams.get('taskName') || '对比任务';
userQuestion = decodeURIComponent(urlParams.get('question') || '');
try {
const urlParams = new URLSearchParams(window.location.search);
compareTaskId = urlParams.get('taskId');
taskName = urlParams.get('taskName') || '对比任务';
userQuestion = decodeURIComponent(urlParams.get('question') || '');
const needRealData = urlParams.get('real') === '1';
// 设置用户提问
document.getElementById('questionText').textContent = userQuestion;
// 设置用户提问
const questionTextEl = document.getElementById('questionText');
if (questionTextEl) {
questionTextEl.textContent = userQuestion;
}
// 加载模型列表
await loadModels();
// 加载模型列表和任务数据
await Promise.all([loadModels(), loadCompareTask()]);
// 加载任务数据获取模型列表
await loadCompareTask();
// 初始化输出卡片(显示加载中状态)
initializeOutputCards(true);
// 初始化输出卡片并开始模拟流式输出
initializeOutputCards();
simulateStreaming();
// 读取 localStorage 结果
let shouldUseStoredResults = false;
const storedResults = localStorage.getItem('chatResults');
if (storedResults && !needRealData) {
try {
const parsed = JSON.parse(storedResults);
const isRecent = (Date.now() - parsed.timestamp) < 5 * 60 * 1000;
if (parsed.results && isRecent) {
chatResults = parsed.results;
shouldUseStoredResults = true;
}
} catch (e) {
console.error('解析localStorage失败:', e);
}
}
// 如果需要真实数据或没有缓存结果则调用API
if (needRealData || !shouldUseStoredResults) {
if (selectedModelIds.length > 0 && userQuestion) {
await fetchChatResults();
// 存储到 localStorage
localStorage.setItem('chatResults', JSON.stringify({
results: chatResults,
timestamp: Date.now()
}));
}
}
// 重新初始化卡片(移除加载状态)
initializeOutputCards(false);
// 开始流式输出
simulateStreaming();
} catch (err) {
console.error('初始化失败:', err);
const contentGrid = document.getElementById('outputGrid');
if (contentGrid) {
contentGrid.innerHTML = `<div class="text-red-500 p-8">初始化失败: ${err.message}</div>`;
}
}
}
// 调用批量对话 API 获取结果
async function fetchChatResults() {
try {
const response = await fetch(`${API_BASE}/model-chat/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_ids: selectedModelIds,
system_prompt: '',
user_question: userQuestion,
temperature: 0.7,
max_tokens: 2048
})
});
const result = await response.json();
if (result.code === 0 && result.data) {
chatResults = result.data;
}
} catch (error) {
console.error('调用API失败:', error);
}
}
// 加载对比任务数据
@@ -146,12 +256,11 @@
}
} catch (error) {
console.error('加载模型失败:', error);
allModels = mockModels;
}
}
// 初始化输出卡片
function initializeOutputCards() {
function initializeOutputCards(showLoading = false) {
const grid = document.getElementById('outputGrid');
const allAvailableModels = [...allModels, ...mockModels];
@@ -160,21 +269,26 @@
? selectedModelIds.slice(0, 4)
: [1, 2, 3, 4];
const statusText = showLoading ? '加载中...' : '等待中';
const statusIcon = showLoading ? 'fa-spinner fa-spin' : 'fa-clock-o';
const statusClass = showLoading ? 'text-primary' : 'text-gray-400';
const contentText = showLoading ? '<span class="text-gray-400">正在调用模型API...</span>' : '<span class="text-gray-300">模型即将开始生成回答...</span>';
grid.innerHTML = displayModelIds.map((modelId, index) => {
const model = allAvailableModels.find(m => m.id === modelId) || { name: `模型 ${modelId}` };
const colors = ['bg-blue-100 text-blue-700', 'bg-green-100 text-green-700', 'bg-purple-100 text-purple-700', 'bg-orange-100 text-orange-700'];
const colorClass = colors[index % colors.length];
return `
<div class="bg-white rounded-lg shadow-sm flex flex-col h-full" id="output-${modelId}">
<div class="flex items-center justify-between px-5 py-3 border-b border-gray-100">
<div class="flex items-center justify-between px-5 py-3 border-b border-gray-100 flex-shrink-0">
<span class="px-3 py-1 rounded text-sm font-medium ${colorClass}">${model.name}</span>
<span id="status-${modelId}" class="text-xs text-gray-400 flex items-center">
<i class="fa fa-clock-o mr-1"></i>
等待中
<span id="status-${modelId}" class="text-xs ${statusClass} flex items-center">
<i class="fa ${statusIcon} mr-1"></i>
${statusText}
</span>
</div>
<div id="content-${modelId}" class="flex-1 overflow-y-auto p-8 text-base text-gray-600 leading-relaxed min-h-[300px]">
<span class="text-gray-300">模型即将开始生成回答...</span>
<div id="content-${modelId}" class="markdown-content flex-1 p-8 text-base text-gray-600 leading-relaxed">
${contentText}
</div>
</div>
`;
@@ -204,6 +318,27 @@
});
}
// 获取模型对应的真实响应
function getRealResponse(modelId) {
if (chatResults.length === 0) return null;
const result = chatResults.find(r => r.model_id == modelId || r.modelId == modelId);
if (result && result.response && result.response.trim() !== '') {
return result.response;
}
return null;
}
// 获取错误信息
function getErrorMessage(modelId) {
if (chatResults.length === 0) return null;
const result = chatResults.find(r => r.model_id == modelId || r.modelId == modelId);
// 如果有 error 字段且 response 为空,返回错误信息
if (result && result.error && (!result.response || result.response.trim() === '')) {
return result.error;
}
return null;
}
// 流式输出单个模型
function streamModelResponse(modelId, responseIndex) {
const contentEl = document.getElementById(`content-${modelId}`);
@@ -218,24 +353,69 @@
contentEl.innerHTML = '';
contentEl.classList.add('typing-cursor');
const response = mockResponses[responseIndex % mockResponses.length];
// 获取响应内容(真实数据或模拟数据)
const realResponse = getRealResponse(modelId);
const errorMessage = getErrorMessage(modelId);
let response;
let isRealData = false;
if (realResponse) {
response = realResponse;
isRealData = true;
} else if (errorMessage) {
response = `[错误] ${errorMessage}`;
} else {
response = mockResponses[responseIndex % mockResponses.length];
}
// 对于错误信息直接显示,不进行 Markdown 渲染
const isError = errorMessage && !realResponse;
// 标记是否需要 Markdown 渲染(真实数据需要)
const needMarkdown = isRealData && !isError;
// 统计信息
const startTime = Date.now();
let firstCharTime = null;
let charIndex = 0;
const interval = setInterval(() => {
if (charIndex < response.length) {
contentEl.textContent = response.substring(0, charIndex + 1);
// 记录第一个字符输出的时间
if (charIndex === 0) {
firstCharTime = Date.now();
}
// 流式输出期间直接显示 Markdown 格式
const currentText = response.substring(0, charIndex + 1);
if (needMarkdown) {
contentEl.innerHTML = marked.parse(currentText);
} else {
contentEl.textContent = currentText;
}
charIndex++;
contentEl.scrollTop = contentEl.scrollHeight;
} else {
clearInterval(interval);
const idx = currentStreamingIntervals.indexOf(interval);
if (idx > -1) currentStreamingIntervals.splice(idx, 1);
statusEl.innerHTML = '<i class="fa fa-check-circle text-green-500 mr-1"></i> 完成';
statusEl.classList.remove('text-primary');
statusEl.classList.add('text-green-500');
contentEl.classList.remove('typing-cursor');
// 计算统计信息
const totalTime = (Date.now() - startTime) / 1000;
const firstCharLatency = firstCharTime ? ((firstCharTime - startTime) / 1000).toFixed(2) : 0;
const charCount = response.length;
const speed = totalTime > 0 ? (charCount / totalTime).toFixed(1) : 0;
// 根据状态更新UI
if (isError) {
statusEl.innerHTML = '<i class="fa fa-times-circle text-red-500 mr-1"></i> 失败';
statusEl.classList.remove('text-primary');
statusEl.classList.add('text-red-500');
} else {
statusEl.innerHTML = `<i class="fa fa-check-circle text-green-500 mr-1"></i> 完成 <span class="text-gray-400 ml-2">${speed} 字/秒 · ${totalTime.toFixed(1)}秒 · 首字 ${firstCharLatency}秒</span>`;
statusEl.classList.remove('text-primary');
statusEl.classList.add('text-green-500');
}
// 所有模型完成时启用按钮
if (currentStreamingIntervals.length === 0) {
const btn = document.getElementById('restartBtn');
@@ -245,7 +425,7 @@
}
}
}
}, 30 + Math.random() * 40);
}, needMarkdown ? 20 : 30 + Math.random() * 40);
currentStreamingIntervals.push(interval);
}
@@ -256,13 +436,10 @@
}
// 页面加载完成后初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPage);
} else {
initPage();
}
setTimeout(() => {
initPage().catch(err => console.error('初始化失败:', err));
}, 50);
// 直接绑定按钮点击事件(不依赖 DOMContentLoaded因为页面可能通过 fetch 加载)
const restartBtn = document.getElementById('restartBtn');
if (restartBtn) {
restartBtn.onclick = restartQuestion;

View File

@@ -238,7 +238,7 @@
<span class="text-sm">本地模型</span>
</label>
<label class="flex items-center cursor-pointer">
<input type="radio" name="model_source" value="online" class="mr-2" onchange="toggleModelSource('online')">
<input type="radio" name="model_source" value="api" class="mr-2" onchange="toggleModelSource('api')">
<span class="text-sm">在线模型</span>
</label>
</div>
@@ -405,8 +405,11 @@
document.querySelector('input[name="name"]').value = model.name || '';
document.querySelector('select[name="type"]').value = model.type || 'LLM';
// 设置模型来源
const modelSource = model.model_source || 'local';
// 设置模型来源(兼容旧的 'online' 值,转换为新的 'api' 值)
let modelSource = model.model_source || 'local';
if (modelSource === 'online') {
modelSource = 'api'; // 兼容旧数据
}
document.querySelectorAll('input[name="model_source"]').forEach(radio => {
radio.checked = radio.value === modelSource;
});
@@ -446,6 +449,9 @@
}
}
// 为了兼容性,保留 online 作为别名
window.toggleModelSource = toggleModelSource;
// 提交表单
async function submitForm() {
console.log('submitForm called');