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:
@@ -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({
|
||||
)}
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${poppins.variable} ${openSans.variable} antialiased`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<Providers>
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
|
||||
{/* ── 教程弹窗 ── */}
|
||||
{state.showTutorial && state.tutorial && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center glass-overlay"
|
||||
onClick={() => state.setShowTutorial(false)}
|
||||
>
|
||||
{state.showTutorial && state.tutorial && typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<div
|
||||
className="glass-surface-modal mx-4 w-full max-w-lg overflow-hidden rounded-xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center glass-overlay"
|
||||
onClick={() => state.setShowTutorial(false)}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--glass-stroke-base)] px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="glass-btn-base glass-btn-primary flex h-8 w-8 items-center justify-center rounded-lg text-white">
|
||||
<AppIcon name="bookOpen" className="w-4 h-4" />
|
||||
</div>
|
||||
<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
|
||||
className="glass-surface-modal mx-4 w-full max-w-lg overflow-hidden rounded-xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--glass-stroke-base)] px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="glass-btn-base glass-btn-primary flex h-8 w-8 items-center justify-center rounded-lg text-white">
|
||||
<AppIcon name="bookOpen" className="w-4 h-4" />
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<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 className="space-y-4 p-5">
|
||||
{state.tutorial.steps.map((step, index) => (
|
||||
<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)]">
|
||||
{index + 1}
|
||||
<div className="space-y-4 p-5">
|
||||
{state.tutorial.steps.map((step, index) => (
|
||||
<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)]">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1 pt-0.5">
|
||||
<p className="text-sm leading-relaxed text-[var(--glass-text-secondary)]">
|
||||
{t(`tutorial.steps.${step.text}`)}
|
||||
</p>
|
||||
{step.url && (
|
||||
<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 className="flex-1 pt-0.5">
|
||||
<p className="text-sm leading-relaxed text-[var(--glass-text-secondary)]">
|
||||
{t(`tutorial.steps.${step.text}`)}
|
||||
</p>
|
||||
{step.url && (
|
||||
<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 justify-end border-t border-[var(--glass-stroke-base)] px-5 py-3">
|
||||
<button
|
||||
onClick={() => state.setShowTutorial(false)}
|
||||
className="glass-btn-base glass-btn-secondary rounded-lg px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
{t('tutorial.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-[var(--glass-stroke-base)] px-5 py-3">
|
||||
<button
|
||||
onClick={() => state.setShowTutorial(false)}
|
||||
className="glass-btn-base glass-btn-secondary rounded-lg px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
{t('tutorial.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -80,21 +80,6 @@ export default function ProfilePage() {
|
||||
<span className="font-medium">{t('billingRecords')}</span>
|
||||
</button>
|
||||
</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
|
||||
onClick={() => signOut({ callbackUrl: '/' })}
|
||||
|
||||
@@ -165,8 +165,6 @@ function NovelPromotionWorkspaceContent(props: NovelPromotionWorkspaceProps) {
|
||||
scriptToStoryboardConsoleMinimized={vm.execution.scriptToStoryboardConsoleMinimized}
|
||||
onStoryToScriptMinimizedChange={vm.execution.setStoryToScriptConsoleMinimized}
|
||||
onScriptToStoryboardMinimizedChange={vm.execution.setScriptToStoryboardConsoleMinimized}
|
||||
projectId={projectId}
|
||||
episodeId={episodeId}
|
||||
hideMinimizedBadges={vm.execution.showCreatingToast}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -424,6 +424,9 @@ AI 将根据您的文本智能分析:
|
||||
style: artStyleDisplayLabel
|
||||
})}
|
||||
</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">
|
||||
{t("storyInput.moreConfig")}
|
||||
</p>
|
||||
|
||||
@@ -32,8 +32,6 @@ type RunStreamState = {
|
||||
payload: Record<string, unknown> | 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={(
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -229,9 +220,6 @@ export default function WorkspaceRunStreamConsoles({
|
||||
showCursor={scriptToStoryboardShowCursor}
|
||||
autoScroll={scriptToStoryboardStream.selectedStep?.id === scriptToStoryboardStream.activeStepId}
|
||||
errorMessage={scriptToStoryboardStream.errorMessage}
|
||||
feedbackStage="storyboard"
|
||||
projectId={projectId}
|
||||
episodeId={episodeId}
|
||||
topRightAction={(
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
|
||||
@@ -6,6 +6,7 @@ import { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'
|
||||
import { TASK_TYPE } from '@/lib/task/types'
|
||||
import { buildDefaultTaskBillingInfo } from '@/lib/billing'
|
||||
import { getProjectModelConfig } from '@/lib/config-service'
|
||||
import { resolveInsertPanelUserInput } from '@/lib/novel-promotion/insert-panel'
|
||||
|
||||
export const POST = apiHandler(async (
|
||||
request: NextRequest,
|
||||
@@ -21,6 +22,7 @@ export const POST = apiHandler(async (
|
||||
const locale = resolveRequiredTaskLocale(request, body)
|
||||
const storyboardId = body?.storyboardId
|
||||
const insertAfterPanelId = body?.insertAfterPanelId
|
||||
const userInput = resolveInsertPanelUserInput((body || {}) as Record<string, unknown>, locale)
|
||||
|
||||
if (!storyboardId || !insertAfterPanelId) {
|
||||
throw new ApiError('INVALID_PARAMS', {
|
||||
@@ -28,7 +30,11 @@ export const POST = apiHandler(async (
|
||||
}
|
||||
|
||||
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({
|
||||
userId: session.user.id,
|
||||
|
||||
@@ -65,9 +65,7 @@
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--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-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function Navbar() {
|
||||
const { data: session, status } = useSession()
|
||||
const t = useTranslations('nav')
|
||||
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 [checkMsgFading, setCheckMsgFading] = useState(false)
|
||||
const [manualChecking, setManualChecking] = useState(false)
|
||||
@@ -101,15 +101,6 @@ export default function Navbar() {
|
||||
</div>
|
||||
) : 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
|
||||
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"
|
||||
@@ -133,6 +124,15 @@ export default function Navbar() {
|
||||
{t('profile')}
|
||||
</Link>
|
||||
<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'
|
||||
|
||||
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({
|
||||
<span className="text-base">⚠️</span>
|
||||
<span className="text-sm font-medium">{errorMessage}</span>
|
||||
</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>
|
||||
)}
|
||||
</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 { 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<TaskJobData>) {
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user