This commit is contained in:
Your Name
2026-07-22 10:18:59 +08:00
parent 2530ddada6
commit 0fb03d0bca
618 changed files with 19445 additions and 3 deletions
Submodule backend deleted from 49917ae7c0
+11
View File
@@ -0,0 +1,11 @@
APP_DEBUG = true
DB_TYPE = mysql
DB_HOST = 127.0.0.1
DB_NAME = test
DB_USER = username
DB_PASS = password
DB_PORT = 3306
DB_CHARSET = utf8
DEFAULT_LANG = zh-cn
+12
View File
@@ -0,0 +1,12 @@
*.log
.env
composer.phar
composer.lock
.DS_Store
Thumbs.db
/.idea
/.vscode
/vendor
/.settings
/.buildpath
/.project
+42
View File
@@ -0,0 +1,42 @@
sudo: false
language: php
branches:
only:
- stable
cache:
directories:
- $HOME/.composer/cache
before_install:
- composer self-update
install:
- composer install --no-dev --no-interaction --ignore-platform-reqs
- zip -r --exclude='*.git*' --exclude='*.zip' --exclude='*.travis.yml' ThinkPHP_Core.zip .
- composer require --update-no-dev --no-interaction "topthink/think-image:^1.0"
- composer require --update-no-dev --no-interaction "topthink/think-migration:^1.0"
- composer require --update-no-dev --no-interaction "topthink/think-captcha:^1.0"
- composer require --update-no-dev --no-interaction "topthink/think-mongo:^1.0"
- composer require --update-no-dev --no-interaction "topthink/think-worker:^1.0"
- composer require --update-no-dev --no-interaction "topthink/think-helper:^1.0"
- composer require --update-no-dev --no-interaction "topthink/think-queue:^1.0"
- composer require --update-no-dev --no-interaction "topthink/think-angular:^1.0"
- composer require --dev --update-no-dev --no-interaction "topthink/think-testing:^1.0"
- zip -r --exclude='*.git*' --exclude='*.zip' --exclude='*.travis.yml' ThinkPHP_Full.zip .
script:
- php think unit
deploy:
provider: releases
api_key:
secure: TSF6bnl2JYN72UQOORAJYL+CqIryP2gHVKt6grfveQ7d9rleAEoxlq6PWxbvTI4jZ5nrPpUcBUpWIJHNgVcs+bzLFtyh5THaLqm39uCgBbrW7M8rI26L8sBh/6nsdtGgdeQrO/cLu31QoTzbwuz1WfAVoCdCkOSZeXyT/CclH99qV6RYyQYqaD2wpRjrhA5O4fSsEkiPVuk0GaOogFlrQHx+C+lHnf6pa1KxEoN1A0UxxVfGX6K4y5g4WQDO5zT4bLeubkWOXK0G51XSvACDOZVIyLdjApaOFTwamPcD3S1tfvuxRWWvsCD5ljFvb2kSmx5BIBNwN80MzuBmrGIC27XLGOxyMerwKxB6DskNUO9PflKHDPI61DRq0FTy1fv70SFMSiAtUv9aJRT41NQh9iJJ0vC8dl+xcxrWIjU1GG6+l/ZcRqVx9V1VuGQsLKndGhja7SQ+X1slHl76fRq223sMOql7MFCd0vvvxVQ2V39CcFKao/LB1aPH3VhODDEyxwx6aXoTznvC/QPepgWsHOWQzKj9ftsgDbsNiyFlXL4cu8DWUty6rQy8zT2b4O8b1xjcwSUCsy+auEjBamzQkMJFNlZAIUrukL/NbUhQU37TAbwsFyz7X0E/u/VMle/nBCNAzgkMwAUjiHM6FqrKKBRWFbPrSIixjfjkCnrMEPw=
file:
- ThinkPHP_Core.zip
- ThinkPHP_Full.zip
skip_cleanup: true
on:
tags: true
+32
View File
@@ -0,0 +1,32 @@
ThinkPHP遵循Apache2开源协议发布,并提供免费使用。
版权所有Copyright © 2006-2025 by ThinkPHP (http://thinkphp.cn)
All rights reserved。
ThinkPHP® 商标和著作权所有者为上海顶想信息科技有限公司。
Apache Licence是著名的非盈利开源组织Apache采用的协议。
该协议和BSD类似,鼓励代码共享和尊重原作者的著作权,
允许代码修改,再作为开源或商业软件发布。需要满足
的条件:
1. 需要给代码的用户一份Apache Licence
2. 如果你修改了代码,需要在被修改的文件中说明;
3. 在延伸的代码中(修改和有源代码衍生的代码中)需要
带有原来代码中的协议,商标,专利声明和其他原来作者规
定需要包含的说明;
4. 如果再发布的产品中包含一个Notice文件,则在Notice文
件中需要带有本协议内容。你可以在Notice中增加自己的
许可,但不可以表现为对Apache Licence构成更改。
具体的协议参考:http://www.apache.org/licenses/LICENSE-2.0
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
+77
View File
@@ -0,0 +1,77 @@
![](https://www.thinkphp.cn/uploads/images/20230630/300c856765af4d8ae758c503185f8739.png)
ThinkPHP 8
===============
## 特性
* 基于PHP`8.0+`重构
* 升级`PSR`依赖
* 依赖`think-orm`3.0+版本
* 全新的`think-dumper`服务,支持远程调试
* 支持`6.0`/`6.1`无缝升级
> ThinkPHP8的运行环境要求PHP8.0+
现在开始,你可以使用官方提供的[ThinkChat](https://chat.topthink.com/),让你在学习ThinkPHP的旅途中享受私人AI助理服务!
![](https://www.topthink.com/uploads/assistant/20230630/4d1a3f0ad2958b49bb8189b7ef824cb0.png)
ThinkPHP生态服务由[顶想云](https://www.topthink.com)TOPThink Cloud)提供,为生态提供专业的开发者服务和价值之选。
## 文档
[完全开发手册](https://doc.thinkphp.cn)
## 赞助
全新的[赞助计划](https://www.thinkphp.cn/sponsor)可以让你通过我们的网站、手册、欢迎页及GIT仓库获得巨大曝光,同时提升企业的品牌声誉,也更好保障ThinkPHP的可持续发展。
[![](https://www.thinkphp.cn/sponsor/special.svg)](https://www.thinkphp.cn/sponsor/special)
[![](https://www.thinkphp.cn/sponsor.svg)](https://www.thinkphp.cn/sponsor)
## 安装
~~~
composer create-project topthink/think tp
~~~
启动服务
~~~
cd tp
php think run
~~~
然后就可以在浏览器中访问
~~~
http://localhost:8000
~~~
如果需要更新框架使用
~~~
composer update topthink/framework
~~~
## 命名规范
`ThinkPHP`遵循PSR-2命名规范和PSR-4自动加载规范。
## 参与开发
直接提交PR或者Issue即可
## 版权信息
ThinkPHP遵循Apache2开源协议发布,并提供免费使用。
本项目包含的第三方源码和二进制文件之版权信息另行标注。
版权所有Copyright © 2006-2024 by ThinkPHP (http://thinkphp.cn) All rights reserved。
ThinkPHP® 商标和著作权所有者为上海顶想信息科技有限公司。
更多细节参阅 [LICENSE.txt](LICENSE.txt)
+1
View File
@@ -0,0 +1 @@
deny from all
+22
View File
@@ -0,0 +1,22 @@
<?php
declare (strict_types = 1);
namespace app;
use think\Service;
/**
* 应用服务类
*/
class AppService extends Service
{
public function register()
{
// 服务注册
}
public function boot()
{
// 服务启动
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
declare (strict_types = 1);
namespace app;
use think\App;
use think\exception\ValidateException;
use think\Validate;
/**
* 控制器基础类
*/
abstract class BaseController
{
/**
* Request实例
* @var \think\Request
*/
protected $request;
/**
* 应用实例
* @var \think\App
*/
protected $app;
/**
* 是否批量验证
* @var bool
*/
protected $batchValidate = false;
/**
* 控制器中间件
* @var array
*/
protected $middleware = [];
/**
* 构造方法
* @access public
* @param App $app 应用对象
*/
public function __construct(App $app)
{
$this->app = $app;
$this->request = $this->app->request;
// 控制器初始化
$this->initialize();
}
// 初始化
protected function initialize()
{}
/**
* 验证数据
* @access protected
* @param array $data 数据
* @param string|array $validate 验证器名或者验证规则数组
* @param array $message 提示信息
* @param bool $batch 是否批量验证
* @return array|string|true
* @throws ValidateException
*/
protected function validate(array $data, string|array $validate, array $message = [], bool $batch = false)
{
if (is_array($validate)) {
$v = new Validate();
$v->rule($validate);
} else {
if (strpos($validate, '.')) {
// 支持场景
[$validate, $scene] = explode('.', $validate);
}
$class = false !== strpos($validate, '\\') ? $validate : $this->app->parseClass('validate', $validate);
$v = new $class();
if (!empty($scene)) {
$v->scene($scene);
}
}
$v->message($message);
// 是否批量验证
if ($batch || $this->batchValidate) {
$v->batch(true);
}
return $v->failException(true)->check($data);
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace app;
use think\db\exception\DataNotFoundException;
use think\db\exception\ModelNotFoundException;
use think\exception\Handle;
use think\exception\HttpException;
use think\exception\HttpResponseException;
use think\exception\ValidateException;
use think\Response;
use Throwable;
/**
* 应用异常处理类
*/
class ExceptionHandle extends Handle
{
/**
* 不需要记录信息(日志)的异常类列表
* @var array
*/
protected $ignoreReport = [
HttpException::class,
HttpResponseException::class,
ModelNotFoundException::class,
DataNotFoundException::class,
ValidateException::class,
];
/**
* 记录异常信息(包括日志或者其它方式记录)
*
* @access public
* @param Throwable $exception
* @return void
*/
public function report(Throwable $exception): void
{
// 使用内置的方式记录异常日志
parent::report($exception);
}
/**
* Render an exception into an HTTP response.
*
* @access public
* @param \think\Request $request
* @param Throwable $e
* @return Response
*/
public function render($request, Throwable $e): Response
{
// 添加自定义异常处理机制
// 其他错误交给系统处理
return parent::render($request, $e);
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace app;
// 应用请求对象类
class Request extends \think\Request
{
}
+2
View File
@@ -0,0 +1,2 @@
<?php
// 应用公共文件
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace app\controller;
use app\BaseController;
class Index extends BaseController
{
public function index()
{
return $this->serveSpa('index.html');
}
private function serveSpa(string $file)
{
$path = rtrim(public_path($file), '/\\');
if (!is_file($path)) {
return response('前端尚未编译,请在项目根目录执行 npm run build', 503, [
'Content-Type' => 'text/plain; charset=utf-8',
]);
}
return response(file_get_contents($path), 200, [
'Content-Type' => 'text/html; charset=utf-8',
]);
}
}
File diff suppressed because it is too large Load Diff
+160
View File
@@ -0,0 +1,160 @@
<?php
namespace app\controller\api;
use app\model\User;
use app\model\MembershipLevel;
use app\model\Role;
use app\service\JwtService;
use app\service\SettingsService;
use app\service\UserContextService;
class Auth extends BaseApi
{
public function guest()
{
$guestKey = strtolower(trim((string) $this->request->post('guest_key', '')));
if (!preg_match('/^[a-f0-9]{64}$/', $guestKey)) {
return $this->error('游客标识无效,请刷新页面重试', 422);
}
// Only persist a one-way fingerprint; the random browser key remains the credential.
$fingerprint = hash('sha256', $guestKey);
$username = 'guest_' . substr($fingerprint, 0, 32);
$user = User::where('username', $username)->find();
if (!$user) {
$roleId = Role::where('slug', 'user')->value('id');
$membershipId = MembershipLevel::where('slug', 'free')->value('id') ?: 1;
try {
$user = User::create([
'username' => $username,
'email' => $username . '@guest.local',
'password_hash' => password_hash(bin2hex(random_bytes(32)), PASSWORD_BCRYPT),
'nickname' => '访客',
'role' => 'user',
'role_id' => $roleId ?: null,
'membership_level_id' => $membershipId,
'status' => 'active',
]);
} catch (\Throwable $exception) {
// Two tabs may initialize the same browser guest at the same time.
$user = User::where('username', $username)->find();
if (!$user) {
throw $exception;
}
}
}
if ($user->status !== 'active') {
return $this->error('游客访问暂不可用', 403);
}
$token = JwtService::generateToken(['user_id' => $user->id]);
return $this->success([
'token' => $token,
'user' => $this->formatUser($user->id),
], '已进入游客模式');
}
public function register()
{
$allow = SettingsService::get('allow_register', true);
if ($allow !== true && $allow !== 'true') {
return $this->error('当前不允许注册');
}
$input = $this->request->post();
$username = trim($input['username'] ?? '');
$email = trim($input['email'] ?? '');
$password = $input['password'] ?? '';
if (strlen($username) < 3 || strlen($username) > 50) {
return $this->error('用户名长度需 3-50 个字符');
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $this->error('邮箱格式不正确');
}
if (strlen($password) < 6) {
return $this->error('密码至少 6 位');
}
if (User::where('username', $username)->whereOr('email', $email)->find()) {
return $this->error('用户名或邮箱已存在');
}
$defaultRoleId = \app\model\Role::where('slug', 'user')->value('id');
$user = User::create([
'username' => $username,
'email' => $email,
'password_hash' => password_hash($password, PASSWORD_BCRYPT),
'nickname' => $username,
'membership_level_id' => 1,
'role_id' => $defaultRoleId ?: null,
]);
$token = JwtService::generateToken(['user_id' => $user->id]);
return $this->success([
'token' => $token,
'user' => $this->formatUser($user->id),
], '注册成功');
}
public function login()
{
$input = $this->request->post();
$account = trim($input['account'] ?? '');
$password = $input['password'] ?? '';
if (!$account || !$password) {
return $this->error('请输入账号和密码');
}
$user = User::where(function ($query) use ($account) {
$query->where('username', $account)->whereOr('email', $account);
})->where('status', 'active')->find();
if (!$user || !password_verify($password, $user->password_hash)) {
return $this->error('账号或密码错误', 401);
}
$user->save(['last_login_at' => date('Y-m-d H:i:s')]);
$token = JwtService::generateToken(['user_id' => $user->id]);
return $this->success([
'token' => $token,
'user' => $this->formatUser($user->id),
], '登录成功');
}
public function me()
{
return $this->success($this->authUser());
}
public function updateProfile()
{
$user = $this->authUser();
$nickname = trim($this->request->put('nickname', ''));
if ($nickname) {
User::where('id', $user['id'])->update(['nickname' => $nickname]);
}
return $this->success($this->formatUser($user['id']));
}
public function logout()
{
return $this->success(null, '已退出');
}
private function formatUser(int $userId): array
{
return UserContextService::formatPublicUser($userId);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace app\controller\api;
use app\BaseController;
use think\response\Json;
class BaseApi extends BaseController
{
protected function success(mixed $data = null, string $message = 'success', int $httpCode = 200): Json
{
return json([
'code' => 0,
'message' => $message,
'data' => $data,
], $httpCode);
}
protected function error(string $message, int $httpCode = 400, int $code = 1): Json
{
return json([
'code' => $code,
'message' => $message,
'data' => null,
], $httpCode);
}
protected function authUser(): array
{
return $this->request->authUser ?? [];
}
}
File diff suppressed because it is too large Load Diff
+216
View File
@@ -0,0 +1,216 @@
<?php
namespace app\controller\api;
use app\model\AiModel;
use app\model\Conversation as ConversationModel;
use app\model\Message;
use app\service\ComfyUIService;
use app\service\PermissionService;
use think\exception\HttpResponseException;
class Conversation extends BaseApi
{
public function index()
{
$user = $this->authUser();
$page = max(1, (int) $this->request->get('page', 1));
$limit = min(50, max(1, (int) $this->request->get('limit', 20)));
$query = ConversationModel::alias('c')
->leftJoin('ai_models m', 'c.model_id = m.id')
->where('c.user_id', $user['id'])
->whereNull('c.deleted_at')
->field('c.*,m.name as model_name,m.provider as model_provider')
->order(['c.is_pinned' => 'desc', 'c.updated_at' => 'desc']);
$total = (clone $query)->count();
$list = $query->page($page, $limit)->select();
return $this->success([
'list' => $list,
'total' => $total,
'page' => $page,
'limit' => $limit,
]);
}
public function create()
{
$user = $this->authUser();
PermissionService::checkConversationLimit($user);
$input = $this->request->post();
$modelId = $input['model_id'] ?? null;
if ($modelId !== null && $modelId !== '') {
$modelId = (int) $modelId;
if ($modelId <= 0) {
$modelId = null;
}
} else {
$modelId = null;
}
$conversation = ConversationModel::create([
'user_id' => $user['id'],
'title' => trim($input['title'] ?? '新对话'),
'model_id' => $modelId,
]);
return $this->success($this->findConversation((int) $conversation->id, (int) $user['id']), '创建成功');
}
public function show($id)
{
$user = $this->authUser();
return $this->success($this->findConversation((int) $id, (int) $user['id']));
}
public function update($id)
{
$user = $this->authUser();
$conversation = ConversationModel::where('id', $id)
->where('user_id', $user['id'])
->whereNull('deleted_at')
->find();
if (!$conversation) {
return $this->error('会话不存在', 404);
}
$input = $this->request->put();
$data = [];
if (isset($input['title'])) {
$data['title'] = trim($input['title']);
}
if (isset($input['is_pinned'])) {
$data['is_pinned'] = (int) $input['is_pinned'];
}
if (array_key_exists('model_id', $input)) {
$mid = $input['model_id'];
if ($mid === null || $mid === '') {
$data['model_id'] = null;
} else {
$data['model_id'] = (int) $mid;
}
}
if (empty($data)) {
return $this->error('无更新内容');
}
$conversation->save($data);
return $this->success($this->findConversation((int) $id, (int) $user['id']));
}
public function delete($id)
{
$user = $this->authUser();
$conversation = ConversationModel::where('id', $id)
->where('user_id', $user['id'])
->whereNull('deleted_at')
->find();
if (!$conversation) {
return $this->error('会话不存在', 404);
}
$conversation->save(['deleted_at' => date('Y-m-d H:i:s')]);
return $this->success(null, '删除成功');
}
public function messages($id)
{
$user = $this->authUser();
$this->findConversation((int) $id, (int) $user['id']);
$rows = Message::where('conversation_id', $id)
->field('id,role,content,content_type,attachments,created_at')
->order('created_at', 'asc')
->select();
$list = [];
foreach ($rows as $item) {
$attachments = $item->attachments;
if (is_string($attachments)) {
$attachments = json_decode($attachments, true) ?: [];
}
if (!is_array($attachments)) {
$attachments = [];
}
// 刷新后恢复未完成的 Comfy 生图任务
$stillPending = false;
$pendingJob = ComfyUIService::findPendingJob($attachments);
if ($pendingJob && $item->role === 'assistant') {
$modelId = (int) ($pendingJob['model_id'] ?? 0);
$model = $modelId > 0 ? AiModel::find($modelId) : null;
if ($model) {
$recovered = ComfyUIService::recoverPendingMessage([
'id' => (int) $item->id,
'content' => (string) $item->content,
'attachments' => $attachments,
], $model, (int) $user['id']);
$item->content = $recovered['content'];
$item->attachments = $recovered['attachments'];
$attachments = $recovered['attachments'];
$stillPending = empty($recovered['finished']);
Message::where('id', $item->id)->update([
'content' => $recovered['content'],
'attachments' => json_encode($recovered['attachments'], JSON_UNESCAPED_UNICODE),
]);
} else {
$stillPending = true;
}
}
if ($item->role === 'assistant' && trim((string) $item->content) === '') {
if (empty($attachments) || !ComfyUIService::hasImageAttachments($attachments)) {
continue;
}
}
// 前端只展示真实媒体附件;pending 任务信息已体现在 content 文案中
$visibleAttachments = array_values(array_filter(
$attachments,
fn ($a) => is_array($a) && ($a['type'] ?? '') !== ComfyUIService::JOB_TYPE
));
$list[] = [
'id' => $item->id,
'role' => $item->role,
'content' => $item->content,
'content_type' => $item->content_type,
'attachments' => $visibleAttachments,
'pending_job' => $stillPending,
'pending_image_count' => $stillPending ? (int) ($pendingJob['image_count'] ?? 0) : 0,
'created_at' => $item->created_at,
];
}
return $this->success($list);
}
private function findConversation(int $id, int $userId): array
{
$conversation = ConversationModel::alias('c')
->leftJoin('ai_models m', 'c.model_id = m.id')
->where('c.id', $id)
->where('c.user_id', $userId)
->whereNull('c.deleted_at')
->field('c.*,m.name as model_name,m.provider as model_provider')
->find();
if (!$conversation) {
throw new HttpResponseException(json([
'code' => 1,
'message' => '会话不存在',
'data' => null,
], 404));
}
return $conversation->toArray();
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace app\controller\api;
use app\model\AiModel;
use app\service\AgentCatalog;
use app\service\SettingsService;
class Settings extends BaseApi
{
public function features()
{
return $this->success(SettingsService::getFeatures());
}
public function publicSettings()
{
$allow = SettingsService::get('allow_register', true);
return $this->success([
'site_name' => SettingsService::get('site_name', 'AI Chat'),
'allow_register' => $allow === true || $allow === 'true',
'features' => SettingsService::getFeatures(),
]);
}
public function models()
{
$list = AiModel::where('enabled', 1)
->field('id,name,provider,model_id,is_default,support_context,support_image')
->order('sort_order,id')
->select();
return $this->success($list);
}
public function agents()
{
return $this->success(AgentCatalog::publicList());
}
}
+229
View File
@@ -0,0 +1,229 @@
<?php
namespace app\controller\api;
use app\model\UploadFile;
use app\service\PermissionService;
use think\exception\HttpResponseException;
class Upload extends BaseApi
{
public function upload()
{
$user = $this->authUser();
$file = $this->request->file('file');
if (!$file) {
return $this->error('请选择文件');
}
try {
$ext = strtolower($file->extension() ?: pathinfo($file->getOriginalName(), PATHINFO_EXTENSION));
$ext = $ext ?: 'bin';
$mime = $this->resolveMime($file, $ext);
$fileType = $this->detectFileType($mime, $ext);
if (!empty($user['is_guest']) && $fileType === 'image') {
return $this->error('游客模式不支持发送图片,请登录后再上传', 403);
}
if ($fileType === 'document' && !in_array($mime, config('upload.allowed_documents'), true)) {
$mime = self::DOCUMENT_MIME_BY_EXT[$ext] ?? $mime;
}
if (!PermissionService::canUpload($user, $fileType)) {
return $this->error('您没有权限上传此类型文件或功能未开启', 403);
}
$maxSize = PermissionService::getMaxUploadSizeMb($user) * 1024 * 1024;
if ($file->getSize() > $maxSize) {
return $this->error('文件大小超出限制');
}
if (!$this->isAllowedMime($mime, $fileType, $ext)) {
return $this->error('不支持的文件类型: ' . $mime);
}
$originalName = $file->getOriginalName();
$fileSize = $file->getSize();
$uploadPath = rtrim(config('upload.path'), '/\\');
$this->ensureUploadDir($uploadPath);
$subdir = date('Y/m/d');
$storedBase = uniqid('', true) . '.' . $ext;
$storedName = $subdir . '/' . $storedBase;
$fullDir = $uploadPath . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $subdir);
if (!is_dir($fullDir) && !mkdir($fullDir, 0755, true) && !is_dir($fullDir)) {
return $this->error('无法创建上传目录,请检查 uploads 权限', 500);
}
if (!is_writable($fullDir)) {
return $this->error('上传目录不可写,请执行: chmod -R 775 uploads && chown -R www-data:www-data uploads', 500);
}
$moved = $file->move($fullDir, $storedBase);
if (!$moved) {
return $this->error('文件保存失败: ' . ($file->getError() ?: '未知错误'), 500);
}
$record = UploadFile::create([
'user_id' => $user['id'],
'original_name' => $originalName,
'stored_name' => $storedBase,
'file_path' => $storedName,
'mime_type' => $mime,
'file_size' => $fileSize,
'file_type' => $fileType,
]);
return $this->success([
'id' => $record->id,
'url' => '/api/uploads/' . rawurlencode($storedBase),
'name' => $originalName,
'type' => $fileType,
'mime' => $mime,
'size' => $fileSize,
]);
} catch (\think\exception\FileException $e) {
return $this->error('文件保存失败: ' . $e->getMessage(), 500);
} catch (\Throwable $e) {
return $this->error('上传失败: ' . $e->getMessage(), 500);
}
}
public function serve($filename)
{
$filename = basename($filename);
$upload = UploadFile::where('stored_name', $filename)
->whereOr('file_path', 'like', '%/' . $filename)
->find();
if (!$upload) {
throw new HttpResponseException(json([
'code' => 1,
'message' => '文件不存在',
'data' => null,
], 404));
}
$path = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $upload->file_path);
if (!is_file($path)) {
throw new HttpResponseException(json([
'code' => 1,
'message' => '文件不存在',
'data' => null,
], 404));
}
$mime = $upload->mime_type ?: (@mime_content_type($path) ?: 'application/octet-stream');
return response(file_get_contents($path), 200, [
'Content-Type' => $mime,
'Content-Length' => (string) filesize($path),
'Cache-Control' => 'public, max-age=604800',
]);
}
/**
* 文档扩展名 -> 常见但不同系统上可能检测出不一致的 MIME 兜底表。
* .doc/.docx 等 Office 文档在不同服务器 fileinfo 版本下识别出的 MIME 差异很大,
* 仅靠 MIME 白名单很容易误判为“不支持的文件类型”,因此这里用扩展名兜底放行。
*/
private const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'];
private const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov'];
private const DOCUMENT_EXTENSIONS = ['pdf', 'doc', 'docx', 'txt', 'md'];
private const IMAGE_MIME_BY_EXT = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'bmp' => 'image/bmp',
];
private const DOCUMENT_MIME_BY_EXT = [
'pdf' => 'application/pdf',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'txt' => 'text/plain',
'md' => 'text/markdown',
];
private function resolveMime($file, string $ext): string
{
$mime = $file->getMime();
if (!$mime && is_file($file->getPathname())) {
$mime = @mime_content_type($file->getPathname()) ?: '';
}
$mime = strtolower(trim((string) $mime));
if ($mime === 'image/jpg') {
$mime = 'image/jpeg';
}
if ($mime === '' || $mime === 'application/octet-stream') {
$mime = self::IMAGE_MIME_BY_EXT[$ext] ?? self::DOCUMENT_MIME_BY_EXT[$ext] ?? $mime;
}
return $mime ?: 'application/octet-stream';
}
private function ensureUploadDir(string $uploadPath): void
{
if (is_dir($uploadPath)) {
return;
}
if (!mkdir($uploadPath, 0755, true) && !is_dir($uploadPath)) {
throw new \RuntimeException('无法创建 uploads 目录: ' . $uploadPath);
}
}
private function detectFileType(string $mime, string $ext = ''): string
{
$ext = strtolower($ext);
if (in_array($ext, self::IMAGE_EXTENSIONS, true)) {
return 'image';
}
if (in_array($ext, self::VIDEO_EXTENSIONS, true)) {
return 'video';
}
if (str_starts_with($mime, 'image/')) {
return 'image';
}
if (str_starts_with($mime, 'video/')) {
return 'video';
}
if (str_starts_with($mime, 'audio/')) {
return 'audio';
}
if (in_array($mime, config('upload.allowed_documents'), true)) {
return 'document';
}
if (in_array(strtolower($ext), self::DOCUMENT_EXTENSIONS, true)) {
return 'document';
}
return 'other';
}
private function isAllowedMime(string $mime, string $fileType, string $ext = ''): bool
{
$config = config('upload');
return match ($fileType) {
'image' => in_array($mime, $config['allowed_images'], true)
|| in_array(strtolower($ext), self::IMAGE_EXTENSIONS, true),
'video' => in_array($mime, $config['allowed_videos'], true)
|| in_array(strtolower($ext), self::VIDEO_EXTENSIONS, true),
'audio' => in_array($mime, $config['allowed_audios'], true),
'document' => in_array($mime, $config['allowed_documents'], true)
|| in_array(strtolower($ext), self::DOCUMENT_EXTENSIONS, true),
default => false,
};
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
// 事件定义文件
return [
'bind' => [
],
'listen' => [
'AppInit' => [],
'HttpRun' => [],
'HttpEnd' => [],
'LogLevel' => [],
'LogWrite' => [],
],
'subscribe' => [
],
];
+10
View File
@@ -0,0 +1,10 @@
<?php
// 全局中间件定义文件
return [
// 全局请求缓存
// \think\middleware\CheckRequestCache::class,
// 多语言加载
// \think\middleware\LoadLangPack::class,
// Session初始化
// \think\middleware\SessionInit::class
];
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace app\middleware;
use app\service\AdminScopeService;
use think\exception\HttpResponseException;
class AdminAuth
{
public function handle($request, \Closure $next)
{
$user = $request->authUser ?? null;
if (!$user || !AdminScopeService::canAccessAdmin($user)) {
throw new HttpResponseException(json([
'code' => 1,
'message' => '无管理后台访问权限',
'data' => null,
], 403));
}
return $next($request);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace app\middleware;
use app\service\JwtService;
use think\Response;
class Cors
{
public function handle($request, \Closure $next)
{
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($request->method(true) === 'OPTIONS') {
return Response::create('', 'html', 204);
}
return $next($request);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace app\middleware;
use app\model\User;
use app\service\JwtService;
use app\service\UserContextService;
use think\exception\HttpResponseException;
use think\Response;
class JwtAuth
{
public function handle($request, \Closure $next)
{
$token = $request->header('Authorization', '');
$payload = JwtService::verifyToken($token);
if (!$payload || empty($payload['user_id'])) {
$this->abort('请先登录', 401);
}
$user = User::with(['membership', 'roleModel', 'department'])->find($payload['user_id']);
if (!$user || $user->status !== 'active') {
$this->abort('请先登录', 401);
}
$request->authUser = UserContextService::formatAuthUser($user);
return $next($request);
}
private function abort(string $message, int $httpCode = 400): void
{
throw new HttpResponseException(json([
'code' => 1,
'message' => $message,
'data' => null,
], $httpCode));
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace app\model;
use think\Model;
class AiModel extends Model
{
protected $name = 'ai_models';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
protected $type = [
'extra_config' => 'json',
];
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace app\model;
use think\Model;
class Conversation extends Model
{
protected $name = 'conversations';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
public function aiModel()
{
return $this->belongsTo(AiModel::class, 'model_id');
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace app\model;
use think\Model;
class Department extends Model
{
protected $name = 'departments';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
public function parent()
{
return $this->belongsTo(self::class, 'parent_id');
}
public function children()
{
return $this->hasMany(self::class, 'parent_id');
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace app\model;
use think\Model;
class MembershipLevel extends Model
{
protected $name = 'membership_levels';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
protected $type = [
'permissions' => 'json',
'allowed_models' => 'json',
];
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace app\model;
use think\Model;
class Message extends Model
{
protected $name = 'messages';
protected $autoWriteTimestamp = 'created_at';
protected $createTime = 'created_at';
protected $updateTime = false;
protected $type = [
'attachments' => 'json',
];
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace app\model;
use app\service\PermissionCatalog;
use think\Model;
class Role extends Model
{
protected $name = 'roles';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
public static function defaultPermissions(): array
{
return PermissionCatalog::emptyPermissions();
}
public function getPermissionsAttr($value): array
{
return PermissionCatalog::normalize(self::toPermArray($value));
}
public function setPermissionsAttr($value): string
{
return json_encode(
PermissionCatalog::normalize(self::toPermArray($value)),
JSON_UNESCAPED_UNICODE
);
}
/**
* ThinkPHP 8 会把 JSON 列转成 \think\model\type\Json,需调用 value() 取出数组。
*/
private static function toPermArray(mixed $value): array
{
if ($value === null || $value === '') {
return [];
}
if (is_array($value)) {
return $value;
}
if (is_object($value) && method_exists($value, 'value')) {
$data = $value->value();
return is_array($data) ? $data : [];
}
if (is_string($value)) {
$decoded = json_decode($value, true);
return (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) ? $decoded : [];
}
return [];
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace app\model;
use think\Model;
class SysPermission extends Model
{
protected $name = 'sys_permissions';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
public function children()
{
return $this->hasMany(self::class, 'parent_id')->order('sort_order')->order('id');
}
public function parent()
{
return $this->belongsTo(self::class, 'parent_id');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace app\model;
use think\Model;
class SystemSetting extends Model
{
protected $name = 'system_settings';
protected $autoWriteTimestamp = 'updated_at';
protected $updateTime = 'updated_at';
protected $createTime = false;
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace app\model;
use think\Model;
class UploadFile extends Model
{
protected $name = 'uploads';
protected $autoWriteTimestamp = 'created_at';
protected $createTime = 'created_at';
protected $updateTime = false;
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace app\model;
use think\Model;
class User extends Model
{
protected $name = 'users';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
public function membership()
{
return $this->belongsTo(MembershipLevel::class, 'membership_level_id');
}
public function roleModel()
{
return $this->belongsTo(Role::class, 'role_id');
}
public function department()
{
return $this->belongsTo(Department::class, 'department_id');
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace app\model;
use think\Model;
class UserDailyStat extends Model
{
protected $name = 'user_daily_stats';
protected $autoWriteTimestamp = false;
}
+9
View File
@@ -0,0 +1,9 @@
<?php
use app\ExceptionHandle;
use app\Request;
// 容器Provider定义文件
return [
'think\Request' => Request::class,
'think\exception\Handle' => ExceptionHandle::class,
];
+9
View File
@@ -0,0 +1,9 @@
<?php
use app\AppService;
// 系统服务定义文件
// 服务在完成全局初始化之后执行
return [
AppService::class,
];
+159
View File
@@ -0,0 +1,159 @@
<?php
namespace app\service;
use app\model\Role;
use app\model\User;
use app\service\DepartmentService as DeptSvc;
use app\service\PermissionCatalog;
class AdminScopeService
{
public static function permissions(array $authUser): array
{
$perms = $authUser['role_permissions'] ?? [];
if (!is_array($perms)) {
$perms = [];
}
return PermissionCatalog::normalize($perms);
}
public static function hasPermission(array $authUser, string $key): bool
{
$perms = self::permissions($authUser);
// 超级管理员 / 旧 admin 角色:全部放行
if (($authUser['role'] ?? '') === 'admin' && empty($authUser['role_id'])) {
return true;
}
if (($authUser['role_slug'] ?? '') === 'super_admin') {
return true;
}
return PermissionCatalog::hasCode($perms, $key);
}
public static function hasAny(array $authUser, array $keys): bool
{
foreach ($keys as $key) {
if (self::hasPermission($authUser, $key)) {
return true;
}
}
return false;
}
public static function canAccessAdmin(array $authUser): bool
{
if (($authUser['role'] ?? '') === 'admin') {
return true;
}
return self::hasPermission($authUser, 'can_access_admin');
}
public static function requirePermission(array $authUser, string $key): void
{
if (!self::hasPermission($authUser, $key)) {
throw new \think\exception\HttpResponseException(json([
'code' => 1,
'message' => '无操作权限',
'data' => null,
], 403));
}
}
public static function requireAny(array $authUser, array $keys): void
{
if (!self::hasAny($authUser, $keys)) {
throw new \think\exception\HttpResponseException(json([
'code' => 1,
'message' => '无操作权限',
'data' => null,
], 403));
}
}
/**
* @return int[]|null null 表示可见全部用户
*/
public static function visibleUserIds(array $authUser): ?array
{
if (self::hasPermission($authUser, 'btn:conv:view_all')
|| self::hasPermission($authUser, 'can_view_all_conversations')) {
return null;
}
if (self::hasPermission($authUser, 'btn:conv:view_subordinate')
|| self::hasPermission($authUser, 'can_view_subordinate_conversations')) {
$departmentId = (int) ($authUser['department_id'] ?? 0);
if ($departmentId <= 0) {
return [(int) $authUser['id']];
}
$deptIds = DeptSvc::descendantIds($departmentId);
$userIds = DeptSvc::userIdsInDepartments($deptIds);
return array_values(array_unique(array_map('intval', $userIds)));
}
if (($authUser['role'] ?? '') === 'admin') {
return null;
}
return [(int) $authUser['id']];
}
public static function canViewUser(array $authUser, int $targetUserId): bool
{
$visible = self::visibleUserIds($authUser);
if ($visible === null) {
return true;
}
return in_array($targetUserId, $visible, true);
}
public static function canViewConversation(array $authUser, int $conversationUserId): bool
{
return self::canViewUser($authUser, $conversationUserId);
}
public static function applyUserScope($query, array $authUser, string $alias = 'u')
{
$visible = self::visibleUserIds($authUser);
if ($visible !== null) {
$query->whereIn("{$alias}.id", $visible ?: [0]);
}
return $query;
}
public static function applyConversationScope($query, array $authUser, string $conversationAlias = 'c')
{
$visible = self::visibleUserIds($authUser);
if ($visible !== null) {
$query->whereIn("{$conversationAlias}.user_id", $visible ?: [0]);
}
return $query;
}
public static function syncLegacyRoleField(int $userId, ?int $roleId): void
{
if (!$roleId) {
return;
}
$role = Role::find($roleId);
if (!$role) {
return;
}
$permissions = PermissionCatalog::normalize($role->permissions ?? []);
$legacyRole = !empty($permissions['can_access_admin']) ? 'admin' : 'user';
User::where('id', $userId)->update(['role' => $legacyRole]);
}
}
+909
View File
@@ -0,0 +1,909 @@
<?php
namespace app\service;
class AgentCatalog
{
public static function all(): array
{
return [
[
'id' => 'auto',
'name' => 'Agent',
'short_name' => 'Agent',
'description' => '自动识别任务并调用合适的语言或图片生成模型',
'icon' => 'robot',
'accent' => 'blue',
'placeholder' => '描述你要完成的任务,Agent 会自动判断并执行',
'supports_image' => true,
'image_prompt' => 'High-quality purposeful image, coherent composition, clear visual hierarchy, refined lighting and color, faithful to the user request, professional art direction, no readable text, no letters, no Chinese characters, no title, no caption, no watermark.',
'system_prompt' => '你是通用自主 Agent。先在内部识别用户的真实目标与任务类型,再选择合适的方法直接完成:规划类任务梳理目标、约束、依赖和执行步骤;代码类任务理解现有约束,定位根因,给出可靠实现与验证;写作类任务判断受众、场景、目标和语气,组织清晰自然的内容;分析类任务核对口径,区分事实、推断与假设,给出证据和建议。不要要求用户先选择任务分类,也不要无意义地复述分类结果。信息足够时直接推进,信息不足时只询问真正阻塞的关键问题。必须理解连续对话中的省略、指代和真实意图,不能只按当前一句做关键词匹配:如果前文正在讨论某个对象“长什么样”、外观、形态、颜色或场景,用户随后说“我看不到”“看不见”“我想看看”“给我看看”“展示一下”等,真实目标就是直接看到该对象的图片;应从最近对话提取主体和视觉特征并执行生图,绝不能回答自己是纯文本模型、建议用户另找绘图工具或继续用文字描述。解析“他、她、它、这个、那个”等代词时,最近一轮由用户明确提到的实体拥有最高优先级;代词的字形或性别绝不能自行把动物、物品、建筑等改成人类。新的知识询问、新主体介绍、图片识别和单纯情绪反馈默认是文本任务,绝不能因为前面生成过图片就继承旧主体或继续生图。“什么鬼”“不对”“胡说”“离谱”等短反馈通常是在质疑上一轮回答,应结合紧邻的助手回复承认偏差、重新核对或询问具体错误,不能把短语当成词条进行字面解释。图片任务必须遵守动作协议:当用户明确或隐含地希望看到、生成、重做或修改图片,评价上一张生成图并期待调整,或确认刚讨论的图片方案(例如“对”“就这样”“按方案三”)时,不要继续讲解或重复确认,只输出一行 JSON{"action":"generate_image","prompt":"完整、独立、可直接用于生图的最终画面描述"}。prompt 必须以当前图片版本状态为基础,合并本轮要求并保留所有未被本轮明确推翻的历史修改,绝不能擅自退回更早版本的场景、主体、风格或构图;“太 X”通常表示降低 X,“不够 X”表示增强 X,不得反向理解。prompt 的视觉描述以英文为主;用户未要求画面含字时不得保留中文指令词,并必须明确 no text, no letters, no Chinese characters, no title, no caption, no watermark;用户明确给出标题、书名或画面文字时,必须将原文逐字保留为 EXACT_VISIBLE_TEXT: <<<原文>>>,禁止翻译、改写、漏字、换序或另造字符,此时禁止追加 no text/no title 等冲突要求。prompt 只能描述修改完成后真正可见的主体、环境、构图、风格、光线和色彩,不得引用、复述或翻译“生成一张图片”“重新优化”等命令和反馈原句。“在某个环节生成”“接着生成”“帮我生成”等流程、时序和操作措辞不是画面内容,禁止据此添加人物或场景;只有用户明确说画面包含某元素时才能加入。只有缺失会改变核心主体的关键信息且无法从上下文推断时,才简短追问一次。用户只是要求识图、分析图片、编写提示词或询问生成方法时,应正常回答,不得输出图片动作。系统会自动执行动作 JSON,绝不能把 JSON 当作回答展示给用户。用户询问能力时应明确说明可以生成图片,不要声称不支持。不得编造图片链接,也不得在系统没有返回真实图片附件时声称图片已经生成。',
],
];
}
public static function publicList(): array
{
return array_map(function (array $agent) {
unset($agent['system_prompt'], $agent['supports_image'], $agent['image_prompt']);
return $agent;
}, self::all());
}
public static function find(?string $id): ?array
{
if (!$id) {
return null;
}
if (in_array($id, ['planner', 'engineer', 'writer', 'analyst'], true)) {
$id = 'auto';
}
foreach (self::all() as $agent) {
if ($agent['id'] === $id) {
return $agent;
}
}
return null;
}
public static function supportsImage(?array $agent): bool
{
return $agent !== null && ($agent['supports_image'] ?? false) === true;
}
public static function buildImagePrompt(?array $agent, string $content): string
{
$content = trim($content);
if (!self::supportsImage($agent)) {
return $content;
}
$direction = trim((string) ($agent['image_prompt'] ?? ''));
if (str_contains($content, 'EXACT_VISIBLE_TEXT: <<<')) {
$direction = 'High-quality purposeful image, coherent composition, clear visual hierarchy, refined lighting and color, faithful to the user request, professional art direction.';
}
return $direction === '' ? $content : $direction . "\n\n" . $content;
}
/**
* Parse the private action envelope emitted by the language model. The model may
* wrap it in a Markdown fence or use the legacy `task` key, so accept both forms.
*/
public static function parseImageAction(string $content): ?array
{
$content = trim($content);
if ($content === '') {
return null;
}
$candidates = [[
'json' => $content,
'wrapper' => '',
]];
if (preg_match_all('/```(?:json)?\s*([\s\S]*?)```/iu', $content, $fences, PREG_OFFSET_CAPTURE)) {
foreach ($fences[1] as $index => $match) {
$fullMatch = $fences[0][$index];
$candidates[] = [
'json' => $match[0],
'wrapper' => substr($content, 0, $fullMatch[1])
. substr($content, $fullMatch[1] + strlen($fullMatch[0])),
];
}
}
foreach (self::extractJsonObjects($content) as $object) {
$candidates[] = $object;
}
foreach ($candidates as $candidate) {
if (!self::allowsImageActionWrapper((string) ($candidate['wrapper'] ?? ''))) {
continue;
}
$json = trim((string) preg_replace(
'/^```(?:json)?\s*|\s*```$/iu',
'',
trim((string) ($candidate['json'] ?? ''))
));
$payload = json_decode($json, true);
if (!is_array($payload)) {
continue;
}
$action = mb_strtolower(trim((string) ($payload['action'] ?? $payload['task'] ?? $payload['tool'] ?? '')));
$action = str_replace(['-', ' '], '_', $action);
if (!in_array($action, ['generate_image', 'create_image', 'image_generation', 'draw_image'], true)) {
continue;
}
$arguments = $payload['arguments'] ?? [];
if (is_string($arguments)) {
$arguments = json_decode($arguments, true) ?: [];
}
$prompt = trim((string) ($payload['prompt'] ?? (is_array($arguments) ? ($arguments['prompt'] ?? '') : '')));
if ($prompt === '') {
continue;
}
return [
'action' => 'generate_image',
'prompt' => mb_substr($prompt, 0, 8000),
];
}
return null;
}
/**
* The deterministic router has already approved an image operation, so a valid
* action object may be recovered even when the model surrounded it with prose.
*/
public static function parseRoutedImageAction(string $content): ?array
{
foreach (self::extractJsonObjects($content) as $object) {
$action = self::parseImageAction((string) ($object['json'] ?? ''));
if ($action !== null) {
return $action;
}
}
return null;
}
private static function extractJsonObjects(string $content): array
{
$objects = [];
$length = strlen($content);
$depth = 0;
$start = null;
$inString = false;
$escaped = false;
for ($index = 0; $index < $length; $index++) {
$char = $content[$index];
if ($inString) {
if ($escaped) {
$escaped = false;
} elseif ($char === '\\') {
$escaped = true;
} elseif ($char === '"') {
$inString = false;
}
continue;
}
if ($char === '"') {
$inString = true;
} elseif ($char === '{') {
if ($depth === 0) {
$start = $index;
}
$depth++;
} elseif ($char === '}' && $depth > 0) {
$depth--;
if ($depth === 0 && $start !== null) {
$json = substr($content, $start, $index - $start + 1);
$objects[] = [
'json' => $json,
'wrapper' => substr($content, 0, $start) . substr($content, $index + 1),
];
$start = null;
}
}
}
return $objects;
}
private static function allowsImageActionWrapper(string $wrapper): bool
{
$wrapper = trim((string) preg_replace('/[`\s,。.!:;]+/u', ' ', $wrapper));
if ($wrapper === '') {
return true;
}
if (preg_match('/示例|格式|例如|假设|说明|解释|请勿|不要执行|仅供参考|example|format|do\s+not|don[\']t|reference/iu', $wrapper)) {
return false;
}
if (mb_strlen($wrapper) > 320) {
return false;
}
if (mb_strlen($wrapper) > 80) {
return preg_match(
'/(?:我将|将为你|将为您|现在为你|现在为您|接下来|马上|立即|正在)'
. '[\s\S]{0,180}(?:执行|生成|创作|制作|绘制|生图|generate|create|draw|paint)/iu',
$wrapper
) === 1;
}
return preg_match(
'/(?:好的|收到|明白|可以|没问题|开始|马上|立即|现在|正在|将为你|下面开始|'
. 'here\s+you\s+go|here\s+is|i[\']ll|i\s+will|we[\']ll|we\s+will|generating|creating)'
. '[^。.!?]{0,48}(?:执行|生成|创作|制作|绘制|生图|generate|create|draw|paint)?/iu',
$wrapper
) === 1;
}
/**
* Detect natural visual requests that omit words such as “图片” or “生成”.
* Elliptical complaints like “我看不到” only count when recent dialogue is
* actually about appearance, preventing code/document visibility issues from
* being routed to the image model.
*/
public static function requestsContextualImageGeneration(string $content, array $history = []): bool
{
$content = mb_strtolower(trim($content));
if ($content === '' || self::requestsImageGeneration($content)) {
return false;
}
// “看不懂” describes comprehension, not an inability to see an image.
if (preg_match('/^(?:我)?(?:还是)?(?:看不懂|看不明白|没看懂|没有看懂)[了啊呀呢吗,,。!?!?\s]*$/u', $content)) {
return false;
}
if (preg_match('/(?:不要|不用|无需|别|不需要|只要文字|文字说明|不用图片|不要图片)/u', $content)) {
return false;
}
$nonVisualTarget = '/(?:介绍|资料|知识|习性|毒性|分类|区别|原因|为什么|怎么|如何|教程|步骤|方法|代码|文档|表格|报表|数据|日志|报错|新闻|视频|电影|直播|实时|天气|预报|价格|股票|网页|页面|消息|聊天记录|文字|答案)/u';
if (preg_match($nonVisualTarget, $content)) {
return false;
}
$directDisplay = '/^(?:请|麻烦)?(?:'
. '(?:给|让)(?:我)?(?:看(?:看|到|见|一下)?|瞧瞧|展示(?:一下)?)'
. '|(?:我)?(?:想|要|希望|想要)(?:看(?:看|到|见|一下)?|瞧瞧)'
. '|(?:能否|能不能|可以|可不可以)?(?:给我|让我)?(?:看(?:看|到|见|一下)?|瞧瞧|展示(?:一下)?)'
. ')\s*(?<target>[^,。!?!?\n]{1,30}?)(?:的?(?:样子|图片|照片|图像))?(?:可以吗|行吗|好吗|呢)?$/u';
if (preg_match($directDisplay, $content, $match)) {
$target = trim((string) ($match['target'] ?? ''));
$resolvedTarget = self::normalizeVisualSubject($target);
if ($resolvedTarget !== '' && !preg_match($nonVisualTarget, $resolvedTarget)) {
return true;
}
if (self::resolveContextualImageSubject($content, $history) !== '') {
return true;
}
}
$appearanceQuestion = '/^(?:他|她|它|他们|她们|它们|这个|那个|这|那|该对象|该东西|[^,。!?!?\n]{1,24})?'
. '(?:长啥样|长什么样(?:子)?|是什么样子|外观(?:怎样|如何|是什么样)?)'
. '[啊呀呢吗么,,。!?!?\s]*$/u';
if (preg_match($appearanceQuestion, $content)
&& self::resolveContextualImageSubject($content, $history) !== '') {
return true;
}
if (preg_match('/^(?:那)?也(?:给我|让我)?(?:看看|看一下|瞧瞧|展示一下)[了啊吧呀呢,,。!?!?\s]*$/u', $content)
&& self::resolveContextualImageSubject($content, $history) !== '') {
return true;
}
$naturalContextualDisplay = '/^(?:'
. '(?:给我?)?瞅一眼|有照片吗|(?:他|她|它|这个|那个)有(?:图|照片)吗|'
. '(?:这个|那个)?也想(?:看看|瞧瞧)|我也想(?:看看|瞧瞧)|'
. '也来(?:一)?张(?:他|她|它|这个|那个)(?:的)?|'
. '(?:两(?:个|位|只)|俩)都(?:给我|让我)?(?:看看|瞧瞧|展示一下)|'
. '(?:那)?也展示一下'
. ')[了啊吧呀呢呗,,。!?!?\s]*$/u';
if (preg_match($naturalContextualDisplay, $content)
&& self::resolveContextualImageSubject($content, $history) !== '') {
return true;
}
$appearanceThenDisplay = '/^(?:他|她|它|他们|她们|它们|这个|那个|这|那|该对象|该东西|[^,。!?!?\n]{1,24})?'
. '(?:长啥样|长什么样|是什么样子|外观(?:怎样|如何|是什么样)?)'
. '[,、\s]*(?:我)?(?:想|要|希望|想要)?(?:看(?:看|到|见|一下)?|瞧瞧|展示(?:一下)?)'
. '[了啊呀呢吗??。!!]*$/u';
if (preg_match($appearanceThenDisplay, $content)
&& self::resolveContextualImageSubject($content, $history) !== '') {
return true;
}
$ellipticalDisplay = '/^(?:我)?(?:还是|但|可是|就是|现在)?\s*'
. '(?:看不到|看不见|没看到|没有看到|想看(?:看|到)?|想瞧瞧|给我看看|让我看看|展示一下|直接展示|有图吗|图片呢|图呢|能看吗|能看到吗)'
. '[了啊呀呢吗??。!!]*$/u';
if (!preg_match($ellipticalDisplay, $content)) {
return false;
}
$contextParts = [];
$history = array_slice($history, -12);
foreach ($history as $index => $message) {
$messageContent = trim((string) ($message['content'] ?? ''));
$isCurrentMessage = $index === array_key_last($history)
&& ($message['role'] ?? '') === 'user'
&& mb_strtolower($messageContent) === $content;
if ($messageContent !== '' && !$isCurrentMessage) {
$contextParts[] = $messageContent;
}
}
$context = implode("\n", $contextParts);
if ($context === '') {
return false;
}
return preg_match(
'/(?:长什么样|什么样子|外观|外形|形态|样貌|造型|轮廓|颜色|纹理|头部|身体|眼睛|四肢|翅膀|鳞片|毛发|姿态|构图|场景|风景|天空|动物|人物|建筑|物体|照片|图片|图像|画面|视觉|写实|卡通)/u',
$context
) === 1;
}
/**
* Resolve the concrete entity behind a contextual visual request. Pronouns are
* never accepted as subjects; the latest explicit user entity wins.
*/
public static function resolveContextualImageSubject(string $content, array $history = []): string
{
$content = mb_strtolower(trim($content));
if ($content === '') {
return '';
}
$currentPatterns = [
'/^(?<subject>[^,。!?!?\n]{1,24}?)(?:长啥样|长什么样|是什么样子|外观(?:怎样|如何|是什么样)?)/u',
'/(?:给我|让我|我想|我希望|我想要|想|要|希望)(?:看(?:看|到|见|一下)?|瞧瞧|展示(?:一下)?)\s*(?<subject>[^,。!?!?\n]{1,30})/u',
];
foreach ($currentPatterns as $pattern) {
if (preg_match($pattern, $content, $match)) {
$subject = self::normalizeVisualSubject((string) ($match['subject'] ?? ''));
if ($subject !== '') {
return $subject;
}
}
}
$history = array_slice($history, -12);
$priorMessages = [];
foreach ($history as $index => $message) {
$messageContent = mb_strtolower(trim((string) ($message['content'] ?? '')));
$isCurrentMessage = $index === array_key_last($history)
&& ($message['role'] ?? '') === 'user'
&& $messageContent === $content;
if ($messageContent !== '' && !$isCurrentMessage) {
$priorMessages[] = [
'role' => (string) ($message['role'] ?? ''),
'content' => $messageContent,
];
}
}
for ($index = count($priorMessages) - 1; $index >= 0; $index--) {
$message = $priorMessages[$index];
if ($message['role'] !== 'user') {
continue;
}
$patterns = [
'/^(?:不是[^,。!?!?\n]{1,20}[,]?\s*)?(?:我说的是|我要的是|应该是)\s*(?<subject>[^,。!?!?\n]{1,24})/u',
'/(?:你知道|你了解|认识|了解一下|讲讲|介绍一下)\s*(?<subject>[^,。!?!?\n]{1,24}?)(?:吗|么|呢|吧||\?|。||!)?$/u',
'/^(?:那|那么|至于)\s*(?<subject>[^,。!?!?\n]{1,24}?)\s*(?:呢|怎么样|又如何)?[,。!?!?\s]*$/u',
'/^(?<subject>[^,。!?!?\n]{1,24}?)(?:长啥样|长什么样|是什么样子|的?(?:外观|外形|形态|样貌|造型))/u',
'/(?:想看|看看|展示|生成|画|绘制)\s*(?<subject>[^,。!?!?\n]{1,24})/u',
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $message['content'], $match)) {
$subject = self::normalizeVisualSubject((string) ($match['subject'] ?? ''));
if ($subject !== '') {
if (self::isAmbiguousReferencedSubject($subject, $content)) {
return '';
}
return $subject;
}
}
}
}
for ($index = count($priorMessages) - 1; $index >= 0; $index--) {
$message = $priorMessages[$index];
if ($message['role'] !== 'assistant') {
continue;
}
if (preg_match(
'/^(?:知道|了解|当然知道|当然了解)?[。,.!\s]*(?<subject>[\p{Han}a-z0-9·_-]{1,24}?)(?:是|属于|通常|一般|具有|有)/iu',
$message['content'],
$match
)) {
$subject = self::normalizeVisualSubject((string) ($match['subject'] ?? ''));
if ($subject !== '') {
if (self::isAmbiguousReferencedSubject($subject, $content)) {
return '';
}
return $subject;
}
}
}
return '';
}
private static function normalizeVisualSubject(string $subject): string
{
$subject = mb_strtolower(trim($subject));
$subject = preg_replace('/^[“”‘’"\'《》〈〉【】\[\]()()\s]+|[“”‘’"\'《》〈〉【】\[\]()()\s]+$/u', '', $subject);
$subject = preg_replace(
'/^(?:请|麻烦)?(?:给我|让我|我想|我希望|我想要|想|要|希望)(?:看(?:看|到|见|一下)?|瞧瞧|展示(?:一下)?)?/u',
'',
$subject
);
$subject = preg_replace('/^(?:关于|有关|这个|那个|这种|那种|一个|一种|一只|一位|该)/u', '', $subject);
$subject = preg_replace('/(?:的?(?:样子|图片|照片|图像)|长啥样|长什么样|是什么样子|可以吗|行吗|好吗|呢|吗)$/u', '', $subject);
$subject = trim((string) $subject);
if ($subject === '' || mb_strlen($subject) > 24) {
return '';
}
if (preg_match('/^(?:看|看看|看一下|瞧瞧|展示|展示一下)$/u', $subject)) {
return '';
}
if (preg_match('/^(?:那)?(?:他|她|它|他们|她们|它们|这个|那个|这|那|玩意儿|东西|对象|该对象|该东西)(?:长|是|外观)?$/u', $subject)) {
return '';
}
if (preg_match('/^(?:他|她|它|他们|她们|它们|这个|那个|这|那|其|对象|东西|该对象|该东西)$/u', $subject)) {
return '';
}
if (preg_match('/(?:介绍|资料|知识|习性|毒性|分类|区别|原因|教程|步骤|方法|代码|文档|表格|报表|数据|日志|报错|新闻|视频|电影|直播|实时|天气|预报|价格|股票|网页|页面|消息|聊天记录|文字|答案|php|javascript|java|python)/iu', $subject)) {
return '';
}
if (preg_match('/(?:不要|不用|生成|执行|系统|提示词|prompt|json|模型|工具)/iu', $subject)) {
return '';
}
return $subject;
}
private static function isAmbiguousReferencedSubject(string $subject, string $content): bool
{
$hasMultipleCandidates = preg_match('/(?:和|与|、|以及|还有|及)/u', $subject) === 1;
if (!$hasMultipleCandidates) {
return false;
}
$usesPluralReference = preg_match('/(?:他们|她们|它们|两(?:个|位|只)|都|一起|全部)/u', $content) === 1;
$usesSingularReference = preg_match('/(?:他|她|它)(?!们)|(?:这个|那个|这位|那位)/u', $content) === 1
|| preg_match('/^(?:我)?(?:想|要|希望)?(?:看看|看一下|瞧瞧)[了啊吧呀呢,,。!?!?\s]*$/u', $content) === 1;
return $usesSingularReference && !$usesPluralReference;
}
/**
* Detect instructions that require the pixels of an existing image. The
* controller only enables this route when a user image or generated image
* is actually available, so ordinary text such as “删除这一段” is unaffected.
*/
public static function requestsImageEditing(string $content): bool
{
$content = mb_strtolower(trim($content));
if ($content === '') {
return false;
}
if (self::requestsImageEnhancement($content)) {
return true;
}
if (preg_match('/(?:分析|解释|识别|读取|描述|评价|为什么|是什么|是谁|教程|方法|怎么做)/u', $content)
&& !preg_match('/(?:修改|处理|修复|重绘|去掉|移除|擦除|删除|替换|换成|改成)/u', $content)) {
return false;
}
return preg_match(
'/(?:img2img|image\s*to\s*image|inpaint|图生图|局部重绘|局部修复|图片编辑|处理这张图|修改这张图|'
. '(?:去|移除|擦除|抹掉|删除|去掉|清除)[^,。!?!?\n]{0,16}(?:水印|logo|标志|文字|字幕|人物|路人|物体|东西|瑕疵|污点|反光|背景)|'
. '(?:水印|logo|标志|文字|字幕|人物|路人|物体|东西|瑕疵|污点|反光)[^,。!?!?\n]{0,12}(?:去|移除|擦除|抹掉|删除|去掉|清除)|'
. '(?:换|替换|修改|改变|改成)[^,。!?!?\n]{0,10}(?:背景|天空|衣服|颜色|风格|人物|物体)|'
. '(?:抠图|扩图|补图|修图|精修|老照片修复|照片修复|画质修复|修复[^,,。!?!?\n]{0,10}(?:照片|图片|画面)|上色|去噪|锐化|无损放大|增强清晰度)|'
. '(?:(?:去除|移除|删除|擦除|清除)[^,。!?!?\n]{0,6}(?:不彻底|不干净|有残留)|'
. '(?:没|没有|未)(?:去|删|清|擦)[^,。!?!?\n]{0,6}(?:干净|掉|完)|'
. '(?:还|仍然|依然)(?:有|在)[^,。!?!?\n]{0,6}(?:水印|残留))|'
. '(?:把|将)[^,。!?!?\n]{1,24}(?:换成|改成|变成|去(?:除|掉)(?:了)?|移除(?:了)?|删除(?:了)?|擦掉(?:了)?|修复))'
. '/iu',
$content
) === 1;
}
public static function requestsImageEnhancement(string $content): bool
{
$content = mb_strtolower(trim($content));
if ($content === '') {
return false;
}
if (preg_match('/(?:怎么|如何|为什么|教程|方法|原理|能不能介绍|请解释)/u', $content)) {
return false;
}
return preg_match(
'/(?:变|变得|弄|调|处理)(?:成|得)?(?:更)?清晰|'
. '(?:更|再)清晰(?:一?点|一些)?|清晰(?:一?点|一些)|'
. '提高清晰度|增强清晰度|高清化|变高清|提升画质|改善画质|'
. '超分(?:辨率)?|无损放大|锐化(?:一下)?/u',
$content
) === 1;
}
public static function imageEditMode(string $content): string
{
$content = mb_strtolower(trim($content));
return preg_match(
'/(?:inpaint|局部|遮罩|蒙版|修补|补全|修复|不彻底|不干净|有残留|没去干净|'
. '(?:去|移除|擦除|擦掉|抹掉|删除|去掉|清除)|'
. '(?:换|替换|修改|改变|改成)[^,。!?!?\n]{0,10}(?:背景|天空|衣服|人物|物体))/iu',
$content
) === 1 ? 'inpaint' : 'img2img';
}
public static function requestsBackgroundRemoval(string $content): bool
{
$content = mb_strtolower(trim($content));
if ($content === '') {
return false;
}
if (preg_match('/(?:怎么|如何|教程|方法|原理|能不能介绍|请解释)/u', $content)) {
return false;
}
// Do not treat "补全/相邻/周围背景" (inpaint fill language) as cutout.
return preg_match(
'/(?:抠图|抠出[^,。!?!?\n]{0,16}(?:主体|人物|商品|物体)|'
. '(?:移除|去除|删除|去掉|清除)\s*(?:图片|照片|整图|图像|这张图)?(?:中|里)?(?:的|了)?背景|'
. '(?:把|将)背景(?:移除|去除|删除|去掉|清除)|'
. '(?:背景透明|透明背景|透明底|transparent\s+background|remove\s+(?:the\s+)?background))/iu',
$content
) === 1;
}
/**
* Resolve cutout / watermark intent. Explicit workbench image_tool wins over content heuristics.
*
* @return array{is_background_removal: bool, is_watermark_removal: bool}
*/
public static function resolveImageEditFlags(string $content, string $imageTool = ''): array
{
$imageTool = trim($imageTool);
if ($imageTool === 'cutout') {
return ['is_background_removal' => true, 'is_watermark_removal' => false];
}
if ($imageTool === 'watermark') {
return ['is_background_removal' => false, 'is_watermark_removal' => true];
}
if ($imageTool !== '') {
// Other explicit tools (erase/replace/...) must never be misrouted to cutout.
$isWatermarkRemoval = $imageTool === 'erase' && self::requestsWatermarkRemoval($content);
return ['is_background_removal' => false, 'is_watermark_removal' => $isWatermarkRemoval];
}
$isBackgroundRemoval = self::requestsBackgroundRemoval($content);
$isWatermarkRemoval = self::requestsWatermarkRemoval($content);
if ($isBackgroundRemoval && $isWatermarkRemoval) {
// Phrases like "去掉水印并补全背景" are watermark jobs, not cutout.
$isBackgroundRemoval = false;
}
return [
'is_background_removal' => $isBackgroundRemoval,
'is_watermark_removal' => $isWatermarkRemoval,
];
}
public static function requestsWatermarkRemoval(string $content): bool
{
$content = mb_strtolower(trim($content));
if ($content === '') {
return false;
}
if (preg_match(
'/(?:(?:去除|移除|删除|擦除|清除)[^,。!?!?\n]{0,6}(?:不彻底|不干净|有残留)|'
. '(?:没|没有|未)(?:去|删|清|擦)[^,。!?!?\n]{0,6}(?:干净|掉|完)|'
. '(?:还|仍然|依然)(?:有|在)[^,。!?!?\n]{0,6}(?:水印|残留))/u',
$content
)) {
return true;
}
return preg_match(
'/(?:(?:去(?:除|掉)?|移除|删除|擦除|擦掉|抹掉|清除)[^,。!?!?\n]{0,16}(?:水印|watermark)|'
. '(?:水印|watermark)[^,。!?!?\n]{0,16}(?:去(?:除|掉)?|移除|删除|擦除|擦掉|抹掉|清除|remove|erase)|'
. '(?:remove|erase)[^,.!?\n]{0,16}watermark)/iu',
$content
) === 1;
}
public static function imageTextRemovalTarget(string $content): ?string
{
$content = mb_strtolower(trim($content));
if ($content === '') {
return null;
}
$hasRemoval = preg_match(
'/(?:(?:去(?:除|掉)?|移除|删除|擦除|擦掉|抹掉|清除)(?:了)?|'
. '(?:不要|不保留|隐藏)[^,。!?!?\n]{0,8}(?:文字|文本|字样|署名|作者|作者名|标题|书名))/u',
$content
) === 1;
if (!$hasRemoval) {
return null;
}
if (preg_match('/(?:作者|作者名|署名|笔名)/u', $content)) {
return 'author';
}
if (preg_match('/(?:标题|书名|主标题|副标题)/u', $content)) {
return 'title';
}
if (preg_match('/(?:所有|全部|整张|画面中|图中)?[^,。!?!?\n]{0,6}(?:文字|文本|字样|字幕)/u', $content)) {
return 'all_text';
}
return null;
}
public static function requestsImageRevision(string $content): bool
{
$content = mb_strtolower(trim($content));
if ($content === '') {
return false;
}
$textOnlyTarget = preg_match('/文案|文章|代码|方案|报告|表格|数据|提示词|prompt/u', $content) === 1;
$imageReference = preg_match('/图片|图像|画面|插画|海报|封面|头像|照片|背景|构图|人物|角色/u', $content) === 1;
$strongRevision = '/(?:重新|再次|再)(?:生成|制作|创作|做|画|绘制|渲染|优化|调整|修改|来|出图|生图)|重做|重画|换(?:一|个)?张|再来(?:一|个)?张|重新来/u';
if (preg_match($strongRevision, $content)) {
if ($textOnlyTarget && !$imageReference) {
return false;
}
return true;
}
if (preg_match('/为什么|怎么|怎么办|如何|是什么|描述|分析|评价|解释|识别|读取|建议|方法/u', $content)) {
return false;
}
$correctionRevision = '/^(?:不对|不是|错了|搞错了|认错了|画错了|这不是|我说的是|我要的是|应该是)'
. '[^。!?!?\n]{0,36}(?:我说的是|我要的是|应该是|不是|而是|是|换成|改成|画成)'
. '[^。!?!?\n]{1,30}/u';
if (preg_match($correctionRevision, $content)) {
return !$textOnlyTarget || $imageReference;
}
$visualTarget = '(?:图片|图像|画面|主体|人物|角色|背景|构图|镜头|光线|色彩|颜色|风格|姿势|动作|表情|服装|材质|细节|比例|胸围|胸部|肌肉|脸|眼睛|头发|毛发|鬃毛|毛|角|翅膀|尾巴|身体|天空|草地|招牌|文字|字|尺寸|分辨率)';
$editAction = '(?:调整|修改|优化|改(?:成|为)?|换(?:成|为|个)?|变成|做成|画成|调(?:成|整)?|增加|减少|加强|弱化|放大|缩小|加大|减小|删掉|去掉|别加|不要|取消|保留|保持|替换|修正)';
$qualityTarget = '(?:大|小|长|短|亮|暗|高|低|多|少|宽|窄|强|弱|胖|瘦|饱满|明显|真实|自然|写实|卡通|清晰|模糊|拥挤|空旷|空|荒凉|生动|僵硬|单调|鲜艳|饱和|假|胸围|胸部|肌肉|细节|比例)';
$contextualRevision = '/^(?:请|麻烦)?(?:把|将)?[^,。!?!?\n]{0,14}'
. '(?:改(?:成|为)?|换(?:成|为|个)?|变成|做成|画成|调(?:成|整)?)'
. '[^,。!?!?\n]{1,24}/u';
if (preg_match($contextualRevision, $content)) {
return !$textOnlyTarget || $imageReference;
}
$shortVisualRevision = preg_match(
'/^(?:请|麻烦)?(?:'
. $visualTarget . '(?:往|向)(?:左|右|上|下|前|后)(?:移|挪)?(?:一点|点|一些|些)?'
. '|' . $visualTarget . '(?:拉远|拉近|推近|后退|前移)(?:一点|点|一些|些)?'
. '|' . $visualTarget . '(?:再|更|稍微)?(?:淡|浓|亮堂|明亮|柔和|鲜艳|自然)(?:一点|点|一些|些)?'
. '|(?:再|更|稍微)?(?:亮堂|明亮|柔和|自然)(?:一点|点|一些|些)?'
. '|别这么(?:挤|拥挤|塑料|假|僵硬|空|暗|亮)'
. '|更有(?:电影|故事|层次|空间|氛围|质感|生命)感'
. ')[了啊吧呀呢,,。!?!?\s]*$/u',
$content
) === 1;
if ($shortVisualRevision) {
return true;
}
return preg_match('/' . $editAction . '[^,。!?!?\n]{0,16}' . $visualTarget . '/u', $content) === 1
|| preg_match('/' . $visualTarget . '[^,。!?!?\n]{0,16}' . $editAction . '/u', $content) === 1
|| preg_match('/(?:不够|太|需要更|想要更)[^,。!?!?\n]{0,16}' . $qualityTarget . '/u', $content) === 1
|| preg_match('/' . $visualTarget . '[^,。!?!?\n]{0,12}(?:再|更)[^,。!?!?\n]{0,8}(?:一点|点|一些|' . $qualityTarget . ')/u', $content) === 1
|| preg_match('/^(?:请|麻烦)?(?:再|更|稍微)' . $qualityTarget . '(?:一点|点|一些|些)?[了啊吧呀呢,,。!?!?\s]*$/u', $content) === 1;
}
public static function referencesPriorImagePlan(string $content): bool
{
$content = mb_strtolower(trim($content));
if ($content === '') {
return false;
}
return preg_match('/(?:按照|按|根据|采用|选择|就用|使用|照着)[^,。!?!?\n]{0,20}(?:方案|选项|建议|上面|前面|刚才|你说的|这个|那个)/u', $content) === 1
|| preg_match('/(?:方案|选项)\s*(?:第)?[一二三四五六七八九十\d]+[^,。!?!?\n]{0,12}(?:生成|制作|做|画|执行|来一张)/u', $content) === 1
|| preg_match('/^\s*(?:就用|选择|采用)?\s*(?:方案|选项)\s*(?:第)?[一二三四五六七八九十\d]+\s*$/u', $content) === 1
|| preg_match('/(?:就这样|按这个|照这个|用这个|照你说的)[^,。!?!?\n]{0,12}(?:生成|制作|做|画|来|执行)/u', $content) === 1;
}
/**
* A short confirmation only means "generate it" when the immediately preceding
* assistant turn actually offered a concrete image plan. This keeps replies such
* as "对" from inheriting an unrelated image much earlier in the conversation.
*/
public static function confirmsContextualImagePlan(string $content, array $history = []): bool
{
$content = mb_strtolower(trim($content));
$simpleConfirmation = preg_match(
'/^(?:对|好|好的|可以|行|就这样|按这个|照这个|用这个|开始吧|生成吧|做吧|画吧)[了啊吧呀呢,,。!?!?\s]*$/u',
$content
) === 1;
$selectionConfirmation = mb_strlen($content) <= 36
&& !preg_match('/(?:不行|不要|别|先不|为什么|怎么|哪一个|哪个好|吗|么|\?|)/u', $content)
&& preg_match('/(?:第?[一二三四五六七八九十\d]+(?:个|套|种|版|号|方案|方向)?|方案|方向|选项|这个|那个|它|你说的)/u', $content)
&& preg_match('/(?:挺好|不错|可以|就|选|用|照|按|采用|确定|来|做|生成)/u', $content);
$naturalConfirmation = preg_match(
'/^(?:那?就)?(?:按|照|用|采用)?(?:你说的|这个|那个|它|这样|这么|这些建议|刚才的建议)'
. '(?:来|改|调整|优化|做|生成|执行)?[了啊吧呀呢,,。!?!?\s]*$/u',
$content
) === 1;
$bareSelection = preg_match(
'/^(?:(?:第)?[一二三四五六七八九十\d]+(?:个|套|种|版|号|方案|方向)|最后(?:一个|一套|一种|一版|那个))'
. '[了啊吧呀呢,,。!?!?\s]*$/u',
$content
) === 1;
$contextualExecution = preg_match(
'/^(?:听你的|你推荐的那个|开始干吧|行[,,]?照办|可以[,,]?动手|嗯?[,]?就这么弄|'
. '好[,,]?修一下|执行吧|就这么办)[了啊吧呀呢,,。!?!?\s]*$/u',
$content
) === 1;
if (!$simpleConfirmation
&& !$selectionConfirmation
&& !$naturalConfirmation
&& !$bareSelection
&& !$contextualExecution) {
return false;
}
$history = array_slice($history, -12);
for ($index = count($history) - 1; $index >= 0; $index--) {
$message = $history[$index];
$messageContent = trim((string) ($message['content'] ?? ''));
if (($message['role'] ?? '') === 'user' && mb_strtolower($messageContent) === $content) {
continue;
}
if (($message['role'] ?? '') !== 'assistant' || $messageContent === '') {
continue;
}
$attachments = $message['attachments'] ?? [];
if (is_string($attachments)) {
$attachments = json_decode($attachments, true) ?: [];
}
foreach ((array) $attachments as $attachment) {
if (is_array($attachment) && ($attachment['type'] ?? '') === 'image') {
return false;
}
}
$hasVisualPlan = preg_match(
'/(?:图片|图像|插画|海报|封面|头像|画面|视觉|构图|风格|场景|生图|出图|生成|制作|绘制|方案|方向)/u',
$messageContent
) === 1;
$offersExecution = preg_match(
'/(?:选择|确认|采用|生成|制作|绘制|开始|要哪|哪个|哪一个|告诉我|可以吗|怎么样)/u',
$messageContent
) === 1;
return $hasVisualPlan && $offersExecution;
}
return false;
}
/**
* Conservative Agent router: only creation intent triggers ComfyUI; image analysis and prompt-writing stay on the LLM.
*/
public static function requestsImageGeneration(string $content): bool
{
$content = mb_strtolower(trim($content));
if ($content === '') {
return false;
}
$imageTarget = '(?:图片|图像|插画|海报|封面|头像|壁纸|配图|宣传图|广告图|商品图|产品图|场景图|人物图|背景图|电商图|主图|详情图|效果图|概念图|示意图|流程图|架构图|思维导图|图表|照片|视觉稿|视觉图|logo|标志|图标|banner|image|picture|illustration|poster|cover|wallpaper|photo|artwork|icon)';
$drawAction = '(?<![插漫油图绘字壁年版国动彩])画(?!面|质|风|布|廊|家|作|册|笔|纸|框|室|展)';
$creationAction = '(?:生成|创建|生图|出图|' . $drawAction . '|绘制|创作|制作|设计|渲染|generate|create|draw|paint|render|design|make)';
$directImageAction = '(?:生图|出图|绘图|作图|制图|' . $drawAction . '|绘制|paint\b|draw\s+(?!a\s+conclusion\b|conclusions?\b))';
$directRequest = '(?:(?:^|给我|帮我|请|来)(?:直接|马上|立即|现在|重新|再)?'
. $directImageAction . '|(?:把|将)[^,。!?!?\n]{1,12}(?:' . $drawAction . '|绘制|绘图|重画))';
// Pronoun-only “来张它的” needs conversation context and must not discard history.
if (preg_match('/^(?:也)?来(?:一)?张(?:他|她|它|他们|她们|它们|这个|那个|刚才那个)(?:的)?[了啊吧呀呢,,。!?!?\s]*$/u', $content)) {
return false;
}
$explicitDeferral = '/(?:别急着|先别|暂时(?:先)?不|暂不|目前(?:先)?不|先不要)[^。!?!?\n]{0,10}'
. '(?:生成|制作|创作|绘制|生图|出图)|先[^。!?!?\n]{0,20}'
. '(?:讨论|分析|规划|构思|方案|方向|建议)[^。!?!?\n]{0,24}'
. '(?:确认后|之后|以后|再)(?:生成|制作|创作|绘制|生图|出图)/u';
if (preg_match($explicitDeferral, $content)) {
return false;
}
$capabilityQuestion = '/(?:你|模型|agent|ai|助手)?[^,。!?!?\n]{0,5}'
. '(?:会不会|能不能|能否|是否(?:可以|能够|支持)|会|能|可以|支持)'
. '[^,。!?!?\n]{0,16}(?:' . $directImageAction . '|'
. $creationAction . '[^,。!?!?\n]{0,10}' . $imageTarget . '|'
. $imageTarget . '[^,。!?!?\n]{0,10}' . $creationAction . ')'
. '[^,。!?!?\n]{0,4}(?:吗|么|不||\?)?$/iu';
$concreteBrief = '/(?:一|两|二|三|几|多)?(?:张|幅|个|只|位|套|枚|款|版)'
. '|(?:主体|场景|背景|风格|构图|镜头|光线|颜色|色彩|姿势|动作|材质)[:为是]?/u';
$englishCapabilityQuestion = '/\b(?:(?:can|could)\s+you|do\s+you\s+support|are\s+you\s+able\s+to)\s+'
. '(?:generate|create|draw|paint|make|image\s+generation)[^.!?\n]{0,16}'
. '(?:images?|pictures?|illustrations?|artwork)?\s*\??$/iu';
$englishConcreteBrief = '/\b(?:of|showing|featuring|depicting|with|in\s+the\s+style\s+of)\b/iu';
$isCapabilityOnly = static function (string $text) use (
$capabilityQuestion,
$concreteBrief,
$englishCapabilityQuestion,
$englishConcreteBrief
): bool {
return (preg_match($capabilityQuestion, $text) && !preg_match($concreteBrief, $text))
|| (preg_match($englishCapabilityQuestion, $text) && !preg_match($englishConcreteBrief, $text));
};
if ($isCapabilityOnly($content)) {
return false;
}
if (preg_match(
'/(?:解释|介绍|说明|讲讲)[^,。!?!?\n]{0,16}(?:图片生成|图像生成|生图)'
. '[^,。!?!?\n]{0,12}(?:原理|机制|技术|能力|流程)/u',
$content
)) {
return false;
}
$negatedRequest = '/(?:不要|不用|无需|别|禁止|避免|不需要|do\s+not|don[\']t)[^,。!?!?\n]{0,8}'
. '(?:' . $directImageAction . '|' . $creationAction . '[^,。!?!?\n]{0,12}' . $imageTarget . ')/iu';
$metaRequest = '/(?:'
. '(?:如何|怎么|怎样|教程|方法|步骤|是否支持|支不支持|会不会)[^,。!?!?\n]{0,20}(?:'
. $directImageAction . '|' . $creationAction . '[^,。!?!?\n]{0,12}' . $imageTarget . ')'
. '|(?:告诉|说明|介绍|解释|列出|给出|讲讲|想知道)[^,。!?!?\n]{0,10}'
. '(?:' . $directImageAction . '|' . $creationAction . '[^,。!?!?\n]{0,12}' . $imageTarget . ')'
. '[^,。!?!?\n]{0,10}(?:步骤|教程|方法|流程)'
. '|(?:' . $directImageAction . '|' . $creationAction . '[^,。!?!?\n]{0,12}' . $imageTarget . ')'
. '[^,。!?!?\n]{0,8}(?:步骤|教程|方法|流程)(?:是什么|有哪些|怎么|如何|呢|吗||\?)?'
. '|(?:解释|说明|介绍|讨论|询问|查看|输出|给出)[^,。!?!?\n]{0,16}'
. '(?:生图|出图|生成图片|图片生成)[^,。!?!?\n]{0,14}(?:动作|协议|格式|json|工具|接口|示例)'
. '|(?:生图|出图|生成图片|图片生成)[^,。!?!?\n]{0,12}'
. '(?:是什么|怎么用|动作|协议|格式|json|模型|工具|接口|能力)'
. '|(?:写|撰写|优化|润色|翻译|改写|分析|解释|提供|给出)[^,。!?!?\n]{0,16}'
. '(?:生图|生成图片|图片生成)[^,。!?!?\n]{0,10}(?:提示词|prompt)'
. ')/iu';
$deferredRequest = '/(?:只|先|暂时|目前)(?:讨论|分析|规划|构思|给建议|写提示词|不生成|不要生成|先不生成|暂不生成|不执行|不要执行)/u';
$visualScene = '(?:头像|壁纸|海报|封面|主图|夜景|日落|日出|风景|城市|书房|客厅|卧室|花园|海边|沙漠|森林|雪山|草原|天空|极光|人物|人像|女孩|男孩|动物|猫|狗|龙)';
$colloquialVisualRequest = '/^(?:请|麻烦)?(?:给|帮)?(?:我)?'
. '(?:整|来|出|做)(?:一|两|三)?(?:个|张|幅|套)?'
. '[^,。!?!?\n]{0,18}' . $visualScene . '(?:看看|瞧瞧)?[了啊吧呀呢,,。!?!?\s]*$/u';
$patterns = [
'/' . $creationAction . '[^,。!?!?\n]{0,18}' . $imageTarget . '/iu',
'/' . $imageTarget . '[^,。!?!?\n]{0,12}' . $creationAction . '/iu',
'/' . $directRequest . '/iu',
$colloquialVisualRequest,
'/(?:给我|帮我|来|生成|创建|创作|制作|做|出)[^,。!?!?\n]{0,8}(?:一|两|三|几|多)?张(?!表(?:格)?)/u',
'/^\s*\/(?:image|imagine|draw)\b/iu',
];
$clauses = preg_split('/(?:但是|改为|改成|而是|然后|随后|并且|但|并)|[,。!?!?;\n]+/u', $content) ?: [$content];
$decision = false;
foreach ($clauses as $clause) {
if ($clause === '') {
continue;
}
if ($isCapabilityOnly($clause)
|| preg_match($negatedRequest, $clause)
|| preg_match($metaRequest, $clause)
|| preg_match($deferredRequest, $clause)) {
$decision = false;
continue;
}
foreach ($patterns as $pattern) {
if (preg_match($pattern, $clause)) {
$decision = true;
break;
}
}
}
return $decision;
}
}
@@ -0,0 +1,10 @@
<?php
namespace app\service;
/**
* HTTP 请求不宜再阻塞时抛出:任务仍在 ComfyUI 中,消息保持 pending,稍后可恢复。
*/
class ComfyJobDeferredException extends \RuntimeException
{
}
File diff suppressed because it is too large Load Diff
+172
View File
@@ -0,0 +1,172 @@
<?php
namespace app\service;
use app\model\Department;
use app\model\User;
class DepartmentService
{
/**
* 获取部门及其所有下级部门 ID(含自身)。
*
* @return int[]
*/
public static function descendantIds(int $departmentId): array
{
$all = Department::field('id,parent_id')->select()->toArray();
$childrenMap = [];
foreach ($all as $row) {
$parentId = (int) ($row['parent_id'] ?? 0);
$childrenMap[$parentId][] = (int) $row['id'];
}
$result = [];
$stack = [$departmentId];
while ($stack) {
$current = array_pop($stack);
if (in_array($current, $result, true)) {
continue;
}
$result[] = $current;
foreach ($childrenMap[$current] ?? [] as $childId) {
$stack[] = $childId;
}
}
return $result;
}
/**
* 构建带层级缩进的部门树(扁平列表,供下拉选择)。
*/
public static function treeOptions(): array
{
$rows = Department::order('sort_order')->order('id')->select()->toArray();
$childrenMap = [];
foreach ($rows as $row) {
$parentId = (int) ($row['parent_id'] ?? 0);
$childrenMap[$parentId][] = $row;
}
$options = [];
self::walkTree($childrenMap, 0, 0, $options);
return $options;
}
private static function walkTree(array $childrenMap, int $parentId, int $depth, array &$options): void
{
foreach ($childrenMap[$parentId] ?? [] as $row) {
$prefix = $depth > 0 ? str_repeat(' ', $depth) . '└ ' : '';
$options[] = [
'id' => (int) $row['id'],
'name' => $row['name'],
'parent_id' => $row['parent_id'] ? (int) $row['parent_id'] : null,
'label' => $prefix . $row['name'],
'depth' => $depth,
];
self::walkTree($childrenMap, (int) $row['id'], $depth + 1, $options);
}
}
/**
* 获取某部门及下级部门内的所有用户 ID。
*
* @return int[]
*/
public static function userIdsInDepartments(array $departmentIds): array
{
if (empty($departmentIds)) {
return [];
}
return User::whereIn('department_id', $departmentIds)->column('id');
}
}
+406
View File
@@ -0,0 +1,406 @@
<?php
namespace app\service;
use app\model\AiModel;
use think\exception\HttpResponseException;
/**
* Dify 应用 API 接入服务。
*
* Dify 使用的是自己的一套接口协议,跟 OpenAI 的 /chat/completions 完全不同:
* - 发送消息:POST {api_base_url}/chat-messages
* - 上传文件:POST {api_base_url}/files/upload
* - 会话上下文由 Dify 自己维护(通过 conversation_id 串联),不需要像 OpenAI 那样
* 每次把完整的历史消息数组传过去,只需要传当前这一句 query + 上一次返回的 conversation_id。
*
* 参考文档:https://docs.dify.ai/api-reference
*/
class DifyService
{
public static function chat(AiModel $model, string $query, array $files, ?string $conversationId, string $userId): array
{
$url = rtrim($model->api_base_url, '/') . '/chat-messages';
$payload = self::buildPayload($query, $files, $conversationId, $userId, false);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $model->api_key,
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($response === false) {
throw new HttpResponseException(json([
'code' => 1,
'message' => 'Dify 请求失败: ' . ($curlError ?: '网络错误'),
'data' => null,
], 502));
}
if ($httpCode !== 200) {
$detail = self::parseErrorBody($response) ?: ('HTTP ' . $httpCode);
throw new HttpResponseException(json([
'code' => 1,
'message' => self::humanizeError('Dify 请求失败: ' . $detail . self::urlHint($httpCode)),
'data' => null,
], 502));
}
$data = json_decode($response, true);
if (!$data) {
throw new HttpResponseException(json([
'code' => 1,
'message' => 'Dify 响应解析失败',
'data' => null,
], 502));
}
return [
'answer' => $data['answer'] ?? '',
'conversation_id' => $data['conversation_id'] ?? null,
'tokens' => $data['metadata']['usage']['total_tokens'] ?? 0,
];
}
public static function streamChat(
AiModel $model,
string $query,
array $files,
?string $conversationId,
string $userId,
callable $onChunk,
callable $onDone,
?callable $onError = null
): void {
$url = rtrim($model->api_base_url, '/') . '/chat-messages';
$payload = self::buildPayload($query, $files, $conversationId, $userId, true);
$errorBody = '';
$httpCode = 0;
$finalConversationId = $conversationId;
$finalTokens = 0;
$eventError = null;
$streamBuffer = '';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $model->api_key,
],
CURLOPT_RETURNTRANSFER => false,
CURLOPT_HEADERFUNCTION => function ($ch, $header) use (&$httpCode) {
if (preg_match('/^HTTP\/\d+\.\d+\s+(\d+)/', $header, $m)) {
$httpCode = (int) $m[1];
}
return strlen($header);
},
CURLOPT_WRITEFUNCTION => function ($ch, $data) use (
&$errorBody,
&$httpCode,
$onChunk,
&$finalConversationId,
&$finalTokens,
&$eventError,
&$streamBuffer
) {
if ($httpCode >= 400) {
$errorBody .= $data;
return strlen($data);
}
// cURL may split one SSE data line across arbitrary network chunks.
$streamBuffer .= $data;
$lines = preg_split('/\r?\n/', $streamBuffer) ?: [];
$streamBuffer = (string) (array_pop($lines) ?? '');
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || !str_starts_with($line, 'data:')) {
continue;
}
$json = json_decode(substr($line, 5), true);
if (!$json) {
continue;
}
$event = $json['event'] ?? '';
if (!empty($json['conversation_id'])) {
$finalConversationId = $json['conversation_id'];
}
if ($event === 'error') {
$eventError = $json['message'] ?? 'Dify 返回错误';
continue;
}
if (in_array($event, ['message', 'agent_message'], true)) {
$delta = $json['answer'] ?? '';
if ($delta !== '') {
$onChunk($delta);
}
}
if ($event === 'message_end') {
$finalTokens = $json['metadata']['usage']['total_tokens'] ?? $finalTokens;
}
}
return strlen($data);
},
CURLOPT_TIMEOUT => 120,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => false,
]);
try {
$result = curl_exec($ch);
if ($result === false) {
$message = 'Dify 请求失败: ' . curl_error($ch);
$onError ? $onError($message) : null;
return;
}
if ($httpCode >= 400) {
$detail = self::parseErrorBody($errorBody) ?: ('HTTP ' . $httpCode);
$onError ? $onError(self::humanizeError('Dify 请求失败: ' . $detail . self::urlHint($httpCode))) : null;
return;
}
if ($eventError) {
$onError ? $onError(self::humanizeError('Dify 请求失败: ' . $eventError)) : null;
return;
}
} finally {
curl_close($ch);
}
$onDone($finalConversationId, $finalTokens);
}
/**
* 把本地已上传的文件转发上传到 Dify(Dify 需要自己的 upload_file_id 才能在
* chat-messages 里引用文件),失败时返回 null,调用方应做优雅降级处理。
*/
public static function uploadFile(AiModel $model, string $path, string $mime, string $originalName, string $userId): ?string
{
[$fileId] = self::uploadFileWithDetail($model, $path, $mime, $originalName, $userId);
return $fileId;
}
/**
* 上传文件到 Dify,失败时返回 [null, errorMessage]
*/
public static function uploadFileWithDetail(AiModel $model, string $path, string $mime, string $originalName, string $userId): array
{
if (!is_file($path)) {
return [null, '本地文件不存在'];
}
$url = rtrim($model->api_base_url, '/') . '/files/upload';
$safeName = self::safeUploadFilename($originalName, $path, $mime);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => [
'file' => curl_file_create($path, $mime, $safeName),
'user' => $userId,
],
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $model->api_key,
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($response === false) {
return [null, '网络错误: ' . ($curlError ?: '无法连接 Dify')];
}
if ($httpCode !== 200 && $httpCode !== 201) {
$detail = self::parseErrorBody($response) ?: ('HTTP ' . $httpCode);
return [null, $detail];
}
$data = json_decode($response, true);
$fileId = $data['id'] ?? ($data['data']['id'] ?? null);
return $fileId ? [$fileId, null] : [null, 'Dify 未返回 file_id'];
}
private static function safeUploadFilename(string $originalName, string $path, string $mime): string
{
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION) ?: pathinfo($path, PATHINFO_EXTENSION));
if ($ext === '') {
$ext = match (true) {
str_starts_with($mime, 'image/png') => 'png',
str_starts_with($mime, 'image/gif') => 'gif',
str_starts_with($mime, 'image/webp') => 'webp',
default => 'jpg',
};
}
return 'upload_' . uniqid('', true) . '.' . $ext;
}
/**
* 测试 Dify 应用连接是否正常
*/
public static function testConnection(array $config): array
{
$apiBaseUrl = rtrim($config['api_base_url'] ?? '', '/');
$apiKey = $config['api_key'] ?? '';
if (!$apiBaseUrl) {
throw new \InvalidArgumentException('请填写 API 地址(Dify 应用的 API Base URL,如 https://api.dify.ai/v1');
}
if (!$apiKey) {
throw new \InvalidArgumentException('请填写 API Key(在 Dify 应用的"访问 API"页面获取)');
}
$url = $apiBaseUrl . '/chat-messages';
$payload = [
'inputs' => new \stdClass(),
'query' => '请只回复:测试成功',
'response_mode' => 'blocking',
'conversation_id' => '',
'user' => 'connection-test',
];
$start = microtime(true);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
$latencyMs = (int) round((microtime(true) - $start) * 1000);
if ($response === false) {
throw new \RuntimeException('连接失败: ' . ($curlError ?: '网络不可达'));
}
if ($httpCode !== 200) {
$detail = self::parseErrorBody($response) ?: ('HTTP ' . $httpCode);
throw new \RuntimeException('API 返回错误: ' . $detail . self::urlHint($httpCode));
}
$data = json_decode($response, true);
if (!$data) {
throw new \RuntimeException('响应解析失败,请确认接口地址正确');
}
$reply = $data['answer'] ?? '';
if ($reply === '') {
throw new \RuntimeException('接口连接成功,但未返回有效内容');
}
return [
'success' => true,
'latency_ms' => $latencyMs,
'reply' => $reply,
'model' => 'dify',
'tokens' => $data['metadata']['usage']['total_tokens'] ?? null,
];
}
private static function buildPayload(string $query, array $files, ?string $conversationId, string $userId, bool $streaming): array
{
$payload = [
// 注意:必须用 stdClass 而不是 []PHP 的空数组 json_encode 后是 "[]"
// 但 DifyPydantic)要求 inputs 必须是字典 "{}",否则会报
// "Input should be a valid dictionary" 的校验错误
'inputs' => new \stdClass(),
'query' => $query,
'response_mode' => $streaming ? 'streaming' : 'blocking',
'conversation_id' => $conversationId ?: '',
'user' => $userId,
];
if ($files) {
$payload['files'] = $files;
}
return $payload;
}
private static function urlHint(int $httpCode): string
{
if ($httpCode === 404) {
return '(请确认 API 地址填写的是 Dify 的 API 根路径,如 https://api.dify.ai/v1 或自部署的 http://your-host/v1Dify 使用 /chat-messages 接口,不是 OpenAI 的 /chat/completions';
}
if ($httpCode === 401) {
return '(请确认 API Key 是在 Dify 应用"访问 API"页面获取的密钥,而不是账号登录密码或 OpenAI Key';
}
return '';
}
private static function parseErrorBody(?string $body): ?string
{
if (!$body) {
return null;
}
$json = json_decode($body, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $json['message'] ?? null;
}
return trim($body) ?: null;
}
/**
* 将 Dify 插件/模型层的英文错误转为可操作的中文提示
*/
public static function humanizeError(string $message): string
{
if (str_contains($message, "Unsupported chat content part type: 'file'")
|| str_contains($message, 'Unsupported chat content part type')) {
return 'Dify 模型层仍不接受 file 类型。请确认 Dify 应用已开启文档上传,且 files.type 使用 document(不是 file)。'
. ' 原始错误:' . mb_substr($message, 0, 180);
}
if (str_contains($message, 'PluginInvokeError')) {
return 'Dify 插件调用失败,请检查 Dify 应用内模型供应商配置是否与图片输入兼容。'
. ' 详情:' . mb_substr($message, 0, 300);
}
return $message;
}
}
+207
View File
@@ -0,0 +1,207 @@
<?php
namespace app\service;
/**
* 从常见文档格式提取纯文本,供 Dify 等不支持 file 附件的接口使用。
*/
class DocumentTextService
{
private const MAX_CHARS = 12000;
/** @var string|null 最近一次 extract 失败原因(供上层展示) */
private static ?string $lastFailure = null;
public static function extract(string $path, string $mime = '', string $filename = ''): ?string
{
self::$lastFailure = null;
if (!is_file($path) || !is_readable($path)) {
self::$lastFailure = 'file_unreadable';
return null;
}
$ext = strtolower(pathinfo($filename ?: $path, PATHINFO_EXTENSION));
$mime = strtolower($mime);
$text = match (true) {
in_array($ext, ['txt', 'md'], true) || str_starts_with($mime, 'text/') => self::readPlainText($path),
$ext === 'docx' || str_contains($mime, 'wordprocessingml') => self::extractDocx($path),
$ext === 'pdf' || str_contains($mime, 'pdf') => self::extractPdf($path),
default => null,
};
if ($text === null) {
return null;
}
$text = self::normalize($text);
if ($text === '') {
if (self::$lastFailure === null) {
self::$lastFailure = 'empty_content';
}
return null;
}
return mb_substr($text, 0, self::MAX_CHARS);
}
public static function unsupportedReason(string $filename, string $mime = ''): string
{
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if (self::$lastFailure === 'exec_disabled') {
return 'PHP 禁用了 exec 函数,无法调用 pdftotext。请在 php.ini 的 disable_functions 中移除 exec';
}
if (self::$lastFailure === 'pdftotext_missing') {
return '未找到 pdftotext 命令。请在**运行 PHP 的服务器**(不是 Dify 容器)执行:apt install poppler-utils';
}
if (self::$lastFailure === 'pdftotext_failed') {
return 'pdftotext 执行失败,请检查 PDF 是否损坏或 uploads 目录是否可读';
}
if (self::$lastFailure === 'empty_content' && ($ext === 'pdf' || str_contains($mime, 'pdf'))) {
return 'PDF 未提取到文字,可能是扫描版图片 PDF,请改用可复制文字的 PDF 或粘贴文字';
}
if ($ext === 'doc' || str_contains($mime, 'msword')) {
return '旧版 .doc 暂不支持自动解析,请另存为 .docx 或复制文字发送';
}
if ($ext === 'pdf' || str_contains($mime, 'pdf')) {
return 'PDF 文字提取失败,请在运行 PHP 的服务器安装 poppler-utilspdftotext';
}
if (!class_exists(\ZipArchive::class) && ($ext === 'docx' || str_contains($mime, 'wordprocessingml'))) {
return 'PHP 未启用 zip 扩展,无法解析 .docx,请安装 php-zip';
}
return '未能提取文档文字,请改用 .txt / .docx 或粘贴文字内容';
}
private static function readPlainText(string $path): ?string
{
$content = @file_get_contents($path);
if ($content === false) {
return null;
}
if (!mb_check_encoding($content, 'UTF-8')) {
$content = mb_convert_encoding($content, 'UTF-8', 'GB18030,UTF-8,ASCII');
}
return $content;
}
private static function extractDocx(string $path): ?string
{
if (!class_exists(\ZipArchive::class)) {
return null;
}
$zip = new \ZipArchive();
if ($zip->open($path) !== true) {
return null;
}
$xml = $zip->getFromName('word/document.xml');
$zip->close();
if (!$xml) {
return null;
}
$xml = preg_replace('/<w:tab[^>]*\/>/', "\t", $xml);
$xml = preg_replace('/<\/w:p>/', "\n", $xml);
$xml = preg_replace('/<\/w:tr>/', "\n", $xml);
$text = strip_tags($xml);
return html_entity_decode($text, ENT_QUOTES | ENT_XML1, 'UTF-8');
}
private static function extractPdf(string $path): ?string
{
if (!function_exists('exec')) {
self::$lastFailure = 'exec_disabled';
return null;
}
$binary = self::resolvePdftotextBinary();
if ($binary === null) {
self::$lastFailure = 'pdftotext_missing';
return null;
}
$out = tempnam(sys_get_temp_dir(), 'pdftxt_');
if (!$out) {
self::$lastFailure = 'pdftotext_failed';
return null;
}
$cmd = escapeshellarg($binary) . ' -enc UTF-8 -layout '
. escapeshellarg($path) . ' ' . escapeshellarg($out) . ' 2>&1';
exec($cmd, $_, $code);
$text = ($code === 0 && is_file($out)) ? @file_get_contents($out) : false;
@unlink($out);
if ($text === false) {
self::$lastFailure = 'pdftotext_failed';
return null;
}
return $text;
}
/**
* PHP-FPM 进程的 PATH 常不含 /usr/bin,需显式探测可执行文件路径。
*/
private static function resolvePdftotextBinary(): ?string
{
$candidates = [
'/usr/bin/pdftotext',
'/usr/local/bin/pdftotext',
'pdftotext',
];
foreach ($candidates as $bin) {
if (str_starts_with($bin, '/')) {
if (is_executable($bin)) {
return $bin;
}
continue;
}
if (self::commandExists($bin)) {
return $bin;
}
}
return null;
}
private static function commandExists(string $command): bool
{
if (!function_exists('exec')) {
return false;
}
$check = stripos(PHP_OS, 'WIN') === 0 ? 'where' : 'command -v';
exec($check . ' ' . escapeshellarg($command) . ' 2>/dev/null', $output, $code);
return $code === 0 && !empty($output);
}
private static function normalize(string $text): string
{
$text = str_replace(["\r\n", "\r"], "\n", $text);
$text = preg_replace("/[ \t]+\n/", "\n", $text);
$text = preg_replace("/\n{3,}/", "\n\n", $text);
return trim($text);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace app\service;
class JwtService
{
public static function generateToken(array $payload): string
{
$secret = config('jwt.secret');
$expire = config('jwt.expire');
$header = self::base64UrlEncode(json_encode(['typ' => 'JWT', 'alg' => 'HS256']));
$payload['exp'] = time() + $expire;
$payload['iat'] = time();
$body = self::base64UrlEncode(json_encode($payload));
$signature = self::base64UrlEncode(hash_hmac('sha256', "{$header}.{$body}", $secret, true));
return "{$header}.{$body}.{$signature}";
}
public static function verifyToken(?string $token): ?array
{
if (!$token) {
return null;
}
if (str_starts_with($token, 'Bearer ')) {
$token = substr($token, 7);
}
$parts = explode('.', $token);
if (count($parts) !== 3) {
return null;
}
[$header, $body, $signature] = $parts;
$secret = config('jwt.secret');
$expected = self::base64UrlEncode(hash_hmac('sha256', "{$header}.{$body}", $secret, true));
if (!hash_equals($expected, $signature)) {
return null;
}
$payload = json_decode(self::base64UrlDecode($body), true);
if (!$payload || ($payload['exp'] ?? 0) < time()) {
return null;
}
return $payload;
}
private static function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
private static function base64UrlDecode(string $data): string
{
return base64_decode(strtr($data, '-_', '+/'));
}
}
+393
View File
@@ -0,0 +1,393 @@
<?php
namespace app\service;
use app\model\AiModel;
use think\exception\HttpResponseException;
class OpenAIService
{
public static function getModel(?int $modelId = null): AiModel
{
if ($modelId) {
$model = AiModel::where('id', $modelId)->where('enabled', 1)->find();
} else {
$model = AiModel::where('is_default', 1)->where('enabled', 1)->find();
}
if (!$model) {
$model = AiModel::where('enabled', 1)->order('sort_order')->find();
}
if (!$model) {
throw new HttpResponseException(json([
'code' => 1,
'message' => '未配置可用的 AI 模型',
'data' => null,
], 500));
}
return $model;
}
public static function getLanguageModel(?int $preferredModelId = null): AiModel
{
$model = null;
if ($preferredModelId) {
$model = AiModel::where('id', $preferredModelId)
->where('enabled', 1)
->where('provider', '<>', 'comfy')
->find();
}
if (!$model) {
$model = AiModel::where('enabled', 1)
->where('provider', '<>', 'comfy')
->order('is_default', 'desc')
->order('sort_order')
->find();
}
if (!$model) {
self::throwUnavailableModel('未配置可用的语言模型,Agent 暂时无法处理文本任务');
}
return $model;
}
public static function getImageModel(?int $preferredModelId = null): AiModel
{
$model = null;
if ($preferredModelId) {
$model = AiModel::where('id', $preferredModelId)
->where('enabled', 1)
->where('provider', 'comfy')
->find();
}
if (!$model) {
$model = AiModel::where('enabled', 1)
->where('provider', 'comfy')
->order('is_default', 'desc')
->order('sort_order')
->find();
}
if (!$model) {
self::throwUnavailableModel('未配置可用的图片生成模型,Agent 暂时无法生成图片');
}
return $model;
}
private static function throwUnavailableModel(string $message): void
{
throw new HttpResponseException(json([
'code' => 1,
'message' => $message,
'data' => null,
], 503));
}
/**
* frequency_penalty / presence_penalty 是 OpenAI 协议标准参数,vLLM/SGLang 等
* OpenAI 兼容服务通常都支持。部分自部署模型(尤其是 OCR/文档解析类模型)在纯文本
* 对话场景下容易陷入重复输出循环,通过这两个参数可以有效抑制。仅在管理员配置了
* 非零值时才带上,避免影响已经正常工作的模型。
*/
private static function penaltyParams(AiModel $model): array
{
$params = [];
$frequencyPenalty = (float) ($model->frequency_penalty ?? 0);
$presencePenalty = (float) ($model->presence_penalty ?? 0);
if ($frequencyPenalty !== 0.0) {
$params['frequency_penalty'] = $frequencyPenalty;
}
if ($presencePenalty !== 0.0) {
$params['presence_penalty'] = $presencePenalty;
}
return $params;
}
public static function streamChat(AiModel $model, array $messages, callable $onChunk, ?callable $onError = null): void
{
$url = rtrim($model->api_base_url, '/') . '/chat/completions';
$payload = array_merge([
'model' => $model->model_id,
'messages' => $messages,
'stream' => true,
'max_tokens' => (int) $model->max_tokens,
'temperature' => (float) $model->temperature,
], self::penaltyParams($model));
$errorBody = '';
$httpCode = 0;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $model->api_key,
],
CURLOPT_RETURNTRANSFER => false,
CURLOPT_HEADERFUNCTION => function ($ch, $header) use (&$httpCode) {
if (preg_match('/^HTTP\/\d+\.\d+\s+(\d+)/', $header, $m)) {
$httpCode = (int) $m[1];
}
return strlen($header);
},
CURLOPT_WRITEFUNCTION => function ($ch, $data) use (&$errorBody, &$httpCode, $onChunk) {
if ($httpCode >= 400) {
$errorBody .= $data;
return strlen($data);
}
$lines = explode("\n", $data);
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $line === 'data: [DONE]') {
continue;
}
if (str_starts_with($line, 'data: ')) {
$json = json_decode(substr($line, 6), true);
if ($json) {
if (!empty($json['error']['message'])) {
throw new \RuntimeException($json['error']['message']);
}
$onChunk($json);
}
}
}
return strlen($data);
},
CURLOPT_TIMEOUT => 120,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => false,
]);
try {
$result = curl_exec($ch);
if ($result === false) {
$message = 'AI 请求失败: ' . curl_error($ch);
if ($onError) {
$onError($message);
} else {
self::sseEvent('error', ['message' => $message]);
}
curl_close($ch);
return;
}
if ($httpCode >= 400) {
$detail = self::parseErrorBody($errorBody) ?: ('HTTP ' . $httpCode);
$message = 'AI 请求失败: ' . $detail;
if ($onError) {
$onError($message);
} else {
self::sseEvent('error', ['message' => $message]);
}
}
} catch (\Throwable $e) {
if ($onError) {
$onError($e->getMessage());
} else {
self::sseEvent('error', ['message' => $e->getMessage()]);
}
} finally {
curl_close($ch);
}
}
public static function chat(AiModel $model, array $messages): array
{
$url = rtrim($model->api_base_url, '/') . '/chat/completions';
$payload = array_merge([
'model' => $model->model_id,
'messages' => $messages,
'stream' => false,
'max_tokens' => (int) $model->max_tokens,
'temperature' => (float) $model->temperature,
], self::penaltyParams($model));
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $model->api_key,
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
$detail = self::parseErrorBody($response) ?: ('HTTP ' . $httpCode);
throw new HttpResponseException(json([
'code' => 1,
'message' => 'AI 请求失败: ' . $detail,
'data' => null,
], 502));
}
$data = json_decode($response, true);
if (!$data) {
throw new HttpResponseException(json([
'code' => 1,
'message' => 'AI 响应解析失败',
'data' => null,
], 502));
}
return $data;
}
/**
* 测试模型连接是否正常
* @return array{success: bool, latency_ms: int, reply: string, model: string}
*/
public static function testConnection(array $config): array
{
$apiBaseUrl = rtrim($config['api_base_url'] ?? '', '/');
$modelId = trim($config['model_id'] ?? '');
$apiKey = $config['api_key'] ?? '';
if (!$apiBaseUrl || !$modelId) {
throw new \InvalidArgumentException('请填写 API 地址和 Model ID');
}
$url = $apiBaseUrl . '/chat/completions';
$payload = [
'model' => $modelId,
'messages' => [
['role' => 'user', 'content' => '请只回复:测试成功'],
],
'stream' => false,
'max_tokens' => 32,
'temperature' => (float) ($config['temperature'] ?? 0.7),
];
$headers = ['Content-Type: application/json'];
if ($apiKey !== '') {
$headers[] = 'Authorization: Bearer ' . $apiKey;
}
$start = microtime(true);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
$latencyMs = (int) round((microtime(true) - $start) * 1000);
if ($response === false) {
throw new \RuntimeException('连接失败: ' . ($curlError ?: '网络不可达'));
}
if ($httpCode !== 200) {
$detail = self::parseErrorBody($response) ?: ('HTTP ' . $httpCode);
throw new \RuntimeException('API 返回错误: ' . $detail);
}
$data = json_decode($response, true);
if (!$data) {
throw new \RuntimeException('响应解析失败,请确认接口为 OpenAI 兼容格式');
}
$reply = self::extractMessageContent($data);
if ($reply === '') {
throw new \RuntimeException('接口连接成功,但未返回有效内容');
}
return [
'success' => true,
'latency_ms' => $latencyMs,
'reply' => $reply,
'model' => $modelId,
'tokens' => $data['usage']['total_tokens'] ?? null,
];
}
public static function extractStreamDelta(array $chunk): string
{
$choice = $chunk['choices'][0] ?? [];
$delta = $choice['delta'] ?? [];
$message = $choice['message'] ?? [];
$content = $delta['content']
?? $delta['reasoning_content']
?? $message['content']
?? '';
return is_string($content) ? $content : '';
}
public static function extractMessageContent(array $result): string
{
$choice = $result['choices'][0] ?? [];
$message = $choice['message'] ?? [];
$content = $message['content']
?? $message['reasoning_content']
?? $choice['text']
?? '';
return is_string($content) ? trim($content) : '';
}
public static function sseHeaders(): void
{
header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-cache, no-transform');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');
}
public static function sseEvent(string $event, mixed $data): void
{
// 用户关闭页面后仍继续后台等待,不再往已断开的连接写数据
if (connection_aborted()) {
return;
}
echo "event: {$event}\n";
echo 'data: ' . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n";
if (ob_get_level() > 0) {
@ob_flush();
}
@flush();
}
private static function parseErrorBody(?string $body): ?string
{
if (!$body) {
return null;
}
$json = json_decode($body, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $json['error']['message'] ?? $json['message'] ?? null;
}
return trim($body) ?: null;
}
}
+447
View File
@@ -0,0 +1,447 @@
<?php
namespace app\service;
use app\model\SysPermission;
use think\facade\Db;
/**
* 后台权限目录:目录(dir) / 菜单(menu) / 按钮(btn)
* 优先从 sys_permissions 表读取,表不存在时回退内置定义。
*/
class PermissionCatalog
{
public static function builtinTree(): array
{
return [
[
'code' => 'dir:overview',
'name' => '概览',
'type' => 'dir',
'children' => [
[
'code' => 'menu:dashboard',
'name' => '数据概览',
'type' => 'menu',
'path' => '/dashboard',
'icon' => '📊',
'children' => [],
],
],
],
[
'code' => 'dir:org',
'name' => '组织架构',
'type' => 'dir',
'children' => [
[
'code' => 'menu:users',
'name' => '用户管理',
'type' => 'menu',
'path' => '/users',
'icon' => '👥',
'children' => [
['code' => 'btn:user:create', 'name' => '新增用户', 'type' => 'btn'],
['code' => 'btn:user:edit', 'name' => '编辑用户', 'type' => 'btn'],
['code' => 'btn:user:reset_password', 'name' => '重置密码', 'type' => 'btn'],
['code' => 'btn:user:delete', 'name' => '删除用户', 'type' => 'btn'],
],
],
[
'code' => 'menu:departments',
'name' => '部门管理',
'type' => 'menu',
'path' => '/departments',
'icon' => '🏢',
'children' => [
['code' => 'btn:dept:create', 'name' => '新增部门', 'type' => 'btn'],
['code' => 'btn:dept:edit', 'name' => '编辑部门', 'type' => 'btn'],
['code' => 'btn:dept:delete', 'name' => '删除部门', 'type' => 'btn'],
],
],
[
'code' => 'menu:roles',
'name' => '角色管理',
'type' => 'menu',
'path' => '/roles',
'icon' => '🛡️',
'children' => [
['code' => 'btn:role:create', 'name' => '新增角色', 'type' => 'btn'],
['code' => 'btn:role:edit', 'name' => '编辑角色', 'type' => 'btn'],
['code' => 'btn:role:delete', 'name' => '删除角色', 'type' => 'btn'],
],
],
],
],
[
'code' => 'dir:business',
'name' => '业务数据',
'type' => 'dir',
'children' => [
[
'code' => 'menu:conversations',
'name' => '会话管理',
'type' => 'menu',
'path' => '/conversations',
'icon' => '💬',
'children' => [
['code' => 'btn:conv:view_all', 'name' => '查看全部会话', 'type' => 'btn'],
['code' => 'btn:conv:view_subordinate', 'name' => '查看下级部门会话', 'type' => 'btn'],
],
],
[
'code' => 'menu:memberships',
'name' => '会员等级',
'type' => 'menu',
'path' => '/memberships',
'icon' => '⭐',
'children' => [
['code' => 'btn:membership:create', 'name' => '新增会员等级', 'type' => 'btn'],
['code' => 'btn:membership:edit', 'name' => '编辑会员等级', 'type' => 'btn'],
['code' => 'btn:membership:delete', 'name' => '删除会员等级', 'type' => 'btn'],
],
],
],
],
[
'code' => 'dir:system',
'name' => '系统管理',
'type' => 'dir',
'children' => [
[
'code' => 'menu:models',
'name' => 'AI 模型',
'type' => 'menu',
'path' => '/models',
'icon' => '🤖',
'children' => [
['code' => 'btn:model:create', 'name' => '新增模型', 'type' => 'btn'],
['code' => 'btn:model:edit', 'name' => '编辑模型', 'type' => 'btn'],
['code' => 'btn:model:delete', 'name' => '删除模型', 'type' => 'btn'],
['code' => 'btn:model:test', 'name' => '测试连接', 'type' => 'btn'],
],
],
[
'code' => 'menu:permissions',
'name' => '权限管理',
'type' => 'menu',
'path' => '/permissions',
'icon' => '🔑',
'children' => [
['code' => 'btn:perm:create', 'name' => '新增权限', 'type' => 'btn'],
['code' => 'btn:perm:edit', 'name' => '编辑权限', 'type' => 'btn'],
['code' => 'btn:perm:delete', 'name' => '删除权限', 'type' => 'btn'],
],
],
[
'code' => 'menu:settings',
'name' => '系统设置',
'type' => 'menu',
'path' => '/settings',
'icon' => '🔧',
'children' => [
['code' => 'btn:settings:save', 'name' => '保存设置', 'type' => 'btn'],
],
],
],
],
];
}
public static function tree(): array
{
try {
if (!self::tableReady()) {
return self::builtinTree();
}
$rows = SysPermission::order('sort_order')->order('id')->select()->toArray();
if (!$rows) {
return self::builtinTree();
}
return self::buildTreeFromRows($rows);
} catch (\Throwable $e) {
return self::builtinTree();
}
}
public static function flatList(): array
{
try {
if (!self::tableReady()) {
return self::flattenBuiltin();
}
return SysPermission::order('sort_order')->order('id')->select()->toArray();
} catch (\Throwable $e) {
return self::flattenBuiltin();
}
}
private static function tableReady(): bool
{
static $ready = null;
if ($ready !== null) {
return $ready;
}
try {
$db = Db::getConfig('database') ?: env('DB_NAME', 'ai_chat');
$rows = Db::query(
'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? LIMIT 1',
[$db, 'sys_permissions']
);
$ready = !empty($rows);
} catch (\Throwable $e) {
$ready = false;
}
return $ready;
}
private static function buildTreeFromRows(array $rows): array
{
$byParent = [];
foreach ($rows as $row) {
$pid = $row['parent_id'] ? (int) $row['parent_id'] : 0;
$byParent[$pid][] = $row;
}
$mapNode = function (array $row) use (&$mapNode, $byParent) {
$children = [];
foreach ($byParent[(int) $row['id']] ?? [] as $child) {
$children[] = $mapNode($child);
}
return [
'id' => (int) $row['id'],
'code' => $row['code'],
'name' => $row['name'],
'type' => $row['type'],
'path' => $row['path'] ?? '',
'icon' => $row['icon'] ?? '',
'parent_id' => $row['parent_id'] ? (int) $row['parent_id'] : null,
'sort_order' => (int) ($row['sort_order'] ?? 0),
'is_system' => (int) ($row['is_system'] ?? 0),
'children' => $children,
];
};
$tree = [];
foreach ($byParent[0] ?? [] as $root) {
$tree[] = $mapNode($root);
}
return $tree;
}
private static function flattenBuiltin(): array
{
$list = [];
$walk = function (array $nodes, $parentId = null) use (&$walk, &$list) {
foreach ($nodes as $i => $node) {
$id = count($list) + 1;
$list[] = [
'id' => $id,
'type' => $node['type'],
'code' => $node['code'],
'name' => $node['name'],
'parent_id' => $parentId,
'path' => $node['path'] ?? null,
'icon' => $node['icon'] ?? null,
'sort_order' => $i,
'is_system' => 1,
];
if (!empty($node['children'])) {
$walk($node['children'], $id);
}
}
};
$walk(self::builtinTree());
return $list;
}
public static function emptyPermissions(): array
{
$perms = [
'can_access_admin' => false,
'dirs' => [],
'menus' => [],
'buttons' => [],
];
foreach (self::legacyKeys() as $key) {
$perms[$key] = false;
}
return $perms;
}
public static function fullPermissions(): array
{
$dirs = [];
$menus = [];
$buttons = [];
foreach (self::tree() as $dir) {
$dirs[] = $dir['code'];
foreach ($dir['children'] ?? [] as $menu) {
$menus[] = $menu['code'];
foreach ($menu['children'] ?? [] as $btn) {
$buttons[] = $btn['code'];
}
}
}
$perms = [
'can_access_admin' => true,
'dirs' => $dirs,
'menus' => $menus,
'buttons' => $buttons,
];
foreach (self::legacyKeys() as $key) {
$perms[$key] = true;
}
return $perms;
}
public static function legacyKeys(): array
{
return [
'can_manage_users',
'can_manage_roles',
'can_manage_departments',
'can_view_all_conversations',
'can_view_subordinate_conversations',
'can_manage_models',
'can_manage_settings',
'can_manage_memberships',
'can_manage_permissions',
];
}
public static function normalize(array $input): array
{
$base = self::emptyPermissions();
$dirs = array_values(array_unique(array_filter((array) ($input['dirs'] ?? []))));
$menus = array_values(array_unique(array_filter((array) ($input['menus'] ?? []))));
$buttons = array_values(array_unique(array_filter((array) ($input['buttons'] ?? []))));
if (empty($menus) && empty($buttons) && empty($dirs)) {
[$dirs, $menus, $buttons] = self::fromLegacy($input);
}
$perms = array_merge($base, [
'can_access_admin' => !empty($input['can_access_admin']),
'dirs' => $dirs,
'menus' => $menus,
'buttons' => $buttons,
]);
return self::syncLegacyFlags($perms);
}
public static function syncLegacyFlags(array $perms): array
{
$menus = $perms['menus'] ?? [];
$buttons = $perms['buttons'] ?? [];
$perms['can_manage_users'] = in_array('menu:users', $menus, true);
$perms['can_manage_roles'] = in_array('menu:roles', $menus, true);
$perms['can_manage_departments'] = in_array('menu:departments', $menus, true);
$perms['can_manage_models'] = in_array('menu:models', $menus, true);
$perms['can_manage_settings'] = in_array('menu:settings', $menus, true);
$perms['can_manage_memberships'] = in_array('menu:memberships', $menus, true);
$perms['can_manage_permissions'] = in_array('menu:permissions', $menus, true);
$perms['can_view_all_conversations'] = in_array('btn:conv:view_all', $buttons, true);
$perms['can_view_subordinate_conversations'] = in_array('btn:conv:view_subordinate', $buttons, true);
if (in_array('menu:conversations', $menus, true)
&& empty($perms['can_view_all_conversations'])
&& empty($perms['can_view_subordinate_conversations'])) {
$perms['can_view_subordinate_conversations'] = true;
if (!in_array('btn:conv:view_subordinate', $buttons, true)) {
$perms['buttons'][] = 'btn:conv:view_subordinate';
}
}
return $perms;
}
private static function fromLegacy(array $input): array
{
$dirs = [];
$menus = [];
$buttons = [];
$map = [
'can_manage_users' => ['dir:org', 'menu:users', ['btn:user:create', 'btn:user:edit', 'btn:user:reset_password', 'btn:user:delete']],
'can_manage_departments' => ['dir:org', 'menu:departments', ['btn:dept:create', 'btn:dept:edit', 'btn:dept:delete']],
'can_manage_roles' => ['dir:org', 'menu:roles', ['btn:role:create', 'btn:role:edit', 'btn:role:delete']],
'can_manage_memberships' => ['dir:business', 'menu:memberships', ['btn:membership:create', 'btn:membership:edit', 'btn:membership:delete']],
'can_manage_models' => ['dir:system', 'menu:models', ['btn:model:create', 'btn:model:edit', 'btn:model:delete', 'btn:model:test']],
'can_manage_settings' => ['dir:system', 'menu:settings', ['btn:settings:save']],
'can_manage_permissions' => ['dir:system', 'menu:permissions', ['btn:perm:create', 'btn:perm:edit', 'btn:perm:delete']],
];
foreach ($map as $key => [$dir, $menu, $btns]) {
if (!empty($input[$key])) {
$dirs[] = $dir;
$menus[] = $menu;
$buttons = array_merge($buttons, $btns);
}
}
if (!empty($input['can_view_all_conversations']) || !empty($input['can_view_subordinate_conversations'])) {
$dirs[] = 'dir:business';
$menus[] = 'menu:conversations';
if (!empty($input['can_view_all_conversations'])) {
$buttons[] = 'btn:conv:view_all';
}
if (!empty($input['can_view_subordinate_conversations'])) {
$buttons[] = 'btn:conv:view_subordinate';
}
}
if (!empty($input['can_access_admin'])) {
$dirs[] = 'dir:overview';
$menus[] = 'menu:dashboard';
}
return [
array_values(array_unique($dirs)),
array_values(array_unique($menus)),
array_values(array_unique($buttons)),
];
}
public static function hasCode(array $perms, string $code): bool
{
if ($code === 'can_access_admin') {
return !empty($perms['can_access_admin']);
}
if (str_starts_with($code, 'dir:')) {
return in_array($code, $perms['dirs'] ?? [], true);
}
if (str_starts_with($code, 'menu:')) {
return in_array($code, $perms['menus'] ?? [], true);
}
if (str_starts_with($code, 'btn:')) {
return in_array($code, $perms['buttons'] ?? [], true);
}
return !empty($perms[$code]);
}
public static function menuMeta(): array
{
$items = [];
foreach (self::tree() as $dir) {
foreach ($dir['children'] ?? [] as $menu) {
$items[] = [
'id' => $menu['id'] ?? null,
'code' => $menu['code'],
'name' => $menu['name'],
'path' => $menu['path'] ?? '',
'icon' => $menu['icon'] ?? '',
'dir' => $dir['code'],
'dirName' => $dir['name'],
];
}
}
return $items;
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
namespace app\service;
use app\model\UserDailyStat;
use think\exception\HttpResponseException;
class PermissionService
{
public static function checkDailyLimit(array $user): void
{
$today = date('Y-m-d');
$stats = UserDailyStat::where('user_id', $user['id'])
->where('stat_date', $today)
->find();
$count = (int) ($stats->message_count ?? 0);
if ($count >= (int) $user['max_messages_per_day']) {
self::abort('今日消息数量已达上限', 429);
}
}
public static function incrementDailyCount(int $userId): void
{
$today = date('Y-m-d');
$stats = UserDailyStat::where('user_id', $userId)
->where('stat_date', $today)
->find();
if ($stats) {
$stats->inc('message_count')->save();
} else {
UserDailyStat::create([
'user_id' => $userId,
'stat_date' => $today,
'message_count' => 1,
]);
}
}
public static function checkConversationLimit(array $user): void
{
$count = \app\model\Conversation::where('user_id', $user['id'])
->whereNull('deleted_at')
->count();
if ($count >= (int) $user['max_conversations']) {
self::abort('会话数量已达上限', 429);
}
}
public static function canUpload(array $user, string $type): bool
{
if (!empty($user['is_guest']) && $type === 'image') {
return false;
}
$permissions = $user['membership_permissions'] ?? [];
$map = [
'image' => ['upload_image', 'can_upload_image'],
'video' => ['upload_video', 'can_upload_video'],
'document' => ['upload_file', 'can_upload_file'],
'audio' => ['voice', 'can_use_voice'],
];
if (!isset($map[$type])) {
return false;
}
[$featureKey, $permKey] = $map[$type];
if (!SettingsService::isFeatureEnabled($featureKey)) {
return false;
}
return !empty($permissions[$permKey]);
}
public static function getMaxUploadSizeMb(array $user): int
{
return min((int) $user['max_upload_size_mb'], (int) config('upload.max_size_mb'));
}
private static function abort(string $message, int $httpCode = 400): void
{
throw new HttpResponseException(json([
'code' => 1,
'message' => $message,
'data' => null,
], $httpCode));
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace app\service;
use app\model\SystemSetting;
class SettingsService
{
public static function get(string $key, mixed $default = null): mixed
{
$row = SystemSetting::where('setting_key', $key)->find();
if (!$row) {
return $default;
}
$decoded = json_decode($row->setting_value, true);
return json_last_error() === JSON_ERROR_NONE ? $decoded : $row->setting_value;
}
public static function set(string $key, mixed $value): void
{
$stored = is_array($value) || is_object($value)
? json_encode($value, JSON_UNESCAPED_UNICODE)
: (string) $value;
$setting = SystemSetting::where('setting_key', $key)->find();
if ($setting) {
$setting->save(['setting_value' => $stored]);
} else {
SystemSetting::create([
'setting_key' => $key,
'setting_value' => $stored,
]);
}
}
public static function getFeatures(): array
{
return self::get('features', [
'markdown' => true,
'image' => true,
'video' => true,
'voice' => true,
'document' => true,
'emoji' => true,
'upload_image' => true,
'upload_video' => true,
'upload_file' => true,
'paste_image' => true,
]);
}
public static function isFeatureEnabled(string $feature): bool
{
$features = self::getFeatures();
return !empty($features[$feature]);
}
}
@@ -0,0 +1,65 @@
<?php
namespace app\service;
use app\model\Role;
use app\model\User;
use app\service\PermissionCatalog;
class UserContextService
{
public static function formatAuthUser(User $user): array
{
$user = User::with(['membership', 'roleModel', 'department'])->find($user->id);
if (!$user) {
return [];
}
$level = $user->membership;
$role = $user->roleModel;
$department = $user->department;
$permissions = PermissionCatalog::normalize($role?->permissions ?? []);
if (($user->role ?? '') === 'admin' && (!$role || ($role->slug ?? '') === 'super_admin')) {
$permissions = PermissionCatalog::fullPermissions();
}
return [
'id' => $user->id,
'username' => $user->username,
'email' => $user->email,
'nickname' => $user->nickname,
'avatar' => $user->avatar,
'is_guest' => str_starts_with($user->username, 'guest_'),
'role' => $user->role,
'role_id' => $user->role_id,
'role_slug' => $role?->slug,
'role_name' => $role?->name,
'role_permissions' => $permissions,
'department_id' => $user->department_id,
'department_name' => $department?->name,
'status' => $user->status,
'membership_level_id' => $user->membership_level_id,
'membership_name' => $level?->name,
'membership_slug' => $level?->slug,
'max_conversations' => $level?->max_conversations ?? 20,
'max_messages_per_day' => $level?->max_messages_per_day ?? 50,
'max_upload_size_mb' => $level?->max_upload_size_mb ?? 5,
'membership_permissions' => $level?->permissions ?? [],
];
}
public static function formatPublicUser(int $userId): array
{
$user = User::with(['membership', 'roleModel', 'department'])->find($userId);
if (!$user) {
return [];
}
$data = self::formatAuthUser($user);
unset($data['status']);
return $data;
}
}
+49
View File
@@ -0,0 +1,49 @@
{
"name": "topthink/think",
"description": "the new thinkphp framework",
"type": "project",
"keywords": [
"framework",
"thinkphp",
"ORM"
],
"homepage": "https://www.thinkphp.cn/",
"license": "Apache-2.0",
"authors": [
{
"name": "liu21st",
"email": "liu21st@gmail.com"
},
{
"name": "yunwuxin",
"email": "448901948@qq.com"
}
],
"require": {
"php": ">=8.0.0",
"topthink/framework": "^8.0",
"topthink/think-orm": "^3.0|^4.0",
"topthink/think-filesystem": "^2.0|^3.0"
},
"require-dev": {
"topthink/think-dumper": "^1.0",
"topthink/think-trace": "^2.0"
},
"autoload": {
"psr-4": {
"app\\": "app"
},
"psr-0": {
"": "extend/"
}
},
"config": {
"preferred-install": "dist"
},
"scripts": {
"post-autoload-dump": [
"@php think service:discover",
"@php think vendor:publish"
]
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
// +----------------------------------------------------------------------
// | 应用设置
// +----------------------------------------------------------------------
return [
// 应用的命名空间
'app_namespace' => '',
// 是否启用路由
'with_route' => true,
// 默认应用
'default_app' => 'index',
// 默认时区
'default_timezone' => 'Asia/Shanghai',
// 应用映射(自动多应用模式有效)
'app_map' => [],
// 域名绑定(自动多应用模式有效)
'domain_bind' => [],
// 禁止URL访问的应用列表(自动多应用模式有效)
'deny_app_list' => [],
// 异常页面的模板文件
'exception_tmpl' => app()->getThinkPath() . 'tpl/think_exception.tpl',
// 错误显示信息,非调试模式有效
'error_message' => '页面错误!请稍后再试~',
// 显示错误信息
'show_error_msg' => false,
];
+29
View File
@@ -0,0 +1,29 @@
<?php
// +----------------------------------------------------------------------
// | 缓存设置
// +----------------------------------------------------------------------
return [
// 默认缓存驱动
'default' => 'file',
// 缓存连接方式配置
'stores' => [
'file' => [
// 驱动方式
'type' => 'File',
// 缓存保存目录
'path' => '',
// 缓存前缀
'prefix' => '',
// 缓存有效期 0表示永久缓存
'expire' => 0,
// 缓存标签前缀
'tag_prefix' => 'tag:',
// 序列化机制 例如 ['serialize', 'unserialize']
'serialize' => [],
],
// 更多的缓存连接
],
];
+265
View File
@@ -0,0 +1,265 @@
{
"29": {
"inputs": {
"filename_prefix": "ZImage_T2I_",
"images": [
"30:8",
0
]
},
"class_type": "SaveImage",
"_meta": {
"title": "保存图片到 output"
}
},
"49": {
"inputs": {
"aspect_ratio": "1:1 (Square)",
"megapixels": 1,
"multiple": 8
},
"class_type": "ResolutionSelector",
"_meta": {
"title": "分辨率选择器"
}
},
"30:10": {
"inputs": {
"unet_name": "z_image_turbo_bf16.safetensors",
"weight_dtype": "default",
"device": "cuda:0"
},
"class_type": "UNETLoaderMultiGPU",
"_meta": {
"title": "UNet · cuda:0"
}
},
"30:11": {
"inputs": {
"clip_name": "qwen_3_4b.safetensors",
"type": "lumina2",
"device": "cuda:1"
},
"class_type": "CLIPLoaderMultiGPU",
"_meta": {
"title": "CLIP · cuda:1"
}
},
"30:12": {
"inputs": {
"vae_name": "ae.safetensors",
"device": "cuda:1"
},
"class_type": "VAELoaderMultiGPU",
"_meta": {
"title": "VAE · cuda:1"
}
},
"30:14": {
"inputs": {
"shift": 5,
"model": [
"30:10",
0
]
},
"class_type": "ModelSamplingAuraFlow",
"_meta": {
"title": "采样算法(AuraFlow"
}
},
"30:6": {
"inputs": {
"text": [
"30:20",
0
],
"clip": [
"30:11",
0
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP文本编码"
}
},
"30:13": {
"inputs": {
"conditioning": [
"30:6",
0
]
},
"class_type": "ConditioningZeroOut",
"_meta": {
"title": "条件零化"
}
},
"30:5": {
"inputs": {
"width": [
"49",
0
],
"height": [
"49",
1
],
"batch_size": 1
},
"class_type": "EmptySD3LatentImage",
"_meta": {
"title": "空Latent图像(SD3"
}
},
"30:3": {
"inputs": {
"seed": 133701729959619,
"steps": 8,
"cfg": 1,
"sampler_name": "res_multistep",
"scheduler": "simple",
"denoise": 1,
"model": [
"30:14",
0
],
"positive": [
"30:6",
0
],
"negative": [
"30:13",
0
],
"latent_image": [
"30:5",
0
]
},
"class_type": "KSampler",
"_meta": {
"title": "K采样器"
}
},
"30:8": {
"inputs": {
"samples": [
"30:3",
0
],
"vae": [
"30:12",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE解码"
}
},
"30:18": {
"inputs": {
"value": "You are an expert prompt engineer for text-to-image models.\nExpand the user's short request (often Chinese) into ONE detailed English visual prompt.\nRules:\n- Preserve the user intent exactly (subject, action, brand/product names, style).\n- Add concrete visual details: composition, lighting, camera angle, materials, colors, atmosphere.\n- Keep it as a single paragraph of natural English tags/phrases suitable for image generation.\n- Do NOT ask questions. Do NOT output Chinese. Do NOT wrap in quotes or markdown.\n- If the request is an ad/poster, emphasize product focus, clean layout, and commercial photography quality."
},
"class_type": "PrimitiveStringMultiline",
"_meta": {
"title": "System Prompt"
}
},
"30:19": {
"inputs": {
"value": ""
},
"class_type": "PrimitiveStringMultiline",
"_meta": {
"title": "User Prompt"
}
},
"30:17": {
"inputs": {
"string_a": [
"30:18",
0
],
"string_b": [
"30:19",
0
],
"delimiter": ""
},
"class_type": "StringConcatenate",
"_meta": {
"title": "连接"
}
},
"30:16": {
"inputs": {
"prompt": [
"30:17",
0
],
"max_length": 128,
"sampling_mode": "on",
"sampling_mode.temperature": 0.25,
"sampling_mode.top_k": 32,
"sampling_mode.top_p": 0.88,
"sampling_mode.min_p": 0.02,
"sampling_mode.repetition_penalty": 1.08,
"sampling_mode.seed": 0,
"sampling_mode.presence_penalty": 0,
"thinking": false,
"use_default_template": true,
"clip": [
"30:11",
0
]
},
"class_type": "TextGenerate",
"_meta": {
"title": "TextGenerate"
}
},
"30:24": {
"inputs": {
"value": true
},
"class_type": "PrimitiveBoolean",
"_meta": {
"title": "Refine Prompt?"
}
},
"30:21": {
"inputs": {
"switch": [
"30:24",
0
],
"on_false": [
"30:19",
0
],
"on_true": [
"30:16",
0
]
},
"class_type": "ComfySwitchNode",
"_meta": {
"title": "切换"
}
},
"30:20": {
"inputs": {
"source": [
"30:21",
0
]
},
"class_type": "PreviewAny",
"_meta": {
"title": "预览任意"
}
}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
// +----------------------------------------------------------------------
// | 控制台配置
// +----------------------------------------------------------------------
return [
// 指令定义
'commands' => [
],
];
+20
View File
@@ -0,0 +1,20 @@
<?php
// +----------------------------------------------------------------------
// | Cookie设置
// +----------------------------------------------------------------------
return [
// cookie 保存时间
'expire' => 0,
// cookie 保存路径
'path' => '/',
// cookie 有效域名
'domain' => '',
// cookie 启用安全传输
'secure' => false,
// httponly设置
'httponly' => false,
// 是否使用 setcookie
'setcookie' => true,
// samesite 设置,支持 'strict' 'lax'
'samesite' => '',
];
+66
View File
@@ -0,0 +1,66 @@
<?php
return [
// 默认使用的数据库连接配置
'default' => env('DB_DRIVER', 'mysql'),
// 自定义时间查询规则
'time_query_rule' => [],
// 自动写入时间戳字段
// true为自动识别类型 false关闭
// 字符串则明确指定时间字段类型 支持 int timestamp datetime date
'auto_timestamp' => true,
// 时间字段取出后的默认时间格式
'datetime_format' => 'Y-m-d H:i:s',
// 时间字段配置 配置格式:create_time,update_time
'datetime_field' => '',
// 数据库连接配置信息
'connections' => [
'mysql' => [
// 数据库类型
'type' => env('DB_TYPE', 'mysql'),
// 服务器地址
'hostname' => env('DB_HOST', '127.0.0.1'),
// 数据库名
'database' => env('DB_NAME', ''),
// 用户名
'username' => env('DB_USER', 'root'),
// 密码
'password' => env('DB_PASS', ''),
// 端口
'hostport' => env('DB_PORT', '3306'),
// 数据库连接参数
'params' => [],
// 数据库编码
'charset' => env('DB_CHARSET', 'utf8mb4'),
// 数据库表前缀
'prefix' => env('DB_PREFIX', ''),
// 数据库部署方式:0 集中式(单一服务器),1 分布式(主从服务器)
'deploy' => 0,
// 数据库读写是否分离 主从式有效
'rw_separate' => false,
// 读写分离后 主服务器数量
'master_num' => 1,
// 指定从服务器序号
'slave_no' => '',
// 是否严格检查字段是否存在
'fields_strict' => true,
// 是否需要断线重连
// Image jobs can legitimately outlive MySQL's idle timeout. Let
// ThinkORM reconnect and retry when the server closes an idle PDO
// connection between the submit/poll and persistence phases.
'break_reconnect' => true,
// 监听SQL
'trigger_sql' => env('APP_DEBUG', true),
// 开启字段缓存
'fields_cache' => false,
],
// 更多的数据库配置信息
],
];
+24
View File
@@ -0,0 +1,24 @@
<?php
return [
// 默认磁盘
'default' => 'local',
// 磁盘列表
'disks' => [
'local' => [
'type' => 'local',
'root' => app()->getRuntimePath() . 'storage',
],
'public' => [
// 磁盘类型
'type' => 'local',
// 磁盘路径
'root' => app()->getRootPath() . 'public/storage',
// 磁盘路径对应的外部URL路径
'url' => '/storage',
// 可见性
'visibility' => 'public',
],
// 更多的磁盘配置信息
],
];
+6
View File
@@ -0,0 +1,6 @@
<?php
return [
'secret' => env('JWT_SECRET', 'change-this-secret-key-in-production'),
'expire' => (int) env('JWT_EXPIRE', 86400 * 7),
];
+29
View File
@@ -0,0 +1,29 @@
<?php
// +----------------------------------------------------------------------
// | 多语言设置
// +----------------------------------------------------------------------
return [
// 默认语言
'default_lang' => env('DEFAULT_LANG', 'zh-cn'),
// 自动侦测浏览器语言
'auto_detect_browser' => true,
// 允许的语言列表
'allow_lang_list' => [],
// 多语言自动侦测变量名
'detect_var' => 'lang',
// 是否使用Cookie记录
'use_cookie' => true,
// 多语言cookie变量
'cookie_var' => 'think_lang',
// 多语言header变量
'header_var' => 'think-lang',
// 扩展语言包
'extend_list' => [],
// Accept-Language转义为对应语言包名称
'accept_language' => [
'zh-hans-cn' => 'zh-cn',
],
// 是否支持语言分组
'allow_group' => false,
];
+45
View File
@@ -0,0 +1,45 @@
<?php
// +----------------------------------------------------------------------
// | 日志设置
// +----------------------------------------------------------------------
return [
// 默认日志记录通道
'default' => 'file',
// 日志记录级别
'level' => [],
// 日志类型记录的通道 ['error'=>'email',...]
'type_channel' => [],
// 关闭全局日志写入
'close' => false,
// 全局日志处理 支持闭包
'processor' => null,
// 日志通道列表
'channels' => [
'file' => [
// 日志记录方式
'type' => 'File',
// 日志保存目录
'path' => '',
// 单文件日志写入
'single' => false,
// 独立日志级别
'apart_level' => [],
// 最大日志文件数量
'max_files' => 0,
// 使用JSON格式记录
'json' => false,
// 日志处理
'processor' => null,
// 关闭通道日志写入
'close' => false,
// 日志输出格式化
'format' => '[%s][%s] %s',
// 是否实时写入
'realtime_write' => false,
],
// 其它日志通道配置
],
];
+8
View File
@@ -0,0 +1,8 @@
<?php
// 中间件配置
return [
// 别名或分组
'alias' => [],
// 优先级设置,此数组中的中间件会按照数组中的顺序优先执行
'priority' => [],
];
+55
View File
@@ -0,0 +1,55 @@
<?php
// +----------------------------------------------------------------------
// | 路由设置
// +----------------------------------------------------------------------
return [
// pathinfo分隔符
'pathinfo_depr' => '/',
// 是否开启路由延迟解析
'url_lazy_route' => false,
// 是否强制使用路由
'url_route_must' => false,
// 是否区分大小写
'url_case_sensitive' => false,
// 自动扫描子目录分组
'route_auto_group' => false,
// 合并路由规则
'route_rule_merge' => false,
// 路由是否完全匹配
'route_complete_match' => true,
// 去除斜杠
'remove_slash' => false,
// 默认的路由变量规则
'default_route_pattern' => '[\w\.]+',
// URL伪静态后缀
'url_html_suffix' => 'html',
// 访问控制器层名称
'controller_layer' => 'controller',
// 空控制器名
'empty_controller' => 'Error',
// 是否使用控制器后缀
'controller_suffix' => false,
// 默认模块名(开启自动多模块有效)
'default_module' => 'index',
// 默认控制器名
'default_controller' => 'Index',
// 默认操作名
'default_action' => 'index',
// 操作方法后缀
'action_suffix' => '',
// 非路由变量是否使用普通参数方式(用于URL生成)
'url_common_param' => true,
// 操作方法的参数绑定方式 route get param
'action_bind_param' => 'get',
// 请求缓存规则 true为自动规则
'request_cache_key' => true,
// 请求缓存有效期
'request_cache_expire' => null,
// 全局请求缓存排除规则
'request_cache_except' => [],
// 请求缓存的Tag
'request_cache_tag' => '',
// API版本header变量
'api_version' => 'Api-Version',
];
+19
View File
@@ -0,0 +1,19 @@
<?php
// +----------------------------------------------------------------------
// | 会话设置
// +----------------------------------------------------------------------
return [
// session name
'name' => 'PHPSESSID',
// SESSION_ID的提交变量,解决flash上传跨域
'var_session_id' => '',
// 驱动方式 支持file cache
'type' => 'file',
// 存储连接标识 当type使用cache的时候有效
'store' => null,
// 过期时间
'expire' => 1440,
// 前缀
'prefix' => '',
];
+10
View File
@@ -0,0 +1,10 @@
<?php
// +----------------------------------------------------------------------
// | Trace设置 开启调试模式后有效
// +----------------------------------------------------------------------
return [
// 内置Html和Console两种方式 支持扩展
'type' => 'Html',
// 读取的日志通道名
'channel' => '',
];
+16
View File
@@ -0,0 +1,16 @@
<?php
return [
'path' => root_path() . 'uploads',
'max_size_mb' => 100,
'allowed_images' => ['image/jpeg', 'image/jpg', 'image/pjpeg', 'image/png', 'image/x-png', 'image/gif', 'image/webp', 'image/bmp'],
'allowed_videos' => ['video/mp4', 'video/webm', 'video/quicktime'],
'allowed_audios' => ['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/webm'],
'allowed_documents' => [
'application/pdf',
'text/plain',
'text/markdown',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
],
];
+25
View File
@@ -0,0 +1,25 @@
<?php
// +----------------------------------------------------------------------
// | 模板设置
// +----------------------------------------------------------------------
return [
// 模板引擎类型使用Think
'type' => 'Think',
// 默认模板渲染规则 1 解析为小写+下划线 2 全部转换小写 3 保持操作方法
'auto_rule' => 1,
// 模板目录名
'view_dir_name' => 'view',
// 模板后缀
'view_suffix' => 'html',
// 模板文件名分隔符
'view_depr' => DIRECTORY_SEPARATOR,
// 模板引擎普通标签开始标记
'tpl_begin' => '{',
// 模板引擎普通标签结束标记
'tpl_end' => '}',
// 标签库标签开始标记
'taglib_begin' => '{',
// 标签库标签结束标记
'taglib_end' => '}',
];
+29
View File
@@ -0,0 +1,29 @@
<?php
require __DIR__ . '/../vendor/autoload.php';
$app = new think\App();
$app->initialize();
use think\facade\Db;
echo "=== users ===\n";
foreach (Db::name('users')->field('id,username,role,role_id,department_id')->select() as $u) {
echo json_encode($u, JSON_UNESCAPED_UNICODE) . "\n";
}
echo "\n=== departments ===\n";
foreach (Db::name('departments')->select() as $d) {
echo json_encode($d, JSON_UNESCAPED_UNICODE) . "\n";
}
echo "\n=== roles view scope ===\n";
foreach (Db::name('roles')->field('id,name,slug,permissions')->select() as $r) {
$p = json_decode($r['permissions'], true) ?: [];
echo sprintf(
"%s(%s) view_all=%s view_sub=%s dept_menus=%s\n",
$r['slug'],
$r['id'],
!empty($p['can_view_all_conversations']) || in_array('btn:conv:view_all', $p['buttons'] ?? [], true) ? 'Y' : 'N',
!empty($p['can_view_subordinate_conversations']) || in_array('btn:conv:view_subordinate', $p['buttons'] ?? [], true) ? 'Y' : 'N',
implode(',', $p['menus'] ?? [])
);
}
+32
View File
@@ -0,0 +1,32 @@
<?php
/**
* 初始化数据库脚本
* 用法: php database/install.php
*/
$sqlFile = __DIR__ . '/schema.sql';
$sql = file_get_contents($sqlFile);
$host = '127.0.0.1';
$user = 'root';
$pass = 'root';
$port = 3306;
try {
$pdo = new PDO("mysql:host={$host};port={$port};charset=utf8mb4", $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$statements = array_filter(array_map('trim', explode(';', $sql)));
foreach ($statements as $statement) {
if ($statement !== '') {
$pdo->exec($statement);
}
}
echo "Database installed successfully.\n";
} catch (PDOException $e) {
echo "Error: " . $e->getMessage() . "\n";
exit(1);
}
@@ -0,0 +1,36 @@
<?php
/**
* 迁移脚本:为支持 Dify 接口新增所需字段(已存在则跳过)
* 用法: php database/migrate_add_dify_support.php
*/
$host = '127.0.0.1';
$user = 'root';
$pass = 'root';
$port = 3306;
$dbName = 'ai_chat';
try {
$pdo = new PDO("mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4", $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$changes = [
['table' => 'ai_models', 'column' => 'provider', 'sql' => "ALTER TABLE ai_models ADD COLUMN provider VARCHAR(20) NOT NULL DEFAULT 'openai' COMMENT '接口类型:openai/dify' AFTER name"],
['table' => 'conversations', 'column' => 'external_conversation_id', 'sql' => "ALTER TABLE conversations ADD COLUMN external_conversation_id VARCHAR(64) NULL COMMENT '第三方平台会话ID' AFTER message_count"],
];
foreach ($changes as $change) {
$exists = $pdo->query("SHOW COLUMNS FROM {$change['table']} LIKE '{$change['column']}'")->fetch();
if ($exists) {
echo "{$change['table']}.{$change['column']} 已存在,跳过。\n";
continue;
}
$pdo->exec($change['sql']);
echo "已为 {$change['table']} 添加 {$change['column']} 字段。\n";
}
} catch (PDOException $e) {
echo "Error: " . $e->getMessage() . "\n";
exit(1);
}
@@ -0,0 +1,36 @@
<?php
/**
* 迁移脚本:为 ai_models 表添加 frequency_penalty / presence_penalty 字段(已存在则跳过)
* 用法: php database/migrate_add_penalty_fields.php
*/
$host = '127.0.0.1';
$user = 'root';
$pass = 'root';
$port = 3306;
$dbName = 'ai_chat';
try {
$pdo = new PDO("mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4", $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$columns = [
'frequency_penalty' => "ALTER TABLE ai_models ADD COLUMN frequency_penalty DECIMAL(3,2) DEFAULT 0.00 COMMENT '频率惩罚,抑制模型重复输出' AFTER support_image",
'presence_penalty' => "ALTER TABLE ai_models ADD COLUMN presence_penalty DECIMAL(3,2) DEFAULT 0.00 COMMENT '存在惩罚,抑制模型重复话题' AFTER frequency_penalty",
];
foreach ($columns as $name => $sql) {
$exists = $pdo->query("SHOW COLUMNS FROM ai_models LIKE '{$name}'")->fetch();
if ($exists) {
echo "{$name} 字段已存在,跳过。\n";
continue;
}
$pdo->exec($sql);
echo "已添加 {$name} 字段。\n";
}
} catch (PDOException $e) {
echo "Error: " . $e->getMessage() . "\n";
exit(1);
}
@@ -0,0 +1,30 @@
<?php
/**
* 迁移脚本:为 ai_models 表添加 support_context 字段(已存在则跳过)
* 用法: php database/migrate_add_support_context.php
*/
$host = '127.0.0.1';
$user = 'root';
$pass = 'root';
$port = 3306;
$dbName = 'ai_chat';
try {
$pdo = new PDO("mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4", $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$exists = $pdo->query("SHOW COLUMNS FROM ai_models LIKE 'support_context'")->fetch();
if ($exists) {
echo "support_context 字段已存在,跳过。\n";
} else {
$pdo->exec("ALTER TABLE ai_models ADD COLUMN support_context TINYINT(1) DEFAULT 1 COMMENT '是否支持上下文(多轮对话)' AFTER enabled");
echo "已添加 support_context 字段。\n";
}
} catch (PDOException $e) {
echo "Error: " . $e->getMessage() . "\n";
exit(1);
}
@@ -0,0 +1,30 @@
<?php
/**
* 迁移脚本:为 ai_models 表添加 support_image 字段(已存在则跳过)
* 用法: php database/migrate_add_support_image.php
*/
$host = '127.0.0.1';
$user = 'root';
$pass = 'root';
$port = 3306;
$dbName = 'ai_chat';
try {
$pdo = new PDO("mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4", $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$exists = $pdo->query("SHOW COLUMNS FROM ai_models LIKE 'support_image'")->fetch();
if ($exists) {
echo "support_image 字段已存在,跳过。\n";
} else {
$pdo->exec("ALTER TABLE ai_models ADD COLUMN support_image TINYINT(1) DEFAULT 1 COMMENT '是否支持图片/多模态输入' AFTER support_context");
echo "已添加 support_image 字段。\n";
}
} catch (PDOException $e) {
echo "Error: " . $e->getMessage() . "\n";
exit(1);
}
+37
View File
@@ -0,0 +1,37 @@
<?php
/**
* 为 ai_models 增加 extra_config 字段(可重复执行)
* 用法: php database/migrate_extra_config.php
*/
require __DIR__ . '/../vendor/autoload.php';
$envFile = dirname(__DIR__) . '/.env';
$env = [];
if (is_file($envFile)) {
foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
continue;
}
[$k, $v] = array_map('trim', explode('=', $line, 2));
$env[$k] = trim($v, " \t\"'");
}
}
$host = $env['DB_HOST'] ?? '127.0.0.1';
$name = $env['DB_NAME'] ?? 'ai_chat';
$user = $env['DB_USER'] ?? 'root';
$pass = $env['DB_PASS'] ?? '';
$port = $env['DB_PORT'] ?? '3306';
$pdo = new PDO("mysql:host={$host};port={$port};dbname={$name};charset=utf8mb4", $user, $pass);
$cols = $pdo->query("SHOW COLUMNS FROM ai_models LIKE 'extra_config'")->fetchAll();
if ($cols) {
echo "extra_config already exists\n";
exit(0);
}
$pdo->exec("ALTER TABLE ai_models ADD COLUMN extra_config JSON NULL COMMENT 'ComfyUI工作流等扩展配置' AFTER presence_penalty");
echo "extra_config column added\n";
@@ -0,0 +1,61 @@
<?php
/**
* 增量补种会员等级 新增/删除 按钮权限
* 用法: php database/migrate_membership_buttons.php
*/
require __DIR__ . '/../vendor/autoload.php';
$app = new think\App();
$app->initialize();
use app\service\PermissionCatalog;
use think\facade\Db;
try {
$parent = Db::name('sys_permissions')->where('code', 'menu:memberships')->find();
if (!$parent) {
echo "menu:memberships not found, skip.\n";
exit(0);
}
$buttons = [
['code' => 'btn:membership:create', 'name' => '新增会员等级'],
['code' => 'btn:membership:edit', 'name' => '编辑会员等级'],
['code' => 'btn:membership:delete', 'name' => '删除会员等级'],
];
foreach ($buttons as $i => $btn) {
if (Db::name('sys_permissions')->where('code', $btn['code'])->find()) {
echo "exists {$btn['code']}\n";
continue;
}
Db::name('sys_permissions')->insert([
'type' => 'btn',
'code' => $btn['code'],
'name' => $btn['name'],
'parent_id' => $parent['id'],
'path' => null,
'icon' => null,
'sort_order' => $i,
'is_system' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
echo "added {$btn['code']}\n";
}
// 超级管理员补齐全部权限
if (Db::name('roles')->where('slug', 'super_admin')->find()) {
Db::name('roles')->where('slug', 'super_admin')->update([
'permissions' => json_encode(PermissionCatalog::fullPermissions(), JSON_UNESCAPED_UNICODE),
]);
echo "Updated super_admin permissions\n";
}
echo "Done.\n";
} catch (\Throwable $e) {
echo 'Error: ' . $e->getMessage() . "\n";
exit(1);
}
@@ -0,0 +1,77 @@
<?php
/**
* 将已有角色权限升级为 目录/菜单/按钮 结构
* 若 roles 表不存在,会先自动执行角色/部门建表迁移。
*
* 用法: php database/migrate_permission_tree.php
*/
require __DIR__ . '/../vendor/autoload.php';
$app = new think\App();
$app->initialize();
use app\service\PermissionCatalog;
use think\facade\Db;
try {
$dbName = Db::getConfig('database') ?: env('DB_NAME', 'ai_chat');
$tableExists = function (string $table) use ($dbName) {
$rows = Db::query(
'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? LIMIT 1',
[$dbName, $table]
);
return !empty($rows);
};
if (!$tableExists('roles') || !$tableExists('departments')) {
echo "roles/departments 表不存在,先执行 migrate_roles_departments.php ...\n";
$cmd = 'php ' . escapeshellarg(__DIR__ . DIRECTORY_SEPARATOR . 'migrate_roles_departments.php');
passthru($cmd, $code);
if ($code !== 0) {
throw new RuntimeException('migrate_roles_departments.php 执行失败');
}
}
if (!$tableExists('roles')) {
throw new RuntimeException('roles 表仍不存在,请检查数据库连接');
}
$roles = Db::name('roles')->select();
if (!$roles || count($roles) === 0) {
echo "roles 表为空,跳过权限升级。\n";
exit(0);
}
foreach ($roles as $role) {
$raw = $role['permissions'] ?? '{}';
$perms = is_string($raw) ? json_decode($raw, true) : $raw;
if (!is_array($perms)) {
$perms = [];
}
if (($role['slug'] ?? '') === 'super_admin') {
$normalized = PermissionCatalog::fullPermissions();
} elseif (($role['slug'] ?? '') === 'dept_manager') {
$normalized = PermissionCatalog::normalize([
'can_access_admin' => true,
'dirs' => ['dir:overview', 'dir:business'],
'menus' => ['menu:dashboard', 'menu:conversations'],
'buttons' => ['btn:conv:view_subordinate'],
]);
} else {
$normalized = PermissionCatalog::normalize($perms);
}
Db::name('roles')->where('id', $role['id'])->update([
'permissions' => json_encode($normalized, JSON_UNESCAPED_UNICODE),
]);
echo "Updated role: {$role['name']}\n";
}
echo "Permission tree migration completed.\n";
} catch (\Throwable $e) {
echo 'Error: ' . $e->getMessage() . "\n";
exit(1);
}
@@ -0,0 +1,150 @@
<?php
/**
* 角色与部门迁移
* 用法: php database/migrate_roles_departments.php
*/
require __DIR__ . '/../vendor/autoload.php';
$app = new think\App();
$app->initialize();
use think\facade\Db;
function columnExists(string $table, string $column): bool
{
$db = Db::getConfig('database') ?: env('DB_NAME', 'ai_chat');
$rows = Db::query(
'SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1',
[$db, $table, $column]
);
return !empty($rows);
}
function tableExists(string $table): bool
{
$db = Db::getConfig('database') ?: env('DB_NAME', 'ai_chat');
$rows = Db::query(
'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? LIMIT 1',
[$db, $table]
);
return !empty($rows);
}
try {
if (!tableExists('roles')) {
Db::execute("CREATE TABLE roles (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
slug VARCHAR(50) NOT NULL UNIQUE,
permissions JSON NULL,
sort_order INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
echo "Created table roles\n";
}
if (!tableExists('departments')) {
Db::execute("CREATE TABLE departments (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
parent_id INT UNSIGNED NULL,
sort_order INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_parent (parent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
echo "Created table departments\n";
}
if (!columnExists('users', 'role_id')) {
Db::execute('ALTER TABLE users ADD COLUMN role_id INT UNSIGNED NULL AFTER role');
echo "Added users.role_id\n";
}
if (!columnExists('users', 'department_id')) {
Db::execute('ALTER TABLE users ADD COLUMN department_id INT UNSIGNED NULL AFTER role_id');
echo "Added users.department_id\n";
}
$roleCount = (int) Db::name('roles')->count();
if ($roleCount === 0) {
$allTrue = json_encode([
'can_access_admin' => true,
'can_manage_users' => true,
'can_manage_roles' => true,
'can_manage_departments' => true,
'can_view_all_conversations' => true,
'can_view_subordinate_conversations' => true,
'can_manage_models' => true,
'can_manage_settings' => true,
'can_manage_memberships' => true,
], JSON_UNESCAPED_UNICODE);
$deptManager = json_encode([
'can_access_admin' => true,
'can_manage_users' => false,
'can_manage_roles' => false,
'can_manage_departments' => false,
'can_view_all_conversations' => false,
'can_view_subordinate_conversations' => true,
'can_manage_models' => false,
'can_manage_settings' => false,
'can_manage_memberships' => false,
], JSON_UNESCAPED_UNICODE);
$userPerms = json_encode([
'can_access_admin' => false,
'can_manage_users' => false,
'can_manage_roles' => false,
'can_manage_departments' => false,
'can_view_all_conversations' => false,
'can_view_subordinate_conversations' => false,
'can_manage_models' => false,
'can_manage_settings' => false,
'can_manage_memberships' => false,
], JSON_UNESCAPED_UNICODE);
Db::name('roles')->insertAll([
['name' => '超级管理员', 'slug' => 'super_admin', 'permissions' => $allTrue, 'sort_order' => 1],
['name' => '部门管理员', 'slug' => 'dept_manager', 'permissions' => $deptManager, 'sort_order' => 2],
['name' => '普通用户', 'slug' => 'user', 'permissions' => $userPerms, 'sort_order' => 3],
]);
echo "Seeded default roles\n";
}
$superAdminId = (int) Db::name('roles')->where('slug', 'super_admin')->value('id');
$userRoleId = (int) Db::name('roles')->where('slug', 'user')->value('id');
if ($superAdminId > 0) {
Db::name('users')->where('role', 'admin')->where(function ($q) {
$q->whereNull('role_id')->whereOr('role_id', 0);
})->update(['role_id' => $superAdminId]);
}
if ($userRoleId > 0) {
Db::name('users')->where('role', 'user')->where(function ($q) {
$q->whereNull('role_id')->whereOr('role_id', 0);
})->update(['role_id' => $userRoleId]);
Db::name('users')->where(function ($q) {
$q->whereNull('role_id')->whereOr('role_id', 0);
})->update(['role_id' => $userRoleId]);
}
if ((int) Db::name('departments')->count() === 0) {
Db::name('departments')->insert([
'name' => '总公司',
'parent_id' => null,
'sort_order' => 0,
]);
echo "Seeded root department\n";
}
echo "Migration completed.\n";
} catch (\Throwable $e) {
echo 'Error: ' . $e->getMessage() . "\n";
exit(1);
}
@@ -0,0 +1,183 @@
<?php
/**
* 系统权限节点表(目录/菜单/按钮)
* 用法: php database/migrate_sys_permissions.php
*/
require __DIR__ . '/../vendor/autoload.php';
$app = new think\App();
$app->initialize();
use app\service\PermissionCatalog;
use think\facade\Db;
function tableExists(string $table): bool
{
$db = Db::getConfig('database') ?: env('DB_NAME', 'ai_chat');
$rows = Db::query(
'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? LIMIT 1',
[$db, $table]
);
return !empty($rows);
}
function seedNode(array $node, ?int $parentId = null, int $sort = 0): int
{
$existing = Db::name('sys_permissions')->where('code', $node['code'])->find();
if ($existing) {
return (int) $existing['id'];
}
return (int) Db::name('sys_permissions')->insertGetId([
'type' => $node['type'],
'code' => $node['code'],
'name' => $node['name'],
'parent_id' => $parentId,
'path' => $node['path'] ?? null,
'icon' => $node['icon'] ?? null,
'sort_order' => $node['sort_order'] ?? $sort,
'is_system' => $node['is_system'] ?? 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
try {
if (!tableExists('sys_permissions')) {
Db::execute("CREATE TABLE sys_permissions (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
type ENUM('dir','menu','btn') NOT NULL,
code VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
parent_id INT UNSIGNED NULL,
path VARCHAR(200) NULL,
icon VARCHAR(50) NULL,
sort_order INT DEFAULT 0,
is_system TINYINT(1) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_parent (parent_id),
INDEX idx_type (type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
echo "Created table sys_permissions\n";
}
$builtin = PermissionCatalog::builtinTree();
// 补齐:权限管理菜单 + 用户增删按钮
foreach ($builtin as &$dir) {
if ($dir['code'] === 'dir:org') {
foreach ($dir['children'] as &$menu) {
if ($menu['code'] === 'menu:users') {
$codes = array_column($menu['children'], 'code');
if (!in_array('btn:user:create', $codes, true)) {
array_unshift($menu['children'], ['code' => 'btn:user:create', 'name' => '新增用户', 'type' => 'btn']);
}
if (!in_array('btn:user:delete', $codes, true)) {
$menu['children'][] = ['code' => 'btn:user:delete', 'name' => '删除用户', 'type' => 'btn'];
}
}
}
unset($menu);
}
if ($dir['code'] === 'dir:system') {
$menuCodes = array_column($dir['children'], 'code');
if (!in_array('menu:permissions', $menuCodes, true)) {
$dir['children'][] = [
'code' => 'menu:permissions',
'name' => '权限管理',
'type' => 'menu',
'path' => '/permissions',
'icon' => '🔑',
'children' => [
['code' => 'btn:perm:create', 'name' => '新增权限', 'type' => 'btn'],
['code' => 'btn:perm:edit', 'name' => '编辑权限', 'type' => 'btn'],
['code' => 'btn:perm:delete', 'name' => '删除权限', 'type' => 'btn'],
],
];
}
}
}
unset($dir);
$count = (int) Db::name('sys_permissions')->count();
if ($count === 0) {
$sortDir = 0;
foreach ($builtin as $dir) {
$dirId = seedNode($dir, null, $sortDir++);
$sortMenu = 0;
foreach ($dir['children'] ?? [] as $menu) {
$menuId = seedNode($menu, $dirId, $sortMenu++);
$sortBtn = 0;
foreach ($menu['children'] ?? [] as $btn) {
seedNode($btn, $menuId, $sortBtn++);
}
}
}
echo "Seeded builtin permissions\n";
} else {
// 增量补种新增节点
$extra = [
['type' => 'btn', 'code' => 'btn:user:create', 'name' => '新增用户', 'parent_code' => 'menu:users'],
['type' => 'btn', 'code' => 'btn:user:delete', 'name' => '删除用户', 'parent_code' => 'menu:users'],
['type' => 'menu', 'code' => 'menu:permissions', 'name' => '权限管理', 'parent_code' => 'dir:system', 'path' => '/permissions', 'icon' => '🔑'],
['type' => 'btn', 'code' => 'btn:perm:create', 'name' => '新增权限', 'parent_code' => 'menu:permissions'],
['type' => 'btn', 'code' => 'btn:perm:edit', 'name' => '编辑权限', 'parent_code' => 'menu:permissions'],
['type' => 'btn', 'code' => 'btn:perm:delete', 'name' => '删除权限', 'parent_code' => 'menu:permissions'],
];
foreach ($extra as $item) {
if (Db::name('sys_permissions')->where('code', $item['code'])->find()) {
continue;
}
$parent = Db::name('sys_permissions')->where('code', $item['parent_code'])->find();
if (!$parent && $item['code'] === 'btn:perm:create') {
continue;
}
// 若菜单还不存在,先建菜单
if ($item['code'] === 'menu:permissions' && $parent) {
seedNode([
'type' => 'menu',
'code' => 'menu:permissions',
'name' => '权限管理',
'path' => '/permissions',
'icon' => '🔑',
'is_system' => 1,
], (int) $parent['id']);
continue;
}
if (!$parent) {
if ($item['parent_code'] === 'menu:permissions') {
$parent = Db::name('sys_permissions')->where('code', 'menu:permissions')->find();
}
}
if (!$parent) {
continue;
}
seedNode([
'type' => $item['type'],
'code' => $item['code'],
'name' => $item['name'],
'path' => $item['path'] ?? null,
'icon' => $item['icon'] ?? null,
'is_system' => 1,
], (int) $parent['id']);
echo "Added {$item['code']}\n";
}
}
// 超级管理员补齐全部权限码
if (tableExists('roles')) {
$full = PermissionCatalog::fullPermissions();
Db::name('roles')->where('slug', 'super_admin')->update([
'permissions' => json_encode($full, JSON_UNESCAPED_UNICODE),
]);
echo "Updated super_admin permissions\n";
}
echo "Migration completed.\n";
} catch (\Throwable $e) {
echo 'Error: ' . $e->getMessage() . "\n";
exit(1);
}
+173
View File
@@ -0,0 +1,173 @@
-- AI Chat Database Schema
CREATE DATABASE IF NOT EXISTS ai_chat DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE ai_chat;
-- 会员等级
CREATE TABLE IF NOT EXISTS membership_levels (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
slug VARCHAR(50) NOT NULL UNIQUE,
max_conversations INT UNSIGNED DEFAULT 50,
max_messages_per_day INT UNSIGNED DEFAULT 100,
max_upload_size_mb INT UNSIGNED DEFAULT 10,
allowed_models JSON NULL COMMENT '允许使用的模型ID列表,null表示全部',
permissions JSON NULL COMMENT '额外权限配置',
sort_order INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
-- 角色
CREATE TABLE IF NOT EXISTS roles (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
slug VARCHAR(50) NOT NULL UNIQUE,
permissions JSON NULL COMMENT '后台权限配置',
sort_order INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
-- 部门(支持上下级)
CREATE TABLE IF NOT EXISTS departments (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
parent_id INT UNSIGNED NULL,
sort_order INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_parent (parent_id)
) ENGINE=InnoDB;
-- 用户
CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
nickname VARCHAR(50) DEFAULT NULL,
avatar VARCHAR(255) DEFAULT NULL,
role ENUM('user', 'admin') DEFAULT 'user',
role_id INT UNSIGNED NULL,
department_id INT UNSIGNED NULL,
membership_level_id INT UNSIGNED DEFAULT 1,
status ENUM('active', 'disabled') DEFAULT 'active',
last_login_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (membership_level_id) REFERENCES membership_levels(id)
) ENGINE=InnoDB;
-- 系统功能开关
CREATE TABLE IF NOT EXISTS system_settings (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
setting_key VARCHAR(100) NOT NULL UNIQUE,
setting_value TEXT NOT NULL,
description VARCHAR(255) DEFAULT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
-- AI 模型配置(OpenAI 格式)
CREATE TABLE IF NOT EXISTS ai_models (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
provider VARCHAR(20) NOT NULL DEFAULT 'openai' COMMENT '接口类型:openai=OpenAI兼容协议,dify=Dify应用API',
model_id VARCHAR(100) NOT NULL COMMENT 'API model 参数(Dify 模式下可留空,应用由 API Key 决定)',
api_base_url VARCHAR(255) NOT NULL DEFAULT 'https://api.openai.com/v1',
api_key VARCHAR(255) NOT NULL,
max_tokens INT UNSIGNED DEFAULT 4096,
temperature DECIMAL(3,2) DEFAULT 0.70,
is_default TINYINT(1) DEFAULT 0,
enabled TINYINT(1) DEFAULT 1,
support_context TINYINT(1) DEFAULT 1 COMMENT '是否支持上下文(多轮对话),关闭则每次只发送当前消息',
support_image TINYINT(1) DEFAULT 1 COMMENT '是否支持图片/多模态输入,关闭则图片不会发送给模型',
frequency_penalty DECIMAL(3,2) DEFAULT 0.00 COMMENT '频率惩罚,值越大越能抑制模型重复输出相同内容(部分自部署模型容易陷入重复循环)',
presence_penalty DECIMAL(3,2) DEFAULT 0.00 COMMENT '存在惩罚,抑制模型重复讨论相同话题/短语',
extra_config JSON NULL COMMENT '扩展配置(ComfyUI 工作流 JSON、节点映射等)',
sort_order INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
-- 会话
CREATE TABLE IF NOT EXISTS conversations (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
title VARCHAR(200) DEFAULT '新对话',
model_id INT UNSIGNED NULL,
is_pinned TINYINT(1) DEFAULT 0,
message_count INT UNSIGNED DEFAULT 0,
external_conversation_id VARCHAR(64) NULL COMMENT '第三方平台(如 Dify)自己维护的会话ID,用于保持上下文连续',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (model_id) REFERENCES ai_models(id) ON DELETE SET NULL,
INDEX idx_user_updated (user_id, updated_at DESC)
) ENGINE=InnoDB;
-- 消息
CREATE TABLE IF NOT EXISTS messages (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
conversation_id INT UNSIGNED NOT NULL,
role ENUM('user', 'assistant', 'system') NOT NULL,
content TEXT NOT NULL,
content_type ENUM('text', 'markdown', 'mixed') DEFAULT 'text',
attachments JSON NULL COMMENT '[{type,url,name,size,mime}]',
tokens_used INT UNSIGNED DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
INDEX idx_conversation (conversation_id, created_at)
) ENGINE=InnoDB;
-- 上传文件
CREATE TABLE IF NOT EXISTS uploads (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
original_name VARCHAR(255) NOT NULL,
stored_name VARCHAR(255) NOT NULL,
file_path VARCHAR(500) NOT NULL,
mime_type VARCHAR(100) NOT NULL,
file_size INT UNSIGNED NOT NULL,
file_type ENUM('image', 'video', 'audio', 'document', 'other') NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- 每日消息统计
CREATE TABLE IF NOT EXISTS user_daily_stats (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
stat_date DATE NOT NULL,
message_count INT UNSIGNED DEFAULT 0,
UNIQUE KEY uk_user_date (user_id, stat_date),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- 初始会员等级
INSERT INTO membership_levels (name, slug, max_conversations, max_messages_per_day, max_upload_size_mb, permissions) VALUES
('免费用户', 'free', 20, 50, 5, '{"can_upload_image":true,"can_upload_video":false,"can_upload_file":false,"can_use_voice":false}'),
('高级会员', 'premium', 200, 500, 50, '{"can_upload_image":true,"can_upload_video":true,"can_upload_file":true,"can_use_voice":true}'),
('管理员', 'admin', 9999, 9999, 100, '{"can_upload_image":true,"can_upload_video":true,"can_upload_file":true,"can_use_voice":true}');
INSERT INTO roles (name, slug, permissions, sort_order) VALUES
('超级管理员', 'super_admin', '{"can_access_admin":true,"dirs":["dir:overview","dir:org","dir:business","dir:system"],"menus":["menu:dashboard","menu:users","menu:departments","menu:roles","menu:conversations","menu:memberships","menu:models","menu:settings"],"buttons":["btn:user:edit","btn:user:reset_password","btn:dept:create","btn:dept:edit","btn:dept:delete","btn:role:create","btn:role:edit","btn:role:delete","btn:conv:view_all","btn:conv:view_subordinate","btn:membership:edit","btn:model:create","btn:model:edit","btn:model:delete","btn:model:test","btn:settings:save"],"can_manage_users":true,"can_manage_roles":true,"can_manage_departments":true,"can_view_all_conversations":true,"can_view_subordinate_conversations":true,"can_manage_models":true,"can_manage_settings":true,"can_manage_memberships":true}', 1),
('部门管理员', 'dept_manager', '{"can_access_admin":true,"dirs":["dir:overview","dir:business"],"menus":["menu:dashboard","menu:conversations"],"buttons":["btn:conv:view_subordinate"],"can_manage_users":false,"can_manage_roles":false,"can_manage_departments":false,"can_view_all_conversations":false,"can_view_subordinate_conversations":true,"can_manage_models":false,"can_manage_settings":false,"can_manage_memberships":false}', 2),
('普通用户', 'user', '{"can_access_admin":false,"dirs":[],"menus":[],"buttons":[],"can_manage_users":false,"can_manage_roles":false,"can_manage_departments":false,"can_view_all_conversations":false,"can_view_subordinate_conversations":false,"can_manage_models":false,"can_manage_settings":false,"can_manage_memberships":false}', 3);
INSERT INTO departments (name, parent_id, sort_order) VALUES
('总公司', NULL, 0);
-- 默认系统设置
INSERT INTO system_settings (setting_key, setting_value, description) VALUES
('features', '{"markdown":true,"image":true,"video":true,"voice":true,"document":true,"emoji":true,"upload_image":true,"upload_video":true,"upload_file":true,"paste_image":true}', '功能开关'),
('site_name', 'AI Chat', '站点名称'),
('allow_register', 'true', '是否允许注册');
-- 默认管理员 (密码: admin123)
INSERT INTO users (username, email, password_hash, nickname, role, role_id, department_id, membership_level_id) VALUES
('admin', 'admin@example.com', '$2y$10$VJCJHjJoAdUAENQZzLgKO.DrJaVsquBXGWSg5ok3u2FHAKRkVMWX.', '管理员', 'admin', 1, 1, 3);
-- 示例 AI 模型(需替换 api_key
INSERT INTO ai_models (name, model_id, api_base_url, api_key, is_default, enabled) VALUES
('GPT-4o Mini', 'gpt-4o-mini', 'https://api.openai.com/v1', 'your-api-key-here', 1, 1);
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+24
View File
@@ -0,0 +1,24 @@
<IfModule mod_rewrite.c>
Options +FollowSymlinks -Multiviews
RewriteEngine On
DirectoryIndex index.html index.php
# API 请求 -> ThinkPHP
RewriteCond %{REQUEST_URI} ^/api [NC]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php?s=/$1 [QSA,L]
# 管理后台 SPA
RewriteCond %{REQUEST_URI} ^/admin [NC]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^admin(?:/.*)?$ admin/index.html [L]
# 会员端 SPA
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} !^/api [NC]
RewriteCond %{REQUEST_URI} !^/admin [NC]
RewriteRule ^(.*)$ index.html [L]
</IfModule>
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.conv-page[data-v-f8276717]{display:flex;flex-direction:column;height:calc(100vh - 48px);min-height:560px}.page-header[data-v-f8276717]{flex-shrink:0;margin-bottom:16px}.conv-layout[data-v-f8276717]{flex:1;min-height:0;display:grid;grid-template-columns:320px 1fr;gap:16px}.panel[data-v-f8276717]{display:flex;flex-direction:column;min-height:0;padding:0;overflow:hidden}.list-toolbar[data-v-f8276717]{padding:16px;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:8px}.dept-filter[data-v-f8276717],.search-input[data-v-f8276717]{font-size:13px}.conv-list[data-v-f8276717]{flex:1;overflow-y:auto;padding:8px}.conv-item[data-v-f8276717]{width:100%;text-align:left;padding:12px;border-radius:8px;margin-bottom:4px;transition:background .15s}.conv-item[data-v-f8276717]:hover{background:#ffffff0a}.conv-item.active[data-v-f8276717]{background:#6366f126;border:1px solid rgba(99,102,241,.35)}.conv-item-title[data-v-f8276717]{font-size:14px;font-weight:500;margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.conv-item-meta[data-v-f8276717],.conv-item-time[data-v-f8276717]{font-size:12px;color:var(--text-muted)}.conv-item-meta[data-v-f8276717]{display:flex;justify-content:space-between;gap:8px;margin-bottom:2px}.list-pagination[data-v-f8276717]{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-top:1px solid var(--border);font-size:13px;color:var(--text-secondary)}.detail-header[data-v-f8276717]{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:16px 20px;border-bottom:1px solid var(--border)}.detail-header h3[data-v-f8276717]{font-size:16px;margin-bottom:4px}.detail-meta[data-v-f8276717]{font-size:13px;color:var(--text-secondary);display:flex;flex-wrap:wrap;gap:4px}.messages-scroll[data-v-f8276717]{flex:1;overflow-y:auto;padding:20px}.messages[data-v-f8276717]{display:flex;flex-direction:column;gap:16px}.message[data-v-f8276717]{display:flex;gap:10px;max-width:85%}.message.user[data-v-f8276717]{flex-direction:row-reverse;align-self:flex-end}.message.assistant[data-v-f8276717]{align-self:flex-start}.message-avatar[data-v-f8276717]{flex-shrink:0;width:36px;height:36px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;background:var(--bg-tertiary);color:var(--text-secondary)}.message.user .message-avatar[data-v-f8276717]{background:var(--accent);color:#fff}.message-body[data-v-f8276717]{min-width:0}.message-content[data-v-f8276717]{padding:10px 14px;border-radius:12px;background:var(--bg-tertiary);font-size:14px;line-height:1.6;white-space:pre-wrap;word-break:break-word}.message.user .message-content[data-v-f8276717]{background:#6366f133;border:1px solid rgba(99,102,241,.3)}.message-time[data-v-f8276717]{display:block;margin-top:4px;font-size:11px;color:var(--text-muted)}.message.user .message-time[data-v-f8276717]{text-align:right}.attachments[data-v-f8276717]{display:flex;flex-direction:column;gap:8px;margin-bottom:8px}.att-image[data-v-f8276717]{max-width:240px;max-height:180px;border-radius:8px;cursor:pointer;border:1px solid var(--border)}.att-link[data-v-f8276717]{display:inline-flex;align-items:center;gap:6px;padding:8px 12px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;font-size:13px;color:var(--text-primary)}.att-link[data-v-f8276717]:hover{border-color:var(--accent)}.detail-empty[data-v-f8276717],.empty[data-v-f8276717]{text-align:center;padding:48px 24px;color:var(--text-muted);font-size:14px}.detail-empty[data-v-f8276717]{flex:1;display:flex;align-items:center;justify-content:center}@media(max-width:900px){.conv-layout[data-v-f8276717]{grid-template-columns:1fr;grid-template-rows:280px 1fr}.conv-page[data-v-f8276717]{height:auto;min-height:calc(100vh - 48px)}}
@@ -0,0 +1 @@
import{_ as l,g as e,h as d,c as i,a as s,t as o,r,o as p}from"./index-z4tF8s-R.js";const u={class:"stats-grid"},v={class:"stat-card"},c={class:"stat-value"},_={class:"stat-card"},m={class:"stat-value"},g={class:"stat-card"},b={class:"stat-value"},f={class:"stat-card"},y={class:"stat-value"},w={__name:"DashboardView",setup(x){const t=r({users:0,conversations:0,messages:0,today_messages:0});return e(async()=>{const n=await d.get("/admin/stats");t.value=n.data.data}),(n,a)=>(p(),i("div",null,[a[8]||(a[8]=s("div",{class:"page-header"},[s("h2",null,"数据概览"),s("p",null,"系统运行统计数据")],-1)),s("div",u,[s("div",v,[a[0]||(a[0]=s("span",{class:"stat-icon"},"👥",-1)),s("span",c,o(t.value.users),1),a[1]||(a[1]=s("span",{class:"stat-label"},"用户总数",-1))]),s("div",_,[a[2]||(a[2]=s("span",{class:"stat-icon"},"💬",-1)),s("span",m,o(t.value.conversations),1),a[3]||(a[3]=s("span",{class:"stat-label"},"会话总数",-1))]),s("div",g,[a[4]||(a[4]=s("span",{class:"stat-icon"},"📝",-1)),s("span",b,o(t.value.messages),1),a[5]||(a[5]=s("span",{class:"stat-label"},"消息总数",-1))]),s("div",f,[a[6]||(a[6]=s("span",{class:"stat-icon"},"📈",-1)),s("span",y,o(t.value.today_messages),1),a[7]||(a[7]=s("span",{class:"stat-label"},"今日消息",-1))])])]))}},D=l(w,[["__scopeId","data-v-5214a2c8"]]);export{D as default};
@@ -0,0 +1 @@
.stats-grid[data-v-5214a2c8]{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px}.stat-card[data-v-5214a2c8]{background:var(--bg-secondary);border:1px solid var(--border);border-radius:12px;padding:24px;text-align:center}.stat-icon[data-v-5214a2c8]{font-size:28px;display:block;margin-bottom:8px}.stat-value[data-v-5214a2c8]{display:block;font-size:36px;font-weight:700;color:var(--accent)}.stat-label[data-v-5214a2c8]{font-size:14px;color:var(--text-secondary);margin-top:4px}
@@ -0,0 +1 @@
.toolbar[data-v-1b3fb9c5]{margin-bottom:16px}.empty[data-v-1b3fb9c5]{text-align:center;padding:32px;color:var(--text-muted)}.danger[data-v-1b3fb9c5]{color:#ef4444}
@@ -0,0 +1 @@
import{_ as L,u as N,g as U,h as b,c as s,a as t,i as f,d as u,F as C,j as B,w as E,t as d,b as h,v as $,k as F,r as m,m as j,l as z,o,B as A}from"./index-z4tF8s-R.js";const I={class:"toolbar"},O={class:"panel"},R={class:"data-table"},T=["onClick"],q=["onClick"],G=["onClick"],H={key:0,class:"empty"},J={class:"modal"},K={class:"modal-header"},P={class:"modal-body"},Q={class:"form-group"},W={class:"form-group"},X=["value","disabled"],Y={class:"form-group"},Z={key:0,class:"form-error"},tt={__name:"DepartmentsView",setup(et){const c=N(),v=m([]),k=m([]),p=m(!1),r=m(null),i=m(""),l=j({name:"",parent_id:null,sort_order:0}),D=z(()=>v.value.filter(a=>a.id!==r.value));U(y);async function y(){const e=(await b.get("/admin/departments")).data.data||{};v.value=e.tree||[],k.value=e.list||[]}function V(a){var e;return a&&((e=k.value.find(n=>n.id===a))==null?void 0:e.name)||"-"}function g(a=null){r.value=null,l.name="",l.parent_id=a,l.sort_order=0,i.value="",p.value=!0}function M(a){r.value=a.id,l.name=a.name,l.parent_id=a.parent_id||null,l.sort_order=a.sort_order??0,i.value="",p.value=!0}function _(){p.value=!1}async function x(){if(i.value="",!l.name.trim()){i.value="请填写部门名称";return}const a={name:l.name.trim(),parent_id:l.parent_id,sort_order:l.sort_order};try{r.value?await b.put(`/admin/departments/${r.value}`,a):await b.post("/admin/departments",a),_(),await y()}catch(e){i.value=e.message||"保存失败"}}async function S(a){if(confirm(`确定删除部门「${a.name}」?`))try{await b.delete(`/admin/departments/${a.id}`),await y()}catch(e){alert(e.message||"删除失败")}}return(a,e)=>(o(),s("div",null,[e[9]||(e[9]=t("div",{class:"page-header"},[t("h2",null,"部门管理"),t("p",null,"维护组织部门层级,上级部门可查看下级部门员工聊天记录")],-1)),t("div",I,[f(c).hasButton("btn:dept:create")?(o(),s("button",{key:0,class:"btn btn-primary",onClick:e[0]||(e[0]=n=>g())},"新增部门")):u("",!0)]),t("div",O,[t("table",R,[e[4]||(e[4]=t("thead",null,[t("tr",null,[t("th",null,"部门名称"),t("th",null,"上级部门"),t("th",null,"排序"),t("th",null,"操作")])],-1)),t("tbody",null,[(o(!0),s(C,null,B(v.value,n=>(o(),s("tr",{key:n.id},[t("td",null,[t("span",{style:A({paddingLeft:`${n.depth*16}px`})},d(n.label||n.name),5)]),t("td",null,d(V(n.parent_id)),1),t("td",null,d(n.sort_order??0),1),t("td",null,[f(c).hasButton("btn:dept:create")?(o(),s("button",{key:0,class:"btn btn-ghost",onClick:w=>g(n.id)},"添加下级",8,T)):u("",!0),f(c).hasButton("btn:dept:edit")?(o(),s("button",{key:1,class:"btn btn-ghost",onClick:w=>M(n)},"编辑",8,q)):u("",!0),f(c).hasButton("btn:dept:delete")?(o(),s("button",{key:2,class:"btn btn-ghost danger",onClick:w=>S(n)},"删除",8,G)):u("",!0)])]))),128))])]),v.value.length?u("",!0):(o(),s("p",H,"暂无部门"))]),p.value?(o(),s("div",{key:0,class:"modal-overlay",onClick:E(_,["self"])},[t("div",J,[t("div",K,[t("h3",null,d(r.value?"编辑部门":"新增部门"),1),t("button",{onClick:_},"×")]),t("div",P,[t("div",Q,[e[5]||(e[5]=t("label",null,"部门名称",-1)),h(t("input",{"onUpdate:modelValue":e[1]||(e[1]=n=>l.name=n),class:"form-input"},null,512),[[$,l.name]])]),t("div",W,[e[7]||(e[7]=t("label",null,"上级部门",-1)),h(t("select",{"onUpdate:modelValue":e[2]||(e[2]=n=>l.parent_id=n),class:"form-select"},[e[6]||(e[6]=t("option",{value:null},"无(顶级部门)",-1)),(o(!0),s(C,null,B(D.value,n=>(o(),s("option",{key:n.id,value:n.id,disabled:r.value===n.id},d(n.label||n.name),9,X))),128))],512),[[F,l.parent_id]])]),t("div",Y,[e[8]||(e[8]=t("label",null,"排序",-1)),h(t("input",{"onUpdate:modelValue":e[3]||(e[3]=n=>l.sort_order=n),type:"number",class:"form-input"},null,512),[[$,l.sort_order,void 0,{number:!0}]])]),i.value?(o(),s("p",Z,d(i.value),1)):u("",!0)]),t("div",{class:"modal-footer"},[t("button",{class:"btn btn-ghost",onClick:_},"取消"),t("button",{class:"btn btn-primary",onClick:x},"保存")])])])):u("",!0)]))}},at=L(tt,[["__scopeId","data-v-1b3fb9c5"]]);export{at as default};
@@ -0,0 +1 @@
import{_ as b,u as h,c as i,a as e,w as y,b as d,v as p,t as c,d as w,r as a,e as V,f as x,o as v}from"./index-z4tF8s-R.js";const k={class:"login-page"},q={class:"login-card"},B={class:"form-group"},L={class:"form-group"},S={key:0,class:"form-error"},A=["disabled"],C={__name:"LoginView",setup(D){const f=V(),m=x(),g=h(),l=a(""),n=a(""),o=a(""),t=a(!1);async function _(){o.value="",t.value=!0;try{await g.login(l.value,n.value),f.push(m.query.redirect||"/dashboard")}catch(u){o.value=u.message}finally{t.value=!1}}return(u,s)=>(v(),i("div",k,[e("div",q,[s[4]||(s[4]=e("div",{class:"login-header"},[e("div",{class:"logo"},"⚙️"),e("h1",null,"AI Chat 管理后台"),e("p",null,"请使用管理员账户登录")],-1)),e("form",{onSubmit:y(_,["prevent"])},[e("div",B,[s[2]||(s[2]=e("label",null,"账号",-1)),d(e("input",{"onUpdate:modelValue":s[0]||(s[0]=r=>l.value=r),class:"form-input",placeholder:"管理员用户名或邮箱",required:""},null,512),[[p,l.value]])]),e("div",L,[s[3]||(s[3]=e("label",null,"密码",-1)),d(e("input",{"onUpdate:modelValue":s[1]||(s[1]=r=>n.value=r),type:"password",class:"form-input",placeholder:"请输入密码",required:""},null,512),[[p,n.value]])]),o.value?(v(),i("p",S,c(o.value),1)):w("",!0),e("button",{type:"submit",class:"btn btn-primary login-btn",disabled:t.value},c(t.value?"登录中...":"登录"),9,A)],32)])]))}},M=b(C,[["__scopeId","data-v-7f135f83"]]);export{M as default};
@@ -0,0 +1 @@
.login-page[data-v-7f135f83]{min-height:100%;display:flex;align-items:center;justify-content:center;padding:24px;background:linear-gradient(135deg,#0f172a,#1e1b4b)}.login-card[data-v-7f135f83]{width:100%;max-width:400px;padding:40px 32px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:16px}.login-header[data-v-7f135f83]{text-align:center;margin-bottom:32px}.logo[data-v-7f135f83]{font-size:48px;margin-bottom:12px}.login-header h1[data-v-7f135f83]{font-size:22px;margin-bottom:8px}.login-header p[data-v-7f135f83]{color:var(--text-secondary);font-size:14px}.login-btn[data-v-7f135f83]{width:100%;padding:12px;margin-top:8px}
@@ -0,0 +1 @@
.toolbar[data-v-b7a460a8]{margin-bottom:16px}.membership-grid[data-v-b7a460a8]{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px}.membership-card[data-v-b7a460a8]{background:var(--bg-secondary);border:1px solid var(--border);border-radius:12px;padding:20px}.card-header[data-v-b7a460a8]{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.card-header h3[data-v-b7a460a8]{font-size:18px}.slug[data-v-b7a460a8]{font-size:12px;color:var(--text-muted);background:var(--bg-tertiary);padding:2px 8px;border-radius:4px}.info-row[data-v-b7a460a8]{display:flex;justify-content:space-between;padding:8px 0;font-size:14px;border-bottom:1px solid var(--border)}.info-row span[data-v-b7a460a8]{color:var(--text-secondary)}.permissions[data-v-b7a460a8]{display:flex;flex-wrap:wrap;gap:6px;margin-top:12px;min-height:24px}.perm-tag[data-v-b7a460a8]{font-size:12px;padding:2px 8px;background:#6366f126;color:var(--accent);border-radius:4px}.field-hint[data-v-b7a460a8]{font-size:12px;color:var(--text-muted)}.card-actions[data-v-b7a460a8]{display:flex;gap:8px;margin-top:16px}.card-actions .btn[data-v-b7a460a8]{flex:1}.check-item[data-v-b7a460a8]{display:flex;align-items:center;gap:8px;margin-bottom:8px;font-size:14px;cursor:pointer}.danger[data-v-b7a460a8]{color:#ef4444}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.toolbar[data-v-16cf4364]{margin-bottom:16px}.url-cell[data-v-16cf4364]{max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;color:var(--text-secondary)}code[data-v-16cf4364]{background:var(--bg-tertiary);padding:2px 6px;border-radius:4px;font-size:13px}.test-result[data-v-16cf4364]{margin-top:8px;padding:12px;border-radius:8px;font-size:13px}.test-result.success[data-v-16cf4364]{background:#22c55e1f;border:1px solid rgba(34,197,94,.3);color:var(--success)}.test-result.error[data-v-16cf4364]{background:#ef44441f;border:1px solid rgba(239,68,68,.3);color:var(--danger)}.test-result p[data-v-16cf4364]{margin-top:6px;color:var(--text-secondary)}.test-result.success p[data-v-16cf4364]{color:#86efac}.reply-preview[data-v-16cf4364]{word-break:break-all}.field-hint[data-v-16cf4364]{margin-top:4px;font-size:12px;color:var(--text-secondary)}.field-hint-warn[data-v-16cf4364]{color:var(--danger)}.saved-key-hint[data-v-16cf4364]{margin-bottom:6px;font-size:12px;color:var(--success)}.saved-key-hint code[data-v-16cf4364]{background:#22c55e1f;color:var(--success)}.workflow-json[data-v-16cf4364]{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.45;min-height:180px;resize:vertical}.workflow-actions[data-v-16cf4364]{display:flex;gap:8px;margin-top:8px;flex-wrap:wrap}.form-row-2[data-v-16cf4364]{display:grid;grid-template-columns:1fr 1fr;gap:12px}.btn-sm[data-v-16cf4364]{padding:6px 10px;font-size:12px}.file-btn[data-v-16cf4364]{cursor:pointer;display:inline-flex;align-items:center}@media(max-width:640px){.form-row-2[data-v-16cf4364]{grid-template-columns:1fr}}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.toolbar[data-v-f8ed284c]{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap}.empty[data-v-f8ed284c]{text-align:center;padding:32px;color:var(--text-muted)}.type-badge[data-v-f8ed284c]{font-size:11px;padding:2px 6px;border-radius:4px}.type-badge.dir[data-v-f8ed284c]{background:#0ea5e926;color:#0ea5e9}.type-badge.menu[data-v-f8ed284c]{background:#6366f126;color:var(--accent)}.type-badge.btn[data-v-f8ed284c]{background:#22c55e26;color:#22c55e}.field-hint[data-v-f8ed284c]{margin-top:6px;font-size:12px;color:var(--text-muted)}.danger[data-v-f8ed284c]{color:#ef4444}
@@ -0,0 +1 @@
.toolbar[data-v-fc345f7d]{margin-bottom:16px}.perm-tags[data-v-fc345f7d]{display:flex;flex-wrap:wrap;gap:4px}.perm-tag[data-v-fc345f7d]{font-size:12px;padding:2px 8px;background:#6366f126;color:var(--accent);border-radius:4px}.field-hint[data-v-fc345f7d]{font-size:12px;color:var(--text-muted)}.modal-wide[data-v-fc345f7d]{max-width:640px;max-height:90vh;display:flex;flex-direction:column}.modal-wide .modal-body[data-v-fc345f7d]{overflow-y:auto}.top-check[data-v-fc345f7d]{font-weight:500}.perm-section[data-v-fc345f7d]{border:1px solid var(--border);border-radius:10px;padding:12px;background:var(--bg-tertiary)}.perm-section-header[data-v-fc345f7d]{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;font-size:13px;font-weight:500}.perm-actions[data-v-fc345f7d]{display:flex;gap:4px}.perm-dir[data-v-fc345f7d]{margin-bottom:14px;padding-bottom:10px;border-bottom:1px dashed var(--border)}.perm-dir[data-v-fc345f7d]:last-child{border-bottom:none;margin-bottom:0}.dir-check[data-v-fc345f7d]{font-weight:600;margin-bottom:8px}.perm-menu[data-v-fc345f7d]{margin-left:22px;margin-bottom:8px}.perm-btns[data-v-fc345f7d]{margin-left:24px;display:flex;flex-direction:column;gap:4px}.btn-check[data-v-fc345f7d]{font-size:13px;color:var(--text-secondary)}.check-item[data-v-fc345f7d]{display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:14px;cursor:pointer}.type-badge[data-v-fc345f7d]{font-size:11px;padding:1px 6px;border-radius:4px;font-weight:500}.type-badge.dir[data-v-fc345f7d]{background:#0ea5e926;color:#0ea5e9}.type-badge.menu[data-v-fc345f7d]{background:#6366f126;color:var(--accent)}.type-badge.btn[data-v-fc345f7d]{background:#22c55e26;color:#22c55e}.danger[data-v-fc345f7d]{color:#ef4444}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{_ as V,u as h,g as y,h as c,c as o,a as t,b as p,v as j,x as m,s as B,F as C,j as M,i as N,t as _,d as f,r as v,m as S,o as u}from"./index-z4tF8s-R.js";const U={class:"panel"},A={class:"form-group"},T={class:"form-group"},D={class:"check-item"},F={class:"panel",style:{"margin-top":"16px"}},I={class:"feature-grid"},L=["onUpdate:modelValue"],E=["disabled"],O={key:1,class:"success-msg"},R={__name:"SettingsView",setup(q){const g=h(),i=v("AI Chat"),n=v(!0),r=v(!1),d=v(!1),a=S({markdown:!0,image:!0,video:!0,voice:!0,document:!0,emoji:!0,upload_image:!0,upload_video:!0,upload_file:!0,paste_image:!0}),b={markdown:"Markdown 解析",image:"图片解析",video:"视频解析",voice:"语音解析",document:"文档解析",emoji:"表情",upload_image:"上传图片",upload_video:"上传视频",upload_file:"上传文件",paste_image:"粘贴图片"};y(async()=>{var s;const e=(await c.get("/admin/settings")).data.data;e.site_name&&(i.value=e.site_name.value),e.allow_register&&(n.value=e.allow_register.value===!0||e.allow_register.value==="true"),(s=e.features)!=null&&s.value&&Object.assign(a,e.features.value)});async function w(){r.value=!0,d.value=!1;try{await c.put("/admin/settings",{site_name:i.value,allow_register:n.value?"true":"false",features:{...a}}),d.value=!0,setTimeout(()=>{d.value=!1},3e3)}finally{r.value=!1}}return(x,e)=>(u(),o("div",null,[e[7]||(e[7]=t("div",{class:"page-header"},[t("h2",null,"系统设置"),t("p",null,"控制前端功能开关与站点配置")],-1)),t("div",U,[e[4]||(e[4]=t("h3",{class:"section-title"},"站点配置",-1)),t("div",A,[e[2]||(e[2]=t("label",null,"站点名称",-1)),p(t("input",{"onUpdate:modelValue":e[0]||(e[0]=s=>i.value=s),class:"form-input"},null,512),[[j,i.value]])]),t("div",T,[t("label",D,[p(t("input",{type:"checkbox","onUpdate:modelValue":e[1]||(e[1]=s=>n.value=s)},null,512),[[m,n.value]]),e[3]||(e[3]=B(" 允许用户注册 ",-1))])])]),t("div",F,[e[5]||(e[5]=t("h3",{class:"section-title"},"功能开关(会员端)",-1)),e[6]||(e[6]=t("p",{class:"section-desc"},"关闭后,会员端对应功能将不可用",-1)),t("div",I,[(u(!0),o(C,null,M(a,(s,l)=>(u(),o("label",{key:l,class:"feature-item"},[p(t("input",{type:"checkbox","onUpdate:modelValue":k=>a[l]=k},null,8,L),[[m,a[l]]]),t("span",null,_(b[l]||l),1)]))),128))]),N(g).hasButton("btn:settings:save")?(u(),o("button",{key:0,class:"btn btn-primary",onClick:w,disabled:r.value},_(r.value?"保存中...":"保存全部设置"),9,E)):f("",!0),d.value?(u(),o("p",O,"保存成功")):f("",!0)])]))}},G=V(R,[["__scopeId","data-v-75cd9393"]]);export{G as default};
@@ -0,0 +1 @@
.section-title[data-v-75cd9393]{font-size:16px;margin-bottom:16px}.section-desc[data-v-75cd9393]{font-size:13px;color:var(--text-secondary);margin-bottom:16px}.feature-grid[data-v-75cd9393]{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:20px}.feature-item[data-v-75cd9393]{display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer}.feature-item input[data-v-75cd9393]{accent-color:var(--accent)}.check-item[data-v-75cd9393]{display:flex;align-items:center;gap:8px;cursor:pointer}.success-msg[data-v-75cd9393]{color:var(--success);font-size:14px;margin-top:12px}

Some files were not shown because too many files have changed in this diff Show More