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
+94
View File
@@ -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 内置服务器性能差,不适合生产。
+67 -58
View File
@@ -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
```
+1 -1
View File
@@ -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"
BIN
View File
Binary file not shown.
+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);
+17 -3
View File
@@ -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 (
<div className="w-screen h-screen flex items-center justify-center bg-white">
<div className="text-sm text-[#999]">...</div>
<div className="w-screen h-screen flex items-center justify-center bg-[#0d0d0d]">
<div className="flex flex-col items-center gap-3">
<div className="w-6 h-6 border-2 border-[#333] border-t-[#0f0] rounded-full animate-spin" />
<span className="font-mono text-xs text-[#555]">INITIALIZING...</span>
</div>
</div>
);
}
if (!isAuthenticated) {
return <LoginPage onLogin={login} />;
return <LoginPage onLogin={handleLogin} />;
}
return (
@@ -53,6 +66,7 @@ function App() {
conversation={activeConversation}
isStreaming={isStreaming}
onSendMessage={sendMessage}
onRegenerateMessage={regenerateMessage}
onStopStreaming={stopStreaming}
sidebarOpen={sidebarOpen}
onToggleSidebar={() => setSidebarOpen(prev => !prev)}
+8 -3
View File
@@ -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<ChatStreamEvent> {
export async function* sendMessageStream(conversationId: string, content: string, regenerateFrom?: string): AsyncGenerator<ChatStreamEvent> {
const token = localStorage.getItem('chat_token');
const body: Record<string, string> = { 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) {
+5 -2
View File
@@ -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 || '',
};
}
+84
View File
@@ -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<SavedModelConfig[]> {
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<SavedModelConfig> {
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<SavedModelConfig> {
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<void> {
await apiDelete(`/model-configs/${id}`);
}
+72 -17
View File
@@ -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
</span>
</div>
<div className="group relative">
{/* 思考过程(可折叠) */}
{message.thinking && (
<div className="mb-3">
<button
onClick={() => setThinkingOpen(!thinkingOpen)}
className="flex items-center gap-1.5 text-xs text-[#888] hover:text-[#16a34a] transition-colors"
>
<svg className={`w-3 h-3 transition-transform ${thinkingOpen ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
<span></span>
<span className="text-[#bbb]">({message.thinking.length} )</span>
</button>
{thinkingOpen && (
<div className="mt-2 p-3 bg-[#f8f8f8] border border-[#e5e5e5] rounded-lg text-xs text-[#888] leading-relaxed whitespace-pre-wrap font-mono">
{message.thinking}
</div>
)}
</div>
)}
{message.content || isStreaming ? (
<div className="text-[#d4d4d4] text-sm leading-relaxed">
<div className="text-[#333] text-sm leading-relaxed">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ inline, className, children, ...props }: any) {
const match = /language-(\w+)/.exec(className || '');
@@ -176,7 +201,7 @@ function MessageBubble({ message, isStreaming }: { message: Message; isStreaming
{message.content || (isStreaming ? '' : '')}
</ReactMarkdown>
{/* Streaming cursor */}
{/* 流式输出光标 */}
{isStreaming && !message.content && (
<span className="inline-block w-2 h-4 bg-[#0f0] animate-pulse ml-0.5" />
)}
@@ -189,15 +214,37 @@ function MessageBubble({ message, isStreaming }: { message: Message; isStreaming
</div>
)}
{/* Copy button for assistant messages */}
{message.content && (
<button
onClick={handleCopy}
className="absolute -right-8 top-0 p-1 text-[#ccc] hover:text-[#16a34a] opacity-0 group-hover:opacity-100 transition-all"
>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
</button>
{/* Token 用量 */}
{(message.promptTokens || message.completionTokens) && (
<div className="flex items-center gap-3 mt-2 text-[10px] text-[#bbb]">
{message.promptTokens ? <span>: {message.promptTokens} tokens</span> : null}
{message.completionTokens ? <span>: {message.completionTokens} tokens</span> : null}
</div>
)}
{/* 操作按钮:复制 + 重新生成 */}
<div className="flex items-center gap-2 mt-2">
{message.content && (
<button
onClick={handleCopy}
className="flex items-center gap-1 text-[10px] text-[#bbb] hover:text-[#16a34a] transition-colors"
title="复制"
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
<span>{copied ? '已复制' : '复制'}</span>
</button>
)}
{onRegenerate && message.content && !isStreaming && (
<button
onClick={onRegenerate}
className="flex items-center gap-1 text-[10px] text-[#bbb] hover:text-[#16a34a] transition-colors"
title="重新生成"
>
<RefreshCw className="w-3 h-3" />
<span></span>
</button>
)}
</div>
</div>
</div>
</div>
@@ -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<HTMLTextAreaElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(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 (
<div className="flex-1 flex flex-col h-full bg-white relative">
{/* Header */}
{/* 顶部标题栏 */}
<div className="flex items-center justify-between px-4 py-3 border-b border-[#1a1a1a]">
<div className="flex items-center gap-3">
{!sidebarOpen && (
@@ -279,7 +327,7 @@ export default function ChatArea({
)}
</div>
{/* Messages area */}
{/* 消息列表区域 */}
<div className="flex-1 overflow-y-auto px-4 md:px-8 py-6 custom-scrollbar">
{!conversation || conversation.messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-[#bbb]">
@@ -310,11 +358,18 @@ export default function ChatArea({
</div>
) : (
<div className="max-w-3xl mx-auto">
{conversation.messages.map(message => (
{conversation.messages.map((message, idx) => (
<MessageBubble
key={message.id}
message={message}
isStreaming={isStreaming && message.role === 'assistant' && message.id === conversation.messages[conversation.messages.length - 1]?.id}
onRegenerate={message.role === 'user' ? undefined : () => {
// 找到该助手消息对应的用户消息
const userMsg = conversation.messages[idx - 1];
if (userMsg && userMsg.role === 'user') {
onRegenerateMessage(userMsg.id);
}
}}
/>
))}
<div ref={messagesEndRef} />
@@ -322,7 +377,7 @@ export default function ChatArea({
)}
</div>
{/* Input area */}
{/* 输入区域 */}
<div className="px-4 md:px-8 pb-6 pt-4">
<div className="max-w-3xl mx-auto relative">
<div className="flex items-end gap-3 bg-white border border-[#d0d0d0] rounded-xl p-4 shadow-md transition-all">
+342 -153
View File
@@ -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<ModelConfig>(DEFAULT_MODEL_CONFIG);
const [editingConfigId, setEditingConfigId] = useState<string | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [pendingOpenConfig, setPendingOpenConfig] = useState(false);
// 从后端 API 加载已保存的配置列表
const [savedConfigs, setSavedConfigs] = useState<SavedModelConfig[]>([]);
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 && (
<div
className="fixed inset-0 bg-black/20 z-40 md:hidden"
@@ -102,14 +165,14 @@ export default function Sidebar({
/>
)}
{/* Sidebar */}
{/* 侧边栏 */}
<aside
className={`fixed md:relative z-50 h-full bg-white border-r border-[#e5e5e5] flex flex-col transition-all duration-300 ease-in-out ${
isOpen ? 'w-[280px] translate-x-0' : 'w-0 -translate-x-full md:w-0 md:translate-x-0'
} overflow-hidden`}
style={{ minWidth: isOpen ? '280px' : '0' }}
>
{/* Top bar */}
{/* 顶部操作栏 */}
<div className="flex items-center justify-between p-3 border-b border-[#e5e5e5]">
<button
onClick={() => onCreateConversation()}
@@ -136,7 +199,7 @@ export default function Sidebar({
</div>
</div>
{/* Conversation list */}
{/* 会话列表 */}
<div className="flex-1 overflow-y-auto py-2 px-2 custom-scrollbar">
{conversations.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-[#999]">
@@ -182,7 +245,7 @@ export default function Sidebar({
)}
</div>
{/* Bottom actions */}
{/* 底部操作区 */}
<div className="p-3 border-t border-[#e5e5e5] space-y-1">
{activeConversation && (
<button
@@ -203,15 +266,23 @@ export default function Sidebar({
</div>
</aside>
{/* Model Config Panel (Slide-over) */}
{/* 模型配置面板(滑出式) */}
{showConfig && (
<div className="fixed inset-0 z-[60] flex">
<div className="flex-1 bg-black/20" onClick={() => setShowConfig(false)} />
<div className="w-[480px] bg-[#fafafa] border-l border-[#e0e0e0] h-full overflow-y-auto custom-scrollbar">
<div className="flex items-center justify-between p-5 border-b border-[#e5e5e5]">
<h3 className="text-sm font-medium text-[#333] flex items-center gap-2">
{configMode === 'edit' ? (
<button
onClick={() => setConfigMode('list')}
className="flex items-center gap-1 text-[#888] hover:text-[#000] transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
) : null}
<Settings className="w-4 h-4 text-[#16a34a]" />
{configMode === 'list' ? '模型配置' : (editingConfigId ? '编辑配置' : '新建配置')}
</h3>
<button
onClick={() => setShowConfig(false)}
@@ -221,152 +292,270 @@ export default function Sidebar({
</button>
</div>
<div className="p-5 space-y-6">
{/* Preset models */}
<div>
<label className="flex items-center gap-2 text-sm text-[#888] mb-3">
<Sparkles className="w-4 h-4" />
</label>
<div className="space-y-1">
{PRESET_MODELS.map(preset => (
<button
key={preset.label}
onClick={() => handlePresetSelect(preset)}
className={`w-full text-left px-4 py-3 text-sm rounded-lg transition-all ${
editingConfig.model === preset.model && editingConfig.baseUrl === preset.baseUrl
? 'bg-[#16a34a]/10 text-[#16a34a] border border-[#16a34a]/30'
: 'text-[#666] hover:bg-[#f0f0f0] border border-transparent'
}`}
>
<div className="font-medium">{preset.label}</div>
</button>
))}
{configMode === 'list' ? (
/* ===== 配置列表模式 ===== */
<div className="p-5">
{/* 新增配置按钮 */}
<button
onClick={handleOpenCreate}
className="w-full flex items-center justify-center gap-2 py-3 mb-5 border-2 border-dashed border-[#ddd] text-[#888] rounded-lg hover:border-[#16a34a] hover:text-[#16a34a] transition-all"
>
<Plus className="w-4 h-4" />
</button>
{loadingConfigs ? (
<div className="text-center text-sm text-[#999] py-8">...</div>
) : savedConfigs.length === 0 ? (
<div className="text-center text-sm text-[#999] py-8">
<Layers className="w-8 h-8 mx-auto mb-2 text-[#ccc]" />
<p></p>
<p className="text-xs mt-1"></p>
</div>
) : (
<div className="space-y-2">
{savedConfigs.map(saved => {
const isActive = activeConversation?.modelConfig.name === saved.config.name;
return (
<div
key={saved.id}
className={`group relative p-4 rounded-lg border transition-all ${
isActive
? 'bg-[#16a34a]/5 border-[#16a34a]/30'
: 'bg-white border-[#e5e5e5] hover:border-[#ccc]'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[#333] truncate">
{saved.config.name}
</span>
{isActive && (
<span className="text-[10px] px-1.5 py-0.5 bg-[#16a34a]/10 text-[#16a34a] rounded">使</span>
)}
</div>
<div className="mt-1 text-xs text-[#999] font-mono">
{saved.config.model} · {saved.config.baseUrl?.replace(/^https?:\/\//, '').split('/')[0]}
</div>
<div className="mt-1 text-xs text-[#bbb]">
temp: {saved.config.temperature} · max: {saved.config.maxTokens} tokens
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleOpenEdit(saved)}
className="p-1.5 text-[#888] hover:text-[#16a34a] rounded transition-colors"
title="编辑"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => {
if (deleteConfirm === saved.id) {
handleDeleteConfig(saved.id);
setDeleteConfirm(null);
} else {
setDeleteConfirm(saved.id);
setTimeout(() => setDeleteConfirm(null), 3000);
}
}}
className={`p-1.5 rounded transition-colors ${
deleteConfirm === saved.id ? 'text-[#ef4444]' : 'text-[#888] hover:text-[#ef4444]'
}`}
title="删除"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* 应用按钮 */}
{!isActive && (
<button
onClick={() => handleApplyConfig(saved.config)}
className="mt-3 w-full py-1.5 text-xs text-[#16a34a] bg-[#16a34a]/5 border border-[#16a34a]/20 rounded hover:bg-[#16a34a]/10 transition-all"
>
</button>
)}
</div>
);
})}
</div>
)}
{/* 预设模型(快速新建) */}
<div className="mt-8 pt-5 border-t border-[#e5e5e5]">
<label className="flex items-center gap-2 text-sm text-[#888] mb-3">
<Sparkles className="w-3.5 h-3.5" />
</label>
<div className="grid grid-cols-2 gap-2">
{PRESET_MODELS.map(preset => (
<button
key={preset.label}
onClick={() => {
setEditingConfig({ ...DEFAULT_MODEL_CONFIG, name: preset.label, baseUrl: preset.baseUrl, model: preset.model });
setEditingConfigId(null);
setConfigMode('edit');
}}
className="text-left px-3 py-2 text-xs text-[#666] bg-white border border-[#e5e5e5] rounded-lg hover:border-[#16a34a]/30 hover:text-[#16a34a] transition-all"
>
{preset.label}
</button>
))}
</div>
</div>
</div>
{/* Config name */}
<div>
<label className="flex items-center gap-2 text-sm text-[#888] mb-2">
<FileText className="w-3 h-3" />
</label>
<input
type="text"
value={editingConfig.name}
onChange={e => 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="给这个配置起个名字"
/>
</div>
{/* Base URL */}
<div>
<label className="flex items-center gap-2 text-sm text-[#888] mb-2">
<Globe className="w-3 h-3" />
API
</label>
<input
type="text"
value={editingConfig.baseUrl}
onChange={e => 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"
/>
</div>
{/* Model name */}
<div>
<label className="flex items-center gap-2 text-sm text-[#888] mb-2">
<Hash className="w-3 h-3" />
ID
</label>
<input
type="text"
value={editingConfig.model}
onChange={e => 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"
/>
</div>
{/* API Key */}
<div>
<label className="flex items-center gap-2 text-sm text-[#888] mb-2">
<Key className="w-3 h-3" />
API Key
</label>
<input
type="password"
value={editingConfig.apiKey}
onChange={e => 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-..."
/>
</div>
{/* Temperature */}
<div>
<label className="flex items-center gap-2 text-sm text-[#888] mb-2">
<Thermometer className="w-4 h-4" />
Temperature: {editingConfig.temperature}
</label>
<input
type="range"
min="0"
max="2"
step="0.1"
value={editingConfig.temperature}
onChange={e => setEditingConfig(prev => ({ ...prev, temperature: parseFloat(e.target.value) }))}
className="w-full accent-[#16a34a]"
/>
<div className="flex justify-between text-xs text-[#aaa] mt-2">
<span> (0)</span>
<span> (1)</span>
<span> (2)</span>
) : (
/* ===== 编辑/新建配置模式 ===== */
<div className="p-5 space-y-5">
{/* 预设模型快速填充 */}
<div>
<label className="flex items-center gap-2 text-sm text-[#888] mb-2">
<Sparkles className="w-3.5 h-3.5" />
</label>
<div className="flex flex-wrap gap-2">
{PRESET_MODELS.map(preset => (
<button
key={preset.label}
onClick={() => handlePresetSelect(preset)}
className={`px-3 py-1.5 text-xs rounded-md border transition-all ${
editingConfig.model === preset.model
? 'bg-[#16a34a]/10 text-[#16a34a] border-[#16a34a]/30'
: 'bg-white text-[#666] border-[#e0e0e0] hover:border-[#ccc]'
}`}
>
{preset.label}
</button>
))}
</div>
</div>
</div>
{/* Max Tokens */}
<div>
<label className="flex items-center gap-2 text-sm text-[#888] mb-2">
<Hash className="w-4 h-4" />
Token: {editingConfig.maxTokens}
</label>
<input
type="range"
min="256"
max="8192"
step="256"
value={editingConfig.maxTokens}
onChange={e => setEditingConfig(prev => ({ ...prev, maxTokens: parseInt(e.target.value) }))}
className="w-full accent-[#16a34a]"
/>
</div>
{/* 配置名称 */}
<div>
<label className="flex items-center gap-2 text-sm text-[#888] mb-2">
<FileText className="w-3 h-3" />
</label>
<input
type="text"
value={editingConfig.name}
onChange={e => 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="给这个配置起个名字"
/>
</div>
{/* System Prompt */}
<div>
<label className="flex items-center gap-2 text-sm text-[#888] mb-2">
<FileText className="w-3 h-3" />
</label>
<textarea
value={editingConfig.systemPrompt}
onChange={e => setEditingConfig(prev => ({ ...prev, systemPrompt: 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 resize-none h-24"
placeholder="You are a helpful assistant."
/>
</div>
{/* API 地址 */}
<div>
<label className="flex items-center gap-2 text-sm text-[#888] mb-2">
<Globe className="w-3 h-3" />
API
</label>
<input
type="text"
value={editingConfig.baseUrl}
onChange={e => setEditingConfig(prev => ({ ...prev, baseUrl: 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 font-mono"
placeholder="https://api.example.com"
/>
</div>
{/* Save button */}
<button
onClick={handleSaveConfig}
className="w-full flex items-center justify-center gap-2 py-3 bg-[#16a34a]/10 text-[#16a34a] border border-[#16a34a]/30 rounded-lg text-base hover:bg-[#16a34a]/20 transition-all"
>
<Save className="w-5 h-5" />
</button>
</div>
{/* 模型 ID */}
<div>
<label className="flex items-center gap-2 text-sm text-[#888] mb-2">
<Hash className="w-3 h-3" />
ID
</label>
<input
type="text"
value={editingConfig.model}
onChange={e => setEditingConfig(prev => ({ ...prev, model: 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 font-mono"
placeholder="deepseek-v4-pro"
/>
</div>
{/* API Key */}
<div>
<label className="flex items-center gap-2 text-sm text-[#888] mb-2">
<Key className="w-3 h-3" />
API Key
</label>
<input
type="password"
value={editingConfig.apiKey}
onChange={e => setEditingConfig(prev => ({ ...prev, apiKey: 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 font-mono"
placeholder="sk-..."
/>
</div>
{/* 温度参数 */}
<div>
<label className="flex items-center gap-2 text-sm text-[#888] mb-2">
<Thermometer className="w-3.5 h-3.5" />
Temperature: {editingConfig.temperature}
</label>
<input
type="range"
min="0"
max="2"
step="0.1"
value={editingConfig.temperature}
onChange={e => setEditingConfig(prev => ({ ...prev, temperature: parseFloat(e.target.value) }))}
className="w-full accent-[#16a34a]"
/>
<div className="flex justify-between text-xs text-[#aaa] mt-1">
<span> (0)</span>
<span> (1)</span>
<span> (2)</span>
</div>
</div>
{/* 最大 Token */}
<div>
<label className="flex items-center gap-2 text-sm text-[#888] mb-2">
<Hash className="w-3.5 h-3.5" />
Token: {editingConfig.maxTokens}
</label>
<input
type="range"
min="256"
max="8192"
step="256"
value={editingConfig.maxTokens}
onChange={e => setEditingConfig(prev => ({ ...prev, maxTokens: parseInt(e.target.value) }))}
className="w-full accent-[#16a34a]"
/>
</div>
{/* 系统提示词 */}
<div>
<label className="flex items-center gap-2 text-sm text-[#888] mb-2">
<FileText className="w-3 h-3" />
</label>
<textarea
value={editingConfig.systemPrompt}
onChange={e => setEditingConfig(prev => ({ ...prev, systemPrompt: e.target.value }))}
className="w-full bg-white border border-[#ddd] rounded-lg px-4 py-3 text-sm text-[#333] outline-none focus:border-[#16a34a]/50 transition-colors font-mono resize-none h-28"
placeholder="You are a helpful assistant."
/>
</div>
{/* 保存按钮 */}
<button
onClick={handleSaveConfig}
className="w-full flex items-center justify-center gap-2 py-3 bg-[#16a34a]/10 text-[#16a34a] border border-[#16a34a]/30 rounded-lg text-sm hover:bg-[#16a34a]/20 transition-all"
>
<Save className="w-4 h-4" />
{editingConfigId ? '保存修改' : '保存配置'}
</button>
</div>
)}
</div>
</div>
)}
+3 -1
View File
@@ -5,7 +5,9 @@ export function useAuth() {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => {
return !!localStorage.getItem('chat_token');
});
const [isLoading, setIsLoading] = useState(true);
const [isLoading, setIsLoading] = useState(() => {
return !!localStorage.getItem('chat_token'); // 有 token 才需要 loading,没 token 直接显示登录页
});
useEffect(() => {
const token = localStorage.getItem('chat_token');
+137 -49
View File
@@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useRef } from 'react';
import type { Conversation, Message, ModelConfig } from '@/types';
import {
listConversations,
getConversation,
createConversation as apiCreateConversation,
updateConversation as apiUpdateConversation,
deleteConversation as apiDeleteConversation,
@@ -21,6 +22,9 @@ function apiMsgToMessage(msg: ApiMessage): Message {
id: msg.id,
role: msg.role,
content: msg.content,
thinking: msg.thinking || undefined,
promptTokens: msg.prompt_tokens || undefined,
completionTokens: msg.completion_tokens || undefined,
timestamp: msg.timestamp,
};
}
@@ -40,41 +44,60 @@ export function useChat() {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [isStreaming, setIsStreaming] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isLoading, setIsLoading] = useState(() => {
return !!localStorage.getItem('chat_token'); // 有 token 才需要 loading
});
const abortRef = useRef<boolean>(false);
const activeConversation = conversations.find(c => c.id === activeId) || null;
// 初始加载
useEffect(() => {
// 加载会话列表
const loadConversations = useCallback(async () => {
const token = localStorage.getItem('chat_token');
if (!token) {
setConversations([]);
setActiveId(null);
setIsLoading(false);
return;
}
let mounted = true;
setIsLoading(true);
listConversations()
.then((rows) => {
if (!mounted) return;
const convs = rows.map(apiConvToConversation);
setConversations(convs);
if (convs.length > 0 && !activeId) {
setActiveId(convs[0].id);
}
})
.catch((err) => {
console.error('Failed to load conversations:', err);
})
.finally(() => {
if (mounted) setIsLoading(false);
});
return () => { mounted = false; };
try {
const rows = await listConversations();
const convs = rows.map(apiConvToConversation);
setConversations(convs);
if (convs.length > 0 && !activeId) {
setActiveId(convs[0].id);
}
} catch (err) {
console.error('Failed to load conversations:', err);
} finally {
setIsLoading(false);
}
}, [activeId]);
// 初始加载
useEffect(() => {
loadConversations();
}, []);
const setActive = useCallback((id: string | null) => {
const setActive = useCallback(async (id: string | null) => {
setActiveId(id);
}, []);
// 如果切换到的会话没有消息,重新获取完整数据
if (id) {
const conv = conversations.find(c => c.id === id);
if (conv && conv.messages.length === 0) {
try {
const fullConv = await getConversation(id);
if (!fullConv.messages) return;
setConversations(prev =>
prev.map(c => (c.id === id ? apiConvToConversation(fullConv) : c))
);
} catch (err) {
console.error('Load conversation failed:', err);
}
}
}
}, [conversations]);
const createConversation = useCallback(async (config?: ModelConfig) => {
// 如果当前已经在空对话中,不再创建新的
@@ -130,41 +153,78 @@ export function useChat() {
}
}, []);
const sendMessage = useCallback(async (content: string) => {
const sendMessage = useCallback(async (content: string, regenerateFrom?: string) => {
if (!activeConversation) return;
const convId = activeConversation.id;
// 乐观更新:先添加用户消息到本地状态
const userMessage: Message = {
id: generateId(),
role: 'user',
content,
timestamp: Date.now(),
};
setConversations(prev =>
prev.map(c => {
if (c.id === convId) {
const newMessages = [...c.messages, userMessage];
const newTitle = c.messages.length === 0
? content.slice(0, 20) + (content.length > 20 ? '...' : '')
: c.title;
return { ...c, messages: newMessages, title: newTitle, updatedAt: Date.now() };
}
return c;
})
);
let userMessage: Message | null = null;
let assistantMessageId = generateId();
let fullContent = '';
let fullThinking = '';
let promptTokens = 0;
let completionTokens = 0;
if (regenerateFrom) {
// 重新生成:找到目标用户消息,删除其后所有消息
const targetMsg = activeConversation.messages.find(m => m.id === regenerateFrom && m.role === 'user');
if (!targetMsg) return;
userMessage = targetMsg;
content = targetMsg.content;
setConversations(prev =>
prev.map(c => {
if (c.id !== convId) return c;
const idx = c.messages.findIndex(m => m.id === regenerateFrom);
if (idx === -1) return c;
return { ...c, messages: c.messages.slice(0, idx + 1) };
})
);
} else {
// 正常发送:先添加用户消息到本地状态(乐观更新)
userMessage = {
id: generateId(),
role: 'user',
content,
timestamp: Date.now(),
};
setConversations(prev =>
prev.map(c => {
if (c.id === convId) {
const newMessages = [...c.messages, userMessage!];
const newTitle = c.messages.length === 0
? content.slice(0, 20) + (content.length > 20 ? '...' : '')
: c.title;
return { ...c, messages: newMessages, title: newTitle, updatedAt: Date.now() };
}
return c;
})
);
}
setIsStreaming(true);
abortRef.current = false;
const assistantMessageId = generateId();
let fullContent = '';
try {
for await (const event of sendMessageStream(convId, content)) {
for await (const event of sendMessageStream(convId, content, regenerateFrom)) {
if (abortRef.current) break;
if (event.type === 'delta' && event.content) {
if (event.type === 'thinking' && event.content) {
fullThinking += event.content;
setConversations(prev =>
prev.map(c => {
if (c.id !== convId) return c;
const lastMsg = c.messages[c.messages.length - 1];
if (lastMsg && lastMsg.role === 'assistant' && lastMsg.id === assistantMessageId) {
const newMessages = c.messages.slice(0, -1);
newMessages.push({ ...lastMsg, thinking: fullThinking });
return { ...c, messages: newMessages };
}
return {
...c,
messages: [...c.messages, { id: assistantMessageId, role: 'assistant', content: '', thinking: fullThinking, timestamp: Date.now() }],
};
})
);
} else if (event.type === 'delta' && event.content) {
fullContent += event.content;
setConversations(prev =>
prev.map(c => {
@@ -172,17 +232,39 @@ export function useChat() {
const lastMsg = c.messages[c.messages.length - 1];
if (lastMsg && lastMsg.role === 'assistant' && lastMsg.id === assistantMessageId) {
const newMessages = c.messages.slice(0, -1);
newMessages.push({ ...lastMsg, content: fullContent });
newMessages.push({ ...lastMsg, content: fullContent, thinking: fullThinking || undefined });
return { ...c, messages: newMessages };
}
return {
...c,
messages: [...c.messages, { id: assistantMessageId, role: 'assistant', content: fullContent, timestamp: Date.now() }],
messages: [...c.messages, { id: assistantMessageId, role: 'assistant', content: fullContent, thinking: fullThinking || undefined, timestamp: Date.now() }],
};
})
);
} else if (event.type === 'done') {
fullContent = event.content || fullContent;
fullThinking = event.thinking || fullThinking;
promptTokens = event.promptTokens || 0;
completionTokens = event.completionTokens || 0;
// 最终更新:写入 token 用量
setConversations(prev =>
prev.map(c => {
if (c.id !== convId) return c;
const lastMsg = c.messages[c.messages.length - 1];
if (lastMsg && lastMsg.role === 'assistant' && lastMsg.id === assistantMessageId) {
const newMessages = c.messages.slice(0, -1);
newMessages.push({
...lastMsg,
content: fullContent,
thinking: fullThinking || undefined,
promptTokens,
completionTokens,
});
return { ...c, messages: newMessages };
}
return c;
})
);
} else if (event.type === 'error') {
console.error('Stream error:', event.content);
}
@@ -215,6 +297,10 @@ export function useChat() {
setIsStreaming(false);
}, []);
const regenerateMessage = useCallback((messageId: string) => {
sendMessage('', messageId);
}, [sendMessage]);
return {
conversations,
activeConversation,
@@ -226,6 +312,8 @@ export function useChat() {
updateModelConfig,
clearMessages,
sendMessage,
regenerateMessage,
stopStreaming,
reloadConversations: loadConversations,
};
}
+9 -5
View File
@@ -2,6 +2,9 @@ export interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
thinking?: string; // 思考过程(DeepSeek V4 reasoning_content
promptTokens?: number; // 输入 token 数
completionTokens?: number; // 输出 token 数
timestamp: number;
}
@@ -28,14 +31,15 @@ export const DEFAULT_MODEL_CONFIG: ModelConfig = {
name: '默认配置',
apiKey: '',
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-chat',
temperature: 0.7,
maxTokens: 4096,
systemPrompt: 'You are a helpful assistant.',
model: 'deepseek-v4-pro',
temperature: 0.2,
maxTokens: 8192,
systemPrompt: '你是一位资深全栈架构师。回答技术问题时,请:\n1. 先给出核心结论,再展开解释。\n2. 所有代码必须用 Markdown 代码块包裹,并标注语言。\n3. 如果需要对比方案,使用列表形式说明优缺点。\n4. 不要输出无关的表情、客套话,保持简洁专业。',
};
export const PRESET_MODELS = [
{ label: 'DeepSeek', baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat' },
{ label: 'DeepSeek V4 Pro', baseUrl: 'https://api.deepseek.com', model: 'deepseek-v4-pro' },
{ label: 'DeepSeek V4 Flash', baseUrl: 'https://api.deepseek.com', model: 'deepseek-v4-flash' },
{ label: 'Kimi', baseUrl: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k' },
{ label: '自定义', baseUrl: '', model: '' },
];