fix: 登录后自动加载对话历史;新增生产部署指南 DEPLOY.md

- 修复 useChat useEffect 只在挂载时执行导致的登录后无历史问题
- 暴露 reloadConversations 函数,App.tsx 登录成功后主动调用
- 添加 DEPLOY.md 生产部署文档(Nginx + PHP-FPM)

2026-05-15
This commit is contained in:
2026-05-15 18:20:39 +08:00
parent 893cd136c1
commit 6b26ccb1e4
16 changed files with 1076 additions and 331 deletions
+4 -4
View File
@@ -9,7 +9,7 @@ require __DIR__ . '/../src/jwt.php';
$app = AppFactory::create();
// CORS for dev
// 开发环境跨域支持
$app->add(function ($request, $handler) {
$response = $handler->handle($request);
return $response
@@ -21,15 +21,15 @@ $app->add(function ($request, $handler) {
$app->addRoutingMiddleware();
$app->addErrorMiddleware(true, true, true);
// Options handler
// OPTIONS 预检请求处理
$app->options('/{routes:.+}', function ($request, $response) {
return $response;
});
// Init DB
// 初始化数据库
getDb();
// Register routes
// 注册路由
require __DIR__ . '/../src/routes.php';
$app->run();
+35 -2
View File
@@ -15,6 +15,8 @@ function getDb(): PDO {
if ($isNew) {
initDb($pdo);
} else {
migrateDb($pdo);
}
return $pdo;
@@ -37,8 +39,8 @@ function initDb(PDO $pdo): void {
model_base_url TEXT,
model_id TEXT,
api_key TEXT,
temperature REAL DEFAULT 0.7,
max_tokens INTEGER DEFAULT 4096,
temperature REAL DEFAULT 0.2,
max_tokens INTEGER DEFAULT 8192,
system_prompt TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
@@ -49,10 +51,41 @@ function initDb(PDO $pdo): void {
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK(role IN ('user','assistant','system')),
content TEXT NOT NULL DEFAULT '',
thinking TEXT DEFAULT NULL,
prompt_tokens INTEGER DEFAULT 0,
completion_tokens INTEGER DEFAULT 0,
timestamp INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS model_configs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
base_url TEXT,
model_id TEXT,
api_key TEXT,
temperature REAL DEFAULT 0.2,
max_tokens INTEGER DEFAULT 8192,
system_prompt TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id);
CREATE INDEX IF NOT EXISTS idx_conversations_user ON conversations(user_id);
");
}
// 数据库迁移:检查并添加新列
function migrateDb(PDO $pdo): void {
$columns = $pdo->query("PRAGMA table_info(messages)")->fetchAll(PDO::FETCH_COLUMN, 1);
if (!in_array('thinking', $columns)) {
$pdo->exec("ALTER TABLE messages ADD COLUMN thinking TEXT DEFAULT NULL");
}
if (!in_array('prompt_tokens', $columns)) {
$pdo->exec("ALTER TABLE messages ADD COLUMN prompt_tokens INTEGER DEFAULT 0");
}
if (!in_array('completion_tokens', $columns)) {
$pdo->exec("ALTER TABLE messages ADD COLUMN completion_tokens INTEGER DEFAULT 0");
}
}
+198 -33
View File
@@ -3,7 +3,7 @@
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
// Auth middleware
// JWT 认证中间件:验证请求头中的 Bearer Token
$authMiddleware = function (Request $request, $handler) {
$authHeader = $request->getHeaderLine('Authorization');
if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) {
@@ -22,13 +22,13 @@ $authMiddleware = function (Request $request, $handler) {
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'] ?? '';
@@ -61,7 +61,7 @@ $app->get('/api/auth/me', function (Request $request, Response $response) {
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();
@@ -85,7 +85,7 @@ $app->get('/api/conversations/{id}', function (Request $request, Response $respo
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 = $db->prepare('SELECT id, role, content, thinking, prompt_tokens, completion_tokens, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp ASC');
$stmt->execute([$id]);
$messages = $stmt->fetchAll();
@@ -110,8 +110,8 @@ $app->post('/api/conversations', function (Request $request, Response $response)
$modelConfig['baseUrl'] ?? null,
$modelConfig['model'] ?? null,
$modelConfig['apiKey'] ?? null,
$modelConfig['temperature'] ?? 0.7,
$modelConfig['maxTokens'] ?? 4096,
$modelConfig['temperature'] ?? 0.2,
$modelConfig['maxTokens'] ?? 8192,
$modelConfig['systemPrompt'] ?? null,
$now, $now
]);
@@ -157,7 +157,7 @@ $app->put('/api/conversations/{id}', function (Request $request, Response $respo
$stmt->execute([$id]);
$conv = $stmt->fetch();
$stmt = $db->prepare('SELECT id, role, content, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp ASC');
$stmt = $db->prepare('SELECT id, role, content, thinking, prompt_tokens, completion_tokens, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp ASC');
$stmt->execute([$id]);
$messages = $stmt->fetchAll();
@@ -203,14 +203,15 @@ $app->delete('/api/conversations/{id}/messages', function (Request $request, Res
return $response->withHeader('Content-Type', 'application/json');
})->add($authMiddleware);
// Chat route (SSE)
// 聊天路由(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'] ?? '');
$regenerateFrom = $body['regenerateFrom'] ?? null; // 重新生成:从指定消息 ID 开始
if (!$conversationId || !$content) {
if (!$conversationId || (!$content && !$regenerateFrom)) {
$response->getBody()->write(json_encode(['error' => 'conversationId and content required']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
@@ -225,12 +226,12 @@ $app->post('/api/chat', function (Request $request, Response $response) {
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']];
@@ -242,13 +243,30 @@ $app->post('/api/chat', function (Request $request, Response $response) {
}
$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]);
// 处理重新生成:删除指定消息之后的所有消息
if ($regenerateFrom) {
$stmt = $db->prepare('SELECT id, content, timestamp FROM messages WHERE id = ? AND conversation_id = ? AND role = ?');
$stmt->execute([$regenerateFrom, $conversationId, 'user']);
$targetMsg = $stmt->fetch();
if (!$targetMsg) {
$response->getBody()->write(json_encode(['error' => 'Message not found']));
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
}
$content = $targetMsg['content'];
$userMsgId = $targetMsg['id'];
$now = $targetMsg['timestamp'];
// 删除该消息之后的所有消息
$stmt = $db->prepare('DELETE FROM messages WHERE conversation_id = ? AND timestamp > ?');
$stmt->execute([$conversationId, $now]);
} else {
// 保存新的用户消息
$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;
@@ -258,7 +276,7 @@ $app->post('/api/chat', function (Request $request, Response $response) {
$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));
@@ -268,7 +286,7 @@ $app->post('/api/chat', function (Request $request, Response $response) {
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
// SSE output
// 设置 SSE 响应头
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
@@ -277,13 +295,17 @@ $app->post('/api/chat', function (Request $request, Response $response) {
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
// 通过 curl 调用大模型 API(支持 DeepSeek V4 reasoning_content
$assistantMsgId = bin2hex(random_bytes(16));
$fullContent = '';
$fullThinking = '';
$promptTokens = 0;
$completionTokens = 0;
$inThinking = true; // 当前是否在接收 thinking 内容
$payload = json_encode([
'model' => $conv['model_id'],
@@ -291,8 +313,15 @@ $app->post('/api/chat', function (Request $request, Response $response) {
'temperature' => (float)$conv['temperature'],
'max_tokens' => (int)$conv['max_tokens'],
'stream' => true,
'stream_options' => ['include_usage' => true],
]);
// 检测连接是否中断(用户点击停止)
$aborted = false;
if (function_exists('connection_aborted')) {
$aborted = connection_aborted();
}
$ch = curl_init($conv['model_base_url'] . '/chat/completions');
curl_setopt_array($ch, [
CURLOPT_POST => true,
@@ -302,7 +331,13 @@ $app->post('/api/chat', function (Request $request, Response $response) {
'Authorization: Bearer ' . $conv['api_key'],
],
CURLOPT_RETURNTRANSFER => false,
CURLOPT_WRITEFUNCTION => function ($ch, $data) use (&$fullContent) {
CURLOPT_WRITEFUNCTION => function ($ch, $data) use (
&$fullContent, &$fullThinking, &$inThinking, &$promptTokens, &$completionTokens, &$aborted
) {
if ($aborted || connection_aborted()) {
$aborted = true;
return 0; // 返回 0 中断 curl
}
$lines = explode("\n", $data);
foreach ($lines as $line) {
$line = trim($line);
@@ -311,10 +346,38 @@ $app->post('/api/chat', function (Request $request, Response $response) {
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";
// 处理 usage(最后一个 chunk
if (isset($parsed['usage'])) {
$promptTokens = $parsed['usage']['prompt_tokens'] ?? 0;
$completionTokens = $parsed['usage']['completion_tokens'] ?? 0;
continue;
}
$choice = $parsed['choices'][0] ?? null;
if (!$choice) continue;
$delta = $choice['delta'] ?? [];
// DeepSeek V4: reasoning_content(思考过程)
$reasoning = $delta['reasoning_content'] ?? '';
if ($reasoning) {
$fullThinking .= $reasoning;
echo "data: " . json_encode(['type' => 'thinking', 'content' => $reasoning]) . "\n\n";
flush();
continue;
}
// 正常 content(最终答案)
$content = $delta['content'] ?? '';
if ($content) {
// 第一次收到 content,表示 thinking 结束
if ($inThinking && $fullThinking) {
$inThinking = false;
echo "data: " . json_encode(['type' => 'thinking_end']) . "\n\n";
flush();
}
$fullContent .= $content;
echo "data: " . json_encode(['type' => 'delta', 'content' => $content]) . "\n\n";
flush();
}
}
@@ -324,26 +387,128 @@ $app->post('/api/chat', function (Request $request, Response $response) {
CURLOPT_CONNECTTIMEOUT => 30,
]);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_exec($ch);
$error = curl_error($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($error) {
if ($error && $error !== 'Callback aborted') {
$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]);
if ($httpCode >= 400) {
$errMsg = "API 请求失败 (HTTP {$httpCode})";
echo "data: " . json_encode(['type' => 'error', 'content' => $errMsg]) . "\n\n";
}
// 保存助手回复消息(包含 thinking 和 token 用量)
$stmt = $db->prepare('INSERT INTO messages (id, conversation_id, role, content, thinking, prompt_tokens, completion_tokens, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
$stmt->execute([
$assistantMsgId, $conversationId, 'assistant',
$fullContent, $fullThinking ?: null,
$promptTokens, $completionTokens,
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: " . json_encode([
'type' => 'done',
'id' => $assistantMsgId,
'content' => $fullContent,
'thinking' => $fullThinking,
'promptTokens' => $promptTokens,
'completionTokens' => $completionTokens,
]) . "\n\n";
echo "data: [DONE]\n\n";
flush();
exit(0);
})->add($authMiddleware);
// ========== 模型配置模板路由 ==========
// 获取当前用户的配置列表
$app->get('/api/model-configs', function (Request $request, Response $response) {
$userId = $request->getAttribute('userId');
$db = getDb();
$stmt = $db->prepare('SELECT id, name, base_url, model_id, api_key, temperature, max_tokens, system_prompt, created_at, updated_at FROM model_configs 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->post('/api/model-configs', function (Request $request, Response $response) {
$userId = $request->getAttribute('userId');
$body = json_decode((string)$request->getBody(), true);
$db = getDb();
$id = bin2hex(random_bytes(16));
$now = time() * 1000;
$stmt = $db->prepare('INSERT INTO model_configs (id, user_id, name, base_url, model_id, api_key, temperature, max_tokens, system_prompt, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
$stmt->execute([
$id, $userId,
$body['name'] ?? '未命名',
$body['baseUrl'] ?? null,
$body['model'] ?? null,
$body['apiKey'] ?? null,
$body['temperature'] ?? 0.2,
$body['maxTokens'] ?? 8192,
$body['systemPrompt'] ?? null,
$now, $now
]);
$stmt = $db->prepare('SELECT * FROM model_configs WHERE id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch();
$response->getBody()->write(json_encode($row));
return $response->withStatus(201)->withHeader('Content-Type', 'application/json');
})->add($authMiddleware);
// 更新配置
$app->put('/api/model-configs/{id}', function (Request $request, Response $response, array $args) {
$userId = $request->getAttribute('userId');
$id = $args['id'];
$body = json_decode((string)$request->getBody(), true);
$db = getDb();
$stmt = $db->prepare('SELECT id FROM model_configs WHERE id = ? AND user_id = ?');
$stmt->execute([$id, $userId]);
if (!$stmt->fetch()) {
$response->getBody()->write(json_encode(['error' => 'Config not found']));
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
}
$now = time() * 1000;
$stmt = $db->prepare('UPDATE model_configs SET name = ?, base_url = ?, model_id = ?, api_key = ?, temperature = ?, max_tokens = ?, system_prompt = ?, updated_at = ? WHERE id = ?');
$stmt->execute([
$body['name'] ?? null,
$body['baseUrl'] ?? null,
$body['model'] ?? null,
$body['apiKey'] ?? null,
$body['temperature'] ?? null,
$body['maxTokens'] ?? null,
$body['systemPrompt'] ?? null,
$now, $id
]);
$stmt = $db->prepare('SELECT * FROM model_configs WHERE id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch();
$response->getBody()->write(json_encode($row));
return $response->withHeader('Content-Type', 'application/json');
})->add($authMiddleware);
// 删除配置
$app->delete('/api/model-configs/{id}', function (Request $request, Response $response, array $args) {
$userId = $request->getAttribute('userId');
$id = $args['id'];
$db = getDb();
$stmt = $db->prepare('DELETE FROM model_configs WHERE id = ? AND user_id = ?');
$stmt->execute([$id, $userId]);
if ($stmt->rowCount() === 0) {
$response->getBody()->write(json_encode(['error' => 'Config 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);