Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d9e2376b6 | ||
|
|
01729b1e0b |
@@ -0,0 +1,13 @@
|
|||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
**/.env
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
backend/vendor
|
||||||
|
backend/runtime/*
|
||||||
|
backend/uploads/*
|
||||||
|
backend/storage/*
|
||||||
|
logs
|
||||||
|
backend-tp
|
||||||
|
backend-tp8
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 复制为 .env 后再按需修改;不要提交包含真实密钥的 .env。
|
||||||
|
APP_PORT=8081
|
||||||
|
APP_PUBLIC_URL=http://localhost:8081
|
||||||
|
APP_DEBUG=false
|
||||||
|
MYSQL_ROOT_PASSWORD=请替换为强密码
|
||||||
|
DB_USER=ai_chat
|
||||||
|
DB_PASSWORD=请替换为独立的数据库用户密码
|
||||||
|
JWT_SECRET=请替换为至少32位随机字符串
|
||||||
|
JWT_EXPIRE=604800
|
||||||
|
|
||||||
|
# 可选:宿主机或局域网中的 CosyVoice 服务
|
||||||
|
COSYVOICE_ENABLED=false
|
||||||
|
COSYVOICE_BASE_URL=http://host.docker.internal:50000
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
logs/stability/
|
logs/stability/
|
||||||
logs/conversation/
|
logs/conversation/
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
|||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
FROM node:22-alpine AS frontend-builder
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./frontend/
|
||||||
|
COPY frontend-admin/package.json frontend-admin/package-lock.json ./frontend-admin/
|
||||||
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm --prefix frontend ci && npm --prefix frontend-admin ci
|
||||||
|
|
||||||
|
COPY frontend ./frontend
|
||||||
|
COPY frontend-admin ./frontend-admin
|
||||||
|
COPY scripts ./scripts
|
||||||
|
COPY backend/public ./backend/public
|
||||||
|
RUN npm --prefix frontend run build && npm --prefix frontend-admin run build
|
||||||
|
|
||||||
|
FROM php:8.2-fpm-bookworm AS app
|
||||||
|
WORKDIR /var/www/backend
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
ffmpeg \
|
||||||
|
fonts-noto-cjk \
|
||||||
|
libfreetype6-dev \
|
||||||
|
libjpeg62-turbo-dev \
|
||||||
|
libonig-dev \
|
||||||
|
libpng-dev \
|
||||||
|
libwebp-dev \
|
||||||
|
libzip-dev \
|
||||||
|
poppler-utils \
|
||||||
|
unzip \
|
||||||
|
&& docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
|
||||||
|
&& docker-php-ext-install -j"$(nproc)" gd mbstring pdo_mysql zip \
|
||||||
|
&& mkdir -p /usr/local/share/cosyvoice \
|
||||||
|
&& curl -fsSL --retry 3 \
|
||||||
|
https://raw.githubusercontent.com/FunAudioLLM/CosyVoice/main/asset/zero_shot_prompt.wav \
|
||||||
|
-o /usr/local/share/cosyvoice/zero_shot_prompt.wav \
|
||||||
|
&& echo "c7b31d6dbe7cc6a716dded00550db5b50940bf209e424e4ad207b12e657c8ff6 /usr/local/share/cosyvoice/zero_shot_prompt.wav" | sha256sum -c - \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||||
|
COPY backend/composer.json backend/composer.lock ./
|
||||||
|
RUN --mount=type=cache,target=/tmp/composer-cache \
|
||||||
|
COMPOSER_CACHE_DIR=/tmp/composer-cache composer install \
|
||||||
|
--no-dev --no-interaction --no-progress --prefer-dist \
|
||||||
|
--no-scripts --optimize-autoloader
|
||||||
|
|
||||||
|
COPY backend ./
|
||||||
|
COPY --from=frontend-builder /workspace/backend/public ./public
|
||||||
|
RUN composer run-script post-autoload-dump --no-interaction \
|
||||||
|
&& mkdir -p runtime uploads storage/cosyvoice \
|
||||||
|
&& chown -R www-data:www-data runtime uploads storage
|
||||||
|
|
||||||
|
COPY deploy/docker/php.ini /usr/local/etc/php/conf.d/99-app.ini
|
||||||
|
COPY deploy/docker/php-fpm.conf /usr/local/etc/php-fpm.d/zz-app.conf
|
||||||
|
COPY deploy/docker/app-entrypoint.sh /usr/local/bin/app-entrypoint
|
||||||
|
RUN chmod +x /usr/local/bin/app-entrypoint
|
||||||
|
ENTRYPOINT ["app-entrypoint"]
|
||||||
|
|
||||||
|
FROM nginx:1.28-alpine AS web
|
||||||
|
COPY deploy/docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=frontend-builder /workspace/backend/public /var/www/backend/public
|
||||||
@@ -103,6 +103,10 @@ npm run dev
|
|||||||
|
|
||||||
登录管理后台,进入「AI 模型」,填入 API Key 和接口地址(支持 OpenAI 兼容 API)。
|
登录管理后台,进入「AI 模型」,填入 API Key 和接口地址(支持 OpenAI 兼容 API)。
|
||||||
|
|
||||||
|
### 7. 可选:接入 CosyVoice 真人感客服音色
|
||||||
|
|
||||||
|
语音对话会优先请求 CosyVoice,服务不可用时自动回落到 OpenAI 或浏览器语音。AI 播报期间会继续监听麦克风,用户插话后立即停止当前音频和剩余播放队列,并转入新一轮识别。启动 GPU 服务后,在管理后台「系统设置 → AI 客服人物」中配置人物名称、欢迎语、性格、说话人、合成模式,并可上传已授权的 WAV 音色样本和在线试听。GPU 服务部署、SFT 与零样本音色克隆说明见 [`deploy/cosyvoice.md`](deploy/cosyvoice.md)。
|
||||||
|
|
||||||
## 默认账户
|
## 默认账户
|
||||||
|
|
||||||
| 用途 | 用户名 | 密码 |
|
| 用途 | 用户名 | 密码 |
|
||||||
@@ -126,6 +130,37 @@ npm run dev
|
|||||||
|
|
||||||
## 生产部署
|
## 生产部署
|
||||||
|
|
||||||
|
### Docker Compose 一键部署
|
||||||
|
|
||||||
|
项目提供 Nginx + PHP-FPM 8.2 + MySQL 8 的容器化部署。首次启动时会自动编译两个 Vue 前端、安装 Composer 生产依赖,并导入数据库结构。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.docker.example .env
|
||||||
|
# 修改 .env 中的 MYSQL_ROOT_PASSWORD 和 JWT_SECRET
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
默认访问地址:
|
||||||
|
|
||||||
|
- 会员端:`http://localhost:8081/`
|
||||||
|
- 管理后台:`http://localhost:8081/admin/`
|
||||||
|
- API:`http://localhost:8081/api/`
|
||||||
|
|
||||||
|
查看状态和日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs -f web app db
|
||||||
|
```
|
||||||
|
|
||||||
|
停止服务(保留数据库与上传文件):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
只有明确需要清空全部数据时才执行 `docker compose down -v`。如需改用其他端口,修改 `.env` 中的 `APP_PORT`。
|
||||||
|
|
||||||
### 1. 一键编译并部署静态资源
|
### 1. 一键编译并部署静态资源
|
||||||
|
|
||||||
在项目根目录执行:
|
在项目根目录执行:
|
||||||
|
|||||||
+1
-2
@@ -1,7 +1,6 @@
|
|||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
composer.phar
|
composer.phar
|
||||||
composer.lock
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
/.idea
|
/.idea
|
||||||
@@ -9,4 +8,4 @@ Thumbs.db
|
|||||||
/vendor
|
/vendor
|
||||||
/.settings
|
/.settings
|
||||||
/.buildpath
|
/.buildpath
|
||||||
/.project
|
/.project
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace app\controller\api;
|
|||||||
use app\model\AiModel;
|
use app\model\AiModel;
|
||||||
use app\model\Conversation as ConversationModel;
|
use app\model\Conversation as ConversationModel;
|
||||||
use app\model\Department;
|
use app\model\Department;
|
||||||
|
use app\model\InvitationCode;
|
||||||
use app\model\MembershipLevel;
|
use app\model\MembershipLevel;
|
||||||
use app\model\Message;
|
use app\model\Message;
|
||||||
use app\model\Role;
|
use app\model\Role;
|
||||||
@@ -14,6 +15,7 @@ use app\model\User;
|
|||||||
use app\model\UserDailyStat;
|
use app\model\UserDailyStat;
|
||||||
use app\service\AdminScopeService;
|
use app\service\AdminScopeService;
|
||||||
use app\service\ComfyUIService;
|
use app\service\ComfyUIService;
|
||||||
|
use app\service\CosyVoiceService;
|
||||||
use app\service\DepartmentService;
|
use app\service\DepartmentService;
|
||||||
use app\service\DifyService;
|
use app\service\DifyService;
|
||||||
use app\service\OpenAIService;
|
use app\service\OpenAIService;
|
||||||
@@ -154,6 +156,7 @@ class Admin extends BaseApi
|
|||||||
->leftJoin('roles r', 'u.role_id = r.id')
|
->leftJoin('roles r', 'u.role_id = r.id')
|
||||||
->leftJoin('departments d', 'u.department_id = d.id')
|
->leftJoin('departments d', 'u.department_id = d.id')
|
||||||
->field('u.id,u.username,u.email,u.nickname,u.role,u.role_id,u.department_id,u.status,u.membership_level_id,u.created_at,u.last_login_at,ml.name as membership_name,r.name as role_name,r.slug as role_slug,d.name as department_name')
|
->field('u.id,u.username,u.email,u.nickname,u.role,u.role_id,u.department_id,u.status,u.membership_level_id,u.created_at,u.last_login_at,ml.name as membership_name,r.name as role_name,r.slug as role_slug,d.name as department_name')
|
||||||
|
->whereRaw('LEFT(u.username, 6) <> ?', ['guest_'])
|
||||||
->order('u.id', 'desc');
|
->order('u.id', 'desc');
|
||||||
|
|
||||||
AdminScopeService::applyUserScope($query, $this->authUser(), 'u');
|
AdminScopeService::applyUserScope($query, $this->authUser(), 'u');
|
||||||
@@ -182,6 +185,9 @@ class Admin extends BaseApi
|
|||||||
if (strlen($username) < 3 || strlen($username) > 50) {
|
if (strlen($username) < 3 || strlen($username) > 50) {
|
||||||
return $this->error('用户名长度需 3-50 个字符');
|
return $this->error('用户名长度需 3-50 个字符');
|
||||||
}
|
}
|
||||||
|
if (str_starts_with(strtolower($username), 'guest_')) {
|
||||||
|
return $this->error('guest_ 为系统访客账号保留前缀');
|
||||||
|
}
|
||||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
return $this->error('邮箱格式不正确');
|
return $this->error('邮箱格式不正确');
|
||||||
}
|
}
|
||||||
@@ -248,6 +254,9 @@ class Admin extends BaseApi
|
|||||||
if (!$user) {
|
if (!$user) {
|
||||||
return $this->error('用户不存在', 404);
|
return $this->error('用户不存在', 404);
|
||||||
}
|
}
|
||||||
|
if (str_starts_with($user->username, 'guest_')) {
|
||||||
|
return $this->error('访客请在访客管理中操作', 422);
|
||||||
|
}
|
||||||
|
|
||||||
$roleSlug = Role::where('id', $user->role_id)->value('slug');
|
$roleSlug = Role::where('id', $user->role_id)->value('slug');
|
||||||
if ($roleSlug === 'super_admin') {
|
if ($roleSlug === 'super_admin') {
|
||||||
@@ -321,6 +330,9 @@ class Admin extends BaseApi
|
|||||||
if (!$user) {
|
if (!$user) {
|
||||||
return $this->error('用户不存在', 404);
|
return $this->error('用户不存在', 404);
|
||||||
}
|
}
|
||||||
|
if (str_starts_with($user->username, 'guest_')) {
|
||||||
|
return $this->error('访客请在访客管理中操作', 422);
|
||||||
|
}
|
||||||
|
|
||||||
User::where('id', $targetId)->update($data);
|
User::where('id', $targetId)->update($data);
|
||||||
|
|
||||||
@@ -331,6 +343,77 @@ class Admin extends BaseApi
|
|||||||
return $this->success(null, '更新成功');
|
return $this->success(null, '更新成功');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function guests()
|
||||||
|
{
|
||||||
|
$auth = $this->authUser();
|
||||||
|
AdminScopeService::requireAny($auth, ['menu:guests']);
|
||||||
|
|
||||||
|
$page = max(1, (int) $this->request->get('page', 1));
|
||||||
|
$limit = min(50, max(1, (int) $this->request->get('limit', 20)));
|
||||||
|
$status = trim((string) $this->request->get('status', ''));
|
||||||
|
$keyword = trim((string) $this->request->get('keyword', ''));
|
||||||
|
|
||||||
|
$query = User::alias('u')
|
||||||
|
->whereRaw('LEFT(u.username, 6) = ?', ['guest_'])
|
||||||
|
->field("u.id,u.username,u.nickname,u.status,u.created_at,u.last_login_at,
|
||||||
|
(SELECT COUNT(*) FROM conversations c WHERE c.user_id = u.id AND c.deleted_at IS NULL) AS conversation_count,
|
||||||
|
(SELECT COUNT(*) FROM messages msg INNER JOIN conversations c2 ON msg.conversation_id = c2.id WHERE c2.user_id = u.id) AS message_count")
|
||||||
|
->order('u.last_login_at', 'desc')
|
||||||
|
->order('u.id', 'desc');
|
||||||
|
|
||||||
|
AdminScopeService::applyUserScope($query, $auth, 'u');
|
||||||
|
if (in_array($status, ['active', 'disabled'], true)) {
|
||||||
|
$query->where('u.status', $status);
|
||||||
|
}
|
||||||
|
if ($keyword !== '') {
|
||||||
|
$query->where('u.username', 'like', '%' . $keyword . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = (clone $query)->count();
|
||||||
|
$list = $query->page($page, $limit)->select();
|
||||||
|
|
||||||
|
return $this->success(['list' => $list, 'total' => $total]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateGuestStatus($id)
|
||||||
|
{
|
||||||
|
$auth = $this->authUser();
|
||||||
|
AdminScopeService::requireAny($auth, ['btn:guest:status']);
|
||||||
|
|
||||||
|
$guest = User::find((int) $id);
|
||||||
|
if (!$guest || !str_starts_with($guest->username, 'guest_')) {
|
||||||
|
return $this->error('访客不存在', 404);
|
||||||
|
}
|
||||||
|
if (!AdminScopeService::canViewUser($auth, (int) $guest->id)) {
|
||||||
|
return $this->error('无权操作该访客', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = trim((string) $this->request->put('status', ''));
|
||||||
|
if (!in_array($status, ['active', 'disabled'], true)) {
|
||||||
|
return $this->error('访客状态无效', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$guest->save(['status' => $status]);
|
||||||
|
return $this->success(null, $status === 'active' ? '访客已启用' : '访客已禁用');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteGuest($id)
|
||||||
|
{
|
||||||
|
$auth = $this->authUser();
|
||||||
|
AdminScopeService::requireAny($auth, ['btn:guest:delete']);
|
||||||
|
|
||||||
|
$guest = User::find((int) $id);
|
||||||
|
if (!$guest || !str_starts_with($guest->username, 'guest_')) {
|
||||||
|
return $this->error('访客不存在', 404);
|
||||||
|
}
|
||||||
|
if (!AdminScopeService::canViewUser($auth, (int) $guest->id)) {
|
||||||
|
return $this->error('无权操作该访客', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
User::destroy((int) $guest->id);
|
||||||
|
return $this->success(null, '访客记录已删除');
|
||||||
|
}
|
||||||
|
|
||||||
public function conversations()
|
public function conversations()
|
||||||
{
|
{
|
||||||
AdminScopeService::requireAny($this->authUser(), [
|
AdminScopeService::requireAny($this->authUser(), [
|
||||||
@@ -556,6 +639,109 @@ class Admin extends BaseApi
|
|||||||
return $this->success($options);
|
return $this->success($options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function invitations()
|
||||||
|
{
|
||||||
|
AdminScopeService::requireAny($this->authUser(), [
|
||||||
|
'menu:invitations',
|
||||||
|
'btn:invitation:create',
|
||||||
|
'btn:invitation:revoke',
|
||||||
|
'can_manage_users',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$status = trim((string) $this->request->get('status', ''));
|
||||||
|
$query = InvitationCode::alias('i')
|
||||||
|
->leftJoin('departments d', 'i.department_id = d.id')
|
||||||
|
->leftJoin('users creator', 'i.created_by = creator.id')
|
||||||
|
->leftJoin('users used', 'i.used_by = used.id')
|
||||||
|
->field('i.*,d.name as department_name,creator.username as creator_name,used.username as used_by_name')
|
||||||
|
->order('i.created_at', 'desc')
|
||||||
|
->order('i.id', 'desc');
|
||||||
|
|
||||||
|
if (in_array($status, ['active', 'used', 'revoked'], true)) {
|
||||||
|
$query->where('i.status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success($query->limit(500)->select());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createInvitation()
|
||||||
|
{
|
||||||
|
$user = $this->authUser();
|
||||||
|
AdminScopeService::requireAny($user, [
|
||||||
|
'btn:invitation:create',
|
||||||
|
'menu:invitations',
|
||||||
|
'can_manage_users',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$input = $this->request->post();
|
||||||
|
$departmentId = $input['department_id'] ?? null;
|
||||||
|
if ($departmentId !== null && $departmentId !== '') {
|
||||||
|
$departmentId = (int) $departmentId;
|
||||||
|
if ($departmentId <= 0 || !Department::find($departmentId)) {
|
||||||
|
return $this->error('所选部门不存在', 422);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$departmentId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expiresAt = trim((string) ($input['expires_at'] ?? ''));
|
||||||
|
if ($expiresAt !== '') {
|
||||||
|
$expiresAt = str_replace('T', ' ', $expiresAt);
|
||||||
|
$expiresTimestamp = strtotime($expiresAt);
|
||||||
|
if (!$expiresTimestamp || $expiresTimestamp <= time()) {
|
||||||
|
return $this->error('过期时间必须晚于当前时间', 422);
|
||||||
|
}
|
||||||
|
$expiresAt = date('Y-m-d H:i:s', $expiresTimestamp);
|
||||||
|
} else {
|
||||||
|
$expiresAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
$code = 'INV-' . strtoupper(bin2hex(random_bytes(5)));
|
||||||
|
} while (InvitationCode::where('code', $code)->find());
|
||||||
|
|
||||||
|
$invitation = InvitationCode::create([
|
||||||
|
'code' => $code,
|
||||||
|
'department_id' => $departmentId,
|
||||||
|
'created_by' => (int) $user['id'],
|
||||||
|
'status' => 'active',
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'id' => (int) $invitation->id,
|
||||||
|
'code' => $invitation->code,
|
||||||
|
'department_id' => $departmentId,
|
||||||
|
'department_name' => $departmentId ? Department::where('id', $departmentId)->value('name') : null,
|
||||||
|
'status' => 'active',
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
'created_at' => $invitation->created_at,
|
||||||
|
], '邀请码已生成');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revokeInvitation($id)
|
||||||
|
{
|
||||||
|
AdminScopeService::requireAny($this->authUser(), [
|
||||||
|
'btn:invitation:revoke',
|
||||||
|
'menu:invitations',
|
||||||
|
'can_manage_users',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$invitation = InvitationCode::find((int) $id);
|
||||||
|
if (!$invitation) {
|
||||||
|
return $this->error('邀请码不存在', 404);
|
||||||
|
}
|
||||||
|
if ($invitation->status === 'used') {
|
||||||
|
return $this->error('已使用的邀请码不能作废', 422);
|
||||||
|
}
|
||||||
|
if ($invitation->status === 'revoked') {
|
||||||
|
return $this->success(null, '邀请码已作废');
|
||||||
|
}
|
||||||
|
|
||||||
|
$invitation->save(['status' => 'revoked']);
|
||||||
|
return $this->success(null, '邀请码已作废');
|
||||||
|
}
|
||||||
|
|
||||||
public function createDepartment()
|
public function createDepartment()
|
||||||
{
|
{
|
||||||
AdminScopeService::requireAny($this->authUser(), ['btn:dept:create', 'can_manage_departments']);
|
AdminScopeService::requireAny($this->authUser(), ['btn:dept:create', 'can_manage_departments']);
|
||||||
@@ -666,11 +852,87 @@ class Admin extends BaseApi
|
|||||||
AdminScopeService::requireAny($this->authUser(), ['btn:settings:save', 'can_manage_settings']);
|
AdminScopeService::requireAny($this->authUser(), ['btn:settings:save', 'can_manage_settings']);
|
||||||
$input = $this->request->put();
|
$input = $this->request->put();
|
||||||
foreach ($input as $key => $value) {
|
foreach ($input as $key => $value) {
|
||||||
|
if ($key === 'voice_persona') {
|
||||||
|
if (!is_array($value)) {
|
||||||
|
return $this->error('AI 客服人物配置格式无效', 422);
|
||||||
|
}
|
||||||
|
$value = CosyVoiceService::normalizePersona($value);
|
||||||
|
}
|
||||||
SettingsService::set($key, $value);
|
SettingsService::set($key, $value);
|
||||||
}
|
}
|
||||||
|
CosyVoiceService::clearFailure();
|
||||||
return $this->success(null, '设置已更新');
|
return $this->success(null, '设置已更新');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function uploadVoiceReference()
|
||||||
|
{
|
||||||
|
AdminScopeService::requireAny($this->authUser(), ['btn:settings:save', 'can_manage_settings']);
|
||||||
|
$file = $this->request->file('file');
|
||||||
|
if (!$file) {
|
||||||
|
return $this->error('请选择 WAV 参考音频', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$originalName = basename((string) $file->getOriginalName());
|
||||||
|
$extension = strtolower($file->extension() ?: pathinfo($originalName, PATHINFO_EXTENSION));
|
||||||
|
if ($extension !== 'wav') {
|
||||||
|
return $this->error('音色样本只支持 WAV 文件', 422);
|
||||||
|
}
|
||||||
|
if ((int) $file->getSize() > 15 * 1024 * 1024) {
|
||||||
|
return $this->error('音色样本不能超过 15MB', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$header = @file_get_contents($file->getPathname(), false, null, 0, 12);
|
||||||
|
if (!is_string($header) || strlen($header) < 12 || substr($header, 0, 4) !== 'RIFF' || substr($header, 8, 4) !== 'WAVE') {
|
||||||
|
return $this->error('文件不是有效的 WAV 音频', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetDir = root_path() . 'storage' . DIRECTORY_SEPARATOR . 'cosyvoice';
|
||||||
|
if (!is_dir($targetDir) && !mkdir($targetDir, 0755, true) && !is_dir($targetDir)) {
|
||||||
|
return $this->error('无法创建音色样本目录', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$storedName = 'voice-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.wav';
|
||||||
|
$moved = $file->move($targetDir, $storedName);
|
||||||
|
if (!$moved) {
|
||||||
|
return $this->error('音色样本保存失败', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $targetDir . DIRECTORY_SEPARATOR . $storedName;
|
||||||
|
$persona = CosyVoiceService::getPersona();
|
||||||
|
$persona['prompt_wav'] = $path;
|
||||||
|
$persona['prompt_wav_name'] = $originalName;
|
||||||
|
$persona = CosyVoiceService::normalizePersona($persona, $persona);
|
||||||
|
SettingsService::set('voice_persona', $persona);
|
||||||
|
CosyVoiceService::clearFailure($persona);
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'name' => $persona['prompt_wav_name'],
|
||||||
|
'path' => $persona['prompt_wav'],
|
||||||
|
], '音色样本上传成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function previewVoicePersona()
|
||||||
|
{
|
||||||
|
AdminScopeService::requireAny($this->authUser(), ['btn:settings:save', 'can_manage_settings']);
|
||||||
|
$text = trim((string) ($this->request->post('text') ?: '您好,我是您的 AI 客服,很高兴为您服务。'));
|
||||||
|
$text = mb_substr($text, 0, 160);
|
||||||
|
|
||||||
|
try {
|
||||||
|
CosyVoiceService::clearFailure();
|
||||||
|
$speech = CosyVoiceService::speech($text);
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
return $this->error($error->getMessage(), 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response($speech['audio'], 200, [
|
||||||
|
'Content-Type' => $speech['content_type'],
|
||||||
|
'Content-Length' => (string) strlen($speech['audio']),
|
||||||
|
'Cache-Control' => 'no-store',
|
||||||
|
'X-Content-Type-Options' => 'nosniff',
|
||||||
|
'X-TTS-Provider' => 'cosyvoice',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function models()
|
public function models()
|
||||||
{
|
{
|
||||||
AdminScopeService::requireAny($this->authUser(), ['menu:models', 'can_manage_models']);
|
AdminScopeService::requireAny($this->authUser(), ['menu:models', 'can_manage_models']);
|
||||||
@@ -872,6 +1134,9 @@ class Admin extends BaseApi
|
|||||||
'inpaint_seed_node',
|
'inpaint_seed_node',
|
||||||
'inpaint_image_node',
|
'inpaint_image_node',
|
||||||
'inpaint_mask_node',
|
'inpaint_mask_node',
|
||||||
|
'tts_model',
|
||||||
|
'tts_voice',
|
||||||
|
'tts_instructions',
|
||||||
] as $key) {
|
] as $key) {
|
||||||
if (!array_key_exists($key, $raw)) {
|
if (!array_key_exists($key, $raw)) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
namespace app\controller\api;
|
namespace app\controller\api;
|
||||||
|
|
||||||
use app\model\User;
|
use app\model\User;
|
||||||
|
use app\model\InvitationCode;
|
||||||
use app\model\MembershipLevel;
|
use app\model\MembershipLevel;
|
||||||
use app\model\Role;
|
use app\model\Role;
|
||||||
use app\service\JwtService;
|
use app\service\JwtService;
|
||||||
use app\service\SettingsService;
|
use app\service\SettingsService;
|
||||||
use app\service\UserContextService;
|
use app\service\UserContextService;
|
||||||
|
use think\exception\HttpResponseException;
|
||||||
|
use think\facade\Db;
|
||||||
|
|
||||||
class Auth extends BaseApi
|
class Auth extends BaseApi
|
||||||
{
|
{
|
||||||
@@ -51,6 +54,8 @@ class Auth extends BaseApi
|
|||||||
return $this->error('游客访问暂不可用', 403);
|
return $this->error('游客访问暂不可用', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$user->save(['last_login_at' => date('Y-m-d H:i:s')]);
|
||||||
|
|
||||||
$token = JwtService::generateToken(['user_id' => $user->id]);
|
$token = JwtService::generateToken(['user_id' => $user->id]);
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
@@ -70,6 +75,7 @@ class Auth extends BaseApi
|
|||||||
$username = trim($input['username'] ?? '');
|
$username = trim($input['username'] ?? '');
|
||||||
$email = trim($input['email'] ?? '');
|
$email = trim($input['email'] ?? '');
|
||||||
$password = $input['password'] ?? '';
|
$password = $input['password'] ?? '';
|
||||||
|
$invitationCode = strtoupper(preg_replace('/\s+/', '', trim((string) ($input['invitation_code'] ?? ''))));
|
||||||
|
|
||||||
if (strlen($username) < 3 || strlen($username) > 50) {
|
if (strlen($username) < 3 || strlen($username) > 50) {
|
||||||
return $this->error('用户名长度需 3-50 个字符');
|
return $this->error('用户名长度需 3-50 个字符');
|
||||||
@@ -80,21 +86,59 @@ class Auth extends BaseApi
|
|||||||
if (strlen($password) < 6) {
|
if (strlen($password) < 6) {
|
||||||
return $this->error('密码至少 6 位');
|
return $this->error('密码至少 6 位');
|
||||||
}
|
}
|
||||||
|
if ($invitationCode === '') {
|
||||||
|
return $this->error('请输入邀请码', 422);
|
||||||
|
}
|
||||||
|
|
||||||
if (User::where('username', $username)->whereOr('email', $email)->find()) {
|
if (User::where('username', $username)->whereOr('email', $email)->find()) {
|
||||||
return $this->error('用户名或邮箱已存在');
|
return $this->error('用户名或邮箱已存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
$defaultRoleId = \app\model\Role::where('slug', 'user')->value('id');
|
$defaultRoleId = Role::where('slug', 'user')->value('id');
|
||||||
|
$membershipId = MembershipLevel::where('slug', 'free')->value('id') ?: 1;
|
||||||
|
|
||||||
$user = User::create([
|
$user = Db::transaction(function () use (
|
||||||
'username' => $username,
|
$invitationCode,
|
||||||
'email' => $email,
|
$username,
|
||||||
'password_hash' => password_hash($password, PASSWORD_BCRYPT),
|
$email,
|
||||||
'nickname' => $username,
|
$password,
|
||||||
'membership_level_id' => 1,
|
$defaultRoleId,
|
||||||
'role_id' => $defaultRoleId ?: null,
|
$membershipId
|
||||||
]);
|
) {
|
||||||
|
$invitation = InvitationCode::where('code', $invitationCode)->lock(true)->find();
|
||||||
|
if (!$invitation) {
|
||||||
|
$this->abortRegistration('邀请码不存在', 422);
|
||||||
|
}
|
||||||
|
if ($invitation->status !== 'active') {
|
||||||
|
$this->abortRegistration($invitation->status === 'used' ? '邀请码已被使用' : '邀请码已作废', 422);
|
||||||
|
}
|
||||||
|
if ($invitation->expires_at && strtotime((string) $invitation->expires_at) <= time()) {
|
||||||
|
$this->abortRegistration('邀请码已过期', 422);
|
||||||
|
}
|
||||||
|
if (User::where('username', $username)->whereOr('email', $email)->find()) {
|
||||||
|
$this->abortRegistration('用户名或邮箱已存在', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::create([
|
||||||
|
'username' => $username,
|
||||||
|
'email' => $email,
|
||||||
|
'password_hash' => password_hash($password, PASSWORD_BCRYPT),
|
||||||
|
'nickname' => $username,
|
||||||
|
'role' => 'user',
|
||||||
|
'role_id' => $defaultRoleId ?: null,
|
||||||
|
'department_id' => $invitation->department_id ?: null,
|
||||||
|
'membership_level_id' => $membershipId,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$invitation->save([
|
||||||
|
'status' => 'used',
|
||||||
|
'used_by' => $user->id,
|
||||||
|
'used_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
});
|
||||||
|
|
||||||
$token = JwtService::generateToken(['user_id' => $user->id]);
|
$token = JwtService::generateToken(['user_id' => $user->id]);
|
||||||
|
|
||||||
@@ -157,4 +201,13 @@ class Auth extends BaseApi
|
|||||||
{
|
{
|
||||||
return UserContextService::formatPublicUser($userId);
|
return UserContextService::formatPublicUser($userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function abortRegistration(string $message, int $httpCode): never
|
||||||
|
{
|
||||||
|
throw new HttpResponseException(json([
|
||||||
|
'code' => 1,
|
||||||
|
'message' => $message,
|
||||||
|
'data' => null,
|
||||||
|
], $httpCode));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ use app\model\UploadFile;
|
|||||||
use app\service\AgentCatalog;
|
use app\service\AgentCatalog;
|
||||||
use app\service\ComfyJobDeferredException;
|
use app\service\ComfyJobDeferredException;
|
||||||
use app\service\ComfyUIService;
|
use app\service\ComfyUIService;
|
||||||
|
use app\service\CosyVoiceService;
|
||||||
use app\service\DifyService;
|
use app\service\DifyService;
|
||||||
use app\service\DocumentTextService;
|
use app\service\DocumentTextService;
|
||||||
|
use app\service\GuestAccessService;
|
||||||
use app\service\OpenAIService;
|
use app\service\OpenAIService;
|
||||||
use app\service\PermissionService;
|
use app\service\PermissionService;
|
||||||
use app\service\SettingsService;
|
use app\service\SettingsService;
|
||||||
@@ -18,6 +20,136 @@ use think\facade\Log;
|
|||||||
|
|
||||||
class Chat extends BaseApi
|
class Chat extends BaseApi
|
||||||
{
|
{
|
||||||
|
public function speech()
|
||||||
|
{
|
||||||
|
$user = $this->authUser();
|
||||||
|
GuestAccessService::assertAccountRequired($user, '语音对话');
|
||||||
|
$input = $this->request->post();
|
||||||
|
$text = trim((string) ($input['text'] ?? ''));
|
||||||
|
|
||||||
|
if ($text === '') {
|
||||||
|
return $this->error('语音内容不能为空', 422);
|
||||||
|
}
|
||||||
|
if (mb_strlen($text) > 600) {
|
||||||
|
return $this->error('单次语音内容不能超过 600 个字符', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$modelId = isset($input['model_id']) && $input['model_id'] !== ''
|
||||||
|
? (int) $input['model_id']
|
||||||
|
: null;
|
||||||
|
$persona = CosyVoiceService::getPersona();
|
||||||
|
$voice = trim((string) ($persona['fallback_voice'] ?? $input['voice'] ?? 'marin'));
|
||||||
|
$allowedVoices = [
|
||||||
|
'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova',
|
||||||
|
'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar',
|
||||||
|
];
|
||||||
|
if (!in_array($voice, $allowedVoices, true)) {
|
||||||
|
$voice = 'marin';
|
||||||
|
}
|
||||||
|
|
||||||
|
$speech = null;
|
||||||
|
if (CosyVoiceService::canAttempt()) {
|
||||||
|
try {
|
||||||
|
$speech = CosyVoiceService::speech($text);
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
Log::warning('CosyVoice speech fallback: ' . $error->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$speech) {
|
||||||
|
$model = OpenAIService::getSpeechModel($modelId);
|
||||||
|
$speech = OpenAIService::speech($model, $text, $voice);
|
||||||
|
$speech['provider'] = 'openai';
|
||||||
|
}
|
||||||
|
|
||||||
|
return response($speech['audio'], 200, [
|
||||||
|
'Content-Type' => $speech['content_type'],
|
||||||
|
'Content-Length' => (string) strlen($speech['audio']),
|
||||||
|
'Cache-Control' => 'no-store',
|
||||||
|
'X-Content-Type-Options' => 'nosniff',
|
||||||
|
'X-TTS-Provider' => $speech['provider'] ?? 'unknown',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function speechStream(): never
|
||||||
|
{
|
||||||
|
$user = $this->authUser();
|
||||||
|
GuestAccessService::assertAccountRequired($user, '语音对话');
|
||||||
|
$input = $this->request->post();
|
||||||
|
$text = trim((string) ($input['text'] ?? ''));
|
||||||
|
$requestId = trim((string) ($input['request_id'] ?? ''));
|
||||||
|
if (!preg_match('/^[A-Za-z0-9._:-]{8,128}$/', $requestId)) {
|
||||||
|
$requestId = bin2hex(random_bytes(16));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($text === '' || mb_strlen($text) > 600 || !CosyVoiceService::canAttempt()) {
|
||||||
|
http_response_code($text === '' || mb_strlen($text) > 600 ? 422 : 503);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode([
|
||||||
|
'code' => 1,
|
||||||
|
'message' => $text === ''
|
||||||
|
? '语音内容不能为空'
|
||||||
|
: (mb_strlen($text) > 600 ? '单次语音内容不能超过 600 个字符' : 'CosyVoice 暂时不可用'),
|
||||||
|
'data' => null,
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (ob_get_level() > 0) {
|
||||||
|
ob_end_clean();
|
||||||
|
}
|
||||||
|
@ini_set('zlib.output_compression', '0');
|
||||||
|
ignore_user_abort(false);
|
||||||
|
OpenAIService::sseHeaders();
|
||||||
|
|
||||||
|
$persona = CosyVoiceService::getPersona();
|
||||||
|
OpenAIService::sseEvent('meta', [
|
||||||
|
'provider' => 'cosyvoice',
|
||||||
|
'request_id' => $requestId,
|
||||||
|
'cancel_url' => rtrim((string) ($persona['base_url'] ?? ''), '/')
|
||||||
|
. '/cancel/' . rawurlencode($requestId),
|
||||||
|
'format' => 'pcm_s16le',
|
||||||
|
'sample_rate' => (int) ($persona['sample_rate'] ?? 24000),
|
||||||
|
'channels' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = CosyVoiceService::streamSpeech($text, static function (string $pcm): void {
|
||||||
|
OpenAIService::sseEvent('audio', [
|
||||||
|
'audio' => base64_encode($pcm),
|
||||||
|
]);
|
||||||
|
}, $requestId);
|
||||||
|
|
||||||
|
if (empty($result['aborted'])) {
|
||||||
|
OpenAIService::sseEvent('done', [
|
||||||
|
'bytes' => (int) ($result['bytes'] ?? 0),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
Log::warning('CosyVoice stream failed: ' . $error->getMessage());
|
||||||
|
OpenAIService::sseEvent('error', [
|
||||||
|
'message' => $error->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function speechCancel()
|
||||||
|
{
|
||||||
|
$user = $this->authUser();
|
||||||
|
GuestAccessService::assertAccountRequired($user, '语音对话');
|
||||||
|
$requestId = trim((string) $this->request->post('request_id', ''));
|
||||||
|
if (!preg_match('/^[A-Za-z0-9._:-]{8,128}$/', $requestId)) {
|
||||||
|
return $this->error('语音请求标识无效', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'cancelled' => CosyVoiceService::cancelSpeech($requestId),
|
||||||
|
'request_id' => $requestId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function completions()
|
public function completions()
|
||||||
{
|
{
|
||||||
$user = $this->authUser();
|
$user = $this->authUser();
|
||||||
@@ -29,6 +161,7 @@ class Chat extends BaseApi
|
|||||||
$attachments = $input['attachments'] ?? [];
|
$attachments = $input['attachments'] ?? [];
|
||||||
$agentId = trim((string) ($input['agent_id'] ?? ''));
|
$agentId = trim((string) ($input['agent_id'] ?? ''));
|
||||||
$imageTool = trim((string) ($input['image_tool'] ?? ''));
|
$imageTool = trim((string) ($input['image_tool'] ?? ''));
|
||||||
|
$voiceMode = !empty($input['voice_mode']);
|
||||||
$stream = ($input['stream'] ?? true) !== false;
|
$stream = ($input['stream'] ?? true) !== false;
|
||||||
|
|
||||||
$allowedImageTools = ['enhance', 'erase', 'watermark', 'cutout', 'outpaint', 'replace', 'text', 'restore', 'creative', 'commit'];
|
$allowedImageTools = ['enhance', 'erase', 'watermark', 'cutout', 'outpaint', 'replace', 'text', 'restore', 'creative', 'commit'];
|
||||||
@@ -39,6 +172,7 @@ class Chat extends BaseApi
|
|||||||
if (!is_array($attachments)) {
|
if (!is_array($attachments)) {
|
||||||
return $this->error('附件格式无效', 422);
|
return $this->error('附件格式无效', 422);
|
||||||
}
|
}
|
||||||
|
GuestAccessService::assertTextChatOnly($user, $attachments, $agentId, $imageTool, $voiceMode);
|
||||||
|
|
||||||
if (!$conversationId) {
|
if (!$conversationId) {
|
||||||
return $this->error('缺少 conversation_id');
|
return $this->error('缺少 conversation_id');
|
||||||
@@ -49,10 +183,6 @@ class Chat extends BaseApi
|
|||||||
if ($imageTool !== '' && !$this->hasImageAttachments($attachments)) {
|
if ($imageTool !== '' && !$this->hasImageAttachments($attachments)) {
|
||||||
return $this->error('图片处理工具需要一张原图', 422);
|
return $this->error('图片处理工具需要一张原图', 422);
|
||||||
}
|
}
|
||||||
if (!empty($user['is_guest']) && $this->hasImageAttachments($attachments)) {
|
|
||||||
return $this->error('游客模式不支持发送图片,请登录后重试', 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$agent = AgentCatalog::find($agentId);
|
$agent = AgentCatalog::find($agentId);
|
||||||
if ($agentId !== '' && !$agent) {
|
if ($agentId !== '' && !$agent) {
|
||||||
return $this->error('所选 Agent 不存在或已停用', 422);
|
return $this->error('所选 Agent 不存在或已停用', 422);
|
||||||
@@ -68,7 +198,17 @@ class Chat extends BaseApi
|
|||||||
}
|
}
|
||||||
|
|
||||||
$imageGenerationContent = $content;
|
$imageGenerationContent = $content;
|
||||||
$model = OpenAIService::getModel($conversation->model_id ? (int) $conversation->model_id : null);
|
if (GuestAccessService::isGuest($user)) {
|
||||||
|
$model = GuestAccessService::model();
|
||||||
|
if ((int) $conversation->model_id !== (int) $model->id) {
|
||||||
|
$conversation->save([
|
||||||
|
'model_id' => (int) $model->id,
|
||||||
|
'external_conversation_id' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$model = OpenAIService::getModel($conversation->model_id ? (int) $conversation->model_id : null);
|
||||||
|
}
|
||||||
if ($imageTool !== '' && $imageTool !== 'commit') {
|
if ($imageTool !== '' && $imageTool !== 'commit') {
|
||||||
$preferredImageModelId = ($model->provider ?? '') === 'comfy' ? (int) ($model->id ?? 0) : null;
|
$preferredImageModelId = ($model->provider ?? '') === 'comfy' ? (int) ($model->id ?? 0) : null;
|
||||||
$model = OpenAIService::getImageModel($preferredImageModelId ?: null);
|
$model = OpenAIService::getImageModel($preferredImageModelId ?: null);
|
||||||
@@ -153,7 +293,7 @@ class Chat extends BaseApi
|
|||||||
$history = Message::where('conversation_id', $conversationId)
|
$history = Message::where('conversation_id', $conversationId)
|
||||||
->field('role,content,attachments')
|
->field('role,content,attachments')
|
||||||
->order('id', 'desc')
|
->order('id', 'desc')
|
||||||
->limit(50)
|
->limit($voiceMode ? 16 : 50)
|
||||||
->select()
|
->select()
|
||||||
->toArray();
|
->toArray();
|
||||||
$history = array_reverse($history);
|
$history = array_reverse($history);
|
||||||
@@ -167,6 +307,16 @@ class Chat extends BaseApi
|
|||||||
}
|
}
|
||||||
|
|
||||||
$apiMessages = $this->buildApiMessages($history, $model);
|
$apiMessages = $this->buildApiMessages($history, $model);
|
||||||
|
if ($voiceMode) {
|
||||||
|
$voicePersona = CosyVoiceService::getPersona();
|
||||||
|
$personaName = trim((string) ($voicePersona['name'] ?? 'AI 客服')) ?: 'AI 客服';
|
||||||
|
$personaPrompt = trim((string) ($voicePersona['role_prompt'] ?? ''));
|
||||||
|
array_unshift($apiMessages, [
|
||||||
|
'role' => 'system',
|
||||||
|
'content' => '你是名为“' . $personaName . '”的 AI 客服。人物设定:' . $personaPrompt
|
||||||
|
. ' 当前正在进行低延迟实时语音对话。像真人客服一样先回应用户的真实诉求,语气口语化、有耐心、有适度共情,不复述问题,不使用 Markdown 列表,不说“作为 AI”。先给结论,通常控制在 1 到 3 句;信息不足时每轮只追问一个最关键的问题,除非用户明确要求详细说明。',
|
||||||
|
]);
|
||||||
|
}
|
||||||
$agentImageActionAllowed = false;
|
$agentImageActionAllowed = false;
|
||||||
if ($agent) {
|
if ($agent) {
|
||||||
$agentSystemPrompt = $agent['system_prompt'];
|
$agentSystemPrompt = $agent['system_prompt'];
|
||||||
@@ -1590,6 +1740,12 @@ class Chat extends BaseApi
|
|||||||
},
|
},
|
||||||
function (string $message) use (&$streamError) {
|
function (string $message) use (&$streamError) {
|
||||||
$streamError = $message;
|
$streamError = $message;
|
||||||
|
},
|
||||||
|
function () use ($conversationId, &$resolvedExternalConversationId) {
|
||||||
|
$resolvedExternalConversationId = null;
|
||||||
|
ConversationModel::where('id', $conversationId)->update([
|
||||||
|
'external_conversation_id' => null,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1723,6 +1879,10 @@ class Chat extends BaseApi
|
|||||||
ConversationModel::where('id', $conversationId)->update([
|
ConversationModel::where('id', $conversationId)->update([
|
||||||
'external_conversation_id' => $resolvedExternalConversationId,
|
'external_conversation_id' => $resolvedExternalConversationId,
|
||||||
]);
|
]);
|
||||||
|
} elseif (!empty($result['conversation_reset'])) {
|
||||||
|
ConversationModel::where('id', $conversationId)->update([
|
||||||
|
'external_conversation_id' => null,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$imageAction = $agent
|
$imageAction = $agent
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use app\model\AiModel;
|
|||||||
use app\model\Conversation as ConversationModel;
|
use app\model\Conversation as ConversationModel;
|
||||||
use app\model\Message;
|
use app\model\Message;
|
||||||
use app\service\ComfyUIService;
|
use app\service\ComfyUIService;
|
||||||
|
use app\service\GuestAccessService;
|
||||||
use app\service\PermissionService;
|
use app\service\PermissionService;
|
||||||
use think\exception\HttpResponseException;
|
use think\exception\HttpResponseException;
|
||||||
|
|
||||||
@@ -14,6 +15,12 @@ class Conversation extends BaseApi
|
|||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$user = $this->authUser();
|
$user = $this->authUser();
|
||||||
|
if (GuestAccessService::isGuest($user)) {
|
||||||
|
$guestModelId = GuestAccessService::modelId();
|
||||||
|
$reset = ['model_id' => $guestModelId, 'external_conversation_id' => null];
|
||||||
|
ConversationModel::where('user_id', $user['id'])->whereNull('deleted_at')->whereNull('model_id')->update($reset);
|
||||||
|
ConversationModel::where('user_id', $user['id'])->whereNull('deleted_at')->where('model_id', '<>', $guestModelId)->update($reset);
|
||||||
|
}
|
||||||
$page = max(1, (int) $this->request->get('page', 1));
|
$page = max(1, (int) $this->request->get('page', 1));
|
||||||
$limit = min(50, max(1, (int) $this->request->get('limit', 20)));
|
$limit = min(50, max(1, (int) $this->request->get('limit', 20)));
|
||||||
|
|
||||||
@@ -51,6 +58,12 @@ class Conversation extends BaseApi
|
|||||||
$modelId = null;
|
$modelId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (GuestAccessService::isGuest($user)) {
|
||||||
|
$modelId = GuestAccessService::assertModelAllowed($user, $modelId);
|
||||||
|
} elseif ($modelId && !AiModel::where('id', $modelId)->where('enabled', 1)->find()) {
|
||||||
|
return $this->error('所选模型不存在或已停用', 422);
|
||||||
|
}
|
||||||
|
|
||||||
$conversation = ConversationModel::create([
|
$conversation = ConversationModel::create([
|
||||||
'user_id' => $user['id'],
|
'user_id' => $user['id'],
|
||||||
'title' => trim($input['title'] ?? '新对话'),
|
'title' => trim($input['title'] ?? '新对话'),
|
||||||
@@ -93,6 +106,29 @@ class Conversation extends BaseApi
|
|||||||
} else {
|
} else {
|
||||||
$data['model_id'] = (int) $mid;
|
$data['model_id'] = (int) $mid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (GuestAccessService::isGuest($user)) {
|
||||||
|
$requestedModelId = $data['model_id'] ?: null;
|
||||||
|
$data['model_id'] = GuestAccessService::assertModelAllowed($user, $requestedModelId);
|
||||||
|
} elseif ($data['model_id'] && !AiModel::where('id', $data['model_id'])->where('enabled', 1)->find()) {
|
||||||
|
return $this->error('所选模型不存在或已停用', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentModelId = $conversation->model_id === null
|
||||||
|
? null
|
||||||
|
: (int) $conversation->model_id;
|
||||||
|
if ($data['model_id'] !== $currentModelId) {
|
||||||
|
// Dify conversation_id 只属于创建它的应用/模型;切换模型后不可复用。
|
||||||
|
$data['external_conversation_id'] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (GuestAccessService::isGuest($user) && !array_key_exists('model_id', $input)) {
|
||||||
|
$guestModelId = GuestAccessService::modelId();
|
||||||
|
if ((int) $conversation->model_id !== $guestModelId) {
|
||||||
|
$data['model_id'] = $guestModelId;
|
||||||
|
$data['external_conversation_id'] = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($data)) {
|
if (empty($data)) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ namespace app\controller\api;
|
|||||||
|
|
||||||
use app\model\AiModel;
|
use app\model\AiModel;
|
||||||
use app\service\AgentCatalog;
|
use app\service\AgentCatalog;
|
||||||
|
use app\service\CosyVoiceService;
|
||||||
|
use app\service\GuestAccessService;
|
||||||
use app\service\SettingsService;
|
use app\service\SettingsService;
|
||||||
|
|
||||||
class Settings extends BaseApi
|
class Settings extends BaseApi
|
||||||
@@ -19,12 +21,28 @@ class Settings extends BaseApi
|
|||||||
return $this->success([
|
return $this->success([
|
||||||
'site_name' => SettingsService::get('site_name', 'AI Chat'),
|
'site_name' => SettingsService::get('site_name', 'AI Chat'),
|
||||||
'allow_register' => $allow === true || $allow === 'true',
|
'allow_register' => $allow === true || $allow === 'true',
|
||||||
|
'registration_requires_invite' => true,
|
||||||
'features' => SettingsService::getFeatures(),
|
'features' => SettingsService::getFeatures(),
|
||||||
|
'voice_persona' => CosyVoiceService::publicPersona(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function models()
|
public function models()
|
||||||
{
|
{
|
||||||
|
$user = $this->authUser();
|
||||||
|
if (GuestAccessService::isGuest($user)) {
|
||||||
|
$model = GuestAccessService::model();
|
||||||
|
return $this->success([[
|
||||||
|
'id' => (int) $model->id,
|
||||||
|
'name' => $model->name,
|
||||||
|
'provider' => $model->provider,
|
||||||
|
'model_id' => $model->model_id,
|
||||||
|
'is_default' => 1,
|
||||||
|
'support_context' => (int) $model->support_context,
|
||||||
|
'support_image' => 0,
|
||||||
|
]]);
|
||||||
|
}
|
||||||
|
|
||||||
$list = AiModel::where('enabled', 1)
|
$list = AiModel::where('enabled', 1)
|
||||||
->field('id,name,provider,model_id,is_default,support_context,support_image')
|
->field('id,name,provider,model_id,is_default,support_context,support_image')
|
||||||
->order('sort_order,id')
|
->order('sort_order,id')
|
||||||
@@ -35,6 +53,10 @@ class Settings extends BaseApi
|
|||||||
|
|
||||||
public function agents()
|
public function agents()
|
||||||
{
|
{
|
||||||
|
if (GuestAccessService::isGuest($this->authUser())) {
|
||||||
|
return $this->success([]);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->success(AgentCatalog::publicList());
|
return $this->success(AgentCatalog::publicList());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -118,12 +118,46 @@ class Upload extends BaseApi
|
|||||||
}
|
}
|
||||||
|
|
||||||
$mime = $upload->mime_type ?: (@mime_content_type($path) ?: 'application/octet-stream');
|
$mime = $upload->mime_type ?: (@mime_content_type($path) ?: 'application/octet-stream');
|
||||||
|
$size = (int) filesize($path);
|
||||||
return response(file_get_contents($path), 200, [
|
$headers = [
|
||||||
'Content-Type' => $mime,
|
'Content-Type' => $mime,
|
||||||
'Content-Length' => (string) filesize($path),
|
'Content-Length' => (string) $size,
|
||||||
|
'Accept-Ranges' => 'bytes',
|
||||||
'Cache-Control' => 'public, max-age=604800',
|
'Cache-Control' => 'public, max-age=604800',
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
$range = trim((string) $this->request->header('range', ''));
|
||||||
|
if ($range !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', $range, $matches)) {
|
||||||
|
$start = $matches[1] === '' ? 0 : (int) $matches[1];
|
||||||
|
$end = $matches[2] === '' ? $size - 1 : (int) $matches[2];
|
||||||
|
if ($matches[1] === '' && $matches[2] !== '') {
|
||||||
|
$length = min($size, (int) $matches[2]);
|
||||||
|
$start = $size - $length;
|
||||||
|
$end = $size - 1;
|
||||||
|
}
|
||||||
|
if ($start < 0 || $start >= $size || $end < $start) {
|
||||||
|
return response('', 416, [
|
||||||
|
'Content-Range' => 'bytes */' . $size,
|
||||||
|
'Accept-Ranges' => 'bytes',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$end = min($end, $size - 1);
|
||||||
|
$length = $end - $start + 1;
|
||||||
|
$handle = fopen($path, 'rb');
|
||||||
|
if ($handle === false || fseek($handle, $start) !== 0) {
|
||||||
|
if (is_resource($handle)) {
|
||||||
|
fclose($handle);
|
||||||
|
}
|
||||||
|
throw new HttpResponseException(response('文件读取失败', 500));
|
||||||
|
}
|
||||||
|
$content = (string) fread($handle, $length);
|
||||||
|
fclose($handle);
|
||||||
|
$headers['Content-Length'] = (string) strlen($content);
|
||||||
|
$headers['Content-Range'] = "bytes {$start}-{$end}/{$size}";
|
||||||
|
return response($content, 206, $headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(file_get_contents($path), 200, $headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\model;
|
||||||
|
|
||||||
|
use think\Model;
|
||||||
|
|
||||||
|
class InvitationCode extends Model
|
||||||
|
{
|
||||||
|
protected $name = 'invitation_codes';
|
||||||
|
protected $autoWriteTimestamp = true;
|
||||||
|
protected $createTime = 'created_at';
|
||||||
|
protected $updateTime = 'updated_at';
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\model;
|
||||||
|
|
||||||
|
use think\Model;
|
||||||
|
|
||||||
|
class VideoCharacter extends Model
|
||||||
|
{
|
||||||
|
protected $name = 'video_characters';
|
||||||
|
protected $autoWriteTimestamp = true;
|
||||||
|
protected $createTime = 'created_at';
|
||||||
|
protected $updateTime = 'updated_at';
|
||||||
|
|
||||||
|
protected $type = [
|
||||||
|
'is_locked' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\model;
|
||||||
|
|
||||||
|
use think\Model;
|
||||||
|
|
||||||
|
class VideoEpisode extends Model
|
||||||
|
{
|
||||||
|
protected $name = 'video_episodes';
|
||||||
|
protected $autoWriteTimestamp = true;
|
||||||
|
protected $createTime = 'created_at';
|
||||||
|
protected $updateTime = 'updated_at';
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\model;
|
||||||
|
|
||||||
|
use think\Model;
|
||||||
|
|
||||||
|
class VideoProject extends Model
|
||||||
|
{
|
||||||
|
protected $name = 'video_projects';
|
||||||
|
protected $autoWriteTimestamp = true;
|
||||||
|
protected $createTime = 'created_at';
|
||||||
|
protected $updateTime = 'updated_at';
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\model;
|
||||||
|
|
||||||
|
use think\Model;
|
||||||
|
|
||||||
|
class VideoShot extends Model
|
||||||
|
{
|
||||||
|
protected $name = 'video_shots';
|
||||||
|
protected $autoWriteTimestamp = true;
|
||||||
|
protected $createTime = 'created_at';
|
||||||
|
protected $updateTime = 'updated_at';
|
||||||
|
|
||||||
|
protected $type = [
|
||||||
|
'meta' => 'json',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\service;
|
||||||
|
|
||||||
|
use think\facade\Cache;
|
||||||
|
|
||||||
|
class CosyVoiceService
|
||||||
|
{
|
||||||
|
private const MODES = ['sft', 'instruct', 'zero_shot', 'cross_lingual', 'instruct2'];
|
||||||
|
private const FALLBACK_VOICES = [
|
||||||
|
'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova',
|
||||||
|
'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar',
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function getPersona(): array
|
||||||
|
{
|
||||||
|
$defaults = [
|
||||||
|
'enabled' => (bool) config('cosyvoice.enabled', true),
|
||||||
|
'name' => '小暖',
|
||||||
|
'greeting' => '您好,我是 AI 客服小暖,请问有什么可以帮您?',
|
||||||
|
'role_prompt' => '温暖、专业、耐心,像经验丰富的真人客服一样理解用户的真实诉求。',
|
||||||
|
'base_url' => (string) config('cosyvoice.base_url', 'http://127.0.0.1:50000'),
|
||||||
|
'mode' => (string) config('cosyvoice.mode', 'sft'),
|
||||||
|
'speaker' => (string) config('cosyvoice.speaker', '中文女'),
|
||||||
|
'instruct_text' => (string) config('cosyvoice.instruct_text', ''),
|
||||||
|
'prompt_text' => (string) config('cosyvoice.prompt_text', ''),
|
||||||
|
'prompt_wav' => (string) config('cosyvoice.prompt_wav', ''),
|
||||||
|
'prompt_wav_name' => '',
|
||||||
|
'sample_rate' => (int) config('cosyvoice.sample_rate', 22050),
|
||||||
|
'fallback_voice' => 'marin',
|
||||||
|
'connect_timeout_ms' => (int) config('cosyvoice.connect_timeout_ms', 800),
|
||||||
|
'timeout_seconds' => (int) config('cosyvoice.timeout_seconds', 8),
|
||||||
|
'failure_ttl' => (int) config('cosyvoice.failure_ttl', 20),
|
||||||
|
];
|
||||||
|
$stored = SettingsService::get('voice_persona', []);
|
||||||
|
|
||||||
|
return array_merge($defaults, is_array($stored) ? $stored : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function publicPersona(): array
|
||||||
|
{
|
||||||
|
$persona = self::getPersona();
|
||||||
|
return [
|
||||||
|
'name' => $persona['name'],
|
||||||
|
'greeting' => $persona['greeting'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function normalizePersona(array $input, ?array $current = null): array
|
||||||
|
{
|
||||||
|
$current ??= self::getPersona();
|
||||||
|
$mode = trim((string) ($input['mode'] ?? $current['mode'] ?? 'sft'));
|
||||||
|
if (!in_array($mode, self::MODES, true)) {
|
||||||
|
$mode = 'sft';
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseUrl = rtrim(trim((string) ($input['base_url'] ?? $current['base_url'] ?? '')), '/');
|
||||||
|
if ($baseUrl !== '' && !preg_match('#^https?://#i', $baseUrl)) {
|
||||||
|
$baseUrl = (string) ($current['base_url'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$fallbackVoice = trim((string) ($input['fallback_voice'] ?? $current['fallback_voice'] ?? 'marin'));
|
||||||
|
if (!in_array($fallbackVoice, self::FALLBACK_VOICES, true)) {
|
||||||
|
$fallbackVoice = 'marin';
|
||||||
|
}
|
||||||
|
|
||||||
|
$promptWav = self::safePromptPath((string) ($input['prompt_wav'] ?? $current['prompt_wav'] ?? ''));
|
||||||
|
$sampleRate = (int) ($input['sample_rate'] ?? $current['sample_rate'] ?? 22050);
|
||||||
|
if (!in_array($sampleRate, [16000, 22050, 24000, 44100, 48000], true)) {
|
||||||
|
$sampleRate = 22050;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'enabled' => filter_var($input['enabled'] ?? $current['enabled'] ?? true, FILTER_VALIDATE_BOOLEAN),
|
||||||
|
'name' => self::limitedText($input['name'] ?? $current['name'] ?? '小暖', 40),
|
||||||
|
'greeting' => self::limitedText($input['greeting'] ?? $current['greeting'] ?? '', 200),
|
||||||
|
'role_prompt' => self::limitedText($input['role_prompt'] ?? $current['role_prompt'] ?? '', 2000),
|
||||||
|
'base_url' => $baseUrl,
|
||||||
|
'mode' => $mode,
|
||||||
|
'speaker' => self::limitedText($input['speaker'] ?? $current['speaker'] ?? '中文女', 80),
|
||||||
|
'instruct_text' => self::limitedText($input['instruct_text'] ?? $current['instruct_text'] ?? '', 1000),
|
||||||
|
'prompt_text' => self::limitedText($input['prompt_text'] ?? $current['prompt_text'] ?? '', 1500),
|
||||||
|
'prompt_wav' => $promptWav,
|
||||||
|
'prompt_wav_name' => self::limitedText($input['prompt_wav_name'] ?? $current['prompt_wav_name'] ?? '', 180),
|
||||||
|
'sample_rate' => $sampleRate,
|
||||||
|
'fallback_voice' => $fallbackVoice,
|
||||||
|
'connect_timeout_ms' => max(200, min(5000, (int) ($input['connect_timeout_ms'] ?? 800))),
|
||||||
|
'timeout_seconds' => max(2, min(60, (int) ($input['timeout_seconds'] ?? 8))),
|
||||||
|
'failure_ttl' => max(5, min(300, (int) ($input['failure_ttl'] ?? 20))),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isEnabled(): bool
|
||||||
|
{
|
||||||
|
$persona = self::getPersona();
|
||||||
|
return !empty($persona['enabled']) && trim((string) $persona['base_url']) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canAttempt(): bool
|
||||||
|
{
|
||||||
|
if (!self::isEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !Cache::get(self::failureCacheKey(self::getPersona()), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function clearFailure(?array $persona = null): void
|
||||||
|
{
|
||||||
|
Cache::delete(self::failureCacheKey($persona ?? self::getPersona()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用官方 FastAPI 服务。上游返回裸 PCM16 流,这里封装为浏览器可播放的 WAV。
|
||||||
|
*
|
||||||
|
* @return array{audio: string, content_type: string, provider: string}
|
||||||
|
*/
|
||||||
|
public static function speech(string $text): array
|
||||||
|
{
|
||||||
|
$persona = self::getPersona();
|
||||||
|
if (empty($persona['enabled']) || trim((string) $persona['base_url']) === '') {
|
||||||
|
throw new \RuntimeException('CosyVoice 未启用');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mode = trim((string) $persona['mode']);
|
||||||
|
if (!in_array($mode, self::MODES, true)) {
|
||||||
|
throw new \RuntimeException('CosyVoice 模式无效: ' . $mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields = self::requestFields($mode, $text, $persona);
|
||||||
|
$url = rtrim((string) $persona['base_url'], '/') . '/inference_' . $mode;
|
||||||
|
$headers = ['Accept: application/octet-stream'];
|
||||||
|
$apiKey = trim((string) config('cosyvoice.api_key', ''));
|
||||||
|
if ($apiKey !== '') {
|
||||||
|
$headers[] = 'Authorization: Bearer ' . $apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $fields,
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CONNECTTIMEOUT_MS => (int) $persona['connect_timeout_ms'],
|
||||||
|
CURLOPT_TIMEOUT => (int) $persona['timeout_seconds'],
|
||||||
|
CURLOPT_SSL_VERIFYPEER => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pcm = curl_exec($ch);
|
||||||
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$contentType = strtolower((string) (curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: ''));
|
||||||
|
$curlError = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($pcm === false) {
|
||||||
|
self::rememberFailure($persona);
|
||||||
|
throw new \RuntimeException('CosyVoice 连接失败: ' . ($curlError ?: '网络不可达'));
|
||||||
|
}
|
||||||
|
if ($httpCode < 200 || $httpCode >= 300) {
|
||||||
|
self::rememberFailure($persona);
|
||||||
|
$detail = self::errorDetail($pcm);
|
||||||
|
throw new \RuntimeException('CosyVoice 返回 HTTP ' . $httpCode . ($detail ? ': ' . $detail : ''));
|
||||||
|
}
|
||||||
|
if (str_contains($contentType, 'json')) {
|
||||||
|
self::rememberFailure($persona);
|
||||||
|
throw new \RuntimeException('CosyVoice 返回了错误响应: ' . (self::errorDetail($pcm) ?: '未知错误'));
|
||||||
|
}
|
||||||
|
if (strlen($pcm) < 2) {
|
||||||
|
self::rememberFailure($persona);
|
||||||
|
throw new \RuntimeException('CosyVoice 返回了空音频');
|
||||||
|
}
|
||||||
|
|
||||||
|
// PCM16 每个采样占两个字节;丢弃异常的尾部半个采样。
|
||||||
|
if (strlen($pcm) % 2 !== 0) {
|
||||||
|
$pcm = substr($pcm, 0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Cache::delete(self::failureCacheKey($persona));
|
||||||
|
return [
|
||||||
|
'audio' => self::pcm16ToWav($pcm, (int) $persona['sample_rate']),
|
||||||
|
'content_type' => 'audio/wav',
|
||||||
|
'provider' => 'cosyvoice',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 CosyVoice 上游产生的 PCM16 数据块原样向下游推送,避免等待整段音频生成完成。
|
||||||
|
*
|
||||||
|
* @return array{bytes: int, sample_rate: int, provider: string, aborted: bool}
|
||||||
|
*/
|
||||||
|
public static function streamSpeech(string $text, callable $onChunk, string $requestId = ''): array
|
||||||
|
{
|
||||||
|
$persona = self::getPersona();
|
||||||
|
if (empty($persona['enabled']) || trim((string) $persona['base_url']) === '') {
|
||||||
|
throw new \RuntimeException('CosyVoice 未启用');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mode = trim((string) $persona['mode']);
|
||||||
|
if (!in_array($mode, self::MODES, true)) {
|
||||||
|
throw new \RuntimeException('CosyVoice 模式无效: ' . $mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields = self::requestFields($mode, $text, $persona);
|
||||||
|
if ($requestId !== '') {
|
||||||
|
$fields['request_id'] = $requestId;
|
||||||
|
}
|
||||||
|
$url = rtrim((string) $persona['base_url'], '/') . '/inference_' . $mode;
|
||||||
|
$headers = ['Accept: application/octet-stream'];
|
||||||
|
$apiKey = trim((string) config('cosyvoice.api_key', ''));
|
||||||
|
if ($apiKey !== '') {
|
||||||
|
$headers[] = 'Authorization: Bearer ' . $apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
$httpCode = 0;
|
||||||
|
$contentType = '';
|
||||||
|
$errorBody = '';
|
||||||
|
$bytes = 0;
|
||||||
|
$aborted = false;
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $fields,
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_RETURNTRANSFER => false,
|
||||||
|
CURLOPT_HEADERFUNCTION => function ($ch, string $header) use (&$httpCode, &$contentType): int {
|
||||||
|
if (preg_match('/^HTTP\/\d+(?:\.\d+)?\s+(\d+)/i', trim($header), $matches)) {
|
||||||
|
$httpCode = (int) $matches[1];
|
||||||
|
} elseif (stripos($header, 'Content-Type:') === 0) {
|
||||||
|
$contentType = strtolower(trim(substr($header, strlen('Content-Type:'))));
|
||||||
|
}
|
||||||
|
return strlen($header);
|
||||||
|
},
|
||||||
|
CURLOPT_WRITEFUNCTION => function ($ch, string $chunk) use (
|
||||||
|
&$httpCode,
|
||||||
|
&$contentType,
|
||||||
|
&$errorBody,
|
||||||
|
&$bytes,
|
||||||
|
&$aborted,
|
||||||
|
$onChunk
|
||||||
|
): int {
|
||||||
|
if ($httpCode < 200 || $httpCode >= 300 || str_contains($contentType, 'json')) {
|
||||||
|
$errorBody .= $chunk;
|
||||||
|
return strlen($chunk);
|
||||||
|
}
|
||||||
|
if (connection_aborted()) {
|
||||||
|
$aborted = true;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bytes += strlen($chunk);
|
||||||
|
$onChunk($chunk);
|
||||||
|
return strlen($chunk);
|
||||||
|
},
|
||||||
|
CURLOPT_CONNECTTIMEOUT_MS => (int) $persona['connect_timeout_ms'],
|
||||||
|
CURLOPT_TIMEOUT => (int) $persona['timeout_seconds'],
|
||||||
|
CURLOPT_SSL_VERIFYPEER => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = curl_exec($ch);
|
||||||
|
$curlError = curl_error($ch);
|
||||||
|
$curlErrno = curl_errno($ch);
|
||||||
|
if ($httpCode === 0) {
|
||||||
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
}
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($aborted || ($result === false && $curlErrno === CURLE_WRITE_ERROR && connection_aborted())) {
|
||||||
|
return [
|
||||||
|
'bytes' => $bytes,
|
||||||
|
'sample_rate' => (int) $persona['sample_rate'],
|
||||||
|
'provider' => 'cosyvoice',
|
||||||
|
'aborted' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($result === false) {
|
||||||
|
self::rememberFailure($persona);
|
||||||
|
throw new \RuntimeException('CosyVoice 流连接失败: ' . ($curlError ?: '网络不可达'));
|
||||||
|
}
|
||||||
|
if ($httpCode < 200 || $httpCode >= 300) {
|
||||||
|
self::rememberFailure($persona);
|
||||||
|
$detail = self::errorDetail($errorBody);
|
||||||
|
throw new \RuntimeException('CosyVoice 返回 HTTP ' . $httpCode . ($detail ? ': ' . $detail : ''));
|
||||||
|
}
|
||||||
|
if (str_contains($contentType, 'json')) {
|
||||||
|
self::rememberFailure($persona);
|
||||||
|
throw new \RuntimeException('CosyVoice 返回了错误响应: ' . (self::errorDetail($errorBody) ?: '未知错误'));
|
||||||
|
}
|
||||||
|
if ($bytes < 2) {
|
||||||
|
self::rememberFailure($persona);
|
||||||
|
throw new \RuntimeException('CosyVoice 返回了空音频');
|
||||||
|
}
|
||||||
|
|
||||||
|
Cache::delete(self::failureCacheKey($persona));
|
||||||
|
return [
|
||||||
|
'bytes' => $bytes,
|
||||||
|
'sample_rate' => (int) $persona['sample_rate'],
|
||||||
|
'provider' => 'cosyvoice',
|
||||||
|
'aborted' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function cancelSpeech(string $requestId): bool
|
||||||
|
{
|
||||||
|
$persona = self::getPersona();
|
||||||
|
$requestId = trim($requestId);
|
||||||
|
if ($requestId === '' || empty($persona['enabled']) || trim((string) $persona['base_url']) === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = rtrim((string) $persona['base_url'], '/') . '/cancel/' . rawurlencode($requestId);
|
||||||
|
$headers = ['Accept: application/json'];
|
||||||
|
$apiKey = trim((string) config('cosyvoice.api_key', ''));
|
||||||
|
if ($apiKey !== '') {
|
||||||
|
$headers[] = 'Authorization: Bearer ' . $apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => '',
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CONNECTTIMEOUT_MS => min(800, (int) $persona['connect_timeout_ms']),
|
||||||
|
CURLOPT_TIMEOUT_MS => 1500,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => false,
|
||||||
|
]);
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($body === false || $httpCode < 200 || $httpCode >= 300) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_decode($body, true);
|
||||||
|
return is_array($payload) && !empty($payload['cancelled']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function requestFields(string $mode, string $text, array $persona): array
|
||||||
|
{
|
||||||
|
$fields = ['tts_text' => $text];
|
||||||
|
$speaker = trim((string) $persona['speaker']);
|
||||||
|
$instruct = trim((string) $persona['instruct_text']);
|
||||||
|
|
||||||
|
if (in_array($mode, ['sft', 'instruct'], true)) {
|
||||||
|
$fields['spk_id'] = $speaker;
|
||||||
|
}
|
||||||
|
if (in_array($mode, ['instruct', 'instruct2'], true)) {
|
||||||
|
$fields['instruct_text'] = $instruct;
|
||||||
|
}
|
||||||
|
if (in_array($mode, ['zero_shot', 'instruct2'], true)) {
|
||||||
|
$fields['prompt_text'] = trim((string) $persona['prompt_text']);
|
||||||
|
}
|
||||||
|
if (in_array($mode, ['zero_shot', 'cross_lingual', 'instruct2'], true)) {
|
||||||
|
$promptWav = trim((string) $persona['prompt_wav']);
|
||||||
|
if ($promptWav === '' || !is_file($promptWav)) {
|
||||||
|
throw new \RuntimeException('CosyVoice 音色样本不存在: ' . $promptWav);
|
||||||
|
}
|
||||||
|
$fields['prompt_wav'] = new \CURLFile($promptWav, 'audio/wav', basename($promptWav));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function pcm16ToWav(string $pcm, int $sampleRate): string
|
||||||
|
{
|
||||||
|
$channels = 1;
|
||||||
|
$bitsPerSample = 16;
|
||||||
|
$dataSize = strlen($pcm);
|
||||||
|
$blockAlign = (int) ($channels * $bitsPerSample / 8);
|
||||||
|
$byteRate = $sampleRate * $blockAlign;
|
||||||
|
|
||||||
|
return 'RIFF'
|
||||||
|
. pack('V', 36 + $dataSize)
|
||||||
|
. 'WAVEfmt '
|
||||||
|
. pack('VvvVVvv', 16, 1, $channels, $sampleRate, $byteRate, $blockAlign, $bitsPerSample)
|
||||||
|
. 'data'
|
||||||
|
. pack('V', $dataSize)
|
||||||
|
. $pcm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function errorDetail(string $body): string
|
||||||
|
{
|
||||||
|
$data = json_decode($body, true);
|
||||||
|
if (is_array($data)) {
|
||||||
|
$detail = $data['detail'] ?? $data['message'] ?? $data['error'] ?? '';
|
||||||
|
if (is_array($detail)) {
|
||||||
|
return json_encode($detail, JSON_UNESCAPED_UNICODE) ?: '';
|
||||||
|
}
|
||||||
|
return trim((string) $detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mb_substr(trim(strip_tags($body)), 0, 240);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function rememberFailure(array $persona): void
|
||||||
|
{
|
||||||
|
Cache::set(
|
||||||
|
self::failureCacheKey($persona),
|
||||||
|
true,
|
||||||
|
(int) $persona['failure_ttl']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function failureCacheKey(array $persona): string
|
||||||
|
{
|
||||||
|
return 'cosyvoice_unavailable_' . md5((string) $persona['base_url']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function limitedText(mixed $value, int $maxLength): string
|
||||||
|
{
|
||||||
|
return mb_substr(trim((string) $value), 0, $maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function safePromptPath(string $path): string
|
||||||
|
{
|
||||||
|
$path = trim($path);
|
||||||
|
if ($path === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$realPath = realpath($path);
|
||||||
|
$allowedRoot = realpath(root_path() . 'storage' . DIRECTORY_SEPARATOR . 'cosyvoice');
|
||||||
|
if (!$realPath || !$allowedRoot || !is_file($realPath)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedPath = strtolower(str_replace('\\', '/', $realPath));
|
||||||
|
$normalizedRoot = rtrim(strtolower(str_replace('\\', '/', $allowedRoot)), '/') . '/';
|
||||||
|
return str_starts_with($normalizedPath, $normalizedRoot) ? $realPath : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,61 +21,147 @@ class DifyService
|
|||||||
public static function chat(AiModel $model, string $query, array $files, ?string $conversationId, string $userId): array
|
public static function chat(AiModel $model, string $query, array $files, ?string $conversationId, string $userId): array
|
||||||
{
|
{
|
||||||
$url = rtrim($model->api_base_url, '/') . '/chat-messages';
|
$url = rtrim($model->api_base_url, '/') . '/chat-messages';
|
||||||
$payload = self::buildPayload($query, $files, $conversationId, $userId, false);
|
$conversationReset = false;
|
||||||
|
|
||||||
$ch = curl_init($url);
|
for ($attempt = 0; $attempt < 2; $attempt++) {
|
||||||
curl_setopt_array($ch, [
|
$requestConversationId = $attempt === 0 ? $conversationId : null;
|
||||||
CURLOPT_POST => true,
|
$payload = self::buildPayload($query, $files, $requestConversationId, $userId, false);
|
||||||
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);
|
$ch = curl_init($url);
|
||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
curl_setopt_array($ch, [
|
||||||
$curlError = curl_error($ch);
|
CURLOPT_POST => true,
|
||||||
curl_close($ch);
|
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,
|
||||||
|
]);
|
||||||
|
|
||||||
if ($response === false) {
|
$response = curl_exec($ch);
|
||||||
throw new HttpResponseException(json([
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
'code' => 1,
|
$curlError = curl_error($ch);
|
||||||
'message' => 'Dify 请求失败: ' . ($curlError ?: '网络错误'),
|
curl_close($ch);
|
||||||
'data' => null,
|
|
||||||
], 502));
|
if ($response === false) {
|
||||||
|
throw new HttpResponseException(json([
|
||||||
|
'code' => 1,
|
||||||
|
'message' => 'Dify 请求失败: ' . ($curlError ?: '网络错误'),
|
||||||
|
'data' => null,
|
||||||
|
], 502));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($httpCode !== 200) {
|
||||||
|
$detail = self::parseErrorBody($response) ?: ('HTTP ' . $httpCode);
|
||||||
|
if (
|
||||||
|
$attempt === 0 &&
|
||||||
|
$conversationId &&
|
||||||
|
self::isConversationNotFoundError($response . ' ' . $detail)
|
||||||
|
) {
|
||||||
|
$conversationReset = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
'conversation_reset' => $conversationReset,
|
||||||
|
'tokens' => $data['metadata']['usage']['total_tokens'] ?? 0,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($httpCode !== 200) {
|
throw new \RuntimeException('Dify 会话恢复失败');
|
||||||
$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(
|
public static function streamChat(
|
||||||
|
AiModel $model,
|
||||||
|
string $query,
|
||||||
|
array $files,
|
||||||
|
?string $conversationId,
|
||||||
|
string $userId,
|
||||||
|
callable $onChunk,
|
||||||
|
callable $onDone,
|
||||||
|
?callable $onError = null,
|
||||||
|
?callable $onConversationReset = null
|
||||||
|
): void {
|
||||||
|
$emittedContent = false;
|
||||||
|
$attemptError = null;
|
||||||
|
$attemptDone = null;
|
||||||
|
|
||||||
|
$chunkProxy = function (string $delta) use (&$emittedContent, $onChunk) {
|
||||||
|
$emittedContent = true;
|
||||||
|
$onChunk($delta);
|
||||||
|
};
|
||||||
|
$doneProxy = function (?string $newConversationId, int $tokens) use (&$attemptDone) {
|
||||||
|
$attemptDone = [$newConversationId, $tokens];
|
||||||
|
};
|
||||||
|
$errorProxy = function (string $message) use (&$attemptError) {
|
||||||
|
$attemptError = $message;
|
||||||
|
};
|
||||||
|
|
||||||
|
self::streamChatAttempt(
|
||||||
|
$model,
|
||||||
|
$query,
|
||||||
|
$files,
|
||||||
|
$conversationId,
|
||||||
|
$userId,
|
||||||
|
$chunkProxy,
|
||||||
|
$doneProxy,
|
||||||
|
$errorProxy
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
$attemptDone === null &&
|
||||||
|
!$emittedContent &&
|
||||||
|
$conversationId &&
|
||||||
|
self::isConversationNotFoundError((string) $attemptError)
|
||||||
|
) {
|
||||||
|
if ($onConversationReset) {
|
||||||
|
$onConversationReset();
|
||||||
|
}
|
||||||
|
$attemptError = null;
|
||||||
|
self::streamChatAttempt(
|
||||||
|
$model,
|
||||||
|
$query,
|
||||||
|
$files,
|
||||||
|
null,
|
||||||
|
$userId,
|
||||||
|
$chunkProxy,
|
||||||
|
$doneProxy,
|
||||||
|
$errorProxy
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($attemptDone !== null) {
|
||||||
|
$onDone($attemptDone[0], $attemptDone[1]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($onError && $attemptError !== null) {
|
||||||
|
$onError($attemptError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function streamChatAttempt(
|
||||||
AiModel $model,
|
AiModel $model,
|
||||||
string $query,
|
string $query,
|
||||||
array $files,
|
array $files,
|
||||||
@@ -373,6 +459,25 @@ class DifyService
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function isConversationNotFoundError(string $message): bool
|
||||||
|
{
|
||||||
|
$message = mb_strtolower($message);
|
||||||
|
foreach ([
|
||||||
|
'conversation not exists',
|
||||||
|
'conversation does not exist',
|
||||||
|
'conversation not found',
|
||||||
|
'conversation_not_exists',
|
||||||
|
'conversation_not_found',
|
||||||
|
'dify 会话已失效',
|
||||||
|
] as $needle) {
|
||||||
|
if (str_contains($message, $needle)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private static function parseErrorBody(?string $body): ?string
|
private static function parseErrorBody(?string $body): ?string
|
||||||
{
|
{
|
||||||
if (!$body) {
|
if (!$body) {
|
||||||
@@ -390,6 +495,10 @@ class DifyService
|
|||||||
*/
|
*/
|
||||||
public static function humanizeError(string $message): string
|
public static function humanizeError(string $message): string
|
||||||
{
|
{
|
||||||
|
if (self::isConversationNotFoundError($message)) {
|
||||||
|
return 'Dify 会话已失效,系统创建新会话后仍未恢复,请稍后重新发送。';
|
||||||
|
}
|
||||||
|
|
||||||
if (str_contains($message, "Unsupported chat content part type: 'file'")
|
if (str_contains($message, "Unsupported chat content part type: 'file'")
|
||||||
|| str_contains($message, 'Unsupported chat content part type')) {
|
|| str_contains($message, 'Unsupported chat content part type')) {
|
||||||
return 'Dify 模型层仍不接受 file 类型。请确认 Dify 应用已开启文档上传,且 files.type 使用 document(不是 file)。'
|
return 'Dify 模型层仍不接受 file 类型。请确认 Dify 应用已开启文档上传,且 files.type 使用 document(不是 file)。'
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\service;
|
||||||
|
|
||||||
|
use app\model\AiModel;
|
||||||
|
use think\exception\HttpResponseException;
|
||||||
|
|
||||||
|
class GuestAccessService
|
||||||
|
{
|
||||||
|
public const MODEL_NAME = 'qwen3.6';
|
||||||
|
|
||||||
|
public static function isGuest(array $user): bool
|
||||||
|
{
|
||||||
|
return !empty($user['is_guest']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function model(): AiModel
|
||||||
|
{
|
||||||
|
$models = AiModel::where('enabled', 1)->order('sort_order')->order('id')->select();
|
||||||
|
foreach ($models as $model) {
|
||||||
|
$identity = strtolower(trim((string) ($model->model_id ?: $model->name)));
|
||||||
|
$name = strtolower(trim((string) $model->name));
|
||||||
|
if (str_starts_with($identity, self::MODEL_NAME) || str_starts_with($name, self::MODEL_NAME)) {
|
||||||
|
return $model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::abort('游客专用模型 qwen3.6 尚未启用,请联系管理员', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function modelId(): int
|
||||||
|
{
|
||||||
|
return (int) self::model()->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function assertModelAllowed(array $user, ?int $modelId): int
|
||||||
|
{
|
||||||
|
if (!self::isGuest($user)) {
|
||||||
|
return $modelId ?: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$guestModelId = self::modelId();
|
||||||
|
if ($modelId && $modelId !== $guestModelId) {
|
||||||
|
self::abort('游客仅可使用 qwen3.6 模型,登录后可使用全部模型', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $guestModelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function assertTextChatOnly(array $user, array $attachments, string $agentId, string $imageTool, bool $voiceMode): void
|
||||||
|
{
|
||||||
|
if (!self::isGuest($user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($attachments || $agentId !== '' || $imageTool !== '' || $voiceMode) {
|
||||||
|
self::abort('游客仅支持 qwen3.6 文本对话,登录后可使用全部模型和工具', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function assertAccountRequired(array $user, string $feature): void
|
||||||
|
{
|
||||||
|
if (self::isGuest($user)) {
|
||||||
|
self::abort("游客不能使用{$feature},请先登录", 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function abort(string $message, int $httpCode): never
|
||||||
|
{
|
||||||
|
throw new HttpResponseException(json([
|
||||||
|
'code' => 1,
|
||||||
|
'message' => $message,
|
||||||
|
'data' => null,
|
||||||
|
], $httpCode));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,823 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\service;
|
||||||
|
|
||||||
|
use app\model\AiModel;
|
||||||
|
use app\model\UploadFile;
|
||||||
|
use app\model\VideoProject;
|
||||||
|
use app\model\VideoShot;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MiniMax H3 原生 ComfyUI API 接入。
|
||||||
|
*
|
||||||
|
* 只使用 ComfyUI core 节点,提供无参考的 FL2VA 和带角色图的 REF2VA 两条工作流。
|
||||||
|
*/
|
||||||
|
class MiniMaxH3Service
|
||||||
|
{
|
||||||
|
public const WORKFLOW_VERSION = 'minimax-h3-joint-av-v6';
|
||||||
|
private const FL2VA_MODEL = 'minimax_h3_fl2va_pruned_int8_convrot.safetensors';
|
||||||
|
private const REF2VA_MODEL = 'minimax_h3_ref2va_pruned_int8_convrot.safetensors';
|
||||||
|
private const TEXT_ENCODER = 'qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors';
|
||||||
|
private const VIDEO_VAE = 'minimax_h3_video_vae_fp16.safetensors';
|
||||||
|
private const AUDIO_VAE = 'minimax_h3_audio_vae_fp32.safetensors';
|
||||||
|
|
||||||
|
public static function workflowManifest(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'version' => self::WORKFLOW_VERSION,
|
||||||
|
'fps' => 24,
|
||||||
|
'supported_shot_durations' => [5, 10],
|
||||||
|
'frames_by_duration' => ['5' => 124, '10' => 243],
|
||||||
|
'trained_frame_range' => [124, 362],
|
||||||
|
'fl2va_model' => self::FL2VA_MODEL,
|
||||||
|
'ref2va_model' => self::REF2VA_MODEL,
|
||||||
|
'text_encoder' => self::TEXT_ENCODER,
|
||||||
|
'video_vae' => self::VIDEO_VAE,
|
||||||
|
'audio_vae' => self::AUDIO_VAE,
|
||||||
|
'text_render_policy' => 'clean_surface_plus_exact_ass_postprocess',
|
||||||
|
'synced_project_settings' => [
|
||||||
|
'aspect_ratio',
|
||||||
|
'quality',
|
||||||
|
'voice_language',
|
||||||
|
'show_subtitles',
|
||||||
|
'character_origin',
|
||||||
|
'screen_text_language',
|
||||||
|
'shot_duration_mode',
|
||||||
|
],
|
||||||
|
'nodes' => [
|
||||||
|
'UNETLoader',
|
||||||
|
'MiniMaxH3SigmaShift',
|
||||||
|
'CLIPLoader',
|
||||||
|
'VAELoader',
|
||||||
|
'MiniMaxH3ImageToVideo / MiniMaxH3ReferenceToVideo',
|
||||||
|
'ConditioningZeroOut',
|
||||||
|
'KSampler',
|
||||||
|
'LTXVSeparateAVLatent',
|
||||||
|
'VAEDecode',
|
||||||
|
'VAEDecodeAudio',
|
||||||
|
'CreateVideo',
|
||||||
|
'SaveVideo',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int,AiModel> 每个 ComfyUI 地址只保留一个工作节点。 */
|
||||||
|
public static function workers(): array
|
||||||
|
{
|
||||||
|
$models = AiModel::where('provider', 'comfy')
|
||||||
|
->where('enabled', 1)
|
||||||
|
->order('is_default', 'desc')
|
||||||
|
->order('sort_order')
|
||||||
|
->order('id')
|
||||||
|
->select();
|
||||||
|
$workers = [];
|
||||||
|
foreach ($models as $model) {
|
||||||
|
$endpoint = strtolower(self::baseUrl((string) $model->api_base_url));
|
||||||
|
if (!isset($workers[$endpoint])) {
|
||||||
|
$workers[$endpoint] = $model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$workers) {
|
||||||
|
throw new \RuntimeException('管理端尚未启用 ComfyUI 模型');
|
||||||
|
}
|
||||||
|
return array_values($workers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function workerSummary(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$workers = self::workers();
|
||||||
|
$count = count($workers);
|
||||||
|
return [
|
||||||
|
'configured_workers' => $count,
|
||||||
|
'effective_concurrency' => min(3, $count),
|
||||||
|
'mode' => $count > 1 ? 'multi_endpoint_parallel' : 'single_endpoint_serial',
|
||||||
|
'message' => $count > 1
|
||||||
|
? "已配置 {$count} 个独立 ComfyUI 地址,最多并发 3 个镜头"
|
||||||
|
: '当前只有 1 个 ComfyUI 地址;同地址任务按队列串行执行',
|
||||||
|
];
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
return [
|
||||||
|
'configured_workers' => 0,
|
||||||
|
'effective_concurrency' => 0,
|
||||||
|
'mode' => 'unavailable',
|
||||||
|
'message' => $error->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function model(?int $workerId = null): AiModel
|
||||||
|
{
|
||||||
|
if ($workerId !== null && $workerId > 0) {
|
||||||
|
$model = AiModel::where('provider', 'comfy')->where('id', $workerId)->find();
|
||||||
|
if ($model) {
|
||||||
|
return $model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self::workers()[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,array<string,mixed>> $characters
|
||||||
|
* @return array{prompt_id:string,workflow_type:string,reference_count:int,continuity_applied:bool,audio_mode:string,workflow_version:string,worker_id:int,worker_name:string,applied_settings:array<string,mixed>}
|
||||||
|
*/
|
||||||
|
public static function submitShot(
|
||||||
|
VideoShot $shot,
|
||||||
|
VideoProject $project,
|
||||||
|
array $characters,
|
||||||
|
?array $preparedReferenceFiles = null,
|
||||||
|
?int $continuityUploadId = null,
|
||||||
|
?AiModel $worker = null
|
||||||
|
): array
|
||||||
|
{
|
||||||
|
$model = $worker ?? self::model();
|
||||||
|
$baseUrl = self::baseUrl($model->api_base_url);
|
||||||
|
$apiKey = (string) ($model->api_key ?? '');
|
||||||
|
$referenceFiles = $preparedReferenceFiles
|
||||||
|
?? self::uploadReferenceFiles($project, $characters, $baseUrl, $apiKey);
|
||||||
|
|
||||||
|
$continuityFile = $continuityUploadId
|
||||||
|
? self::uploadContinuityFrame($project, $continuityUploadId, $baseUrl, $apiKey)
|
||||||
|
: null;
|
||||||
|
$characterReferenceCount = count($referenceFiles);
|
||||||
|
$language = VideoDubService::normalizeLanguage((string) ($project->voice_language ?? 'zh-CN'));
|
||||||
|
$shotMeta = is_array($shot->meta) ? $shot->meta : [];
|
||||||
|
$timeline = is_array($shotMeta['timeline'] ?? null) ? $shotMeta['timeline'] : [];
|
||||||
|
$audioMode = VideoDubService::normalizeAudioMode((string) ($timeline['audio_mode'] ?? 'ambient_only'));
|
||||||
|
$workflowPrompt = self::audioDirective($language, $audioMode, $timeline)
|
||||||
|
. self::screenTextDirective(
|
||||||
|
(string) ($project->screen_text_language ?? ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_ZH_CN),
|
||||||
|
$timeline
|
||||||
|
)
|
||||||
|
. (string) $shot->prompt;
|
||||||
|
$firstFrameFile = null;
|
||||||
|
|
||||||
|
if ($continuityFile !== null && $referenceFiles) {
|
||||||
|
// REF2VA 最多支持 9 张图,给真实连续帧固定保留最后一个槽位。
|
||||||
|
$referenceFiles = array_slice($referenceFiles, 0, 8);
|
||||||
|
$continuityPictureNo = count($referenceFiles) + 1;
|
||||||
|
$referenceFiles[] = $continuityFile;
|
||||||
|
$workflowPrompt = preg_replace('/。+$/u', '', $workflowPrompt) ?? $workflowPrompt;
|
||||||
|
$workflowPrompt .= "。<Picture {$continuityPictureNo}> 是上一镜头的真实结束帧;本镜头第一帧必须复现其人物位置、脸部、服装、动作相位、构图、背景、光向和色温,再从该动作自然继续,禁止重新起势或跳切。";
|
||||||
|
$workflowType = 'ref2va-continuity';
|
||||||
|
} elseif ($continuityFile !== null) {
|
||||||
|
$firstFrameFile = $continuityFile;
|
||||||
|
$workflowPrompt = preg_replace('/。+$/u', '', $workflowPrompt) ?? $workflowPrompt;
|
||||||
|
$workflowPrompt .= '。输入首帧是上一镜头的真实结束帧;必须从这张画面无缝继续人物动作、视线和摄影机运动,禁止改变脸、服装、背景、光线或重新起势。';
|
||||||
|
$workflowType = 'i2v-continuity';
|
||||||
|
} else {
|
||||||
|
$workflowType = $referenceFiles ? 'ref2va' : 'fl2va';
|
||||||
|
}
|
||||||
|
[$width, $height, $steps] = self::generationPreset(
|
||||||
|
(string) $project->aspect_ratio,
|
||||||
|
(string) $project->quality
|
||||||
|
);
|
||||||
|
$shotDuration = in_array((int) $shot->duration_seconds, [5, 10], true)
|
||||||
|
? (int) $shot->duration_seconds
|
||||||
|
: 5;
|
||||||
|
$frameLength = self::frameLengthForDuration($shotDuration);
|
||||||
|
$workflow = self::buildWorkflow([
|
||||||
|
'workflow_type' => $workflowType,
|
||||||
|
'prompt' => $workflowPrompt,
|
||||||
|
'width' => $width,
|
||||||
|
'height' => $height,
|
||||||
|
'length' => $frameLength,
|
||||||
|
'steps' => $steps,
|
||||||
|
'seed' => (int) ($shot->seed ?: random_int(1, PHP_INT_MAX)),
|
||||||
|
'reference_files' => $referenceFiles,
|
||||||
|
'first_frame_file' => $firstFrameFile,
|
||||||
|
'ref_image_size' => ($shotMeta['identity_boost'] ?? false) ? 'max' : 'match',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'prompt_id' => self::queuePrompt($baseUrl, $workflow, $apiKey),
|
||||||
|
'workflow_type' => $workflowType,
|
||||||
|
'reference_count' => $characterReferenceCount,
|
||||||
|
'continuity_applied' => $continuityFile !== null,
|
||||||
|
'audio_mode' => $audioMode,
|
||||||
|
'workflow_version' => self::WORKFLOW_VERSION,
|
||||||
|
'worker_id' => (int) $model->id,
|
||||||
|
'worker_name' => (string) $model->name,
|
||||||
|
'applied_settings' => [
|
||||||
|
'aspect_ratio' => (string) $project->aspect_ratio,
|
||||||
|
'quality' => (string) $project->quality,
|
||||||
|
'voice_language' => $language,
|
||||||
|
'show_subtitles' => (bool) ($project->show_subtitles ?? false),
|
||||||
|
'character_origin' => ShortDramaPlannerService::normalizeCharacterOrigin(
|
||||||
|
(string) ($project->character_origin ?? '')
|
||||||
|
),
|
||||||
|
'screen_text_language' => ShortDramaPlannerService::normalizeScreenTextLanguage(
|
||||||
|
(string) ($project->screen_text_language ?? '')
|
||||||
|
),
|
||||||
|
'shot_duration_mode' => ShortDramaPlannerService::normalizeShotDurationMode(
|
||||||
|
(string) ($project->shot_duration_mode ?? '')
|
||||||
|
),
|
||||||
|
'width' => $width,
|
||||||
|
'height' => $height,
|
||||||
|
'steps' => $steps,
|
||||||
|
'shot_duration_seconds' => $shotDuration,
|
||||||
|
'frame_length' => $frameLength,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function screenTextDirective(string $language, array $timeline): string
|
||||||
|
{
|
||||||
|
$language = ShortDramaPlannerService::normalizeScreenTextLanguage($language);
|
||||||
|
$screenText = trim((string) ($timeline['screen_text'] ?? ''));
|
||||||
|
if ($language === ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_NONE || $screenText === '') {
|
||||||
|
return 'SCENE TEXT POLICY: render no readable glyphs. ';
|
||||||
|
}
|
||||||
|
$label = $language === ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_EN_US
|
||||||
|
? 'English'
|
||||||
|
: 'Simplified Chinese';
|
||||||
|
return "SCENE TEXT POLICY: reserve a clean, stable, unobstructed surface for {$label} scene text, but draw no glyphs inside H3. Exact post-render text is: “{$screenText}”. The compositor will burn it in; do not invent pseudo-letters or symbols. ";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function audioDirective(string $language, string $audioMode, array $timeline): string
|
||||||
|
{
|
||||||
|
$noText = 'ABSOLUTELY NO visible text, subtitles, captions, speech bubbles, typography, letters, numbers, logos, watermarks or interface. ';
|
||||||
|
if ($language === VideoDubService::LANG_NONE || $audioMode === VideoDubService::AUDIO_AMBIENT) {
|
||||||
|
return 'MINIMAX H3 JOINT AUDIO-VIDEO SHOT. Generate continuous synchronized scene ambience and physical action sounds only. '
|
||||||
|
. 'NO dialogue, narration, singing, yelling, mumbling, pseudo-language or any human voice. Every visible person keeps a naturally closed mouth and never performs speaking mouth motion. '
|
||||||
|
. $noText;
|
||||||
|
}
|
||||||
|
if ($audioMode === VideoDubService::AUDIO_SCENE) {
|
||||||
|
$soundEffects = is_array($timeline['sound_effects'] ?? null)
|
||||||
|
? array_values(array_filter(array_map('trim', $timeline['sound_effects'])))
|
||||||
|
: [];
|
||||||
|
$soundRule = $soundEffects
|
||||||
|
? 'Generate these exact synchronized diegetic sounds: ' . implode('; ', $soundEffects) . '. '
|
||||||
|
: 'Generate only synchronized diegetic ambience and physical action sounds visible in the shot. ';
|
||||||
|
return 'MINIMAX H3 NATIVE JOINT AUDIO-VIDEO SCENE-SOUND SHOT. ' . $soundRule
|
||||||
|
. 'These are environmental/action sounds, never spoken words. NO dialogue, narration, off-screen voice, singing, yelling, crying speech, mumbling, gibberish or pseudo-language. '
|
||||||
|
. 'Every visible person keeps a naturally closed mouth and never performs speaking mouth motion. '
|
||||||
|
. $noText;
|
||||||
|
}
|
||||||
|
|
||||||
|
$speaker = trim((string) ($timeline['dialogue_speaker'] ?? '主角')) ?: '主角';
|
||||||
|
$dialogue = trim((string) ($timeline['dialogue'] ?? ''));
|
||||||
|
$delivery = trim((string) ($timeline['dialogue_delivery'] ?? '自然')) ?: '自然';
|
||||||
|
if ($audioMode === VideoDubService::AUDIO_NARRATION) {
|
||||||
|
return 'MINIMAX H3 VISUAL SHOT FOR POST-DUBBED NARRATION. Do not generate the narration or any other human voice; the raw H3 soundtrack will be discarded. '
|
||||||
|
. 'All visible people keep their mouths naturally closed and never lip-sync, yell or perform speaking motion. '
|
||||||
|
. 'NO visible character speech, NO off-screen speech, NO singing, NO gibberish and NO pseudo-language. '
|
||||||
|
. $noText;
|
||||||
|
}
|
||||||
|
|
||||||
|
$gender = trim((string) ($timeline['speaker_gender'] ?? 'auto'));
|
||||||
|
$genderRule = $gender === 'auto'
|
||||||
|
? 'The speaking voice must match the visible speaker’s actual sex, apparent age and identity.'
|
||||||
|
: "Use a {$gender} voice matching the visible speaker’s age and identity.";
|
||||||
|
return "MINIMAX H3 NATIVE JOINT AUDIO-VIDEO CHARACTER DIALOGUE. The visible {$speaker}, and nobody else, speaks exact standard Mandarin Chinese: “{$dialogue}”. "
|
||||||
|
. "Delivery: {$delivery}. {$genderRule} The speaker starts with a closed mouth, opens the mouth only for this exact line with frame-accurate natural lip synchronization, then closes the mouth. "
|
||||||
|
. 'NO narrator, NO off-screen voice, NO second speaker, NO voice/sex mismatch, NO extra words, NO repeated words, NO gibberish and NO pseudo-language. Keep synchronized scene ambience and action sounds. '
|
||||||
|
. $noText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每次整集提交只上传一次角色图,所有镜头复用同一个 ComfyUI input 文件。
|
||||||
|
*
|
||||||
|
* @param array<int,array<string,mixed>> $characters
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public static function prepareReferenceFiles(
|
||||||
|
VideoProject $project,
|
||||||
|
array $characters,
|
||||||
|
?AiModel $worker = null
|
||||||
|
): array
|
||||||
|
{
|
||||||
|
$model = $worker ?? self::model();
|
||||||
|
return self::uploadReferenceFiles(
|
||||||
|
$project,
|
||||||
|
$characters,
|
||||||
|
self::baseUrl($model->api_base_url),
|
||||||
|
(string) ($model->api_key ?? '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return string[] */
|
||||||
|
private static function uploadReferenceFiles(
|
||||||
|
VideoProject $project,
|
||||||
|
array $characters,
|
||||||
|
string $baseUrl,
|
||||||
|
string $apiKey
|
||||||
|
): array {
|
||||||
|
$referenceFiles = [];
|
||||||
|
|
||||||
|
foreach (array_slice($characters, 0, 9) as $index => $character) {
|
||||||
|
$uploadId = (int) ($character['reference_upload_id'] ?? 0);
|
||||||
|
if ($uploadId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$upload = UploadFile::where('id', $uploadId)
|
||||||
|
->where('user_id', (int) $project->user_id)
|
||||||
|
->where('file_type', 'image')
|
||||||
|
->find();
|
||||||
|
if (!$upload) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$path = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
|
||||||
|
. str_replace('/', DIRECTORY_SEPARATOR, (string) $upload->file_path);
|
||||||
|
if (!is_file($path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$referenceFiles[] = self::uploadInputImage(
|
||||||
|
$baseUrl,
|
||||||
|
$path,
|
||||||
|
$apiKey,
|
||||||
|
'character_' . ((int) $index + 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return $referenceFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function uploadContinuityFrame(
|
||||||
|
VideoProject $project,
|
||||||
|
int $uploadId,
|
||||||
|
string $baseUrl,
|
||||||
|
string $apiKey
|
||||||
|
): ?string {
|
||||||
|
$upload = UploadFile::where('id', $uploadId)
|
||||||
|
->where('user_id', (int) $project->user_id)
|
||||||
|
->where('file_type', 'image')
|
||||||
|
->find();
|
||||||
|
if (!$upload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$path = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
|
||||||
|
. str_replace('/', DIRECTORY_SEPARATOR, (string) $upload->file_path);
|
||||||
|
if (!is_file($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return self::uploadInputImage($baseUrl, $path, $apiKey, 'continuity');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{state:string,message:string,files:array,error:?string}
|
||||||
|
*/
|
||||||
|
public static function inspect(string $promptId, ?int $workerId = null): array
|
||||||
|
{
|
||||||
|
$model = self::model($workerId);
|
||||||
|
$baseUrl = self::baseUrl($model->api_base_url);
|
||||||
|
$apiKey = (string) ($model->api_key ?? '');
|
||||||
|
$history = self::getJson($baseUrl . '/history/' . rawurlencode($promptId), $apiKey);
|
||||||
|
|
||||||
|
if (isset($history[$promptId])) {
|
||||||
|
$entry = $history[$promptId];
|
||||||
|
$status = is_array($entry['status'] ?? null) ? $entry['status'] : [];
|
||||||
|
foreach (($status['messages'] ?? []) as $message) {
|
||||||
|
if (($message[0] ?? '') !== 'execution_error') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$detail = $message[1]['exception_message']
|
||||||
|
?? json_encode($message[1] ?? [], JSON_UNESCAPED_UNICODE);
|
||||||
|
return [
|
||||||
|
'state' => 'error',
|
||||||
|
'message' => '视频生成失败',
|
||||||
|
'files' => [],
|
||||||
|
'error' => (string) $detail,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = self::collectVideoFiles($entry['outputs'] ?? []);
|
||||||
|
if ($files) {
|
||||||
|
return [
|
||||||
|
'state' => 'done',
|
||||||
|
'message' => '视频镜头生成完成',
|
||||||
|
'files' => $files,
|
||||||
|
'error' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!empty($status['completed']) || ($status['status_str'] ?? '') === 'success') {
|
||||||
|
return [
|
||||||
|
'state' => 'error',
|
||||||
|
'message' => '任务完成但没有找到 MP4 输出',
|
||||||
|
'files' => [],
|
||||||
|
'error' => 'SaveVideo 未返回可下载文件',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$queue = self::getJson($baseUrl . '/queue', $apiKey);
|
||||||
|
foreach (($queue['queue_running'] ?? []) as $item) {
|
||||||
|
if ((string) ($item[1] ?? '') === $promptId) {
|
||||||
|
return ['state' => 'running', 'message' => '正在渲染镜头', 'files' => [], 'error' => null];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (array_values($queue['queue_pending'] ?? []) as $index => $item) {
|
||||||
|
if ((string) ($item[1] ?? '') === $promptId) {
|
||||||
|
return [
|
||||||
|
'state' => 'queued',
|
||||||
|
'message' => $index > 0 ? "排队中,前面还有 {$index} 个任务" : '即将开始渲染',
|
||||||
|
'files' => [],
|
||||||
|
'error' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'state' => 'missing',
|
||||||
|
'message' => 'ComfyUI 队列中未找到任务,正在确认是否需要自动重试',
|
||||||
|
'files' => [],
|
||||||
|
'error' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{id:int,url:string,name:string,mime:string,size:int,path:string}
|
||||||
|
*/
|
||||||
|
public static function storeVideo(array $file, int $userId, ?int $workerId = null): array
|
||||||
|
{
|
||||||
|
$model = self::model($workerId);
|
||||||
|
$baseUrl = self::baseUrl($model->api_base_url);
|
||||||
|
$apiKey = (string) ($model->api_key ?? '');
|
||||||
|
$filename = basename((string) ($file['filename'] ?? ''));
|
||||||
|
if ($filename === '') {
|
||||||
|
throw new \RuntimeException('ComfyUI 视频文件名为空');
|
||||||
|
}
|
||||||
|
$query = http_build_query([
|
||||||
|
'filename' => $filename,
|
||||||
|
'subfolder' => (string) ($file['subfolder'] ?? ''),
|
||||||
|
'type' => (string) ($file['type'] ?? 'output'),
|
||||||
|
]);
|
||||||
|
$binary = self::getBinary($baseUrl . '/view?' . $query, $apiKey);
|
||||||
|
if ($binary === null || $binary === '') {
|
||||||
|
throw new \RuntimeException('下载 ComfyUI 视频失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||||||
|
if (!in_array($extension, ['mp4', 'webm', 'mov'], true)) {
|
||||||
|
$extension = 'mp4';
|
||||||
|
}
|
||||||
|
$subdir = date('Y/m/d');
|
||||||
|
$storedBase = 'h3_' . uniqid('', true) . '.' . $extension;
|
||||||
|
$relativePath = $subdir . '/' . $storedBase;
|
||||||
|
$fullDir = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
|
||||||
|
. str_replace('/', DIRECTORY_SEPARATOR, $subdir);
|
||||||
|
if (!is_dir($fullDir) && !mkdir($fullDir, 0755, true) && !is_dir($fullDir)) {
|
||||||
|
throw new \RuntimeException('无法创建视频存储目录');
|
||||||
|
}
|
||||||
|
$fullPath = $fullDir . DIRECTORY_SEPARATOR . $storedBase;
|
||||||
|
if (file_put_contents($fullPath, $binary) === false) {
|
||||||
|
throw new \RuntimeException('保存生成视频失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mime = @mime_content_type($fullPath) ?: ($extension === 'webm' ? 'video/webm' : 'video/mp4');
|
||||||
|
$size = (int) filesize($fullPath);
|
||||||
|
$upload = UploadFile::create([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'original_name' => 'short_drama_shot.' . $extension,
|
||||||
|
'stored_name' => $storedBase,
|
||||||
|
'file_path' => $relativePath,
|
||||||
|
'mime_type' => $mime,
|
||||||
|
'file_size' => $size,
|
||||||
|
'file_type' => 'video',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) $upload->id,
|
||||||
|
'url' => '/api/uploads/' . rawurlencode($storedBase),
|
||||||
|
'name' => (string) $upload->original_name,
|
||||||
|
'mime' => $mime,
|
||||||
|
'size' => $size,
|
||||||
|
'path' => $fullPath,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{workflow_type:string,prompt:string,width:int,height:int,length:int,steps:int,seed:int,reference_files:array,first_frame_file:?string,ref_image_size:string} $options
|
||||||
|
*/
|
||||||
|
public static function buildWorkflow(array $options): array
|
||||||
|
{
|
||||||
|
$isReference = str_starts_with($options['workflow_type'], 'ref2va')
|
||||||
|
&& !empty($options['reference_files']);
|
||||||
|
$workflow = [
|
||||||
|
'1' => [
|
||||||
|
'_meta' => ['title' => 'MiniMax H3 FL2VA / REF2VA 模型'],
|
||||||
|
'class_type' => 'UNETLoader',
|
||||||
|
'inputs' => [
|
||||||
|
'unet_name' => $isReference ? self::REF2VA_MODEL : self::FL2VA_MODEL,
|
||||||
|
'weight_dtype' => 'default',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'2' => [
|
||||||
|
'_meta' => ['title' => 'H3 视频/音频联合采样时间表'],
|
||||||
|
'class_type' => 'MiniMaxH3SigmaShift',
|
||||||
|
'inputs' => ['model' => ['1', 0], 'shift_video' => 12.0, 'shift_audio' => 3.0],
|
||||||
|
],
|
||||||
|
'3' => [
|
||||||
|
'_meta' => ['title' => 'Qwen3-VL H3 文本编码器'],
|
||||||
|
'class_type' => 'CLIPLoader',
|
||||||
|
'inputs' => ['clip_name' => self::TEXT_ENCODER, 'type' => 'minimax', 'device' => 'default'],
|
||||||
|
],
|
||||||
|
'4' => [
|
||||||
|
'_meta' => ['title' => 'H3 视频 VAE'],
|
||||||
|
'class_type' => 'VAELoader',
|
||||||
|
'inputs' => ['vae_name' => self::VIDEO_VAE],
|
||||||
|
],
|
||||||
|
'5' => [
|
||||||
|
'_meta' => ['title' => 'H3 音频 VAE'],
|
||||||
|
'class_type' => 'VAELoader',
|
||||||
|
'inputs' => ['vae_name' => self::AUDIO_VAE],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($isReference) {
|
||||||
|
$conditionInputs = [
|
||||||
|
'clip' => ['3', 0],
|
||||||
|
'vae' => ['4', 0],
|
||||||
|
'audio_vae' => ['5', 0],
|
||||||
|
'prompt' => (string) $options['prompt'],
|
||||||
|
'width' => (int) $options['width'],
|
||||||
|
'height' => (int) $options['height'],
|
||||||
|
'length' => (int) $options['length'],
|
||||||
|
'ref_image_size' => $options['ref_image_size'] === 'max' ? 'max' : 'match',
|
||||||
|
];
|
||||||
|
foreach (array_values($options['reference_files']) as $index => $filename) {
|
||||||
|
$nodeId = (string) (20 + $index);
|
||||||
|
$workflow[$nodeId] = [
|
||||||
|
'_meta' => ['title' => '角色/连续性参考图 ' . ($index + 1)],
|
||||||
|
'class_type' => 'LoadImage',
|
||||||
|
'inputs' => ['image' => (string) $filename],
|
||||||
|
];
|
||||||
|
// V3 Autogrow inputs use dotted API keys: <group>.<generated input>.
|
||||||
|
// The visible character numbering remains one-based in prompts (<Picture 1>),
|
||||||
|
// while TemplatePrefix itself is zero-based (ref_image_0, ref_image_1, ...).
|
||||||
|
$conditionInputs['ref_images.ref_image_' . $index] = [$nodeId, 0];
|
||||||
|
}
|
||||||
|
$workflow['6'] = [
|
||||||
|
'_meta' => ['title' => 'H3 REF2VA 联合音画条件'],
|
||||||
|
'class_type' => 'MiniMaxH3ReferenceToVideo',
|
||||||
|
'inputs' => $conditionInputs,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$imageToVideoInputs = [
|
||||||
|
'clip' => ['3', 0],
|
||||||
|
'vae' => ['4', 0],
|
||||||
|
'prompt' => (string) $options['prompt'],
|
||||||
|
'width' => (int) $options['width'],
|
||||||
|
'height' => (int) $options['height'],
|
||||||
|
'length' => (int) $options['length'],
|
||||||
|
];
|
||||||
|
if (!empty($options['first_frame_file'])) {
|
||||||
|
$workflow['20'] = [
|
||||||
|
'_meta' => ['title' => '上一镜头真实尾帧'],
|
||||||
|
'class_type' => 'LoadImage',
|
||||||
|
'inputs' => ['image' => (string) $options['first_frame_file']],
|
||||||
|
];
|
||||||
|
$imageToVideoInputs['first_frame'] = ['20', 0];
|
||||||
|
}
|
||||||
|
$workflow['6'] = [
|
||||||
|
'_meta' => ['title' => 'H3 FL2VA / 首帧续拍联合音画条件'],
|
||||||
|
'class_type' => 'MiniMaxH3ImageToVideo',
|
||||||
|
'inputs' => $imageToVideoInputs,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$workflow += [
|
||||||
|
'7' => [
|
||||||
|
'_meta' => ['title' => '零负向条件'],
|
||||||
|
'class_type' => 'ConditioningZeroOut',
|
||||||
|
'inputs' => ['conditioning' => ['6', 0]],
|
||||||
|
],
|
||||||
|
'8' => [
|
||||||
|
'_meta' => ['title' => 'H3 联合视频+音频采样器'],
|
||||||
|
'class_type' => 'KSampler',
|
||||||
|
'inputs' => [
|
||||||
|
'model' => ['2', 0],
|
||||||
|
'seed' => (int) $options['seed'],
|
||||||
|
'steps' => (int) $options['steps'],
|
||||||
|
'cfg' => 1.0,
|
||||||
|
'sampler_name' => 'euler',
|
||||||
|
'scheduler' => 'simple',
|
||||||
|
'positive' => ['6', 0],
|
||||||
|
'negative' => ['7', 0],
|
||||||
|
'latent_image' => ['6', 1],
|
||||||
|
'denoise' => 1.0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'9' => [
|
||||||
|
'_meta' => ['title' => '拆分联合视频/音频潜变量'],
|
||||||
|
'class_type' => 'LTXVSeparateAVLatent',
|
||||||
|
'inputs' => ['av_latent' => ['8', 0]],
|
||||||
|
],
|
||||||
|
'10' => [
|
||||||
|
'_meta' => ['title' => '解码视频画面'],
|
||||||
|
'class_type' => 'VAEDecode',
|
||||||
|
'inputs' => ['samples' => ['9', 0], 'vae' => ['4', 0]],
|
||||||
|
],
|
||||||
|
'11' => [
|
||||||
|
'_meta' => ['title' => '解码 H3 原生同步音轨'],
|
||||||
|
'class_type' => 'VAEDecodeAudio',
|
||||||
|
'inputs' => ['samples' => ['9', 1], 'vae' => ['5', 0]],
|
||||||
|
],
|
||||||
|
'12' => [
|
||||||
|
'_meta' => ['title' => '24fps 联合音画封装'],
|
||||||
|
'class_type' => 'CreateVideo',
|
||||||
|
'inputs' => ['images' => ['10', 0], 'fps' => 24.0, 'audio' => ['11', 0], 'bit_depth' => 8],
|
||||||
|
],
|
||||||
|
'13' => [
|
||||||
|
'_meta' => ['title' => '保存 H3 联合音画 MP4'],
|
||||||
|
'class_type' => 'SaveVideo',
|
||||||
|
'inputs' => [
|
||||||
|
'video' => ['12', 0],
|
||||||
|
'filename_prefix' => 'short_drama/H3_AV_V2_',
|
||||||
|
'format' => 'mp4',
|
||||||
|
'codec' => 'auto',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
return $workflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{int,int,int} */
|
||||||
|
private static function generationPreset(string $aspectRatio, string $quality): array
|
||||||
|
{
|
||||||
|
$portrait = $aspectRatio !== '16:9';
|
||||||
|
if ($quality === 'high') {
|
||||||
|
return $portrait ? [768, 1344, 16] : [1344, 768, 16];
|
||||||
|
}
|
||||||
|
if ($quality === 'standard') {
|
||||||
|
return $portrait ? [576, 1024, 12] : [1024, 576, 12];
|
||||||
|
}
|
||||||
|
return $portrait ? [512, 896, 8] : [896, 512, 8];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function frameLengthForDuration(int $durationSeconds): int
|
||||||
|
{
|
||||||
|
// H3 使用 17k+5 帧网格:124 帧约 5.17 秒,243 帧约 10.13 秒。
|
||||||
|
return $durationSeconds >= 10 ? 243 : 124;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function uploadInputImage(
|
||||||
|
string $baseUrl,
|
||||||
|
string $path,
|
||||||
|
string $apiKey,
|
||||||
|
string $purpose
|
||||||
|
): string {
|
||||||
|
$imageInfo = @getimagesize($path);
|
||||||
|
if (!is_array($imageInfo) || empty($imageInfo['mime'])) {
|
||||||
|
throw new \RuntimeException('角色参考图不是有效图片');
|
||||||
|
}
|
||||||
|
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||||
|
if (!in_array($extension, ['png', 'jpg', 'jpeg', 'webp'], true)) {
|
||||||
|
$extension = $imageInfo['mime'] === 'image/jpeg' ? 'jpg' : 'png';
|
||||||
|
}
|
||||||
|
$subfolder = 'short_drama/' . date('Ymd');
|
||||||
|
$uploadName = $purpose . '_' . bin2hex(random_bytes(8)) . '.' . $extension;
|
||||||
|
$ch = curl_init($baseUrl . '/upload/image');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => [
|
||||||
|
'image' => new \CURLFile($path, (string) $imageInfo['mime'], $uploadName),
|
||||||
|
'type' => 'input',
|
||||||
|
'subfolder' => $subfolder,
|
||||||
|
'overwrite' => 'true',
|
||||||
|
],
|
||||||
|
CURLOPT_HTTPHEADER => self::authHeaders($apiKey),
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 90,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 15,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => false,
|
||||||
|
]);
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$error = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($response === false || $httpCode < 200 || $httpCode >= 300) {
|
||||||
|
throw new \RuntimeException('上传角色参考图到 ComfyUI 失败: ' . ($error ?: 'HTTP ' . $httpCode));
|
||||||
|
}
|
||||||
|
$data = json_decode((string) $response, true);
|
||||||
|
$name = trim((string) ($data['name'] ?? $uploadName));
|
||||||
|
$storedSubfolder = trim((string) ($data['subfolder'] ?? $subfolder), '/\\');
|
||||||
|
return $storedSubfolder === '' ? $name : $storedSubfolder . '/' . $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function queuePrompt(string $baseUrl, array $workflow, string $apiKey): string
|
||||||
|
{
|
||||||
|
$prompt = new \stdClass();
|
||||||
|
foreach ($workflow as $id => $node) {
|
||||||
|
$prompt->{(string) $id} = $node;
|
||||||
|
}
|
||||||
|
$body = json_encode([
|
||||||
|
'prompt' => $prompt,
|
||||||
|
'client_id' => 'short-drama-' . bin2hex(random_bytes(6)),
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
if ($body === false) {
|
||||||
|
throw new \RuntimeException('H3 工作流编码失败');
|
||||||
|
}
|
||||||
|
$ch = curl_init($baseUrl . '/prompt');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $body,
|
||||||
|
CURLOPT_HTTPHEADER => array_merge(['Content-Type: application/json'], self::authHeaders($apiKey)),
|
||||||
|
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);
|
||||||
|
$error = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
$data = json_decode((string) $response, true);
|
||||||
|
if ($response === false || $httpCode !== 200 || !empty($data['node_errors'])) {
|
||||||
|
$detail = $data['error']['message'] ?? $data['error'] ?? ($error ?: 'HTTP ' . $httpCode);
|
||||||
|
if (is_array($detail)) {
|
||||||
|
$detail = json_encode($detail, JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
if (!empty($data['node_errors'])) {
|
||||||
|
$detail .= ';' . json_encode($data['node_errors'], JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
throw new \RuntimeException('ComfyUI 拒绝 H3 工作流: ' . $detail);
|
||||||
|
}
|
||||||
|
$promptId = trim((string) ($data['prompt_id'] ?? ''));
|
||||||
|
if ($promptId === '') {
|
||||||
|
throw new \RuntimeException('ComfyUI 未返回视频任务 ID');
|
||||||
|
}
|
||||||
|
return $promptId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function collectVideoFiles(array $outputs): array
|
||||||
|
{
|
||||||
|
$files = [];
|
||||||
|
$walk = function (mixed $value) use (&$files, &$walk): void {
|
||||||
|
if (!is_array($value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isset($value['filename'])) {
|
||||||
|
$extension = strtolower(pathinfo((string) $value['filename'], PATHINFO_EXTENSION));
|
||||||
|
if (in_array($extension, ['mp4', 'webm', 'mov'], true)) {
|
||||||
|
$files[] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($value as $child) {
|
||||||
|
if (is_array($child)) {
|
||||||
|
$walk($child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$walk($outputs);
|
||||||
|
|
||||||
|
$unique = [];
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$key = ($file['type'] ?? 'output') . '|' . ($file['subfolder'] ?? '') . '|' . $file['filename'];
|
||||||
|
$unique[$key] = $file;
|
||||||
|
}
|
||||||
|
return array_values($unique);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function getJson(string $url, string $apiKey): array
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_HTTPGET => true,
|
||||||
|
CURLOPT_HTTPHEADER => self::authHeaders($apiKey),
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 8,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 3,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => false,
|
||||||
|
]);
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($response === false || $httpCode >= 400) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$data = json_decode((string) $response, true);
|
||||||
|
return is_array($data) ? $data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function getBinary(string $url, string $apiKey): ?string
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_HTTPGET => true,
|
||||||
|
CURLOPT_HTTPHEADER => self::authHeaders($apiKey),
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 180,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 15,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => false,
|
||||||
|
]);
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
return $response !== false && $httpCode === 200 ? $response : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function baseUrl(?string $url): string
|
||||||
|
{
|
||||||
|
$url = rtrim((string) $url, '/');
|
||||||
|
if ($url === '') {
|
||||||
|
throw new \InvalidArgumentException('未配置 ComfyUI 地址');
|
||||||
|
}
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function authHeaders(string $apiKey): array
|
||||||
|
{
|
||||||
|
return trim($apiKey) === '' ? [] : ['Authorization: Bearer ' . $apiKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,35 @@ class OpenAIService
|
|||||||
return $model;
|
return $model;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 语音合成必须使用 OpenAI 兼容协议模型。Dify/ComfyUI 的 API 地址不提供
|
||||||
|
* /audio/speech,因此所选对话模型不兼容时自动回落到已启用的 OpenAI 模型。
|
||||||
|
*/
|
||||||
|
public static function getSpeechModel(?int $preferredModelId = null): AiModel
|
||||||
|
{
|
||||||
|
$model = null;
|
||||||
|
if ($preferredModelId) {
|
||||||
|
$model = AiModel::where('id', $preferredModelId)
|
||||||
|
->where('enabled', 1)
|
||||||
|
->where('provider', 'openai')
|
||||||
|
->find();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$model) {
|
||||||
|
$model = AiModel::where('enabled', 1)
|
||||||
|
->where('provider', 'openai')
|
||||||
|
->order('is_default', 'desc')
|
||||||
|
->order('sort_order')
|
||||||
|
->find();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$model) {
|
||||||
|
self::throwUnavailableModel('未配置支持语音合成的 OpenAI 兼容模型');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $model;
|
||||||
|
}
|
||||||
|
|
||||||
public static function getImageModel(?int $preferredModelId = null): AiModel
|
public static function getImageModel(?int $preferredModelId = null): AiModel
|
||||||
{
|
{
|
||||||
$model = null;
|
$model = null;
|
||||||
@@ -252,6 +281,106 @@ class OpenAIService
|
|||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用神经语音模型生成短句 WAV。短句由前端在文本流式输出期间提前提交,
|
||||||
|
* WAV 则避免浏览器额外的解码启动开销。
|
||||||
|
*
|
||||||
|
* @return array{audio: string, content_type: string}
|
||||||
|
*/
|
||||||
|
public static function speech(AiModel $model, string $input, string $voice = 'marin'): array
|
||||||
|
{
|
||||||
|
$extra = is_array($model->extra_config ?? null) ? $model->extra_config : [];
|
||||||
|
$ttsModel = trim((string) ($extra['tts_model'] ?? 'gpt-4o-mini-tts'));
|
||||||
|
$ttsVoice = trim((string) ($extra['tts_voice'] ?? $voice));
|
||||||
|
$instructions = trim((string) ($extra['tts_instructions'] ?? (
|
||||||
|
'Speak in natural, warm, conversational Mandarin Chinese. '
|
||||||
|
. 'Use relaxed pacing, subtle emotion, human-like phrasing and short natural pauses. '
|
||||||
|
. 'Avoid an announcer, customer-service, robotic, or overly enthusiastic tone.'
|
||||||
|
)));
|
||||||
|
|
||||||
|
$allowedVoices = [
|
||||||
|
'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova',
|
||||||
|
'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar',
|
||||||
|
];
|
||||||
|
if (!in_array($ttsVoice, $allowedVoices, true)) {
|
||||||
|
$ttsVoice = 'marin';
|
||||||
|
}
|
||||||
|
if ($ttsModel === '') {
|
||||||
|
$ttsModel = 'gpt-4o-mini-tts';
|
||||||
|
}
|
||||||
|
|
||||||
|
$legacyTts = in_array($ttsModel, ['tts-1', 'tts-1-hd'], true);
|
||||||
|
if ($legacyTts && !in_array($ttsVoice, ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'], true)) {
|
||||||
|
$ttsVoice = 'nova';
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = rtrim((string) $model->api_base_url, '/') . '/audio/speech';
|
||||||
|
$payload = [
|
||||||
|
'model' => $ttsModel,
|
||||||
|
'input' => $input,
|
||||||
|
'voice' => $ttsVoice,
|
||||||
|
'response_format' => 'wav',
|
||||||
|
];
|
||||||
|
if (!$legacyTts && $instructions !== '') {
|
||||||
|
$payload['instructions'] = $instructions;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'Accept: audio/wav',
|
||||||
|
'Authorization: Bearer ' . $model->api_key,
|
||||||
|
],
|
||||||
|
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);
|
||||||
|
$contentType = (string) (curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: 'audio/wav');
|
||||||
|
$curlError = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($response === false) {
|
||||||
|
throw new HttpResponseException(json([
|
||||||
|
'code' => 1,
|
||||||
|
'message' => '语音合成连接失败: ' . ($curlError ?: '网络不可达'),
|
||||||
|
'data' => null,
|
||||||
|
], 502));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($httpCode < 200 || $httpCode >= 300) {
|
||||||
|
$detail = self::parseErrorBody($response) ?: ('HTTP ' . $httpCode);
|
||||||
|
throw new HttpResponseException(json([
|
||||||
|
'code' => 1,
|
||||||
|
'message' => '自然语音生成失败: ' . $detail,
|
||||||
|
'data' => null,
|
||||||
|
], 502));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response === '') {
|
||||||
|
throw new HttpResponseException(json([
|
||||||
|
'code' => 1,
|
||||||
|
'message' => '语音服务返回了空音频',
|
||||||
|
'data' => null,
|
||||||
|
], 502));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!str_starts_with(strtolower($contentType), 'audio/')) {
|
||||||
|
$contentType = 'audio/wav';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'audio' => $response,
|
||||||
|
'content_type' => $contentType,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 测试模型连接是否正常
|
* 测试模型连接是否正常
|
||||||
* @return array{success: bool, latency_ms: int, reply: string, model: string}
|
* @return array{success: bool, latency_ms: int, reply: string, model: string}
|
||||||
|
|||||||
@@ -47,6 +47,17 @@ class PermissionCatalog
|
|||||||
['code' => 'btn:user:delete', 'name' => '删除用户', 'type' => 'btn'],
|
['code' => 'btn:user:delete', 'name' => '删除用户', 'type' => 'btn'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'code' => 'menu:guests',
|
||||||
|
'name' => '访客管理',
|
||||||
|
'type' => 'menu',
|
||||||
|
'path' => '/guests',
|
||||||
|
'icon' => 'visitors',
|
||||||
|
'children' => [
|
||||||
|
['code' => 'btn:guest:status', 'name' => '启用/禁用访客', 'type' => 'btn'],
|
||||||
|
['code' => 'btn:guest:delete', 'name' => '删除访客', 'type' => 'btn'],
|
||||||
|
],
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'code' => 'menu:departments',
|
'code' => 'menu:departments',
|
||||||
'name' => '部门管理',
|
'name' => '部门管理',
|
||||||
@@ -59,6 +70,17 @@ class PermissionCatalog
|
|||||||
['code' => 'btn:dept:delete', 'name' => '删除部门', 'type' => 'btn'],
|
['code' => 'btn:dept:delete', 'name' => '删除部门', 'type' => 'btn'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'code' => 'menu:invitations',
|
||||||
|
'name' => '邀请码管理',
|
||||||
|
'type' => 'menu',
|
||||||
|
'path' => '/invitations',
|
||||||
|
'icon' => 'ticket',
|
||||||
|
'children' => [
|
||||||
|
['code' => 'btn:invitation:create', 'name' => '生成邀请码', 'type' => 'btn'],
|
||||||
|
['code' => 'btn:invitation:revoke', 'name' => '作废邀请码', 'type' => 'btn'],
|
||||||
|
],
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'code' => 'menu:roles',
|
'code' => 'menu:roles',
|
||||||
'name' => '角色管理',
|
'name' => '角色管理',
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class PermissionService
|
|||||||
|
|
||||||
public static function canUpload(array $user, string $type): bool
|
public static function canUpload(array $user, string $type): bool
|
||||||
{
|
{
|
||||||
if (!empty($user['is_guest']) && $type === 'image') {
|
if (!empty($user['is_guest'])) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,20 @@ use app\model\SystemSetting;
|
|||||||
|
|
||||||
class SettingsService
|
class SettingsService
|
||||||
{
|
{
|
||||||
|
private const DEFAULT_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,
|
||||||
|
'short_drama' => true,
|
||||||
|
];
|
||||||
|
|
||||||
public static function get(string $key, mixed $default = null): mixed
|
public static function get(string $key, mixed $default = null): mixed
|
||||||
{
|
{
|
||||||
$row = SystemSetting::where('setting_key', $key)->find();
|
$row = SystemSetting::where('setting_key', $key)->find();
|
||||||
@@ -36,18 +50,8 @@ class SettingsService
|
|||||||
|
|
||||||
public static function getFeatures(): array
|
public static function getFeatures(): array
|
||||||
{
|
{
|
||||||
return self::get('features', [
|
$stored = self::get('features', []);
|
||||||
'markdown' => true,
|
return array_replace(self::DEFAULT_FEATURES, is_array($stored) ? $stored : []);
|
||||||
'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
|
public static function isFeatureEnabled(string $feature): bool
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,308 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\service;
|
||||||
|
|
||||||
|
use app\model\UploadFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 短剧确定性配音:不再依赖 H3 原生音频猜测语言。
|
||||||
|
*/
|
||||||
|
class VideoDubService
|
||||||
|
{
|
||||||
|
public const LANG_MANDARIN = 'zh-CN';
|
||||||
|
public const LANG_NONE = 'none';
|
||||||
|
public const LANG_NATIVE = 'native';
|
||||||
|
public const AUDIO_CHARACTER = 'character_dialogue';
|
||||||
|
public const AUDIO_NARRATION = 'narration';
|
||||||
|
public const AUDIO_SCENE = 'scene_sound';
|
||||||
|
public const AUDIO_AMBIENT = 'ambient_only';
|
||||||
|
public const AUDIO_POST_TTS = 'post_tts_narration';
|
||||||
|
public const POLICY_VERSION = 'h3-joint-av-v5';
|
||||||
|
|
||||||
|
public static function normalizeLanguage(?string $language): string
|
||||||
|
{
|
||||||
|
return in_array($language, [self::LANG_MANDARIN, self::LANG_NONE, self::LANG_NATIVE], true)
|
||||||
|
? (string) $language
|
||||||
|
: self::LANG_MANDARIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function label(string $language): string
|
||||||
|
{
|
||||||
|
return match (self::normalizeLanguage($language)) {
|
||||||
|
self::LANG_NONE => '无配音',
|
||||||
|
self::LANG_NATIVE => 'H3 原生音轨',
|
||||||
|
default => '普通话(H3 原生口型同步)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function normalizeAudioMode(?string $mode): string
|
||||||
|
{
|
||||||
|
return in_array($mode, [
|
||||||
|
self::AUDIO_CHARACTER,
|
||||||
|
self::AUDIO_NARRATION,
|
||||||
|
self::AUDIO_SCENE,
|
||||||
|
self::AUDIO_AMBIENT,
|
||||||
|
self::AUDIO_POST_TTS,
|
||||||
|
], true) ? (string) $mode : self::AUDIO_AMBIENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{id:int,processed:bool,language:string}
|
||||||
|
*/
|
||||||
|
public static function replaceVoice(
|
||||||
|
int $videoUploadId,
|
||||||
|
int $userId,
|
||||||
|
string $dialogue,
|
||||||
|
string $language,
|
||||||
|
int $durationSeconds = 5,
|
||||||
|
string $placement = 'start',
|
||||||
|
array $audioPolicy = []
|
||||||
|
): array {
|
||||||
|
$language = self::normalizeLanguage($language);
|
||||||
|
$audioMode = self::normalizeAudioMode((string) ($audioPolicy['audio_mode'] ?? self::AUDIO_AMBIENT));
|
||||||
|
if ($language === self::LANG_NATIVE) {
|
||||||
|
return [
|
||||||
|
'id' => $videoUploadId,
|
||||||
|
'processed' => false,
|
||||||
|
'language' => $language,
|
||||||
|
'source' => 'h3_native',
|
||||||
|
'policy_version' => self::POLICY_VERSION,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($language === self::LANG_MANDARIN && $audioMode === self::AUDIO_CHARACTER) {
|
||||||
|
// 只有画面角色对白保留 H3 联合采样原声,确保人物性别、声音和口型来自同一次生成。
|
||||||
|
return [
|
||||||
|
'id' => $videoUploadId,
|
||||||
|
'processed' => false,
|
||||||
|
'language' => $language,
|
||||||
|
'source' => 'h3_native',
|
||||||
|
'policy_version' => self::POLICY_VERSION,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($language === self::LANG_MANDARIN && $audioMode === self::AUDIO_SCENE) {
|
||||||
|
// 场景音由 H3 与画面同次联合生成,才能让撞击、脚步、发动机等声音
|
||||||
|
// 精确跟随动作;规划器已明确禁止该类镜头产生任何人物声音。
|
||||||
|
return [
|
||||||
|
'id' => $videoUploadId,
|
||||||
|
'processed' => false,
|
||||||
|
'language' => $language,
|
||||||
|
'source' => 'h3_scene_sound',
|
||||||
|
'policy_version' => self::POLICY_VERSION,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($language === self::LANG_MANDARIN && $audioMode === self::AUDIO_AMBIENT) {
|
||||||
|
// H3 已经在同一次联合采样里生成与画面同步的空间底噪、动作声和
|
||||||
|
// 环境声。旧版用极低音量粉红噪声覆盖它,最终成片约 -71dB,
|
||||||
|
// 听感接近静音;保留原声既有声音,也不会破坏动作同步。
|
||||||
|
return [
|
||||||
|
'id' => $videoUploadId,
|
||||||
|
'processed' => false,
|
||||||
|
'language' => $language,
|
||||||
|
'source' => 'h3_ambient',
|
||||||
|
'policy_version' => self::POLICY_VERSION,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$video = UploadFile::where('id', $videoUploadId)
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('file_type', 'video')
|
||||||
|
->find();
|
||||||
|
if (!$video) {
|
||||||
|
throw new \RuntimeException('无法读取待配音视频');
|
||||||
|
}
|
||||||
|
$videoPath = self::uploadPath($video);
|
||||||
|
if (!is_file($videoPath)) {
|
||||||
|
throw new \RuntimeException('待配音视频文件不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
$durationSeconds = max(1, min(30, $durationSeconds));
|
||||||
|
$pcmPath = null;
|
||||||
|
$speechTempoFilter = '';
|
||||||
|
$voiceDelayMs = 200;
|
||||||
|
if ($language === self::LANG_MANDARIN && $audioMode === self::AUDIO_NARRATION) {
|
||||||
|
$spokenText = self::spokenText($dialogue);
|
||||||
|
if ($spokenText !== '') {
|
||||||
|
$pcmPath = tempnam(sys_get_temp_dir(), 'short_drama_tts_');
|
||||||
|
if ($pcmPath === false) {
|
||||||
|
throw new \RuntimeException('无法创建普通话配音任务');
|
||||||
|
}
|
||||||
|
$pcm = self::synthesizeMandarin($spokenText);
|
||||||
|
if (file_put_contents($pcmPath, $pcm) === false) {
|
||||||
|
@unlink($pcmPath);
|
||||||
|
throw new \RuntimeException('无法保存普通话配音数据');
|
||||||
|
}
|
||||||
|
// 24 kHz、16 bit、单声道 PCM:48000 bytes/s。超过镜头时长时加速,避免截断台词。
|
||||||
|
$pcmDuration = strlen($pcm) / 48000;
|
||||||
|
$targetDuration = max(0.8, $durationSeconds - 0.4);
|
||||||
|
$tempo = max(1.0, $pcmDuration / $targetDuration);
|
||||||
|
$speechTempoFilter = self::tempoFilter($tempo);
|
||||||
|
$speechDuration = $pcmDuration / $tempo;
|
||||||
|
if ($placement === 'end') {
|
||||||
|
$voiceDelayMs = max(200, (int) round(
|
||||||
|
max(0.0, $durationSeconds - $speechDuration - 0.3) * 1000
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$subdir = date('Y/m/d');
|
||||||
|
$storedBase = 'dub_' . str_replace('-', '_', $language) . '_' . uniqid('', true) . '.mp4';
|
||||||
|
$relativePath = $subdir . '/' . $storedBase;
|
||||||
|
$fullDir = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
|
||||||
|
. str_replace('/', DIRECTORY_SEPARATOR, $subdir);
|
||||||
|
if (!is_dir($fullDir) && !mkdir($fullDir, 0755, true) && !is_dir($fullDir)) {
|
||||||
|
if ($pcmPath) {
|
||||||
|
@unlink($pcmPath);
|
||||||
|
}
|
||||||
|
throw new \RuntimeException('无法创建配音视频目录');
|
||||||
|
}
|
||||||
|
$outputPath = $fullDir . DIRECTORY_SEPARATOR . $storedBase;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$command = $audioMode === self::AUDIO_AMBIENT && $language !== self::LANG_NONE
|
||||||
|
? [
|
||||||
|
'ffmpeg', '-y', '-i', $videoPath,
|
||||||
|
'-f', 'lavfi', '-i', 'anoisesrc=color=pink:amplitude=0.006:r=48000',
|
||||||
|
'-filter_complex', '[1:a]highpass=f=80,lowpass=f=4800,volume=0.35,apad,atrim=0:' . $durationSeconds . '[amb]',
|
||||||
|
'-map', '0:v:0', '-map', '[amb]',
|
||||||
|
'-t', (string) $durationSeconds,
|
||||||
|
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '128k',
|
||||||
|
'-movflags', '+faststart', $outputPath,
|
||||||
|
]
|
||||||
|
: ($language === self::LANG_NONE || $pcmPath === null
|
||||||
|
? [
|
||||||
|
'ffmpeg', '-y', '-i', $videoPath,
|
||||||
|
'-f', 'lavfi', '-i', 'anullsrc=r=48000:cl=mono',
|
||||||
|
'-map', '0:v:0', '-map', '1:a:0',
|
||||||
|
'-t', (string) $durationSeconds,
|
||||||
|
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '128k',
|
||||||
|
'-movflags', '+faststart', $outputPath,
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
'ffmpeg', '-y', '-i', $videoPath,
|
||||||
|
'-f', 's16le', '-ar', '24000', '-ac', '1', '-i', (string) $pcmPath,
|
||||||
|
'-filter_complex', "[1:a]aresample=48000{$speechTempoFilter},volume=2.0,alimiter=limit=0.90:level=false,adelay={$voiceDelayMs},apad,atrim=0:{$durationSeconds}[dub]",
|
||||||
|
'-map', '0:v:0', '-map', '[dub]',
|
||||||
|
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '160k',
|
||||||
|
'-movflags', '+faststart', '-shortest', $outputPath,
|
||||||
|
]);
|
||||||
|
[$exitCode, $error] = self::run($command);
|
||||||
|
if ($exitCode !== 0 || !is_file($outputPath) || filesize($outputPath) === 0) {
|
||||||
|
@unlink($outputPath);
|
||||||
|
throw new \RuntimeException('普通话音轨合成失败: ' . mb_substr(trim($error), -500));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if ($pcmPath) {
|
||||||
|
@unlink($pcmPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$upload = UploadFile::create([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'original_name' => 'short_drama_' . $language . '.mp4',
|
||||||
|
'stored_name' => $storedBase,
|
||||||
|
'file_path' => $relativePath,
|
||||||
|
'mime_type' => 'video/mp4',
|
||||||
|
'file_size' => (int) filesize($outputPath),
|
||||||
|
'file_type' => 'video',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) $upload->id,
|
||||||
|
'processed' => true,
|
||||||
|
'language' => $language,
|
||||||
|
'source' => match (true) {
|
||||||
|
$language === self::LANG_NONE => 'silence',
|
||||||
|
$audioMode === self::AUDIO_AMBIENT => 'clean_ambient',
|
||||||
|
default => 'cosyvoice',
|
||||||
|
},
|
||||||
|
'policy_version' => self::POLICY_VERSION,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function tempoFilter(float $tempo): string
|
||||||
|
{
|
||||||
|
if ($tempo <= 1.0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$filters = [];
|
||||||
|
while ($tempo > 2.0) {
|
||||||
|
$filters[] = 'atempo=2.0';
|
||||||
|
$tempo /= 2.0;
|
||||||
|
}
|
||||||
|
$filters[] = 'atempo=' . number_format(max(1.0, $tempo), 4, '.', '');
|
||||||
|
return ',' . implode(',', $filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function synthesizeMandarin(string $text): string
|
||||||
|
{
|
||||||
|
$baseUrl = rtrim((string) config('short_drama.tts_base_url'), '/');
|
||||||
|
$promptWav = (string) config('short_drama.tts_prompt_wav');
|
||||||
|
$promptText = (string) config('short_drama.tts_prompt_text');
|
||||||
|
if ($baseUrl === '' || !is_file($promptWav)) {
|
||||||
|
throw new \RuntimeException('普通话配音服务尚未配置');
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($baseUrl . '/inference_zero_shot');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => [
|
||||||
|
'tts_text' => $text,
|
||||||
|
'prompt_text' => $promptText,
|
||||||
|
'prompt_wav' => new \CURLFile($promptWav, 'audio/wav', 'mandarin_reference.wav'),
|
||||||
|
],
|
||||||
|
CURLOPT_HTTPHEADER => ['Accept: application/octet-stream'],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 90,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 5,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => false,
|
||||||
|
]);
|
||||||
|
$pcm = curl_exec($ch);
|
||||||
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$error = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($pcm === false || $httpCode < 200 || $httpCode >= 300) {
|
||||||
|
throw new \RuntimeException('普通话配音服务调用失败: ' . ($error ?: 'HTTP ' . $httpCode));
|
||||||
|
}
|
||||||
|
if (strlen((string) $pcm) < 4800) {
|
||||||
|
throw new \RuntimeException('普通话配音服务返回空音频');
|
||||||
|
}
|
||||||
|
if (strlen($pcm) % 2 !== 0) {
|
||||||
|
$pcm = substr($pcm, 0, -1);
|
||||||
|
}
|
||||||
|
return $pcm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function spokenText(string $dialogue): string
|
||||||
|
{
|
||||||
|
$dialogue = trim($dialogue);
|
||||||
|
$dialogue = preg_replace('/^[^::]{1,16}[::]/u', '', $dialogue) ?? $dialogue;
|
||||||
|
return mb_substr(trim($dialogue), 0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function uploadPath(UploadFile $upload): string
|
||||||
|
{
|
||||||
|
return rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
|
||||||
|
. str_replace('/', DIRECTORY_SEPARATOR, (string) $upload->file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{int,string} */
|
||||||
|
private static function run(array $command): array
|
||||||
|
{
|
||||||
|
$pipes = [];
|
||||||
|
$process = @proc_open($command, [
|
||||||
|
0 => ['pipe', 'r'],
|
||||||
|
1 => ['pipe', 'w'],
|
||||||
|
2 => ['pipe', 'w'],
|
||||||
|
], $pipes);
|
||||||
|
if (!is_resource($process)) {
|
||||||
|
throw new \RuntimeException('服务器无法启动 FFmpeg');
|
||||||
|
}
|
||||||
|
fclose($pipes[0]);
|
||||||
|
stream_get_contents($pipes[1]);
|
||||||
|
fclose($pipes[1]);
|
||||||
|
$error = (string) stream_get_contents($pipes[2]);
|
||||||
|
fclose($pipes[2]);
|
||||||
|
return [proc_close($process), $error];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\service;
|
||||||
|
|
||||||
|
use app\model\UploadFile;
|
||||||
|
|
||||||
|
class VideoRenderService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 提取已生成镜头的最后一帧,作为下一镜头的真实视觉起点。
|
||||||
|
*/
|
||||||
|
public static function extractLastFrame(int $videoUploadId, int $userId): int
|
||||||
|
{
|
||||||
|
$video = UploadFile::where('id', $videoUploadId)
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('file_type', 'video')
|
||||||
|
->find();
|
||||||
|
if (!$video) {
|
||||||
|
throw new \RuntimeException('无法读取上一镜头视频');
|
||||||
|
}
|
||||||
|
|
||||||
|
$videoPath = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
|
||||||
|
. str_replace('/', DIRECTORY_SEPARATOR, (string) $video->file_path);
|
||||||
|
if (!is_file($videoPath)) {
|
||||||
|
throw new \RuntimeException('上一镜头视频文件不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
$subdir = date('Y/m/d');
|
||||||
|
$storedBase = 'continuity_' . uniqid('', true) . '.jpg';
|
||||||
|
$relativePath = $subdir . '/' . $storedBase;
|
||||||
|
$fullDir = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
|
||||||
|
. str_replace('/', DIRECTORY_SEPARATOR, $subdir);
|
||||||
|
if (!is_dir($fullDir) && !mkdir($fullDir, 0755, true) && !is_dir($fullDir)) {
|
||||||
|
throw new \RuntimeException('无法创建镜头连续帧目录');
|
||||||
|
}
|
||||||
|
$outputPath = $fullDir . DIRECTORY_SEPARATOR . $storedBase;
|
||||||
|
|
||||||
|
// H3 输出最后几帧有时会包含编码尾部黑帧,向前取 0.12 秒更稳定。
|
||||||
|
[$exitCode, $error] = self::run([
|
||||||
|
'ffmpeg', '-y', '-sseof', '-0.12', '-i', $videoPath,
|
||||||
|
'-frames:v', '1', '-q:v', '2', $outputPath,
|
||||||
|
]);
|
||||||
|
if ($exitCode !== 0 || !is_file($outputPath) || filesize($outputPath) === 0) {
|
||||||
|
@unlink($outputPath);
|
||||||
|
throw new \RuntimeException('提取镜头尾帧失败: ' . mb_substr(trim($error), -400));
|
||||||
|
}
|
||||||
|
|
||||||
|
$upload = UploadFile::create([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'original_name' => 'continuity_last_frame.jpg',
|
||||||
|
'stored_name' => $storedBase,
|
||||||
|
'file_path' => $relativePath,
|
||||||
|
'mime_type' => 'image/jpeg',
|
||||||
|
'file_size' => (int) filesize($outputPath),
|
||||||
|
'file_type' => 'image',
|
||||||
|
]);
|
||||||
|
return (int) $upload->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,array<string,mixed>> $shots
|
||||||
|
*/
|
||||||
|
public static function concatenate(
|
||||||
|
array $shots,
|
||||||
|
int $userId,
|
||||||
|
bool $showSubtitles = false,
|
||||||
|
string $aspectRatio = '9:16',
|
||||||
|
string $screenTextLanguage = ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_NONE,
|
||||||
|
string $quality = 'standard'
|
||||||
|
): int
|
||||||
|
{
|
||||||
|
$uploadIds = [];
|
||||||
|
$durations = [];
|
||||||
|
$renderShots = [];
|
||||||
|
foreach ($shots as $shot) {
|
||||||
|
$uploadId = (int) ($shot['output_upload_id'] ?? 0);
|
||||||
|
if ($uploadId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$uploadIds[] = $uploadId;
|
||||||
|
$durations[] = max(1, min(30, (int) ($shot['duration_seconds'] ?? 5)));
|
||||||
|
$renderShots[] = $shot;
|
||||||
|
}
|
||||||
|
if (!$uploadIds) {
|
||||||
|
throw new \RuntimeException('没有可合成的视频镜头');
|
||||||
|
}
|
||||||
|
$overlayDocument = self::overlayDocument(
|
||||||
|
$renderShots,
|
||||||
|
$durations,
|
||||||
|
$aspectRatio,
|
||||||
|
$showSubtitles,
|
||||||
|
$screenTextLanguage
|
||||||
|
);
|
||||||
|
if (count($uploadIds) === 1 && $overlayDocument === null) {
|
||||||
|
return $uploadIds[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$uploads = UploadFile::whereIn('id', $uploadIds)
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->select()
|
||||||
|
->column(null, 'id');
|
||||||
|
$paths = [];
|
||||||
|
foreach ($uploadIds as $uploadId) {
|
||||||
|
$upload = $uploads[$uploadId] ?? null;
|
||||||
|
if (!$upload) {
|
||||||
|
throw new \RuntimeException('部分视频镜头文件已不存在');
|
||||||
|
}
|
||||||
|
$path = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
|
||||||
|
. str_replace('/', DIRECTORY_SEPARATOR, (string) $upload->file_path);
|
||||||
|
if (!is_file($path)) {
|
||||||
|
throw new \RuntimeException('视频镜头文件无法读取');
|
||||||
|
}
|
||||||
|
$paths[] = $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subdir = date('Y/m/d');
|
||||||
|
$storedBase = 'short_drama_' . uniqid('', true) . '.mp4';
|
||||||
|
$relativePath = $subdir . '/' . $storedBase;
|
||||||
|
$fullDir = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
|
||||||
|
. str_replace('/', DIRECTORY_SEPARATOR, $subdir);
|
||||||
|
if (!is_dir($fullDir) && !mkdir($fullDir, 0755, true) && !is_dir($fullDir)) {
|
||||||
|
throw new \RuntimeException('无法创建短剧成片目录');
|
||||||
|
}
|
||||||
|
$outputPath = $fullDir . DIRECTORY_SEPARATOR . $storedBase;
|
||||||
|
$subtitlePath = null;
|
||||||
|
if ($overlayDocument !== null) {
|
||||||
|
$subtitlePath = $fullDir . DIRECTORY_SEPARATOR . 'subtitle_' . uniqid('', true) . '.ass';
|
||||||
|
if (file_put_contents($subtitlePath, $overlayDocument) === false) {
|
||||||
|
throw new \RuntimeException('无法创建文字合成文件');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$command = ['ffmpeg', '-y'];
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
$command[] = '-i';
|
||||||
|
$command[] = $path;
|
||||||
|
}
|
||||||
|
$filters = [];
|
||||||
|
$concatInputs = '';
|
||||||
|
[$targetWidth, $targetHeight] = self::renderSize($aspectRatio, $quality);
|
||||||
|
foreach ($durations as $index => $duration) {
|
||||||
|
// 不同批次或重试镜头可能使用不同质量档位。concat 要求每路画面和
|
||||||
|
// 音轨参数完全一致,因此先统一尺寸、SAR、帧率、像素格式和双声道。
|
||||||
|
$filters[] = "[{$index}:v:0]trim=duration={$duration},setpts=PTS-STARTPTS,"
|
||||||
|
. "scale={$targetWidth}:{$targetHeight}:force_original_aspect_ratio=decrease,"
|
||||||
|
. "pad={$targetWidth}:{$targetHeight}:(ow-iw)/2:(oh-ih)/2:color=black,"
|
||||||
|
. "setsar=1,fps=24,format=yuv420p[v{$index}]";
|
||||||
|
$filters[] = "[{$index}:a:0]aresample=48000,"
|
||||||
|
. "aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,"
|
||||||
|
. "atrim=duration={$duration},asetpts=PTS-STARTPTS[a{$index}]";
|
||||||
|
$concatInputs .= "[v{$index}][a{$index}]";
|
||||||
|
}
|
||||||
|
if (count($paths) === 1) {
|
||||||
|
$videoOutput = '[v0]';
|
||||||
|
$audioOutput = '[a0]';
|
||||||
|
} else {
|
||||||
|
$filters[] = $concatInputs . 'concat=n=' . count($paths) . ':v=1:a=1[vconcat][aout]';
|
||||||
|
$videoOutput = '[vconcat]';
|
||||||
|
$audioOutput = '[aout]';
|
||||||
|
}
|
||||||
|
if ($subtitlePath !== null) {
|
||||||
|
$filters[] = $videoOutput . "ass=filename='" . self::escapeFilterPath($subtitlePath) . "'[vout]";
|
||||||
|
$videoOutput = '[vout]';
|
||||||
|
}
|
||||||
|
array_push(
|
||||||
|
$command,
|
||||||
|
'-filter_complex', implode(';', $filters),
|
||||||
|
'-map', $videoOutput, '-map', $audioOutput,
|
||||||
|
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p',
|
||||||
|
'-c:a', 'aac', '-b:a', '192k', '-ar', '48000',
|
||||||
|
'-movflags', '+faststart', $outputPath
|
||||||
|
);
|
||||||
|
[$exitCode, $error] = self::run($command);
|
||||||
|
if ($subtitlePath !== null) {
|
||||||
|
@unlink($subtitlePath);
|
||||||
|
}
|
||||||
|
if ($exitCode !== 0 || !is_file($outputPath) || filesize($outputPath) === 0) {
|
||||||
|
@unlink($outputPath);
|
||||||
|
throw new \RuntimeException('视频合成失败: ' . mb_substr(trim($error), -600));
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = (int) filesize($outputPath);
|
||||||
|
$upload = UploadFile::create([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'original_name' => 'short_drama_episode.mp4',
|
||||||
|
'stored_name' => $storedBase,
|
||||||
|
'file_path' => $relativePath,
|
||||||
|
'mime_type' => 'video/mp4',
|
||||||
|
'file_size' => $size,
|
||||||
|
'file_type' => 'video',
|
||||||
|
]);
|
||||||
|
return (int) $upload->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成后期 ASS 文字层。字幕与场景文字都不交给视频模型直接绘制,避免乱码、漂移和闪烁。
|
||||||
|
*
|
||||||
|
* @param array<int,array<string,mixed>> $shots
|
||||||
|
* @param int[] $durations
|
||||||
|
*/
|
||||||
|
private static function overlayDocument(
|
||||||
|
array $shots,
|
||||||
|
array $durations,
|
||||||
|
string $aspectRatio,
|
||||||
|
bool $showSubtitles,
|
||||||
|
string $screenTextLanguage
|
||||||
|
): ?string
|
||||||
|
{
|
||||||
|
$screenTextLanguage = ShortDramaPlannerService::normalizeScreenTextLanguage($screenTextLanguage);
|
||||||
|
$portrait = $aspectRatio !== '16:9';
|
||||||
|
$playResX = $portrait ? 1080 : 1920;
|
||||||
|
$playResY = $portrait ? 1920 : 1080;
|
||||||
|
$fontSize = $portrait ? 54 : 48;
|
||||||
|
$marginV = $portrait ? 150 : 72;
|
||||||
|
$lineLength = $portrait ? 17 : 28;
|
||||||
|
$cursor = 0.0;
|
||||||
|
$events = [];
|
||||||
|
|
||||||
|
foreach ($shots as $index => $shot) {
|
||||||
|
$duration = (float) ($durations[$index] ?? 5);
|
||||||
|
$meta = is_array($shot['meta'] ?? null) ? $shot['meta'] : [];
|
||||||
|
if (!$meta && is_string($shot['meta'] ?? null)) {
|
||||||
|
$decoded = json_decode((string) $shot['meta'], true);
|
||||||
|
$meta = is_array($decoded) ? $decoded : [];
|
||||||
|
}
|
||||||
|
$timeline = is_array($meta['timeline'] ?? null) ? $meta['timeline'] : [];
|
||||||
|
$dialogue = $showSubtitles
|
||||||
|
? self::cleanSubtitleText((string) ($shot['dialogue'] ?? ''), $lineLength)
|
||||||
|
: '';
|
||||||
|
if ($dialogue !== '') {
|
||||||
|
$placement = (string) ($timeline['voice_timing'] ?? 'start');
|
||||||
|
$start = $cursor + 0.18;
|
||||||
|
if ($placement === 'end') {
|
||||||
|
$start = $cursor + max(0.18, $duration - 2.8);
|
||||||
|
}
|
||||||
|
$end = max($start + 0.5, $cursor + $duration - 0.16);
|
||||||
|
$events[] = 'Dialogue: 0,' . self::assTime($start) . ',' . self::assTime($end)
|
||||||
|
. ',Default,,0,0,0,,' . $dialogue;
|
||||||
|
}
|
||||||
|
$sceneText = $screenTextLanguage !== ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_NONE
|
||||||
|
? self::cleanSceneText((string) ($timeline['screen_text'] ?? ''), $portrait ? 14 : 24)
|
||||||
|
: '';
|
||||||
|
if ($sceneText !== '') {
|
||||||
|
$start = $cursor + 0.35;
|
||||||
|
$end = max($start + 0.6, $cursor + $duration - 0.25);
|
||||||
|
$events[] = 'Dialogue: 1,' . self::assTime($start) . ',' . self::assTime($end)
|
||||||
|
. ',SceneText,,0,0,0,,' . $sceneText;
|
||||||
|
}
|
||||||
|
$cursor += $duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$events) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "[Script Info]\n"
|
||||||
|
. "ScriptType: v4.00+\n"
|
||||||
|
. "PlayResX: {$playResX}\n"
|
||||||
|
. "PlayResY: {$playResY}\n"
|
||||||
|
. "WrapStyle: 0\n"
|
||||||
|
. "ScaledBorderAndShadow: yes\n\n"
|
||||||
|
. "[V4+ Styles]\n"
|
||||||
|
. "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
|
||||||
|
. "Style: Default,Noto Sans CJK SC,{$fontSize},&H00FFFFFF,&H00FFFFFF,&H50000000,&H78000000,-1,0,0,0,100,100,0,0,3,2,0,2,70,70,{$marginV},1\n"
|
||||||
|
. 'Style: SceneText,Noto Sans CJK SC,' . ($portrait ? 62 : 54) . ',&H00FFFFFF,&H00FFFFFF,&H78000000,&HA0000000,-1,0,0,0,100,100,0,0,3,3,0,8,90,90,' . ($portrait ? 260 : 105) . ",1\n\n"
|
||||||
|
. "[Events]\n"
|
||||||
|
. "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
|
||||||
|
. implode("\n", $events)
|
||||||
|
. "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function cleanSubtitleText(string $text, int $lineLength): string
|
||||||
|
{
|
||||||
|
$text = trim($text);
|
||||||
|
$text = preg_replace('/^[^::\n]{1,20}[::]\s*/u', '', $text) ?? $text;
|
||||||
|
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
|
||||||
|
$text = trim($text);
|
||||||
|
if ($text === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$text = mb_substr($text, 0, 80);
|
||||||
|
$text = str_replace(['\\', '{', '}'], ['\', '(', ')'], $text);
|
||||||
|
$lines = [];
|
||||||
|
for ($offset = 0, $length = mb_strlen($text); $offset < $length; $offset += $lineLength) {
|
||||||
|
$lines[] = mb_substr($text, $offset, $lineLength);
|
||||||
|
}
|
||||||
|
return implode('\\N', array_slice($lines, 0, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function cleanSceneText(string $text, int $lineLength): string
|
||||||
|
{
|
||||||
|
$text = preg_replace('/\s+/u', ' ', trim($text)) ?? trim($text);
|
||||||
|
$text = preg_replace('/^[\s\"\'“”‘’]+|[\s\"\'“”‘’]+$/u', '', $text) ?? trim($text);
|
||||||
|
if ($text === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$text = mb_substr($text, 0, 100);
|
||||||
|
$text = str_replace(['\\', '{', '}'], ['\', '(', ')'], $text);
|
||||||
|
$lines = [];
|
||||||
|
for ($offset = 0, $length = mb_strlen($text); $offset < $length; $offset += $lineLength) {
|
||||||
|
$lines[] = mb_substr($text, $offset, $lineLength);
|
||||||
|
}
|
||||||
|
return implode('\\N', array_slice($lines, 0, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function assTime(float $seconds): string
|
||||||
|
{
|
||||||
|
$centiseconds = max(0, (int) round($seconds * 100));
|
||||||
|
$hours = intdiv($centiseconds, 360000);
|
||||||
|
$minutes = intdiv($centiseconds % 360000, 6000);
|
||||||
|
$secs = intdiv($centiseconds % 6000, 100);
|
||||||
|
return sprintf('%d:%02d:%02d.%02d', $hours, $minutes, $secs, $centiseconds % 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function escapeFilterPath(string $path): string
|
||||||
|
{
|
||||||
|
return str_replace(['\\', "'", ':'], ['\\\\', "\\'", '\\:'], $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{int,int} */
|
||||||
|
private static function renderSize(string $aspectRatio, string $quality): array
|
||||||
|
{
|
||||||
|
$portrait = $aspectRatio !== '16:9';
|
||||||
|
return match ($quality) {
|
||||||
|
'high' => $portrait ? [768, 1344] : [1344, 768],
|
||||||
|
'fast' => $portrait ? [512, 896] : [896, 512],
|
||||||
|
default => $portrait ? [576, 1024] : [1024, 576],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{int,string} */
|
||||||
|
private static function run(array $command): array
|
||||||
|
{
|
||||||
|
$pipes = [];
|
||||||
|
$process = @proc_open($command, [
|
||||||
|
0 => ['pipe', 'r'],
|
||||||
|
1 => ['pipe', 'w'],
|
||||||
|
2 => ['pipe', 'w'],
|
||||||
|
], $pipes);
|
||||||
|
if (!is_resource($process)) {
|
||||||
|
throw new \RuntimeException('服务器未安装或无法启动 FFmpeg');
|
||||||
|
}
|
||||||
|
fclose($pipes[0]);
|
||||||
|
stream_get_contents($pipes[1]);
|
||||||
|
fclose($pipes[1]);
|
||||||
|
$error = (string) stream_get_contents($pipes[2]);
|
||||||
|
fclose($pipes[2]);
|
||||||
|
$code = proc_close($process);
|
||||||
|
return [$code, $error];
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1070
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
// 开启后优先请求 CosyVoice;服务异常时会自动回落到 OpenAI / 浏览器语音。
|
||||||
|
'enabled' => filter_var(env('COSYVOICE_ENABLED', true), FILTER_VALIDATE_BOOLEAN),
|
||||||
|
'base_url' => rtrim((string) env('COSYVOICE_BASE_URL', 'http://127.0.0.1:50000'), '/'),
|
||||||
|
// sft / instruct / zero_shot / cross_lingual / instruct2
|
||||||
|
'mode' => (string) env('COSYVOICE_MODE', 'sft'),
|
||||||
|
'speaker' => (string) env('COSYVOICE_SPEAKER', '中文女'),
|
||||||
|
'instruct_text' => (string) env(
|
||||||
|
'COSYVOICE_INSTRUCT',
|
||||||
|
'请用温暖、自然、耐心的中文客服语气表达,语速适中,停顿真实,避免播音腔和夸张情绪。'
|
||||||
|
),
|
||||||
|
'prompt_text' => (string) env('COSYVOICE_PROMPT_TEXT', ''),
|
||||||
|
'prompt_wav' => (string) env(
|
||||||
|
'COSYVOICE_PROMPT_WAV',
|
||||||
|
root_path() . 'storage/cosyvoice/customer-service.wav'
|
||||||
|
),
|
||||||
|
// CosyVoice-300M 常用 22050;CosyVoice2/3 通常应配置为 24000。
|
||||||
|
'sample_rate' => max(8000, (int) env('COSYVOICE_SAMPLE_RATE', 22050)),
|
||||||
|
'connect_timeout_ms' => max(200, (int) env('COSYVOICE_CONNECT_TIMEOUT_MS', 800)),
|
||||||
|
'timeout_seconds' => max(2, (int) env('COSYVOICE_TIMEOUT_SECONDS', 8)),
|
||||||
|
'failure_ttl' => max(5, (int) env('COSYVOICE_FAILURE_TTL', 20)),
|
||||||
|
// 官方 FastAPI 默认无鉴权;经网关暴露时可使用 Bearer Token。
|
||||||
|
'api_key' => (string) env('COSYVOICE_API_KEY', ''),
|
||||||
|
];
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
$targetModel = env('SHORT_DRAMA_TARGET_MODEL', 'MiniMax H3');
|
||||||
|
|
||||||
|
$directorPrompt = <<<'PROMPT'
|
||||||
|
请全面化身为顶级AI视频提示词架构师、电影级镜头语言分析师与多模态视频逆向工程师。不要只总结视频内容,请按照时间顺序逐镜头拆解主体身份与外观锚点、动作轨迹、表情变化、场景空间、前中后景关系、景别、机位、焦段、构图、景深、运镜方式、运动速度、光线方向、综合色温、材质、节奏、转场、特效和环境声音。
|
||||||
|
先输出完整镜头时间轴,再反推出一套适用于[目标视频模型]的成品生成提示词。
|
||||||
|
多镜头必须分别标注起始画面、人物动作、摄影机运动、结束画面和镜头衔接,并补充防止人物变脸、服装漂移、肢体错误、背景闪烁、动作断裂和物理关系失真的禁止项。
|
||||||
|
只学习镜头逻辑和视觉方法,不照抄人物、品牌、台词和具体故事。
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'target_model' => $targetModel,
|
||||||
|
'director_system_prompt' => str_replace('[目标视频模型]', $targetModel, $directorPrompt),
|
||||||
|
'tts_base_url' => rtrim((string) env('SHORT_DRAMA_TTS_BASE_URL', 'http://192.168.110.111:50000'), '/'),
|
||||||
|
'tts_prompt_wav' => (string) env(
|
||||||
|
'SHORT_DRAMA_TTS_PROMPT_WAV',
|
||||||
|
'/usr/local/share/cosyvoice/zero_shot_prompt.wav'
|
||||||
|
),
|
||||||
|
'tts_prompt_text' => (string) env(
|
||||||
|
'SHORT_DRAMA_TTS_PROMPT_TEXT',
|
||||||
|
'You are a helpful assistant.<|endofprompt|>希望你以后能够做的比我还好呦。'
|
||||||
|
),
|
||||||
|
];
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 访客管理权限迁移(可重复执行)。
|
||||||
|
* 用法: php database/migrate_guest_management.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
$app = new think\App();
|
||||||
|
$app->initialize();
|
||||||
|
|
||||||
|
use app\service\PermissionCatalog;
|
||||||
|
use think\facade\Db;
|
||||||
|
|
||||||
|
function guestManagementTableExists(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 (guestManagementTableExists('users')) {
|
||||||
|
Db::execute("UPDATE users SET last_login_at = created_at WHERE LEFT(username, 6) = 'guest_' AND last_login_at IS NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guestManagementTableExists('sys_permissions')) {
|
||||||
|
$parent = Db::name('sys_permissions')->where('code', 'dir:org')->find();
|
||||||
|
if ($parent) {
|
||||||
|
$menu = Db::name('sys_permissions')->where('code', 'menu:guests')->find();
|
||||||
|
if (!$menu) {
|
||||||
|
$menuId = Db::name('sys_permissions')->insertGetId([
|
||||||
|
'type' => 'menu',
|
||||||
|
'code' => 'menu:guests',
|
||||||
|
'name' => '访客管理',
|
||||||
|
'parent_id' => (int) $parent['id'],
|
||||||
|
'path' => '/guests',
|
||||||
|
'icon' => 'visitors',
|
||||||
|
'sort_order' => 15,
|
||||||
|
'is_system' => 1,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
$menu = ['id' => $menuId];
|
||||||
|
echo "Added menu:guests\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
['btn:guest:status', '启用/禁用访客'],
|
||||||
|
['btn:guest:delete', '删除访客'],
|
||||||
|
] as [$code, $name]) {
|
||||||
|
if (!Db::name('sys_permissions')->where('code', $code)->find()) {
|
||||||
|
Db::name('sys_permissions')->insert([
|
||||||
|
'type' => 'btn',
|
||||||
|
'code' => $code,
|
||||||
|
'name' => $name,
|
||||||
|
'parent_id' => (int) $menu['id'],
|
||||||
|
'sort_order' => 0,
|
||||||
|
'is_system' => 1,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
echo "Added {$code}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guestManagementTableExists('roles')) {
|
||||||
|
Db::name('roles')->where('slug', 'super_admin')->update([
|
||||||
|
'permissions' => json_encode(PermissionCatalog::fullPermissions(), JSON_UNESCAPED_UNICODE),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Guest management migration completed.\n";
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
echo 'Error: ' . $e->getMessage() . "\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邀请码与对应后台权限迁移(可重复执行)。
|
||||||
|
* 用法: php database/migrate_invitation_codes.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
$app = new think\App();
|
||||||
|
$app->initialize();
|
||||||
|
|
||||||
|
use app\service\PermissionCatalog;
|
||||||
|
use think\facade\Db;
|
||||||
|
|
||||||
|
function invitationTableExists(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 (!invitationTableExists('invitation_codes')) {
|
||||||
|
Db::execute("CREATE TABLE invitation_codes (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
code VARCHAR(32) NOT NULL UNIQUE,
|
||||||
|
department_id INT UNSIGNED NULL,
|
||||||
|
created_by INT UNSIGNED NULL,
|
||||||
|
used_by INT UNSIGNED NULL,
|
||||||
|
status ENUM('active','used','revoked') NOT NULL DEFAULT 'active',
|
||||||
|
expires_at TIMESTAMP NULL,
|
||||||
|
used_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_invitation_status (status),
|
||||||
|
INDEX idx_invitation_department (department_id),
|
||||||
|
INDEX idx_invitation_created (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
|
||||||
|
echo "Created table invitation_codes\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invitationTableExists('sys_permissions')) {
|
||||||
|
$parent = Db::name('sys_permissions')->where('code', 'dir:org')->find();
|
||||||
|
if ($parent) {
|
||||||
|
$menu = Db::name('sys_permissions')->where('code', 'menu:invitations')->find();
|
||||||
|
if (!$menu) {
|
||||||
|
$menuId = Db::name('sys_permissions')->insertGetId([
|
||||||
|
'type' => 'menu',
|
||||||
|
'code' => 'menu:invitations',
|
||||||
|
'name' => '邀请码管理',
|
||||||
|
'parent_id' => (int) $parent['id'],
|
||||||
|
'path' => '/invitations',
|
||||||
|
'icon' => 'ticket',
|
||||||
|
'sort_order' => 25,
|
||||||
|
'is_system' => 1,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
$menu = ['id' => $menuId];
|
||||||
|
echo "Added menu:invitations\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
['btn:invitation:create', '生成邀请码'],
|
||||||
|
['btn:invitation:revoke', '作废邀请码'],
|
||||||
|
] as [$code, $name]) {
|
||||||
|
if (!Db::name('sys_permissions')->where('code', $code)->find()) {
|
||||||
|
Db::name('sys_permissions')->insert([
|
||||||
|
'type' => 'btn',
|
||||||
|
'code' => $code,
|
||||||
|
'name' => $name,
|
||||||
|
'parent_id' => (int) $menu['id'],
|
||||||
|
'sort_order' => 0,
|
||||||
|
'is_system' => 1,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
echo "Added {$code}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invitationTableExists('roles')) {
|
||||||
|
Db::name('roles')->where('slug', 'super_admin')->update([
|
||||||
|
'permissions' => json_encode(PermissionCatalog::fullPermissions(), JSON_UNESCAPED_UNICODE),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Invitation migration completed.\n";
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
echo 'Error: ' . $e->getMessage() . "\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MiniMax H3 短剧工坊迁移(可重复执行)。
|
||||||
|
* 用法: php database/migrate_short_drama.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
$app = new think\App();
|
||||||
|
$app->initialize();
|
||||||
|
|
||||||
|
use app\service\SettingsService;
|
||||||
|
use app\service\ShortDramaPlannerService;
|
||||||
|
use think\facade\Db;
|
||||||
|
|
||||||
|
function shortDramaTableExists(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 shortDramaColumnExists(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 shortDramaColumnType(string $table, string $column): string
|
||||||
|
{
|
||||||
|
$db = Db::getConfig('database') ?: env('DB_NAME', 'ai_chat');
|
||||||
|
$rows = Db::query(
|
||||||
|
'SELECT DATA_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1',
|
||||||
|
[$db, $table, $column]
|
||||||
|
);
|
||||||
|
return strtolower((string) ($rows[0]['DATA_TYPE'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!shortDramaTableExists('video_projects')) {
|
||||||
|
Db::execute("CREATE TABLE video_projects (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
|
title VARCHAR(160) NOT NULL,
|
||||||
|
idea TEXT NOT NULL,
|
||||||
|
style VARCHAR(100) DEFAULT '电影写实',
|
||||||
|
aspect_ratio VARCHAR(10) NOT NULL DEFAULT '9:16',
|
||||||
|
episode_duration INT UNSIGNED NOT NULL DEFAULT 30,
|
||||||
|
duration_mode VARCHAR(10) NOT NULL DEFAULT 'fixed',
|
||||||
|
quality VARCHAR(20) NOT NULL DEFAULT 'fast',
|
||||||
|
voice_language VARCHAR(20) NOT NULL DEFAULT 'zh-CN',
|
||||||
|
show_subtitles TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
character_origin VARCHAR(20) NOT NULL DEFAULT 'east_asian',
|
||||||
|
screen_text_language VARCHAR(20) NOT NULL DEFAULT 'zh-CN',
|
||||||
|
shot_duration_mode VARCHAR(10) NOT NULL DEFAULT 'auto',
|
||||||
|
status VARCHAR(30) NOT NULL DEFAULT 'draft',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_video_project_user (user_id, updated_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
|
||||||
|
echo "Created table video_projects\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shortDramaColumnExists('video_projects', 'voice_language')) {
|
||||||
|
// 历史成片保留原音;尚未完成的项目升级为普通话重配音。
|
||||||
|
Db::execute("ALTER TABLE video_projects ADD COLUMN voice_language VARCHAR(20) NOT NULL DEFAULT 'native' AFTER quality");
|
||||||
|
Db::execute("UPDATE video_projects SET voice_language = 'zh-CN'
|
||||||
|
WHERE status IN ('storyboard', 'generating', 'needs_attention')
|
||||||
|
OR id = (SELECT id FROM (SELECT id FROM video_projects ORDER BY id DESC LIMIT 1) recent_project)");
|
||||||
|
Db::execute("ALTER TABLE video_projects ALTER COLUMN voice_language SET DEFAULT 'zh-CN'");
|
||||||
|
echo "Added video_projects.voice_language\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shortDramaColumnExists('video_projects', 'duration_mode')) {
|
||||||
|
Db::execute("ALTER TABLE video_projects ADD COLUMN duration_mode VARCHAR(10) NOT NULL DEFAULT 'fixed' AFTER episode_duration");
|
||||||
|
echo "Added video_projects.duration_mode\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shortDramaColumnExists('video_projects', 'show_subtitles')) {
|
||||||
|
// 已有成片没有烧录字幕,保持关闭;新建项目默认显示字幕。
|
||||||
|
Db::execute('ALTER TABLE video_projects ADD COLUMN show_subtitles TINYINT(1) NOT NULL DEFAULT 0 AFTER voice_language');
|
||||||
|
Db::execute('ALTER TABLE video_projects ALTER COLUMN show_subtitles SET DEFAULT 1');
|
||||||
|
echo "Added video_projects.show_subtitles\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shortDramaColumnExists('video_projects', 'character_origin')) {
|
||||||
|
Db::execute("ALTER TABLE video_projects ADD COLUMN character_origin VARCHAR(20) NOT NULL DEFAULT 'east_asian' AFTER show_subtitles");
|
||||||
|
echo "Added video_projects.character_origin\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shortDramaColumnExists('video_projects', 'screen_text_language')) {
|
||||||
|
// 历史项目没有精确场景文字层,保持关闭;新项目默认使用简体中文。
|
||||||
|
Db::execute("ALTER TABLE video_projects ADD COLUMN screen_text_language VARCHAR(20) NOT NULL DEFAULT 'none' AFTER character_origin");
|
||||||
|
Db::execute("ALTER TABLE video_projects ALTER COLUMN screen_text_language SET DEFAULT 'zh-CN'");
|
||||||
|
echo "Added video_projects.screen_text_language\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shortDramaColumnExists('video_projects', 'shot_duration_mode')) {
|
||||||
|
// 历史项目已按 5 秒分镜生成;新项目默认交给 AI 在 5/10 秒间选择。
|
||||||
|
Db::execute("ALTER TABLE video_projects ADD COLUMN shot_duration_mode VARCHAR(10) NOT NULL DEFAULT '5' AFTER screen_text_language");
|
||||||
|
Db::execute("ALTER TABLE video_projects ALTER COLUMN shot_duration_mode SET DEFAULT 'auto'");
|
||||||
|
echo "Added video_projects.shot_duration_mode\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shortDramaColumnType('video_projects', 'episode_duration') !== 'int') {
|
||||||
|
Db::execute('ALTER TABLE video_projects MODIFY COLUMN episode_duration INT UNSIGNED NOT NULL DEFAULT 30');
|
||||||
|
echo "Expanded video_projects.episode_duration to INT UNSIGNED\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shortDramaColumnType('video_projects', 'idea') !== 'mediumtext') {
|
||||||
|
Db::execute('ALTER TABLE video_projects MODIFY COLUMN idea MEDIUMTEXT NOT NULL');
|
||||||
|
echo "Expanded video_projects.idea to MEDIUMTEXT\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shortDramaTableExists('video_characters')) {
|
||||||
|
Db::execute("CREATE TABLE video_characters (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
project_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
|
name VARCHAR(80) NOT NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
reference_upload_id INT UNSIGNED NULL,
|
||||||
|
voice_key VARCHAR(100) NULL,
|
||||||
|
is_locked TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
asset_version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (project_id) REFERENCES video_projects(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_video_character_project (project_id, id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
|
||||||
|
echo "Created table video_characters\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shortDramaTableExists('video_episodes')) {
|
||||||
|
Db::execute("CREATE TABLE video_episodes (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
project_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
episode_no INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
title VARCHAR(160) NOT NULL,
|
||||||
|
script MEDIUMTEXT NULL,
|
||||||
|
status VARCHAR(30) NOT NULL DEFAULT 'storyboard',
|
||||||
|
progress TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
progress_message VARCHAR(255) NULL,
|
||||||
|
final_upload_id INT UNSIGNED NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (project_id) REFERENCES video_projects(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE KEY uk_video_episode_number (project_id, episode_no),
|
||||||
|
INDEX idx_video_episode_status (status, updated_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
|
||||||
|
echo "Created table video_episodes\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shortDramaTableExists('video_shots')) {
|
||||||
|
Db::execute("CREATE TABLE video_shots (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
episode_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
shot_no INT UNSIGNED NOT NULL,
|
||||||
|
title VARCHAR(160) NOT NULL,
|
||||||
|
prompt TEXT NOT NULL,
|
||||||
|
dialogue TEXT NULL,
|
||||||
|
duration_seconds SMALLINT UNSIGNED NOT NULL DEFAULT 5,
|
||||||
|
workflow_type VARCHAR(20) NOT NULL DEFAULT 'fl2va',
|
||||||
|
status VARCHAR(30) NOT NULL DEFAULT 'draft',
|
||||||
|
prompt_id VARCHAR(80) NULL,
|
||||||
|
seed BIGINT UNSIGNED NULL,
|
||||||
|
output_upload_id INT UNSIGNED NULL,
|
||||||
|
error_message TEXT NULL,
|
||||||
|
meta JSON NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (episode_id) REFERENCES video_episodes(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE KEY uk_video_shot_number (episode_id, shot_no),
|
||||||
|
INDEX idx_video_shot_prompt (prompt_id),
|
||||||
|
INDEX idx_video_shot_status (status, updated_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
|
||||||
|
echo "Created table video_shots\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 旧版项目没有保存对白。为升级成普通话的项目补齐每个镜头的短句,避免保留 H3 含糊原声。
|
||||||
|
$dialogueBackfillCount = 0;
|
||||||
|
$mandarinProjects = Db::query("SELECT * FROM video_projects WHERE voice_language = 'zh-CN'");
|
||||||
|
foreach ($mandarinProjects as $mandarinProject) {
|
||||||
|
$plan = ShortDramaPlannerService::plan(
|
||||||
|
(string) $mandarinProject['idea'],
|
||||||
|
(int) $mandarinProject['episode_duration'],
|
||||||
|
(string) $mandarinProject['aspect_ratio'],
|
||||||
|
(string) $mandarinProject['style'],
|
||||||
|
'zh-CN',
|
||||||
|
(string) ($mandarinProject['character_origin'] ?? ShortDramaPlannerService::CHARACTER_ORIGIN_EAST_ASIAN),
|
||||||
|
(string) ($mandarinProject['screen_text_language'] ?? ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_NONE),
|
||||||
|
(string) ($mandarinProject['shot_duration_mode'] ?? ShortDramaPlannerService::SHOT_DURATION_FIVE)
|
||||||
|
);
|
||||||
|
$projectShots = Db::query(
|
||||||
|
'SELECT s.id, s.shot_no, s.dialogue FROM video_shots s '
|
||||||
|
. 'INNER JOIN video_episodes e ON e.id = s.episode_id '
|
||||||
|
. 'WHERE e.project_id = ? ORDER BY s.shot_no',
|
||||||
|
[(int) $mandarinProject['id']]
|
||||||
|
);
|
||||||
|
foreach ($projectShots as $projectShot) {
|
||||||
|
if (trim((string) ($projectShot['dialogue'] ?? '')) !== '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$plannedShot = $plan['shots'][max(0, (int) $projectShot['shot_no'] - 1)] ?? null;
|
||||||
|
if (!is_array($plannedShot) || trim((string) ($plannedShot['dialogue'] ?? '')) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Db::execute(
|
||||||
|
'UPDATE video_shots SET dialogue = ? WHERE id = ?',
|
||||||
|
[(string) $plannedShot['dialogue'], (int) $projectShot['id']]
|
||||||
|
);
|
||||||
|
$dialogueBackfillCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($dialogueBackfillCount > 0) {
|
||||||
|
echo "Backfilled {$dialogueBackfillCount} Mandarin shot dialogues\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$features = SettingsService::getFeatures();
|
||||||
|
$features['short_drama'] = $features['short_drama'] ?? true;
|
||||||
|
SettingsService::set('features', $features);
|
||||||
|
|
||||||
|
echo "Short drama migration completed.\n";
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
echo 'Error: ' . $e->getMessage() . "\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Docker 内短剧接力进程。
|
||||||
|
*
|
||||||
|
* H3 的下一镜头必须等上一镜头落盘并提取尾帧后才能提交,因此由这个进程
|
||||||
|
* 持续触发同一套状态接口。页面关闭后,长视频仍会按顺序继续生成。
|
||||||
|
*/
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
$app = new think\App();
|
||||||
|
$app->initialize();
|
||||||
|
|
||||||
|
use app\model\VideoProject;
|
||||||
|
use app\service\JwtService;
|
||||||
|
|
||||||
|
set_time_limit(0);
|
||||||
|
|
||||||
|
$internalBaseUrl = rtrim((string) (getenv('SHORT_DRAMA_INTERNAL_URL') ?: 'http://web'), '/');
|
||||||
|
if (!preg_match('#^https?://#i', $internalBaseUrl)) {
|
||||||
|
fwrite(STDERR, "[short-drama-worker] SHORT_DRAMA_INTERNAL_URL 必须使用 http 或 https\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
$projects = VideoProject::where('status', 'generating')
|
||||||
|
->order('updated_at')
|
||||||
|
->limit(30)
|
||||||
|
->select();
|
||||||
|
|
||||||
|
foreach ($projects as $project) {
|
||||||
|
$token = JwtService::generateToken(['user_id' => (int) $project->user_id]);
|
||||||
|
$url = $internalBaseUrl . '/api/short-drama/projects/' . (int) $project->id . '/status';
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_HTTPGET => true,
|
||||||
|
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 25,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 3,
|
||||||
|
]);
|
||||||
|
curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
}
|
||||||
|
} catch (Throwable $error) {
|
||||||
|
fwrite(STDERR, '[short-drama-worker] ' . $error->getMessage() . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(8);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
-- AI Chat Database Schema
|
-- AI Chat Database Schema
|
||||||
|
SET NAMES utf8mb4;
|
||||||
CREATE DATABASE IF NOT EXISTS ai_chat DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
CREATE DATABASE IF NOT EXISTS ai_chat DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
USE ai_chat;
|
USE ai_chat;
|
||||||
|
|
||||||
@@ -58,6 +59,23 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
FOREIGN KEY (membership_level_id) REFERENCES membership_levels(id)
|
FOREIGN KEY (membership_level_id) REFERENCES membership_levels(id)
|
||||||
) ENGINE=InnoDB;
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
-- 一次性注册邀请码;指定 department_id 后,新用户会自动归属到该部门
|
||||||
|
CREATE TABLE IF NOT EXISTS invitation_codes (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
code VARCHAR(32) NOT NULL UNIQUE,
|
||||||
|
department_id INT UNSIGNED NULL,
|
||||||
|
created_by INT UNSIGNED NULL,
|
||||||
|
used_by INT UNSIGNED NULL,
|
||||||
|
status ENUM('active', 'used', 'revoked') NOT NULL DEFAULT 'active',
|
||||||
|
expires_at TIMESTAMP NULL,
|
||||||
|
used_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_invitation_status (status),
|
||||||
|
INDEX idx_invitation_department (department_id),
|
||||||
|
INDEX idx_invitation_created (created_at)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
-- 系统功能开关
|
-- 系统功能开关
|
||||||
CREATE TABLE IF NOT EXISTS system_settings (
|
CREATE TABLE IF NOT EXISTS system_settings (
|
||||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
@@ -134,6 +152,86 @@ CREATE TABLE IF NOT EXISTS uploads (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB;
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
-- 短剧项目(一个项目可包含多集,角色资产在项目内复用)
|
||||||
|
CREATE TABLE IF NOT EXISTS video_projects (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
|
title VARCHAR(160) NOT NULL,
|
||||||
|
idea MEDIUMTEXT NOT NULL,
|
||||||
|
style VARCHAR(100) DEFAULT '电影写实',
|
||||||
|
aspect_ratio VARCHAR(10) NOT NULL DEFAULT '9:16',
|
||||||
|
episode_duration INT UNSIGNED NOT NULL DEFAULT 30,
|
||||||
|
duration_mode VARCHAR(10) NOT NULL DEFAULT 'fixed',
|
||||||
|
quality VARCHAR(20) NOT NULL DEFAULT 'fast',
|
||||||
|
voice_language VARCHAR(20) NOT NULL DEFAULT 'zh-CN',
|
||||||
|
show_subtitles TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
character_origin VARCHAR(20) NOT NULL DEFAULT 'east_asian',
|
||||||
|
screen_text_language VARCHAR(20) NOT NULL DEFAULT 'zh-CN',
|
||||||
|
shot_duration_mode VARCHAR(10) NOT NULL DEFAULT 'auto',
|
||||||
|
status VARCHAR(30) NOT NULL DEFAULT 'draft',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_video_project_user (user_id, updated_at)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS video_characters (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
project_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
|
name VARCHAR(80) NOT NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
reference_upload_id INT UNSIGNED NULL,
|
||||||
|
voice_key VARCHAR(100) NULL,
|
||||||
|
is_locked TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
asset_version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (project_id) REFERENCES video_projects(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_video_character_project (project_id, id)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS video_episodes (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
project_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
episode_no INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
title VARCHAR(160) NOT NULL,
|
||||||
|
script MEDIUMTEXT NULL,
|
||||||
|
status VARCHAR(30) NOT NULL DEFAULT 'storyboard',
|
||||||
|
progress TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
progress_message VARCHAR(255) NULL,
|
||||||
|
final_upload_id INT UNSIGNED NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (project_id) REFERENCES video_projects(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE KEY uk_video_episode_number (project_id, episode_no),
|
||||||
|
INDEX idx_video_episode_status (status, updated_at)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS video_shots (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
episode_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
shot_no INT UNSIGNED NOT NULL,
|
||||||
|
title VARCHAR(160) NOT NULL,
|
||||||
|
prompt TEXT NOT NULL,
|
||||||
|
dialogue TEXT NULL,
|
||||||
|
duration_seconds SMALLINT UNSIGNED NOT NULL DEFAULT 5,
|
||||||
|
workflow_type VARCHAR(20) NOT NULL DEFAULT 'fl2va',
|
||||||
|
status VARCHAR(30) NOT NULL DEFAULT 'draft',
|
||||||
|
prompt_id VARCHAR(80) NULL,
|
||||||
|
seed BIGINT UNSIGNED NULL,
|
||||||
|
output_upload_id INT UNSIGNED NULL,
|
||||||
|
error_message TEXT NULL,
|
||||||
|
meta JSON NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (episode_id) REFERENCES video_episodes(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE KEY uk_video_shot_number (episode_id, shot_no),
|
||||||
|
INDEX idx_video_shot_prompt (prompt_id),
|
||||||
|
INDEX idx_video_shot_status (status, updated_at)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
-- 每日消息统计
|
-- 每日消息统计
|
||||||
CREATE TABLE IF NOT EXISTS user_daily_stats (
|
CREATE TABLE IF NOT EXISTS user_daily_stats (
|
||||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
@@ -160,7 +258,7 @@ INSERT INTO departments (name, parent_id, sort_order) VALUES
|
|||||||
|
|
||||||
-- 默认系统设置
|
-- 默认系统设置
|
||||||
INSERT INTO system_settings (setting_key, setting_value, description) VALUES
|
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}', '功能开关'),
|
('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,"short_drama":true}', '功能开关'),
|
||||||
('site_name', 'AI Chat', '站点名称'),
|
('site_name', 'AI Chat', '站点名称'),
|
||||||
('allow_register', 'true', '是否允许注册');
|
('allow_register', 'true', '是否允许注册');
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
.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 @@
|
|||||||
|
.conv-page[data-v-f98449d7]{display:flex;flex-direction:column;height:calc(100vh - 48px);min-height:560px}.page-header[data-v-f98449d7]{flex-shrink:0;margin-bottom:16px}.conv-layout[data-v-f98449d7]{flex:1;min-height:0;display:grid;grid-template-columns:320px 1fr;gap:16px}.panel[data-v-f98449d7]{display:flex;flex-direction:column;min-height:0;padding:0;overflow:hidden}.list-toolbar[data-v-f98449d7]{padding:16px;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:8px}.dept-filter[data-v-f98449d7],.search-input[data-v-f98449d7]{font-size:13px}.conv-list[data-v-f98449d7]{flex:1;overflow-y:auto;padding:8px}.conv-item[data-v-f98449d7]{width:100%;text-align:left;padding:12px;border-radius:8px;margin-bottom:4px;transition:background .15s}.conv-item[data-v-f98449d7]:hover{background:#ffffff0a}.conv-item.active[data-v-f98449d7]{background:var(--accent-soft);border:1px solid rgba(183,243,107,.28)}.conv-item-title[data-v-f98449d7]{font-size:14px;font-weight:500;margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.conv-item-meta[data-v-f98449d7],.conv-item-time[data-v-f98449d7]{font-size:12px;color:var(--text-muted)}.conv-item-meta[data-v-f98449d7]{display:flex;justify-content:space-between;gap:8px;margin-bottom:2px}.list-pagination[data-v-f98449d7]{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-f98449d7]{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-f98449d7]{font-size:16px;margin-bottom:4px}.detail-meta[data-v-f98449d7]{font-size:13px;color:var(--text-secondary);display:flex;flex-wrap:wrap;gap:4px}.messages-scroll[data-v-f98449d7]{flex:1;overflow-y:auto;padding:20px}.messages[data-v-f98449d7]{display:flex;flex-direction:column;gap:16px}.message[data-v-f98449d7]{display:flex;gap:10px;max-width:85%}.message.user[data-v-f98449d7]{flex-direction:row-reverse;align-self:flex-end}.message.assistant[data-v-f98449d7]{align-self:flex-start}.message-avatar[data-v-f98449d7]{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-f98449d7]{background:var(--accent);color:#fff}.message-body[data-v-f98449d7]{min-width:0}.message-content[data-v-f98449d7]{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-f98449d7]{background:var(--accent-soft);border:1px solid rgba(183,243,107,.24)}.message-time[data-v-f98449d7]{display:block;margin-top:4px;font-size:11px;color:var(--text-muted)}.message.user .message-time[data-v-f98449d7]{text-align:right}.attachments[data-v-f98449d7]{display:flex;flex-direction:column;gap:8px;margin-bottom:8px}.att-image[data-v-f98449d7]{max-width:240px;max-height:180px;border-radius:8px;cursor:pointer;border:1px solid var(--border)}.att-link[data-v-f98449d7]{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-f98449d7]:hover{border-color:var(--accent)}.detail-empty[data-v-f98449d7],.empty[data-v-f98449d7]{text-align:center;padding:48px 24px;color:var(--text-muted);font-size:14px}.detail-empty[data-v-f98449d7]{flex:1;display:flex;align-items:center;justify-content:center}@media(max-width:900px){.conv-layout[data-v-f98449d7]{grid-template-columns:1fr;grid-template-rows:280px 1fr}.conv-page[data-v-f98449d7]{height:auto;min-height:calc(100vh - 48px)}}
|
||||||
@@ -1 +0,0 @@
|
|||||||
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 @@
|
|||||||
|
import{_ as i,i as d,j as r,c,b as s,a as e,t as n,r as p,o as _,U as o}from"./index-9aV50nsX.js";const u={class:"stats-grid"},v={class:"stat-card"},m={class:"stat-icon"},g={class:"stat-value"},b={class:"stat-card"},x={class:"stat-icon"},f={class:"stat-value"},y={class:"stat-card"},z={class:"stat-icon"},V={class:"stat-value"},w={class:"stat-card"},B={class:"stat-icon"},D={class:"stat-value"},k={__name:"DashboardView",setup(I){const t=p({users:0,conversations:0,messages:0,today_messages:0});return d(async()=>{const l=await r.get("/admin/stats");t.value=l.data.data}),(l,a)=>(_(),c("div",null,[a[8]||(a[8]=s("div",{class:"page-header"},[s("h2",null,"数据概览"),s("p",null,"系统运行统计数据")],-1)),s("div",u,[s("article",v,[s("span",m,[e(o,{name:"users",size:21})]),s("span",g,n(t.value.users),1),a[0]||(a[0]=s("span",{class:"stat-label"},"用户总数",-1)),a[1]||(a[1]=s("span",{class:"stat-index"},"01",-1))]),s("article",b,[s("span",x,[e(o,{name:"conversations",size:21})]),s("span",f,n(t.value.conversations),1),a[2]||(a[2]=s("span",{class:"stat-label"},"会话总数",-1)),a[3]||(a[3]=s("span",{class:"stat-index"},"02",-1))]),s("article",y,[s("span",z,[e(o,{name:"messages",size:21})]),s("span",V,n(t.value.messages),1),a[4]||(a[4]=s("span",{class:"stat-label"},"消息总数",-1)),a[5]||(a[5]=s("span",{class:"stat-index"},"03",-1))]),s("article",w,[s("span",B,[e(o,{name:"activity",size:21})]),s("span",D,n(t.value.today_messages),1),a[6]||(a[6]=s("span",{class:"stat-label"},"今日消息",-1)),a[7]||(a[7]=s("span",{class:"stat-index"},"04",-1))])])]))}},U=i(k,[["__scopeId","data-v-5a83d2b7"]]);export{U as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.stats-grid[data-v-5a83d2b7]{display:grid;grid-template-columns:repeat(4,minmax(180px,1fr));gap:14px}.stat-card[data-v-5a83d2b7]{display:grid;min-height:178px;grid-template-columns:1fr auto;grid-template-rows:auto 1fr auto;padding:19px;transition:transform .22s var(--ease-spring),border-color .18s ease,box-shadow .18s ease}.stat-card[data-v-5a83d2b7]:hover{border-color:#b7f36b38;box-shadow:inset 0 1px #ffffff0b,0 22px 52px #00000057;transform:translateY(-3px)}.stat-icon[data-v-5a83d2b7]{z-index:1;display:grid;width:42px;height:42px;place-items:center;border:1px solid rgba(183,243,107,.24);border-radius:12px;background:var(--accent-soft);color:var(--accent);box-shadow:inset 0 1px #ffffff0e,0 0 22px #b7f36b0f}.stat-value[data-v-5a83d2b7]{z-index:1;align-self:end;color:var(--text-primary);font-family:Cascadia Code,Consolas,monospace;font-size:clamp(34px,4vw,48px);font-weight:760;font-variant-numeric:tabular-nums;letter-spacing:-.065em;line-height:1}.stat-label[data-v-5a83d2b7]{z-index:1;align-self:end;margin-top:9px;color:var(--text-secondary);font-size:12px;font-weight:600}.stat-index[data-v-5a83d2b7]{z-index:1;grid-column:2;grid-row:1;color:var(--text-muted);font-family:Cascadia Code,Consolas,monospace;font-size:10px;letter-spacing:.08em}@media(max-width:1050px){.stats-grid[data-v-5a83d2b7]{grid-template-columns:repeat(2,minmax(180px,1fr))}}@media(max-width:560px){.stats-grid[data-v-5a83d2b7]{grid-template-columns:1fr}.stat-card[data-v-5a83d2b7]{min-height:150px}}
|
||||||
@@ -1 +0,0 @@
|
|||||||
.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-adb7470c]{margin-bottom:16px}.empty[data-v-adb7470c]{text-align:center;padding:32px;color:var(--text-muted)}.danger[data-v-adb7470c]{color:var(--danger)}
|
||||||
@@ -1 +0,0 @@
|
|||||||
.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}
|
|
||||||
+1
-1
@@ -1 +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};
|
import{_ as L,u as N,i as U,j as b,c as s,b as t,k as f,f as u,F as w,l as $,w as E,t as d,e as k,v as B,m as F,r as m,p as j,n as z,o,C as A}from"./index-9aV50nsX.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([]),h=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||[],h.value=e.list||[]}function V(a){var e;return a&&((e=h.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(w,null,$(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:C=>g(n.id)},"添加下级",8,T)):u("",!0),f(c).hasButton("btn:dept:edit")?(o(),s("button",{key:1,class:"btn btn-ghost",onClick:C=>M(n)},"编辑",8,q)):u("",!0),f(c).hasButton("btn:dept:delete")?(o(),s("button",{key:2,class:"btn btn-ghost danger",onClick:C=>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)),k(t("input",{"onUpdate:modelValue":e[1]||(e[1]=n=>l.name=n),class:"form-input"},null,512),[[B,l.name]])]),t("div",W,[e[7]||(e[7]=t("label",null,"上级部门",-1)),k(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(w,null,$(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)),k(t("input",{"onUpdate:modelValue":e[3]||(e[3]=n=>l.sort_order=n),type:"number",class:"form-input"},null,512),[[B,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-adb7470c"]]);export{at as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.guest-summary[data-v-257286bc]{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-bottom:16px}.summary-card[data-v-257286bc]{display:grid;grid-template-columns:1fr auto;gap:4px 16px;padding:17px 18px;border:1px solid var(--border);border-radius:14px;background:var(--glass-panel)}.summary-card span[data-v-257286bc],.summary-card small[data-v-257286bc]{color:var(--text-muted);font-size:11px}.summary-card strong[data-v-257286bc]{grid-row:span 2;font-size:27px;letter-spacing:-.04em}.guest-toolbar[data-v-257286bc],.filter-group[data-v-257286bc],.search-form[data-v-257286bc],.actions-cell[data-v-257286bc]{display:flex;align-items:center;gap:7px}.guest-toolbar[data-v-257286bc]{justify-content:space-between;margin-bottom:16px}.filter-btn.active[data-v-257286bc]{border-color:var(--accent-line);background:var(--accent-soft);color:var(--accent)}.search-form .form-input[data-v-257286bc]{width:min(260px,42vw)}.table-scroll[data-v-257286bc]{overflow-x:auto}.guest-identity[data-v-257286bc]{display:flex;min-width:220px;flex-direction:column;gap:5px}.guest-identity strong[data-v-257286bc]{font-size:13px}.guest-identity code[data-v-257286bc]{width:fit-content;padding:3px 6px;color:var(--text-muted);font-size:10px}.empty[data-v-257286bc]{padding:34px;color:var(--text-muted);text-align:center}.pagination[data-v-257286bc]{display:flex;align-items:center;justify-content:center;gap:16px;padding:18px;color:var(--text-secondary);font-size:13px}.danger[data-v-257286bc]{color:var(--danger)}@media(max-width:760px){.guest-summary[data-v-257286bc]{grid-template-columns:1fr}.guest-toolbar[data-v-257286bc]{align-items:stretch;flex-direction:column}.search-form .form-input[data-v-257286bc]{width:100%}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{_ as L,u as P,i as T,j as f,c as o,b as t,t as l,F as S,l as x,w as U,e as j,v as q,f as b,r as d,n as h,o as u,q as G,k as M}from"./index-9aV50nsX.js";const A={class:"guest-summary"},E={class:"summary-card"},O={class:"summary-card"},H={class:"summary-card"},J={class:"toolbar guest-toolbar"},K={class:"filter-group"},Q=["onClick"],R={class:"panel"},W={class:"table-scroll"},X={class:"data-table"},Y={class:"guest-identity"},Z={class:"actions-cell"},tt=["onClick"],st=["onClick"],et={key:0,class:"empty"},at={key:1,class:"empty"},lt={key:2,class:"pagination"},nt=["disabled"],ot=["disabled"],y=20,ut={__name:"GuestsView",setup(it){const k=P(),i=d([]),m=d(!1),n=d(1),c=d(0),p=d(""),g=d(""),B=[{label:"全部",value:""},{label:"正常",value:"active"},{label:"已禁用",value:"disabled"}],C=h(()=>Math.max(1,Math.ceil(c.value/y))),D=h(()=>i.value.reduce((a,s)=>a+Number(s.conversation_count||0),0)),N=h(()=>i.value.reduce((a,s)=>a+Number(s.message_count||0),0));T(r);async function r(){m.value=!0;try{const s=(await f.get("/admin/guests",{params:{page:n.value,limit:y,status:p.value||void 0,keyword:g.value||void 0}})).data.data||{};i.value=s.list||[],c.value=s.total??i.value.length}finally{m.value=!1}}function V(a){p.value=a,n.value=1,r()}function I(){n.value=1,r()}async function w(a){n.value=a,await r()}async function z(a){const s=a.status==="active"?"disabled":"active",e=s==="active"?"启用":"禁用";if(confirm(`确定${e}访客 #${_(a.username)}?`))try{await f.put(`/admin/guests/${a.id}/status`,{status:s}),await r()}catch(v){alert(v.message||`${e}失败`)}}async function F(a){if(confirm(`确定删除访客 #${_(a.username)}?其会话和消息记录也会一并删除,此操作不可恢复。`))try{await f.delete(`/admin/guests/${a.id}`),i.value.length===1&&n.value>1&&(n.value-=1),await r()}catch(s){alert(s.message||"删除失败")}}function _(a){return String(a||"").replace(/^guest_/,"").slice(-8).toUpperCase()}function $(a){return a?new Date(String(a).replace(" ","T")).toLocaleString("zh-CN",{hour12:!1}):"-"}return(a,s)=>(u(),o("div",null,[s[11]||(s[11]=t("div",{class:"page-header"},[t("h2",null,"访客管理"),t("p",null,"单独记录匿名访问者的使用情况,不计入注册用户管理列表")],-1)),t("div",A,[t("div",E,[s[3]||(s[3]=t("span",null,"当前记录",-1)),t("strong",null,l(c.value),1),s[4]||(s[4]=t("small",null,"匿名访客",-1))]),t("div",O,[s[5]||(s[5]=t("span",null,"本页会话",-1)),t("strong",null,l(D.value),1),s[6]||(s[6]=t("small",null,"未删除会话",-1))]),t("div",H,[s[7]||(s[7]=t("span",null,"本页消息",-1)),t("strong",null,l(N.value),1),s[8]||(s[8]=t("small",null,"累计消息",-1))])]),t("div",J,[t("div",K,[(u(),o(S,null,x(B,e=>t("button",{key:e.value,class:G(["btn btn-ghost filter-btn",{active:p.value===e.value}]),type:"button",onClick:v=>V(e.value)},l(e.label),11,Q)),64))]),t("form",{class:"search-form",onSubmit:U(I,["prevent"])},[j(t("input",{"onUpdate:modelValue":s[0]||(s[0]=e=>g.value=e),class:"form-input",placeholder:"搜索访客编号"},null,512),[[q,g.value,void 0,{trim:!0}]]),s[9]||(s[9]=t("button",{class:"btn btn-ghost",type:"submit"},"搜索",-1))],32)]),t("div",R,[t("div",W,[t("table",X,[s[10]||(s[10]=t("thead",null,[t("tr",null,[t("th",null,"ID"),t("th",null,"访客编号"),t("th",null,"状态"),t("th",null,"会话数"),t("th",null,"消息数"),t("th",null,"首次访问"),t("th",null,"最近访问"),t("th",null,"操作")])],-1)),t("tbody",null,[(u(!0),o(S,null,x(i.value,e=>(u(),o("tr",{key:e.id},[t("td",null,l(e.id),1),t("td",null,[t("div",Y,[t("strong",null,"访客 #"+l(_(e.username)),1),t("code",null,l(e.username),1)])]),t("td",null,[t("span",{class:G(["badge",e.status==="active"?"badge-success":"badge-danger"])},l(e.status==="active"?"正常":"已禁用"),3)]),t("td",null,l(e.conversation_count||0),1),t("td",null,l(e.message_count||0),1),t("td",null,l($(e.created_at)),1),t("td",null,l($(e.last_login_at)),1),t("td",Z,[M(k).hasButton("btn:guest:status")?(u(),o("button",{key:0,class:"btn btn-ghost",type:"button",onClick:v=>z(e)},l(e.status==="active"?"禁用":"启用"),9,tt)):b("",!0),M(k).hasButton("btn:guest:delete")?(u(),o("button",{key:1,class:"btn btn-ghost danger",type:"button",onClick:v=>F(e)},"删除",8,st)):b("",!0)])]))),128))])])]),m.value?(u(),o("p",et,"正在加载...")):i.value.length?b("",!0):(u(),o("p",at,"暂无访客记录")),c.value>y?(u(),o("div",lt,[t("button",{class:"btn btn-ghost",disabled:n.value<=1,onClick:s[1]||(s[1]=e=>w(n.value-1))},"上一页",8,nt),t("span",null,l(n.value)+" / "+l(C.value),1),t("button",{class:"btn btn-ghost",disabled:n.value>=C.value,onClick:s[2]||(s[2]=e=>w(n.value+1))},"下一页",8,ot)])):b("",!0)])]))}},dt=L(ut,[["__scopeId","data-v-257286bc"]]);export{dt as default};
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
.invitation-toolbar[data-v-f3c9dbd2]{display:flex;align-items:center;justify-content:space-between;gap:14px}.filter-group[data-v-f3c9dbd2]{display:flex;flex-wrap:wrap;gap:6px}.filter-btn.active[data-v-f3c9dbd2]{border-color:var(--accent-line);background:var(--accent-soft);color:var(--accent)}.invitation-panel[data-v-f3c9dbd2],.table-scroll[data-v-f3c9dbd2]{overflow-x:auto}.invite-code[data-v-f3c9dbd2]{display:inline-block;padding:6px 8px;white-space:nowrap;letter-spacing:.04em}.creator-cell[data-v-f3c9dbd2]{display:flex;flex-direction:column;gap:3px}.creator-cell small[data-v-f3c9dbd2],.form-help[data-v-f3c9dbd2]{color:var(--text-muted);font-size:11px}.form-help[data-v-f3c9dbd2]{margin:7px 0 0;line-height:1.5}.empty[data-v-f3c9dbd2]{padding:34px;color:var(--text-muted);text-align:center}.danger[data-v-f3c9dbd2]{color:var(--danger)}@media(max-width:680px){.invitation-toolbar[data-v-f3c9dbd2]{align-items:stretch;flex-direction:column}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.login-page[data-v-d42d96d7]{position:relative;min-height:100dvh;display:grid;place-items:center;padding:28px;overflow:hidden;background:radial-gradient(circle at 70% 20%,rgba(183,243,107,.08),transparent 24%),var(--bg-primary)}.login-theme-toggle[data-v-d42d96d7]{position:absolute;top:18px;right:18px;z-index:3}.login-shell[data-v-d42d96d7]{position:relative;display:grid;width:min(880px,100%);grid-template-columns:1.08fr .92fr;overflow:hidden;border:1px solid var(--border-strong);border-radius:22px;background:var(--bg-secondary);box-shadow:inset 0 1px #ffffff0d,var(--shadow)}.login-shell[data-v-d42d96d7]:after{position:absolute;top:0;left:18%;width:36%;height:1px;background:linear-gradient(90deg,transparent,var(--accent),transparent);box-shadow:0 0 17px #b7f36b70;content:""}.login-aside[data-v-d42d96d7]{position:relative;display:flex;min-height:530px;flex-direction:column;justify-content:center;padding:52px;overflow:hidden;border-right:1px solid var(--border);background:linear-gradient(135deg,rgba(183,243,107,.07),transparent 45%),repeating-linear-gradient(135deg,rgba(255,255,255,.018) 0 1px,transparent 1px 14px),#0c0f13}.login-mark[data-v-d42d96d7]{display:grid;width:46px;height:46px;place-items:center;margin-bottom:44px;border:1px solid rgba(183,243,107,.48);border-radius:14px;background:var(--accent);color:#11150e;box-shadow:inset 0 1px #ffffff80,0 5px #55782f,0 14px 28px #6ea6362b}.login-eyebrow[data-v-d42d96d7],.login-version[data-v-d42d96d7]{color:var(--accent);font-family:Cascadia Code,Consolas,monospace;font-size:10px;letter-spacing:.16em}.login-aside h1[data-v-d42d96d7]{margin:15px 0 18px;font-size:clamp(34px,4.6vw,52px);font-weight:760;letter-spacing:-.055em;line-height:1.04;text-wrap:balance}.login-aside p[data-v-d42d96d7]{max-width:32ch;color:var(--text-secondary);font-size:14px;line-height:1.75}.login-version[data-v-d42d96d7]{position:absolute;bottom:28px;left:52px;color:var(--text-muted);font-size:9px}.login-card[data-v-d42d96d7]{display:flex;flex-direction:column;justify-content:center;padding:48px 42px;background:radial-gradient(circle at 100% 0%,rgba(183,243,107,.055),transparent 26%),var(--bg-secondary)}.login-header[data-v-d42d96d7]{margin-bottom:30px}.login-header>span[data-v-d42d96d7]:not(.status-dot){color:var(--text-muted);font-size:11px;font-weight:650;letter-spacing:.08em}.status-dot[data-v-d42d96d7]{display:inline-block;width:7px;height:7px;margin-right:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 12px #b7f36b99}.login-header h2[data-v-d42d96d7]{margin:12px 0 7px;font-size:26px;letter-spacing:-.04em}.login-header p[data-v-d42d96d7]{color:var(--text-secondary);font-size:13px}.login-btn[data-v-d42d96d7]{width:100%;min-height:46px;margin-top:7px}@media(max-width:720px){.login-page[data-v-d42d96d7]{padding:14px}.login-shell[data-v-d42d96d7]{grid-template-columns:1fr}.login-aside[data-v-d42d96d7]{display:none}.login-card[data-v-d42d96d7]{min-height:520px;padding:38px 25px}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{_ as y,u as w,c as u,a as d,b as s,d as p,w as V,e as v,v as g,t as c,f as T,r as t,g as N,h as x,o as m,T as A,U}from"./index-9aV50nsX.js";const k={class:"login-page"},C={class:"login-shell"},I={class:"login-aside"},L={class:"login-mark"},R={class:"login-card"},S={class:"form-group"},q={class:"form-group"},B={key:0,class:"form-error"},D=["disabled"],M={__name:"LoginView",setup(E){const f=N(),b=x(),_=w(),a=t(""),n=t(""),o=t(""),l=t(!1);async function h(){o.value="",l.value=!0;try{await _.login(a.value,n.value),f.push(b.query.redirect||"/dashboard")}catch(r){o.value=r.message}finally{l.value=!1}}return(r,e)=>(m(),u("div",k,[d(A,{class:"login-theme-toggle"}),s("div",C,[s("div",I,[s("span",L,[d(U,{name:"bolt",size:22})]),e[2]||(e[2]=s("span",{class:"login-eyebrow"},"AI CHAT / ADMIN",-1)),e[3]||(e[3]=s("h1",null,[p("让系统配置"),s("br"),p("保持清晰可控")],-1)),e[4]||(e[4]=s("p",null,"统一管理用户、模型、权限与会话数据。",-1)),e[5]||(e[5]=s("span",{class:"login-version"},"CONTROL SURFACE · V2",-1))]),s("div",R,[e[8]||(e[8]=s("div",{class:"login-header"},[s("span",{class:"status-dot","aria-hidden":"true"}),s("span",null,"安全入口"),s("h2",null,"登录管理后台"),s("p",null,"请使用管理员账户继续")],-1)),s("form",{onSubmit:V(h,["prevent"])},[s("div",S,[e[6]||(e[6]=s("label",null,"账号",-1)),v(s("input",{"onUpdate:modelValue":e[0]||(e[0]=i=>a.value=i),class:"form-input",placeholder:"管理员用户名或邮箱",required:""},null,512),[[g,a.value]])]),s("div",q,[e[7]||(e[7]=s("label",null,"密码",-1)),v(s("input",{"onUpdate:modelValue":e[1]||(e[1]=i=>n.value=i),type:"password",class:"form-input",placeholder:"请输入密码",required:""},null,512),[[g,n.value]])]),o.value?(m(),u("p",B,c(o.value),1)):T("",!0),s("button",{type:"submit",class:"btn btn-primary login-btn",disabled:l.value},c(l.value?"登录中...":"进入控制台"),9,D)],32)])])]))}},z=y(M,[["__scopeId","data-v-d42d96d7"]]);export{z as default};
|
||||||
@@ -1 +0,0 @@
|
|||||||
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};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
.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}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
.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}
|
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
.toolbar[data-v-cc64fdb8]{margin-bottom:16px}.membership-grid[data-v-cc64fdb8]{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px}.membership-card[data-v-cc64fdb8]{position:relative;overflow:hidden;background:radial-gradient(circle at 100% 0%,rgba(183,243,107,.05),transparent 30%),var(--bg-secondary);border:1px solid var(--border);border-radius:16px;padding:20px;box-shadow:inset 0 1px #ffffff09,var(--shadow-soft);transition:transform .2s var(--ease-spring),border-color .18s ease,box-shadow .18s ease}.membership-card[data-v-cc64fdb8]:hover{border-color:#b7f36b38;transform:translateY(-3px);box-shadow:inset 0 1px #ffffff0b,0 22px 52px #00000057}.card-header[data-v-cc64fdb8]{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.card-header h3[data-v-cc64fdb8]{font-size:18px}.slug[data-v-cc64fdb8]{font-size:12px;color:var(--text-muted);background:var(--bg-tertiary);padding:2px 8px;border-radius:4px}.info-row[data-v-cc64fdb8]{display:flex;justify-content:space-between;padding:8px 0;font-size:14px;border-bottom:1px solid var(--border)}.info-row span[data-v-cc64fdb8]{color:var(--text-secondary)}.permissions[data-v-cc64fdb8]{display:flex;flex-wrap:wrap;gap:6px;margin-top:12px;min-height:24px}.perm-tag[data-v-cc64fdb8]{font-size:12px;padding:2px 8px;border:1px solid rgba(183,243,107,.22);background:var(--accent-soft);color:var(--accent);border-radius:4px}.field-hint[data-v-cc64fdb8]{font-size:12px;color:var(--text-muted)}.card-actions[data-v-cc64fdb8]{display:flex;gap:8px;margin-top:16px}.card-actions .btn[data-v-cc64fdb8]{flex:1}.check-item[data-v-cc64fdb8]{display:flex;align-items:center;gap:8px;margin-bottom:8px;font-size:14px;cursor:pointer}.danger[data-v-cc64fdb8]{color:var(--danger)}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
.toolbar[data-v-bdc5d18d]{margin-bottom:16px}.url-cell[data-v-bdc5d18d]{max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;color:var(--text-secondary)}code[data-v-bdc5d18d]{background:var(--bg-tertiary);padding:2px 6px;border-radius:4px;font-size:13px}.test-result[data-v-bdc5d18d]{margin-top:8px;padding:12px;border-radius:8px;font-size:13px}.test-result.success[data-v-bdc5d18d]{background:#22c55e1f;border:1px solid rgba(34,197,94,.3);color:var(--success)}.test-result.error[data-v-bdc5d18d]{background:#ef44441f;border:1px solid rgba(239,68,68,.3);color:var(--danger)}.test-result p[data-v-bdc5d18d]{margin-top:6px;color:var(--text-secondary)}.test-result.success p[data-v-bdc5d18d]{color:#86efac}.reply-preview[data-v-bdc5d18d]{word-break:break-all}.field-hint[data-v-bdc5d18d]{margin-top:4px;font-size:12px;color:var(--text-secondary)}.field-hint-warn[data-v-bdc5d18d]{color:var(--danger)}.saved-key-hint[data-v-bdc5d18d]{margin-bottom:6px;font-size:12px;color:var(--success)}.saved-key-hint code[data-v-bdc5d18d]{background:#22c55e1f;color:var(--success)}.workflow-json[data-v-bdc5d18d]{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-bdc5d18d]{display:flex;gap:8px;margin-top:8px;flex-wrap:wrap}.form-row-2[data-v-bdc5d18d]{display:grid;grid-template-columns:1fr 1fr;gap:12px}.btn-sm[data-v-bdc5d18d]{padding:6px 10px;font-size:12px}.file-btn[data-v-bdc5d18d]{cursor:pointer;display:inline-flex;align-items:center}@media(max-width:640px){.form-row-2[data-v-bdc5d18d]{grid-template-columns:1fr}}
|
||||||
@@ -1 +0,0 @@
|
|||||||
.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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
.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-2e1246c9]{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap}.empty[data-v-2e1246c9]{text-align:center;padding:32px;color:var(--text-muted)}.type-badge[data-v-2e1246c9]{font-size:11px;padding:2px 6px;border-radius:4px}.type-badge.dir[data-v-2e1246c9]{background:#ffffff0b;color:var(--text-secondary)}.type-badge.menu[data-v-2e1246c9]{background:var(--accent-soft);color:var(--accent)}.type-badge.btn[data-v-2e1246c9]{background:#8fe06a14;color:var(--success)}.path-cell[data-v-2e1246c9]{display:flex;align-items:center;gap:8px;color:var(--text-secondary);font-family:Cascadia Code,Consolas,monospace;font-size:12px}.path-cell[data-v-2e1246c9] svg{color:var(--accent)}.field-hint[data-v-2e1246c9]{margin-top:6px;font-size:12px;color:var(--text-muted)}.danger[data-v-2e1246c9]{color:var(--danger)}
|
||||||
@@ -1 +0,0 @@
|
|||||||
.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 @@
|
|||||||
|
.toolbar[data-v-f4d935cd]{margin-bottom:16px}.perm-tags[data-v-f4d935cd]{display:flex;flex-wrap:wrap;gap:4px}.perm-tag[data-v-f4d935cd]{font-size:12px;padding:2px 8px;background:var(--accent-soft);color:var(--accent);border-radius:4px}.field-hint[data-v-f4d935cd]{font-size:12px;color:var(--text-muted)}.modal-wide[data-v-f4d935cd]{max-width:640px;max-height:90vh;display:flex;flex-direction:column}.modal-wide .modal-body[data-v-f4d935cd]{overflow-y:auto}.top-check[data-v-f4d935cd]{font-weight:500}.perm-section[data-v-f4d935cd]{border:1px solid var(--border);border-radius:10px;padding:12px;background:var(--bg-tertiary)}.perm-section-header[data-v-f4d935cd]{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;font-size:13px;font-weight:500}.perm-actions[data-v-f4d935cd]{display:flex;gap:4px}.perm-dir[data-v-f4d935cd]{margin-bottom:14px;padding-bottom:10px;border-bottom:1px dashed var(--border)}.perm-dir[data-v-f4d935cd]:last-child{border-bottom:none;margin-bottom:0}.dir-check[data-v-f4d935cd]{font-weight:600;margin-bottom:8px}.perm-menu[data-v-f4d935cd]{margin-left:22px;margin-bottom:8px}.perm-btns[data-v-f4d935cd]{margin-left:24px;display:flex;flex-direction:column;gap:4px}.btn-check[data-v-f4d935cd]{font-size:13px;color:var(--text-secondary)}.check-item[data-v-f4d935cd]{display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:14px;cursor:pointer}.type-badge[data-v-f4d935cd]{font-size:11px;padding:1px 6px;border-radius:4px;font-weight:500}.type-badge.dir[data-v-f4d935cd]{background:#b7f36b12;color:var(--text-secondary)}.type-badge.menu[data-v-f4d935cd]{background:var(--accent-soft);color:var(--accent)}.type-badge.btn[data-v-f4d935cd]{background:#8fe06a14;color:var(--success)}.danger[data-v-f4d935cd]{color:var(--danger)}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
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};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
.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}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.section-title[data-v-c3e4aba4]{margin-bottom:16px;font-size:16px}.section-desc[data-v-c3e4aba4],.field-hint[data-v-c3e4aba4]{color:var(--text-secondary);font-size:12px}.section-desc[data-v-c3e4aba4]{margin-bottom:16px;font-size:13px}.voice-persona-panel[data-v-c3e4aba4],.feature-panel[data-v-c3e4aba4]{margin-top:16px}.persona-heading[data-v-c3e4aba4]{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;padding-bottom:20px;border-bottom:1px solid var(--border)}.persona-preview[data-v-c3e4aba4]{display:flex;align-items:center;gap:14px;min-width:0}.persona-avatar[data-v-c3e4aba4]{width:54px;height:54px;flex:0 0 54px;border-radius:18px;display:grid;place-items:center;border:1px solid rgba(183,243,107,.42);background:linear-gradient(145deg,var(--accent-hover),var(--accent));box-shadow:inset 0 1px #ffffff73,0 5px #577c2f,0 13px 26px #6ea63629;color:#11150e;font-size:22px;font-weight:700}.persona-kicker[data-v-c3e4aba4]{color:var(--accent);font-size:10px;font-weight:700;letter-spacing:.13em}.persona-preview h3[data-v-c3e4aba4]{margin-top:3px;font-size:18px}.persona-preview p[data-v-c3e4aba4]{margin-top:4px;color:var(--text-secondary);font-size:13px;line-height:1.5}.enable-switch[data-v-c3e4aba4]{display:inline-flex;align-items:center;gap:8px;padding:8px 11px;border:1px solid var(--border);border-radius:999px;color:var(--text-secondary);font-size:12px;white-space:nowrap}.enable-switch input[data-v-c3e4aba4],.feature-item input[data-v-c3e4aba4],.check-item input[data-v-c3e4aba4]{accent-color:var(--accent)}.preset-row[data-v-c3e4aba4]{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin:18px 0}.preset-row>span[data-v-c3e4aba4]{margin-right:4px;color:var(--text-secondary);font-size:12px}.preset-btn[data-v-c3e4aba4]{padding:7px 11px;border:1px solid var(--border);border-radius:8px;background:#ffffff05;color:var(--text-secondary);font-size:12px}.preset-btn[data-v-c3e4aba4]:hover{border-color:#b7f36b66;background:var(--accent-soft);color:var(--accent)}.form-grid[data-v-c3e4aba4]{display:grid;grid-template-columns:1fr 1fr;gap:14px}.form-grid-3[data-v-c3e4aba4]{grid-template-columns:1fr 1fr .7fr}.persona-textarea[data-v-c3e4aba4]{min-height:86px;resize:vertical;line-height:1.55}.field-hint[data-v-c3e4aba4]{margin-top:6px;line-height:1.5}.input-suffix[data-v-c3e4aba4]{position:relative}.input-suffix input[data-v-c3e4aba4]{padding-right:38px}.input-suffix span[data-v-c3e4aba4]{position:absolute;top:50%;right:12px;color:var(--text-muted);font-size:12px;transform:translateY(-50%)}.reference-box[data-v-c3e4aba4]{margin-bottom:16px;padding:14px;border:1px dashed var(--border-strong);border-radius:10px;display:flex;align-items:center;justify-content:space-between;gap:16px;background:#b7f36b09}.reference-box strong[data-v-c3e4aba4]{font-size:13px}.reference-box p[data-v-c3e4aba4]{margin-top:4px;color:var(--text-secondary);font-size:12px;line-height:1.5}.reference-btn[data-v-c3e4aba4]{flex:0 0 auto;border:1px solid var(--border)}.persona-actions[data-v-c3e4aba4]{min-height:38px;display:flex;align-items:center;flex-wrap:wrap;gap:12px}.preview-btn[data-v-c3e4aba4]{border:1px solid var(--border-strong)}.preview-audio[data-v-c3e4aba4]{width:min(320px,100%);height:36px}.voice-status[data-v-c3e4aba4]{color:var(--success);font-size:12px}.voice-status.error[data-v-c3e4aba4]{color:var(--danger)}.feature-grid[data-v-c3e4aba4]{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:20px}.feature-item[data-v-c3e4aba4],.check-item[data-v-c3e4aba4]{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:14px}.success-msg[data-v-c3e4aba4]{margin-top:12px;color:var(--success);font-size:14px}@media(max-width:760px){.persona-heading[data-v-c3e4aba4],.reference-box[data-v-c3e4aba4]{align-items:stretch;flex-direction:column}.enable-switch[data-v-c3e4aba4]{align-self:flex-start}.form-grid[data-v-c3e4aba4],.form-grid-3[data-v-c3e4aba4]{grid-template-columns:1fr}}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
.toolbar[data-v-855121f7]{margin-bottom:16px}.empty[data-v-855121f7]{text-align:center;padding:32px;color:var(--text-muted)}.pagination[data-v-855121f7]{display:flex;align-items:center;justify-content:center;gap:16px;margin-top:16px;font-size:13px;color:var(--text-secondary)}.modal-wide[data-v-855121f7]{max-width:560px}.user-meta[data-v-855121f7]{display:flex;flex-wrap:wrap;gap:12px;margin-bottom:16px;padding:10px 12px;background:var(--bg-tertiary);border-radius:8px;font-size:13px;color:var(--text-secondary)}.form-divider[data-v-855121f7]{margin:20px 0 12px;padding-bottom:8px;border-bottom:1px solid var(--border);font-size:13px;font-weight:500;color:var(--text-secondary)}.membership-preview[data-v-855121f7]{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}.perm-tag[data-v-855121f7]{font-size:12px;padding:2px 8px;background:var(--accent-soft);color:var(--accent);border-radius:4px}.field-hint[data-v-855121f7]{font-size:12px;color:var(--text-muted)}.danger[data-v-855121f7]{color:var(--danger)}
|
||||||
@@ -1 +0,0 @@
|
|||||||
.toolbar[data-v-921bbcc9]{margin-bottom:16px}.empty[data-v-921bbcc9]{text-align:center;padding:32px;color:var(--text-muted)}.pagination[data-v-921bbcc9]{display:flex;align-items:center;justify-content:center;gap:16px;margin-top:16px;font-size:13px;color:var(--text-secondary)}.modal-wide[data-v-921bbcc9]{max-width:560px}.user-meta[data-v-921bbcc9]{display:flex;flex-wrap:wrap;gap:12px;margin-bottom:16px;padding:10px 12px;background:var(--bg-tertiary);border-radius:8px;font-size:13px;color:var(--text-secondary)}.form-divider[data-v-921bbcc9]{margin:20px 0 12px;padding-bottom:8px;border-bottom:1px solid var(--border);font-size:13px;font-weight:500;color:var(--text-secondary)}.membership-preview[data-v-921bbcc9]{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}.perm-tag[data-v-921bbcc9]{font-size:12px;padding:2px 8px;background:#6366f126;color:var(--accent);border-radius:4px}.field-hint[data-v-921bbcc9]{font-size:12px;color:var(--text-muted)}.danger[data-v-921bbcc9]{color:#ef4444}
|
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AI Chat 管理后台</title>
|
<title>AI Chat 管理后台</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
|
||||||
<script type="module" crossorigin src="/admin/assets/index-z4tF8s-R.js"></script>
|
<script type="module" crossorigin src="/admin/assets/index-9aV50nsX.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-DPq65Hqk.css">
|
<link rel="stylesheet" crossorigin href="/admin/assets/index-DVjG14Yi.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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,21 @@
|
|||||||
|
import{q as a}from"./index-CTF1v35B.js";/**
|
||||||
|
* @license @tabler/icons-vue v3.45.0 - MIT
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/var t=a("outline","plus","Plus",[["path",{d:"M12 5l0 14",key:"svg-0"}],["path",{d:"M5 12l14 0",key:"svg-1"}]]);/**
|
||||||
|
* @license @tabler/icons-vue v3.45.0 - MIT
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/var v=a("outline","trash","Trash",[["path",{d:"M4 7l16 0",key:"svg-0"}],["path",{d:"M10 11l0 6",key:"svg-1"}],["path",{d:"M14 11l0 6",key:"svg-2"}],["path",{d:"M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12",key:"svg-3"}],["path",{d:"M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3",key:"svg-4"}]]);/**
|
||||||
|
* @license @tabler/icons-vue v3.45.0 - MIT
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/var s=a("outline","file-text","FileText",[["path",{d:"M14 3v4a1 1 0 0 0 1 1h4",key:"svg-0"}],["path",{d:"M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2",key:"svg-1"}],["path",{d:"M9 9l1 0",key:"svg-2"}],["path",{d:"M9 13l6 0",key:"svg-3"}],["path",{d:"M9 17l6 0",key:"svg-4"}]]);/**
|
||||||
|
* @license @tabler/icons-vue v3.45.0 - MIT
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/var h=a("outline","movie","Movie",[["path",{d:"M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12",key:"svg-0"}],["path",{d:"M8 4l0 16",key:"svg-1"}],["path",{d:"M16 4l0 16",key:"svg-2"}],["path",{d:"M4 8l4 0",key:"svg-3"}],["path",{d:"M4 16l4 0",key:"svg-4"}],["path",{d:"M4 12l16 0",key:"svg-5"}],["path",{d:"M16 8l4 0",key:"svg-6"}],["path",{d:"M16 16l4 0",key:"svg-7"}]]);export{t as I,v as a,s as b,h as c};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.auth-page[data-v-036d1210]{position:relative;min-height:100dvh;display:grid;place-items:center;padding:28px;overflow:auto;background:radial-gradient(circle at 72% 16%,rgba(183,243,107,.08),transparent 26%),var(--bg-primary)}.auth-theme-toggle[data-v-036d1210]{position:absolute;top:18px;right:18px;z-index:3}.auth-shell[data-v-036d1210]{position:relative;display:grid;width:min(900px,100%);grid-template-columns:1.08fr .92fr;overflow:hidden;border:1px solid var(--border-strong);border-radius:22px;background:var(--bg-secondary);box-shadow:inset 0 1px #ffffff0b,var(--shadow)}.auth-shell[data-v-036d1210]:after{position:absolute;top:0;left:16%;width:36%;height:1px;background:linear-gradient(90deg,transparent,var(--accent),transparent);box-shadow:0 0 17px #b7f36b70;content:""}.auth-story[data-v-036d1210]{position:relative;display:flex;min-height:550px;flex-direction:column;justify-content:center;padding:54px;overflow:hidden;border-right:1px solid var(--border);background:linear-gradient(135deg,rgba(183,243,107,.07),transparent 45%),repeating-linear-gradient(135deg,rgba(255,255,255,.018) 0 1px,transparent 1px 14px),#0c0f13}.auth-logo[data-v-036d1210]{display:grid;width:46px;height:46px;place-items:center;margin-bottom:46px;border:1px solid rgba(183,243,107,.5);border-radius:14px;background:var(--accent);color:#11150e;box-shadow:inset 0 1px #ffffff85,0 5px #55782f,0 14px 28px #6ea6362b}.auth-eyebrow[data-v-036d1210],.auth-note[data-v-036d1210]{color:var(--accent);font-family:Cascadia Code,Consolas,monospace;font-size:10px;letter-spacing:.16em}.auth-story h1[data-v-036d1210]{margin:15px 0 19px;font-size:clamp(36px,4.7vw,54px);font-weight:760;letter-spacing:-.06em;line-height:1.04;text-wrap:balance}.auth-story p[data-v-036d1210]{max-width:31ch;color:var(--text-secondary);font-size:14px;line-height:1.75}.auth-note[data-v-036d1210]{position:absolute;bottom:30px;left:54px;color:var(--text-muted);font-size:9px}.auth-card[data-v-036d1210]{display:flex;flex-direction:column;justify-content:center;padding:48px 42px;background:radial-gradient(circle at 100% 0%,rgba(183,243,107,.05),transparent 28%),var(--bg-secondary)}.auth-header[data-v-036d1210]{margin-bottom:30px}.auth-status[data-v-036d1210]{display:inline-flex;align-items:center;gap:7px;color:var(--text-muted);font-size:11px;font-weight:650;letter-spacing:.06em}.auth-status i[data-v-036d1210]{width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 12px #b7f36b99}.auth-header h2[data-v-036d1210]{margin:12px 0 7px;font-size:27px;letter-spacing:-.045em}.auth-header p[data-v-036d1210],.auth-footer[data-v-036d1210]{color:var(--text-secondary);font-size:13px}.auth-btn[data-v-036d1210]{width:100%;min-height:47px;margin-top:7px}.auth-footer[data-v-036d1210]{margin-top:25px;text-align:center}@media(max-width:720px){.auth-page[data-v-036d1210]{padding:14px}.auth-shell[data-v-036d1210]{grid-template-columns:1fr}.auth-story[data-v-036d1210]{display:none}.auth-card[data-v-036d1210]{min-height:540px;padding:40px 25px}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{_ as V,u as k,a as E,o as S,c as v,b as i,d as t,e as f,f as e,t as d,w as x,g as c,v as g,h as A,i as I,r as l,j as N,k as C,l as M,m as h}from"./index-CTF1v35B.js";import{I as q,T as B}from"./ThemeToggle-BwEr7zqG.js";const L={class:"auth-page"},R={class:"auth-shell"},D={class:"auth-story"},G={class:"auth-logo"},P={class:"auth-card"},U={class:"auth-header"},j={class:"form-group"},z={class:"form-group"},X={key:0,class:"form-error"},F=["disabled"],H={class:"auth-footer"},J={__name:"LoginView",setup(K){const _=C(),b=M(),w=k(),p=E(),n=l(""),u=l(""),o=l(""),a=l(!1);S(()=>p.loadPublic());async function y(){o.value="",a.value=!0;try{await w.login(n.value,u.value),_.push(b.query.redirect||"/")}catch(m){o.value=m.message}finally{a.value=!1}}return(m,s)=>{const T=N("router-link");return h(),v("div",L,[i(B,{class:"auth-theme-toggle"}),t("section",R,[t("aside",D,[t("span",G,[i(f(q),{size:22,"stroke-width":1.8})]),s[2]||(s[2]=t("span",{class:"auth-eyebrow"},"AI CREATIVE SPACE",-1)),s[3]||(s[3]=t("h1",null,[e("把想法放进来,"),t("br"),e("让创作自然发生。")],-1)),s[4]||(s[4]=t("p",null,"在同一个工作区完成对话、图像生成与内容整理。",-1)),s[5]||(s[5]=t("span",{class:"auth-note"},"TEXT · IMAGE · AGENT",-1))]),t("div",P,[t("div",U,[s[6]||(s[6]=t("span",{class:"auth-status"},[t("i"),e(" 账户登录")],-1)),t("h2",null,d(f(p).siteName),1),s[7]||(s[7]=t("p",null,"欢迎回来,请登录您的账户",-1))]),t("form",{onSubmit:x(y,["prevent"])},[t("div",j,[s[8]||(s[8]=t("label",null,"账号",-1)),c(t("input",{"onUpdate:modelValue":s[0]||(s[0]=r=>n.value=r),class:"form-input",placeholder:"用户名或邮箱",required:""},null,512),[[g,n.value]])]),t("div",z,[s[9]||(s[9]=t("label",null,"密码",-1)),c(t("input",{"onUpdate:modelValue":s[1]||(s[1]=r=>u.value=r),type:"password",class:"form-input",placeholder:"请输入密码",required:""},null,512),[[g,u.value]])]),o.value?(h(),v("p",X,d(o.value),1)):A("",!0),t("button",{type:"submit",class:"btn btn-primary auth-btn",disabled:a.value},d(a.value?"登录中...":"进入创作空间"),9,F)],32),t("p",H,[s[11]||(s[11]=e(" 还没有账户? ",-1)),i(T,{to:"/register"},{default:I(()=>[...s[10]||(s[10]=[e("立即注册",-1)])]),_:1})])])])])}}},W=V(J,[["__scopeId","data-v-036d1210"]]);export{W as default};
|
||||||
@@ -1 +0,0 @@
|
|||||||
.auth-page[data-v-a92a8550]{min-height:100%;display:flex;align-items:center;justify-content:center;padding:24px;background:var(--bg-secondary)}.auth-card[data-v-a92a8550]{width:100%;max-width:400px;padding:40px 32px;background:var(--bg-primary);border-radius:16px;border:1px solid var(--border);box-shadow:var(--shadow)}.auth-header[data-v-a92a8550]{text-align:center;margin-bottom:32px}.logo[data-v-a92a8550]{font-size:48px;margin-bottom:12px}.auth-header h1[data-v-a92a8550]{font-size:24px;margin-bottom:8px}.auth-header p[data-v-a92a8550]{color:var(--text-secondary);font-size:14px}.auth-btn[data-v-a92a8550]{width:100%;margin-top:8px;padding:14px}.auth-footer[data-v-a92a8550]{text-align:center;margin-top:24px;font-size:14px;color:var(--text-secondary)}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{_ as y,u as V,o as k,c as p,a as t,t as u,b as x,w as N,d as v,v as c,e as S,f as m,g as q,h as B,r as a,i as C,j as L,k as M,l as f}from"./index-k46zOoYG.js";import{u as D}from"./settings-1-hPceiv.js";const R={class:"auth-page"},T={class:"auth-card"},U={class:"auth-header"},j={class:"form-group"},A={class:"form-group"},E={key:0,class:"form-error"},I=["disabled"],P={class:"auth-footer"},z={__name:"LoginView",setup(F){const _=L(),g=M(),b=V(),i=D(),l=a(""),r=a(""),s=a(""),o=a(!1);k(()=>i.loadPublic());async function h(){s.value="",o.value=!0;try{await b.login(l.value,r.value),_.push(g.query.redirect||"/")}catch(d){s.value=d.message}finally{o.value=!1}}return(d,e)=>{const w=C("router-link");return f(),p("div",R,[t("div",T,[t("div",U,[e[2]||(e[2]=t("div",{class:"logo"},"💬",-1)),t("h1",null,u(x(i).siteName),1),e[3]||(e[3]=t("p",null,"登录您的账户",-1))]),t("form",{onSubmit:N(h,["prevent"])},[t("div",j,[e[4]||(e[4]=t("label",null,"账号",-1)),v(t("input",{"onUpdate:modelValue":e[0]||(e[0]=n=>l.value=n),class:"form-input",placeholder:"用户名或邮箱",required:""},null,512),[[c,l.value]])]),t("div",A,[e[5]||(e[5]=t("label",null,"密码",-1)),v(t("input",{"onUpdate:modelValue":e[1]||(e[1]=n=>r.value=n),type:"password",class:"form-input",placeholder:"请输入密码",required:""},null,512),[[c,r.value]])]),s.value?(f(),p("p",E,u(s.value),1)):S("",!0),t("button",{type:"submit",class:"btn btn-primary auth-btn",disabled:o.value},u(o.value?"登录中...":"登录"),9,I)],32),t("p",P,[e[7]||(e[7]=m(" 还没有账户? ",-1)),q(w,{to:"/register"},{default:B(()=>[...e[6]||(e[6]=[m("立即注册",-1)])]),_:1})])])])}}},J=y(z,[["__scopeId","data-v-a92a8550"]]);export{J as default};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.auth-page[data-v-4a1b27ff]{position:relative;min-height:100dvh;display:grid;place-items:center;padding:28px;overflow:auto;background:radial-gradient(circle at 72% 16%,rgba(183,243,107,.08),transparent 26%),var(--bg-primary)}.auth-theme-toggle[data-v-4a1b27ff]{position:absolute;top:18px;right:18px;z-index:3}.auth-shell[data-v-4a1b27ff]{position:relative;display:grid;width:min(900px,100%);grid-template-columns:1.08fr .92fr;overflow:hidden;border:1px solid var(--border-strong);border-radius:22px;background:var(--bg-secondary);box-shadow:inset 0 1px #ffffff0b,var(--shadow)}.auth-shell[data-v-4a1b27ff]:after{position:absolute;top:0;left:16%;width:36%;height:1px;background:linear-gradient(90deg,transparent,var(--accent),transparent);box-shadow:0 0 17px #b7f36b70;content:""}.auth-story[data-v-4a1b27ff]{position:relative;display:flex;min-height:610px;flex-direction:column;justify-content:center;padding:54px;overflow:hidden;border-right:1px solid var(--border);background:linear-gradient(135deg,rgba(183,243,107,.07),transparent 45%),repeating-linear-gradient(135deg,rgba(255,255,255,.018) 0 1px,transparent 1px 14px),#0c0f13}.auth-logo[data-v-4a1b27ff]{display:grid;width:46px;height:46px;place-items:center;margin-bottom:46px;border:1px solid rgba(183,243,107,.5);border-radius:14px;background:var(--accent);color:#11150e;box-shadow:inset 0 1px #ffffff85,0 5px #55782f,0 14px 28px #6ea6362b}.auth-eyebrow[data-v-4a1b27ff],.auth-note[data-v-4a1b27ff]{color:var(--accent);font-family:Cascadia Code,Consolas,monospace;font-size:10px;letter-spacing:.16em}.auth-story h1[data-v-4a1b27ff]{margin:15px 0 19px;font-size:clamp(36px,4.7vw,54px);font-weight:760;letter-spacing:-.06em;line-height:1.04;text-wrap:balance}.auth-story p[data-v-4a1b27ff]{max-width:31ch;color:var(--text-secondary);font-size:14px;line-height:1.75}.auth-note[data-v-4a1b27ff]{position:absolute;bottom:30px;left:54px;color:var(--text-muted);font-size:9px}.auth-card[data-v-4a1b27ff]{display:flex;flex-direction:column;justify-content:center;padding:42px;background:radial-gradient(circle at 100% 0%,rgba(183,243,107,.05),transparent 28%),var(--bg-secondary)}.auth-header[data-v-4a1b27ff]{margin-bottom:27px}.auth-status[data-v-4a1b27ff]{display:inline-flex;align-items:center;gap:7px;color:var(--text-muted);font-size:11px;font-weight:650;letter-spacing:.06em}.auth-status i[data-v-4a1b27ff]{width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 12px #b7f36b99}.auth-header h2[data-v-4a1b27ff]{margin:12px 0 7px;font-size:27px;letter-spacing:-.045em}.auth-header p[data-v-4a1b27ff],.auth-footer[data-v-4a1b27ff]{color:var(--text-secondary);font-size:13px}.invitation-input[data-v-4a1b27ff]{font-family:Cascadia Code,Consolas,monospace;letter-spacing:.04em;text-transform:uppercase}.field-help[data-v-4a1b27ff]{display:block;margin-top:7px;color:var(--text-muted);font-size:11px;line-height:1.5}.auth-btn[data-v-4a1b27ff]{width:100%;min-height:47px;margin-top:6px}.auth-footer[data-v-4a1b27ff]{margin-top:23px;text-align:center}@media(max-width:720px){.auth-page[data-v-4a1b27ff]{padding:14px}.auth-shell[data-v-4a1b27ff]{grid-template-columns:1fr}.auth-story[data-v-4a1b27ff]{display:none}.auth-card[data-v-4a1b27ff]{min-height:640px;padding:36px 25px}}
|
||||||
@@ -1 +0,0 @@
|
|||||||
.auth-page[data-v-ab6798e7]{min-height:100%;display:flex;align-items:center;justify-content:center;padding:24px;background:var(--bg-secondary)}.auth-card[data-v-ab6798e7]{width:100%;max-width:400px;padding:40px 32px;background:var(--bg-primary);border-radius:16px;border:1px solid var(--border);box-shadow:var(--shadow)}.auth-header[data-v-ab6798e7]{text-align:center;margin-bottom:32px}.logo[data-v-ab6798e7]{font-size:48px;margin-bottom:12px}.auth-header h1[data-v-ab6798e7]{font-size:24px;margin-bottom:8px}.auth-header p[data-v-ab6798e7]{color:var(--text-secondary);font-size:14px}.auth-btn[data-v-ab6798e7]{width:100%;margin-top:8px;padding:14px}.auth-footer[data-v-ab6798e7]{text-align:center;margin-top:24px;font-size:14px;color:var(--text-secondary)}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{_ as h,u as V,o as x,c as f,a as t,t as d,b as k,w as N,d as p,v as m,e as R,f as c,g as S,h as q,r as l,i as B,j as C,l as g}from"./index-k46zOoYG.js";import{u as M}from"./settings-1-hPceiv.js";const U={class:"auth-page"},D={class:"auth-card"},T={class:"auth-header"},j={class:"form-group"},A={class:"form-group"},E={class:"form-group"},I={key:0,class:"form-error"},P=["disabled"],z={class:"auth-footer"},F={__name:"RegisterView",setup(G){const _=C(),b=V(),r=M(),u=l(""),n=l(""),i=l(""),s=l(""),a=l(!1);x(()=>r.loadPublic());async function w(){if(!r.allowRegister){s.value="当前不允许注册";return}s.value="",a.value=!0;try{await b.register(u.value,n.value,i.value),_.push("/")}catch(v){s.value=v.message}finally{a.value=!1}}return(v,e)=>{const y=B("router-link");return g(),f("div",U,[t("div",D,[t("div",T,[e[3]||(e[3]=t("div",{class:"logo"},"💬",-1)),t("h1",null,d(k(r).siteName),1),e[4]||(e[4]=t("p",null,"创建新账户",-1))]),t("form",{onSubmit:N(w,["prevent"])},[t("div",j,[e[5]||(e[5]=t("label",null,"用户名",-1)),p(t("input",{"onUpdate:modelValue":e[0]||(e[0]=o=>u.value=o),class:"form-input",placeholder:"3-50 个字符",required:""},null,512),[[m,u.value]])]),t("div",A,[e[6]||(e[6]=t("label",null,"邮箱",-1)),p(t("input",{"onUpdate:modelValue":e[1]||(e[1]=o=>n.value=o),type:"email",class:"form-input",placeholder:"your@email.com",required:""},null,512),[[m,n.value]])]),t("div",E,[e[7]||(e[7]=t("label",null,"密码",-1)),p(t("input",{"onUpdate:modelValue":e[2]||(e[2]=o=>i.value=o),type:"password",class:"form-input",placeholder:"至少 6 位",required:""},null,512),[[m,i.value]])]),s.value?(g(),f("p",I,d(s.value),1)):R("",!0),t("button",{type:"submit",class:"btn btn-primary auth-btn",disabled:a.value},d(a.value?"注册中...":"注册"),9,P)],32),t("p",z,[e[9]||(e[9]=c(" 已有账户? ",-1)),S(y,{to:"/login"},{default:q(()=>[...e[8]||(e[8]=[c("立即登录",-1)])]),_:1})])])])}}},K=h(F,[["__scopeId","data-v-ab6798e7"]]);export{K as default};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{_ as k,u as N,a as R,o as S,c as h,b as f,d as e,e as w,f as a,t as g,w as x,g as u,v as r,h as A,i as C,r as o,j as E,k as I,m as y}from"./index-CTF1v35B.js";import{I as q,T as B}from"./ThemeToggle-BwEr7zqG.js";const M={class:"auth-page"},U={class:"auth-shell"},D={class:"auth-story"},G={class:"auth-logo"},j={class:"auth-card"},z={class:"auth-header"},P={class:"form-group"},X={class:"form-group"},F={class:"form-group"},H={class:"form-group"},J={key:0,class:"form-error"},K=["disabled"],L={class:"auth-footer"},O={__name:"RegisterView",setup(Q){const T=I(),V=N(),i=R(),d=o(""),p=o(""),m=o(""),v=o(""),l=o(""),n=o(!1);S(()=>i.loadPublic());async function c(){if(!i.allowRegister){l.value="当前不允许注册";return}l.value="",n.value=!0;try{await V.register(d.value,p.value,m.value,v.value),T.push("/")}catch(b){l.value=b.message}finally{n.value=!1}}return(b,s)=>{const _=E("router-link");return y(),h("div",M,[f(B,{class:"auth-theme-toggle"}),e("section",U,[e("aside",D,[e("span",G,[f(w(q),{size:22,"stroke-width":1.8})]),s[4]||(s[4]=e("span",{class:"auth-eyebrow"},"START CREATING",-1)),s[5]||(s[5]=e("h1",null,[a("一个账户,"),e("br"),a("连接全部创作工具。")],-1)),s[6]||(s[6]=e("p",null,"使用管理员签发的邀请码加入指定部门,登录后即可使用全部模型与创作工具。",-1)),s[7]||(s[7]=e("span",{class:"auth-note"},"TEXT · IMAGE · AGENT",-1))]),e("div",j,[e("div",z,[s[8]||(s[8]=e("span",{class:"auth-status"},[e("i"),a(" 创建账户")],-1)),e("h2",null,g(w(i).siteName),1),s[9]||(s[9]=e("p",null,"填写信息,开始新的创作会话",-1))]),e("form",{onSubmit:x(c,["prevent"])},[e("div",P,[s[10]||(s[10]=e("label",null,"用户名",-1)),u(e("input",{"onUpdate:modelValue":s[0]||(s[0]=t=>d.value=t),class:"form-input",placeholder:"3-50 个字符",required:""},null,512),[[r,d.value]])]),e("div",X,[s[11]||(s[11]=e("label",null,"邮箱",-1)),u(e("input",{"onUpdate:modelValue":s[1]||(s[1]=t=>p.value=t),type:"email",class:"form-input",placeholder:"your@email.com",required:""},null,512),[[r,p.value]])]),e("div",F,[s[12]||(s[12]=e("label",null,"密码",-1)),u(e("input",{"onUpdate:modelValue":s[2]||(s[2]=t=>m.value=t),type:"password",class:"form-input",placeholder:"至少 6 位",required:""},null,512),[[r,m.value]])]),e("div",H,[s[13]||(s[13]=e("label",null,"邀请码",-1)),u(e("input",{"onUpdate:modelValue":s[3]||(s[3]=t=>v.value=t),class:"form-input invitation-input",placeholder:"例如 INV-1A2B3C4D5E",autocomplete:"one-time-code",required:""},null,512),[[r,v.value,void 0,{trim:!0}]]),s[14]||(s[14]=e("span",{class:"field-help"},"邀请码仅可使用一次,注册后会自动加入邀请码指定的部门",-1))]),l.value?(y(),h("p",J,g(l.value),1)):A("",!0),e("button",{type:"submit",class:"btn btn-primary auth-btn",disabled:n.value},g(n.value?"注册中...":"创建并进入"),9,K)],32),e("p",L,[s[16]||(s[16]=a(" 已有账户? ",-1)),f(_,{to:"/login"},{default:C(()=>[...s[15]||(s[15]=[a("立即登录",-1)])]),_:1})])])])])}}},Z=k(O,[["__scopeId","data-v-4a1b27ff"]]);export{Z as default};
|
||||||
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,6 @@
|
|||||||
|
import{q as n,_ as i,m as p,c as d,d as t,e as a,z as r,S as m}from"./index-CTF1v35B.js";/**
|
||||||
|
* @license @tabler/icons-vue v3.45.0 - MIT
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/var T=n("outline","sparkles","Sparkles",[["path",{d:"M16 18a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2m0 -12a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2m-7 12a6 6 0 0 1 6 -6a6 6 0 0 1 -6 -6a6 6 0 0 1 -6 6a6 6 0 0 1 6 6",key:"svg-0"}]]);const u={class:"theme-toggle",role:"group","aria-label":"界面主题"},g=["aria-pressed"],k=["aria-pressed"],_={__name:"ThemeToggle",setup(c){const{theme:s,setTheme:o}=m();return(b,e)=>(p(),d("div",u,[t("button",{type:"button",class:r({active:a(s)==="light"}),"aria-pressed":a(s)==="light","aria-label":"使用浅色主题",title:"浅色主题",onClick:e[0]||(e[0]=l=>a(o)("light"))},[...e[2]||(e[2]=[t("span",null,"浅色",-1)])],10,g),t("button",{type:"button",class:r({active:a(s)==="dark"}),"aria-pressed":a(s)==="dark","aria-label":"使用深色主题",title:"深色主题",onClick:e[1]||(e[1]=l=>a(o)("dark"))},[...e[3]||(e[3]=[t("span",null,"深色",-1)])],10,k)]))}},h=i(_,[["__scopeId","data-v-6644b9d7"]]);export{T as I,h as T};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user