feat(llm): 启用AI点评功能并新增AI聊天API接口,自动创建相关数据库表
This commit is contained in:
0
core/sim/sim_3_01/clang.c
Normal file
0
core/sim/sim_3_01/clang.c
Normal file
@@ -146,7 +146,7 @@ static $OJ_SaaS_ENABLE=false;
|
||||
static $OJ_MENU_NEWS=true;
|
||||
|
||||
/* LLM AI Review - AI错误点评功能 */
|
||||
static $OJ_LLM_ENABLED=false; // 总开关,设为true开启AI点评
|
||||
static $OJ_LLM_ENABLED=true; // 总开关,设为true开启AI点评
|
||||
static $OJ_LLM_API_URL="https://api.minimaxi.com/anthropic/v1/messages"; // API地址(MiniMax Anthropic兼容格式)
|
||||
static $OJ_LLM_API_KEY="sk-cp-w571CJpA_6WP5P6b_xGVbjOqXOGBWTQsiNY9xxAPambouIhD7ic3GHIoWce0ON_Y7Q0rVLzBWtgwwS8cQVUxS1guBxYkPJoJ33q0jEcx0Qei60bx5La4fB4"; // 填入你的MiniMax API Key
|
||||
static $OJ_LLM_MODEL="MiniMax-M2.7"; // 模型名称,如 MiniMax-M2.7 / MiniMax-M2.5 / MiniMax-M2.5-highspeed
|
||||
|
||||
275
web/llm-chat-api.php
Normal file
275
web/llm-chat-api.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
/**
|
||||
* LLM Chat API - AI聊天接口(带对话管理)
|
||||
* 支持:创建/删除/切换对话,消息历史持久化
|
||||
*/
|
||||
|
||||
require_once('./include/db_info.inc.php');
|
||||
require_once('./include/setlang.php');
|
||||
require_once('./include/const.inc.php');
|
||||
|
||||
// ---- SSE 辅助函数 ----
|
||||
function sse_send($event, $data) {
|
||||
echo "event: $event\ndata: " . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n";
|
||||
@ob_flush();
|
||||
@flush();
|
||||
}
|
||||
|
||||
function sse_error($msg) {
|
||||
sse_send("error", ["message" => $msg]);
|
||||
}
|
||||
|
||||
// ---- 基础校验 ----
|
||||
if (!isset($OJ_LLM_ENABLED) || !$OJ_LLM_ENABLED) {
|
||||
header("Content-Type: application/json; charset=utf-8");
|
||||
echo json_encode(["error" => "AI聊天功能未开启"], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
session_start();
|
||||
if (!isset($_SESSION[$OJ_NAME . '_user_id'])) {
|
||||
header("Content-Type: application/json; charset=utf-8");
|
||||
echo json_encode(["error" => "请先登录"], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$user_id = $_SESSION[$OJ_NAME . '_user_id'];
|
||||
|
||||
// ---- 自动建表 ----
|
||||
pdo_query("CREATE TABLE IF NOT EXISTS `llm_chat_session` (
|
||||
`session_id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` VARCHAR(48) NOT NULL,
|
||||
`title` VARCHAR(200) NOT NULL DEFAULT '新对话',
|
||||
`create_time` DATETIME NOT NULL,
|
||||
`update_time` DATETIME NOT NULL,
|
||||
INDEX `idx_user` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
|
||||
|
||||
pdo_query("CREATE TABLE IF NOT EXISTS `llm_chat_message` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`session_id` INT NOT NULL,
|
||||
`role` VARCHAR(20) NOT NULL,
|
||||
`content` TEXT NOT NULL,
|
||||
`create_time` DATETIME NOT NULL,
|
||||
INDEX `idx_session` (`session_id`),
|
||||
INDEX `idx_session_role` (`session_id`, `id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
|
||||
|
||||
// ---- GET 请求 ----
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
header("Content-Type: application/json; charset=utf-8");
|
||||
$action = isset($_GET['action']) ? $_GET['action'] : '';
|
||||
|
||||
if ($action === 'sessions') {
|
||||
$sessions = pdo_query(
|
||||
"SELECT `session_id`, `title`, `create_time`, `update_time` FROM `llm_chat_session` WHERE `user_id`=? ORDER BY `update_time` DESC",
|
||||
$user_id
|
||||
);
|
||||
echo json_encode(["sessions" => $sessions !== -1 ? $sessions : []], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
} elseif ($action === 'messages') {
|
||||
$session_id = isset($_GET['session_id']) ? intval($_GET['session_id']) : 0;
|
||||
if ($session_id <= 0) { echo json_encode(["error" => "无效的会话ID"]); exit; }
|
||||
|
||||
$check = pdo_query("SELECT 1 FROM `llm_chat_session` WHERE `session_id`=? AND `user_id`=?", $session_id, $user_id);
|
||||
if (empty($check) || $check === -1) { echo json_encode(["error" => "无权访问"]); exit; }
|
||||
|
||||
$messages = pdo_query("SELECT `role`, `content` FROM `llm_chat_message` WHERE `session_id`=? ORDER BY `id` ASC", $session_id);
|
||||
echo json_encode(["messages" => $messages !== -1 ? $messages : []], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
} elseif ($action === 'last_session') {
|
||||
$last = pdo_query("SELECT `session_id` FROM `llm_chat_session` WHERE `user_id`=? ORDER BY `update_time` DESC LIMIT 1", $user_id);
|
||||
echo json_encode(["session_id" => (!empty($last) && $last !== -1) ? intval($last[0]['session_id']) : 0], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// ---- DELETE 请求 ----
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
||||
header("Content-Type: application/json; charset=utf-8");
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$session_id = isset($input['session_id']) ? intval($input['session_id']) : 0;
|
||||
if ($session_id <= 0) { echo json_encode(["error" => "无效的会话ID"]); exit; }
|
||||
|
||||
$check = pdo_query("SELECT 1 FROM `llm_chat_session` WHERE `session_id`=? AND `user_id`=?", $session_id, $user_id);
|
||||
if (empty($check) || $check === -1) { echo json_encode(["error" => "无权删除"]); exit; }
|
||||
|
||||
pdo_query("DELETE FROM `llm_chat_message` WHERE `session_id`=?", $session_id);
|
||||
pdo_query("DELETE FROM `llm_chat_session` WHERE `session_id`=?", $session_id);
|
||||
echo json_encode(["success" => true], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ---- POST 请求 ----
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$input) { header("Content-Type: application/json; charset=utf-8"); echo json_encode(["error" => "无效请求"]); exit; }
|
||||
|
||||
$action = isset($input['action']) ? $input['action'] : 'chat';
|
||||
|
||||
// 创建新会话
|
||||
if ($action === 'create_session') {
|
||||
header("Content-Type: application/json; charset=utf-8");
|
||||
$title = isset($input['title']) ? trim($input['title']) : '新对话';
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$session_id = pdo_query("INSERT INTO `llm_chat_session` (`user_id`, `title`, `create_time`, `update_time`) VALUES (?, ?, ?, ?)", $user_id, $title, $now, $now);
|
||||
echo json_encode(["session_id" => $session_id, "title" => $title], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 重命名会话
|
||||
if ($action === 'rename_session') {
|
||||
header("Content-Type: application/json; charset=utf-8");
|
||||
$session_id = isset($input['session_id']) ? intval($input['session_id']) : 0;
|
||||
$title = isset($input['title']) ? trim($input['title']) : '';
|
||||
if ($session_id <= 0 || empty($title)) { echo json_encode(["error" => "参数错误"]); exit; }
|
||||
pdo_query("UPDATE `llm_chat_session` SET `title`=? WHERE `session_id`=? AND `user_id`=?", $title, $session_id, $user_id);
|
||||
echo json_encode(["success" => true], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ---- 聊天(流式SSE) ----
|
||||
$message = isset($input['message']) ? trim($input['message']) : '';
|
||||
$session_id = isset($input['session_id']) ? intval($input['session_id']) : 0;
|
||||
|
||||
if (empty($message)) { header("Content-Type: application/json; charset=utf-8"); echo json_encode(["error" => "消息不能为空"]); exit; }
|
||||
|
||||
// 自动创建会话
|
||||
if ($session_id <= 0) {
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$auto_title = mb_substr($message, 0, 20, 'UTF-8');
|
||||
if (mb_strlen($message, 'UTF-8') > 20) $auto_title .= '...';
|
||||
$session_id = pdo_query("INSERT INTO `llm_chat_session` (`user_id`, `title`, `create_time`, `update_time`) VALUES (?, ?, ?, ?)", $user_id, $auto_title, $now, $now);
|
||||
}
|
||||
|
||||
// 验证归属
|
||||
$check = pdo_query("SELECT 1 FROM `llm_chat_session` WHERE `session_id`=? AND `user_id`=?", $session_id, $user_id);
|
||||
if (empty($check) || $check === -1) { header("Content-Type: application/json; charset=utf-8"); echo json_encode(["error" => "无权访问"]); exit; }
|
||||
|
||||
// 保存用户消息
|
||||
$now = date('Y-m-d H:i:s');
|
||||
pdo_query("INSERT INTO `llm_chat_message` (`session_id`, `role`, `content`, `create_time`) VALUES (?, 'user', ?, ?)", $session_id, $message, $now);
|
||||
pdo_query("UPDATE `llm_chat_session` SET `update_time`=? WHERE `session_id`=?", $now, $session_id);
|
||||
|
||||
// 获取历史(最近10条上下文)
|
||||
$history = pdo_query("SELECT `role`, `content` FROM `llm_chat_message` WHERE `session_id`=? ORDER BY `id` DESC LIMIT 11", $session_id);
|
||||
$messages = [];
|
||||
if ($history !== -1 && !empty($history)) {
|
||||
$history = array_reverse($history);
|
||||
$recent = array_slice($history, 0, -1);
|
||||
$recent = array_slice($recent, -10);
|
||||
foreach ($recent as $msg) {
|
||||
$messages[] = ['role' => $msg['role'], 'content' => $msg['content']];
|
||||
}
|
||||
}
|
||||
$messages[] = ['role' => 'user', 'content' => $message];
|
||||
|
||||
// ---- SSE 响应头 ----
|
||||
header("Content-Type: text/event-stream; charset=utf-8");
|
||||
header("Cache-Control: no-cache");
|
||||
header("X-Accel-Buffering: no");
|
||||
if (function_exists('apache_setenv')) @apache_setenv('no-gzip', '1');
|
||||
@ini_set('zlib.output_compression', 0);
|
||||
@ini_set('implicit_flush', 1);
|
||||
while (ob_get_level()) ob_end_flush();
|
||||
|
||||
sse_send("session", ["session_id" => $session_id]);
|
||||
|
||||
// ---- 系统提示词 ----
|
||||
$system_prompt = <<<'EOP'
|
||||
你是一位名叫"MiniMax老师"的AI助教,正在与学生进行日常对话。
|
||||
|
||||
你的职责:
|
||||
1. 用亲切、友好的语气和学生交流
|
||||
2. 回答学生的各种问题,包括但不限于学习、生活、编程概念等
|
||||
3. 提供有帮助的信息和建议
|
||||
4. 使用中文回答
|
||||
|
||||
严格禁止的行为:
|
||||
1. 绝对不要帮助学生解答任何编程题目、算法题、数据结构题等OJ题目
|
||||
2. 绝对不要提供任何题目的解题思路、代码实现或算法分析
|
||||
3. 如果学生询问关于具体OJ题目的问题,请礼貌地拒绝并解释你需要自己思考来完成题目
|
||||
4. 不要帮助学生完成任何看起来像是作业或考试的编程任务
|
||||
|
||||
你可以做的事情:
|
||||
- 解释编程概念和原理(不针对具体题目)
|
||||
- 讨论学习方法和技巧
|
||||
- 回答日常生活问题
|
||||
- 聊天闲谈
|
||||
- 其他与做题无关的问题
|
||||
EOP;
|
||||
|
||||
$api_url = isset($OJ_LLM_API_URL) ? $OJ_LLM_API_URL : "https://api.minimaxi.com/anthropic/v1/messages";
|
||||
$api_key = isset($OJ_LLM_API_KEY) ? $OJ_LLM_API_KEY : "";
|
||||
$model = isset($OJ_LLM_MODEL) ? $OJ_LLM_MODEL : "MiniMax-M2.7";
|
||||
$max_tokens = isset($OJ_LLM_MAX_TOKENS) ? intval($OJ_LLM_MAX_TOKENS) : 2048;
|
||||
$timeout = isset($OJ_LLM_TIMEOUT) ? intval($OJ_LLM_TIMEOUT) : 120;
|
||||
|
||||
if (empty($api_key)) { sse_error("API Key未配置"); exit; }
|
||||
|
||||
$request_body = json_encode([
|
||||
"model" => $model, "max_tokens" => $max_tokens, "system" => $system_prompt,
|
||||
"temperature" => 0.8, "stream" => true, "messages" => $messages
|
||||
]);
|
||||
|
||||
// ---- 流式转发 ----
|
||||
$GLOBALS['_llm_alive'] = true;
|
||||
$GLOBALS['_llm_full_text'] = "";
|
||||
$GLOBALS['_llm_buffer'] = "";
|
||||
register_shutdown_function(function() { $GLOBALS['_llm_alive'] = false; });
|
||||
|
||||
$ch = curl_init($api_url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true, CURLOPT_POSTFIELDS => $request_body,
|
||||
CURLOPT_HTTPHEADER => ["Content-Type: application/json", "x-api-key: " . $api_key, "anthropic-version: 2023-06-01"],
|
||||
CURLOPT_RETURNTRANSFER => false, CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_WRITEFUNCTION => function($ch, $chunk) use (&$_llm_alive, &$_llm_full_text, &$_llm_buffer) {
|
||||
if (!$_llm_alive) return 0;
|
||||
$_llm_buffer .= $chunk;
|
||||
while (($pos = strpos($_llm_buffer, "\n")) !== false) {
|
||||
$line = substr($_llm_buffer, 0, $pos);
|
||||
$_llm_buffer = substr($_llm_buffer, $pos + 1);
|
||||
$line = trim($line);
|
||||
if (empty($line)) continue;
|
||||
if (strncmp($line, "data: ", 6) === 0) {
|
||||
$json_str = substr($line, 6);
|
||||
if ($json_str === "[DONE]") continue;
|
||||
$data = json_decode($json_str, true);
|
||||
if (!$data) continue;
|
||||
$type = isset($data['type']) ? $data['type'] : '';
|
||||
if ($type === 'content_block_delta' && isset($data['delta']['text'])) {
|
||||
$_llm_full_text .= $data['delta']['text'];
|
||||
sse_send("chunk", ["text" => $data['delta']['text']]);
|
||||
} elseif ($type === 'message_delta' && isset($data['delta']['stop_reason'])) {
|
||||
if (in_array($data['delta']['stop_reason'], ['end_turn', 'max_tokens'])) {
|
||||
sse_send("done", []);
|
||||
return 0;
|
||||
}
|
||||
} elseif (in_array($type, ['message_start', 'ping', 'content_block_start', 'content_block_stop'])) {
|
||||
continue;
|
||||
} elseif ($type === 'error') {
|
||||
sse_send("error", ["message" => "API错误: " . (isset($data['error']['message']) ? $data['error']['message'] : '未知')]);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return strlen($chunk);
|
||||
}
|
||||
]);
|
||||
|
||||
$result = curl_exec($ch);
|
||||
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curl_error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
// 保存AI回复
|
||||
if (!empty($_llm_full_text)) {
|
||||
$save_now = date('Y-m-d H:i:s');
|
||||
pdo_query("INSERT INTO `llm_chat_message` (`session_id`, `role`, `content`, `create_time`) VALUES (?, 'assistant', ?, ?)", $session_id, $_llm_full_text, $save_now);
|
||||
}
|
||||
|
||||
if (!$_llm_alive) exit;
|
||||
if ($result === false && empty($_llm_full_text)) { sse_error("网络请求失败: " . ($curl_error ?: "未知")); exit; }
|
||||
if (empty($_llm_full_text) && $http_code !== 200) { sse_error("API请求失败 (HTTP $http_code)"); exit; }
|
||||
if (empty($_llm_full_text)) { sse_error("API返回为空"); exit; }
|
||||
?>
|
||||
515
web/llm-chat.php
Normal file
515
web/llm-chat.php
Normal file
@@ -0,0 +1,515 @@
|
||||
<?php
|
||||
require_once('./include/db_info.inc.php');
|
||||
require_once('./include/setlang.php');
|
||||
require_once('./include/const.inc.php');
|
||||
|
||||
$show_title = "AI 聊天 - " . $OJ_NAME;
|
||||
$OJ_CACHE_SHARE = false;
|
||||
|
||||
require_once('./template/' . $OJ_TEMPLATE . '/header.php');
|
||||
?>
|
||||
|
||||
<style>
|
||||
body { overflow: hidden !important; }
|
||||
|
||||
.chat-wrapper {
|
||||
display: flex;
|
||||
height: calc(100vh - 55px);
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* 左侧对话列表 */
|
||||
.chat-sidebar {
|
||||
width: 260px;
|
||||
min-width: 260px;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #2a2a4a;
|
||||
}
|
||||
.sidebar-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #2a2a4a;
|
||||
}
|
||||
.sidebar-header button {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #2d2d5e;
|
||||
color: #fff;
|
||||
border: 1px solid #4a4a8a;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.sidebar-header button:hover { background: #3d3d7e; }
|
||||
.session-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
.session-item {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background 0.2s;
|
||||
font-size: 14px;
|
||||
color: #ccc;
|
||||
}
|
||||
.session-item:hover { background: #2a2a4a; }
|
||||
.session-item.active { background: #2d2d5e; color: #fff; }
|
||||
.session-item .session-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.session-item .session-actions {
|
||||
display: none;
|
||||
gap: 4px;
|
||||
}
|
||||
.session-item:hover .session-actions { display: flex; }
|
||||
.session-actions button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.session-actions button:hover { color: #fff; background: #444; }
|
||||
.session-actions button.delete-btn:hover { color: #ff6b6b; }
|
||||
|
||||
/* 右侧聊天区 */
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.chat-header {
|
||||
padding: 14px 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.chat-header .ai-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
.chat-header .ai-info h3 { margin: 0; font-size: 16px; color: #333; }
|
||||
.chat-header .ai-info p { margin: 0; font-size: 12px; color: #999; }
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
margin-bottom: 16px;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
.message.user { justify-content: flex-end; }
|
||||
.message.assistant { justify-content: flex-start; }
|
||||
|
||||
.msg-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
.message.user .msg-avatar {
|
||||
background: #0084ff;
|
||||
color: #fff;
|
||||
margin-left: 10px;
|
||||
order: 2;
|
||||
}
|
||||
.message.assistant .msg-avatar {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
max-width: 70%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 16px;
|
||||
line-height: 1.6;
|
||||
font-size: 15px;
|
||||
}
|
||||
.message.user .msg-content {
|
||||
background: #0084ff;
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.message.assistant .msg-content {
|
||||
background: #fff;
|
||||
color: #333;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
/* Markdown 样式 */
|
||||
.msg-content h1, .msg-content h2, .msg-content h3 { margin: 8px 0 4px; color: #222; }
|
||||
.msg-content p { margin: 4px 0; }
|
||||
.msg-content code { background: #f4f4f4; padding: 1px 5px; border-radius: 3px; font-size: 0.9em; }
|
||||
.msg-content pre { background: #282c34; color: #abb2bf; padding: 12px; border-radius: 8px; overflow-x: auto; margin: 8px 0; }
|
||||
.msg-content pre code { background: none; color: inherit; }
|
||||
.msg-content ul, .msg-content ol { margin: 4px 0; padding-left: 20px; }
|
||||
|
||||
/* 输入区 */
|
||||
.chat-input-area {
|
||||
padding: 16px 20px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e8e8e8;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.chat-input-area textarea {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
border: 2px solid #e8e8e8;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
min-height: 42px;
|
||||
max-height: 100px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.chat-input-area textarea:focus { border-color: #0084ff; }
|
||||
.chat-input-area button {
|
||||
padding: 10px 20px;
|
||||
background: #0084ff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.chat-input-area button:hover { background: #0073e6; }
|
||||
.chat-input-area button:disabled { background: #ccc; cursor: not-allowed; }
|
||||
|
||||
.typing-indicator { display: flex; padding: 4px 0; }
|
||||
.typing-indicator span { width: 7px; height: 7px; background: #999; border-radius: 50%; margin: 0 2px; animation: typing 1.4s infinite; }
|
||||
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes typing { 0%,100% { opacity: 0.2; } 50% { opacity: 1; } }
|
||||
|
||||
.welcome-screen {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
}
|
||||
.welcome-screen .big-icon { font-size: 72px; margin-bottom: 16px; }
|
||||
.welcome-screen h2 { color: #555; margin-bottom: 8px; }
|
||||
.welcome-screen p { font-size: 15px; }
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
.chat-sidebar { width: 0; min-width: 0; display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="chat-wrapper">
|
||||
<!-- 左侧对话列表 -->
|
||||
<div class="chat-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<button onclick="createSession()"><i class="plus icon"></i> 新建对话</button>
|
||||
</div>
|
||||
<div class="session-list" id="sessionList"></div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧聊天区 -->
|
||||
<div class="chat-main">
|
||||
<div class="chat-header">
|
||||
<div class="ai-avatar">M</div>
|
||||
<div class="ai-info">
|
||||
<h3>MiniMax老师</h3>
|
||||
<p>AI助教 · 随时为你解答</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="chatMessages" class="chat-messages">
|
||||
<div class="welcome-screen" id="welcomeScreen">
|
||||
<i class="comment outline icon big-icon"></i>
|
||||
<h2>你好,欢迎使用AI聊天</h2>
|
||||
<p>选择一个对话或创建新对话开始聊天</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-input-area">
|
||||
<textarea id="userInput" placeholder="输入你的问题... (Enter发送,Shift+Enter换行)" rows="1"></textarea>
|
||||
<button id="sendBtn" onclick="sendMessage()"><i class="send icon"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script>
|
||||
let currentSessionId = 0;
|
||||
let isLoading = false;
|
||||
|
||||
const userInput = document.getElementById('userInput');
|
||||
userInput.addEventListener('input', function() { this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 100) + 'px'; });
|
||||
userInput.addEventListener('keydown', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } });
|
||||
|
||||
// 加载会话列表
|
||||
async function loadSessions() {
|
||||
try {
|
||||
const res = await fetch('llm-chat-api.php?action=sessions');
|
||||
const data = await res.json();
|
||||
const list = document.getElementById('sessionList');
|
||||
const sessions = data.sessions || [];
|
||||
|
||||
if (sessions.length === 0) {
|
||||
list.innerHTML = '<div style="text-align:center;color:#666;padding:20px;font-size:13px;">暂无对话</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = sessions.map(s => `
|
||||
<div class="session-item ${s.session_id == currentSessionId ? 'active' : ''}"
|
||||
onclick="switchSession(${s.session_id})" data-id="${s.session_id}">
|
||||
<span class="session-title">${escapeHtml(s.title)}</span>
|
||||
<span class="session-actions">
|
||||
<button onclick="event.stopPropagation();renameSession(${s.session_id})" title="重命名">✏️</button>
|
||||
<button class="delete-btn" onclick="event.stopPropagation();deleteSession(${s.session_id})" title="删除">🗑️</button>
|
||||
</span>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (e) { console.error('加载会话列表失败', e); }
|
||||
}
|
||||
|
||||
// 创建新会话
|
||||
async function createSession() {
|
||||
try {
|
||||
const res = await fetch('llm-chat-api.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'create_session', title: '新对话' })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.session_id) {
|
||||
currentSessionId = data.session_id;
|
||||
localStorage.setItem('llm_chat_current', currentSessionId);
|
||||
await loadSessions();
|
||||
clearChatArea();
|
||||
}
|
||||
} catch (e) { alert('创建失败'); }
|
||||
}
|
||||
|
||||
// 切换会话
|
||||
async function switchSession(sessionId) {
|
||||
if (sessionId == currentSessionId) return;
|
||||
currentSessionId = sessionId;
|
||||
localStorage.setItem('llm_chat_current', currentSessionId);
|
||||
await loadSessions();
|
||||
await loadMessages(sessionId);
|
||||
}
|
||||
|
||||
// 加载消息
|
||||
async function loadMessages(sessionId) {
|
||||
try {
|
||||
const res = await fetch(`llm-chat-api.php?action=messages&session_id=${sessionId}`);
|
||||
const data = await res.json();
|
||||
const container = document.getElementById('chatMessages');
|
||||
container.innerHTML = '';
|
||||
|
||||
const messages = data.messages || [];
|
||||
if (messages.length === 0) {
|
||||
container.innerHTML = '<div class="welcome-screen"><i class="comment outline icon big-icon"></i><h2>开始对话吧</h2><p>输入你想问的问题</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
messages.forEach(msg => {
|
||||
addMessageToUI(msg.role, msg.content);
|
||||
});
|
||||
scrollToBottom();
|
||||
} catch (e) { console.error('加载消息失败', e); }
|
||||
}
|
||||
|
||||
// 删除会话
|
||||
async function deleteSession(sessionId) {
|
||||
if (!confirm('确定要删除这个对话吗?')) return;
|
||||
try {
|
||||
await fetch('llm-chat-api.php', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: sessionId })
|
||||
});
|
||||
if (sessionId == currentSessionId) {
|
||||
currentSessionId = 0;
|
||||
localStorage.removeItem('llm_chat_current');
|
||||
clearChatArea();
|
||||
}
|
||||
await loadSessions();
|
||||
} catch (e) { alert('删除失败'); }
|
||||
}
|
||||
|
||||
// 重命名会话
|
||||
async function renameSession(sessionId) {
|
||||
const title = prompt('输入新名称:');
|
||||
if (!title || !title.trim()) return;
|
||||
try {
|
||||
await fetch('llm-chat-api.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'rename_session', session_id: sessionId, title: title.trim() })
|
||||
});
|
||||
await loadSessions();
|
||||
} catch (e) { alert('重命名失败'); }
|
||||
}
|
||||
|
||||
// 清空聊天区
|
||||
function clearChatArea() {
|
||||
document.getElementById('chatMessages').innerHTML = `
|
||||
<div class="welcome-screen" id="welcomeScreen">
|
||||
<i class="comment outline icon big-icon"></i>
|
||||
<h2>开始对话吧</h2>
|
||||
<p>输入你想问的问题</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
async function sendMessage() {
|
||||
const input = document.getElementById('userInput');
|
||||
const message = input.value.trim();
|
||||
if (!message || isLoading) return;
|
||||
|
||||
input.value = '';
|
||||
input.style.height = 'auto';
|
||||
|
||||
// 清空欢迎页
|
||||
const welcome = document.getElementById('welcomeScreen');
|
||||
if (welcome) welcome.remove();
|
||||
|
||||
addMessageToUI('user', message);
|
||||
isLoading = true;
|
||||
document.getElementById('sendBtn').disabled = true;
|
||||
const assistantDiv = addMessageToUI('assistant', '', true);
|
||||
|
||||
try {
|
||||
const res = await fetch('llm-chat-api.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'chat', message: message, session_id: currentSessionId })
|
||||
});
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let fullText = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (data.text) {
|
||||
fullText += data.text;
|
||||
assistantDiv.innerHTML = renderMarkdown(fullText);
|
||||
scrollToBottom();
|
||||
}
|
||||
if (data.session_id && !currentSessionId) {
|
||||
currentSessionId = data.session_id;
|
||||
localStorage.setItem('llm_chat_current', currentSessionId);
|
||||
loadSessions();
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
assistantDiv.innerHTML = '<span style="color:#ff4444">抱歉,发生错误: ' + error.message + '</span>';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
document.getElementById('sendBtn').disabled = false;
|
||||
// 如果是新会话,刷新列表
|
||||
loadSessions();
|
||||
}
|
||||
}
|
||||
|
||||
// 添加消息到UI
|
||||
function addMessageToUI(role, content, loading = false) {
|
||||
const container = document.getElementById('chatMessages');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'message ' + role;
|
||||
|
||||
if (role === 'user') {
|
||||
const userInitial = '<?php echo isset($_SESSION[$OJ_NAME . "_user_id"]) ? mb_substr($_SESSION[$OJ_NAME . "_user_id"], 0, 1, "UTF-8") : "U"; ?>';
|
||||
div.innerHTML = `<div class="msg-content">${escapeHtml(content)}</div><div class="msg-avatar">${userInitial}</div>`;
|
||||
} else {
|
||||
div.innerHTML = `<div class="msg-avatar">M</div><div class="msg-content">${loading ? '<div class="typing-indicator"><span></span><span></span><span></span></div>' : renderMarkdown(content)}</div>`;
|
||||
}
|
||||
|
||||
container.appendChild(div);
|
||||
scrollToBottom();
|
||||
return div.querySelector('.msg-content');
|
||||
}
|
||||
|
||||
function renderMarkdown(text) {
|
||||
if (!text) return '';
|
||||
try { return marked.parse(text); } catch (e) { return escapeHtml(text).replace(/\n/g, '<br>'); }
|
||||
}
|
||||
|
||||
function escapeHtml(text) { const d = document.createElement('div'); d.textContent = text; return d.innerHTML; }
|
||||
function scrollToBottom() { const c = document.getElementById('chatMessages'); c.scrollTop = c.scrollHeight; }
|
||||
|
||||
// 初始化
|
||||
(async function init() {
|
||||
await loadSessions();
|
||||
const saved = localStorage.getItem('llm_chat_current');
|
||||
if (saved && parseInt(saved) > 0) {
|
||||
currentSessionId = parseInt(saved);
|
||||
// 验证该会话是否存在
|
||||
const res = await fetch('llm-chat-api.php?action=sessions');
|
||||
const data = await res.json();
|
||||
const exists = (data.sessions || []).some(s => s.session_id == currentSessionId);
|
||||
if (exists) {
|
||||
await loadMessages(currentSessionId);
|
||||
await loadSessions();
|
||||
} else {
|
||||
currentSessionId = 0;
|
||||
localStorage.removeItem('llm_chat_current');
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php include("template/$OJ_TEMPLATE/footer.php"); ?>
|
||||
@@ -1,50 +1,89 @@
|
||||
<?php
|
||||
/**
|
||||
* LLM AI Review - AI错误点评
|
||||
* 接收 solution_id,收集上下文信息,调用LLM API返回启发式点评
|
||||
* 结果缓存到 llm_review 表,同一 solution_id 只调用一次 LLM
|
||||
* LLM AI Review - AI错误点评(SSE 流式输出版)
|
||||
* 接收 solution_id,收集上下文信息,调用LLM API流式返回启发式点评
|
||||
* 结果缓存到 llm_review 表,同一 solution_id 再次请求直接返回缓存
|
||||
*/
|
||||
header("Content-Type: application/json; charset=utf-8");
|
||||
header("Cache-Control: no-cache, must-revalidate");
|
||||
|
||||
require_once('./include/db_info.inc.php');
|
||||
require_once('./include/setlang.php');
|
||||
require_once('./include/const.inc.php');
|
||||
|
||||
// 检查功能是否开启
|
||||
// ---- 辅助函数:SSE 事件输出 ----
|
||||
function sse_send($event, $data) {
|
||||
echo "event: $event\ndata: " . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n";
|
||||
@ob_flush();
|
||||
@flush();
|
||||
}
|
||||
|
||||
function sse_error($msg) {
|
||||
sse_send("error", ["message" => $msg]);
|
||||
}
|
||||
|
||||
// ---- 基础校验 ----
|
||||
if (!isset($OJ_LLM_ENABLED) || !$OJ_LLM_ENABLED) {
|
||||
// 返回 JSON(非 SSE),因为前端可能用 $.ajax 调用
|
||||
header("Content-Type: application/json; charset=utf-8");
|
||||
echo json_encode(["error" => "AI点评功能未开启"], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 检查登录
|
||||
session_start();
|
||||
if (!isset($_SESSION[$OJ_NAME . '_user_id'])) {
|
||||
header("Content-Type: application/json; charset=utf-8");
|
||||
echo json_encode(["error" => "请先登录"], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 获取 solution_id
|
||||
if (!isset($_GET['sid'])) {
|
||||
header("Content-Type: application/json; charset=utf-8");
|
||||
echo json_encode(["error" => "缺少参数 sid"], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
$sid = intval($_GET['sid']);
|
||||
if ($sid <= 0) {
|
||||
header("Content-Type: application/json; charset=utf-8");
|
||||
echo json_encode(["error" => "无效的 solution_id"], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ---- 1. 检查缓存 ----
|
||||
// 自动建表(首次调用时)
|
||||
pdo_query("CREATE TABLE IF NOT EXISTS `llm_review` (
|
||||
`solution_id` INT NOT NULL,
|
||||
`review` TEXT NOT NULL,
|
||||
`create_time` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`solution_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
|
||||
|
||||
$cache = pdo_query("SELECT `review` FROM `llm_review` WHERE `solution_id`=?", $sid);
|
||||
if ($cache !== -1 && !empty($cache)) {
|
||||
echo json_encode(["review" => $cache[0]['review']], JSON_UNESCAPED_UNICODE);
|
||||
// 有缓存,走 SSE 立刻返回(前端统一用 SSE 接收)
|
||||
header("Content-Type: text/event-stream; charset=utf-8");
|
||||
header("Cache-Control: no-cache");
|
||||
header("X-Accel-Buffering: no");
|
||||
sse_send("cached", ["text" => $cache[0]['review']]);
|
||||
sse_send("done", []);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ---- 设置 SSE 响应头 ----
|
||||
header("Content-Type: text/event-stream; charset=utf-8");
|
||||
header("Cache-Control: no-cache");
|
||||
header("X-Accel-Buffering: no");
|
||||
if (function_exists('apache_setenv')) {
|
||||
@apache_setenv('no-gzip', '1');
|
||||
}
|
||||
@ini_set('zlib.output_compression', 0);
|
||||
@ini_set('implicit_flush', 1);
|
||||
while (ob_get_level()) ob_end_flush();
|
||||
|
||||
// ---- 2. 查询 solution 信息 ----
|
||||
$solution = pdo_query("SELECT * FROM `solution` WHERE `solution_id`=?", $sid);
|
||||
if (empty($solution)) {
|
||||
echo json_encode(["error" => "找不到该提交记录"], JSON_UNESCAPED_UNICODE);
|
||||
sse_error("找不到该提交记录");
|
||||
exit;
|
||||
}
|
||||
$sol = $solution[0];
|
||||
@@ -61,7 +100,7 @@ $student_code = (!empty($src) && $src !== -1) ? $src[0]['source'] : "";
|
||||
// ---- 4. 查询题目信息 ----
|
||||
$prob = pdo_query("SELECT `title`,`description`,`input`,`output`,`sample_input`,`sample_output`,`hint` FROM `problem` WHERE `problem_id`=?", $problem_id);
|
||||
if (empty($prob) || $prob === -1) {
|
||||
echo json_encode(["error" => "找不到该题目"], JSON_UNESCAPED_UNICODE);
|
||||
sse_error("找不到该题目");
|
||||
exit;
|
||||
}
|
||||
$p = $prob[0];
|
||||
@@ -82,13 +121,11 @@ if (!empty($first_ac) && $first_ac !== -1) {
|
||||
// ---- 6. 查询 diff/error 信息 ----
|
||||
$diff_info = "";
|
||||
if ($result_code == 11) {
|
||||
// 编译错误
|
||||
$err = pdo_query("SELECT `error` FROM `compileinfo` WHERE `solution_id`=?", $sid);
|
||||
if (!empty($err) && $err !== -1) {
|
||||
$diff_info = $err[0]['error'];
|
||||
}
|
||||
} else {
|
||||
// 运行时/WA 等
|
||||
$err = pdo_query("SELECT `error` FROM `runtimeinfo` WHERE `solution_id`=?", $sid);
|
||||
if (!empty($err) && $err !== -1) {
|
||||
$diff_info = $err[0]['error'];
|
||||
@@ -139,7 +176,7 @@ if (!empty($reference_code)) {
|
||||
|
||||
$user_prompt .= "\n请分析学生的错误,给出启发性的提示和引导,帮助学生自己发现问题并改正。\n";
|
||||
|
||||
// ---- 8. 调用 LLM API ----
|
||||
// ---- 8. 流式调用 LLM API ----
|
||||
$api_url = isset($OJ_LLM_API_URL) ? $OJ_LLM_API_URL : "https://api.minimaxi.com/anthropic/v1/messages";
|
||||
$api_key = isset($OJ_LLM_API_KEY) ? $OJ_LLM_API_KEY : "";
|
||||
$model = isset($OJ_LLM_MODEL) ? $OJ_LLM_MODEL : "MiniMax-M2.5";
|
||||
@@ -147,21 +184,33 @@ $max_tokens = isset($OJ_LLM_MAX_TOKENS) ? intval($OJ_LLM_MAX_TOKENS) : 1024;
|
||||
$timeout = isset($OJ_LLM_TIMEOUT) ? intval($OJ_LLM_TIMEOUT) : 60;
|
||||
|
||||
if (empty($api_key)) {
|
||||
echo json_encode(["error" => "API Key未配置"], JSON_UNESCAPED_UNICODE);
|
||||
sse_error("API Key未配置");
|
||||
exit;
|
||||
}
|
||||
|
||||
// ---- Anthropic API 兼容格式 ----
|
||||
$request_body = json_encode([
|
||||
"model" => $model,
|
||||
"max_tokens" => $max_tokens,
|
||||
"system" => $system_prompt,
|
||||
"temperature" => 1.0,
|
||||
"stream" => true,
|
||||
"messages" => [
|
||||
["role" => "user", "content" => $user_prompt]
|
||||
]
|
||||
]);
|
||||
|
||||
// ---- 流式 SSE 转发 ----
|
||||
// 追踪连接状态和累积文本
|
||||
$GLOBALS['_llm_alive'] = true;
|
||||
$GLOBALS['_llm_full_text'] = "";
|
||||
$GLOBALS['_llm_buffer'] = "";
|
||||
$GLOBALS['_llm_sent_text'] = ""; // 已发送给前端的文本(用于增量比较)
|
||||
|
||||
// 检测客户端断开
|
||||
register_shutdown_function(function() {
|
||||
$GLOBALS['_llm_alive'] = false;
|
||||
});
|
||||
|
||||
$ch = curl_init($api_url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
@@ -171,51 +220,97 @@ curl_setopt_array($ch, [
|
||||
"x-api-key: " . $api_key,
|
||||
"anthropic-version: 2023-06-01"
|
||||
],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_SSL_VERIFYPEER => true
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_WRITEFUNCTION => function($ch, $chunk) use (&$_llm_alive, &$_llm_full_text, &$_llm_buffer) {
|
||||
if (!$_llm_alive) return 0;
|
||||
|
||||
$_llm_buffer .= $chunk;
|
||||
|
||||
// 按行解析 SSE
|
||||
while (($pos = strpos($_llm_buffer, "\n")) !== false) {
|
||||
$line = substr($_llm_buffer, 0, $pos);
|
||||
$_llm_buffer = substr($_llm_buffer, $pos + 1);
|
||||
|
||||
$line = trim($line);
|
||||
if (empty($line)) continue;
|
||||
|
||||
// 解析 data: 行
|
||||
if (strncmp($line, "data: ", 6) === 0) {
|
||||
$json_str = substr($line, 6);
|
||||
if ($json_str === "[DONE]") continue;
|
||||
|
||||
$data = json_decode($json_str, true);
|
||||
if (!$data) continue;
|
||||
|
||||
$type = isset($data['type']) ? $data['type'] : '';
|
||||
|
||||
// content_block_delta: 增量文本
|
||||
if ($type === 'content_block_delta' && isset($data['delta']['text'])) {
|
||||
$_llm_full_text .= $data['delta']['text'];
|
||||
sse_send("chunk", ["text" => $data['delta']['text']]);
|
||||
|
||||
// message_delta: 消息完成
|
||||
} elseif ($type === 'message_delta' && isset($data['delta']['stop_reason'])) {
|
||||
if ($data['delta']['stop_reason'] === 'end_turn' ||
|
||||
$data['delta']['stop_reason'] === 'max_tokens') {
|
||||
sse_send("done", []);
|
||||
return 0; // 停止 curl
|
||||
}
|
||||
|
||||
// message_start / ping: 忽略
|
||||
} elseif ($type === 'message_start' || $type === 'ping') {
|
||||
continue;
|
||||
|
||||
// content_block_start / stop: 忽略
|
||||
} elseif ($type === 'content_block_start' || $type === 'content_block_stop') {
|
||||
continue;
|
||||
|
||||
// 错误事件
|
||||
} elseif ($type === 'error') {
|
||||
$msg = isset($data['error']['message']) ? $data['error']['message'] : '未知错误';
|
||||
sse_send("error", ["message" => "API错误: " . $msg]);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strlen($chunk);
|
||||
}
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$result = curl_exec($ch);
|
||||
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curl_error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false) {
|
||||
echo json_encode(["error" => "网络请求失败: " . $curl_error], JSON_UNESCAPED_UNICODE);
|
||||
// 如果连接断开,不缓存也不报错
|
||||
if (!$_llm_alive) exit;
|
||||
|
||||
// ---- 处理错误 ----
|
||||
if ($result === false && empty($_llm_full_text)) {
|
||||
sse_error("网络请求失败: " . ($curl_error ?: "未知错误"));
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($http_code !== 200) {
|
||||
echo json_encode(["error" => "API请求失败 (HTTP $http_code)"], JSON_UNESCAPED_UNICODE);
|
||||
// 检查 HTTP 错误(如果 write callback 没有收到数据)
|
||||
if (empty($_llm_full_text) && $http_code !== 200) {
|
||||
sse_error("API请求失败 (HTTP $http_code)");
|
||||
exit;
|
||||
}
|
||||
|
||||
$resp_data = json_decode($response, true);
|
||||
// Anthropic 响应格式: content 是一个数组,取 type=text 的块
|
||||
$review_text = "";
|
||||
if ($resp_data && isset($resp_data['content']) && is_array($resp_data['content'])) {
|
||||
foreach ($resp_data['content'] as $block) {
|
||||
if (isset($block['type']) && $block['type'] === 'text' && isset($block['text'])) {
|
||||
$review_text .= $block['text'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (empty($review_text)) {
|
||||
echo json_encode(["error" => "API返回格式异常"], JSON_UNESCAPED_UNICODE);
|
||||
if (empty($_llm_full_text)) {
|
||||
sse_error("API返回为空");
|
||||
exit;
|
||||
}
|
||||
|
||||
// ---- 9. 缓存结果 ----
|
||||
// 自动建表(首次调用时)
|
||||
pdo_query("CREATE TABLE IF NOT EXISTS `llm_review` (
|
||||
`solution_id` INT NOT NULL,
|
||||
`review` TEXT NOT NULL,
|
||||
`create_time` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`solution_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
|
||||
// ---- 9. 缓存完整结果 ----
|
||||
$review_text = $_llm_full_text;
|
||||
|
||||
pdo_query("INSERT INTO `llm_review` (`solution_id`, `review`, `create_time`) VALUES (?, ?, NOW())", $sid, $review_text);
|
||||
|
||||
// ---- 10. 返回结果 ----
|
||||
echo json_encode(["review" => $review_text], JSON_UNESCAPED_UNICODE);
|
||||
$insert_ok = pdo_query("INSERT INTO `llm_review` (`solution_id`, `review`, `create_time`) VALUES (?, ?, NOW())", $sid, $review_text);
|
||||
if ($insert_ok === -1) {
|
||||
// 插入失败(可能已有记录),尝试更新
|
||||
pdo_query("UPDATE `llm_review` SET `review`=?, `create_time`=NOW() WHERE `solution_id`=?", $review_text, $sid);
|
||||
}
|
||||
|
||||
@@ -230,8 +230,12 @@ a.label:hover {
|
||||
href="cppreference.php">
|
||||
<i class="help icon"></i><?php echo $MSG_CPPREFRENCE?>
|
||||
</a>
|
||||
<a class="item <?php if ($url=="docs.php") echo "active";?>"
|
||||
href="<?php echo $path_fix?>docs.php"><i class="book icon"></i> 文档</a>
|
||||
<!-- 文档 (暂时隐藏) -->
|
||||
<!-- <a class="item <?php if ($url=="docs.php") echo "active";?>"
|
||||
href="<?php echo $path_fix?>docs.php"><i class="book icon"></i> 文档</a> -->
|
||||
<!-- AI聊天 -->
|
||||
<a class="item <?php if ($url=="llm-chat.php") echo "active";?>"
|
||||
href="<?php echo $path_fix?>llm-chat.php"><i class="comment icon"></i> AI聊天</a>
|
||||
<!-- 排名 -->
|
||||
<a class="desktop-only item <?php if ($url=="ranklist.php") echo "active";?> "
|
||||
href="<?php echo $path_fix?>ranklist.php"><i class="signal icon"></i> <?php echo $MSG_RANKLIST?></a>
|
||||
|
||||
@@ -215,41 +215,105 @@
|
||||
$('#llm-review-error').hide();
|
||||
$('#llm-review-result').hide();
|
||||
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url: 'llm-review.php?sid=<?php echo intval($id); ?>',
|
||||
dataType: 'json',
|
||||
timeout: 60000,
|
||||
success: function(data){
|
||||
btn.removeClass('loading');
|
||||
$('#llm-review-loading').hide();
|
||||
if(data.error){
|
||||
$('#llm-review-error-msg').text(data.error);
|
||||
$('#llm-review-error').show();
|
||||
btn.prop('disabled', false);
|
||||
} else if(data.review){
|
||||
$('#llm-review-result').show();
|
||||
var fullText = "";
|
||||
var container = document.getElementById('llm-review-content');
|
||||
// 先以纯文本放入,再用 Vditor 渲染为 Markdown HTML
|
||||
container.textContent = data.review;
|
||||
if(typeof HustOJVditor !== 'undefined' && HustOJVditor.renderMarkdownBlocks){
|
||||
HustOJVditor.renderMarkdownBlocks('#llm-review-content', {
|
||||
useTextContent: true
|
||||
var resultDiv = document.getElementById('llm-review-result');
|
||||
var reader = null;
|
||||
var decoder = new TextDecoder();
|
||||
|
||||
// 解析 SSE 事件
|
||||
var sseBuffer = "";
|
||||
function processSSE(text) {
|
||||
sseBuffer += text;
|
||||
var lines = sseBuffer.split('\n');
|
||||
sseBuffer = lines.pop(); // 保留未完成的行
|
||||
|
||||
var currentEvent = "";
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i];
|
||||
if (line.indexOf('event: ') === 0) {
|
||||
currentEvent = line.substring(7);
|
||||
} else if (line.indexOf('data: ') === 0) {
|
||||
var dataStr = line.substring(6);
|
||||
try {
|
||||
var data = JSON.parse(dataStr);
|
||||
if (currentEvent === 'chunk') {
|
||||
fullText += data.text;
|
||||
container.textContent = fullText;
|
||||
resultDiv.style.display = 'block';
|
||||
} else if (currentEvent === 'cached') {
|
||||
fullText = data.text;
|
||||
container.textContent = fullText;
|
||||
resultDiv.style.display = 'block';
|
||||
} else if (currentEvent === 'done') {
|
||||
finishReview();
|
||||
} else if (currentEvent === 'error') {
|
||||
showError(data.message || '未知错误');
|
||||
}
|
||||
} catch(e) {}
|
||||
currentEvent = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function finishReview() {
|
||||
btn.removeClass('loading').hide();
|
||||
document.getElementById('llm-review-loading').style.display = 'none';
|
||||
resultDiv.style.display = 'block';
|
||||
// 渲染 Markdown
|
||||
if (fullText && typeof HustOJVditor !== 'undefined' && HustOJVditor.renderMarkdownBlocks) {
|
||||
HustOJVditor.renderMarkdownBlocks('#llm-review-content', { useTextContent: true });
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
btn.removeClass('loading').prop('disabled', false);
|
||||
document.getElementById('llm-review-loading').style.display = 'none';
|
||||
document.getElementById('llm-review-error-msg').textContent = msg;
|
||||
document.getElementById('llm-review-error').style.display = 'block';
|
||||
}
|
||||
|
||||
if (typeof fetch === 'undefined') {
|
||||
showError('浏览器不支持,请升级浏览器');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('llm-review.php?sid=<?php echo intval($id); ?>')
|
||||
.then(function(response) {
|
||||
if (!response.ok) {
|
||||
return response.text().then(function(text) {
|
||||
try {
|
||||
var d = JSON.parse(text);
|
||||
showError(d.error || '请求失败 (HTTP ' + response.status + ')');
|
||||
} catch(e) {
|
||||
showError('请求失败 (HTTP ' + response.status + ')');
|
||||
}
|
||||
});
|
||||
}
|
||||
btn.hide();
|
||||
reader = response.body.getReader();
|
||||
function read() {
|
||||
reader.read().then(function(result) {
|
||||
if (result.done) {
|
||||
if (fullText) finishReview();
|
||||
return;
|
||||
}
|
||||
},
|
||||
error: function(xhr, status){
|
||||
btn.removeClass('loading').prop('disabled', false);
|
||||
$('#llm-review-loading').hide();
|
||||
var msg = '请求失败';
|
||||
if(status === 'timeout') msg = '请求超时,请稍后重试';
|
||||
$('#llm-review-error-msg').text(msg);
|
||||
$('#llm-review-error').show();
|
||||
var text = decoder.decode(result.value, { stream: true });
|
||||
processSSE(text);
|
||||
read();
|
||||
}).catch(function(err) {
|
||||
if (fullText) {
|
||||
finishReview();
|
||||
} else {
|
||||
showError('读取响应失败: ' + err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
read();
|
||||
})
|
||||
.catch(function(err) {
|
||||
showError('网络请求失败: ' + err.message);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<?php } ?>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user