From fba480ae6eeb49782c1744834a91e03e9f2f5845 Mon Sep 17 00:00:00 2001 From: saturn Date: Mon, 9 Mar 2026 02:42:35 +0800 Subject: [PATCH] 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 --- README.md | 3 + README_en.md | 3 + docker-compose.yml | 15 -- messages/en/nav.json | 6 +- messages/en/novel-promotion.json | 1 + messages/en/progress.json | 4 +- messages/zh/nav.json | 6 +- messages/zh/novel-promotion.json | 1 + messages/zh/progress.json | 4 +- package.json | 7 +- scripts/migrate-to-minio.sh | 2 +- src/app/[locale]/layout.tsx | 15 +- .../provider-card/ProviderCardShell.tsx | 120 +++++++------ src/app/[locale]/profile/page.tsx | 15 -- .../NovelPromotionWorkspace.tsx | 2 - .../components/NovelInputStage.tsx | 3 + .../components/WorkspaceRunStreamConsoles.tsx | 12 -- .../[projectId]/insert-panel/route.ts | 8 +- src/app/globals.css | 4 +- src/components/Navbar.tsx | 20 +-- .../llm-console/LLMStageStreamCard.tsx | 40 +---- src/lib/feedback-log.ts | 42 ----- src/lib/feedback.ts | 3 - src/lib/novel-promotion/insert-panel.ts | 24 +++ src/lib/storage/bootstrap.ts | 74 ++++++++ src/lib/storage/init.ts | 24 +++ src/lib/workers/text.worker.ts | 11 +- .../api/contract/direct-submit-routes.test.ts | 9 + .../provider-card-tutorial-modal.test.ts | 169 ++++++++++++++++++ .../llm-stage-stream-card-error.test.ts | 72 ++++++++ .../components/navbar-download-logs.test.ts | 115 ++++++++++++ .../insert-panel-user-input.test.ts | 26 +++ tests/unit/storage/bootstrap.test.ts | 97 ++++++++++ .../user-api/user-feedback-form-url.test.ts | 11 -- 34 files changed, 721 insertions(+), 247 deletions(-) delete mode 100644 src/lib/feedback-log.ts delete mode 100644 src/lib/feedback.ts create mode 100644 src/lib/novel-promotion/insert-panel.ts create mode 100644 src/lib/storage/bootstrap.ts create mode 100644 src/lib/storage/init.ts create mode 100644 tests/unit/api-config/provider-card-tutorial-modal.test.ts create mode 100644 tests/unit/components/llm-stage-stream-card-error.test.ts create mode 100644 tests/unit/components/navbar-download-logs.test.ts create mode 100644 tests/unit/novel-promotion/insert-panel-user-input.test.ts create mode 100644 tests/unit/storage/bootstrap.test.ts delete mode 100644 tests/unit/user-api/user-feedback-form-url.test.ts 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) => ( +
+
+ {index + 1} +
+
+

+ {t(`tutorial.steps.${step.text}`)} +

+ {step.url && ( + + + {t('tutorial.openLink')} + + )} +
-
-

- {t(`tutorial.steps.${step.text}`)} -

- {step.url && ( - - - {t('tutorial.openLink')} - - )} -
-
- ))} + ))} +
+
+ +
-
- -
-
-
- )} +
, + 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 && ( -
- - - {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', - ) - }) -}) -