初始化仓库及v1.0.0提交
This commit is contained in:
0
app/Config/.gitkeep
Normal file
0
app/Config/.gitkeep
Normal file
57
app/Config/AppConfig.php
Normal file
57
app/Config/AppConfig.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Config;
|
||||
|
||||
class AppConfig
|
||||
{
|
||||
private static ?array $cache = null;
|
||||
private static string $configPath = '';
|
||||
|
||||
private static function getConfigPath(): string
|
||||
{
|
||||
if (self::$configPath === '') {
|
||||
self::$configPath = dirname(__DIR__, 2) . '/config/app-config.json';
|
||||
}
|
||||
|
||||
return self::$configPath;
|
||||
}
|
||||
|
||||
private static function load(): array
|
||||
{
|
||||
if (self::$cache !== null) {
|
||||
return self::$cache;
|
||||
}
|
||||
|
||||
$path = self::getConfigPath();
|
||||
|
||||
if (!file_exists($path)) {
|
||||
self::$cache = [];
|
||||
return self::$cache;
|
||||
}
|
||||
|
||||
$content = file_get_contents($path);
|
||||
self::$cache = json_decode($content, true) ?? [];
|
||||
|
||||
return self::$cache;
|
||||
}
|
||||
|
||||
public static function get(string $key, $default = null)
|
||||
{
|
||||
$config = self::load();
|
||||
return $config[$key] ?? $default;
|
||||
}
|
||||
|
||||
public static function set(string $key, $value): void
|
||||
{
|
||||
$config = self::load();
|
||||
$config[$key] = $value;
|
||||
self::$cache = $config;
|
||||
|
||||
$dir = dirname(self::getConfigPath());
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
file_put_contents(self::getConfigPath(), json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
39
app/Config/Database.php
Normal file
39
app/Config/Database.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Config;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
|
||||
class Database
|
||||
{
|
||||
private static ?PDO $instance = null;
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
$configPath = dirname(__DIR__, 2) . '/config/db-config.json';
|
||||
|
||||
if (!file_exists($configPath)) {
|
||||
throw new PDOException('数据库配置文件不存在');
|
||||
}
|
||||
|
||||
$config = json_decode(file_get_contents($configPath), true);
|
||||
|
||||
$dsn = "mysql:host={$config['host']};port={$config['port']};dbname={$config['dbname']};charset=utf8mb4";
|
||||
|
||||
self::$instance = new PDO($dsn, $config['user'], $config['password'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4",
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getInstance(): PDO
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
new self();
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
}
|
||||
0
app/Controllers/.gitkeep
Normal file
0
app/Controllers/.gitkeep
Normal file
69
app/Controllers/AuthController.php
Normal file
69
app/Controllers/AuthController.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Config\AppConfig;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
|
||||
class AuthController
|
||||
{
|
||||
public static function login(): void
|
||||
{
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$username = $input['username'] ?? '';
|
||||
$password = $input['password'] ?? '';
|
||||
|
||||
if (!$username || !$password) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => '用户名和密码不能为空']);
|
||||
return;
|
||||
}
|
||||
|
||||
$user = User::findByUsername($username);
|
||||
if (!$user || !User::verifyPassword($username, $password)) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'message' => '用户名或密码错误']);
|
||||
return;
|
||||
}
|
||||
|
||||
$jwtSecret = AppConfig::get('jwtSecret');
|
||||
$jwtExpiry = AppConfig::get('jwtExpiry', 86400);
|
||||
|
||||
$payload = [
|
||||
'userId' => $user['id'],
|
||||
'username' => $user['username'],
|
||||
'role' => $user['role'],
|
||||
'iat' => time(),
|
||||
'exp' => time() + $jwtExpiry
|
||||
];
|
||||
|
||||
$token = JWT::encode($payload, $jwtSecret, 'HS256');
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'token' => $token,
|
||||
'user' => [
|
||||
'id' => $user['id'],
|
||||
'username' => $user['username'],
|
||||
'role' => $user['role']
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public static function me(): void
|
||||
{
|
||||
$user = $GLOBALS['auth_user'];
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'id' => $user['userId'],
|
||||
'username' => $user['username'],
|
||||
'role' => $user['role']
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
117
app/Controllers/ChatController.php
Normal file
117
app/Controllers/ChatController.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\Config;
|
||||
use App\Services\AIService;
|
||||
|
||||
class ChatController
|
||||
{
|
||||
public static function completions(): void
|
||||
{
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$provider = $input['provider'] ?? '';
|
||||
$model = $input['model'] ?? '';
|
||||
$messages = $input['messages'] ?? [];
|
||||
$stream = $input['stream'] ?? true;
|
||||
$systemPrompt = $input['systemPrompt'] ?? '';
|
||||
$thinkingMode = $input['thinkingMode'] ?? false;
|
||||
|
||||
if (!$provider || !$model || !$messages) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => '缺少必要参数(provider、model、messages)']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_array($messages) || empty($messages)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => 'messages 必须是非空数组']);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取供应商配置
|
||||
$configRow = Config::getByKey('providers');
|
||||
if (!$configRow) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'message' => '未找到供应商配置']);
|
||||
return;
|
||||
}
|
||||
|
||||
$providers = json_decode($configRow['config_value'], true);
|
||||
if (!is_array($providers)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'message' => '供应商配置格式错误']);
|
||||
return;
|
||||
}
|
||||
|
||||
$providerKey = $provider;
|
||||
if (!isset($providers[$providerKey])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => '供应商不存在: ' . $providerKey]);
|
||||
return;
|
||||
}
|
||||
|
||||
$providerConfig = $providers[$providerKey];
|
||||
if (empty($providerConfig['enabled'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => '供应商已禁用: ' . $providerKey]);
|
||||
return;
|
||||
}
|
||||
|
||||
$models = $providerConfig['models'] ?? [];
|
||||
if (!empty($models) && !in_array($model, $models)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => '模型不在供应商支持列表中: ' . $model]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 非流式模式暂不支持
|
||||
if (!$stream) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => '当前仅支持流式响应']);
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置 SSE 响应头
|
||||
header('Content-Type: text/event-stream');
|
||||
header('Cache-Control: no-cache');
|
||||
header('Connection: keep-alive');
|
||||
header('X-Accel-Buffering: no');
|
||||
|
||||
// 关闭输出缓冲
|
||||
while (ob_get_level()) {
|
||||
ob_end_flush();
|
||||
}
|
||||
|
||||
$options = [
|
||||
'provider' => $providerConfig,
|
||||
'systemPrompt' => $systemPrompt,
|
||||
'thinkingMode' => (bool) $thinkingMode
|
||||
];
|
||||
|
||||
try {
|
||||
AIService::streamChat($providerKey, $model, $messages, $options, function ($chunk, $type = 'content') {
|
||||
if ($type === 'thinking') {
|
||||
self::sendSSE(['thinking' => $chunk]);
|
||||
} else {
|
||||
self::sendSSE(['content' => $chunk]);
|
||||
}
|
||||
});
|
||||
|
||||
self::sendSSE('[DONE]');
|
||||
} catch (\Throwable $e) {
|
||||
self::sendSSE(['type' => 'error', 'message' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
private static function sendSSE($data): void
|
||||
{
|
||||
if (is_string($data)) {
|
||||
echo "data: " . $data . "\n\n";
|
||||
} else {
|
||||
echo "data: " . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n";
|
||||
}
|
||||
flush();
|
||||
}
|
||||
}
|
||||
106
app/Controllers/ConfigController.php
Normal file
106
app/Controllers/ConfigController.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\Config;
|
||||
use App\Models\Personality;
|
||||
|
||||
class ConfigController
|
||||
{
|
||||
public static function getConfig(): void
|
||||
{
|
||||
$configRow = Config::getByKey('providers');
|
||||
$providers = $configRow ? json_decode($configRow['config_value'], true) : [];
|
||||
echo json_encode(['success' => true, 'data' => ['providers' => $providers]]);
|
||||
}
|
||||
|
||||
public static function updateConfig(): void
|
||||
{
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$providers = $input['providers'] ?? [];
|
||||
|
||||
if (!is_array($providers)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => 'providers 必须是数组']);
|
||||
return;
|
||||
}
|
||||
|
||||
Config::setByKey('providers', json_encode($providers, JSON_UNESCAPED_UNICODE));
|
||||
echo json_encode(['success' => true, 'message' => '配置更新成功']);
|
||||
}
|
||||
|
||||
public static function listPersonalities(): void
|
||||
{
|
||||
$personalities = Personality::findAll();
|
||||
echo json_encode(['success' => true, 'data' => $personalities]);
|
||||
}
|
||||
|
||||
public static function createPersonality(): void
|
||||
{
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$name = $input['name'] ?? '';
|
||||
$prompt = $input['prompt'] ?? '';
|
||||
|
||||
if (!$name || !$prompt) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => '名称和提示词不能为空']);
|
||||
return;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'name' => $name,
|
||||
'prompt' => $prompt,
|
||||
'description' => $input['description'] ?? null,
|
||||
'icon' => $input['icon'] ?? null,
|
||||
'is_preset' => 0,
|
||||
'created_by' => $GLOBALS['auth_user']['userId']
|
||||
];
|
||||
|
||||
$personality = Personality::create($data);
|
||||
echo json_encode(['success' => true, 'data' => $personality]);
|
||||
}
|
||||
|
||||
public static function updatePersonality($id): void
|
||||
{
|
||||
$personality = Personality::findById($id);
|
||||
if (!$personality) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'message' => '人格不存在']);
|
||||
return;
|
||||
}
|
||||
if ($personality['is_preset']) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => '预设人格不可编辑']);
|
||||
return;
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$data = array_filter([
|
||||
'name' => $input['name'] ?? null,
|
||||
'prompt' => $input['prompt'] ?? null,
|
||||
'description' => $input['description'] ?? null,
|
||||
'icon' => $input['icon'] ?? null,
|
||||
], fn($v) => $v !== null);
|
||||
|
||||
$updated = Personality::update($id, $data);
|
||||
echo json_encode(['success' => true, 'data' => $updated]);
|
||||
}
|
||||
|
||||
public static function deletePersonality($id): void
|
||||
{
|
||||
$personality = Personality::findById($id);
|
||||
if (!$personality) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'message' => '人格不存在']);
|
||||
return;
|
||||
}
|
||||
if ($personality['is_preset']) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => '预设人格不可删除']);
|
||||
return;
|
||||
}
|
||||
|
||||
Personality::delete($id);
|
||||
echo json_encode(['success' => true, 'message' => '删除成功']);
|
||||
}
|
||||
}
|
||||
118
app/Controllers/InstallController.php
Normal file
118
app/Controllers/InstallController.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Services\Installer;
|
||||
use App\Config\AppConfig;
|
||||
use App\Config\Database;
|
||||
use App\Models\User;
|
||||
use App\Models\Config;
|
||||
|
||||
class InstallController
|
||||
{
|
||||
public static function status(): void
|
||||
{
|
||||
$installed = Installer::isInstalled();
|
||||
echo json_encode(['success' => true, 'data' => ['installed' => $installed]]);
|
||||
}
|
||||
|
||||
public static function testDb(): void
|
||||
{
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$host = $input['host'] ?? '';
|
||||
$port = $input['port'] ?? 3306;
|
||||
$user = $input['user'] ?? '';
|
||||
$password = $input['password'] ?? '';
|
||||
$database = $input['database'] ?? '';
|
||||
|
||||
if (!$host || !$user || !$database) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => '缺少必要参数']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$dsn = "mysql:host={$host};port={$port};dbname={$database};charset=utf8mb4";
|
||||
$pdo = new \PDO($dsn, $user, $password, [
|
||||
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION
|
||||
]);
|
||||
echo json_encode(['success' => true, 'message' => '数据库连接成功']);
|
||||
} catch (\PDOException $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'message' => '数据库连接失败: ' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public static function setup(): void
|
||||
{
|
||||
if (Installer::isInstalled()) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => '系统已安装,不能重复安装']);
|
||||
return;
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$username = $input['username'] ?? '';
|
||||
$password = $input['password'] ?? '';
|
||||
$dbConfig = $input['dbConfig'] ?? [];
|
||||
$providers = $input['providers'] ?? [];
|
||||
|
||||
if (!$username || !$password) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => '用户名和密码不能为空']);
|
||||
return;
|
||||
}
|
||||
if (strlen($password) < 6) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => '密码长度不能少于6位']);
|
||||
return;
|
||||
}
|
||||
if (!$dbConfig || !$dbConfig['host'] || !$dbConfig['user'] || !$dbConfig['database']) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => '请填写数据库配置']);
|
||||
return;
|
||||
}
|
||||
if (!is_array($providers) || count($providers) === 0) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => '请至少配置一个AI服务提供商']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$dsn = "mysql:host={$dbConfig['host']};port=" . ($dbConfig['port'] ?? 3306) . ";dbname={$dbConfig['database']};charset=utf8mb4";
|
||||
$pdo = new \PDO($dsn, $dbConfig['user'], $dbConfig['password'], [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
|
||||
} catch (\PDOException $e) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => '数据库连接失败: ' . $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
$configDir = __DIR__ . '/../../config';
|
||||
file_put_contents($configDir . '/db-config.json', json_encode($dbConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
try {
|
||||
Installer::runMigration();
|
||||
} catch (\Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'message' => '数据库迁移失败: ' . $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
Installer::seedDefaults();
|
||||
|
||||
$admin = User::create(['username' => $username, 'password' => $password, 'role' => 'admin']);
|
||||
|
||||
$jwtSecret = bin2hex(random_bytes(32));
|
||||
AppConfig::set('jwtSecret', $jwtSecret);
|
||||
AppConfig::set('jwtExpiry', 86400);
|
||||
AppConfig::set('corsOrigin', '');
|
||||
|
||||
Config::setByKey('providers', json_encode($providers));
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => '安装成功',
|
||||
'data' => ['user' => $admin]
|
||||
]);
|
||||
}
|
||||
}
|
||||
57
app/Controllers/MessageController.php
Normal file
57
app/Controllers/MessageController.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\Session;
|
||||
use App\Models\Message;
|
||||
|
||||
class MessageController
|
||||
{
|
||||
public static function index(int $sessionId): void
|
||||
{
|
||||
$user = $GLOBALS['auth_user'];
|
||||
$session = Session::findById($sessionId);
|
||||
if (!$session || $session['user_id'] != $user['userId']) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'message' => '会话不存在']);
|
||||
return;
|
||||
}
|
||||
$messages = Message::findBySessionId($sessionId);
|
||||
echo json_encode(['success' => true, 'data' => $messages]);
|
||||
}
|
||||
|
||||
public static function create(int $sessionId): void
|
||||
{
|
||||
$user = $GLOBALS['auth_user'];
|
||||
$session = Session::findById($sessionId);
|
||||
if (!$session || $session['user_id'] != $user['userId']) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'message' => '会话不存在']);
|
||||
return;
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (empty($input['role']) || !isset($input['content']) || $input['content'] === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => 'role 和 content 为必填字段']);
|
||||
return;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'session_id' => $sessionId,
|
||||
'role' => $input['role'],
|
||||
'content' => $input['content'],
|
||||
];
|
||||
|
||||
if (isset($input['file_info'])) {
|
||||
$data['file_info'] = $input['file_info'];
|
||||
}
|
||||
if (isset($input['thinking_content'])) {
|
||||
$data['thinking_content'] = $input['thinking_content'];
|
||||
}
|
||||
|
||||
$message = Message::create($data);
|
||||
echo json_encode(['success' => true, 'data' => $message]);
|
||||
}
|
||||
}
|
||||
88
app/Controllers/SessionController.php
Normal file
88
app/Controllers/SessionController.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\Session;
|
||||
|
||||
class SessionController
|
||||
{
|
||||
public static function index(): void
|
||||
{
|
||||
$user = $GLOBALS['auth_user'];
|
||||
$sessions = Session::findByUserId($user['userId']);
|
||||
echo json_encode(['success' => true, 'data' => $sessions]);
|
||||
}
|
||||
|
||||
public static function create(): void
|
||||
{
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$user = $GLOBALS['auth_user'];
|
||||
|
||||
$data = [
|
||||
'user_id' => $user['userId'],
|
||||
'name' => $input['name'] ?? '新会话',
|
||||
'provider' => $input['provider'] ?? 'newapi',
|
||||
'model' => $input['model'] ?? 'gpt-3.5-turbo',
|
||||
'system_prompt' => $input['system_prompt'] ?? '',
|
||||
'thinking_mode' => $input['thinking_mode'] ?? false,
|
||||
];
|
||||
|
||||
if (isset($input['personality_id'])) {
|
||||
$data['personality_id'] = $input['personality_id'];
|
||||
}
|
||||
|
||||
$session = Session::create($data);
|
||||
echo json_encode(['success' => true, 'data' => $session]);
|
||||
}
|
||||
|
||||
public static function update(int $id): void
|
||||
{
|
||||
$session = Session::findById($id);
|
||||
if (!$session) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'message' => '会话不存在']);
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $GLOBALS['auth_user'];
|
||||
if ($session['user_id'] != $user['userId']) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'message' => '会话不存在']);
|
||||
return;
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$allowedFields = ['name', 'provider', 'model', 'system_prompt', 'personality_id', 'thinking_mode'];
|
||||
$data = [];
|
||||
foreach ($allowedFields as $field) {
|
||||
if (isset($input[$field])) {
|
||||
$data[$field] = $input[$field];
|
||||
}
|
||||
}
|
||||
|
||||
Session::update($id, $data);
|
||||
$updatedSession = Session::findById($id);
|
||||
echo json_encode(['success' => true, 'data' => $updatedSession]);
|
||||
}
|
||||
|
||||
public static function delete(int $id): void
|
||||
{
|
||||
$session = Session::findById($id);
|
||||
if (!$session) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'message' => '会话不存在']);
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $GLOBALS['auth_user'];
|
||||
if ($session['user_id'] != $user['userId']) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'message' => '会话不存在']);
|
||||
return;
|
||||
}
|
||||
|
||||
Session::delete($id);
|
||||
echo json_encode(['success' => true, 'message' => '删除成功']);
|
||||
}
|
||||
}
|
||||
56
app/Controllers/UploadController.php
Normal file
56
app/Controllers/UploadController.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
class UploadController
|
||||
{
|
||||
public static function upload(): void
|
||||
{
|
||||
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => '请选择要上传的文件']);
|
||||
return;
|
||||
}
|
||||
|
||||
$file = $_FILES['file'];
|
||||
$allowedTypes = [
|
||||
'jpg', 'jpeg', 'png', 'gif', 'webp',
|
||||
'js', 'ts', 'py', 'java', 'cpp', 'c', 'html', 'css', 'json', 'xml',
|
||||
'txt', 'md', 'go', 'rs', 'php', 'rb', 'sql', 'yaml', 'yml', 'sh', 'bat'
|
||||
];
|
||||
|
||||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
|
||||
if (!in_array($ext, $allowedTypes)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => '不支持的文件类型: .' . $ext]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($file['size'] > 10 * 1024 * 1024) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => '文件大小不能超过10MB']);
|
||||
return;
|
||||
}
|
||||
|
||||
$filename = uniqid() . '.' . $ext;
|
||||
$uploadDir = __DIR__ . '/../../uploads/';
|
||||
$filepath = $uploadDir . $filename;
|
||||
|
||||
if (!move_uploaded_file($file['tmp_name'], $filepath)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'message' => '文件上传失败']);
|
||||
return;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'url' => '/uploads/' . $filename,
|
||||
'name' => $file['name'],
|
||||
'size' => $file['size'],
|
||||
'type' => $ext
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
0
app/Middleware/.gitkeep
Normal file
0
app/Middleware/.gitkeep
Normal file
17
app/Middleware/AdminMiddleware.php
Normal file
17
app/Middleware/AdminMiddleware.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Middleware;
|
||||
|
||||
class AdminMiddleware
|
||||
{
|
||||
public static function handle(): void
|
||||
{
|
||||
$user = $GLOBALS['auth_user'] ?? null;
|
||||
|
||||
if (!$user || ($user['role'] ?? '') !== 'admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'message' => '需要管理员权限']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
37
app/Middleware/AuthMiddleware.php
Normal file
37
app/Middleware/AuthMiddleware.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Middleware;
|
||||
|
||||
use App\Config\AppConfig;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
|
||||
class AuthMiddleware
|
||||
{
|
||||
public static function handle(): void
|
||||
{
|
||||
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||
|
||||
if (!$authHeader || !preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'message' => '请先登录']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$token = $matches[1];
|
||||
|
||||
try {
|
||||
$jwtSecret = AppConfig::get('jwtSecret');
|
||||
$decoded = JWT::decode($token, new Key($jwtSecret, 'HS256'));
|
||||
$GLOBALS['auth_user'] = [
|
||||
'userId' => $decoded->userId,
|
||||
'username' => $decoded->username,
|
||||
'role' => $decoded->role
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'message' => '请先登录']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
0
app/Models/.gitkeep
Normal file
0
app/Models/.gitkeep
Normal file
27
app/Models/Config.php
Normal file
27
app/Models/Config.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Config\Database;
|
||||
|
||||
class Config
|
||||
{
|
||||
public static function getByKey(string $key): ?array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
$stmt = $db->prepare('SELECT * FROM config WHERE config_key = :config_key');
|
||||
$stmt->execute(['config_key' => $key]);
|
||||
$result = $stmt->fetch();
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
public static function setByKey(string $key, string $value): bool
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
$stmt = $db->prepare('INSERT INTO config (config_key, config_value) VALUES (:config_key, :config_value) ON DUPLICATE KEY UPDATE config_value = VALUES(config_value)');
|
||||
return $stmt->execute([
|
||||
'config_key' => $key,
|
||||
'config_value' => $value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
33
app/Models/Message.php
Normal file
33
app/Models/Message.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Config\Database;
|
||||
|
||||
class Message
|
||||
{
|
||||
public static function findBySessionId(int $sessionId): array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
$stmt = $db->prepare('SELECT * FROM messages WHERE session_id = :session_id ORDER BY created_at ASC');
|
||||
$stmt->execute(['session_id' => $sessionId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public static function create(array $data): array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
$stmt = $db->prepare('INSERT INTO messages (session_id, role, content, file_info, thinking_content) VALUES (:session_id, :role, :content, :file_info, :thinking_content)');
|
||||
$stmt->execute([
|
||||
'session_id' => $data['session_id'],
|
||||
'role' => $data['role'],
|
||||
'content' => $data['content'],
|
||||
'file_info' => isset($data['file_info']) ? json_encode($data['file_info']) : null,
|
||||
'thinking_content' => $data['thinking_content'] ?? null,
|
||||
]);
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM messages WHERE id = :id');
|
||||
$stmt->execute(['id' => (int) $db->lastInsertId()]);
|
||||
return $stmt->fetch();
|
||||
}
|
||||
}
|
||||
65
app/Models/Personality.php
Normal file
65
app/Models/Personality.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Config\Database;
|
||||
|
||||
class Personality
|
||||
{
|
||||
public static function findAll(): array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
$stmt = $db->query('SELECT * FROM personalities ORDER BY id ASC');
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public static function findById(int $id): ?array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
$stmt = $db->prepare('SELECT * FROM personalities WHERE id = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
$result = $stmt->fetch();
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
public static function create(array $data): array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
$stmt = $db->prepare('INSERT INTO personalities (name, prompt, description, icon, is_preset, created_by) VALUES (:name, :prompt, :description, :icon, :is_preset, :created_by)');
|
||||
$stmt->execute([
|
||||
'name' => $data['name'],
|
||||
'prompt' => $data['prompt'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'icon' => $data['icon'] ?? null,
|
||||
'is_preset' => $data['is_preset'] ?? 0,
|
||||
'created_by' => $data['created_by'] ?? null,
|
||||
]);
|
||||
|
||||
return self::findById((int) $db->lastInsertId());
|
||||
}
|
||||
|
||||
public static function update(int $id, array $data): bool
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$fields = [];
|
||||
$params = ['id' => $id];
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
$fields[] = "{$key} = :{$key}";
|
||||
$params[$key] = $value;
|
||||
}
|
||||
|
||||
$sql = 'UPDATE personalities SET ' . implode(', ', $fields) . ' WHERE id = :id';
|
||||
$stmt = $db->prepare($sql);
|
||||
|
||||
return $stmt->execute($params);
|
||||
}
|
||||
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
$stmt = $db->prepare('DELETE FROM personalities WHERE id = :id');
|
||||
return $stmt->execute(['id' => $id]);
|
||||
}
|
||||
}
|
||||
78
app/Models/Session.php
Normal file
78
app/Models/Session.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Config\Database;
|
||||
|
||||
class Session
|
||||
{
|
||||
public static function findByUserId(int $userId): array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
$stmt = $db->prepare('SELECT * FROM sessions WHERE user_id = :user_id ORDER BY updated_at DESC');
|
||||
$stmt->execute(['user_id' => $userId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public static function findById(int $id): ?array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
$stmt = $db->prepare('SELECT * FROM sessions WHERE id = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
$result = $stmt->fetch();
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
public static function create(array $data): array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
$stmt = $db->prepare('INSERT INTO sessions (user_id, name, provider, model, system_prompt, personality_id, thinking_mode) VALUES (:user_id, :name, :provider, :model, :system_prompt, :personality_id, :thinking_mode)');
|
||||
$stmt->execute([
|
||||
'user_id' => $data['user_id'],
|
||||
'name' => $data['name'] ?? '新会话',
|
||||
'provider' => $data['provider'] ?? 'newapi',
|
||||
'model' => $data['model'] ?? 'gpt-3.5-turbo',
|
||||
'system_prompt' => $data['system_prompt'] ?? '',
|
||||
'personality_id' => $data['personality_id'] ?? null,
|
||||
'thinking_mode' => $data['thinking_mode'] ?? 0,
|
||||
]);
|
||||
|
||||
return self::findById((int) $db->lastInsertId());
|
||||
}
|
||||
|
||||
public static function update(int $id, array $data): bool
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$fields = [];
|
||||
$params = ['id' => $id];
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
$fields[] = "{$key} = :{$key}";
|
||||
$params[$key] = $value;
|
||||
}
|
||||
|
||||
$fields[] = 'updated_at = CURRENT_TIMESTAMP';
|
||||
|
||||
$sql = 'UPDATE sessions SET ' . implode(', ', $fields) . ' WHERE id = :id';
|
||||
$stmt = $db->prepare($sql);
|
||||
|
||||
return $stmt->execute($params);
|
||||
}
|
||||
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$db->prepare('DELETE FROM messages WHERE session_id = :session_id')->execute(['session_id' => $id]);
|
||||
$db->prepare('DELETE FROM sessions WHERE id = :id')->execute(['id' => $id]);
|
||||
$db->commit();
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$db->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
55
app/Models/User.php
Normal file
55
app/Models/User.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Config\Database;
|
||||
|
||||
class User
|
||||
{
|
||||
public static function findByUsername(string $username): ?array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
$stmt = $db->prepare('SELECT * FROM users WHERE username = :username');
|
||||
$stmt->execute(['username' => $username]);
|
||||
$result = $stmt->fetch();
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
public static function findById(int $id): ?array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
$stmt = $db->prepare('SELECT * FROM users WHERE id = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
$result = $stmt->fetch();
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
public static function create(array $data): array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
$stmt = $db->prepare('INSERT INTO users (username, password_hash, role) VALUES (:username, :password_hash, :role)');
|
||||
$stmt->execute([
|
||||
'username' => $data['username'],
|
||||
'password_hash' => password_hash($data['password'], PASSWORD_DEFAULT),
|
||||
'role' => $data['role'] ?? 'user',
|
||||
]);
|
||||
|
||||
$user = self::findById((int) $db->lastInsertId());
|
||||
unset($user['password_hash']);
|
||||
return $user;
|
||||
}
|
||||
|
||||
public static function verifyPassword(string $username, string $password): bool
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
$stmt = $db->prepare('SELECT password_hash FROM users WHERE username = :username');
|
||||
$stmt->execute(['username' => $username]);
|
||||
$result = $stmt->fetch();
|
||||
|
||||
if (!$result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return password_verify($password, $result['password_hash']);
|
||||
}
|
||||
}
|
||||
26
app/Services/AIService.php
Normal file
26
app/Services/AIService.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Services\Providers\OpenAIProvider;
|
||||
use App\Services\Providers\ClaudeProvider;
|
||||
use App\Services\Providers\NewAPIProvider;
|
||||
|
||||
class AIService
|
||||
{
|
||||
public static function streamChat(string $providerName, string $model, array $messages, array $options, callable $onChunk): void
|
||||
{
|
||||
$provider = self::getProvider($providerName);
|
||||
$provider::stream($model, $messages, $options, $onChunk);
|
||||
}
|
||||
|
||||
public static function getProvider(string $name): string
|
||||
{
|
||||
return match ($name) {
|
||||
'openai' => OpenAIProvider::class,
|
||||
'claude' => ClaudeProvider::class,
|
||||
'newapi' => NewAPIProvider::class,
|
||||
default => throw new \RuntimeException('不支持的供应商: ' . $name)
|
||||
};
|
||||
}
|
||||
}
|
||||
132
app/Services/Installer.php
Normal file
132
app/Services/Installer.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Config\Database;
|
||||
use App\Models\Personality;
|
||||
|
||||
class Installer
|
||||
{
|
||||
public static function isInstalled(): bool
|
||||
{
|
||||
$configPath = dirname(__DIR__, 2) . '/config/db-config.json';
|
||||
|
||||
if (!file_exists($configPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$stmt = $db->query("SHOW TABLES LIKE 'users'");
|
||||
return $stmt->fetch() !== false;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function runMigration(): void
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role ENUM('admin','user') DEFAULT 'user',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
name VARCHAR(100),
|
||||
provider VARCHAR(50),
|
||||
model VARCHAR(50),
|
||||
system_prompt TEXT,
|
||||
personality_id INT NULL,
|
||||
thinking_mode TINYINT(1) DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_id (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS messages (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
session_id INT NOT NULL,
|
||||
role ENUM('user','assistant','system') NOT NULL,
|
||||
content LONGTEXT,
|
||||
file_info JSON,
|
||||
thinking_content LONGTEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_session_id (session_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS config (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
config_key VARCHAR(50) UNIQUE NOT NULL,
|
||||
config_value LONGTEXT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS personalities (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
description TEXT,
|
||||
icon VARCHAR(50),
|
||||
is_preset TINYINT(1) DEFAULT 0,
|
||||
created_by INT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
}
|
||||
|
||||
public static function seedDefaults(): void
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$presets = [
|
||||
[
|
||||
'name' => '智能助手',
|
||||
'prompt' => '你是一个全能的智能助手,善于回答各种问题,提供准确、有帮助的回答。',
|
||||
'icon' => '🤖',
|
||||
],
|
||||
[
|
||||
'name' => '代码专家',
|
||||
'prompt' => '你是一个专业的编程专家,精通多种编程语言和技术框架,擅长代码编写、调试和优化。',
|
||||
'icon' => '💻',
|
||||
],
|
||||
[
|
||||
'name' => '翻译官',
|
||||
'prompt' => '你是一个专业的翻译官,精通中文、英文、日文等多种语言,提供准确、流畅的翻译服务。',
|
||||
'icon' => '🌐',
|
||||
],
|
||||
[
|
||||
'name' => '写作助手',
|
||||
'prompt' => '你是一个专业的写作助手,擅长各类文体的写作,包括文章、报告、邮件、创意写作等。',
|
||||
'icon' => '✍️',
|
||||
],
|
||||
[
|
||||
'name' => '数学家',
|
||||
'prompt' => '你是一个数学专家,精通各领域的数学知识,善于解答数学问题并提供详细的解题步骤。',
|
||||
'icon' => '🔢',
|
||||
],
|
||||
];
|
||||
|
||||
$stmt = $db->prepare('INSERT INTO personalities (name, prompt, description, icon, is_preset) VALUES (:name, :prompt, :description, :icon, :is_preset)');
|
||||
|
||||
foreach ($presets as $preset) {
|
||||
$stmt->execute([
|
||||
'name' => $preset['name'],
|
||||
'prompt' => $preset['prompt'],
|
||||
'description' => null,
|
||||
'icon' => $preset['icon'],
|
||||
'is_preset' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
$configStmt = $db->prepare('INSERT INTO config (config_key, config_value) VALUES (:config_key, :config_value) ON DUPLICATE KEY UPDATE config_value = VALUES(config_value)');
|
||||
$configStmt->execute([
|
||||
'config_key' => 'providers',
|
||||
'config_value' => '[]',
|
||||
]);
|
||||
}
|
||||
}
|
||||
0
app/Services/Providers/.gitkeep
Normal file
0
app/Services/Providers/.gitkeep
Normal file
118
app/Services/Providers/ClaudeProvider.php
Normal file
118
app/Services/Providers/ClaudeProvider.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Providers;
|
||||
|
||||
class ClaudeProvider
|
||||
{
|
||||
public static function stream(string $model, array $messages, array $options, callable $onChunk): void
|
||||
{
|
||||
$provider = $options['provider'];
|
||||
$apiUrl = rtrim($provider['apiUrl'] ?? 'https://api.anthropic.com', '/');
|
||||
$apiKey = $provider['apiKey'] ?? '';
|
||||
$systemPrompt = $options['systemPrompt'] ?? '';
|
||||
$thinkingMode = $options['thinkingMode'] ?? false;
|
||||
|
||||
$url = $apiUrl . '/v1/messages';
|
||||
|
||||
// Claude 的 system 消息通过单独参数传递,从 messages 中排除
|
||||
$claudeMessages = array_values(array_filter($messages, function ($msg) {
|
||||
return $msg['role'] !== 'system';
|
||||
}));
|
||||
|
||||
$bodyData = [
|
||||
'model' => $model,
|
||||
'max_tokens' => $thinkingMode ? 16000 : 4096,
|
||||
'messages' => $claudeMessages,
|
||||
'stream' => true
|
||||
];
|
||||
|
||||
if (!empty($systemPrompt)) {
|
||||
$bodyData['system'] = $systemPrompt;
|
||||
}
|
||||
|
||||
if ($thinkingMode) {
|
||||
$bodyData['thinking'] = [
|
||||
'type' => 'enabled',
|
||||
'budget_tokens' => 10000
|
||||
];
|
||||
}
|
||||
|
||||
$body = json_encode($bodyData);
|
||||
|
||||
$headers = [
|
||||
'x-api-key: ' . $apiKey,
|
||||
'anthropic-version: 2023-06-01',
|
||||
'Content-Type: application/json',
|
||||
'Accept: text/event-stream'
|
||||
];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_WRITEFUNCTION => function ($ch, $data) use ($onChunk) {
|
||||
$lines = explode("\n", $data);
|
||||
$currentEvent = '';
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '') {
|
||||
$currentEvent = '';
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($line, 'event: ')) {
|
||||
$currentEvent = substr($line, 7);
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($line, 'data: ')) {
|
||||
$payload = substr($line, 6);
|
||||
$json = json_decode($payload, true);
|
||||
|
||||
if (!$json) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch ($currentEvent) {
|
||||
case 'content_block_delta':
|
||||
if (isset($json['delta'])) {
|
||||
$delta = $json['delta'];
|
||||
if (isset($delta['type']) && $delta['type'] === 'thinking_delta' && isset($delta['thinking'])) {
|
||||
$onChunk($delta['thinking'], 'thinking');
|
||||
} elseif (isset($delta['text'])) {
|
||||
$onChunk($delta['text'], 'content');
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'message_stop':
|
||||
return strlen($data);
|
||||
case 'error':
|
||||
if (isset($json['error']['message'])) {
|
||||
throw new \RuntimeException('Claude API错误: ' . $json['error']['message']);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return strlen($data);
|
||||
}
|
||||
]);
|
||||
|
||||
$result = curl_exec($ch);
|
||||
if ($result === false) {
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
throw new \RuntimeException('API请求失败: ' . $error);
|
||||
}
|
||||
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
throw new \RuntimeException('API返回错误,状态码: ' . $httpCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
app/Services/Providers/NewAPIProvider.php
Normal file
11
app/Services/Providers/NewAPIProvider.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Providers;
|
||||
|
||||
class NewAPIProvider
|
||||
{
|
||||
public static function stream(string $model, array $messages, array $options, callable $onChunk): void
|
||||
{
|
||||
OpenAIProvider::stream($model, $messages, $options, $onChunk);
|
||||
}
|
||||
}
|
||||
77
app/Services/Providers/OpenAIProvider.php
Normal file
77
app/Services/Providers/OpenAIProvider.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Providers;
|
||||
|
||||
class OpenAIProvider
|
||||
{
|
||||
public static function stream(string $model, array $messages, array $options, callable $onChunk): void
|
||||
{
|
||||
$provider = $options['provider'];
|
||||
$apiUrl = rtrim($provider['apiUrl'] ?? 'https://api.openai.com', '/');
|
||||
$apiKey = $provider['apiKey'] ?? '';
|
||||
$systemPrompt = $options['systemPrompt'] ?? '';
|
||||
|
||||
$url = $apiUrl . '/v1/chat/completions';
|
||||
|
||||
if (!empty($systemPrompt)) {
|
||||
array_unshift($messages, ['role' => 'system', 'content' => $systemPrompt]);
|
||||
}
|
||||
|
||||
$body = json_encode([
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
'stream' => true
|
||||
]);
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
'Content-Type: application/json',
|
||||
'Accept: text/event-stream'
|
||||
],
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_WRITEFUNCTION => function ($ch, $data) use ($onChunk) {
|
||||
$lines = explode("\n", $data);
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($line, 'data: ')) {
|
||||
$payload = substr($line, 6);
|
||||
if ($payload === '[DONE]') {
|
||||
return strlen($data);
|
||||
}
|
||||
$json = json_decode($payload, true);
|
||||
if ($json && isset($json['choices'][0]['delta']['content'])) {
|
||||
$content = $json['choices'][0]['delta']['content'];
|
||||
if ($content !== '') {
|
||||
$onChunk($content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return strlen($data);
|
||||
}
|
||||
]);
|
||||
|
||||
$result = curl_exec($ch);
|
||||
if ($result === false) {
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
throw new \RuntimeException('API请求失败: ' . $error);
|
||||
}
|
||||
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
throw new \RuntimeException('API返回错误,状态码: ' . $httpCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
164
app/Views/chat.php
Normal file
164
app/Views/chat.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<div class="chat-layout" id="chatLayout">
|
||||
<!-- 左侧边栏 -->
|
||||
<div class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<button class="btn btn-primary btn-sm" onclick="SessionManager.createSession()">+ 新建会话</button>
|
||||
</div>
|
||||
<div class="session-list" id="sessionList">
|
||||
<!-- 由 SessionManager.renderSessionList() 动态渲染 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主聊天区域 -->
|
||||
<div class="chat-main">
|
||||
<!-- 顶部工具栏 -->
|
||||
<div class="toolbar">
|
||||
<button class="toggle-sidebar" onclick="toggleSidebar()" title="切换侧边栏">☰</button>
|
||||
|
||||
<select id="providerSelect" onchange="onProviderChange()">
|
||||
<option value="">选择供应商</option>
|
||||
<!-- 由 loadProviders() 动态填充 -->
|
||||
</select>
|
||||
|
||||
<select id="modelSelect">
|
||||
<option value="">选择模型</option>
|
||||
<!-- 由 onProviderChange() 动态填充 -->
|
||||
</select>
|
||||
|
||||
<label class="toggle-switch" title="思考模式">
|
||||
<input type="checkbox" id="thinkingMode">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span style="font-size:12px;color:var(--text-secondary)">思考</span>
|
||||
|
||||
<select id="personalitySelect">
|
||||
<option value="">默认人格</option>
|
||||
<!-- 由 loadPersonalities() 动态填充 -->
|
||||
</select>
|
||||
|
||||
<a href="/config.php" class="btn btn-secondary btn-sm" style="margin-left:auto;">⚙️ 设置</a>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div class="messages-container" id="messagesContainer">
|
||||
<div class="empty-state">
|
||||
<h3>开始新的对话</h3>
|
||||
<p>输入消息开始聊天</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部输入区域 -->
|
||||
<div class="input-area">
|
||||
<div class="file-preview" id="filePreview"></div>
|
||||
<div class="input-wrapper">
|
||||
<button id="uploadBtn" title="上传文件" style="background:none;border:none;color:var(--text-secondary);cursor:pointer;font-size:18px;">📎</button>
|
||||
<textarea id="messageInput" rows="1" placeholder="输入消息,Ctrl+Enter 发送..."></textarea>
|
||||
<button id="sendBtn">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 切换侧边栏
|
||||
function toggleSidebar() {
|
||||
document.getElementById('sidebar').classList.toggle('collapsed');
|
||||
}
|
||||
|
||||
// 供应商/模型数据缓存
|
||||
let providersData = [];
|
||||
let personalitiesData = [];
|
||||
|
||||
// 加载供应商配置
|
||||
async function loadProviders() {
|
||||
try {
|
||||
const res = await api.get('/config');
|
||||
providersData = res.data.providers || [];
|
||||
const select = document.getElementById('providerSelect');
|
||||
select.innerHTML = '<option value="">选择供应商</option>';
|
||||
providersData.forEach((p, i) => {
|
||||
if (p.enabled) {
|
||||
const option = document.createElement('option');
|
||||
option.value = i;
|
||||
option.textContent = p.name;
|
||||
select.appendChild(option);
|
||||
}
|
||||
});
|
||||
// 如果只有一个供应商,自动选择
|
||||
if (providersData.filter(p => p.enabled).length === 1) {
|
||||
select.value = providersData.findIndex(p => p.enabled);
|
||||
onProviderChange();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载供应商配置失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 供应商切换时更新模型列表
|
||||
function onProviderChange() {
|
||||
const index = document.getElementById('providerSelect').value;
|
||||
const modelSelect = document.getElementById('modelSelect');
|
||||
modelSelect.innerHTML = '<option value="">选择模型</option>';
|
||||
|
||||
if (index !== '' && providersData[index]) {
|
||||
const provider = providersData[index];
|
||||
(provider.models || []).forEach(m => {
|
||||
const option = document.createElement('option');
|
||||
option.value = m;
|
||||
option.textContent = m;
|
||||
modelSelect.appendChild(option);
|
||||
});
|
||||
// 如果只有一个模型,自动选择
|
||||
if (provider.models && provider.models.length === 1) {
|
||||
modelSelect.value = provider.models[0];
|
||||
}
|
||||
// 如果有默认模型,自动选择
|
||||
if (provider.defaultModel) {
|
||||
modelSelect.value = provider.defaultModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载人格列表
|
||||
async function loadPersonalities() {
|
||||
try {
|
||||
const res = await api.get('/personalities');
|
||||
personalitiesData = res.data || [];
|
||||
const select = document.getElementById('personalitySelect');
|
||||
select.innerHTML = '<option value="">默认人格</option>';
|
||||
personalitiesData.forEach(p => {
|
||||
const option = document.createElement('option');
|
||||
option.value = p.id;
|
||||
option.textContent = (p.icon || '🤖') + ' ' + p.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('加载人格列表失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 页面初始化
|
||||
(async function() {
|
||||
// 检查认证
|
||||
const token = Storage.getToken();
|
||||
if (!token) {
|
||||
window.location.href = '/login.php';
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始化聊天管理器
|
||||
ChatManager.init();
|
||||
|
||||
// 加载数据
|
||||
await Promise.all([
|
||||
loadProviders(),
|
||||
loadPersonalities(),
|
||||
SessionManager.loadSessions()
|
||||
]);
|
||||
|
||||
// 如果有会话,自动选择第一个
|
||||
if (SessionManager.sessions.length > 0) {
|
||||
await SessionManager.switchSession(SessionManager.sessions[0].id);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
249
app/Views/config.php
Normal file
249
app/Views/config.php
Normal file
@@ -0,0 +1,249 @@
|
||||
<div class="config-container">
|
||||
<div class="config-header">
|
||||
<h1>⚙️ 系统配置</h1>
|
||||
<a href="/chat.php" class="btn btn-secondary">← 返回聊天</a>
|
||||
</div>
|
||||
|
||||
<!-- 供应商管理 -->
|
||||
<div class="config-section">
|
||||
<h2>AI 供应商管理</h2>
|
||||
<div id="providerList">
|
||||
<!-- 由 JS 动态渲染 -->
|
||||
</div>
|
||||
<button class="btn btn-secondary" onclick="ConfigPage.addProvider()" style="margin-top:12px;">+ 添加供应商</button>
|
||||
</div>
|
||||
|
||||
<!-- 人格管理 -->
|
||||
<div class="config-section">
|
||||
<h2>人格管理</h2>
|
||||
<div id="personalityList">
|
||||
<!-- 由 JS 动态渲染 -->
|
||||
</div>
|
||||
<h3 style="margin-top:20px;">添加自定义人格</h3>
|
||||
<div class="form-group">
|
||||
<label>名称</label>
|
||||
<input type="text" id="newPersonalityName" placeholder="人格名称">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>图标(Emoji)</label>
|
||||
<input type="text" id="newPersonalityIcon" placeholder="如:🤖" maxlength="2">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>提示词</label>
|
||||
<textarea id="newPersonalityPrompt" rows="3" placeholder="人格的系统提示词"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>描述</label>
|
||||
<input type="text" id="newPersonalityDesc" placeholder="简短描述">
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="ConfigPage.createPersonality()">添加人格</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="configMessage"></div>
|
||||
|
||||
<script>
|
||||
const ConfigPage = {
|
||||
providers: [],
|
||||
personalities: [],
|
||||
|
||||
async init() {
|
||||
// 检查认证和管理员权限
|
||||
const token = Storage.getToken();
|
||||
if (!token) {
|
||||
window.location.href = '/login.php';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 验证管理员权限
|
||||
const userRes = await api.get('/auth/me');
|
||||
if (userRes.data.role !== 'admin') {
|
||||
window.location.href = '/chat.php';
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
window.location.href = '/login.php';
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loadProviders();
|
||||
await this.loadPersonalities();
|
||||
},
|
||||
|
||||
async loadProviders() {
|
||||
try {
|
||||
const res = await api.get('/config');
|
||||
this.providers = res.data.providers || [];
|
||||
this.renderProviders();
|
||||
} catch (err) {
|
||||
this.showMessage('加载供应商配置失败: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
renderProviders() {
|
||||
const list = document.getElementById('providerList');
|
||||
if (this.providers.length === 0) {
|
||||
list.innerHTML = '<p style="color:var(--text-secondary)">暂无供应商配置</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = this.providers.map((p, i) => `
|
||||
<div class="provider-item" style="background:var(--bg-secondary);padding:16px;border-radius:var(--radius);margin-bottom:12px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
||||
<strong>${this.escapeHtml(p.name)}</strong>
|
||||
<div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" ${p.enabled ? 'checked' : ''} onchange="ConfigPage.toggleProvider(${i}, this.checked)">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<button class="btn btn-danger btn-sm" onclick="ConfigPage.deleteProvider(${i})" style="margin-left:8px;">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>API URL</label>
|
||||
<input type="text" value="${this.escapeHtml(p.apiUrl || '')}" onchange="ConfigPage.updateProvider(${i}, 'apiUrl', this.value)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>API Key</label>
|
||||
<input type="password" value="${this.escapeHtml(p.apiKey || '')}" onchange="ConfigPage.updateProvider(${i}, 'apiKey', this.value)" placeholder="API 密钥">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>可用模型(逗号分隔)</label>
|
||||
<input type="text" value="${this.escapeHtml((p.models || []).join(', '))}" onchange="ConfigPage.updateProviderModels(${i}, this.value)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>供应商类型</label>
|
||||
<select onchange="ConfigPage.updateProvider(${i}, 'type', this.value)">
|
||||
<option value="newapi" ${p.type === 'newapi' ? 'selected' : ''}>OpenAI 兼容</option>
|
||||
<option value="openai" ${p.type === 'openai' ? 'selected' : ''}>OpenAI 官方</option>
|
||||
<option value="claude" ${p.type === 'claude' ? 'selected' : ''}>Claude (Anthropic)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
},
|
||||
|
||||
addProvider() {
|
||||
this.providers.push({
|
||||
name: '新供应商',
|
||||
apiUrl: '',
|
||||
apiKey: '',
|
||||
models: [],
|
||||
type: 'newapi',
|
||||
enabled: true
|
||||
});
|
||||
this.renderProviders();
|
||||
this.saveProviders();
|
||||
},
|
||||
|
||||
updateProvider(index, field, value) {
|
||||
this.providers[index][field] = value;
|
||||
this.saveProviders();
|
||||
},
|
||||
|
||||
updateProviderModels(index, value) {
|
||||
this.providers[index].models = value.split(',').map(m => m.trim()).filter(m => m);
|
||||
this.saveProviders();
|
||||
},
|
||||
|
||||
toggleProvider(index, enabled) {
|
||||
this.providers[index].enabled = enabled;
|
||||
this.saveProviders();
|
||||
},
|
||||
|
||||
deleteProvider(index) {
|
||||
if (!confirm('确定要删除供应商 "' + this.providers[index].name + '" 吗?')) return;
|
||||
this.providers.splice(index, 1);
|
||||
this.renderProviders();
|
||||
this.saveProviders();
|
||||
},
|
||||
|
||||
async saveProviders() {
|
||||
try {
|
||||
await api.put('/config', { providers: this.providers });
|
||||
this.showMessage('供应商配置已保存', 'success');
|
||||
} catch (err) {
|
||||
this.showMessage('保存失败: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async loadPersonalities() {
|
||||
try {
|
||||
const res = await api.get('/personalities');
|
||||
this.personalities = res.data || [];
|
||||
this.renderPersonalities();
|
||||
} catch (err) {
|
||||
this.showMessage('加载人格列表失败: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
renderPersonalities() {
|
||||
const list = document.getElementById('personalityList');
|
||||
if (this.personalities.length === 0) {
|
||||
list.innerHTML = '<p style="color:var(--text-secondary)">暂无人格</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = this.personalities.map(p => `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px;background:var(--bg-secondary);border-radius:var(--radius);margin-bottom:8px;">
|
||||
<div>
|
||||
<span>${p.icon || '🤖'} ${this.escapeHtml(p.name)}</span>
|
||||
${p.is_preset ? '<span style="color:var(--warning);font-size:12px;margin-left:8px;">预设</span>' : '<span style="color:var(--success);font-size:12px;margin-left:8px;">自定义</span>'}
|
||||
</div>
|
||||
${!p.is_preset ? '<button class="btn btn-danger btn-sm" onclick="ConfigPage.deletePersonality(' + p.id + ')">删除</button>' : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
},
|
||||
|
||||
async createPersonality() {
|
||||
const name = document.getElementById('newPersonalityName').value.trim();
|
||||
const prompt = document.getElementById('newPersonalityPrompt').value.trim();
|
||||
const icon = document.getElementById('newPersonalityIcon').value.trim();
|
||||
const description = document.getElementById('newPersonalityDesc').value.trim();
|
||||
|
||||
if (!name || !prompt) {
|
||||
this.showMessage('名称和提示词不能为空', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post('/personalities', { name, prompt, icon, description });
|
||||
this.showMessage('人格创建成功', 'success');
|
||||
// 清空表单
|
||||
document.getElementById('newPersonalityName').value = '';
|
||||
document.getElementById('newPersonalityPrompt').value = '';
|
||||
document.getElementById('newPersonalityIcon').value = '';
|
||||
document.getElementById('newPersonalityDesc').value = '';
|
||||
await this.loadPersonalities();
|
||||
} catch (err) {
|
||||
this.showMessage('创建失败: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deletePersonality(id) {
|
||||
if (!confirm('确定要删除这个人格吗?')) return;
|
||||
try {
|
||||
await api.delete('/personalities/' + id);
|
||||
this.showMessage('人格已删除', 'success');
|
||||
await this.loadPersonalities();
|
||||
} catch (err) {
|
||||
this.showMessage('删除失败: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
showMessage(text, type) {
|
||||
const el = document.getElementById('configMessage');
|
||||
el.innerHTML = `<div class="alert alert-${type === 'error' ? 'error' : 'success'}" style="position:fixed;bottom:20px;right:20px;z-index:1000;min-width:200px;">${this.escapeHtml(text)}</div>`;
|
||||
setTimeout(() => { el.innerHTML = ''; }, 3000);
|
||||
},
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
};
|
||||
|
||||
ConfigPage.init();
|
||||
</script>
|
||||
0
app/Views/layout/.gitkeep
Normal file
0
app/Views/layout/.gitkeep
Normal file
10
app/Views/layout/footer.php
Normal file
10
app/Views/layout/footer.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked.js/12.0.0/marked.min.js"></script>
|
||||
<script src="/assets/js/storage.js"></script>
|
||||
<script src="/assets/js/api.js"></script>
|
||||
<script src="/assets/js/markdown.js"></script>
|
||||
<script src="/assets/js/session.js"></script>
|
||||
<script src="/assets/js/upload.js"></script>
|
||||
<script src="/assets/js/chat.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
12
app/Views/layout/header.php
Normal file
12
app/Views/layout/header.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Chat</title>
|
||||
<link rel="stylesheet" href="/assets/css/style.css">
|
||||
<link rel="stylesheet" href="/assets/css/chat.css">
|
||||
<link rel="stylesheet" href="/assets/css/markdown.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
||||
</head>
|
||||
<body>
|
||||
69
app/Views/login.php
Normal file
69
app/Views/login.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<h1>🤖 AI Chat</h1>
|
||||
<div id="loginError" class="alert alert-error" style="display:none;"></div>
|
||||
<form id="loginForm">
|
||||
<div class="form-group">
|
||||
<label for="username">用户名</label>
|
||||
<input type="text" id="username" name="username" required autocomplete="username" placeholder="请输入用户名">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password" placeholder="请输入密码">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block" id="loginBtn">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// 页面加载时检查是否已登录
|
||||
(function() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
window.location.href = '/chat.php';
|
||||
}
|
||||
})();
|
||||
|
||||
// 登录表单提交
|
||||
document.getElementById('loginForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('loginBtn');
|
||||
const errorEl = document.getElementById('loginError');
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!username || !password) {
|
||||
errorEl.textContent = '请输入用户名和密码';
|
||||
errorEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '登录中...';
|
||||
errorEl.style.display = 'none';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
localStorage.setItem('token', data.data.token);
|
||||
window.location.href = '/chat.php';
|
||||
} else {
|
||||
errorEl.textContent = data.message || '登录失败';
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
errorEl.textContent = '网络错误,请稍后重试';
|
||||
errorEl.style.display = 'block';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '登录';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user