feat: 大小标题支持智能生成

后端:
- 新增 POST /script/generate-title API
- 新增提示词模板 title_system.txt / title.txt(文件管理)
- 根据脚本内容调用 LLM 生成大标题(≤8字)/小标题(≤30字)

前端:
- 大标题/小标题输入框右侧新增【智能生成】按钮
- 点击后根据 utterances 拼接脚本内容调用 API
- 添加 title-input-row / title-generate-btn CSS 样式
This commit is contained in:
小鱼开发
2026-04-30 12:09:56 +08:00
parent de0fb0949c
commit 475758beed
7 changed files with 218 additions and 17 deletions
+12
View File
@@ -0,0 +1,12 @@
请根据以下脚本内容,创作一个${title_type_desc}。
脚本内容:
${script_content}
要求:
- 类型:${title_type_desc}
- 字数限制:严格控制在 ${max_length} 个字以内(含标点)
- 风格:短视频平台风格,吸引眼球,口语化,避免生僻字
- 大标题需提炼核心卖点,小标题需补充说明或制造悬念
直接返回标题文字,不要加引号、书名号或任何额外说明。
@@ -0,0 +1,9 @@
你是一位专业的短视频标题创作专家,擅长为口播类短视频创作吸引眼球的标题。
创作原则:
1. 标题要紧扣脚本核心内容,提炼最有吸引力的点
2. 语言口语化,符合短视频平台(抖音、快手)风格
3. 善用数字、疑问、对比等手法增强吸引力
4. 严格控制字数,不超过用户指定的限制
5. 直接返回标题文字,不要加引号、书名号或其他装饰符号
6. 不要返回任何解释、说明或JSON格式
+64 -1
View File
@@ -15,10 +15,13 @@ from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse
from app.schemas.common import ApiResponse, success_response
from app.ai.prompts import list_categories
from app.ai.model_router import get_model_router
from app.ai.prompts import list_categories, load_prompt, render_template
from app.schemas.script import (
CategoryItem,
GenerateScriptRequest,
GenerateTitleRequest,
GenerateTitleResponse,
ModelHealthResponse,
PolishRequest,
ScriptGenerationEvent,
@@ -202,3 +205,63 @@ async def test_model(request: TestModelRequest):
data=TestModelResponse(**result),
message="模型测试完成" if result["success"] else f"模型测试失败: {result.get('error')}",
)
@router.post("/generate-title", response_model=ApiResponse[GenerateTitleResponse])
async def generate_title(request: GenerateTitleRequest):
"""
根据脚本内容智能生成标题
调用 LLM 根据脚本内容生成大标题或小标题。
提示词从文件加载,支持热更新。
"""
model_router = await get_model_router()
# 加载提示词
system_prompt = load_prompt("user/title_system")
user_template = load_prompt("user/title")
if not system_prompt or not user_template:
return success_response(
code=500,
message="标题生成提示词文件缺失",
data=None,
)
# 渲染用户提示词
title_type_desc = "大标题(主标题,提炼核心卖点,吸睛)" if request.title_type == "main" else "小标题(副标题,补充说明或制造悬念)"
user_prompt = render_template(
user_template,
title_type=request.title_type,
title_type_desc=title_type_desc,
script_content=request.script_content,
max_length=request.max_length,
)
try:
result = await model_router.generate(
prompt=user_prompt,
system_prompt=system_prompt,
task_type="script",
temperature=0.8,
max_tokens=64,
)
title = result.content.strip() if result.content else ""
# 去除可能的引号
title = title.strip('"').strip("'").strip('「」').strip('『』').strip('《》')
# 截断到最大长度
if len(title) > request.max_length:
title = title[:request.max_length]
return success_response(
data=GenerateTitleResponse(title=title),
message="标题生成成功",
)
except Exception as e:
logger.error(f"[generate_title] 标题生成失败: {e}")
return success_response(
code=500,
message=f"标题生成失败: {str(e)}",
data=None,
)
+14
View File
@@ -109,3 +109,17 @@ class TestModelResponse(BaseModel):
response_time: float | None = Field(None, description="响应时间(毫秒)")
error: str | None = Field(None, description="错误信息")
checked_at: str | None = Field(None, description="检查时间 ISO 格式")
class GenerateTitleRequest(BaseModel):
"""生成标题请求"""
script_content: str = Field(..., description="脚本内容(utterances 文本拼接)", min_length=1)
title_type: str = Field(..., description="标题类型:main(大标题) / sub(小标题)")
max_length: int = Field(default=8, ge=1, le=100, description="最大字数限制")
class GenerateTitleResponse(BaseModel):
"""生成标题响应"""
title: str = Field(..., description="生成的标题")
+30
View File
@@ -65,6 +65,22 @@ export interface ModelHealthResponse {
error?: string;
}
/**
* 生成标题请求参数
*/
export interface GenerateTitleParams {
scriptContent: string;
titleType: 'main' | 'sub';
maxLength?: number;
}
/**
* 生成标题响应
*/
export interface GenerateTitleResponse {
title: string;
}
/**
* 测试模型请求
*/
@@ -175,6 +191,20 @@ export const scriptApi = {
});
},
/**
* 智能生成标题
* POST /script/generate-title
*
* 根据脚本内容生成大标题或小标题
*/
generateTitle: async (params: GenerateTitleParams): Promise<GenerateTitleResponse> => {
return client.post<GenerateTitleResponse>('/script/generate-title', {
script_content: params.scriptContent,
title_type: params.titleType,
max_length: params.maxLength,
});
},
/**
* AI 润色脚本
* POST /script/polish
@@ -340,6 +340,17 @@
border-bottom: none;
}
/* 标题输入框行(输入框 + 生成按钮) */
.title-input-row {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
.title-input-row .title-input {
flex: 1;
}
/* 标题输入框 */
.title-input {
width: 100%;
@@ -361,6 +372,15 @@
color: var(--text-tertiary);
}
/* 智能生成按钮 */
.title-generate-btn {
white-space: nowrap;
font-size: var(--font-xs);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-md);
flex-shrink: 0;
}
/* 压制字幕按钮 - 固定在底部 */
.burn-btn {
width: 100%;
@@ -11,6 +11,7 @@ import { invoke } from '@tauri-apps/api/core';
import { homeDir } from '@tauri-apps/api/path';
import { useProjectStore, saveMetaToLocalFile } from '../../store';
import { getCurrentProjectId } from '../../api/modules/localStorage';
import { scriptApi } from '../../api/modules/script';
import { useLocalVideo } from '../../hooks/useLocalVideo';
import { useCanvasSubtitleRenderer } from '../../hooks/useCanvasSubtitleRenderer';
@@ -79,6 +80,7 @@ export default function SubtitleBurning() {
const alignment = useProjectStore(state => state.subtitleAlignment);
const [isBurning, setIsBurning] = useState(false);
const [previewMode, setPreviewMode] = useState<'style' | 'result'>('style');
const [isGeneratingTitle, setIsGeneratingTitle] = useState(false);
// 视频播放相关
const videoRef = useRef<HTMLVideoElement>(null);
@@ -111,6 +113,37 @@ export default function SubtitleBurning() {
useProjectStore.setState({ subTitle: value });
saveMetaToLocalFile({ subTitle: value });
};
// 智能生成标题
const handleGenerateTitle = async (titleType: 'main' | 'sub') => {
const utterances = alignment?.utterances;
if (!utterances || utterances.length === 0) {
toast.error('暂无脚本内容,请先生成脚本');
return;
}
const scriptContent = utterances.map(u => u.text).join('\n');
const maxLength = titleType === 'main' ? 8 : 30;
setIsGeneratingTitle(true);
try {
const res = await scriptApi.generateTitle({
scriptContent,
titleType,
maxLength,
});
if (titleType === 'main') {
setMainTitle(res.title);
} else {
setSubTitle(res.title);
}
toast.success(`${titleType === 'main' ? '大标题' : '小标题'}生成成功`);
} catch (e) {
const msg = e instanceof Error ? e.message : '生成失败';
toast.error(msg);
} finally {
setIsGeneratingTitle(false);
}
};
const setMainTitlePreset = (value: string) => {
useProjectStore.setState({ mainTitlePreset: value });
saveMetaToLocalFile({ mainTitlePreset: value });
@@ -318,14 +351,24 @@ export default function SubtitleBurning() {
{/* 大标题设置 */}
<div className="style-section">
<label className="panel-label"></label>
<input
type="text"
className="title-input"
placeholder="输入大标题"
value={mainTitle}
onChange={(e) => setMainTitle(e.target.value)}
maxLength={8}
/>
<div className="title-input-row">
<input
type="text"
className="title-input"
placeholder="输入大标题"
value={mainTitle}
onChange={(e) => setMainTitle(e.target.value)}
maxLength={8}
/>
<button
className="btn btn-ghost title-generate-btn"
onClick={() => handleGenerateTitle('main')}
disabled={isGeneratingTitle}
title="根据脚本内容智能生成大标题"
>
{isGeneratingTitle ? '生成中...' : '智能生成'}
</button>
</div>
<div className="style-presets main-title-presets">
{MAIN_TITLE_PRESETS.map(preset => (
<button
@@ -357,14 +400,24 @@ export default function SubtitleBurning() {
{/* 小标题设置 */}
<div className="style-section">
<label className="panel-label"></label>
<input
type="text"
className="title-input"
placeholder="输入小标题"
value={subTitle}
onChange={(e) => setSubTitle(e.target.value)}
maxLength={30}
/>
<div className="title-input-row">
<input
type="text"
className="title-input"
placeholder="输入小标题"
value={subTitle}
onChange={(e) => setSubTitle(e.target.value)}
maxLength={30}
/>
<button
className="btn btn-ghost title-generate-btn"
onClick={() => handleGenerateTitle('sub')}
disabled={isGeneratingTitle}
title="根据脚本内容智能生成小标题"
>
{isGeneratingTitle ? '生成中...' : '智能生成'}
</button>
</div>
<div className="style-presets sub-title-presets">
{SUB_TITLE_PRESETS.map(preset => (
<button