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 (
-
-
加载中...
+
);
}
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 && (