init: React frontend + PHP/Slim backend chat app

This commit is contained in:
2026-05-14 10:46:20 +08:00
commit 893cd136c1
92 changed files with 20834 additions and 0 deletions
+349
View File
@@ -0,0 +1,349 @@
<?php
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
// Auth middleware
$authMiddleware = function (Request $request, $handler) {
$authHeader = $request->getHeaderLine('Authorization');
if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) {
$response = new \Slim\Psr7\Response();
$response->getBody()->write(json_encode(['error' => 'Unauthorized']));
return $response->withStatus(401)->withHeader('Content-Type', 'application/json');
}
$token = substr($authHeader, 7);
$decoded = jwtVerify($token);
if (!$decoded) {
$response = new \Slim\Psr7\Response();
$response->getBody()->write(json_encode(['error' => 'Invalid token']));
return $response->withStatus(401)->withHeader('Content-Type', 'application/json');
}
$request = $request->withAttribute('userId', $decoded['userId']);
return $handler->handle($request);
};
// Health check
$app->get('/api/health', function (Request $request, Response $response) {
$response->getBody()->write(json_encode(['status' => 'ok']));
return $response->withHeader('Content-Type', 'application/json');
});
// Auth routes
$app->post('/api/auth/login', function (Request $request, Response $response) {
$body = json_decode((string)$request->getBody(), true);
$password = $body['password'] ?? '';
$config = require __DIR__ . '/config.php';
if (!$password) {
$response->getBody()->write(json_encode(['error' => 'Password required']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
if ($password !== $config['password']) {
$response->getBody()->write(json_encode(['error' => 'Invalid password']));
return $response->withStatus(401)->withHeader('Content-Type', 'application/json');
}
$userId = 'admin';
$token = jwtGenerate($userId);
$response->getBody()->write(json_encode([
'token' => $token,
'user' => ['id' => $userId, 'username' => 'admin']
]));
return $response->withHeader('Content-Type', 'application/json');
});
$app->get('/api/auth/me', function (Request $request, Response $response) {
$userId = $request->getAttribute('userId');
$response->getBody()->write(json_encode([
'id' => $userId,
'username' => 'admin'
]));
return $response->withHeader('Content-Type', 'application/json');
})->add($authMiddleware);
// Conversation routes
$app->get('/api/conversations', function (Request $request, Response $response) {
$userId = $request->getAttribute('userId');
$db = getDb();
$stmt = $db->prepare("SELECT id, title, model_name, model_base_url, model_id, temperature, max_tokens, created_at, updated_at FROM conversations WHERE user_id = ? ORDER BY updated_at DESC");
$stmt->execute([$userId]);
$rows = $stmt->fetchAll();
$response->getBody()->write(json_encode($rows));
return $response->withHeader('Content-Type', 'application/json');
})->add($authMiddleware);
$app->get('/api/conversations/{id}', function (Request $request, Response $response, array $args) {
$userId = $request->getAttribute('userId');
$id = $args['id'];
$db = getDb();
$stmt = $db->prepare('SELECT * FROM conversations WHERE id = ? AND user_id = ?');
$stmt->execute([$id, $userId]);
$conv = $stmt->fetch();
if (!$conv) {
$response->getBody()->write(json_encode(['error' => 'Conversation not found']));
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
}
$stmt = $db->prepare('SELECT id, role, content, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp ASC');
$stmt->execute([$id]);
$messages = $stmt->fetchAll();
$response->getBody()->write(json_encode(array_merge($conv, ['messages' => $messages])));
return $response->withHeader('Content-Type', 'application/json');
})->add($authMiddleware);
$app->post('/api/conversations', function (Request $request, Response $response) {
$userId = $request->getAttribute('userId');
$body = json_decode((string)$request->getBody(), true);
$title = $body['title'] ?? '新对话';
$modelConfig = $body['modelConfig'] ?? null;
$db = getDb();
$id = bin2hex(random_bytes(16));
$now = time() * 1000;
$stmt = $db->prepare('INSERT INTO conversations (id, user_id, title, model_name, model_base_url, model_id, api_key, temperature, max_tokens, system_prompt, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
$stmt->execute([
$id, $userId, $title,
$modelConfig['name'] ?? null,
$modelConfig['baseUrl'] ?? null,
$modelConfig['model'] ?? null,
$modelConfig['apiKey'] ?? null,
$modelConfig['temperature'] ?? 0.7,
$modelConfig['maxTokens'] ?? 4096,
$modelConfig['systemPrompt'] ?? null,
$now, $now
]);
$stmt = $db->prepare('SELECT * FROM conversations WHERE id = ?');
$stmt->execute([$id]);
$conv = $stmt->fetch();
$response->getBody()->write(json_encode(array_merge($conv, ['messages' => []])));
return $response->withStatus(201)->withHeader('Content-Type', 'application/json');
})->add($authMiddleware);
$app->put('/api/conversations/{id}', function (Request $request, Response $response, array $args) {
$userId = $request->getAttribute('userId');
$id = $args['id'];
$body = json_decode((string)$request->getBody(), true);
$title = $body['title'] ?? null;
$modelConfig = $body['modelConfig'] ?? null;
$db = getDb();
$stmt = $db->prepare('SELECT id FROM conversations WHERE id = ? AND user_id = ?');
$stmt->execute([$id, $userId]);
if (!$stmt->fetch()) {
$response->getBody()->write(json_encode(['error' => 'Conversation not found']));
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
}
$now = time() * 1000;
$stmt = $db->prepare('UPDATE conversations SET title = COALESCE(?, title), model_name = COALESCE(?, model_name), model_base_url = COALESCE(?, model_base_url), model_id = COALESCE(?, model_id), api_key = COALESCE(?, api_key), temperature = COALESCE(?, temperature), max_tokens = COALESCE(?, max_tokens), system_prompt = COALESCE(?, system_prompt), updated_at = ? WHERE id = ?');
$stmt->execute([
$title,
$modelConfig['name'] ?? null,
$modelConfig['baseUrl'] ?? null,
$modelConfig['model'] ?? null,
$modelConfig['apiKey'] ?? null,
$modelConfig['temperature'] ?? null,
$modelConfig['maxTokens'] ?? null,
$modelConfig['systemPrompt'] ?? null,
$now, $id
]);
$stmt = $db->prepare('SELECT * FROM conversations WHERE id = ?');
$stmt->execute([$id]);
$conv = $stmt->fetch();
$stmt = $db->prepare('SELECT id, role, content, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp ASC');
$stmt->execute([$id]);
$messages = $stmt->fetchAll();
$response->getBody()->write(json_encode(array_merge($conv, ['messages' => $messages])));
return $response->withHeader('Content-Type', 'application/json');
})->add($authMiddleware);
$app->delete('/api/conversations/{id}', function (Request $request, Response $response, array $args) {
$userId = $request->getAttribute('userId');
$id = $args['id'];
$db = getDb();
$stmt = $db->prepare('DELETE FROM conversations WHERE id = ? AND user_id = ?');
$stmt->execute([$id, $userId]);
if ($stmt->rowCount() === 0) {
$response->getBody()->write(json_encode(['error' => 'Conversation not found']));
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
}
$response->getBody()->write(json_encode(['success' => true]));
return $response->withHeader('Content-Type', 'application/json');
})->add($authMiddleware);
$app->delete('/api/conversations/{id}/messages', function (Request $request, Response $response, array $args) {
$userId = $request->getAttribute('userId');
$id = $args['id'];
$db = getDb();
$stmt = $db->prepare('SELECT id FROM conversations WHERE id = ? AND user_id = ?');
$stmt->execute([$id, $userId]);
if (!$stmt->fetch()) {
$response->getBody()->write(json_encode(['error' => 'Conversation not found']));
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
}
$stmt = $db->prepare('DELETE FROM messages WHERE conversation_id = ?');
$stmt->execute([$id]);
$now = time() * 1000;
$stmt = $db->prepare('UPDATE conversations SET updated_at = ? WHERE id = ?');
$stmt->execute([$now, $id]);
$response->getBody()->write(json_encode(['success' => true]));
return $response->withHeader('Content-Type', 'application/json');
})->add($authMiddleware);
// Chat route (SSE)
$app->post('/api/chat', function (Request $request, Response $response) {
$userId = $request->getAttribute('userId');
$body = json_decode((string)$request->getBody(), true);
$conversationId = $body['conversationId'] ?? '';
$content = trim($body['content'] ?? '');
if (!$conversationId || !$content) {
$response->getBody()->write(json_encode(['error' => 'conversationId and content required']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
$db = getDb();
$stmt = $db->prepare('SELECT * FROM conversations WHERE id = ? AND user_id = ?');
$stmt->execute([$conversationId, $userId]);
$conv = $stmt->fetch();
if (!$conv) {
$response->getBody()->write(json_encode(['error' => 'Conversation not found']));
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
}
// Load history
$stmt = $db->prepare("SELECT role, content FROM messages WHERE conversation_id = ? ORDER BY timestamp ASC");
$stmt->execute([$conversationId]);
$history = $stmt->fetchAll();
// Build messages
$messages = [];
if ($conv['system_prompt']) {
$messages[] = ['role' => 'system', 'content' => $conv['system_prompt']];
}
foreach ($history as $msg) {
if ($msg['role'] !== 'system') {
$messages[] = ['role' => $msg['role'], 'content' => $msg['content']];
}
}
$messages[] = ['role' => 'user', 'content' => $content];
// Save user message
$userMsgId = bin2hex(random_bytes(16));
$now = time() * 1000;
$stmt = $db->prepare('INSERT INTO messages (id, conversation_id, role, content, timestamp) VALUES (?, ?, ?, ?, ?)');
$stmt->execute([$userMsgId, $conversationId, 'user', $content, $now]);
// Update title if first message
$stmt = $db->prepare('SELECT COUNT(*) as cnt FROM messages WHERE conversation_id = ?');
$stmt->execute([$conversationId]);
$msgCount = $stmt->fetch()['cnt'] ?? 0;
if ($msgCount == 1) {
$newTitle = mb_substr($content, 0, 20) . (mb_strlen($content) > 20 ? '...' : '');
$stmt = $db->prepare('UPDATE conversations SET title = ?, updated_at = ? WHERE id = ?');
$stmt->execute([$newTitle, $now, $conversationId]);
}
// Check model config
if (!$conv['model_base_url'] || !$conv['api_key'] || !$conv['model_id']) {
$errMsg = 'Error: 模型配置不完整,请先在「配置」中设置 API 地址、API Key 和模型 ID';
$errMsgId = bin2hex(random_bytes(16));
$stmt = $db->prepare('INSERT INTO messages (id, conversation_id, role, content, timestamp) VALUES (?, ?, ?, ?, ?)');
$stmt->execute([$errMsgId, $conversationId, 'assistant', $errMsg, $now]);
$response->getBody()->write(json_encode(['error' => 'Model config incomplete']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
// SSE output
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');
while (ob_get_level() > 0) ob_end_flush();
ob_implicit_flush(true);
// Send user message confirmation
echo "data: " . json_encode(['type' => 'user', 'id' => $userMsgId, 'content' => $content]) . "\n\n";
flush();
// Call LLM API via curl
$assistantMsgId = bin2hex(random_bytes(16));
$fullContent = '';
$payload = json_encode([
'model' => $conv['model_id'],
'messages' => $messages,
'temperature' => (float)$conv['temperature'],
'max_tokens' => (int)$conv['max_tokens'],
'stream' => true,
]);
$ch = curl_init($conv['model_base_url'] . '/chat/completions');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $conv['api_key'],
],
CURLOPT_RETURNTRANSFER => false,
CURLOPT_WRITEFUNCTION => function ($ch, $data) use (&$fullContent) {
$lines = explode("\n", $data);
foreach ($lines as $line) {
$line = trim($line);
if (!$line || !str_starts_with($line, 'data: ')) continue;
$jsonStr = substr($line, 6);
if ($jsonStr === '[DONE]') continue;
$parsed = json_decode($jsonStr, true);
if (!$parsed) continue;
$delta = $parsed['choices'][0]['delta']['content'] ?? '';
if ($delta) {
$fullContent .= $delta;
echo "data: " . json_encode(['type' => 'delta', 'content' => $delta]) . "\n\n";
flush();
}
}
return strlen($data);
},
CURLOPT_TIMEOUT => 120,
CURLOPT_CONNECTTIMEOUT => 30,
]);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_exec($ch);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
$errMsg = 'Error: ' . $error;
echo "data: " . json_encode(['type' => 'error', 'content' => $errMsg]) . "\n\n";
}
// Save assistant message
$stmt = $db->prepare('INSERT INTO messages (id, conversation_id, role, content, timestamp) VALUES (?, ?, ?, ?, ?)');
$stmt->execute([$assistantMsgId, $conversationId, 'assistant', $fullContent, time() * 1000]);
$stmt = $db->prepare('UPDATE conversations SET updated_at = ? WHERE id = ?');
$stmt->execute([time() * 1000, $conversationId]);
echo "data: " . json_encode(['type' => 'done', 'id' => $assistantMsgId, 'content' => $fullContent]) . "\n\n";
echo "data: [DONE]\n\n";
flush();
exit(0);
})->add($authMiddleware);