diff --git a/README.md b/README.md
index cb00dce..f616ed0 100644
--- a/README.md
+++ b/README.md
@@ -55,10 +55,13 @@ docker compose up -d
```bash
docker compose down -v
+docker rmi ghcr.io/saturndec/waoowaoo:latest
curl -O https://raw.githubusercontent.com/saturndec/waoowaoo/main/docker-compose.yml
docker compose up -d
```
+> 启动后请**清空浏览器缓存**并重新登录,避免旧版本缓存导致异常。
+
### 方式二:克隆仓库 + Docker 构建(完全控制)
```bash
diff --git a/README_en.md b/README_en.md
index bfd573a..aa63ec0 100644
--- a/README_en.md
+++ b/README_en.md
@@ -47,10 +47,13 @@ docker compose up -d
```bash
docker compose down -v
+docker rmi ghcr.io/saturndec/waoowaoo:latest
curl -O https://raw.githubusercontent.com/saturndec/waoowaoo/main/docker-compose.yml
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)
```bash
diff --git a/docker-compose.yml b/docker-compose.yml
index 05e2785..2c44f31 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -60,19 +60,6 @@ services:
retries: 30
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:
image: ghcr.io/saturndec/waoowaoo:latest
@@ -140,8 +127,6 @@ services:
condition: service_healthy
minio:
condition: service_healthy
- minio-init:
- condition: service_completed_successfully
command: >
sh -c "
npx prisma db push --skip-generate &&
diff --git a/messages/en/nav.json b/messages/en/nav.json
index 4017fdd..e8d8318 100644
--- a/messages/en/nav.json
+++ b/messages/en/nav.json
@@ -2,8 +2,8 @@
"workspace": "Workspace",
"assetHub": "Asset Hub",
"profile": "Settings",
+ "downloadLogs": "Download Logs",
"signin": "Sign In",
"signup": "Sign Up",
- "logout": "Logout",
- "feedback": "Bug Feedback / Join Community"
-}
\ No newline at end of file
+ "logout": "Logout"
+}
diff --git a/messages/en/novel-promotion.json b/messages/en/novel-promotion.json
index 4387e5b..decc830 100644
--- a/messages/en/novel-promotion.json
+++ b/messages/en/novel-promotion.json
@@ -126,6 +126,7 @@
"visualStyle": "Visual Style",
"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.",
+ "assetLibraryRatioNote": "Asset library ratios are not affected",
"moreConfig": "For more configuration options, click the 「 Settings」 button in the top right",
"narration": {
"title": "Enable Narration Voiceover",
diff --git a/messages/en/progress.json b/messages/en/progress.json
index 6b9914d..53a9353 100644
--- a/messages/en/progress.json
+++ b/messages/en/progress.json
@@ -120,9 +120,7 @@
"storyToScriptSubtitle": "Story To Script V2",
"scriptToStoryboardSubtitle": "Script To Storyboard V2",
"stop": "Stop",
- "minimize": "Minimize",
- "copyErrorDetail": "Copy error detail",
- "openFeedbackForm": "Open feedback form"
+ "minimize": "Minimize"
},
"streamStep": {
"analyzeCharacters": "Analyze characters",
diff --git a/messages/zh/nav.json b/messages/zh/nav.json
index 4bf7cb7..4c78aa0 100644
--- a/messages/zh/nav.json
+++ b/messages/zh/nav.json
@@ -2,8 +2,8 @@
"workspace": "工作区",
"assetHub": "资产中心",
"profile": "设置中心",
+ "downloadLogs": "下载日志",
"signin": "登录",
"signup": "注册",
- "logout": "退出登录",
- "feedback": "Bug 反馈 / 加入群聊"
-}
\ No newline at end of file
+ "logout": "退出登录"
+}
diff --git a/messages/zh/novel-promotion.json b/messages/zh/novel-promotion.json
index b254596..9354e4a 100644
--- a/messages/zh/novel-promotion.json
+++ b/messages/zh/novel-promotion.json
@@ -126,6 +126,7 @@
"visualStyle": "视觉风格",
"visualStyleHint": "根据受众选择画面风格,例如:真人风格适合写实剧情,动漫风格适合二次元内容",
"currentConfigSummary": "当前配置:{ratio} · {style},后续生成都会使用此组合",
+ "assetLibraryRatioNote": "资产库比例不受影响",
"moreConfig": "更多配置请点击右上角「 配置」按钮",
"narration": {
"title": "启用旁白配音",
diff --git a/messages/zh/progress.json b/messages/zh/progress.json
index 7040c72..7898230 100644
--- a/messages/zh/progress.json
+++ b/messages/zh/progress.json
@@ -120,9 +120,7 @@
"storyToScriptSubtitle": "Story To Script V2",
"scriptToStoryboardSubtitle": "Script To Storyboard V2",
"stop": "停止",
- "minimize": "最小化",
- "copyErrorDetail": "复制错误详情",
- "openFeedbackForm": "打开问题反馈表单"
+ "minimize": "最小化"
},
"streamStep": {
"analyzeCharacters": "角色分析",
diff --git a/package.json b/package.json
index c4e8d1b..5bcfc62 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
},
"scripts": {
"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:worker": "tsx watch --env-file=.env src/lib/workers/index.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",
"build": "prisma generate && next build",
"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:worker": "tsx --env-file=.env src/lib/workers/index.ts",
"start:watchdog": "tsx --env-file=.env scripts/watchdog.ts",
@@ -161,4 +162,4 @@
"typescript": "^5",
"vitest": "^2.1.8"
}
-}
\ No newline at end of file
+}
diff --git a/scripts/migrate-to-minio.sh b/scripts/migrate-to-minio.sh
index dc4e149..bd53621 100755
--- a/scripts/migrate-to-minio.sh
+++ b/scripts/migrate-to-minio.sh
@@ -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
echo
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
- docker compose up -d minio minio-init
+ docker compose up -d minio
echo -e "${GREEN}✓ MinIO 启动中,等待 5 秒...${NC}"
sleep 5
else
diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx
index 5c86fb1..7feb586 100644
--- a/src/app/[locale]/layout.tsx
+++ b/src/app/[locale]/layout.tsx
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
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 { getMessages, getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
@@ -19,18 +19,7 @@ const geistMono = Geist_Mono({
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]
@@ -83,7 +72,7 @@ export default async function LocaleLayout({
)}
diff --git a/src/app/[locale]/profile/components/api-config/provider-card/ProviderCardShell.tsx b/src/app/[locale]/profile/components/api-config/provider-card/ProviderCardShell.tsx
index b086162..0acbfea 100644
--- a/src/app/[locale]/profile/components/api-config/provider-card/ProviderCardShell.tsx
+++ b/src/app/[locale]/profile/components/api-config/provider-card/ProviderCardShell.tsx
@@ -1,6 +1,7 @@
'use client'
import type { ReactNode } from 'react'
+import { createPortal } from 'react-dom'
import type { ProviderCardProps, ProviderCardTranslator } from './types'
import { VERIFIABLE_PROVIDER_KEYS } from './types'
import type { UseProviderCardStateResult } from './hooks/useProviderCardState'
@@ -137,70 +138,73 @@ export function ProviderCardShell({
{/* ── 教程弹窗 ── */}
- {state.showTutorial && state.tutorial && (
- state.setShowTutorial(false)}
- >
+ {state.showTutorial && state.tutorial && typeof document !== 'undefined'
+ ? createPortal(
event.stopPropagation()}
+ className="fixed inset-0 z-50 flex items-center justify-center glass-overlay"
+ onClick={() => state.setShowTutorial(false)}
>
-
-
-
-
-
- {provider.name} {t('tutorial.title')}
-
-
{t('tutorial.subtitle')}
+
event.stopPropagation()}
+ >
+
+
+
+
+
+ {provider.name} {t('tutorial.title')}
+
+
{t('tutorial.subtitle')}
+
+
-
-
-
- {state.tutorial.steps.map((step, index) => (
-
-
- {index + 1}
+
+ {state.tutorial.steps.map((step, index) => (
+
-
-
- ))}
+ ))}
+
+
+
+
-
-
-
-
-
- )}
+
,
+ document.body,
+ )
+ : null}
{children}
diff --git a/src/app/[locale]/profile/page.tsx b/src/app/[locale]/profile/page.tsx
index 6e566de..31a35f1 100644
--- a/src/app/[locale]/profile/page.tsx
+++ b/src/app/[locale]/profile/page.tsx
@@ -80,21 +80,6 @@ export default function ProfilePage() {
{t('billingRecords')}
-
- {/* 下载日志 */}
-
-
{/* 退出登录 */}
diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/NovelInputStage.tsx b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/NovelInputStage.tsx
index 88272c6..80cd514 100644
--- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/NovelInputStage.tsx
+++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/NovelInputStage.tsx
@@ -424,6 +424,9 @@ AI 将根据您的文本智能分析:
style: artStyleDisplayLabel
})}
+
+ {t("storyInput.assetLibraryRatioNote")}
+
{t("storyInput.moreConfig")}
diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/WorkspaceRunStreamConsoles.tsx b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/WorkspaceRunStreamConsoles.tsx
index 42bf5fe..15a3796 100644
--- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/WorkspaceRunStreamConsoles.tsx
+++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/WorkspaceRunStreamConsoles.tsx
@@ -32,8 +32,6 @@ type RunStreamState = {
payload: Record
| null
errorMessage: string
}>
- projectId?: string
- episodeId?: string | null
}
interface WorkspaceRunStreamConsolesProps {
@@ -43,8 +41,6 @@ interface WorkspaceRunStreamConsolesProps {
scriptToStoryboardConsoleMinimized: boolean
onStoryToScriptMinimizedChange: (next: boolean) => void
onScriptToStoryboardMinimizedChange: (next: boolean) => void
- projectId: string
- episodeId?: string | null
hideMinimizedBadges?: boolean
}
@@ -55,8 +51,6 @@ export default function WorkspaceRunStreamConsoles({
scriptToStoryboardConsoleMinimized,
onStoryToScriptMinimizedChange,
onScriptToStoryboardMinimizedChange,
- projectId,
- episodeId,
hideMinimizedBadges,
}: WorkspaceRunStreamConsolesProps) {
const t = useTranslations('progress')
@@ -174,9 +168,6 @@ export default function WorkspaceRunStreamConsoles({
showCursor={storyToScriptShowCursor}
autoScroll={storyToScriptStream.selectedStep?.id === storyToScriptStream.activeStepId}
errorMessage={storyToScriptStream.errorMessage}
- feedbackStage="assets"
- projectId={projectId}
- episodeId={episodeId}
topRightAction={(
) : session ? (
<>
-
-
- {t('feedback')}
-
+
+
+ {t('downloadLogs')}
+
>
) : (
diff --git a/src/components/llm-console/LLMStageStreamCard.tsx b/src/components/llm-console/LLMStageStreamCard.tsx
index 8807e21..b6e5723 100644
--- a/src/components/llm-console/LLMStageStreamCard.tsx
+++ b/src/components/llm-console/LLMStageStreamCard.tsx
@@ -1,9 +1,7 @@
'use client'
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
-import { useTranslations, useLocale } from 'next-intl'
-import { USER_FEEDBACK_FORM_URL } from '@/lib/feedback'
-import { buildFeedbackLog, type FeedbackContextStage } from '@/lib/feedback-log'
+import { useTranslations } from 'next-intl'
export type LLMStageViewStatus =
| 'pending'
@@ -41,9 +39,6 @@ export type LLMStageStreamCardProps = {
smoothStreaming?: boolean
errorMessage?: string
topRightAction?: ReactNode
- feedbackStage?: FeedbackContextStage
- projectId?: string
- episodeId?: string | null
}
const PROGRESS_KEY_PREFIX = 'progress.'
@@ -187,13 +182,8 @@ export default function LLMStageStreamCard({
smoothStreaming = true,
errorMessage,
topRightAction,
- feedbackStage,
- projectId,
- episodeId,
}: LLMStageStreamCardProps) {
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 raw = typeof value === 'string' ? value.trim() : ''
@@ -398,34 +388,6 @@ export default function LLMStageStreamCard({
⚠️
{errorMessage}
- {feedbackStage && projectId && (
-
-
{
- 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')}
-
-
- {t('runConsole.openFeedbackForm')}
-
-
- )}
)}
diff --git a/src/lib/feedback-log.ts b/src/lib/feedback-log.ts
deleted file mode 100644
index e3d2937..0000000
--- a/src/lib/feedback-log.ts
+++ /dev/null
@@ -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')
-}
-
diff --git a/src/lib/feedback.ts b/src/lib/feedback.ts
deleted file mode 100644
index 67a4345..0000000
--- a/src/lib/feedback.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export const USER_FEEDBACK_FORM_URL =
- 'https://ox2p5ferjnr.feishu.cn/share/base/form/shrcno200ar2SsTgGiSDYHLmNuc' as const
-
diff --git a/src/lib/novel-promotion/insert-panel.ts b/src/lib/novel-promotion/insert-panel.ts
new file mode 100644
index 0000000..508d0a1
--- /dev/null
+++ b/src/lib/novel-promotion/insert-panel.ts
@@ -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, 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
+}
diff --git a/src/lib/storage/bootstrap.ts b/src/lib/storage/bootstrap.ts
new file mode 100644
index 0000000..4d97db8
--- /dev/null
+++ b/src/lib/storage/bootstrap.ts
@@ -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> {
+ 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 {
+ const provider = createStorageProvider(options)
+
+ if (provider.kind !== 'minio') {
+ return 'skipped'
+ }
+
+ return await ensureMinioBucket()
+}
diff --git a/src/lib/storage/init.ts b/src/lib/storage/init.ts
new file mode 100644
index 0000000..91e158a
--- /dev/null
+++ b/src/lib/storage/init.ts
@@ -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)
+})
diff --git a/src/lib/workers/text.worker.ts b/src/lib/workers/text.worker.ts
index a668499..b2cee4e 100644
--- a/src/lib/workers/text.worker.ts
+++ b/src/lib/workers/text.worker.ts
@@ -7,6 +7,7 @@ import type { LLMStreamKind } from '@/lib/llm-observe/types'
import { QUEUE_NAME } from '@/lib/task/queues'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
+import { resolveInsertPanelUserInput } from '@/lib/novel-promotion/insert-panel'
import {
executePhase1,
executePhase2,
@@ -400,14 +401,10 @@ async function handleInsertPanelTask(job: Job) {
const payload = (job.data.payload || {}) as AnyObj
const storyboardId = typeof payload.storyboardId === 'string' ? payload.storyboardId : job.data.targetId
const insertAfterPanelId = typeof payload.insertAfterPanelId === 'string' ? payload.insertAfterPanelId : ''
- const userInput = typeof payload.userInput === 'string'
- ? payload.userInput
- : typeof payload.prompt === 'string'
- ? payload.prompt
- : ''
+ const userInput = resolveInsertPanelUserInput(payload, job.data.locale)
- if (!storyboardId || !insertAfterPanelId || !userInput) {
- throw new Error('insert_panel requires storyboardId/insertAfterPanelId/userInput')
+ if (!storyboardId || !insertAfterPanelId) {
+ throw new Error('insert_panel requires storyboardId/insertAfterPanelId')
}
const storyboard = await prisma.novelPromotionStoryboard.findUnique({
diff --git a/tests/integration/api/contract/direct-submit-routes.test.ts b/tests/integration/api/contract/direct-submit-routes.test.ts
index 945c1e1..8f3cbb9 100644
--- a/tests/integration/api/contract/direct-submit-routes.test.ts
+++ b/tests/integration/api/contract/direct-submit-routes.test.ts
@@ -23,6 +23,7 @@ type DirectRouteCase = {
expectedTaskType: TaskType
expectedTargetType: string
expectedProjectId: string
+ expectedPayloadSubset?: Record
}
const authState = vi.hoisted(() => ({
@@ -315,6 +316,11 @@ const DIRECT_CASES: ReadonlyArray = [
expectedTaskType: TASK_TYPE.INSERT_PANEL,
expectedTargetType: 'NovelPromotionStoryboard',
expectedProjectId: 'project-1',
+ expectedPayloadSubset: {
+ storyboardId: 'storyboard-1',
+ insertAfterPanelId: 'panel-ins',
+ userInput: '请根据前后镜头自动分析并插入一个自然衔接的新分镜。',
+ },
},
{
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?.projectId).toBe(routeCase.expectedProjectId)
expect(submitArg?.userId).toBe('user-1')
+ if (routeCase.expectedPayloadSubset) {
+ expect(submitArg?.payload).toEqual(expect.objectContaining(routeCase.expectedPayloadSubset))
+ }
const json = await res.json() as Record
const isVoiceGenerateRoute = routeCase.routeFile.endsWith('/voice-generate/route.ts')
diff --git a/tests/unit/api-config/provider-card-tutorial-modal.test.ts b/tests/unit/api-config/provider-card-tutorial-modal.test.ts
new file mode 100644
index 0000000..2fd1162
--- /dev/null
+++ b/tests/unit/api-config/provider-card-tutorial-modal.test.ts
@@ -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('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"')
+ })
+})
diff --git a/tests/unit/components/llm-stage-stream-card-error.test.ts b/tests/unit/components/llm-stage-stream-card-error.test.ts
new file mode 100644
index 0000000..16a56b7
--- /dev/null
+++ b/tests/unit/components/llm-stage-stream-card-error.test.ts
@@ -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 = {
+ 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')
+ })
+})
diff --git a/tests/unit/components/navbar-download-logs.test.ts b/tests/unit/components/navbar-download-logs.test.ts
new file mode 100644
index 0000000..d8db7aa
--- /dev/null
+++ b/tests/unit/components/navbar-download-logs.test.ts
@@ -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) => 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) => {
+ 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 = {
+ 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')
+ })
+})
diff --git a/tests/unit/novel-promotion/insert-panel-user-input.test.ts b/tests/unit/novel-promotion/insert-panel-user-input.test.ts
new file mode 100644
index 0000000..efc9975
--- /dev/null
+++ b/tests/unit/novel-promotion/insert-panel-user-input.test.ts
@@ -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.')
+ })
+})
diff --git a/tests/unit/storage/bootstrap.test.ts b/tests/unit/storage/bootstrap.test.ts
new file mode 100644
index 0000000..842f6c1
--- /dev/null
+++ b/tests/unit/storage/bootstrap.test.ts
@@ -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
+}
+
+const {
+ sendMock,
+ s3ClientMock,
+ headBucketCommandMock,
+ createBucketCommandMock,
+} = vi.hoisted(() => ({
+ sendMock: vi.fn<(command: MockCommand) => Promise>(),
+ s3ClientMock: vi.fn(() => ({ send: undefined as unknown })),
+ headBucketCommandMock: vi.fn((input: Record): MockCommand => ({
+ type: 'HeadBucketCommand',
+ input,
+ })),
+ createBucketCommandMock: vi.fn((input: Record): 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()
+ })
+})
diff --git a/tests/unit/user-api/user-feedback-form-url.test.ts b/tests/unit/user-api/user-feedback-form-url.test.ts
deleted file mode 100644
index c71a131..0000000
--- a/tests/unit/user-api/user-feedback-form-url.test.ts
+++ /dev/null
@@ -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',
- )
- })
-})
-