feat: add asset library download button, fix env ports, update README, optimize semantics, support multi-image reading, and allow voiceover analysis for silent segments

This commit is contained in:
saturn
2026-03-13 17:37:52 +08:00
parent be1853534a
commit eec27fbabf
41 changed files with 977 additions and 187 deletions

View File

@@ -12,6 +12,9 @@ import { useState } from 'react'
import { useTranslations } from 'next-intl'
import AssetsStage from './AssetsStage'
import { AppIcon } from '@/components/ui/icons'
import { useProjectAssets } from '@/lib/query/hooks'
import JSZip from 'jszip'
import { logError as _logError } from '@/lib/logging/core'
interface AssetLibraryProps {
projectId: string
@@ -23,8 +26,77 @@ export default function AssetLibrary({
isAnalyzingAssets
}: AssetLibraryProps) {
const [isOpen, setIsOpen] = useState(false)
const [isDownloading, setIsDownloading] = useState(false)
const t = useTranslations('assets')
// 获取项目资产数据用于下载
const { data: assets } = useProjectAssets(projectId)
const handleDownloadAll = async () => {
const characters = assets?.characters ?? []
const locations = assets?.locations ?? []
// 收集所有有效图片
const imageEntries: Array<{ filename: string; url: string }> = []
// 角色图片
for (const character of characters) {
for (const appearance of character.appearances ?? []) {
const url = appearance.imageUrl
if (!url) continue
const safeName = character.name.replace(/[/\\:*?"<>|]/g, '_')
const filename = appearance.appearanceIndex === 0
? `characters/${safeName}.jpg`
: `characters/${safeName}_appearance${appearance.appearanceIndex}.jpg`
imageEntries.push({ filename, url })
}
}
// 场景图片:取已选中的那张
for (const location of locations) {
const selectedImage = location.images?.find(img => img.isSelected) ?? location.images?.[0]
const url = selectedImage?.imageUrl
if (!url) continue
const safeName = location.name.replace(/[/\\:*?"<>|]/g, '_')
imageEntries.push({ filename: `locations/${safeName}.jpg`, url })
}
if (imageEntries.length === 0) {
alert(t('assetLibrary.downloadEmpty'))
return
}
setIsDownloading(true)
try {
const zip = new JSZip()
await Promise.all(
imageEntries.map(async ({ filename, url }) => {
try {
const response = await fetch(url)
if (!response.ok) return
const blob = await response.blob()
zip.file(filename, blob)
} catch {
// 单张失败不影响其他
}
})
)
const content = await zip.generateAsync({ type: 'blob' })
const link = document.createElement('a')
link.href = URL.createObjectURL(content)
link.download = `assets_${new Date().toISOString().slice(0, 10)}.zip`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(link.href)
} catch (error) {
_logError('打包下载失败:', error)
alert(t('assetLibrary.downloadFailed'))
} finally {
setIsDownloading(false)
}
}
return (
<>
{/* 触发按钮 - 现代玻璃态风格 */}
@@ -48,6 +120,20 @@ export default function AssetLibrary({
<AppIcon name="folderCards" className="w-5 h-5 text-white" />
</div>
<h2 className="text-2xl font-bold text-[var(--glass-text-primary)]">{t('assetLibrary.title')}</h2>
{/* 下载按钮 - 紧贴标题 */}
<button
type="button"
onClick={handleDownloadAll}
disabled={isDownloading}
title={t('common.download')}
className="w-9 h-9 glass-btn-base glass-btn-secondary flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed"
>
<AppIcon
name={isDownloading ? 'refresh' : 'download'}
className={`w-4 h-4${isDownloading ? ' animate-spin' : ''}`}
/>
</button>
</div>
<button
type="button"

View File

@@ -1,9 +1,12 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { useRefreshProjectAssets } from '@/lib/query/hooks'
import { useRefreshProjectAssets, useProjectAssets, useProjectData } from '@/lib/query/hooks'
import TaskStatusInline from '@/components/task/TaskStatusInline'
import { resolveTaskPresentationState } from '@/lib/task/presentation'
import { AppIcon } from '@/components/ui/icons'
import JSZip from 'jszip'
import { logError as _logError } from '@/lib/logging/core'
/**
* AssetToolbar - 资产管理工具栏组件
@@ -37,9 +40,13 @@ export default function AssetToolbar({
onRegenerateAll,
onGlobalAnalyze
}: AssetToolbarProps) {
// 🔥 使用 React Query 刷新
const onRefresh = useRefreshProjectAssets(projectId)
const t = useTranslations('assets')
const { data: assets } = useProjectAssets(projectId)
const { data: projectData } = useProjectData(projectId)
const projectName = projectData?.name
const [isDownloading, setIsDownloading] = useState(false)
const assetTaskRunningState = isBatchSubmitting
? resolveTaskPresentationState({
phase: 'processing',
@@ -48,6 +55,72 @@ export default function AssetToolbar({
hasOutput: true,
})
: null
const handleDownloadAll = async () => {
const characters = assets?.characters ?? []
const locations = assets?.locations ?? []
const imageEntries: Array<{ filename: string; url: string }> = []
// 角色图片
for (const character of characters) {
for (const appearance of character.appearances ?? []) {
const url = appearance.imageUrl
if (!url) continue
const safeName = character.name.replace(/[/\\:*?"<>|]/g, '_')
const filename = appearance.appearanceIndex === 0
? `characters/${safeName}.jpg`
: `characters/${safeName}_appearance${appearance.appearanceIndex}.jpg`
imageEntries.push({ filename, url })
}
}
// 场景图片:取已选中的那张(或第一张)
for (const location of locations) {
const selectedImage = location.images?.find((img: { isSelected: boolean; imageUrl: string | null }) => img.isSelected) ?? location.images?.[0]
const url = selectedImage?.imageUrl
if (!url) continue
const safeName = location.name.replace(/[/\\:*?"<>|]/g, '_')
imageEntries.push({ filename: `locations/${safeName}.jpg`, url })
}
if (imageEntries.length === 0) {
alert(t('assetLibrary.downloadEmpty'))
return
}
setIsDownloading(true)
try {
const zip = new JSZip()
await Promise.all(
imageEntries.map(async ({ filename, url }) => {
try {
const response = await fetch(url)
if (!response.ok) return
const blob = await response.blob()
zip.file(filename, blob)
} catch {
// 单张失败不阻断其他
}
})
)
const content = await zip.generateAsync({ type: 'blob' })
const link = document.createElement('a')
link.href = URL.createObjectURL(content)
const safeName = projectName ? projectName.replace(/[/\\:*?"<>|]/g, '_') : 'assets'
link.download = `${safeName}_${new Date().toISOString().slice(0, 10)}.zip`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(link.href)
} catch (error) {
_logError('打包下载失败:', error)
alert(t('assetLibrary.downloadFailed'))
} finally {
setIsDownloading(false)
}
}
return (
<div className="glass-surface p-4">
<div className="flex items-center justify-between">
@@ -106,6 +179,18 @@ export default function AssetToolbar({
<AppIcon name="refresh" className="w-4 h-4" />
<span>{t("common.refresh")}</span>
</button>
{/* 打包下载按钮 */}
<button
onClick={handleDownloadAll}
disabled={isDownloading || totalAssets === 0}
title={t("toolbar.downloadAll")}
className="glass-btn-base glass-btn-secondary flex items-center justify-center w-9 h-9 disabled:opacity-50 disabled:cursor-not-allowed border border-[var(--glass-stroke-base)]"
>
<AppIcon
name={isDownloading ? 'refresh' : 'download'}
className={`w-4 h-4 ${isDownloading ? 'animate-spin' : ''}`}
/>
</button>
</div>
</div>
</div>

View File

@@ -21,6 +21,8 @@ import CharacterCardActions from './character-card/CharacterCardActions'
import { getImageGenerationCountOptions } from '@/lib/image-generation/count'
import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'
import { AppIcon } from '@/components/ui/icons'
import { AI_EDIT_BUTTON_CLASS, AI_EDIT_ICON_CLASS } from '@/components/ui/ai-edit-style'
import AISparklesIcon from '@/components/ui/icons/AISparklesIcon'
interface CharacterCardProps {
character: Character
@@ -218,15 +220,18 @@ export default function CharacterCard({
prefix={isGroupTaskRunning ? (
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
) : (
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
<>
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)] ml-0.5">{t('image.regenCountPrefix')}</span>
</>
)}
suffix={null}
suffix={<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{t('image.regenCountSuffix')}</span>}
value={generationCount}
options={getImageGenerationCountOptions('character')}
onValueChange={setGenerationCount}
onClick={() => onRegenerate(generationCount)}
disabled={isAppearanceTaskRunning || isAnyTaskRunning || uploadImage.isPending}
ariaLabel={t('image.selectCount')}
ariaLabel={t('image.regenCountAriaLabel')}
className="inline-flex h-6 items-center gap-0.5 rounded px-1 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-[10px] font-semibold text-[var(--glass-tone-info-fg)] outline-none cursor-pointer leading-none transition-colors"
/>
@@ -333,11 +338,10 @@ export default function CharacterCard({
{!isAppearanceTaskRunning && !isAnyTaskRunning && currentImageUrl && onImageEdit && (
<button
onClick={() => onImageEdit(character.id, appearance.id, selectedIndex !== null ? selectedIndex : 0)}
className="w-7 h-7 rounded-full flex items-center justify-center transition-all shadow-sm"
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }}
className={`w-7 h-7 rounded-full flex items-center justify-center transition-all active:scale-95 ${AI_EDIT_BUTTON_CLASS}`}
title={t('image.edit')}
>
<AppIcon name="edit" className="w-4 h-4 text-white" />
<AISparklesIcon className={`w-4 h-4 ${AI_EDIT_ICON_CLASS}`} />
</button>
)}
<button

View File

@@ -190,15 +190,18 @@ export default function LocationCard({
prefix={isGroupTaskRunning ? (
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
) : (
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
<>
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)] ml-0.5">{t('image.regenCountPrefix')}</span>
</>
)}
suffix={null}
suffix={<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{t('image.regenCountSuffix')}</span>}
value={generationCount}
options={getImageGenerationCountOptions('location')}
onValueChange={setGenerationCount}
onClick={() => onRegenerate(generationCount)}
disabled={isTaskRunning || isAnyTaskRunning || uploadImage.isPending}
ariaLabel={t('image.selectCount')}
ariaLabel={t('image.regenCountAriaLabel')}
className="inline-flex h-6 items-center gap-0.5 rounded px-1 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-[10px] font-semibold text-[var(--glass-tone-info-fg)] outline-none cursor-pointer leading-none transition-colors"
/>

View File

@@ -5,6 +5,8 @@ import { AppIcon } from '@/components/ui/icons'
import ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'
import { getImageGenerationCountOptions } from '@/lib/image-generation/count'
import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'
import { AI_EDIT_BUTTON_CLASS, AI_EDIT_ICON_CLASS } from '@/components/ui/ai-edit-style'
import AISparklesIcon from '@/components/ui/icons/AISparklesIcon'
interface ImageSectionActionButtonsProps {
panelId: string
@@ -77,9 +79,10 @@ export default function ImageSectionActionButtons({
{imageUrl && (
<button
onClick={onOpenEditModal}
className={`glass-btn-base glass-btn-secondary flex items-center gap-0.5 px-1.5 py-0.5 rounded-md text-[10px] transition-all active:scale-95 ${isSubmittingPanelImageTask || isModifying ? 'opacity-75' : ''}`}
className={`glass-btn-base h-6 w-6 rounded-full flex items-center justify-center transition-all active:scale-95 ${AI_EDIT_BUTTON_CLASS} ${isSubmittingPanelImageTask || isModifying ? 'opacity-75' : ''}`}
title={t('image.editImage')}
>
<span>{t('image.editImage')}</span>
<AISparklesIcon className={`w-2.5 h-2.5 ${AI_EDIT_ICON_CLASS}`} />
</button>
)}

View File

@@ -10,6 +10,8 @@ import { resolveTaskPresentationState } from '@/lib/task/presentation'
import { AppIcon } from '@/components/ui/icons'
import { SegmentedControl } from '@/components/ui/SegmentedControl'
interface Character {
id: string
name: string
@@ -67,6 +69,8 @@ interface AssetGridProps {
onAddCharacter: () => void
onAddLocation: () => void
onAddVoice: () => void
onDownloadAll?: () => void
isDownloading?: boolean
selectedFolderId: string | null
onImageClick?: (url: string) => void
onImageEdit?: (type: 'character' | 'location', id: string, name: string, imageIndex: number, appearanceIndex?: number) => void
@@ -89,6 +93,8 @@ export function AssetGrid({
onAddCharacter,
onAddLocation,
onAddVoice,
onDownloadAll,
isDownloading,
selectedFolderId: _selectedFolderId,
onImageClick,
onImageEdit,
@@ -192,8 +198,19 @@ export function AssetGrid({
)
})()}
{/* 右侧新建按钮 */}
{/* 右侧操作按钮 */}
<div className="flex items-center gap-3">
{onDownloadAll && (
<button
onClick={onDownloadAll}
disabled={isDownloading || isEmpty}
title={t('downloadAllTitle')}
className="glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<AppIcon name={isDownloading ? 'refresh' : 'download'} className={`w-4 h-4 ${isDownloading ? 'animate-spin' : ''}`} />
<span>{isDownloading ? t('downloading') : t('downloadAll')}</span>
</button>
)}
<button
onClick={onAddCharacter}
className="glass-btn-base glass-btn-primary px-4 py-2 rounded-lg text-sm"

View File

@@ -1,6 +1,7 @@
'use client'
import { logError as _ulogError } from '@/lib/logging/core'
import { apiFetch } from '@/lib/api-fetch'
import JSZip from 'jszip'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
@@ -75,6 +76,8 @@ export default function AssetHubPage() {
// 音色库弹窗状态
const [showAddVoice, setShowAddVoice] = useState(false)
const [voicePickerCharacterId, setVoicePickerCharacterId] = useState<string | null>(null)
const [isDownloading, setIsDownloading] = useState(false)
// 编辑角色弹窗状态
const [characterEditModal, setCharacterEditModal] = useState<{
@@ -340,6 +343,74 @@ export default function AssetHubPage() {
}
}
// 打包下载所有图片资产
const handleDownloadAll = async () => {
// 收集所有有效图片
const imageEntries: Array<{ filename: string; url: string }> = []
// 角色图片:每个角色每个外貌的当前选中图
for (const character of characters) {
for (const appearance of character.appearances) {
const url = appearance.imageUrl
if (!url) continue
const safeName = character.name.replace(/[/\\:*?"<>|]/g, '_')
const filename = appearance.appearanceIndex === 0
? `characters/${safeName}.jpg`
: `characters/${safeName}_appearance${appearance.appearanceIndex}.jpg`
imageEntries.push({ filename, url })
}
}
// 场景图片:每个场景的选中图
for (const location of locations) {
for (const image of location.images) {
const url = image.imageUrl
if (!url) continue
const safeName = location.name.replace(/[/\\:*?"<>|]/g, '_')
const filename = location.images.length <= 1
? `locations/${safeName}.jpg`
: `locations/${safeName}_${image.imageIndex + 1}.jpg`
imageEntries.push({ filename, url })
}
}
if (imageEntries.length === 0) {
alert(t('downloadEmpty'))
return
}
setIsDownloading(true)
try {
const zip = new JSZip()
// 并发 fetch 所有图片
await Promise.all(
imageEntries.map(async ({ filename, url }) => {
try {
const response = await fetch(url)
if (!response.ok) return
const blob = await response.blob()
zip.file(filename, blob)
} catch {
// 单张图片失败不阻断整个流程
}
})
)
const content = await zip.generateAsync({ type: 'blob' })
const link = document.createElement('a')
link.href = URL.createObjectURL(content)
link.download = `asset-hub_${new Date().toISOString().slice(0, 10)}.zip`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(link.href)
} catch (error) {
_ulogError('打包下载失败:', error)
alert(t('downloadFailed'))
} finally {
setIsDownloading(false)
}
}
return (
<div className="glass-page min-h-screen">
<Navbar />
@@ -382,6 +453,8 @@ export default function AssetHubPage() {
onAddCharacter={() => setShowAddCharacter(true)}
onAddLocation={() => setShowAddLocation(true)}
onAddVoice={() => setShowAddVoice(true)}
onDownloadAll={handleDownloadAll}
isDownloading={isDownloading}
selectedFolderId={selectedFolderId}
onImageClick={setPreviewImage}
onImageEdit={handleOpenImageEdit}

View File

@@ -22,10 +22,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
const where: Record<string, unknown> = { userId: session.user.id }
// 如果有搜索关键词,搜索名称和描述
// 注意SQLite 不支持 mode: 'insensitive',但 SQLite 的 LIKE 默认即大小写不敏感ASCII 范围)
if (search.trim()) {
where.OR = [
{ name: { contains: search.trim(), mode: 'insensitive' } },
{ description: { contains: search.trim(), mode: 'insensitive' } }
{ name: { contains: search.trim() } },
{ description: { contains: search.trim() } }
]
}
@@ -77,7 +78,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
select: {
episodes: true,
characters: true,
locations: true}
locations: true
}
},
episodes: {
orderBy: { episodeNumber: 'asc' },
@@ -98,7 +100,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
select: {
imageUrl: true,
videoUrl: true}
videoUrl: true
}
}
}
}
@@ -136,7 +139,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
images: imageCount,
videos: videoCount,
panels: panelCount,
firstEpisodePreview: preview}]
firstEpisodePreview: preview
}]
})
)
@@ -144,7 +148,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
const projectsWithStats = projects.map(project => ({
...project,
totalCost: costMap.get(project.id) ?? 0,
stats: statsMap.get(project.id) ?? { episodes: 0, images: 0, videos: 0, panels: 0, firstEpisodePreview: null }}))
stats: statsMap.get(project.id) ?? { episodes: 0, images: 0, videos: 0, panels: 0, firstEpisodePreview: null }
}))
return NextResponse.json({
projects: projectsWithStats,

View File

@@ -0,0 +1,3 @@
export const AI_EDIT_BUTTON_CLASS = 'bg-[var(--glass-bg-surface-strong)] border border-[var(--glass-stroke-base)] shadow-sm hover:bg-[var(--glass-bg-surface)]'
export const AI_EDIT_ICON_CLASS = ''

View File

@@ -0,0 +1,22 @@
import { Sparkles } from 'lucide-react'
import { useId } from 'react'
interface AISparklesIconProps {
className?: string
}
export default function AISparklesIcon({ className }: AISparklesIconProps) {
const gradientId = useId().replace(/:/g, '')
return (
<Sparkles className={className} stroke={`url(#${gradientId})`}>
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#06b6d4" />
<stop offset="52%" stopColor="#3b82f6" />
<stop offset="100%" stopColor="#8b5cf6" />
</linearGradient>
</defs>
</Sparkles>
)
}

View File

@@ -87,6 +87,13 @@ export const ERROR_CATALOG = {
userMessageKey: 'errors.MODEL_NOT_REGISTERED',
defaultMessage: 'Model is not registered',
},
MODEL_NOT_CONFIGURED: {
httpStatus: 400,
retryable: false,
category: ERROR_CATEGORY.PROVIDER,
userMessageKey: 'errors.MODEL_NOT_CONFIGURED',
defaultMessage: 'Model is not configured. Please add a model in the settings first.',
},
QUOTA_EXCEEDED: {
httpStatus: 429,
retryable: true,

View File

@@ -60,6 +60,32 @@ function isModelNotRegisteredMessage(message: string): boolean {
])
}
/**
* MODEL_NOT_CONFIGURED: 用户未配置对应类型的模型
* 覆盖形式model_not_found / model_not_configured / no xxx model is enabled
*/
function isModelNotConfiguredMessage(message: string): boolean {
return containsAny(message, [
'model_not_found',
'model_not_configured',
'is not enabled for image',
'is not enabled for video',
'is not enabled for audio',
'is not enabled for lipsync',
'is not enabled for llm',
'no image model is enabled',
'no video model is enabled',
'no audio model is enabled',
'no lipsync model is enabled',
'no llm model is enabled',
'multiple image models are enabled',
'multiple video models are enabled',
'multiple audio models are enabled',
'multiple lipsync models are enabled',
'multiple llm models are enabled',
])
}
function isEmptyResponseMessage(message: string): boolean {
return containsAny(message, [
'channel:empty_response',
@@ -139,10 +165,13 @@ function inferCodeFromMessage(message: string): UnifiedErrorCode | null {
if (isModelNotOpenMessage(message)) return 'MODEL_NOT_OPEN'
if (isModelNotRegisteredMessage(message)) return 'MODEL_NOT_REGISTERED'
if (isModelNotConfiguredMessage(message)) return 'MODEL_NOT_CONFIGURED'
if (isEmptyResponseMessage(message)) return 'EMPTY_RESPONSE'
if (isVideoApiFormatUnsupportedMessage(message)) return 'VIDEO_API_FORMAT_UNSUPPORTED'
if (containsAny(message, ['task cancelled', 'canceled by user', 'cancelled by user', '任务已取消'])) return 'CONFLICT'
if (containsAny(message, ['unauthorized', 'not authenticated', 'need login', '401'])) return 'UNAUTHORIZED'
// AccountOverdueErrorARK 欠费 403必须在 FORBIDDEN 之前检查
if (containsAny(message, ['accountoverdueerror', 'overdue balance', 'overdue', 'account has an overdue'])) return 'INSUFFICIENT_BALANCE'
if (containsAny(message, ['forbidden', 'permission denied', '403'])) return 'FORBIDDEN'
if (containsAny(message, ['not found', '不存在', 'missing record'])) return 'NOT_FOUND'
if (containsAny(message, ['invalid', 'missing', 'required', 'bad request', 'fieldinvalid'])) return 'INVALID_PARAMS'
@@ -225,14 +254,22 @@ export function normalizeAnyError(input: unknown, options: NormalizeOptions = {}
if (isModelNotRegisteredMessage(lowerMessage)) {
return buildNormalizedError('MODEL_NOT_REGISTERED', message, options.details, provider)
}
if (isModelNotConfiguredMessage(lowerMessage)) {
return buildNormalizedError('MODEL_NOT_CONFIGURED', message, options.details, provider)
}
if (isEmptyResponseMessage(lowerMessage)) {
return buildNormalizedError('EMPTY_RESPONSE', message, options.details, provider)
}
if (typeof errorLike.status === 'number') {
if (errorLike.status === 401) return buildNormalizedError('UNAUTHORIZED', message, options.details, provider)
if (errorLike.status === 403) return buildNormalizedError('FORBIDDEN', message, options.details, provider)
// 403 可能是欠费AccountOverdueError需优先检查消息内容再决定错误码
if (errorLike.status === 403) {
if (containsAny(lowerMessage, ['accountoverdueerror', 'overdue balance', 'overdue', 'account has an overdue'])) {
return buildNormalizedError('INSUFFICIENT_BALANCE', message, options.details, provider)
}
return buildNormalizedError('FORBIDDEN', message, options.details, provider)
}
if (errorLike.status === 404) return buildNormalizedError('NOT_FOUND', message, options.details, provider)
if (errorLike.status === 409) return buildNormalizedError('CONFLICT', message, options.details, provider)
if (errorLike.status === 422) return buildNormalizedError('SENSITIVE_CONTENT', message, options.details, provider)

View File

@@ -12,6 +12,7 @@ export const USER_ERROR_MESSAGES_ZH: Record<UnifiedErrorCode, string> = {
RATE_LIMIT: '请求过于频繁,请稍后重试。',
MODEL_NOT_OPEN: '模型权限未开通。请前往 https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&advancedActiveKey=model ,在模型管理页面点击右上角「一键开通所有模型」。',
MODEL_NOT_REGISTERED: '模型尚未注册,请先完成模型配置后再试。',
MODEL_NOT_CONFIGURED: '未配置可用模型,请先前往设置页面添加对应类型的模型后再试。',
QUOTA_EXCEEDED: '额度已用尽,请稍后再试。',
EXTERNAL_ERROR: '外部服务暂时不可用,请稍后重试。',
NETWORK_ERROR: '网络异常,请稍后重试。',

View File

@@ -248,7 +248,12 @@ export class ArkImageGenerator extends BaseImageGenerator {
logPrefix: '[ARK Image]'
})
const imageUrl = arkData.data?.[0]?.url
const imageUrls = Array.isArray(arkData.data)
? arkData.data
.map((item) => (typeof item?.url === 'string' ? item.url.trim() : ''))
.filter((item) => item.length > 0)
: []
const imageUrl = imageUrls[0]
if (!imageUrl) {
throw new Error('ARK 未返回图片 URL')
@@ -256,7 +261,8 @@ export class ArkImageGenerator extends BaseImageGenerator {
return {
success: true,
imageUrl
imageUrl,
...(imageUrls.length > 1 ? { imageUrls } : {}),
}
}
}

View File

@@ -20,8 +20,9 @@ export interface GenerateOptions {
export interface GenerateResult {
success: boolean
imageUrl?: string // 图片 URL
imageBase64?: string // 图片 base64
imageUrl?: string // 图片 URL(单图,向后兼容)
imageUrls?: string[] // 多图 URL 列表(接口返回多张时填充)
imageBase64?: string // 图片 base64单图向后兼容
videoUrl?: string // 视频 URL
audioUrl?: string // 音频 URL
error?: string // 错误信息

View File

@@ -127,6 +127,8 @@ async function appendLineAsync(filePath: string, line: string): Promise<void> {
const dir = modules.path.dirname(filePath)
modules.fs.mkdirSync(dir, { recursive: true })
modules.fs.appendFileSync(filePath, line + '\n')
// 写入后异步检查是否需要清理fire-and-forget
void maybeCleanupProjectLog(filePath)
} catch (err) {
// Do not propagate, but surface so file-write failures are visible.
console.error('[file-writer] Failed to write log line to', filePath, err)
@@ -138,6 +140,50 @@ function buildLogFilePath(modules: NodeModules, prefix: string, projectName: str
return modules.path.join(modules.cwd, 'logs', fileName)
}
// ─── 24h cleanup helpers ─────────────────────────────────────────────
const PROJECT_LOG_MAX_BYTES = 2 * 1024 * 1024 // 2 MB 触发清理
const LOG_RETENTION_MS = 24 * 60 * 60 * 1000 // 保留 24 小时
/**
* 从日志内容中过滤掉 24 小时前的行。
* 每行是 JSON通过 "ts" 字段判断时间。
*/
function filterRecentLines(content: string): string {
const cutoff = Date.now() - LOG_RETENTION_MS
const lines = content.split('\n')
const kept = lines.filter((line) => {
if (!line.trim()) return false
try {
const parsed = JSON.parse(line) as { ts?: string }
if (parsed.ts) {
return new Date(parsed.ts).getTime() >= cutoff
}
} catch {
// 非 JSON 行(如分隔符)保留
}
return true
})
return kept.join('\n')
}
/**
* 若项目日志文件超过阈值,清理 24 小时前的内容。
*/
async function maybeCleanupProjectLog(filePath: string): Promise<void> {
const modules = await getNodeModules()
if (!modules) return
try {
const stat = modules.fs.statSync(filePath)
if (stat.size <= PROJECT_LOG_MAX_BYTES) return
const content = modules.fs.readFileSync(filePath, 'utf-8')
const cleaned = filterRecentLines(content)
modules.fs.writeFileSync(filePath, cleaned + '\n')
} catch {
// 文件不存在或读写失败,忽略
}
}
// ─── prefix mapping ──────────────────────────────────────────────────
function getPrefix(module?: string): string {
@@ -318,3 +364,30 @@ export async function readAllLogs(): Promise<string> {
return ''
}
}
/**
* 清理所有项目日志文件中 24 小时前的内容。
* 供 watchdog 定期调用(建议每小时一次)。
*/
export async function cleanupAllProjectLogs(): Promise<void> {
if (isEdgeOrBrowser()) return
const modules = await getNodeModules()
if (!modules) return
const logsDir = modules.path.join(modules.cwd, 'logs')
try {
const files = modules.fs.readdirSync(logsDir)
for (const f of files) {
if (!f.endsWith('.log') || f === 'app.log') continue
const filePath = modules.path.join(logsDir, f)
try {
const content = modules.fs.readFileSync(filePath, 'utf-8')
const cleaned = filterRecentLines(content)
modules.fs.writeFileSync(filePath, cleaned + '\n')
} catch {
// 单个文件失败不影响其他
}
}
} catch {
// logs 目录不存在等情况,忽略
}
}

View File

@@ -109,23 +109,43 @@ function toMimeFromOutputFormat(outputFormat: string | undefined): string {
return 'image/png'
}
function readFirstImagePayload(response: unknown): { b64Json: string | null; url: string | null } {
interface ImagePayloads {
/** 第一张图的 base64向后兼容 */
b64Json: string | null
/** 第一张图的 URL向后兼容 */
url: string | null
/** 所有图的 URL 列表(接口返回多张时有值) */
urls: string[]
}
function readAllImagePayloads(response: unknown): ImagePayloads {
if (typeof response !== 'object' || response === null) {
return { b64Json: null, url: null }
return { b64Json: null, url: null, urls: [] }
}
const data = (response as { data?: unknown }).data
if (!Array.isArray(data) || data.length === 0) {
return { b64Json: null, url: null }
return { b64Json: null, url: null, urls: [] }
}
const first = data[0]
if (typeof first !== 'object' || first === null) {
return { b64Json: null, url: null }
const urls: string[] = []
let firstB64: string | null = null
for (const item of data) {
if (typeof item !== 'object' || item === null) continue
const rawUrl = (item as { url?: unknown }).url
const rawB64 = (item as { b64_json?: unknown }).b64_json
if (typeof rawUrl === 'string' && rawUrl.trim()) {
urls.push(rawUrl.trim())
}
if (firstB64 === null && typeof rawB64 === 'string' && rawB64.trim()) {
firstB64 = rawB64.trim()
}
}
const rawB64 = (first as { b64_json?: unknown }).b64_json
const rawUrl = (first as { url?: unknown }).url
return {
b64Json: typeof rawB64 === 'string' ? rawB64 : null,
url: typeof rawUrl === 'string' ? rawUrl : null,
b64Json: firstB64,
url: urls[0] ?? null,
urls,
}
}
@@ -161,7 +181,7 @@ export async function generateImageViaOpenAICompat(request: OpenAICompatImageReq
...(size ? { size } : {}),
} as unknown as Parameters<typeof client.images.edit>[0])
const imagePayload = readFirstImagePayload(response)
const imagePayload = readAllImagePayloads(response)
const imageBase64 = imagePayload.b64Json
if (typeof imageBase64 === 'string' && imageBase64.trim().length > 0) {
const mimeType = toMimeFromOutputFormat(outputFormat)
@@ -173,7 +193,11 @@ export async function generateImageViaOpenAICompat(request: OpenAICompatImageReq
}
const imageUrl = imagePayload.url
if (typeof imageUrl === 'string' && imageUrl.trim().length > 0) {
return { success: true, imageUrl }
return {
success: true,
imageUrl,
...(imagePayload.urls.length > 1 ? { imageUrls: imagePayload.urls } : {}),
}
}
throw new Error('OPENAI_COMPAT_IMAGE_EMPTY_RESPONSE: no image data returned')
}
@@ -187,7 +211,7 @@ export async function generateImageViaOpenAICompat(request: OpenAICompatImageReq
...(size ? { size } : {}),
} as unknown as Parameters<typeof client.images.generate>[0])
const imagePayload = readFirstImagePayload(response)
const imagePayload = readAllImagePayloads(response)
const imageBase64 = imagePayload.b64Json
if (typeof imageBase64 === 'string' && imageBase64.trim().length > 0) {
const mimeType = toMimeFromOutputFormat(outputFormat)
@@ -199,7 +223,11 @@ export async function generateImageViaOpenAICompat(request: OpenAICompatImageReq
}
const imageUrl = imagePayload.url
if (typeof imageUrl === 'string' && imageUrl.trim().length > 0) {
return { success: true, imageUrl }
return {
success: true,
imageUrl,
...(imagePayload.urls.length > 1 ? { imageUrls: imagePayload.urls } : {}),
}
}
throw new Error('OPENAI_COMPAT_IMAGE_EMPTY_RESPONSE: no image data returned')
}

View File

@@ -36,6 +36,23 @@ function resolveModelRef(request: OpenAICompatImageRequest): string {
throw new Error('OPENAI_COMPAT_IMAGE_MODEL_REF_REQUIRED')
}
function readTemplateOutputUrls(value: unknown): string[] {
if (!Array.isArray(value)) return []
const urls: string[] = []
for (const item of value) {
if (typeof item === 'string' && item.trim()) {
urls.push(item.trim())
continue
}
if (!item || typeof item !== 'object' || Array.isArray(item)) continue
const url = (item as { url?: unknown }).url
if (typeof url === 'string' && url.trim()) {
urls.push(url.trim())
}
}
return urls
}
export async function generateImageViaOpenAICompatTemplate(
request: OpenAICompatImageRequest,
): Promise<GenerateResult> {
@@ -82,6 +99,18 @@ export async function generateImageViaOpenAICompatTemplate(
}
if (request.template.mode === 'sync') {
const outputUrls = readTemplateOutputUrls(
readJsonPath(payload, request.template.response.outputUrlsPath),
)
if (outputUrls.length > 0) {
const first = outputUrls[0]
return {
success: true,
imageUrl: first,
...(outputUrls.length > 1 ? { imageUrls: outputUrls } : {}),
}
}
const outputUrl = readJsonPath(payload, request.template.response.outputUrlPath)
if (typeof outputUrl === 'string' && outputUrl.trim().length > 0) {
return {
@@ -89,25 +118,6 @@ export async function generateImageViaOpenAICompatTemplate(
imageUrl: outputUrl.trim(),
}
}
const outputUrls = readJsonPath(payload, request.template.response.outputUrlsPath)
if (Array.isArray(outputUrls) && outputUrls.length > 0) {
const first = outputUrls[0]
if (typeof first === 'string' && first.trim()) {
return {
success: true,
imageUrl: first.trim(),
}
}
if (first && typeof first === 'object' && !Array.isArray(first)) {
const firstUrl = (first as { url?: unknown }).url
if (typeof firstUrl === 'string' && firstUrl.trim()) {
return {
success: true,
imageUrl: firstUrl.trim(),
}
}
}
}
throw new Error('OPENAI_COMPAT_IMAGE_TEMPLATE_OUTPUT_NOT_FOUND')
}

View File

@@ -1,4 +1,4 @@
import { safeParseJsonArray } from '@/lib/json-repair'
import { safeParseJson, safeParseJsonArray } from '@/lib/json-repair'
import { prisma } from '@/lib/prisma'
import type { StoryboardPanel } from '@/lib/storyboard-phases'
@@ -52,6 +52,10 @@ function parsePanelCharacters(raw: string | null): string[] {
export function parseVoiceLinesJson(responseText: string): JsonRecord[] {
const rows = safeParseJsonArray(responseText)
if (rows.length === 0) {
const raw = safeParseJson(responseText)
if (Array.isArray(raw) && raw.length === 0) {
return []
}
throw new Error('voice_analyze: invalid payload')
}
return rows as JsonRecord[]

View File

@@ -590,14 +590,22 @@ export async function handleScriptToStoryboardTask(job: Job<TaskJobData>) {
const nextLineIndexes = voiceLineRows
.map((row) => (typeof row.lineIndex === 'number' && Number.isFinite(row.lineIndex) ? Math.floor(row.lineIndex) : -1))
.filter((value) => value > 0)
await voiceLineModel.deleteMany({
where: {
episodeId,
lineIndex: {
notIn: nextLineIndexes.length > 0 ? nextLineIndexes : [0],
if (nextLineIndexes.length === 0) {
await voiceLineModel.deleteMany({
where: {
episodeId,
},
},
})
})
} else {
await voiceLineModel.deleteMany({
where: {
episodeId,
lineIndex: {
notIn: nextLineIndexes,
},
},
})
}
return created
}, { timeout: 15000 })

View File

@@ -1,4 +1,4 @@
import { safeParseJsonArray } from '@/lib/json-repair'
import { safeParseJson, safeParseJsonArray } from '@/lib/json-repair'
export interface StoryboardPanelLike {
panelIndex: number
@@ -78,6 +78,10 @@ export function buildStoryboardJson(storyboards: StoryboardLike[]): string {
export function parseVoiceLinesJson(responseText: string): VoiceLinePayload[] {
const parsed = safeParseJsonArray(responseText)
if (parsed.length === 0) {
const raw = safeParseJson(responseText)
if (Array.isArray(raw) && raw.length === 0) {
return []
}
throw new Error('Invalid voice lines data structure')
}
const voiceLines = parsed

View File

@@ -308,14 +308,22 @@ export async function handleVoiceAnalyzeTask(job: Job<TaskJobData>) {
}
const incomingLineIndexes = new Set<number>(voiceLinesData.map((item) => item.lineIndex))
await voiceLineModel.deleteMany({
where: {
episodeId,
lineIndex: {
notIn: Array.from(incomingLineIndexes),
if (incomingLineIndexes.size === 0) {
await voiceLineModel.deleteMany({
where: {
episodeId,
},
},
})
})
} else {
await voiceLineModel.deleteMany({
where: {
episodeId,
lineIndex: {
notIn: Array.from(incomingLineIndexes),
},
},
})
}
return created
})

View File

@@ -276,6 +276,130 @@ export async function resolveImageSourceFromGeneration(
return polled.url
}
/**
* 多图版本:一次生成调用返回所有图片 URL 数组。
*
* - 接口返回多张result.imageUrls→ 返回完整列表
* - 接口只返回单张result.imageUrl / result.imageBase64→ 封装成 [url] 保持接口一致
* - 异步任务:轮询结果只有一个 URL封装成 [url]
*
* 现有代码请继续使用 resolveImageSourceFromGeneration取第一张
* 只有需要利用多图结果时才调用此函数。
*/
export async function resolveImageSourcesFromGeneration(
job: Job<TaskJobData>,
params: {
userId: string
modelId: string
prompt: string
options?: {
referenceImages?: string[]
aspectRatio?: string
resolution?: string
size?: string
provider?: string
}
allowTaskExternalIdResume?: boolean
pollProgress?: { start?: number; end?: number }
},
): Promise<string[]> {
const logger = scopedWorkerUtilLogger(job, 'worker.image.generate_sources')
const startedAt = Date.now()
const allowTaskExternalIdResume = params.allowTaskExternalIdResume !== false
// 服务重启续接:若 DB 中已有 externalId直接恢复轮询异步只有一张
if (allowTaskExternalIdResume) {
const resumeExternalId = await getTaskExistingExternalId(job.data.taskId)
if (resumeExternalId) {
logger.info({
message: 'image sources generation resumed from existing external id',
details: { externalId: resumeExternalId },
})
const polled = await waitExternalResult(job, resumeExternalId, params.userId, {
progressStart: params.pollProgress?.start ?? 40,
progressEnd: params.pollProgress?.end ?? 92,
})
return [polled.url]
}
}
logger.info({
message: 'image sources generation started',
provider: params.options?.provider || undefined,
details: { model: params.modelId },
})
const runtimeSelections: Record<string, string | number | boolean> = {}
if (typeof params.options?.resolution === 'string') {
runtimeSelections.resolution = params.options.resolution
}
const capabilityOptions = await resolveProjectModelCapabilityGenerationOptions({
projectId: job.data.projectId,
userId: params.userId,
modelType: 'image',
modelKey: params.modelId,
runtimeSelections,
})
const result = await withLogContext(
{ projectId: job.data.projectId, taskId: job.data.taskId, userId: params.userId },
() => generateImage(params.userId, params.modelId, params.prompt, {
...params.options,
...capabilityOptions,
}),
)
if (!result.success) {
throw new Error(result.error || 'Image generation failed')
}
// 优先使用多图列表
if (result.imageUrls && result.imageUrls.length > 0) {
logger.info({
message: 'image sources generation completed (multi-image)',
provider: params.options?.provider || undefined,
durationMs: Date.now() - startedAt,
details: { count: result.imageUrls.length },
})
return result.imageUrls
}
if (result.imageUrl) {
logger.info({
message: 'image sources generation completed (single url)',
provider: params.options?.provider || undefined,
durationMs: Date.now() - startedAt,
})
return [result.imageUrl]
}
if (result.imageBase64) {
logger.info({
message: 'image sources generation completed (base64)',
provider: params.options?.provider || undefined,
durationMs: Date.now() - startedAt,
})
return [`data:image/png;base64,${result.imageBase64}`]
}
const externalId = normalizeExternalId(result, 'IMAGE')
if (!externalId) {
throw new Error('Image generation returned no image and no external id')
}
const polled = await waitExternalResult(job, externalId, params.userId, {
progressStart: params.pollProgress?.start ?? 40,
progressEnd: params.pollProgress?.end ?? 92,
})
logger.info({
message: 'image sources generation completed (async)',
provider: params.options?.provider || undefined,
durationMs: Date.now() - startedAt,
details: { externalId },
})
return [polled.url]
}
export async function resolveVideoSourceFromGeneration(
job: Job<TaskJobData>,
params: {