初始化仓库及v1.0.0提交

This commit is contained in:
2026-05-05 03:21:58 +08:00
commit 813bb02672
67 changed files with 5263 additions and 0 deletions

0
app/Config/.gitkeep Normal file
View File

57
app/Config/AppConfig.php Normal file
View 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
View 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
View File

View 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']
]
]);
}
}

View 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();
}
}

View 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' => '删除成功']);
}
}

View 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]
]);
}
}

View 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]);
}
}

View 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' => '删除成功']);
}
}

View 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
View File

View 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;
}
}
}

View 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
View File

27
app/Models/Config.php Normal file
View 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
View 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();
}
}

View 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
View 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
View 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']);
}
}

View 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
View 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' => '[]',
]);
}
}

View File

View 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);
}
}
}

View 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);
}
}

View 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
View 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
View 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>

View File

View 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>

View 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
View 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>