init: React frontend + PHP/Slim backend chat app
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user