feat: Enhance narrator description and instructions in LLMService and Audiobook components

This commit is contained in:
2026-03-12 15:42:53 +08:00
parent 475df0c9ca
commit a1ee476e0f
3 changed files with 89 additions and 55 deletions

View File

@@ -225,8 +225,16 @@ async def analyze_project(project_id: int, user: User, db: Session, turbo: bool
if not has_narrator:
characters_data.insert(0, {
"name": "narrator",
"description": "旁白叙述者",
"instruct": "中性声音,语速平稳,叙述感强"
"gender": "未知",
"description": "第三人称旁白叙述者",
"instruct": (
"音色信息:浑厚醇厚的男性中低音,嗓音饱满有力,带有传统说书人的磁性与感染力\n"
"身份背景:中国传统说书艺人,精通评书、章回小说叙述艺术,深谙故事节奏与听众心理\n"
"年龄设定:中年男性,四五十岁,声音历经岁月沉淀,成熟稳重而不失活力\n"
"外貌特征:面容沉稳,气度从容,台风大气,给人以可信赖的叙述者印象\n"
"性格特质:沉稳睿智,叙事冷静客观,情到深处能引发共鸣,不动声色间娓娓道来\n"
"叙事风格:语速适中偏慢,抑扬顿挫,擅长铺垫悬念,停顿恰到好处,语气庄重而生动,富有画面感"
)
})
ps.append_line(key, f"\n\n[完成] 发现 {len(characters_data)} 个角色:{', '.join(c.get('name', '') for c in characters_data)}")

View File

@@ -131,6 +131,13 @@ class LLMService:
"5. 性格特质:核心性格、情绪模式、表达习惯\n"
"6. 叙事风格:语速节奏、停顿习惯、语气色彩、整体叙述感\n\n"
"注意instruct 的第一行(音色信息)必须与 gender 字段保持一致。如果 gender 为女,第一行绝对不能出现'男性'字样。\n\n"
"【特别规定】narrator旁白的 instruct 必须固定描述为传统说书人风格,参考如下模板(根据书籍风格可微调措辞,但风格不变):\n"
"音色信息:浑厚醇厚的男性中低音,嗓音饱满有力,带有传统说书人的磁性与感染力\n"
"身份背景:中国传统说书艺人,精通评书、章回小说叙述艺术,深谙故事节奏与听众心理\n"
"年龄设定:中年男性,四五十岁,声音历经岁月沉淀,成熟稳重而不失活力\n"
"外貌特征:面容沉稳,气度从容,台风大气,给人以可信赖的叙述者印象\n"
"性格特质:沉稳睿智,叙事冷静客观,情到深处能引发共鸣,不动声色间娓娓道来\n"
"叙事风格:语速适中偏慢,抑扬顿挫,擅长铺垫悬念,停顿恰到好处,语气庄重而生动,富有画面感\n\n"
"只输出JSON格式如下不要有其他文字\n"
'{"characters": [{"name": "narrator", "gender": "未知", "description": "第三人称叙述者", "instruct": "音色信息:...\\n身份背景...\\n年龄设定...\\n外貌特征...\\n性格特质...\\n叙事风格..."}, ...]}'
)

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Book, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X, Loader2, Zap, Settings2 } from 'lucide-react'
import { Book, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X, Loader2, Zap, Settings2, PanelLeftClose, PanelLeftOpen } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
@@ -334,6 +334,8 @@ function ProjectListSidebar({
onNew,
onLLM,
loading,
collapsed,
onToggle,
}: {
projects: AudiobookProject[]
selectedId: number | null
@@ -341,41 +343,55 @@ function ProjectListSidebar({
onNew: () => void
onLLM: () => void
loading: boolean
collapsed: boolean
onToggle: () => void
}) {
const { t } = useTranslation('audiobook')
return (
<div className="w-60 shrink-0 flex flex-col border-r overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b shrink-0">
<span className="text-sm font-semibold">{t('title')}</span>
<div className="flex items-center gap-0.5">
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onLLM} title={t('llmConfig')}>
<Settings2 className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onNew} title={t('newProject')}>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="px-3 py-4 text-xs text-muted-foreground">{t('loading')}</div>
) : projects.length === 0 ? (
<div className="px-3 py-4 text-xs text-muted-foreground">{t('noProjects')}</div>
) : (
projects.map(p => (
<button
key={p.id}
onClick={() => onSelect(p.id)}
className={`w-full text-left px-3 py-2 flex flex-col gap-0.5 hover:bg-muted/50 transition-colors border-b border-border/40 ${selectedId === p.id ? 'bg-muted' : ''}`}
>
<span className="text-sm font-medium truncate">{p.title}</span>
<Badge variant={(STATUS_COLORS[p.status] || 'secondary') as any} className="text-[10px] h-4 px-1 self-start">
{t(`status.${p.status}`, { defaultValue: p.status })}
</Badge>
</button>
))
<div className={`${collapsed ? 'w-10' : 'w-60'} shrink-0 flex flex-col bg-muted/30 overflow-hidden transition-all duration-200`}>
<div className="h-16 flex items-center shrink-0 px-2 gap-1">
{!collapsed && (
<div className="flex items-center gap-2 flex-1 min-w-0 ml-1">
<img src="/qwen.svg" alt="Qwen" className="h-5 w-5 shrink-0" />
<span className="text-sm font-semibold truncate">{t('title')}</span>
</div>
)}
<Button size="icon" variant="ghost" className="h-8 w-8 shrink-0" onClick={onToggle}>
{collapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
</Button>
</div>
{!collapsed && (
<>
<div className="flex items-center justify-end px-2 pb-1 gap-0.5 shrink-0">
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onLLM} title={t('llmConfig')}>
<Settings2 className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onNew} title={t('newProject')}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="px-3 py-4 text-xs text-muted-foreground">{t('loading')}</div>
) : projects.length === 0 ? (
<div className="px-3 py-4 text-xs text-muted-foreground">{t('noProjects')}</div>
) : (
projects.map(p => (
<button
key={p.id}
onClick={() => onSelect(p.id)}
className={`w-full text-left px-3 py-2 flex flex-col gap-0.5 hover:bg-muted/50 transition-colors border-b border-border/40 ${selectedId === p.id ? 'bg-muted' : ''}`}
>
<span className="text-sm font-medium truncate">{p.title}</span>
<Badge variant={(STATUS_COLORS[p.status] || 'secondary') as any} className="text-[10px] h-4 px-1 self-start">
{t(`status.${p.status}`, { defaultValue: p.status })}
</Badge>
</button>
))
)}
</div>
</>
)}
</div>
)
}
@@ -465,8 +481,8 @@ function CharactersPanel({
const charCount = detail?.characters.length ?? 0
return (
<div className="w-72 shrink-0 flex flex-col border-r overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b shrink-0">
<div className="w-72 shrink-0 flex flex-col border-r border-blue-500/20 bg-blue-500/5 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b border-blue-500/20 shrink-0">
<span className="text-xs font-medium text-blue-400/80">
{t('projectCard.characters.title', { count: charCount })}
</span>
@@ -659,8 +675,8 @@ function ChaptersPanel({
const hasChapters = detail && detail.chapters.length > 0 && ['ready', 'generating', 'done'].includes(status)
return (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between px-3 py-2 border-b shrink-0">
<div className="flex-1 flex flex-col bg-emerald-500/5 overflow-hidden">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between px-3 py-2 border-b border-emerald-500/20 shrink-0">
<span className="text-xs font-medium text-emerald-400/80">
{t('projectCard.chapters.title', { count: detail?.chapters.length ?? 0 })}
</span>
@@ -818,6 +834,7 @@ export default function Audiobook() {
const [sequentialPlayingId, setSequentialPlayingId] = useState<number | null>(null)
const [showCreate, setShowCreate] = useState(false)
const [showLLM, setShowLLM] = useState(false)
const [sidebarOpen, setSidebarOpen] = useState(true)
const prevStatusRef = useRef<string>('')
const autoExpandedRef = useRef(new Set<string>())
@@ -1145,24 +1162,26 @@ export default function Audiobook() {
const isTurboMode = ['analyzing', 'parsing', 'processing'].includes(displayStatus)
return (
<div className="h-screen flex flex-col overflow-hidden">
<Navbar />
<div className="flex-1 flex overflow-hidden">
<ProjectListSidebar
projects={projects}
selectedId={selectedProjectId}
onSelect={(id) => {
if (id !== selectedProjectId) {
setSelectedProjectId(id)
setIsPolling(false)
setGeneratingChapterIndices(new Set())
}
}}
onNew={() => { setShowCreate(v => !v); setShowLLM(false) }}
onLLM={() => { setShowLLM(v => !v); setShowCreate(false) }}
loading={loading}
/>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="h-screen overflow-hidden flex bg-background">
<ProjectListSidebar
projects={projects}
selectedId={selectedProjectId}
onSelect={(id) => {
if (id !== selectedProjectId) {
setSelectedProjectId(id)
setIsPolling(false)
setGeneratingChapterIndices(new Set())
}
}}
onNew={() => { setShowCreate(v => !v); setShowLLM(false) }}
onLLM={() => { setShowLLM(v => !v); setShowCreate(false) }}
loading={loading}
collapsed={!sidebarOpen}
onToggle={() => setSidebarOpen(v => !v)}
/>
<div className="flex-1 flex flex-col overflow-hidden bg-muted/30">
<Navbar />
<div className="flex-1 flex flex-col overflow-hidden bg-background rounded-tl-2xl">
{showLLM && (
<div className="shrink-0 border-b px-4 py-3">
<LLMConfigPanel onSaved={() => setShowLLM(false)} />