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 ```bash
tseslint.configs.recommendedTypeChecked, # 前端依赖
// Alternatively, use this for stricter rules npm install
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs... # 后端依赖(PHP + Composer
], cd server && composer install
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
``` ```
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 ```bash
// eslint.config.js # 同时启动前端和后端
import reactX from 'eslint-plugin-react-x' npm run dev
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([ # 前端 http://localhost:3000
globalIgnores(['dist']), # 后端 http://localhost:8000
{ ```
files: ['**/*.{ts,tsx}'],
extends: [ ### 生产构建
// Other configs...
// Enable lint rules for React ```bash
reactX.configs['recommended-typescript'], # 构建前端
// Enable lint rules for React DOM npm run build
reactDom.configs.recommended,
], # 后端无需构建,直接部署 server/ 目录即可
languageOptions: { ```
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'], ## 环境变量
tsconfigRootDir: import.meta.dirname,
}, | 变量 | 说明 | 默认值 |
// other options... |------|------|--------|
}, | `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": { "scripts": {
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"", "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"dev:client": "vite", "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", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "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(); $app = AppFactory::create();
// CORS for dev // 开发环境跨域支持
$app->add(function ($request, $handler) { $app->add(function ($request, $handler) {
$response = $handler->handle($request); $response = $handler->handle($request);
return $response return $response
@@ -21,15 +21,15 @@ $app->add(function ($request, $handler) {
$app->addRoutingMiddleware(); $app->addRoutingMiddleware();
$app->addErrorMiddleware(true, true, true); $app->addErrorMiddleware(true, true, true);
// Options handler // OPTIONS 预检请求处理
$app->options('/{routes:.+}', function ($request, $response) { $app->options('/{routes:.+}', function ($request, $response) {
return $response; return $response;
}); });
// Init DB // 初始化数据库
getDb(); getDb();
// Register routes // 注册路由
require __DIR__ . '/../src/routes.php'; require __DIR__ . '/../src/routes.php';
$app->run(); $app->run();
+35 -2
View File
@@ -15,6 +15,8 @@ function getDb(): PDO {
if ($isNew) { if ($isNew) {
initDb($pdo); initDb($pdo);
} else {
migrateDb($pdo);
} }
return $pdo; return $pdo;
@@ -37,8 +39,8 @@ function initDb(PDO $pdo): void {
model_base_url TEXT, model_base_url TEXT,
model_id TEXT, model_id TEXT,
api_key TEXT, api_key TEXT,
temperature REAL DEFAULT 0.7, temperature REAL DEFAULT 0.2,
max_tokens INTEGER DEFAULT 4096, max_tokens INTEGER DEFAULT 8192,
system_prompt TEXT, system_prompt TEXT,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
updated_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, conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK(role IN ('user','assistant','system')), role TEXT NOT NULL CHECK(role IN ('user','assistant','system')),
content TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '',
thinking TEXT DEFAULT NULL,
prompt_tokens INTEGER DEFAULT 0,
completion_tokens INTEGER DEFAULT 0,
timestamp INTEGER NOT NULL 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_messages_conversation ON messages(conversation_id);
CREATE INDEX IF NOT EXISTS idx_conversations_user ON conversations(user_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
View File
@@ -3,7 +3,7 @@
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
// Auth middleware // JWT 认证中间件:验证请求头中的 Bearer Token
$authMiddleware = function (Request $request, $handler) { $authMiddleware = function (Request $request, $handler) {
$authHeader = $request->getHeaderLine('Authorization'); $authHeader = $request->getHeaderLine('Authorization');
if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) { if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) {
@@ -22,13 +22,13 @@ $authMiddleware = function (Request $request, $handler) {
return $handler->handle($request); return $handler->handle($request);
}; };
// Health check // 健康检查接口
$app->get('/api/health', function (Request $request, Response $response) { $app->get('/api/health', function (Request $request, Response $response) {
$response->getBody()->write(json_encode(['status' => 'ok'])); $response->getBody()->write(json_encode(['status' => 'ok']));
return $response->withHeader('Content-Type', 'application/json'); return $response->withHeader('Content-Type', 'application/json');
}); });
// Auth routes // 认证路由
$app->post('/api/auth/login', function (Request $request, Response $response) { $app->post('/api/auth/login', function (Request $request, Response $response) {
$body = json_decode((string)$request->getBody(), true); $body = json_decode((string)$request->getBody(), true);
$password = $body['password'] ?? ''; $password = $body['password'] ?? '';
@@ -61,7 +61,7 @@ $app->get('/api/auth/me', function (Request $request, Response $response) {
return $response->withHeader('Content-Type', 'application/json'); return $response->withHeader('Content-Type', 'application/json');
})->add($authMiddleware); })->add($authMiddleware);
// Conversation routes // 会话路由
$app->get('/api/conversations', function (Request $request, Response $response) { $app->get('/api/conversations', function (Request $request, Response $response) {
$userId = $request->getAttribute('userId'); $userId = $request->getAttribute('userId');
$db = getDb(); $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'); 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]); $stmt->execute([$id]);
$messages = $stmt->fetchAll(); $messages = $stmt->fetchAll();
@@ -110,8 +110,8 @@ $app->post('/api/conversations', function (Request $request, Response $response)
$modelConfig['baseUrl'] ?? null, $modelConfig['baseUrl'] ?? null,
$modelConfig['model'] ?? null, $modelConfig['model'] ?? null,
$modelConfig['apiKey'] ?? null, $modelConfig['apiKey'] ?? null,
$modelConfig['temperature'] ?? 0.7, $modelConfig['temperature'] ?? 0.2,
$modelConfig['maxTokens'] ?? 4096, $modelConfig['maxTokens'] ?? 8192,
$modelConfig['systemPrompt'] ?? null, $modelConfig['systemPrompt'] ?? null,
$now, $now $now, $now
]); ]);
@@ -157,7 +157,7 @@ $app->put('/api/conversations/{id}', function (Request $request, Response $respo
$stmt->execute([$id]); $stmt->execute([$id]);
$conv = $stmt->fetch(); $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]); $stmt->execute([$id]);
$messages = $stmt->fetchAll(); $messages = $stmt->fetchAll();
@@ -203,14 +203,15 @@ $app->delete('/api/conversations/{id}/messages', function (Request $request, Res
return $response->withHeader('Content-Type', 'application/json'); return $response->withHeader('Content-Type', 'application/json');
})->add($authMiddleware); })->add($authMiddleware);
// Chat route (SSE) // 聊天路由(SSE 流式响应)
$app->post('/api/chat', function (Request $request, Response $response) { $app->post('/api/chat', function (Request $request, Response $response) {
$userId = $request->getAttribute('userId'); $userId = $request->getAttribute('userId');
$body = json_decode((string)$request->getBody(), true); $body = json_decode((string)$request->getBody(), true);
$conversationId = $body['conversationId'] ?? ''; $conversationId = $body['conversationId'] ?? '';
$content = trim($body['content'] ?? ''); $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'])); $response->getBody()->write(json_encode(['error' => 'conversationId and content required']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json'); 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'); 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 = $db->prepare("SELECT role, content FROM messages WHERE conversation_id = ? ORDER BY timestamp ASC");
$stmt->execute([$conversationId]); $stmt->execute([$conversationId]);
$history = $stmt->fetchAll(); $history = $stmt->fetchAll();
// Build messages // 构建消息列表
$messages = []; $messages = [];
if ($conv['system_prompt']) { if ($conv['system_prompt']) {
$messages[] = ['role' => 'system', 'content' => $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]; $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)); $userMsgId = bin2hex(random_bytes(16));
$now = time() * 1000; $now = time() * 1000;
$stmt = $db->prepare('INSERT INTO messages (id, conversation_id, role, content, timestamp) VALUES (?, ?, ?, ?, ?)'); $stmt = $db->prepare('INSERT INTO messages (id, conversation_id, role, content, timestamp) VALUES (?, ?, ?, ?, ?)');
$stmt->execute([$userMsgId, $conversationId, 'user', $content, $now]); $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 = $db->prepare('SELECT COUNT(*) as cnt FROM messages WHERE conversation_id = ?');
$stmt->execute([$conversationId]); $stmt->execute([$conversationId]);
$msgCount = $stmt->fetch()['cnt'] ?? 0; $msgCount = $stmt->fetch()['cnt'] ?? 0;
@@ -258,7 +276,7 @@ $app->post('/api/chat', function (Request $request, Response $response) {
$stmt->execute([$newTitle, $now, $conversationId]); $stmt->execute([$newTitle, $now, $conversationId]);
} }
// Check model config // 检查模型配置是否完整
if (!$conv['model_base_url'] || !$conv['api_key'] || !$conv['model_id']) { if (!$conv['model_base_url'] || !$conv['api_key'] || !$conv['model_id']) {
$errMsg = 'Error: 模型配置不完整,请先在「配置」中设置 API 地址、API Key 和模型 ID'; $errMsg = 'Error: 模型配置不完整,请先在「配置」中设置 API 地址、API Key 和模型 ID';
$errMsgId = bin2hex(random_bytes(16)); $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'); return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
} }
// SSE output // 设置 SSE 响应头
header('Content-Type: text/event-stream'); header('Content-Type: text/event-stream');
header('Cache-Control: no-cache'); header('Cache-Control: no-cache');
header('Connection: keep-alive'); 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(); while (ob_get_level() > 0) ob_end_flush();
ob_implicit_flush(true); ob_implicit_flush(true);
// Send user message confirmation // 发送用户消息确认事件
echo "data: " . json_encode(['type' => 'user', 'id' => $userMsgId, 'content' => $content]) . "\n\n"; echo "data: " . json_encode(['type' => 'user', 'id' => $userMsgId, 'content' => $content]) . "\n\n";
flush(); flush();
// Call LLM API via curl // 通过 curl 调用大模型 API(支持 DeepSeek V4 reasoning_content
$assistantMsgId = bin2hex(random_bytes(16)); $assistantMsgId = bin2hex(random_bytes(16));
$fullContent = ''; $fullContent = '';
$fullThinking = '';
$promptTokens = 0;
$completionTokens = 0;
$inThinking = true; // 当前是否在接收 thinking 内容
$payload = json_encode([ $payload = json_encode([
'model' => $conv['model_id'], 'model' => $conv['model_id'],
@@ -291,8 +313,15 @@ $app->post('/api/chat', function (Request $request, Response $response) {
'temperature' => (float)$conv['temperature'], 'temperature' => (float)$conv['temperature'],
'max_tokens' => (int)$conv['max_tokens'], 'max_tokens' => (int)$conv['max_tokens'],
'stream' => true, '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'); $ch = curl_init($conv['model_base_url'] . '/chat/completions');
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_POST => true,
@@ -302,7 +331,13 @@ $app->post('/api/chat', function (Request $request, Response $response) {
'Authorization: Bearer ' . $conv['api_key'], 'Authorization: Bearer ' . $conv['api_key'],
], ],
CURLOPT_RETURNTRANSFER => false, 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); $lines = explode("\n", $data);
foreach ($lines as $line) { foreach ($lines as $line) {
$line = trim($line); $line = trim($line);
@@ -311,10 +346,38 @@ $app->post('/api/chat', function (Request $request, Response $response) {
if ($jsonStr === '[DONE]') continue; if ($jsonStr === '[DONE]') continue;
$parsed = json_decode($jsonStr, true); $parsed = json_decode($jsonStr, true);
if (!$parsed) continue; if (!$parsed) continue;
$delta = $parsed['choices'][0]['delta']['content'] ?? '';
if ($delta) { // 处理 usage(最后一个 chunk
$fullContent .= $delta; if (isset($parsed['usage'])) {
echo "data: " . json_encode(['type' => 'delta', 'content' => $delta]) . "\n\n"; $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(); flush();
} }
} }
@@ -324,26 +387,128 @@ $app->post('/api/chat', function (Request $request, Response $response) {
CURLOPT_CONNECTTIMEOUT => 30, CURLOPT_CONNECTTIMEOUT => 30,
]); ]);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_exec($ch); curl_exec($ch);
$error = curl_error($ch); $error = curl_error($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch); curl_close($ch);
if ($error) { if ($error && $error !== 'Callback aborted') {
$errMsg = 'Error: ' . $error; $errMsg = 'Error: ' . $error;
echo "data: " . json_encode(['type' => 'error', 'content' => $errMsg]) . "\n\n"; echo "data: " . json_encode(['type' => 'error', 'content' => $errMsg]) . "\n\n";
} }
// Save assistant message if ($httpCode >= 400) {
$stmt = $db->prepare('INSERT INTO messages (id, conversation_id, role, content, timestamp) VALUES (?, ?, ?, ?, ?)'); $errMsg = "API 请求失败 (HTTP {$httpCode})";
$stmt->execute([$assistantMsgId, $conversationId, 'assistant', $fullContent, time() * 1000]); 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 = $db->prepare('UPDATE conversations SET updated_at = ? WHERE id = ?');
$stmt->execute([time() * 1000, $conversationId]); $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"; echo "data: [DONE]\n\n";
flush(); flush();
exit(0); exit(0);
})->add($authMiddleware); })->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, updateModelConfig,
clearMessages, clearMessages,
sendMessage, sendMessage,
regenerateMessage,
stopStreaming, stopStreaming,
reloadConversations,
} = useChat(); } = useChat();
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const handleLogin = async (password: string) => {
const ok = await login(password);
if (ok) {
reloadConversations();
}
return ok;
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="w-screen h-screen flex items-center justify-center bg-white"> <div className="w-screen h-screen flex items-center justify-center bg-[#0d0d0d]">
<div className="text-sm text-[#999]">...</div> <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> </div>
); );
} }
if (!isAuthenticated) { if (!isAuthenticated) {
return <LoginPage onLogin={login} />; return <LoginPage onLogin={handleLogin} />;
} }
return ( return (
@@ -53,6 +66,7 @@ function App() {
conversation={activeConversation} conversation={activeConversation}
isStreaming={isStreaming} isStreaming={isStreaming}
onSendMessage={sendMessage} onSendMessage={sendMessage}
onRegenerateMessage={regenerateMessage}
onStopStreaming={stopStreaming} onStopStreaming={stopStreaming}
sidebarOpen={sidebarOpen} sidebarOpen={sidebarOpen}
onToggleSidebar={() => setSidebarOpen(prev => !prev)} onToggleSidebar={() => setSidebarOpen(prev => !prev)}
+8 -3
View File
@@ -1,20 +1,25 @@
const API_BASE = import.meta.env.VITE_API_BASE || ''; const API_BASE = import.meta.env.VITE_API_BASE || '';
export interface ChatStreamEvent { export interface ChatStreamEvent {
type: 'user' | 'delta' | 'done' | 'error'; type: 'user' | 'thinking' | 'thinking_end' | 'delta' | 'done' | 'error';
id?: string; id?: string;
content?: 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 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`, { const res = await fetch(`${API_BASE}/api/chat`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
}, },
body: JSON.stringify({ conversationId, content }), body: JSON.stringify(body),
}); });
if (!res.ok) { if (!res.ok) {
+5 -2
View File
@@ -5,6 +5,9 @@ export interface ApiMessage {
id: string; id: string;
role: 'user' | 'assistant' | 'system'; role: 'user' | 'assistant' | 'system';
content: string; content: string;
thinking: string | null;
prompt_tokens: number;
completion_tokens: number;
timestamp: number; timestamp: number;
} }
@@ -29,8 +32,8 @@ function rowToModelConfig(row: ApiConversation): ModelConfig {
apiKey: row.api_key || '', apiKey: row.api_key || '',
baseUrl: row.model_base_url || '', baseUrl: row.model_base_url || '',
model: row.model_id || '', model: row.model_id || '',
temperature: row.temperature ?? 0.7, temperature: row.temperature ?? 0.2,
maxTokens: row.max_tokens ?? 4096, maxTokens: row.max_tokens ?? 8192,
systemPrompt: row.system_prompt || '', 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}`);
}
+67 -12
View File
@@ -1,5 +1,6 @@
import { useState, useRef, useEffect, useCallback } from 'react'; import { useState, useRef, useEffect, useCallback } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { import {
@@ -11,6 +12,7 @@ import {
Check, Check,
Menu, Menu,
Sparkles, Sparkles,
RefreshCw,
} from 'lucide-react'; } from 'lucide-react';
import type { Conversation, Message } from '@/types'; import type { Conversation, Message } from '@/types';
@@ -18,6 +20,7 @@ interface ChatAreaProps {
conversation: Conversation | null; conversation: Conversation | null;
isStreaming: boolean; isStreaming: boolean;
onSendMessage: (content: string) => void; onSendMessage: (content: string) => void;
onRegenerateMessage: (messageId: string) => void;
onStopStreaming: () => void; onStopStreaming: () => void;
sidebarOpen: boolean; sidebarOpen: boolean;
onToggleSidebar: () => void; 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 [copied, setCopied] = useState(false);
const [thinkingOpen, setThinkingOpen] = useState(false);
const handleCopy = async () => { const handleCopy = async () => {
await navigator.clipboard.writeText(message.content); await navigator.clipboard.writeText(message.content);
@@ -101,9 +105,30 @@ function MessageBubble({ message, isStreaming }: { message: Message; isStreaming
</span> </span>
</div> </div>
<div className="group relative"> <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 ? ( {message.content || isStreaming ? (
<div className="text-[#d4d4d4] text-sm leading-relaxed"> <div className="text-[#333] text-sm leading-relaxed">
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{ components={{
code({ inline, className, children, ...props }: any) { code({ inline, className, children, ...props }: any) {
const match = /language-(\w+)/.exec(className || ''); const match = /language-(\w+)/.exec(className || '');
@@ -176,7 +201,7 @@ function MessageBubble({ message, isStreaming }: { message: Message; isStreaming
{message.content || (isStreaming ? '' : '')} {message.content || (isStreaming ? '' : '')}
</ReactMarkdown> </ReactMarkdown>
{/* Streaming cursor */} {/* 流式输出光标 */}
{isStreaming && !message.content && ( {isStreaming && !message.content && (
<span className="inline-block w-2 h-4 bg-[#0f0] animate-pulse ml-0.5" /> <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> </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 && ( {message.content && (
<button <button
onClick={handleCopy} 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> </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> </div>
</div> </div>
@@ -208,6 +255,7 @@ export default function ChatArea({
conversation, conversation,
isStreaming, isStreaming,
onSendMessage, onSendMessage,
onRegenerateMessage,
onStopStreaming, onStopStreaming,
sidebarOpen, sidebarOpen,
onToggleSidebar, onToggleSidebar,
@@ -216,7 +264,7 @@ export default function ChatArea({
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
// Auto-resize textarea // 输入框自动调整高度
useEffect(() => { useEffect(() => {
const textarea = textareaRef.current; const textarea = textareaRef.current;
if (textarea) { if (textarea) {
@@ -225,7 +273,7 @@ export default function ChatArea({
} }
}, [input]); }, [input]);
// Auto-scroll to bottom // 自动滚动到底部
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [conversation?.messages]); }, [conversation?.messages]);
@@ -249,7 +297,7 @@ export default function ChatArea({
return ( return (
<div className="flex-1 flex flex-col h-full bg-white relative"> <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 justify-between px-4 py-3 border-b border-[#1a1a1a]">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{!sidebarOpen && ( {!sidebarOpen && (
@@ -279,7 +327,7 @@ export default function ChatArea({
)} )}
</div> </div>
{/* Messages area */} {/* 消息列表区域 */}
<div className="flex-1 overflow-y-auto px-4 md:px-8 py-6 custom-scrollbar"> <div className="flex-1 overflow-y-auto px-4 md:px-8 py-6 custom-scrollbar">
{!conversation || conversation.messages.length === 0 ? ( {!conversation || conversation.messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-[#bbb]"> <div className="flex flex-col items-center justify-center h-full text-[#bbb]">
@@ -310,11 +358,18 @@ export default function ChatArea({
</div> </div>
) : ( ) : (
<div className="max-w-3xl mx-auto"> <div className="max-w-3xl mx-auto">
{conversation.messages.map(message => ( {conversation.messages.map((message, idx) => (
<MessageBubble <MessageBubble
key={message.id} key={message.id}
message={message} message={message}
isStreaming={isStreaming && message.role === 'assistant' && message.id === conversation.messages[conversation.messages.length - 1]?.id} 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} /> <div ref={messagesEndRef} />
@@ -322,7 +377,7 @@ export default function ChatArea({
)} )}
</div> </div>
{/* Input area */} {/* 输入区域 */}
<div className="px-4 md:px-8 pb-6 pt-4"> <div className="px-4 md:px-8 pb-6 pt-4">
<div className="max-w-3xl mx-auto relative"> <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"> <div className="flex items-end gap-3 bg-white border border-[#d0d0d0] rounded-xl p-4 shadow-md transition-all">
+232 -43
View File
@@ -14,9 +14,14 @@ import {
Key, Key,
Save, Save,
RotateCcw, RotateCcw,
Pencil,
ChevronLeft,
Layers,
} from 'lucide-react'; } from 'lucide-react';
import type { Conversation, ModelConfig } from '@/types'; import type { Conversation, ModelConfig } from '@/types';
import { PRESET_MODELS, DEFAULT_MODEL_CONFIG } 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 { interface SidebarProps {
conversations: Conversation[]; conversations: Conversation[];
@@ -44,13 +49,28 @@ export default function Sidebar({
onToggle, onToggle,
}: SidebarProps) { }: SidebarProps) {
const [showConfig, setShowConfig] = useState(false); const [showConfig, setShowConfig] = useState(false);
const [configMode, setConfigMode] = useState<'list' | 'edit'>('list');
const [editingConfig, setEditingConfig] = useState<ModelConfig>(DEFAULT_MODEL_CONFIG); const [editingConfig, setEditingConfig] = useState<ModelConfig>(DEFAULT_MODEL_CONFIG);
const [editingConfigId, setEditingConfigId] = useState<string | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null); const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [pendingOpenConfig, setPendingOpenConfig] = useState(false); 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 = () => { const handleEditConfig = () => {
if (activeConversation) { if (activeConversation) {
setEditingConfig({ ...activeConversation.modelConfig }); setConfigMode('list');
setShowConfig(true); setShowConfig(true);
} else { } else {
onCreateConversation(); onCreateConversation();
@@ -58,22 +78,65 @@ export default function Sidebar({
} }
}; };
// Open config when a new conversation is created via the config button // 通过配置按钮创建新会话后,自动打开配置面板
useEffect(() => { useEffect(() => {
if (pendingOpenConfig && activeConversation) { if (pendingOpenConfig && activeConversation) {
setEditingConfig({ ...activeConversation.modelConfig }); setConfigMode('list');
setShowConfig(true); setShowConfig(true);
setPendingOpenConfig(false); setPendingOpenConfig(false);
} }
}, [pendingOpenConfig, activeConversation]); }, [pendingOpenConfig, activeConversation]);
const handleSaveConfig = () => { // 应用配置到当前会话
const handleApplyConfig = (config: ModelConfig) => {
if (activeConversation) { if (activeConversation) {
onUpdateModelConfig(activeConversation.id, editingConfig); onUpdateModelConfig(activeConversation.id, config);
setShowConfig(false);
} }
}; };
// 打开编辑表单
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]) => { const handlePresetSelect = (preset: typeof PRESET_MODELS[0]) => {
setEditingConfig(prev => ({ setEditingConfig(prev => ({
...prev, ...prev,
@@ -94,7 +157,7 @@ export default function Sidebar({
return ( return (
<> <>
{/* Mobile overlay */} {/* 移动端遮罩层 */}
{isOpen && ( {isOpen && (
<div <div
className="fixed inset-0 bg-black/20 z-40 md:hidden" className="fixed inset-0 bg-black/20 z-40 md:hidden"
@@ -102,14 +165,14 @@ export default function Sidebar({
/> />
)} )}
{/* Sidebar */} {/* 侧边栏 */}
<aside <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 ${ 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' isOpen ? 'w-[280px] translate-x-0' : 'w-0 -translate-x-full md:w-0 md:translate-x-0'
} overflow-hidden`} } overflow-hidden`}
style={{ minWidth: isOpen ? '280px' : '0' }} style={{ minWidth: isOpen ? '280px' : '0' }}
> >
{/* Top bar */} {/* 顶部操作栏 */}
<div className="flex items-center justify-between p-3 border-b border-[#e5e5e5]"> <div className="flex items-center justify-between p-3 border-b border-[#e5e5e5]">
<button <button
onClick={() => onCreateConversation()} onClick={() => onCreateConversation()}
@@ -136,7 +199,7 @@ export default function Sidebar({
</div> </div>
</div> </div>
{/* Conversation list */} {/* 会话列表 */}
<div className="flex-1 overflow-y-auto py-2 px-2 custom-scrollbar"> <div className="flex-1 overflow-y-auto py-2 px-2 custom-scrollbar">
{conversations.length === 0 ? ( {conversations.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-[#999]"> <div className="flex flex-col items-center justify-center h-32 text-[#999]">
@@ -182,7 +245,7 @@ export default function Sidebar({
)} )}
</div> </div>
{/* Bottom actions */} {/* 底部操作区 */}
<div className="p-3 border-t border-[#e5e5e5] space-y-1"> <div className="p-3 border-t border-[#e5e5e5] space-y-1">
{activeConversation && ( {activeConversation && (
<button <button
@@ -203,15 +266,23 @@ export default function Sidebar({
</div> </div>
</aside> </aside>
{/* Model Config Panel (Slide-over) */} {/* 模型配置面板(滑出式) */}
{showConfig && ( {showConfig && (
<div className="fixed inset-0 z-[60] flex"> <div className="fixed inset-0 z-[60] flex">
<div className="flex-1 bg-black/20" onClick={() => setShowConfig(false)} /> <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="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]"> <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"> <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]" /> <Settings className="w-4 h-4 text-[#16a34a]" />
{configMode === 'list' ? '模型配置' : (editingConfigId ? '编辑配置' : '新建配置')}
</h3> </h3>
<button <button
onClick={() => setShowConfig(false)} onClick={() => setShowConfig(false)}
@@ -221,31 +292,148 @@ export default function Sidebar({
</button> </button>
</div> </div>
<div className="p-5 space-y-6"> {configMode === 'list' ? (
{/* Preset models */} /* ===== 配置列表模式 ===== */
<div> <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"> <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> </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 => ( {PRESET_MODELS.map(preset => (
<button <button
key={preset.label} key={preset.label}
onClick={() => handlePresetSelect(preset)} onClick={() => handlePresetSelect(preset)}
className={`w-full text-left px-4 py-3 text-sm rounded-lg transition-all ${ className={`px-3 py-1.5 text-xs rounded-md border transition-all ${
editingConfig.model === preset.model && editingConfig.baseUrl === preset.baseUrl editingConfig.model === preset.model
? 'bg-[#16a34a]/10 text-[#16a34a] border border-[#16a34a]/30' ? 'bg-[#16a34a]/10 text-[#16a34a] border-[#16a34a]/30'
: 'text-[#666] hover:bg-[#f0f0f0] border border-transparent' : 'bg-white text-[#666] border-[#e0e0e0] hover:border-[#ccc]'
}`} }`}
> >
<div className="font-medium">{preset.label}</div> {preset.label}
</button> </button>
))} ))}
</div> </div>
</div> </div>
{/* Config name */} {/* 配置名称 */}
<div> <div>
<label className="flex items-center gap-2 text-sm text-[#888] mb-2"> <label className="flex items-center gap-2 text-sm text-[#888] mb-2">
<FileText className="w-3 h-3" /> <FileText className="w-3 h-3" />
@@ -255,12 +443,12 @@ export default function Sidebar({
type="text" type="text"
value={editingConfig.name} value={editingConfig.name}
onChange={e => setEditingConfig(prev => ({ ...prev, name: e.target.value }))} 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="给这个配置起个名字" placeholder="给这个配置起个名字"
/> />
</div> </div>
{/* Base URL */} {/* API 地址 */}
<div> <div>
<label className="flex items-center gap-2 text-sm text-[#888] mb-2"> <label className="flex items-center gap-2 text-sm text-[#888] mb-2">
<Globe className="w-3 h-3" /> <Globe className="w-3 h-3" />
@@ -270,12 +458,12 @@ export default function Sidebar({
type="text" type="text"
value={editingConfig.baseUrl} value={editingConfig.baseUrl}
onChange={e => setEditingConfig(prev => ({ ...prev, baseUrl: e.target.value }))} 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" placeholder="https://api.example.com"
/> />
</div> </div>
{/* Model name */} {/* 模型 ID */}
<div> <div>
<label className="flex items-center gap-2 text-sm text-[#888] mb-2"> <label className="flex items-center gap-2 text-sm text-[#888] mb-2">
<Hash className="w-3 h-3" /> <Hash className="w-3 h-3" />
@@ -285,8 +473,8 @@ export default function Sidebar({
type="text" type="text"
value={editingConfig.model} value={editingConfig.model}
onChange={e => setEditingConfig(prev => ({ ...prev, model: e.target.value }))} 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" 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="gpt-4o" placeholder="deepseek-v4-pro"
/> />
</div> </div>
@@ -300,15 +488,15 @@ export default function Sidebar({
type="password" type="password"
value={editingConfig.apiKey} value={editingConfig.apiKey}
onChange={e => setEditingConfig(prev => ({ ...prev, apiKey: e.target.value }))} 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-..." placeholder="sk-..."
/> />
</div> </div>
{/* Temperature */} {/* 温度参数 */}
<div> <div>
<label className="flex items-center gap-2 text-sm text-[#888] mb-2"> <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} Temperature: {editingConfig.temperature}
</label> </label>
<input <input
@@ -320,17 +508,17 @@ export default function Sidebar({
onChange={e => setEditingConfig(prev => ({ ...prev, temperature: parseFloat(e.target.value) }))} onChange={e => setEditingConfig(prev => ({ ...prev, temperature: parseFloat(e.target.value) }))}
className="w-full accent-[#16a34a]" 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> (0)</span>
<span> (1)</span> <span> (1)</span>
<span> (2)</span> <span> (2)</span>
</div> </div>
</div> </div>
{/* Max Tokens */} {/* 最大 Token */}
<div> <div>
<label className="flex items-center gap-2 text-sm text-[#888] mb-2"> <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} Token: {editingConfig.maxTokens}
</label> </label>
<input <input
@@ -344,7 +532,7 @@ export default function Sidebar({
/> />
</div> </div>
{/* System Prompt */} {/* 系统提示词 */}
<div> <div>
<label className="flex items-center gap-2 text-sm text-[#888] mb-2"> <label className="flex items-center gap-2 text-sm text-[#888] mb-2">
<FileText className="w-3 h-3" /> <FileText className="w-3 h-3" />
@@ -353,20 +541,21 @@ export default function Sidebar({
<textarea <textarea
value={editingConfig.systemPrompt} value={editingConfig.systemPrompt}
onChange={e => setEditingConfig(prev => ({ ...prev, systemPrompt: e.target.value }))} 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." placeholder="You are a helpful assistant."
/> />
</div> </div>
{/* Save button */} {/* 保存按钮 */}
<button <button
onClick={handleSaveConfig} 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> </button>
</div> </div>
)}
</div> </div>
</div> </div>
)} )}
+3 -1
View File
@@ -5,7 +5,9 @@ export function useAuth() {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => { const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => {
return !!localStorage.getItem('chat_token'); return !!localStorage.getItem('chat_token');
}); });
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(() => {
return !!localStorage.getItem('chat_token'); // 有 token 才需要 loading,没 token 直接显示登录页
});
useEffect(() => { useEffect(() => {
const token = localStorage.getItem('chat_token'); const token = localStorage.getItem('chat_token');
+115 -27
View File
@@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useRef } from 'react';
import type { Conversation, Message, ModelConfig } from '@/types'; import type { Conversation, Message, ModelConfig } from '@/types';
import { import {
listConversations, listConversations,
getConversation,
createConversation as apiCreateConversation, createConversation as apiCreateConversation,
updateConversation as apiUpdateConversation, updateConversation as apiUpdateConversation,
deleteConversation as apiDeleteConversation, deleteConversation as apiDeleteConversation,
@@ -21,6 +22,9 @@ function apiMsgToMessage(msg: ApiMessage): Message {
id: msg.id, id: msg.id,
role: msg.role, role: msg.role,
content: msg.content, content: msg.content,
thinking: msg.thinking || undefined,
promptTokens: msg.prompt_tokens || undefined,
completionTokens: msg.completion_tokens || undefined,
timestamp: msg.timestamp, timestamp: msg.timestamp,
}; };
} }
@@ -40,41 +44,60 @@ export function useChat() {
const [conversations, setConversations] = useState<Conversation[]>([]); const [conversations, setConversations] = useState<Conversation[]>([]);
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [isStreaming, setIsStreaming] = useState(false); 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 abortRef = useRef<boolean>(false);
const activeConversation = conversations.find(c => c.id === activeId) || null; const activeConversation = conversations.find(c => c.id === activeId) || null;
// 初始加载 // 加载会话列表
useEffect(() => { const loadConversations = useCallback(async () => {
const token = localStorage.getItem('chat_token'); const token = localStorage.getItem('chat_token');
if (!token) { if (!token) {
setConversations([]);
setActiveId(null);
setIsLoading(false); setIsLoading(false);
return; return;
} }
let mounted = true;
setIsLoading(true); setIsLoading(true);
listConversations() try {
.then((rows) => { const rows = await listConversations();
if (!mounted) return;
const convs = rows.map(apiConvToConversation); const convs = rows.map(apiConvToConversation);
setConversations(convs); setConversations(convs);
if (convs.length > 0 && !activeId) { if (convs.length > 0 && !activeId) {
setActiveId(convs[0].id); setActiveId(convs[0].id);
} }
}) } catch (err) {
.catch((err) => {
console.error('Failed to load conversations:', err); console.error('Failed to load conversations:', err);
}) } finally {
.finally(() => { setIsLoading(false);
if (mounted) setIsLoading(false); }
}); }, [activeId]);
return () => { mounted = false; };
// 初始加载
useEffect(() => {
loadConversations();
}, []); }, []);
const setActive = useCallback((id: string | null) => { const setActive = useCallback(async (id: string | null) => {
setActiveId(id); 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) => { 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; if (!activeConversation) return;
const convId = activeConversation.id; const convId = activeConversation.id;
// 乐观更新:先添加用户消息到本地状态 let userMessage: Message | null = null;
const userMessage: Message = { 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(), id: generateId(),
role: 'user', role: 'user',
content, content,
@@ -144,7 +189,7 @@ export function useChat() {
setConversations(prev => setConversations(prev =>
prev.map(c => { prev.map(c => {
if (c.id === convId) { if (c.id === convId) {
const newMessages = [...c.messages, userMessage]; const newMessages = [...c.messages, userMessage!];
const newTitle = c.messages.length === 0 const newTitle = c.messages.length === 0
? content.slice(0, 20) + (content.length > 20 ? '...' : '') ? content.slice(0, 20) + (content.length > 20 ? '...' : '')
: c.title; : c.title;
@@ -153,18 +198,33 @@ export function useChat() {
return c; return c;
}) })
); );
}
setIsStreaming(true); setIsStreaming(true);
abortRef.current = false; abortRef.current = false;
const assistantMessageId = generateId();
let fullContent = '';
try { try {
for await (const event of sendMessageStream(convId, content)) { for await (const event of sendMessageStream(convId, content, regenerateFrom)) {
if (abortRef.current) break; 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; fullContent += event.content;
setConversations(prev => setConversations(prev =>
prev.map(c => { prev.map(c => {
@@ -172,17 +232,39 @@ export function useChat() {
const lastMsg = c.messages[c.messages.length - 1]; const lastMsg = c.messages[c.messages.length - 1];
if (lastMsg && lastMsg.role === 'assistant' && lastMsg.id === assistantMessageId) { if (lastMsg && lastMsg.role === 'assistant' && lastMsg.id === assistantMessageId) {
const newMessages = c.messages.slice(0, -1); 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: newMessages };
} }
return { return {
...c, ...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') { } else if (event.type === 'done') {
fullContent = event.content || fullContent; 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') { } else if (event.type === 'error') {
console.error('Stream error:', event.content); console.error('Stream error:', event.content);
} }
@@ -215,6 +297,10 @@ export function useChat() {
setIsStreaming(false); setIsStreaming(false);
}, []); }, []);
const regenerateMessage = useCallback((messageId: string) => {
sendMessage('', messageId);
}, [sendMessage]);
return { return {
conversations, conversations,
activeConversation, activeConversation,
@@ -226,6 +312,8 @@ export function useChat() {
updateModelConfig, updateModelConfig,
clearMessages, clearMessages,
sendMessage, sendMessage,
regenerateMessage,
stopStreaming, stopStreaming,
reloadConversations: loadConversations,
}; };
} }
+9 -5
View File
@@ -2,6 +2,9 @@ export interface Message {
id: string; id: string;
role: 'user' | 'assistant' | 'system'; role: 'user' | 'assistant' | 'system';
content: string; content: string;
thinking?: string; // 思考过程(DeepSeek V4 reasoning_content
promptTokens?: number; // 输入 token 数
completionTokens?: number; // 输出 token 数
timestamp: number; timestamp: number;
} }
@@ -28,14 +31,15 @@ export const DEFAULT_MODEL_CONFIG: ModelConfig = {
name: '默认配置', name: '默认配置',
apiKey: '', apiKey: '',
baseUrl: 'https://api.deepseek.com', baseUrl: 'https://api.deepseek.com',
model: 'deepseek-chat', model: 'deepseek-v4-pro',
temperature: 0.7, temperature: 0.2,
maxTokens: 4096, maxTokens: 8192,
systemPrompt: 'You are a helpful assistant.', systemPrompt: '你是一位资深全栈架构师。回答技术问题时,请:\n1. 先给出核心结论,再展开解释。\n2. 所有代码必须用 Markdown 代码块包裹,并标注语言。\n3. 如果需要对比方案,使用列表形式说明优缺点。\n4. 不要输出无关的表情、客套话,保持简洁专业。',
}; };
export const PRESET_MODELS = [ 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: 'Kimi', baseUrl: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k' },
{ label: '自定义', baseUrl: '', model: '' }, { label: '自定义', baseUrl: '', model: '' },
]; ];