功能: 新增字幕任务专用高效模型支持

新增高推理模型和高效模型的中英文国际化配置字符串
新增高效文本模型配置项,支持空值时自动回退到高推理模型
添加resolve_text_model_name工具函数用于根据偏好解析对应文本模型
更新字幕校对和翻译服务,默认使用高效模型处理批量字幕任务
优化OpenAI兼容提供商的模型参数处理逻辑,支持通过参数覆盖模型名称
更新WebUI基础设置页面,新增高效模型配置项并支持测试两个模型的连接
同步更新示例配置文件、默认配置项与相关测试用例
This commit is contained in:
linyq-laien 2026-07-19 14:53:06 +08:00
parent 022b8bbea3
commit 0a5dcf5f21
13 changed files with 194 additions and 23 deletions

View File

@ -10,6 +10,7 @@ DEFAULT_VISION_OPENAI_MODEL_NAME = "Qwen/Qwen3.5-122B-A10B"
DEFAULT_TEXT_LLM_PROVIDER = DEFAULT_OPENAI_COMPATIBLE_PROVIDER
DEFAULT_TEXT_OPENAI_MODEL_NAME = "Pro/zai-org/GLM-5"
DEFAULT_TEXT_OPENAI_FAST_MODEL_NAME = ""
DEFAULT_LLM_GENERATION_CONFIG = {
"temperature": 1.0,
@ -33,6 +34,7 @@ DEFAULT_LLM_APP_CONFIG = {
"vision_openai_base_url": DEFAULT_OPENAI_COMPATIBLE_BASE_URL,
"text_llm_provider": DEFAULT_TEXT_LLM_PROVIDER,
"text_openai_model_name": DEFAULT_TEXT_OPENAI_MODEL_NAME,
"text_openai_fast_model_name": DEFAULT_TEXT_OPENAI_FAST_MODEL_NAME,
"text_openai_api_key": "",
"text_openai_base_url": DEFAULT_OPENAI_COMPATIBLE_BASE_URL,
"tavily_api_key": "",
@ -69,6 +71,31 @@ def normalize_openai_compatible_model_name(
return normalized
def resolve_text_model_name(
app_config: dict,
provider: str = DEFAULT_OPENAI_COMPATIBLE_PROVIDER,
*,
prefer_fast: bool = False,
) -> str:
"""Resolve the configured reasoning or fast text model with legacy fallback."""
provider = (provider or DEFAULT_OPENAI_COMPATIBLE_PROVIDER).strip().lower()
reasoning_model = normalize_openai_compatible_model_name(
str(app_config.get(f"text_{provider}_model_name") or ""),
provider=provider,
)
if not reasoning_model and provider == DEFAULT_OPENAI_COMPATIBLE_PROVIDER:
reasoning_model = DEFAULT_TEXT_OPENAI_MODEL_NAME
if not prefer_fast:
return reasoning_model
fast_model = normalize_openai_compatible_model_name(
str(app_config.get(f"text_{provider}_fast_model_name") or ""),
provider=provider,
)
return fast_model or reasoning_model
def get_openai_compatible_ui_values(
full_model_name: str,
default_model: str,

View File

@ -12,6 +12,7 @@ from app.config import config as cfg
from app.config.defaults import (
get_openai_compatible_ui_values,
normalize_openai_compatible_model_name,
resolve_text_model_name,
)
@ -82,11 +83,13 @@ hide_config = true
self.assertEqual(0.95, config_data["app"]["vision_openai_top_p"])
self.assertEqual("openai", config_data["app"]["text_llm_provider"])
self.assertEqual("Pro/zai-org/GLM-5", config_data["app"]["text_openai_model_name"])
self.assertEqual("", config_data["app"]["text_openai_fast_model_name"])
self.assertEqual("https://api.siliconflow.cn/v1", config_data["app"]["text_openai_base_url"])
self.assertEqual(1.0, config_data["app"]["text_openai_temperature"])
self.assertEqual(0.95, config_data["app"]["text_openai_top_p"])
self.assertEqual("Qwen/Qwen3.5-122B-A10B", saved_config["app"]["vision_openai_model_name"])
self.assertEqual("Pro/zai-org/GLM-5", saved_config["app"]["text_openai_model_name"])
self.assertEqual("", saved_config["app"]["text_openai_fast_model_name"])
self.assertTrue(saved_config["app"]["hide_config"])
def test_legacy_indextts2_config_is_migrated_to_indextts_15(self):
@ -127,6 +130,23 @@ hide_config = true
class OpenAICompatibleModelDefaultsTests(unittest.TestCase):
def test_fast_text_model_falls_back_to_reasoning_model(self):
app_config = {
"text_openai_model_name": "reasoning-model",
"text_openai_fast_model_name": "",
}
self.assertEqual(
"reasoning-model",
resolve_text_model_name(app_config, "openai", prefer_fast=True),
)
app_config["text_openai_fast_model_name"] = "fast-model"
self.assertEqual(
"fast-model",
resolve_text_model_name(app_config, "openai", prefer_fast=True),
)
def test_ui_keeps_full_model_name_and_openai_provider(self):
provider, model_name = get_openai_compatible_ui_values(
"Qwen/Qwen3.5-122B-A10B",

View File

@ -164,11 +164,13 @@ class LLMConfigValidator:
config_prefix = f"text_{provider_name}"
api_key = config.app.get(f'{config_prefix}_api_key')
model_name = config.app.get(f'{config_prefix}_model_name')
fast_model_name = config.app.get(f'{config_prefix}_fast_model_name')
base_url = config.app.get(f'{config_prefix}_base_url')
result["config"] = {
"api_key": "***" if api_key else None,
"model_name": model_name,
"fast_model_name": fast_model_name,
"base_url": base_url
}
@ -241,7 +243,8 @@ class LLMConfigValidator:
f"text_{provider}_model_name"
],
"optional_configs": [
f"text_{provider}_base_url"
f"text_{provider}_base_url",
f"text_{provider}_fast_model_name",
],
"example_models": LLMConfigValidator._get_example_models(provider, "text")
}

View File

@ -258,8 +258,9 @@ class OpenAICompatibleTextProvider(_OpenAICompatibleBase, TextModelProvider):
response_format: Optional[str],
kwargs: Dict[str, Any],
) -> Dict[str, Any]:
model_name = _normalize_model_name(self.model_name)
generation_kwargs = dict(kwargs)
model_override = generation_kwargs.pop("model", None) or generation_kwargs.pop("model_name", None)
model_name = _normalize_model_name(model_override or self.model_name)
temperature_override = generation_kwargs.pop("temperature", None)
if temperature_override is None and temperature != 1.0:
temperature_override = temperature

View File

@ -149,6 +149,20 @@ class OpenAICompatGenerationOptionTests(unittest.TestCase):
self.assertEqual(65536, options["max_tokens"])
self.assertNotIn("extra_body", options)
def test_text_request_can_override_model_for_fast_tasks(self):
provider = OpenAICompatibleTextProvider(api_key="k", model_name="reasoning-model")
options = provider._build_text_completion_kwargs(
messages=[{"role": "user", "content": "hello"}],
temperature=0.2,
max_tokens=None,
response_format=None,
kwargs={"model": "fast-model", "thinking_level": "off"},
)
self.assertEqual("fast-model", options["model"])
self.assertNotIn("extra_body", options)
def test_build_options_uses_per_model_generation_config(self):
provider = OpenAICompatibleTextProvider(api_key="k", model_name="m")
config.app.update(

View File

@ -10,6 +10,8 @@ from typing import Any
from loguru import logger
from app.config import config
from app.config.defaults import resolve_text_model_name
from app.services.llm.manager import LLMServiceManager
from app.services.llm.migration_adapter import _run_async_safely
from app.services.llm.unified_service import UnifiedLLMService
@ -174,12 +176,16 @@ def correct_srt_content(
provider: str = "",
api_key: str = "",
base_url: str = "",
model_name: str = "",
temperature: float = 0.1,
) -> str:
blocks = parse_srt_blocks(srt_content)
_ensure_llm_providers_registered()
logger.info(f"开始校准字幕,共 {len(blocks)}")
resolved_model_name = str(
model_name or resolve_text_model_name(config.app, provider, prefer_fast=True)
).strip()
logger.info(f"开始使用高效率模型 {resolved_model_name} 校准字幕,共 {len(blocks)}")
prompt = _build_correction_prompt(blocks)
raw_output = _run_async_safely(
UnifiedLLMService.generate_text,
@ -190,6 +196,8 @@ def correct_srt_content(
response_format="json",
api_key=api_key,
api_base=base_url,
model=resolved_model_name,
thinking_level="off",
)
corrections = _parse_corrections(raw_output, {block.order for block in blocks})
corrected_srt = _render_srt(blocks, corrections)
@ -215,6 +223,7 @@ def correct_subtitle_file(
provider: str = "",
api_key: str = "",
base_url: str = "",
model_name: str = "",
temperature: float = 0.1,
) -> str:
if not subtitle_file or not os.path.isfile(subtitle_file):
@ -226,6 +235,7 @@ def correct_subtitle_file(
provider=provider,
api_key=api_key,
base_url=base_url,
model_name=model_name,
temperature=temperature,
)
return write_srt_file(corrected_srt, output_file)

View File

@ -11,6 +11,7 @@ from typing import Any, Callable
from loguru import logger
from app.config import config
from app.config.defaults import resolve_text_model_name
from app.services.llm.migration_adapter import _run_async_safely
from app.services.llm.unified_service import UnifiedLLMService
from app.services.subtitle_corrector import (
@ -151,6 +152,7 @@ def _translate_chunk(
provider: str,
api_key: str,
base_url: str,
model_name: str,
temperature: float,
max_repair_attempts: int,
) -> dict[int, str]:
@ -189,6 +191,8 @@ def _translate_chunk(
response_format="json",
api_key=api_key,
api_base=base_url,
model=model_name,
thinking_level="off",
)
last_output = str(raw_output or "")
try:
@ -243,6 +247,7 @@ def translate_srt_content(
provider: str = "",
api_key: str = "",
base_url: str = "",
model_name: str = "",
temperature: float = 0.2,
batch_size: int | None = None,
max_workers: int | None = None,
@ -251,6 +256,9 @@ def translate_srt_content(
target_language = str(target_language or "").strip() or "中文"
blocks = parse_srt_blocks(srt_content)
_ensure_llm_providers_registered()
resolved_model_name = str(
model_name or resolve_text_model_name(config.app, provider, prefer_fast=True)
).strip()
resolved_batch_size = _resolve_batch_size(batch_size)
chunks = _split_blocks(blocks, resolved_batch_size)
@ -260,7 +268,8 @@ def translate_srt_content(
logger.info(
f"开始批量翻译字幕: 共 {total_blocks} 条, {total_chunks} 批, "
f"每批最多 {resolved_batch_size} 条, 并发 {resolved_max_workers}, 目标语言: {target_language}"
f"每批最多 {resolved_batch_size} 条, 并发 {resolved_max_workers}, "
f"目标语言: {target_language}, 高效率模型: {resolved_model_name}"
)
translations: dict[int, str] = {}
@ -282,6 +291,7 @@ def translate_srt_content(
provider=provider,
api_key=api_key,
base_url=base_url,
model_name=resolved_model_name,
temperature=temperature,
max_repair_attempts=DEFAULT_MAX_REPAIR_ATTEMPTS,
)
@ -301,6 +311,7 @@ def translate_srt_content(
provider=provider,
api_key=api_key,
base_url=base_url,
model_name=resolved_model_name,
temperature=temperature,
max_repair_attempts=DEFAULT_MAX_REPAIR_ATTEMPTS,
)
@ -347,6 +358,7 @@ def translate_subtitle_file(
provider: str = "",
api_key: str = "",
base_url: str = "",
model_name: str = "",
temperature: float = 0.2,
batch_size: int | None = None,
max_workers: int | None = None,
@ -362,6 +374,7 @@ def translate_subtitle_file(
provider=provider,
api_key=api_key,
base_url=base_url,
model_name=model_name,
temperature=temperature,
batch_size=batch_size,
max_workers=max_workers,

View File

@ -27,6 +27,14 @@ class SubtitleCorrectorTests(unittest.TestCase):
}
with (
mock.patch.dict(
corrector.config.app,
{
"text_openai_model_name": "reasoning-model",
"text_openai_fast_model_name": "fast-subtitle-model",
},
clear=False,
),
mock.patch("app.services.subtitle_corrector._ensure_llm_providers_registered"),
mock.patch(
"app.services.subtitle_corrector._run_async_safely",
@ -49,6 +57,8 @@ class SubtitleCorrectorTests(unittest.TestCase):
self.assertEqual("openai", call_kwargs["provider"])
self.assertEqual("sk-test", call_kwargs["api_key"])
self.assertEqual("https://llm.example/v1", call_kwargs["api_base"])
self.assertEqual("fast-subtitle-model", call_kwargs["model"])
self.assertEqual("off", call_kwargs["thinking_level"])
self.assertEqual("json", call_kwargs["response_format"])
self.assertIn("多语言字幕校对员", call_kwargs["system_prompt"])
self.assertIn("保持原语言", call_kwargs["prompt"])

View File

@ -48,6 +48,14 @@ class SubtitleTranslatorTests(unittest.TestCase):
}
with (
mock.patch.dict(
translator.config.app,
{
"text_openai_model_name": "reasoning-model",
"text_openai_fast_model_name": "fast-subtitle-model",
},
clear=False,
),
mock.patch("app.services.subtitle_translator._ensure_llm_providers_registered"),
mock.patch(
"app.services.subtitle_translator._run_async_safely",
@ -71,6 +79,8 @@ class SubtitleTranslatorTests(unittest.TestCase):
self.assertEqual("openai", call_kwargs["provider"])
self.assertEqual("sk-test", call_kwargs["api_key"])
self.assertEqual("https://llm.example/v1", call_kwargs["api_base"])
self.assertEqual("fast-subtitle-model", call_kwargs["model"])
self.assertEqual("off", call_kwargs["thinking_level"])
self.assertEqual("json", call_kwargs["response_format"])
self.assertIn("专业字幕翻译员", call_kwargs["system_prompt"])
self.assertIn("翻译为中文", call_kwargs["prompt"])

View File

@ -53,7 +53,8 @@
# - Qwen: qwen/qwen-plus, qwen/qwen-turbo
# - SiliconFlow: siliconflow/deepseek-ai/DeepSeek-R1
# - Moonshot: moonshot/moonshot-v1-8k
text_openai_model_name = "Pro/zai-org/GLM-5"
text_openai_model_name = "Pro/zai-org/GLM-5" # 高推理模型:剧情分析、文案生成、脚本匹配
text_openai_fast_model_name = "" # 高效率模型:字幕翻译、字幕校准;留空时回退到高推理模型
text_openai_api_key = "" # 填入对应 provider 的 API key
text_openai_base_url = "https://api.siliconflow.cn/v1" # 可选:自定义 API base URL界面会提示 API key 将发送到对应端点
text_openai_temperature = 1.0

View File

@ -9,6 +9,7 @@ from app.config.defaults import (
DEFAULT_OPENAI_COMPATIBLE_BASE_URL,
DEFAULT_OPENAI_COMPATIBLE_PROVIDER,
DEFAULT_TEXT_LLM_PROVIDER,
DEFAULT_TEXT_OPENAI_FAST_MODEL_NAME,
DEFAULT_TEXT_OPENAI_MODEL_NAME,
DEFAULT_VISION_LLM_PROVIDER,
DEFAULT_VISION_OPENAI_MODEL_NAME,
@ -876,6 +877,10 @@ def render_text_llm_settings(tr):
# 获取已保存的配置
full_text_model_name = config.app.get("text_openai_model_name") or DEFAULT_TEXT_OPENAI_MODEL_NAME
full_fast_model_name = (
config.app.get("text_openai_fast_model_name")
or DEFAULT_TEXT_OPENAI_FAST_MODEL_NAME
)
text_api_key = config.app.get("text_openai_api_key", "")
text_base_url = config.app.get("text_openai_base_url", DEFAULT_OPENAI_COMPATIBLE_BASE_URL)
@ -885,10 +890,14 @@ def render_text_llm_settings(tr):
DEFAULT_TEXT_OPENAI_MODEL_NAME,
provider=DEFAULT_TEXT_LLM_PROVIDER,
)
current_fast_model = normalize_openai_compatible_model_id(
full_fast_model_name,
provider=DEFAULT_TEXT_LLM_PROVIDER,
)
selected_provider = DEFAULT_TEXT_LLM_PROVIDER
# 渲染配置输入框
col1, col2 = st.columns([1, 2])
col1, col2, col3 = st.columns([1, 2, 2])
with col1:
render_openai_compatible_protocol_field(
tr,
@ -897,11 +906,13 @@ def render_text_llm_settings(tr):
)
with col2:
model_name_input = st.text_input(
tr("Text Model Name"),
reasoning_model_name_input = st.text_input(
tr("High Reasoning Model Name"),
value=current_model,
help=(
tr("Model Name Input Help")
tr("High Reasoning Model Help")
+ "\n\n"
+ tr("Model Name Input Help")
+ "\n\n"
+ "• Pro/zai-org/GLM-5\n"
+ "• deepseek/deepseek-chat\n"
@ -912,8 +923,24 @@ def render_text_llm_settings(tr):
key="text_model_input"
)
with col3:
fast_model_name_input = st.text_input(
tr("High Efficiency Model Name"),
value=current_fast_model,
help=(
tr("High Efficiency Model Help")
+ "\n\n"
+ "• Qwen/Qwen3.5-32B\n"
+ "• gpt-4o-mini\n"
+ "• gemini-2.5-flash\n"
+ "• deepseek/deepseek-chat"
),
key="text_fast_model_input",
)
# 组合完整的模型名称
st_text_model_name = normalize_openai_compatible_model_name(model_name_input)
st_text_model_name = normalize_openai_compatible_model_name(reasoning_model_name_input)
st_text_fast_model_name = normalize_openai_compatible_model_name(fast_model_name_input)
st_text_api_key = st.text_input(
tr("Text API Key"),
@ -952,7 +979,7 @@ def render_text_llm_settings(tr):
test_errors = []
if not st_text_api_key:
test_errors.append(tr("Please enter API key"))
if not model_name_input:
if not reasoning_model_name_input:
test_errors.append(tr("Please enter model name"))
if test_errors:
@ -961,17 +988,25 @@ def render_text_llm_settings(tr):
else:
with st.spinner(tr("Testing connection...")):
try:
success, message = test_openai_compatible_text_model(
api_key=st_text_api_key,
base_url=st_text_base_url,
model_name=st_text_model_name,
tr=tr
)
if success:
st.success(message)
else:
st.error(message)
test_targets = [
(tr("High Reasoning Model Name"), st_text_model_name),
]
if st_text_fast_model_name:
test_targets.append((
tr("High Efficiency Model Name"),
st_text_fast_model_name,
))
for label, target_model in test_targets:
success, message = test_openai_compatible_text_model(
api_key=st_text_api_key,
base_url=st_text_base_url,
model_name=target_model,
tr=tr,
)
if success:
st.success(f"{label}: {message}")
else:
st.error(f"{label}: {message}")
except Exception as e:
st.error(f"{tr('Connection test error')}: {str(e)}")
logger.error(f"OpenAI 兼容 文案生成模型连接测试失败: {str(e)}")
@ -992,6 +1027,25 @@ def render_text_llm_settings(tr):
else:
text_validation_errors.append(error_msg)
if st_text_fast_model_name:
is_valid, error_msg = validate_openai_compatible_model_name(
st_text_fast_model_name,
"高效率文案生成",
)
if is_valid:
text_config_changed |= update_app_config_if_changed(
"text_openai_fast_model_name",
st_text_fast_model_name,
)
st.session_state["text_openai_fast_model_name"] = st_text_fast_model_name
else:
text_validation_errors.append(error_msg)
else:
text_config_changed |= update_app_config_if_changed(
"text_openai_fast_model_name",
"",
)
# 验证 API 密钥
if st_text_api_key:
is_valid, error_msg = validate_api_key(st_text_api_key, "文案生成")
@ -1027,7 +1081,7 @@ def render_text_llm_settings(tr):
config.save_config()
# 清除缓存,确保下次使用新配置
UnifiedLLMService.clear_cache()
if st_text_api_key or st_text_base_url or st_text_model_name:
if st_text_api_key or st_text_base_url or st_text_model_name or st_text_fast_model_name:
st.success(tr("Text model config saved"))
except Exception as e:
st.error(f"{tr('Failed to save config')}: {str(e)}")

View File

@ -205,6 +205,10 @@
"Text API Key": "Text API Key",
"Text Base URL": "Text Base URL",
"Text Model Name": "Text Model Name",
"High Reasoning Model Name": "High-Reasoning Model Name",
"High Efficiency Model Name": "High-Efficiency Model Name",
"High Reasoning Model Help": "Used for plot analysis, copy generation, and script generation and matching.",
"High Efficiency Model Help": "Used for subtitle translation and calibration; falls back to the high-reasoning model when empty.",
"Top P": "Top P",
"Top K": "Top K",
"Max Output Tokens": "Max Output Tokens",

View File

@ -194,6 +194,10 @@
"Text API Key": "文案生成 API 密钥",
"Text Base URL": "文案生成接口地址",
"Text Model Name": "文案生成模型名称",
"High Reasoning Model Name": "高推理模型名称",
"High Efficiency Model Name": "高效率模型名称",
"High Reasoning Model Help": "用于剧情分析、文案生成、脚本生成与匹配等复杂任务。",
"High Efficiency Model Help": "用于字幕翻译、字幕校准等批量任务;留空时自动使用高推理模型。",
"Top P": "Top P",
"Top K": "Top K",
"Max Output Tokens": "最大输出 Token",