diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..18e6cb6 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,94 @@ +# 生产部署指南 + +## 目录结构 + +``` +/www/chat-app/ +├── public/ # 前端 dist + PHP 入口 +│ ├── index.html # 前端入口 +│ ├── assets/ # 前端 JS/CSS +│ └── index.php # PHP 后端入口 +├── src/ # PHP 业务代码 +├── vendor/ # composer 依赖 +├── chat.db # SQLite 数据库 +└── composer.json +``` + +## 部署步骤 + +### 1. 安装 PHP 依赖 + +```bash +cd /www/chat-app +composer install --no-dev --optimize-autoloader +``` + +### 2. 前端构建并放入 public + +本地执行: +```bash +npm run build +cp -r dist/* server/public/ +``` + +### 3. Nginx 配置 + +```nginx +server { + listen 80; + server_name your-domain.com; + root /www/chat-app/public; + index index.html index.php; + + # 前端静态资源(JS/CSS/图片等) + location /assets/ { + try_files $uri =404; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # API 请求 → PHP + location /api/ { + try_files $uri /index.php$is_args$args; + } + + # 前端路由(SPA)→ index.html + location / { + try_files $uri $uri/ /index.html; + } + + location ~ \.php$ { + fastcgi_pass 127.0.0.1:9000; # 或 unix:/run/php/php8.3-fpm.sock + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + } +} +``` + +### 4. PHP-FPM 配置要点 + +确保 `php.ini` 中: +```ini +; SQLite 需要 +extension=pdo_sqlite + +; SSE 流式输出需要 +output_buffering = Off +zlib.output_compression = Off +``` + +### 5. 数据库权限 + +```bash +chmod 666 /www/chat-app/chat.db +``` + +## 简化版:PHP 内置服务器(仅测试) + +```bash +cd /www/chat-app/public +php -S 0.0.0.0:8000 +``` + +> 注意:PHP 内置服务器性能差,不适合生产。 diff --git a/README.md b/README.md index d2e7761..93c6d79 100755 --- a/README.md +++ b/README.md @@ -1,73 +1,82 @@ -# React + TypeScript + Vite +# Chat App -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +一个自托管的 LLM 聊天应用,支持多会话管理、流式响应和模型配置。 -Currently, two official plugins are available: +## 技术栈 -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +**前端** +- React 19 + TypeScript + Vite +- Tailwind CSS 3.4 + shadcn/ui +- SSE 流式接收 AI 回复 -## React Compiler +**后端** +- PHP 8.3 + Slim 4 +- SQLite 单文件数据库 +- JWT 认证 -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +## 功能特性 -## Expanding the ESLint configuration +- 🖥️ 终端风格登录页 +- 💬 多会话聊天管理 +- ⚡ SSE 流式响应 +- 🔧 模型配置面板(DeepSeek / Kimi / 自定义) +- 🔐 固定密码登录(通过 `CHAT_PASSWORD` 环境变量) +- 📱 响应式布局 -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: +## 快速开始 -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... +### 安装依赖 - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, +```bash +# 前端依赖 +npm install - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +# 后端依赖(PHP + Composer) +cd server && composer install ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: +### 开发运行 -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' +```bash +# 同时启动前端和后端 +npm run dev -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +# 前端 http://localhost:3000 +# 后端 http://localhost:8000 +``` + +### 生产构建 + +```bash +# 构建前端 +npm run build + +# 后端无需构建,直接部署 server/ 目录即可 +``` + +## 环境变量 + +| 变量 | 说明 | 默认值 | +|------|------|--------| +| `CHAT_PASSWORD` | 登录密码 | `admin` | +| `JWT_SECRET` | JWT 签名密钥 | 内置默认值 | + +## 部署 + +将以下目录上传至服务器: +- `dist/` — 前端构建产物 +- `server/` — PHP 后端源码 + +配置 Web 服务器将 `server/public/` 设为根目录,并设置 `dist/` 的别名。 + +## 项目结构 + +``` +chat-app/ +├── src/ # 前端源码 +├── server/ # PHP 后端 +│ ├── public/ # Web 入口 +│ └── src/ # 业务代码 +├── dist/ # 前端构建产物(Git 忽略) +└── package.json ``` diff --git a/package.json b/package.json index 18e3d2b..eb9a3aa 100755 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"", "dev:client": "vite", - "dev:server": "cd server && php -S localhost:8000 -t public", + "dev:server": "cd server && /opt/homebrew/bin/php -S localhost:8000 -t public", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview" diff --git a/server.zip b/server.zip new file mode 100644 index 0000000..1f11109 Binary files /dev/null and b/server.zip differ diff --git a/server/public/index.php b/server/public/index.php index b3660d3..8305621 100644 --- a/server/public/index.php +++ b/server/public/index.php @@ -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(); diff --git a/server/src/db.php b/server/src/db.php index dddaca3..389de3c 100644 --- a/server/src/db.php +++ b/server/src/db.php @@ -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"); + } +} diff --git a/server/src/routes.php b/server/src/routes.php index de6cc83..54562b0 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -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); diff --git a/src/App.tsx b/src/App.tsx index a39133c..506f2ac 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,21 +18,34 @@ function App() { updateModelConfig, clearMessages, sendMessage, + regenerateMessage, stopStreaming, + reloadConversations, } = useChat(); const [sidebarOpen, setSidebarOpen] = useState(true); + const handleLogin = async (password: string) => { + const ok = await login(password); + if (ok) { + reloadConversations(); + } + return ok; + }; + if (isLoading) { return ( -
-
加载中...
+
+
+
+ INITIALIZING... +
); } if (!isAuthenticated) { - return ; + return ; } return ( @@ -53,6 +66,7 @@ function App() { conversation={activeConversation} isStreaming={isStreaming} onSendMessage={sendMessage} + onRegenerateMessage={regenerateMessage} onStopStreaming={stopStreaming} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen(prev => !prev)} diff --git a/src/api/chat.ts b/src/api/chat.ts index de01b8a..f9531c9 100644 --- a/src/api/chat.ts +++ b/src/api/chat.ts @@ -1,20 +1,25 @@ const API_BASE = import.meta.env.VITE_API_BASE || ''; export interface ChatStreamEvent { - type: 'user' | 'delta' | 'done' | 'error'; + type: 'user' | 'thinking' | 'thinking_end' | 'delta' | 'done' | 'error'; id?: string; content?: string; + thinking?: string; + promptTokens?: number; + completionTokens?: number; } -export async function* sendMessageStream(conversationId: string, content: string): AsyncGenerator { +export async function* sendMessageStream(conversationId: string, content: string, regenerateFrom?: string): AsyncGenerator { const token = localStorage.getItem('chat_token'); + const body: Record = { conversationId, content }; + if (regenerateFrom) body.regenerateFrom = regenerateFrom; const res = await fetch(`${API_BASE}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, - body: JSON.stringify({ conversationId, content }), + body: JSON.stringify(body), }); if (!res.ok) { diff --git a/src/api/conversations.ts b/src/api/conversations.ts index 3887731..8bc295e 100644 --- a/src/api/conversations.ts +++ b/src/api/conversations.ts @@ -5,6 +5,9 @@ export interface ApiMessage { id: string; role: 'user' | 'assistant' | 'system'; content: string; + thinking: string | null; + prompt_tokens: number; + completion_tokens: number; timestamp: number; } @@ -29,8 +32,8 @@ function rowToModelConfig(row: ApiConversation): ModelConfig { apiKey: row.api_key || '', baseUrl: row.model_base_url || '', model: row.model_id || '', - temperature: row.temperature ?? 0.7, - maxTokens: row.max_tokens ?? 4096, + temperature: row.temperature ?? 0.2, + maxTokens: row.max_tokens ?? 8192, systemPrompt: row.system_prompt || '', }; } diff --git a/src/api/model-configs.ts b/src/api/model-configs.ts new file mode 100644 index 0000000..7fe185b --- /dev/null +++ b/src/api/model-configs.ts @@ -0,0 +1,84 @@ +import { apiGet, apiPost, apiPut, apiDelete } from './client'; +import type { ModelConfig } from '@/types'; + +interface ApiModelConfig { + id: string; + name: string; + base_url: string; + model_id: string; + api_key: string; + temperature: number; + max_tokens: number; + system_prompt: string; + created_at: number; + updated_at: number; +} + +function rowToModelConfig(row: ApiModelConfig): ModelConfig { + return { + name: row.name, + apiKey: row.api_key || '', + baseUrl: row.base_url || '', + model: row.model_id || '', + temperature: row.temperature ?? 0.2, + maxTokens: row.max_tokens ?? 8192, + systemPrompt: row.system_prompt || '', + }; +} + +export interface SavedModelConfig { + id: string; + config: ModelConfig; + createdAt: number; + updatedAt: number; +} + +export async function listModelConfigs(): Promise { + const rows = await apiGet('/model-configs') as ApiModelConfig[]; + return rows.map(row => ({ + id: row.id, + config: rowToModelConfig(row), + createdAt: row.created_at, + updatedAt: row.updated_at, + })); +} + +export async function createModelConfig(config: ModelConfig): Promise { + const row = await apiPost('/model-configs', { + name: config.name, + baseUrl: config.baseUrl, + model: config.model, + apiKey: config.apiKey, + temperature: config.temperature, + maxTokens: config.maxTokens, + systemPrompt: config.systemPrompt, + }) as ApiModelConfig; + return { + id: row.id, + config: rowToModelConfig(row), + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export async function updateModelConfig(id: string, config: ModelConfig): Promise { + const row = await apiPut(`/model-configs/${id}`, { + name: config.name, + baseUrl: config.baseUrl, + model: config.model, + apiKey: config.apiKey, + temperature: config.temperature, + maxTokens: config.maxTokens, + systemPrompt: config.systemPrompt, + }) as ApiModelConfig; + return { + id: row.id, + config: rowToModelConfig(row), + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export async function deleteModelConfig(id: string): Promise { + await apiDelete(`/model-configs/${id}`); +} diff --git a/src/components/ChatArea.tsx b/src/components/ChatArea.tsx index 298dc2f..5fbe049 100755 --- a/src/components/ChatArea.tsx +++ b/src/components/ChatArea.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect, useCallback } from 'react'; import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { @@ -11,6 +12,7 @@ import { Check, Menu, Sparkles, + RefreshCw, } from 'lucide-react'; import type { Conversation, Message } from '@/types'; @@ -18,6 +20,7 @@ interface ChatAreaProps { conversation: Conversation | null; isStreaming: boolean; onSendMessage: (content: string) => void; + onRegenerateMessage: (messageId: string) => void; onStopStreaming: () => void; sidebarOpen: boolean; onToggleSidebar: () => void; @@ -60,8 +63,9 @@ function CodeBlock({ language, value }: { language: string; value: string }) { ); } -function MessageBubble({ message, isStreaming }: { message: Message; isStreaming: boolean }) { +function MessageBubble({ message, isStreaming, onRegenerate }: { message: Message; isStreaming: boolean; onRegenerate?: () => void }) { const [copied, setCopied] = useState(false); + const [thinkingOpen, setThinkingOpen] = useState(false); const handleCopy = async () => { await navigator.clipboard.writeText(message.content); @@ -101,9 +105,30 @@ function MessageBubble({ message, isStreaming }: { message: Message; isStreaming
+ {/* 思考过程(可折叠) */} + {message.thinking && ( +
+ + {thinkingOpen && ( +
+ {message.thinking} +
+ )} +
+ )} {message.content || isStreaming ? ( -
+
- {/* Streaming cursor */} + {/* 流式输出光标 */} {isStreaming && !message.content && ( )} @@ -189,15 +214,37 @@ function MessageBubble({ message, isStreaming }: { message: Message; isStreaming
)} - {/* Copy button for assistant messages */} - {message.content && ( - + {/* Token 用量 */} + {(message.promptTokens || message.completionTokens) && ( +
+ {message.promptTokens ? 输入: {message.promptTokens} tokens : null} + {message.completionTokens ? 输出: {message.completionTokens} tokens : null} +
)} + + {/* 操作按钮:复制 + 重新生成 */} +
+ {message.content && ( + + )} + {onRegenerate && message.content && !isStreaming && ( + + )} +
@@ -208,6 +255,7 @@ export default function ChatArea({ conversation, isStreaming, onSendMessage, + onRegenerateMessage, onStopStreaming, sidebarOpen, onToggleSidebar, @@ -216,7 +264,7 @@ export default function ChatArea({ const textareaRef = useRef(null); const messagesEndRef = useRef(null); - // Auto-resize textarea + // 输入框自动调整高度 useEffect(() => { const textarea = textareaRef.current; if (textarea) { @@ -225,7 +273,7 @@ export default function ChatArea({ } }, [input]); - // Auto-scroll to bottom + // 自动滚动到底部 useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [conversation?.messages]); @@ -249,7 +297,7 @@ export default function ChatArea({ return (
- {/* Header */} + {/* 顶部标题栏 */}
{!sidebarOpen && ( @@ -279,7 +327,7 @@ export default function ChatArea({ )}
- {/* Messages area */} + {/* 消息列表区域 */}
{!conversation || conversation.messages.length === 0 ? (
@@ -310,11 +358,18 @@ export default function ChatArea({
) : (
- {conversation.messages.map(message => ( + {conversation.messages.map((message, idx) => ( { + // 找到该助手消息对应的用户消息 + const userMsg = conversation.messages[idx - 1]; + if (userMsg && userMsg.role === 'user') { + onRegenerateMessage(userMsg.id); + } + }} /> ))}
@@ -322,7 +377,7 @@ export default function ChatArea({ )}
- {/* Input area */} + {/* 输入区域 */}
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index c18d081..a2026d3 100755 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -14,9 +14,14 @@ import { Key, Save, RotateCcw, + Pencil, + ChevronLeft, + Layers, } from 'lucide-react'; import type { Conversation, ModelConfig } from '@/types'; import { PRESET_MODELS, DEFAULT_MODEL_CONFIG } from '@/types'; +import { listModelConfigs, createModelConfig, updateModelConfig, deleteModelConfig } from '@/api/model-configs'; +import type { SavedModelConfig } from '@/api/model-configs'; interface SidebarProps { conversations: Conversation[]; @@ -44,13 +49,28 @@ export default function Sidebar({ onToggle, }: SidebarProps) { const [showConfig, setShowConfig] = useState(false); + const [configMode, setConfigMode] = useState<'list' | 'edit'>('list'); const [editingConfig, setEditingConfig] = useState(DEFAULT_MODEL_CONFIG); + const [editingConfigId, setEditingConfigId] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState(null); const [pendingOpenConfig, setPendingOpenConfig] = useState(false); + // 从后端 API 加载已保存的配置列表 + const [savedConfigs, setSavedConfigs] = useState([]); + const [loadingConfigs, setLoadingConfigs] = useState(false); + + useEffect(() => { + if (!showConfig) return; + setLoadingConfigs(true); + listModelConfigs() + .then(setSavedConfigs) + .catch(err => console.error('Failed to load model configs:', err)) + .finally(() => setLoadingConfigs(false)); + }, [showConfig]); + const handleEditConfig = () => { if (activeConversation) { - setEditingConfig({ ...activeConversation.modelConfig }); + setConfigMode('list'); setShowConfig(true); } else { onCreateConversation(); @@ -58,22 +78,65 @@ export default function Sidebar({ } }; - // Open config when a new conversation is created via the config button + // 通过配置按钮创建新会话后,自动打开配置面板 useEffect(() => { if (pendingOpenConfig && activeConversation) { - setEditingConfig({ ...activeConversation.modelConfig }); + setConfigMode('list'); setShowConfig(true); setPendingOpenConfig(false); } }, [pendingOpenConfig, activeConversation]); - const handleSaveConfig = () => { + // 应用配置到当前会话 + const handleApplyConfig = (config: ModelConfig) => { if (activeConversation) { - onUpdateModelConfig(activeConversation.id, editingConfig); - setShowConfig(false); + onUpdateModelConfig(activeConversation.id, config); } }; + // 打开编辑表单 + const handleOpenEdit = (saved: SavedModelConfig) => { + setEditingConfig({ ...saved.config }); + setEditingConfigId(saved.id); + setConfigMode('edit'); + }; + + // 打开新建表单 + const handleOpenCreate = () => { + setEditingConfig({ ...DEFAULT_MODEL_CONFIG, name: '新配置' }); + setEditingConfigId(null); + setConfigMode('edit'); + }; + + // 保存配置(新建或编辑) + const handleSaveConfig = async () => { + try { + if (editingConfigId) { + await updateModelConfig(editingConfigId, editingConfig); + } else { + await createModelConfig(editingConfig); + } + // 刷新列表 + const configs = await listModelConfigs(); + setSavedConfigs(configs); + handleApplyConfig(editingConfig); + setConfigMode('list'); + } catch (err) { + console.error('Save config failed:', err); + } + }; + + // 删除配置 + const handleDeleteConfig = async (id: string) => { + try { + await deleteModelConfig(id); + setSavedConfigs(prev => prev.filter(c => c.id !== id)); + } catch (err) { + console.error('Delete config failed:', err); + } + }; + + // 预设模型快速填充 const handlePresetSelect = (preset: typeof PRESET_MODELS[0]) => { setEditingConfig(prev => ({ ...prev, @@ -94,7 +157,7 @@ export default function Sidebar({ return ( <> - {/* Mobile overlay */} + {/* 移动端遮罩层 */} {isOpen && (
)} - {/* Sidebar */} + {/* 侧边栏 */}
- {/* Conversation list */} + {/* 会话列表 */}
{conversations.length === 0 ? (
@@ -182,7 +245,7 @@ export default function Sidebar({ )}
- {/* Bottom actions */} + {/* 底部操作区 */}
{activeConversation && ( + ) : null} - 模型配置 + {configMode === 'list' ? '模型配置' : (editingConfigId ? '编辑配置' : '新建配置')}
-
- {/* Preset models */} -
- -
- {PRESET_MODELS.map(preset => ( - - ))} + {configMode === 'list' ? ( + /* ===== 配置列表模式 ===== */ +
+ {/* 新增配置按钮 */} + + + {loadingConfigs ? ( +
加载中...
+ ) : savedConfigs.length === 0 ? ( +
+ +

暂无保存的配置

+

点击上方「新建配置」创建

+
+ ) : ( +
+ {savedConfigs.map(saved => { + const isActive = activeConversation?.modelConfig.name === saved.config.name; + return ( +
+
+
+
+ + {saved.config.name} + + {isActive && ( + 当前使用 + )} +
+
+ {saved.config.model} · {saved.config.baseUrl?.replace(/^https?:\/\//, '').split('/')[0]} +
+
+ temp: {saved.config.temperature} · max: {saved.config.maxTokens} tokens +
+
+
+ + +
+
+ {/* 应用按钮 */} + {!isActive && ( + + )} +
+ ); + })} +
+ )} + + {/* 预设模型(快速新建) */} +
+ +
+ {PRESET_MODELS.map(preset => ( + + ))} +
- - {/* Config name */} -
- - setEditingConfig(prev => ({ ...prev, name: e.target.value }))} - className="w-full bg-[#f5f5f5] border border-[#ddd] rounded-lg px-4 py-3 text-sm text-[#333] outline-none focus:border-[#16a34a]/50 transition-colors" - placeholder="给这个配置起个名字" - /> -
- - {/* Base URL */} -
- - setEditingConfig(prev => ({ ...prev, baseUrl: e.target.value }))} - className="w-full bg-[#f5f5f5] border border-[#ddd] rounded-lg px-4 py-3 text-sm text-[#333] outline-none focus:border-[#16a34a]/50 transition-colors font-mono" - placeholder="https://api.example.com" - /> -
- - {/* Model name */} -
- - setEditingConfig(prev => ({ ...prev, model: e.target.value }))} - className="w-full bg-[#f5f5f5] border border-[#ddd] rounded-lg px-4 py-3 text-sm text-[#333] outline-none focus:border-[#16a34a]/50 transition-colors font-mono" - placeholder="gpt-4o" - /> -
- - {/* API Key */} -
- - setEditingConfig(prev => ({ ...prev, apiKey: e.target.value }))} - className="w-full bg-[#f5f5f5] border border-[#ddd] rounded-lg px-4 py-3 text-sm text-[#333] outline-none focus:border-[#16a34a]/50 transition-colors font-mono" - placeholder="sk-..." - /> -
- - {/* Temperature */} -
- - setEditingConfig(prev => ({ ...prev, temperature: parseFloat(e.target.value) }))} - className="w-full accent-[#16a34a]" - /> -
- 精确 (0) - 平衡 (1) - 创意 (2) + ) : ( + /* ===== 编辑/新建配置模式 ===== */ +
+ {/* 预设模型快速填充 */} +
+ +
+ {PRESET_MODELS.map(preset => ( + + ))} +
-
- {/* Max Tokens */} -
- - setEditingConfig(prev => ({ ...prev, maxTokens: parseInt(e.target.value) }))} - className="w-full accent-[#16a34a]" - /> -
+ {/* 配置名称 */} +
+ + setEditingConfig(prev => ({ ...prev, name: e.target.value }))} + className="w-full bg-white border border-[#ddd] rounded-lg px-4 py-2.5 text-sm text-[#333] outline-none focus:border-[#16a34a]/50 transition-colors" + placeholder="给这个配置起个名字" + /> +
- {/* System Prompt */} -
- -