feat: add Husky hooks and fix provider tutorial UI/logic
- Add Husky pre-commit and pre-push hooks for linting, type checking, and build validation - Fix visual hierarchy bug in the provider onboarding tutorial - Remove feedback modal - Move MinIO bucket creation logic to before app startup - Wire MiniMax audio through voice generation pipeline - Fix scene insertion issues - Fix portal tutorial modal and harden panel variant task flow
This commit is contained in:
@@ -55,10 +55,13 @@ docker compose up -d
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down -v
|
docker compose down -v
|
||||||
|
docker rmi ghcr.io/saturndec/waoowaoo:latest
|
||||||
curl -O https://raw.githubusercontent.com/saturndec/waoowaoo/main/docker-compose.yml
|
curl -O https://raw.githubusercontent.com/saturndec/waoowaoo/main/docker-compose.yml
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 启动后请**清空浏览器缓存**并重新登录,避免旧版本缓存导致异常。
|
||||||
|
|
||||||
### 方式二:克隆仓库 + Docker 构建(完全控制)
|
### 方式二:克隆仓库 + Docker 构建(完全控制)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -47,10 +47,13 @@ docker compose up -d
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down -v
|
docker compose down -v
|
||||||
|
docker rmi ghcr.io/saturndec/waoowaoo:latest
|
||||||
curl -O https://raw.githubusercontent.com/saturndec/waoowaoo/main/docker-compose.yml
|
curl -O https://raw.githubusercontent.com/saturndec/waoowaoo/main/docker-compose.yml
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> After starting, please **clear your browser cache** and log in again to avoid issues caused by stale cache.
|
||||||
|
|
||||||
### Method 2: Clone & Docker Build (Full Control)
|
### Method 2: Clone & Docker Build (Full Control)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -60,19 +60,6 @@ services:
|
|||||||
retries: 30
|
retries: 30
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
minio-init:
|
|
||||||
image: minio/mc:RELEASE.2025-02-21T16-00-46Z
|
|
||||||
container_name: waoowaoo-minio-init
|
|
||||||
depends_on:
|
|
||||||
minio:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: "no"
|
|
||||||
entrypoint: >
|
|
||||||
/bin/sh -c "
|
|
||||||
mc alias set local http://minio:9000 minioadmin minioadmin &&
|
|
||||||
mc mb --ignore-existing local/waoowaoo
|
|
||||||
"
|
|
||||||
|
|
||||||
# ==================== App (Next.js + Workers) ====================
|
# ==================== App (Next.js + Workers) ====================
|
||||||
app:
|
app:
|
||||||
image: ghcr.io/saturndec/waoowaoo:latest
|
image: ghcr.io/saturndec/waoowaoo:latest
|
||||||
@@ -140,8 +127,6 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
minio:
|
minio:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
minio-init:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
command: >
|
command: >
|
||||||
sh -c "
|
sh -c "
|
||||||
npx prisma db push --skip-generate &&
|
npx prisma db push --skip-generate &&
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"workspace": "Workspace",
|
"workspace": "Workspace",
|
||||||
"assetHub": "Asset Hub",
|
"assetHub": "Asset Hub",
|
||||||
"profile": "Settings",
|
"profile": "Settings",
|
||||||
|
"downloadLogs": "Download Logs",
|
||||||
"signin": "Sign In",
|
"signin": "Sign In",
|
||||||
"signup": "Sign Up",
|
"signup": "Sign Up",
|
||||||
"logout": "Logout",
|
"logout": "Logout"
|
||||||
"feedback": "Bug Feedback / Join Community"
|
|
||||||
}
|
}
|
||||||
@@ -126,6 +126,7 @@
|
|||||||
"visualStyle": "Visual Style",
|
"visualStyle": "Visual Style",
|
||||||
"visualStyleHint": "Pick a style that matches your audience — e.g. Realistic for live‑action, Anime for 2D content",
|
"visualStyleHint": "Pick a style that matches your audience — e.g. Realistic for live‑action, Anime for 2D content",
|
||||||
"currentConfigSummary": "Current config: {ratio} · {style}. All subsequent generations will use this combo.",
|
"currentConfigSummary": "Current config: {ratio} · {style}. All subsequent generations will use this combo.",
|
||||||
|
"assetLibraryRatioNote": "Asset library ratios are not affected",
|
||||||
"moreConfig": "For more configuration options, click the 「 Settings」 button in the top right",
|
"moreConfig": "For more configuration options, click the 「 Settings」 button in the top right",
|
||||||
"narration": {
|
"narration": {
|
||||||
"title": "Enable Narration Voiceover",
|
"title": "Enable Narration Voiceover",
|
||||||
|
|||||||
@@ -120,9 +120,7 @@
|
|||||||
"storyToScriptSubtitle": "Story To Script V2",
|
"storyToScriptSubtitle": "Story To Script V2",
|
||||||
"scriptToStoryboardSubtitle": "Script To Storyboard V2",
|
"scriptToStoryboardSubtitle": "Script To Storyboard V2",
|
||||||
"stop": "Stop",
|
"stop": "Stop",
|
||||||
"minimize": "Minimize",
|
"minimize": "Minimize"
|
||||||
"copyErrorDetail": "Copy error detail",
|
|
||||||
"openFeedbackForm": "Open feedback form"
|
|
||||||
},
|
},
|
||||||
"streamStep": {
|
"streamStep": {
|
||||||
"analyzeCharacters": "Analyze characters",
|
"analyzeCharacters": "Analyze characters",
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"workspace": "工作区",
|
"workspace": "工作区",
|
||||||
"assetHub": "资产中心",
|
"assetHub": "资产中心",
|
||||||
"profile": "设置中心",
|
"profile": "设置中心",
|
||||||
|
"downloadLogs": "下载日志",
|
||||||
"signin": "登录",
|
"signin": "登录",
|
||||||
"signup": "注册",
|
"signup": "注册",
|
||||||
"logout": "退出登录",
|
"logout": "退出登录"
|
||||||
"feedback": "Bug 反馈 / 加入群聊"
|
|
||||||
}
|
}
|
||||||
@@ -126,6 +126,7 @@
|
|||||||
"visualStyle": "视觉风格",
|
"visualStyle": "视觉风格",
|
||||||
"visualStyleHint": "根据受众选择画面风格,例如:真人风格适合写实剧情,动漫风格适合二次元内容",
|
"visualStyleHint": "根据受众选择画面风格,例如:真人风格适合写实剧情,动漫风格适合二次元内容",
|
||||||
"currentConfigSummary": "当前配置:{ratio} · {style},后续生成都会使用此组合",
|
"currentConfigSummary": "当前配置:{ratio} · {style},后续生成都会使用此组合",
|
||||||
|
"assetLibraryRatioNote": "资产库比例不受影响",
|
||||||
"moreConfig": "更多配置请点击右上角「 配置」按钮",
|
"moreConfig": "更多配置请点击右上角「 配置」按钮",
|
||||||
"narration": {
|
"narration": {
|
||||||
"title": "启用旁白配音",
|
"title": "启用旁白配音",
|
||||||
|
|||||||
@@ -120,9 +120,7 @@
|
|||||||
"storyToScriptSubtitle": "Story To Script V2",
|
"storyToScriptSubtitle": "Story To Script V2",
|
||||||
"scriptToStoryboardSubtitle": "Script To Storyboard V2",
|
"scriptToStoryboardSubtitle": "Script To Storyboard V2",
|
||||||
"stop": "停止",
|
"stop": "停止",
|
||||||
"minimize": "最小化",
|
"minimize": "最小化"
|
||||||
"copyErrorDetail": "复制错误详情",
|
|
||||||
"openFeedbackForm": "打开问题反馈表单"
|
|
||||||
},
|
},
|
||||||
"streamStep": {
|
"streamStep": {
|
||||||
"analyzeCharacters": "角色分析",
|
"analyzeCharacters": "角色分析",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "prisma generate",
|
"postinstall": "prisma generate",
|
||||||
"dev": "concurrently --kill-others \"npm run dev:next\" \"npm run dev:worker\" \"npm run dev:watchdog\" \"npm run dev:board\"",
|
"dev": "npm run storage:init && concurrently --kill-others \"npm run dev:next\" \"npm run dev:worker\" \"npm run dev:watchdog\" \"npm run dev:board\"",
|
||||||
"dev:next": "cross-env NODE_OPTIONS=\"--no-deprecation\" next dev --turbopack -H 0.0.0.0",
|
"dev:next": "cross-env NODE_OPTIONS=\"--no-deprecation\" next dev --turbopack -H 0.0.0.0",
|
||||||
"dev:worker": "tsx watch --env-file=.env src/lib/workers/index.ts",
|
"dev:worker": "tsx watch --env-file=.env src/lib/workers/index.ts",
|
||||||
"dev:watchdog": "tsx watch --env-file=.env scripts/watchdog.ts",
|
"dev:watchdog": "tsx watch --env-file=.env scripts/watchdog.ts",
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
"dev:turbo": "next dev --turbopack -H 0.0.0.0",
|
"dev:turbo": "next dev --turbopack -H 0.0.0.0",
|
||||||
"build": "prisma generate && next build",
|
"build": "prisma generate && next build",
|
||||||
"build:turbo": "next build --turbopack",
|
"build:turbo": "next build --turbopack",
|
||||||
"start": "concurrently --kill-others \"npm run start:next\" \"npm run start:worker\" \"npm run start:watchdog\" \"npm run start:board\"",
|
"start": "npm run storage:init && concurrently --kill-others \"npm run start:next\" \"npm run start:worker\" \"npm run start:watchdog\" \"npm run start:board\"",
|
||||||
|
"storage:init": "tsx --env-file=.env src/lib/storage/init.ts",
|
||||||
"start:next": "next start -H 0.0.0.0",
|
"start:next": "next start -H 0.0.0.0",
|
||||||
"start:worker": "tsx --env-file=.env src/lib/workers/index.ts",
|
"start:worker": "tsx --env-file=.env src/lib/workers/index.ts",
|
||||||
"start:watchdog": "tsx --env-file=.env scripts/watchdog.ts",
|
"start:watchdog": "tsx --env-file=.env scripts/watchdog.ts",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ if ! curl -sf http://127.0.0.1:19000/minio/health/live >/devdev/null 2>&1; then
|
|||||||
read -p "是否尝试自动启动 MinIO? [Y/n] " -n 1 -r
|
read -p "是否尝试自动启动 MinIO? [Y/n] " -n 1 -r
|
||||||
echo
|
echo
|
||||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||||
docker compose up -d minio minio-init
|
docker compose up -d minio
|
||||||
echo -e "${GREEN}✓ MinIO 启动中,等待 5 秒...${NC}"
|
echo -e "${GREEN}✓ MinIO 启动中,等待 5 秒...${NC}"
|
||||||
sleep 5
|
sleep 5
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import { Geist, Geist_Mono, Poppins, Open_Sans } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import { getMessages, getTranslations } from 'next-intl/server';
|
import { getMessages, getTranslations } from 'next-intl/server';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
@@ -19,18 +19,7 @@ const geistMono = Geist_Mono({
|
|||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
// UI/UX Pro Max typography: Modern Professional
|
|
||||||
const poppins = Poppins({
|
|
||||||
variable: "--font-heading",
|
|
||||||
subsets: ["latin"],
|
|
||||||
weight: ["400", "500", "600", "700"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const openSans = Open_Sans({
|
|
||||||
variable: "--font-body",
|
|
||||||
subsets: ["latin"],
|
|
||||||
weight: ["300", "400", "500", "600", "700"],
|
|
||||||
});
|
|
||||||
|
|
||||||
type SupportedLocale = (typeof locales)[number]
|
type SupportedLocale = (typeof locales)[number]
|
||||||
|
|
||||||
@@ -83,7 +72,7 @@ export default async function LocaleLayout({
|
|||||||
)}
|
)}
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} ${poppins.variable} ${openSans.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
<Providers>
|
<Providers>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import type { ProviderCardProps, ProviderCardTranslator } from './types'
|
import type { ProviderCardProps, ProviderCardTranslator } from './types'
|
||||||
import { VERIFIABLE_PROVIDER_KEYS } from './types'
|
import { VERIFIABLE_PROVIDER_KEYS } from './types'
|
||||||
import type { UseProviderCardStateResult } from './hooks/useProviderCardState'
|
import type { UseProviderCardStateResult } from './hooks/useProviderCardState'
|
||||||
@@ -137,70 +138,73 @@ export function ProviderCardShell({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 教程弹窗 ── */}
|
{/* ── 教程弹窗 ── */}
|
||||||
{state.showTutorial && state.tutorial && (
|
{state.showTutorial && state.tutorial && typeof document !== 'undefined'
|
||||||
<div
|
? createPortal(
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center glass-overlay"
|
|
||||||
onClick={() => state.setShowTutorial(false)}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="glass-surface-modal mx-4 w-full max-w-lg overflow-hidden rounded-xl"
|
className="fixed inset-0 z-50 flex items-center justify-center glass-overlay"
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={() => state.setShowTutorial(false)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b border-[var(--glass-stroke-base)] px-5 py-4">
|
<div
|
||||||
<div className="flex items-center gap-3">
|
className="glass-surface-modal mx-4 w-full max-w-lg overflow-hidden rounded-xl"
|
||||||
<div className="glass-btn-base glass-btn-primary flex h-8 w-8 items-center justify-center rounded-lg text-white">
|
onClick={(event) => event.stopPropagation()}
|
||||||
<AppIcon name="bookOpen" className="w-4 h-4" />
|
>
|
||||||
</div>
|
<div className="flex items-center justify-between border-b border-[var(--glass-stroke-base)] px-5 py-4">
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<h3 className="text-sm font-semibold text-[var(--glass-text-primary)]">
|
<div className="glass-btn-base glass-btn-primary flex h-8 w-8 items-center justify-center rounded-lg text-white">
|
||||||
{provider.name} {t('tutorial.title')}
|
<AppIcon name="bookOpen" className="w-4 h-4" />
|
||||||
</h3>
|
</div>
|
||||||
<p className="text-xs text-[var(--glass-text-secondary)]">{t('tutorial.subtitle')}</p>
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--glass-text-primary)]">
|
||||||
|
{provider.name} {t('tutorial.title')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-[var(--glass-text-secondary)]">{t('tutorial.subtitle')}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => state.setShowTutorial(false)}
|
||||||
|
className="glass-btn-base glass-btn-soft rounded-lg p-1.5"
|
||||||
|
>
|
||||||
|
<AppIcon name="close" className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="space-y-4 p-5">
|
||||||
onClick={() => state.setShowTutorial(false)}
|
{state.tutorial.steps.map((step, index) => (
|
||||||
className="glass-btn-base glass-btn-soft rounded-lg p-1.5"
|
<div key={index} className="flex gap-3">
|
||||||
>
|
<div className="glass-surface-soft flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-[var(--glass-stroke-base)] text-xs font-bold text-[var(--glass-text-secondary)]">
|
||||||
<AppIcon name="close" className="w-5 h-5" />
|
{index + 1}
|
||||||
</button>
|
</div>
|
||||||
</div>
|
<div className="flex-1 pt-0.5">
|
||||||
<div className="space-y-4 p-5">
|
<p className="text-sm leading-relaxed text-[var(--glass-text-secondary)]">
|
||||||
{state.tutorial.steps.map((step, index) => (
|
{t(`tutorial.steps.${step.text}`)}
|
||||||
<div key={index} className="flex gap-3">
|
</p>
|
||||||
<div className="glass-surface-soft flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-[var(--glass-stroke-base)] text-xs font-bold text-[var(--glass-text-secondary)]">
|
{step.url && (
|
||||||
{index + 1}
|
<a
|
||||||
|
href={step.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mt-2 inline-flex items-center gap-1 text-xs text-[var(--glass-text-secondary)] hover:text-[var(--glass-text-primary)] hover:underline"
|
||||||
|
>
|
||||||
|
<AppIcon name="externalLink" className="w-3 h-3" />
|
||||||
|
{t('tutorial.openLink')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 pt-0.5">
|
))}
|
||||||
<p className="text-sm leading-relaxed text-[var(--glass-text-secondary)]">
|
</div>
|
||||||
{t(`tutorial.steps.${step.text}`)}
|
<div className="flex justify-end border-t border-[var(--glass-stroke-base)] px-5 py-3">
|
||||||
</p>
|
<button
|
||||||
{step.url && (
|
onClick={() => state.setShowTutorial(false)}
|
||||||
<a
|
className="glass-btn-base glass-btn-secondary rounded-lg px-4 py-2 text-sm font-medium"
|
||||||
href={step.url}
|
>
|
||||||
target="_blank"
|
{t('tutorial.close')}
|
||||||
rel="noopener noreferrer"
|
</button>
|
||||||
className="mt-2 inline-flex items-center gap-1 text-xs text-[var(--glass-text-secondary)] hover:text-[var(--glass-text-primary)] hover:underline"
|
</div>
|
||||||
>
|
|
||||||
<AppIcon name="externalLink" className="w-3 h-3" />
|
|
||||||
{t('tutorial.openLink')}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end border-t border-[var(--glass-stroke-base)] px-5 py-3">
|
</div>,
|
||||||
<button
|
document.body,
|
||||||
onClick={() => state.setShowTutorial(false)}
|
)
|
||||||
className="glass-btn-base glass-btn-secondary rounded-lg px-4 py-2 text-sm font-medium"
|
: null}
|
||||||
>
|
|
||||||
{t('tutorial.close')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -80,21 +80,6 @@ export default function ProfilePage() {
|
|||||||
<span className="font-medium">{t('billingRecords')}</span>
|
<span className="font-medium">{t('billingRecords')}</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* 下载日志 */}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = '/api/admin/download-logs'
|
|
||||||
a.download = ''
|
|
||||||
a.click()
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 px-4 py-3 text-sm rounded-xl transition-all cursor-pointer text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-muted)]"
|
|
||||||
>
|
|
||||||
<AppIcon name="download" className="w-4 h-4" />
|
|
||||||
{t('downloadLogs')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 退出登录 */}
|
{/* 退出登录 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => signOut({ callbackUrl: '/' })}
|
onClick={() => signOut({ callbackUrl: '/' })}
|
||||||
|
|||||||
@@ -165,8 +165,6 @@ function NovelPromotionWorkspaceContent(props: NovelPromotionWorkspaceProps) {
|
|||||||
scriptToStoryboardConsoleMinimized={vm.execution.scriptToStoryboardConsoleMinimized}
|
scriptToStoryboardConsoleMinimized={vm.execution.scriptToStoryboardConsoleMinimized}
|
||||||
onStoryToScriptMinimizedChange={vm.execution.setStoryToScriptConsoleMinimized}
|
onStoryToScriptMinimizedChange={vm.execution.setStoryToScriptConsoleMinimized}
|
||||||
onScriptToStoryboardMinimizedChange={vm.execution.setScriptToStoryboardConsoleMinimized}
|
onScriptToStoryboardMinimizedChange={vm.execution.setScriptToStoryboardConsoleMinimized}
|
||||||
projectId={projectId}
|
|
||||||
episodeId={episodeId}
|
|
||||||
hideMinimizedBadges={vm.execution.showCreatingToast}
|
hideMinimizedBadges={vm.execution.showCreatingToast}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -424,6 +424,9 @@ AI 将根据您的文本智能分析:
|
|||||||
style: artStyleDisplayLabel
|
style: artStyleDisplayLabel
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-xs text-[var(--glass-text-tertiary)] mt-1 text-center">
|
||||||
|
{t("storyInput.assetLibraryRatioNote")}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-[var(--glass-text-tertiary)] mt-1 text-center">
|
<p className="text-xs text-[var(--glass-text-tertiary)] mt-1 text-center">
|
||||||
{t("storyInput.moreConfig")}
|
{t("storyInput.moreConfig")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ type RunStreamState = {
|
|||||||
payload: Record<string, unknown> | null
|
payload: Record<string, unknown> | null
|
||||||
errorMessage: string
|
errorMessage: string
|
||||||
}>
|
}>
|
||||||
projectId?: string
|
|
||||||
episodeId?: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkspaceRunStreamConsolesProps {
|
interface WorkspaceRunStreamConsolesProps {
|
||||||
@@ -43,8 +41,6 @@ interface WorkspaceRunStreamConsolesProps {
|
|||||||
scriptToStoryboardConsoleMinimized: boolean
|
scriptToStoryboardConsoleMinimized: boolean
|
||||||
onStoryToScriptMinimizedChange: (next: boolean) => void
|
onStoryToScriptMinimizedChange: (next: boolean) => void
|
||||||
onScriptToStoryboardMinimizedChange: (next: boolean) => void
|
onScriptToStoryboardMinimizedChange: (next: boolean) => void
|
||||||
projectId: string
|
|
||||||
episodeId?: string | null
|
|
||||||
hideMinimizedBadges?: boolean
|
hideMinimizedBadges?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,8 +51,6 @@ export default function WorkspaceRunStreamConsoles({
|
|||||||
scriptToStoryboardConsoleMinimized,
|
scriptToStoryboardConsoleMinimized,
|
||||||
onStoryToScriptMinimizedChange,
|
onStoryToScriptMinimizedChange,
|
||||||
onScriptToStoryboardMinimizedChange,
|
onScriptToStoryboardMinimizedChange,
|
||||||
projectId,
|
|
||||||
episodeId,
|
|
||||||
hideMinimizedBadges,
|
hideMinimizedBadges,
|
||||||
}: WorkspaceRunStreamConsolesProps) {
|
}: WorkspaceRunStreamConsolesProps) {
|
||||||
const t = useTranslations('progress')
|
const t = useTranslations('progress')
|
||||||
@@ -174,9 +168,6 @@ export default function WorkspaceRunStreamConsoles({
|
|||||||
showCursor={storyToScriptShowCursor}
|
showCursor={storyToScriptShowCursor}
|
||||||
autoScroll={storyToScriptStream.selectedStep?.id === storyToScriptStream.activeStepId}
|
autoScroll={storyToScriptStream.selectedStep?.id === storyToScriptStream.activeStepId}
|
||||||
errorMessage={storyToScriptStream.errorMessage}
|
errorMessage={storyToScriptStream.errorMessage}
|
||||||
feedbackStage="assets"
|
|
||||||
projectId={projectId}
|
|
||||||
episodeId={episodeId}
|
|
||||||
topRightAction={(
|
topRightAction={(
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -229,9 +220,6 @@ export default function WorkspaceRunStreamConsoles({
|
|||||||
showCursor={scriptToStoryboardShowCursor}
|
showCursor={scriptToStoryboardShowCursor}
|
||||||
autoScroll={scriptToStoryboardStream.selectedStep?.id === scriptToStoryboardStream.activeStepId}
|
autoScroll={scriptToStoryboardStream.selectedStep?.id === scriptToStoryboardStream.activeStepId}
|
||||||
errorMessage={scriptToStoryboardStream.errorMessage}
|
errorMessage={scriptToStoryboardStream.errorMessage}
|
||||||
feedbackStage="storyboard"
|
|
||||||
projectId={projectId}
|
|
||||||
episodeId={episodeId}
|
|
||||||
topRightAction={(
|
topRightAction={(
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'
|
|||||||
import { TASK_TYPE } from '@/lib/task/types'
|
import { TASK_TYPE } from '@/lib/task/types'
|
||||||
import { buildDefaultTaskBillingInfo } from '@/lib/billing'
|
import { buildDefaultTaskBillingInfo } from '@/lib/billing'
|
||||||
import { getProjectModelConfig } from '@/lib/config-service'
|
import { getProjectModelConfig } from '@/lib/config-service'
|
||||||
|
import { resolveInsertPanelUserInput } from '@/lib/novel-promotion/insert-panel'
|
||||||
|
|
||||||
export const POST = apiHandler(async (
|
export const POST = apiHandler(async (
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -21,6 +22,7 @@ export const POST = apiHandler(async (
|
|||||||
const locale = resolveRequiredTaskLocale(request, body)
|
const locale = resolveRequiredTaskLocale(request, body)
|
||||||
const storyboardId = body?.storyboardId
|
const storyboardId = body?.storyboardId
|
||||||
const insertAfterPanelId = body?.insertAfterPanelId
|
const insertAfterPanelId = body?.insertAfterPanelId
|
||||||
|
const userInput = resolveInsertPanelUserInput((body || {}) as Record<string, unknown>, locale)
|
||||||
|
|
||||||
if (!storyboardId || !insertAfterPanelId) {
|
if (!storyboardId || !insertAfterPanelId) {
|
||||||
throw new ApiError('INVALID_PARAMS', {
|
throw new ApiError('INVALID_PARAMS', {
|
||||||
@@ -28,7 +30,11 @@ export const POST = apiHandler(async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const projectModelConfig = await getProjectModelConfig(projectId, session.user.id)
|
const projectModelConfig = await getProjectModelConfig(projectId, session.user.id)
|
||||||
const billingPayload = { ...body, ...(projectModelConfig.analysisModel ? { analysisModel: projectModelConfig.analysisModel } : {}) }
|
const billingPayload = {
|
||||||
|
...body,
|
||||||
|
userInput,
|
||||||
|
...(projectModelConfig.analysisModel ? { analysisModel: projectModelConfig.analysisModel } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
const result = await submitTask({
|
const result = await submitTask({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
|
|||||||
@@ -65,9 +65,7 @@
|
|||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
/* UI/UX Pro Max typography */
|
|
||||||
--font-heading: var(--font-heading);
|
|
||||||
--font-body: var(--font-body);
|
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default function Navbar() {
|
|||||||
const { data: session, status } = useSession()
|
const { data: session, status } = useSession()
|
||||||
const t = useTranslations('nav')
|
const t = useTranslations('nav')
|
||||||
const tc = useTranslations('common')
|
const tc = useTranslations('common')
|
||||||
const { currentVersion, update, shouldPulse, showModal, isChecking, openModal, dismissCurrentUpdate, checkNow } = useGithubReleaseUpdate()
|
const { currentVersion, update, shouldPulse, showModal, openModal, dismissCurrentUpdate, checkNow } = useGithubReleaseUpdate()
|
||||||
const [checkMsg, setCheckMsg] = useState<string | null>(null)
|
const [checkMsg, setCheckMsg] = useState<string | null>(null)
|
||||||
const [checkMsgFading, setCheckMsgFading] = useState(false)
|
const [checkMsgFading, setCheckMsgFading] = useState(false)
|
||||||
const [manualChecking, setManualChecking] = useState(false)
|
const [manualChecking, setManualChecking] = useState(false)
|
||||||
@@ -101,15 +101,6 @@ export default function Navbar() {
|
|||||||
</div>
|
</div>
|
||||||
) : session ? (
|
) : session ? (
|
||||||
<>
|
<>
|
||||||
<a
|
|
||||||
href="https://www.waoowaoo.com/community.html"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-xs sm:text-sm text-[var(--glass-text-secondary)] hover:text-[var(--glass-text-primary)] font-medium transition-colors flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<AppIcon name="usersRound" className="w-4 h-4" />
|
|
||||||
{t('feedback')}
|
|
||||||
</a>
|
|
||||||
<Link
|
<Link
|
||||||
href={{ pathname: '/workspace' }}
|
href={{ pathname: '/workspace' }}
|
||||||
className="text-sm text-[var(--glass-text-secondary)] hover:text-[var(--glass-text-primary)] font-medium transition-colors flex items-center gap-1"
|
className="text-sm text-[var(--glass-text-secondary)] hover:text-[var(--glass-text-primary)] font-medium transition-colors flex items-center gap-1"
|
||||||
@@ -133,6 +124,15 @@ export default function Navbar() {
|
|||||||
{t('profile')}
|
{t('profile')}
|
||||||
</Link>
|
</Link>
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
|
<a
|
||||||
|
href="/api/admin/download-logs"
|
||||||
|
download
|
||||||
|
className="text-sm text-[var(--glass-text-secondary)] hover:text-[var(--glass-text-primary)] font-medium transition-colors flex items-center gap-1"
|
||||||
|
title={t('downloadLogs')}
|
||||||
|
>
|
||||||
|
<AppIcon name="download" className="w-4 h-4" />
|
||||||
|
{t('downloadLogs')}
|
||||||
|
</a>
|
||||||
</>
|
</>
|
||||||
|
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
import { useTranslations, useLocale } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { USER_FEEDBACK_FORM_URL } from '@/lib/feedback'
|
|
||||||
import { buildFeedbackLog, type FeedbackContextStage } from '@/lib/feedback-log'
|
|
||||||
|
|
||||||
export type LLMStageViewStatus =
|
export type LLMStageViewStatus =
|
||||||
| 'pending'
|
| 'pending'
|
||||||
@@ -41,9 +39,6 @@ export type LLMStageStreamCardProps = {
|
|||||||
smoothStreaming?: boolean
|
smoothStreaming?: boolean
|
||||||
errorMessage?: string
|
errorMessage?: string
|
||||||
topRightAction?: ReactNode
|
topRightAction?: ReactNode
|
||||||
feedbackStage?: FeedbackContextStage
|
|
||||||
projectId?: string
|
|
||||||
episodeId?: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROGRESS_KEY_PREFIX = 'progress.'
|
const PROGRESS_KEY_PREFIX = 'progress.'
|
||||||
@@ -187,13 +182,8 @@ export default function LLMStageStreamCard({
|
|||||||
smoothStreaming = true,
|
smoothStreaming = true,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
topRightAction,
|
topRightAction,
|
||||||
feedbackStage,
|
|
||||||
projectId,
|
|
||||||
episodeId,
|
|
||||||
}: LLMStageStreamCardProps) {
|
}: LLMStageStreamCardProps) {
|
||||||
const t = useTranslations('progress')
|
const t = useTranslations('progress')
|
||||||
const locale = useLocale()
|
|
||||||
const feedbackLocale: 'zh' | 'en' = locale === 'en' ? 'en' : 'zh'
|
|
||||||
|
|
||||||
const resolveProgressText = useCallback((value: string | undefined, fallbackKey: string): string => {
|
const resolveProgressText = useCallback((value: string | undefined, fallbackKey: string): string => {
|
||||||
const raw = typeof value === 'string' ? value.trim() : ''
|
const raw = typeof value === 'string' ? value.trim() : ''
|
||||||
@@ -398,34 +388,6 @@ export default function LLMStageStreamCard({
|
|||||||
<span className="text-base">⚠️</span>
|
<span className="text-base">⚠️</span>
|
||||||
<span className="text-sm font-medium">{errorMessage}</span>
|
<span className="text-sm font-medium">{errorMessage}</span>
|
||||||
</div>
|
</div>
|
||||||
{feedbackStage && projectId && (
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--glass-text-secondary)]">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const log = buildFeedbackLog({
|
|
||||||
locale: feedbackLocale,
|
|
||||||
projectId,
|
|
||||||
episodeId: episodeId ?? undefined,
|
|
||||||
stage: feedbackStage,
|
|
||||||
errorMessage,
|
|
||||||
})
|
|
||||||
void navigator.clipboard.writeText(log)
|
|
||||||
}}
|
|
||||||
className="glass-btn-base glass-btn-secondary rounded-md px-2 py-1 text-[11px]"
|
|
||||||
>
|
|
||||||
{t('runConsole.copyErrorDetail')}
|
|
||||||
</button>
|
|
||||||
<a
|
|
||||||
href={USER_FEEDBACK_FORM_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="glass-btn-base glass-btn-primary rounded-md px-2 py-1 text-[11px]"
|
|
||||||
>
|
|
||||||
{t('runConsole.openFeedbackForm')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import type { Locale } from '@/i18n'
|
|
||||||
|
|
||||||
export type FeedbackContextStage =
|
|
||||||
| 'config'
|
|
||||||
| 'assets'
|
|
||||||
| 'storyboard'
|
|
||||||
| 'videos'
|
|
||||||
| 'voice'
|
|
||||||
|
|
||||||
export type FeedbackLogParams = {
|
|
||||||
locale: Locale
|
|
||||||
projectId: string
|
|
||||||
episodeId?: string | null
|
|
||||||
stage: FeedbackContextStage
|
|
||||||
errorMessage?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildFeedbackLog({
|
|
||||||
locale,
|
|
||||||
projectId,
|
|
||||||
episodeId,
|
|
||||||
stage,
|
|
||||||
errorMessage,
|
|
||||||
}: FeedbackLogParams): string {
|
|
||||||
const lines: string[] = []
|
|
||||||
lines.push('[User Feedback Context]')
|
|
||||||
lines.push(`Locale: ${locale}`)
|
|
||||||
lines.push(`Project ID: ${projectId}`)
|
|
||||||
if (episodeId) {
|
|
||||||
lines.push(`Episode ID: ${episodeId}`)
|
|
||||||
}
|
|
||||||
lines.push(`Stage: ${stage}`)
|
|
||||||
if (errorMessage) {
|
|
||||||
lines.push('')
|
|
||||||
lines.push('[Error]')
|
|
||||||
lines.push(errorMessage)
|
|
||||||
}
|
|
||||||
lines.push('')
|
|
||||||
lines.push('Please paste this block into the Feishu feedback form.')
|
|
||||||
return lines.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export const USER_FEEDBACK_FORM_URL =
|
|
||||||
'https://ox2p5ferjnr.feishu.cn/share/base/form/shrcno200ar2SsTgGiSDYHLmNuc' as const
|
|
||||||
|
|
||||||
24
src/lib/novel-promotion/insert-panel.ts
Normal file
24
src/lib/novel-promotion/insert-panel.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
const DEFAULT_INSERT_PANEL_USER_INPUT = {
|
||||||
|
zh: '请根据前后镜头自动分析并插入一个自然衔接的新分镜。',
|
||||||
|
en: 'Automatically analyze the surrounding panels and insert a naturally connected new panel.',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
function readTrimmedString(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function isZhLocale(locale: string | undefined): boolean {
|
||||||
|
return typeof locale === 'string' && locale.toLowerCase().startsWith('zh')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveInsertPanelUserInput(payload: Record<string, unknown>, locale?: string): string {
|
||||||
|
const explicitInput = readTrimmedString(payload.userInput)
|
||||||
|
if (explicitInput) return explicitInput
|
||||||
|
|
||||||
|
const promptInput = readTrimmedString(payload.prompt)
|
||||||
|
if (promptInput) return promptInput
|
||||||
|
|
||||||
|
return isZhLocale(locale)
|
||||||
|
? DEFAULT_INSERT_PANEL_USER_INPUT.zh
|
||||||
|
: DEFAULT_INSERT_PANEL_USER_INPUT.en
|
||||||
|
}
|
||||||
74
src/lib/storage/bootstrap.ts
Normal file
74
src/lib/storage/bootstrap.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { CreateBucketCommand, HeadBucketCommand, S3Client } from '@aws-sdk/client-s3'
|
||||||
|
import { createStorageProvider } from '@/lib/storage/factory'
|
||||||
|
import type { StorageFactoryOptions } from '@/lib/storage/types'
|
||||||
|
import { requireEnv } from '@/lib/storage/utils'
|
||||||
|
|
||||||
|
const DEFAULT_MINIO_REGION = 'us-east-1'
|
||||||
|
|
||||||
|
export type StorageBootstrapResult = 'skipped' | 'existing' | 'created'
|
||||||
|
|
||||||
|
type BucketErrorShape = {
|
||||||
|
name?: string
|
||||||
|
code?: string
|
||||||
|
Code?: string
|
||||||
|
$metadata?: {
|
||||||
|
httpStatusCode?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingBucketError(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucketError = error as BucketErrorShape
|
||||||
|
const errorName = bucketError.name || ''
|
||||||
|
const errorCode = bucketError.code || bucketError.Code || ''
|
||||||
|
const statusCode = bucketError.$metadata?.httpStatusCode
|
||||||
|
|
||||||
|
return errorName === 'NotFound'
|
||||||
|
|| errorCode === 'NotFound'
|
||||||
|
|| errorCode === 'NoSuchBucket'
|
||||||
|
|| statusCode === 404
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureMinioBucket(): Promise<Exclude<StorageBootstrapResult, 'skipped'>> {
|
||||||
|
const endpoint = requireEnv('MINIO_ENDPOINT')
|
||||||
|
const accessKeyId = requireEnv('MINIO_ACCESS_KEY')
|
||||||
|
const secretAccessKey = requireEnv('MINIO_SECRET_KEY')
|
||||||
|
const bucket = requireEnv('MINIO_BUCKET')
|
||||||
|
const region = (process.env.MINIO_REGION || DEFAULT_MINIO_REGION).trim() || DEFAULT_MINIO_REGION
|
||||||
|
const forcePathStyle = process.env.MINIO_FORCE_PATH_STYLE !== 'false'
|
||||||
|
|
||||||
|
const client = new S3Client({
|
||||||
|
endpoint,
|
||||||
|
region,
|
||||||
|
forcePathStyle,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.send(new HeadBucketCommand({ Bucket: bucket }))
|
||||||
|
return 'existing'
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!isMissingBucketError(error)) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.send(new CreateBucketCommand({ Bucket: bucket }))
|
||||||
|
return 'created'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureStorageReady(options: StorageFactoryOptions = {}): Promise<StorageBootstrapResult> {
|
||||||
|
const provider = createStorageProvider(options)
|
||||||
|
|
||||||
|
if (provider.kind !== 'minio') {
|
||||||
|
return 'skipped'
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ensureMinioBucket()
|
||||||
|
}
|
||||||
24
src/lib/storage/init.ts
Normal file
24
src/lib/storage/init.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { ensureStorageReady } from '@/lib/storage/bootstrap'
|
||||||
|
import { requireEnv } from '@/lib/storage/utils'
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const result = await ensureStorageReady()
|
||||||
|
|
||||||
|
if (result === 'skipped') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = requireEnv('MINIO_BUCKET')
|
||||||
|
if (result === 'created') {
|
||||||
|
console.log(`[storage:init] created MinIO bucket "${bucket}"`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[storage:init] verified MinIO bucket "${bucket}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
void main().catch((error: unknown) => {
|
||||||
|
console.error('[storage:init] failed to prepare storage')
|
||||||
|
console.error(error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -7,6 +7,7 @@ import type { LLMStreamKind } from '@/lib/llm-observe/types'
|
|||||||
import { QUEUE_NAME } from '@/lib/task/queues'
|
import { QUEUE_NAME } from '@/lib/task/queues'
|
||||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||||
import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
|
import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
|
||||||
|
import { resolveInsertPanelUserInput } from '@/lib/novel-promotion/insert-panel'
|
||||||
import {
|
import {
|
||||||
executePhase1,
|
executePhase1,
|
||||||
executePhase2,
|
executePhase2,
|
||||||
@@ -400,14 +401,10 @@ async function handleInsertPanelTask(job: Job<TaskJobData>) {
|
|||||||
const payload = (job.data.payload || {}) as AnyObj
|
const payload = (job.data.payload || {}) as AnyObj
|
||||||
const storyboardId = typeof payload.storyboardId === 'string' ? payload.storyboardId : job.data.targetId
|
const storyboardId = typeof payload.storyboardId === 'string' ? payload.storyboardId : job.data.targetId
|
||||||
const insertAfterPanelId = typeof payload.insertAfterPanelId === 'string' ? payload.insertAfterPanelId : ''
|
const insertAfterPanelId = typeof payload.insertAfterPanelId === 'string' ? payload.insertAfterPanelId : ''
|
||||||
const userInput = typeof payload.userInput === 'string'
|
const userInput = resolveInsertPanelUserInput(payload, job.data.locale)
|
||||||
? payload.userInput
|
|
||||||
: typeof payload.prompt === 'string'
|
|
||||||
? payload.prompt
|
|
||||||
: ''
|
|
||||||
|
|
||||||
if (!storyboardId || !insertAfterPanelId || !userInput) {
|
if (!storyboardId || !insertAfterPanelId) {
|
||||||
throw new Error('insert_panel requires storyboardId/insertAfterPanelId/userInput')
|
throw new Error('insert_panel requires storyboardId/insertAfterPanelId')
|
||||||
}
|
}
|
||||||
|
|
||||||
const storyboard = await prisma.novelPromotionStoryboard.findUnique({
|
const storyboard = await prisma.novelPromotionStoryboard.findUnique({
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type DirectRouteCase = {
|
|||||||
expectedTaskType: TaskType
|
expectedTaskType: TaskType
|
||||||
expectedTargetType: string
|
expectedTargetType: string
|
||||||
expectedProjectId: string
|
expectedProjectId: string
|
||||||
|
expectedPayloadSubset?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
const authState = vi.hoisted<AuthState>(() => ({
|
const authState = vi.hoisted<AuthState>(() => ({
|
||||||
@@ -315,6 +316,11 @@ const DIRECT_CASES: ReadonlyArray<DirectRouteCase> = [
|
|||||||
expectedTaskType: TASK_TYPE.INSERT_PANEL,
|
expectedTaskType: TASK_TYPE.INSERT_PANEL,
|
||||||
expectedTargetType: 'NovelPromotionStoryboard',
|
expectedTargetType: 'NovelPromotionStoryboard',
|
||||||
expectedProjectId: 'project-1',
|
expectedProjectId: 'project-1',
|
||||||
|
expectedPayloadSubset: {
|
||||||
|
storyboardId: 'storyboard-1',
|
||||||
|
insertAfterPanelId: 'panel-ins',
|
||||||
|
userInput: '请根据前后镜头自动分析并插入一个自然衔接的新分镜。',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
routeFile: 'src/app/api/novel-promotion/[projectId]/lip-sync/route.ts',
|
routeFile: 'src/app/api/novel-promotion/[projectId]/lip-sync/route.ts',
|
||||||
@@ -471,6 +477,9 @@ describe('api contract - direct submit routes (behavior)', () => {
|
|||||||
expect(submitArg?.targetType).toBe(routeCase.expectedTargetType)
|
expect(submitArg?.targetType).toBe(routeCase.expectedTargetType)
|
||||||
expect(submitArg?.projectId).toBe(routeCase.expectedProjectId)
|
expect(submitArg?.projectId).toBe(routeCase.expectedProjectId)
|
||||||
expect(submitArg?.userId).toBe('user-1')
|
expect(submitArg?.userId).toBe('user-1')
|
||||||
|
if (routeCase.expectedPayloadSubset) {
|
||||||
|
expect(submitArg?.payload).toEqual(expect.objectContaining(routeCase.expectedPayloadSubset))
|
||||||
|
}
|
||||||
|
|
||||||
const json = await res.json() as Record<string, unknown>
|
const json = await res.json() as Record<string, unknown>
|
||||||
const isVoiceGenerateRoute = routeCase.routeFile.endsWith('/voice-generate/route.ts')
|
const isVoiceGenerateRoute = routeCase.routeFile.endsWith('/voice-generate/route.ts')
|
||||||
|
|||||||
169
tests/unit/api-config/provider-card-tutorial-modal.test.ts
Normal file
169
tests/unit/api-config/provider-card-tutorial-modal.test.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { createElement } from 'react'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { UseProviderCardStateResult } from '@/app/[locale]/profile/components/api-config/provider-card/hooks/useProviderCardState'
|
||||||
|
import { ProviderCardShell } from '@/app/[locale]/profile/components/api-config/provider-card/ProviderCardShell'
|
||||||
|
import type { ProviderTutorial } from '@/app/[locale]/profile/components/api-config/types'
|
||||||
|
|
||||||
|
const portalMocks = vi.hoisted(() => {
|
||||||
|
return {
|
||||||
|
currentPortalTarget: null as unknown,
|
||||||
|
createPortalMock: vi.fn((node: React.ReactNode, target: unknown) => {
|
||||||
|
const targetLabel = target === portalMocks.currentPortalTarget ? 'body' : 'unknown'
|
||||||
|
return createElement('div', { 'data-portal-target': targetLabel }, node)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('react-dom', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('react-dom')>('react-dom')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
createPortal: portalMocks.createPortalMock,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function createState(tutorial: ProviderTutorial): UseProviderCardStateResult {
|
||||||
|
return {
|
||||||
|
providerKey: 'ark',
|
||||||
|
isPresetProvider: true,
|
||||||
|
showBaseUrlEdit: false,
|
||||||
|
tutorial,
|
||||||
|
groupedModels: {},
|
||||||
|
hasModels: false,
|
||||||
|
isEditing: false,
|
||||||
|
isEditingUrl: false,
|
||||||
|
showKey: false,
|
||||||
|
tempKey: '',
|
||||||
|
tempUrl: '',
|
||||||
|
showTutorial: true,
|
||||||
|
showAddForm: null,
|
||||||
|
newModel: {
|
||||||
|
name: '',
|
||||||
|
modelId: '',
|
||||||
|
enableCustomPricing: false,
|
||||||
|
priceInput: '',
|
||||||
|
priceOutput: '',
|
||||||
|
basePrice: '',
|
||||||
|
optionPricesJson: '',
|
||||||
|
},
|
||||||
|
batchMode: false,
|
||||||
|
editingModelId: null,
|
||||||
|
editModel: {
|
||||||
|
name: '',
|
||||||
|
modelId: '',
|
||||||
|
enableCustomPricing: false,
|
||||||
|
priceInput: '',
|
||||||
|
priceOutput: '',
|
||||||
|
basePrice: '',
|
||||||
|
optionPricesJson: '',
|
||||||
|
},
|
||||||
|
maskedKey: '',
|
||||||
|
isPresetModel: () => false,
|
||||||
|
isDefaultModel: () => false,
|
||||||
|
setShowKey: () => undefined,
|
||||||
|
setShowTutorial: () => undefined,
|
||||||
|
setShowAddForm: () => undefined,
|
||||||
|
setBatchMode: () => undefined,
|
||||||
|
setNewModel: () => undefined,
|
||||||
|
setEditModel: () => undefined,
|
||||||
|
setTempKey: () => undefined,
|
||||||
|
setTempUrl: () => undefined,
|
||||||
|
startEditKey: () => undefined,
|
||||||
|
startEditUrl: () => undefined,
|
||||||
|
handleSaveKey: () => Promise.resolve(),
|
||||||
|
handleCancelEdit: () => undefined,
|
||||||
|
handleSaveUrl: () => undefined,
|
||||||
|
handleCancelUrlEdit: () => undefined,
|
||||||
|
handleEditModel: () => undefined,
|
||||||
|
handleCancelEditModel: () => undefined,
|
||||||
|
handleSaveModel: () => Promise.resolve(),
|
||||||
|
handleAddModel: () => Promise.resolve(),
|
||||||
|
handleCancelAdd: () => undefined,
|
||||||
|
needsCustomPricing: false,
|
||||||
|
keyTestStatus: 'idle',
|
||||||
|
keyTestSteps: [],
|
||||||
|
handleForceSaveKey: () => undefined,
|
||||||
|
handleTestOnly: () => undefined,
|
||||||
|
handleDismissTest: () => undefined,
|
||||||
|
isModelSavePending: false,
|
||||||
|
assistantEnabled: false,
|
||||||
|
isAssistantOpen: false,
|
||||||
|
assistantSavedEvent: null,
|
||||||
|
assistantChat: {
|
||||||
|
messages: [],
|
||||||
|
input: '',
|
||||||
|
status: 'ready',
|
||||||
|
pending: false,
|
||||||
|
error: undefined,
|
||||||
|
setInput: () => undefined,
|
||||||
|
send: async () => undefined,
|
||||||
|
clear: () => undefined,
|
||||||
|
},
|
||||||
|
openAssistant: () => undefined,
|
||||||
|
closeAssistant: () => undefined,
|
||||||
|
handleAssistantSend: () => Promise.resolve(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProviderCardShell tutorial modal', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
portalMocks.currentPortalTarget = null
|
||||||
|
Reflect.deleteProperty(globalThis, 'React')
|
||||||
|
Reflect.deleteProperty(globalThis, 'document')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mounts the tutorial modal through a portal to document.body', () => {
|
||||||
|
const fakeDocument = {
|
||||||
|
body: { nodeName: 'BODY' },
|
||||||
|
}
|
||||||
|
Reflect.set(globalThis, 'React', React)
|
||||||
|
portalMocks.currentPortalTarget = fakeDocument.body
|
||||||
|
Reflect.set(globalThis, 'document', fakeDocument)
|
||||||
|
|
||||||
|
const tutorial: ProviderTutorial = {
|
||||||
|
providerId: 'ark',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
text: 'ark_step1',
|
||||||
|
url: 'https://example.com/ark-key',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const state = createState(tutorial)
|
||||||
|
const t = (key: string): string => {
|
||||||
|
if (key === 'tutorial.button') return '开通教程'
|
||||||
|
if (key === 'tutorial.title') return '开通教程'
|
||||||
|
if (key === 'tutorial.subtitle') return '按照以下步骤完成配置'
|
||||||
|
if (key === 'tutorial.steps.ark_step1') return '进入控制台创建 API Key'
|
||||||
|
if (key === 'tutorial.openLink') return '点击打开'
|
||||||
|
if (key === 'tutorial.close') return '关闭'
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
createElement(
|
||||||
|
ProviderCardShell,
|
||||||
|
{
|
||||||
|
provider: {
|
||||||
|
id: 'ark',
|
||||||
|
name: '阿里云百炼',
|
||||||
|
hasApiKey: true,
|
||||||
|
},
|
||||||
|
onDeleteProvider: () => undefined,
|
||||||
|
t,
|
||||||
|
state,
|
||||||
|
},
|
||||||
|
createElement('div', null, 'provider-body'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(portalMocks.createPortalMock).toHaveBeenCalledTimes(1)
|
||||||
|
expect(portalMocks.createPortalMock.mock.calls[0]?.[1]).toBe(fakeDocument.body)
|
||||||
|
expect(html).toContain('data-portal-target="body"')
|
||||||
|
expect(html).toContain('进入控制台创建 API Key')
|
||||||
|
expect(html).toContain('href="https://example.com/ark-key"')
|
||||||
|
})
|
||||||
|
})
|
||||||
72
tests/unit/components/llm-stage-stream-card-error.test.ts
Normal file
72
tests/unit/components/llm-stage-stream-card-error.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { createElement } from 'react'
|
||||||
|
import type { ComponentProps, ReactElement } from 'react'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import type { AbstractIntlMessages } from 'next-intl'
|
||||||
|
import LLMStageStreamCard from '@/components/llm-console/LLMStageStreamCard'
|
||||||
|
|
||||||
|
const messages = {
|
||||||
|
progress: {
|
||||||
|
status: {
|
||||||
|
completed: '已完成',
|
||||||
|
failed: '失败',
|
||||||
|
processing: '进行中',
|
||||||
|
queued: '排队中',
|
||||||
|
pending: '未开始',
|
||||||
|
},
|
||||||
|
stageCard: {
|
||||||
|
stage: '阶段',
|
||||||
|
realtimeStream: '实时流',
|
||||||
|
currentStage: '当前阶段',
|
||||||
|
outputTitle: 'AI 实时输出 · {stage}',
|
||||||
|
waitingModelOutput: '等待模型输出...',
|
||||||
|
reasoningNotProvided: '该步骤未返回思考过程',
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
llm: {
|
||||||
|
processing: '模型处理中...',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const renderWithIntl = (node: ReactElement) => {
|
||||||
|
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
|
||||||
|
locale: 'zh',
|
||||||
|
messages: messages as unknown as AbstractIntlMessages,
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
children: node,
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderToStaticMarkup(
|
||||||
|
createElement(NextIntlClientProvider, providerProps),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LLMStageStreamCard error rendering', () => {
|
||||||
|
it('renders the error without any feedback action entry', () => {
|
||||||
|
Reflect.set(globalThis, 'React', React)
|
||||||
|
const html = renderWithIntl(
|
||||||
|
createElement(LLMStageStreamCard, {
|
||||||
|
title: '内容到剧本',
|
||||||
|
stages: [{
|
||||||
|
id: 'story_to_script',
|
||||||
|
title: '内容到剧本',
|
||||||
|
status: 'failed',
|
||||||
|
progress: 0,
|
||||||
|
}],
|
||||||
|
activeStageId: 'story_to_script',
|
||||||
|
outputText: '',
|
||||||
|
errorMessage: 'Failed to fetch',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(html).toContain('Failed to fetch')
|
||||||
|
expect(html).not.toContain('复制错误详情')
|
||||||
|
expect(html).not.toContain('打开问题反馈表单')
|
||||||
|
expect(html).not.toContain('Copy error detail')
|
||||||
|
expect(html).not.toContain('Open feedback form')
|
||||||
|
})
|
||||||
|
})
|
||||||
115
tests/unit/components/navbar-download-logs.test.ts
Normal file
115
tests/unit/components/navbar-download-logs.test.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { createElement } from 'react'
|
||||||
|
import type { ComponentProps, ReactElement } from 'react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import type { AbstractIntlMessages } from 'next-intl'
|
||||||
|
import Navbar from '@/components/Navbar'
|
||||||
|
|
||||||
|
const useSessionMock = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('next-auth/react', () => ({
|
||||||
|
useSession: () => useSessionMock(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('next/image', () => ({
|
||||||
|
default: ({ alt, ...props }: { alt: string } & Record<string, unknown>) => createElement('img', { alt, ...props }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/LanguageSwitcher', () => ({
|
||||||
|
default: () => createElement('div', null, 'LanguageSwitcher'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/hooks/common/useGithubReleaseUpdate', () => ({
|
||||||
|
useGithubReleaseUpdate: () => ({
|
||||||
|
currentVersion: '0.3.0',
|
||||||
|
update: null,
|
||||||
|
shouldPulse: false,
|
||||||
|
showModal: false,
|
||||||
|
openModal: () => undefined,
|
||||||
|
dismissCurrentUpdate: () => undefined,
|
||||||
|
checkNow: async () => undefined,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/i18n/navigation', () => ({
|
||||||
|
Link: ({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
href: string | { pathname: string }
|
||||||
|
children: React.ReactNode
|
||||||
|
} & Record<string, unknown>) => {
|
||||||
|
const resolvedHref = typeof href === 'string' ? href : href.pathname
|
||||||
|
return createElement('a', { href: resolvedHref, ...props }, children)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const messages = {
|
||||||
|
nav: {
|
||||||
|
workspace: '工作区',
|
||||||
|
assetHub: '资产中心',
|
||||||
|
profile: '设置中心',
|
||||||
|
downloadLogs: '下载日志',
|
||||||
|
signin: '登录',
|
||||||
|
signup: '注册',
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
appName: 'waoowaoo',
|
||||||
|
betaVersion: 'Beta v{version}',
|
||||||
|
updateNotice: {
|
||||||
|
openDialog: '打开更新弹窗',
|
||||||
|
updateTag: '更新',
|
||||||
|
checkUpdate: '检查更新',
|
||||||
|
upToDate: '已是最新版本',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const renderWithIntl = (node: ReactElement) => {
|
||||||
|
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
|
||||||
|
locale: 'zh',
|
||||||
|
messages: messages as unknown as AbstractIntlMessages,
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
children: node,
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderToStaticMarkup(
|
||||||
|
createElement(NextIntlClientProvider, providerProps),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Navbar download logs entry', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useSessionMock.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the download logs entry on the far-right action group for signed-in users', () => {
|
||||||
|
Reflect.set(globalThis, 'React', React)
|
||||||
|
useSessionMock.mockReturnValue({
|
||||||
|
data: { user: { name: 'Earth' } },
|
||||||
|
status: 'authenticated',
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderWithIntl(createElement(Navbar))
|
||||||
|
|
||||||
|
expect(html).toContain('下载日志')
|
||||||
|
expect(html).toContain('href="/api/admin/download-logs"')
|
||||||
|
expect(html).toContain('download=""')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render the download logs entry for signed-out users', () => {
|
||||||
|
Reflect.set(globalThis, 'React', React)
|
||||||
|
useSessionMock.mockReturnValue({
|
||||||
|
data: null,
|
||||||
|
status: 'unauthenticated',
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderWithIntl(createElement(Navbar))
|
||||||
|
|
||||||
|
expect(html).not.toContain('下载日志')
|
||||||
|
expect(html).not.toContain('/api/admin/download-logs')
|
||||||
|
})
|
||||||
|
})
|
||||||
26
tests/unit/novel-promotion/insert-panel-user-input.test.ts
Normal file
26
tests/unit/novel-promotion/insert-panel-user-input.test.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { resolveInsertPanelUserInput } from '@/lib/novel-promotion/insert-panel'
|
||||||
|
|
||||||
|
describe('insert panel user input normalization', () => {
|
||||||
|
it('uses localized default instruction when AI analyze sends empty input', () => {
|
||||||
|
expect(resolveInsertPanelUserInput({ userInput: '' }, 'zh')).toBe(
|
||||||
|
'请根据前后镜头自动分析并插入一个自然衔接的新分镜。',
|
||||||
|
)
|
||||||
|
expect(resolveInsertPanelUserInput({ userInput: ' ' }, 'en')).toBe(
|
||||||
|
'Automatically analyze the surrounding panels and insert a naturally connected new panel.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers explicit user input over fallback prompt or default', () => {
|
||||||
|
expect(resolveInsertPanelUserInput({
|
||||||
|
userInput: ' 添加一个特写反应镜头 ',
|
||||||
|
prompt: 'unused prompt',
|
||||||
|
}, 'zh')).toBe('添加一个特写反应镜头')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to prompt when userInput is missing', () => {
|
||||||
|
expect(resolveInsertPanelUserInput({
|
||||||
|
prompt: ' Insert a pause beat between these panels. ',
|
||||||
|
}, 'en')).toBe('Insert a pause beat between these panels.')
|
||||||
|
})
|
||||||
|
})
|
||||||
97
tests/unit/storage/bootstrap.test.ts
Normal file
97
tests/unit/storage/bootstrap.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ensureStorageReady } from '@/lib/storage/bootstrap'
|
||||||
|
|
||||||
|
type MockCommand = {
|
||||||
|
readonly type: 'HeadBucketCommand' | 'CreateBucketCommand'
|
||||||
|
readonly input: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
sendMock,
|
||||||
|
s3ClientMock,
|
||||||
|
headBucketCommandMock,
|
||||||
|
createBucketCommandMock,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
sendMock: vi.fn<(command: MockCommand) => Promise<unknown>>(),
|
||||||
|
s3ClientMock: vi.fn(() => ({ send: undefined as unknown })),
|
||||||
|
headBucketCommandMock: vi.fn((input: Record<string, unknown>): MockCommand => ({
|
||||||
|
type: 'HeadBucketCommand',
|
||||||
|
input,
|
||||||
|
})),
|
||||||
|
createBucketCommandMock: vi.fn((input: Record<string, unknown>): MockCommand => ({
|
||||||
|
type: 'CreateBucketCommand',
|
||||||
|
input,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
s3ClientMock.mockImplementation(() => ({ send: sendMock }))
|
||||||
|
|
||||||
|
vi.mock('@aws-sdk/client-s3', () => ({
|
||||||
|
S3Client: s3ClientMock,
|
||||||
|
HeadBucketCommand: headBucketCommandMock,
|
||||||
|
CreateBucketCommand: createBucketCommandMock,
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('storage bootstrap', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
process.env.MINIO_ENDPOINT = 'http://127.0.0.1:9000'
|
||||||
|
process.env.MINIO_REGION = 'us-east-1'
|
||||||
|
process.env.MINIO_BUCKET = 'waoowaoo'
|
||||||
|
process.env.MINIO_ACCESS_KEY = 'minioadmin'
|
||||||
|
process.env.MINIO_SECRET_KEY = 'minioadmin'
|
||||||
|
process.env.MINIO_FORCE_PATH_STYLE = 'true'
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips startup storage initialization when STORAGE_TYPE=local', async () => {
|
||||||
|
await expect(ensureStorageReady({ storageType: 'local' })).resolves.toBe('skipped')
|
||||||
|
expect(s3ClientMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies the MinIO bucket during startup when it already exists', async () => {
|
||||||
|
sendMock.mockResolvedValueOnce({})
|
||||||
|
|
||||||
|
await expect(ensureStorageReady({ storageType: 'minio' })).resolves.toBe('existing')
|
||||||
|
|
||||||
|
expect(s3ClientMock).toHaveBeenCalledWith({
|
||||||
|
endpoint: 'http://127.0.0.1:9000',
|
||||||
|
region: 'us-east-1',
|
||||||
|
forcePathStyle: true,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: 'minioadmin',
|
||||||
|
secretAccessKey: 'minioadmin',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(headBucketCommandMock).toHaveBeenCalledWith({ Bucket: 'waoowaoo' })
|
||||||
|
expect(createBucketCommandMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates the MinIO bucket during startup when HeadBucket reports it missing', async () => {
|
||||||
|
sendMock
|
||||||
|
.mockRejectedValueOnce(Object.assign(new Error('missing bucket'), {
|
||||||
|
name: 'NotFound',
|
||||||
|
$metadata: { httpStatusCode: 404 },
|
||||||
|
}))
|
||||||
|
.mockResolvedValueOnce({})
|
||||||
|
|
||||||
|
await expect(ensureStorageReady({ storageType: 'minio' })).resolves.toBe('created')
|
||||||
|
|
||||||
|
expect(headBucketCommandMock).toHaveBeenCalledWith({ Bucket: 'waoowaoo' })
|
||||||
|
expect(createBucketCommandMock).toHaveBeenCalledWith({ Bucket: 'waoowaoo' })
|
||||||
|
expect(sendMock).toHaveBeenNthCalledWith(2, {
|
||||||
|
type: 'CreateBucketCommand',
|
||||||
|
input: { Bucket: 'waoowaoo' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails fast when MinIO returns a non-bucket-missing error at startup', async () => {
|
||||||
|
const startupError = Object.assign(new Error('MinIO unavailable'), {
|
||||||
|
name: 'TimeoutError',
|
||||||
|
$metadata: { httpStatusCode: 503 },
|
||||||
|
})
|
||||||
|
sendMock.mockRejectedValueOnce(startupError)
|
||||||
|
|
||||||
|
await expect(ensureStorageReady({ storageType: 'minio' })).rejects.toBe(startupError)
|
||||||
|
expect(createBucketCommandMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { USER_FEEDBACK_FORM_URL } from '@/lib/feedback'
|
|
||||||
|
|
||||||
describe('USER_FEEDBACK_FORM_URL', () => {
|
|
||||||
it('should point to the Feishu feedback form', () => {
|
|
||||||
expect(USER_FEEDBACK_FORM_URL).toBe(
|
|
||||||
'https://ox2p5ferjnr.feishu.cn/share/base/form/shrcno200ar2SsTgGiSDYHLmNuc',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user