fix: 登录后自动加载对话历史;新增生产部署指南 DEPLOY.md
- 修复 useChat useEffect 只在挂载时执行导致的登录后无历史问题 - 暴露 reloadConversations 函数,App.tsx 登录成功后主动调用 - 添加 DEPLOY.md 生产部署文档(Nginx + PHP-FPM) 2026-05-15
This commit is contained in:
@@ -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 内置服务器性能差,不适合生产。
|
||||
@@ -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
@@ -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
Binary file not shown.
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+194
-29
@@ -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
|
||||
// 处理重新生成:删除指定消息之后的所有消息
|
||||
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
@@ -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
@@ -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,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 || '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
+67
-12
@@ -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 */}
|
||||
{/* 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="absolute -right-8 top-0 p-1 text-[#ccc] hover:text-[#16a34a] opacity-0 group-hover:opacity-100 transition-all"
|
||||
className="flex items-center gap-1 text-[10px] text-[#bbb] hover:text-[#16a34a] transition-colors"
|
||||
title="复制"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{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">
|
||||
|
||||
+232
-43
@@ -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,31 +292,148 @@ export default function Sidebar({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-6">
|
||||
{/* Preset models */}
|
||||
<div>
|
||||
{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-4 h-4" />
|
||||
预设模型
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
预设模型(快速新建)
|
||||
</label>
|
||||
<div className="space-y-1">
|
||||
<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>
|
||||
) : (
|
||||
/* ===== 编辑/新建配置模式 ===== */
|
||||
<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={`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'
|
||||
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]'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{preset.label}</div>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Config name */}
|
||||
{/* 配置名称 */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-[#888] mb-2">
|
||||
<FileText className="w-3 h-3" />
|
||||
@@ -255,12 +443,12 @@ export default function Sidebar({
|
||||
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"
|
||||
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>
|
||||
|
||||
{/* Base URL */}
|
||||
{/* API 地址 */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-[#888] mb-2">
|
||||
<Globe className="w-3 h-3" />
|
||||
@@ -270,12 +458,12 @@ export default function Sidebar({
|
||||
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"
|
||||
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>
|
||||
|
||||
{/* Model name */}
|
||||
{/* 模型 ID */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-[#888] mb-2">
|
||||
<Hash className="w-3 h-3" />
|
||||
@@ -285,8 +473,8 @@ export default function Sidebar({
|
||||
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"
|
||||
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>
|
||||
|
||||
@@ -300,15 +488,15 @@ export default function Sidebar({
|
||||
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"
|
||||
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>
|
||||
|
||||
{/* Temperature */}
|
||||
{/* 温度参数 */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-[#888] mb-2">
|
||||
<Thermometer className="w-4 h-4" />
|
||||
<Thermometer className="w-3.5 h-3.5" />
|
||||
Temperature: {editingConfig.temperature}
|
||||
</label>
|
||||
<input
|
||||
@@ -320,17 +508,17 @@ export default function Sidebar({
|
||||
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">
|
||||
<div className="flex justify-between text-xs text-[#aaa] mt-1">
|
||||
<span>精确 (0)</span>
|
||||
<span>平衡 (1)</span>
|
||||
<span>创意 (2)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Max Tokens */}
|
||||
{/* 最大 Token */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-[#888] mb-2">
|
||||
<Hash className="w-4 h-4" />
|
||||
<Hash className="w-3.5 h-3.5" />
|
||||
最大 Token: {editingConfig.maxTokens}
|
||||
</label>
|
||||
<input
|
||||
@@ -344,7 +532,7 @@ export default function Sidebar({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* System Prompt */}
|
||||
{/* 系统提示词 */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-[#888] mb-2">
|
||||
<FileText className="w-3 h-3" />
|
||||
@@ -353,20 +541,21 @@ export default function Sidebar({
|
||||
<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"
|
||||
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>
|
||||
|
||||
{/* 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"
|
||||
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-5 h-5" />
|
||||
保存配置
|
||||
<Save className="w-4 h-4" />
|
||||
{editingConfigId ? '保存修改' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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');
|
||||
|
||||
+115
-27
@@ -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;
|
||||
try {
|
||||
const rows = await listConversations();
|
||||
const convs = rows.map(apiConvToConversation);
|
||||
setConversations(convs);
|
||||
if (convs.length > 0 && !activeId) {
|
||||
setActiveId(convs[0].id);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
} catch (err) {
|
||||
console.error('Failed to load conversations:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
if (mounted) setIsLoading(false);
|
||||
});
|
||||
return () => { mounted = false; };
|
||||
} 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,12 +153,34 @@ 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 = {
|
||||
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,
|
||||
@@ -144,7 +189,7 @@ export function useChat() {
|
||||
setConversations(prev =>
|
||||
prev.map(c => {
|
||||
if (c.id === convId) {
|
||||
const newMessages = [...c.messages, userMessage];
|
||||
const newMessages = [...c.messages, userMessage!];
|
||||
const newTitle = c.messages.length === 0
|
||||
? content.slice(0, 20) + (content.length > 20 ? '...' : '')
|
||||
: c.title;
|
||||
@@ -153,18 +198,33 @@ export function useChat() {
|
||||
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
@@ -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: '' },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user