将llm与转录区分开
This commit is contained in:
parent
508f28d092
commit
38a94d0d87
@ -7,7 +7,9 @@ mod translate;
|
|||||||
mod vad;
|
mod vad;
|
||||||
mod whisper;
|
mod whisper;
|
||||||
|
|
||||||
use models::{DefaultModelPaths, StartTaskPayload, SubtitleSegment, SubtitleTask};
|
use models::{
|
||||||
|
DefaultModelPaths, StartTaskPayload, SubtitleSegment, SubtitleTask, TranslationConfig,
|
||||||
|
};
|
||||||
use state::AppState;
|
use state::AppState;
|
||||||
use tauri::{
|
use tauri::{
|
||||||
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder},
|
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder},
|
||||||
@ -72,6 +74,19 @@ fn get_default_model_paths(app: tauri::AppHandle) -> std::result::Result<Default
|
|||||||
task::get_default_model_paths(&app).map_err(error_to_string)
|
task::get_default_model_paths(&app).map_err(error_to_string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn retry_translation(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
window: tauri::Window,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
task_id: String,
|
||||||
|
translation_config: TranslationConfig,
|
||||||
|
) -> std::result::Result<SubtitleTask, String> {
|
||||||
|
task::retry_translation(app, window, state, task_id, translation_config)
|
||||||
|
.await
|
||||||
|
.map_err(error_to_string)
|
||||||
|
}
|
||||||
|
|
||||||
fn error_to_string(error: anyhow::Error) -> String {
|
fn error_to_string(error: anyhow::Error) -> String {
|
||||||
format!("{error:#}")
|
format!("{error:#}")
|
||||||
}
|
}
|
||||||
@ -93,7 +108,8 @@ pub fn run() {
|
|||||||
update_segment_text,
|
update_segment_text,
|
||||||
delete_task,
|
delete_task,
|
||||||
export_subtitles,
|
export_subtitles,
|
||||||
get_default_model_paths
|
get_default_model_paths,
|
||||||
|
retry_translation
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@ -10,8 +10,9 @@ use uuid::Uuid;
|
|||||||
use crate::{
|
use crate::{
|
||||||
audio::AudioPipeline,
|
audio::AudioPipeline,
|
||||||
models::{
|
models::{
|
||||||
DefaultModelPaths, ErrorEvent, LogEvent, OutputMode, ProgressEvent, ResetSegmentsEvent,
|
DefaultModelPaths, ErrorEvent, LogEvent, OutputMode, ProgressEvent,
|
||||||
StartTaskPayload, SubtitleSegment, SubtitleTask, TaskStatus, TranslationConfig,
|
ResetSegmentsEvent, StartTaskPayload, SubtitleSegment, SubtitleTask,
|
||||||
|
TargetLanguage, TaskStatus, TranslationConfig,
|
||||||
},
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
subtitle::{render, SubtitleFormat},
|
subtitle::{render, SubtitleFormat},
|
||||||
@ -185,6 +186,42 @@ async fn run_pipeline(
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
set_status(&window, &app_state, &mut task, TaskStatus::Transcribing, 30.0, "正在执行 Whisper")?;
|
set_status(&window, &app_state, &mut task, TaskStatus::Transcribing, 30.0, "正在执行 Whisper")?;
|
||||||
|
|
||||||
|
// Setup concurrent translation: as whisper emits segments, send them to
|
||||||
|
// the translation worker so it can start translating immediately in batches
|
||||||
|
let app_handle = app.clone();
|
||||||
|
let (segment_tx, translate_join_handle) = if should_translate {
|
||||||
|
let config = payload
|
||||||
|
.translation_config
|
||||||
|
.clone()
|
||||||
|
.or_else(load_translation_config)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("翻译模式需要填写 LLM API 配置,或设置 OPENAI_API_BASE / OPENAI_API_KEY"))?;
|
||||||
|
let translator = Translator::new(config)?;
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::channel::<SubtitleSegment>(1024);
|
||||||
|
let window_for_worker = window.clone();
|
||||||
|
let task_id_for_worker = task.id.clone();
|
||||||
|
let target_lang_for_worker = task.target_lang.clone();
|
||||||
|
let app_handle_for_worker = app_handle.clone();
|
||||||
|
let handle = tauri::async_runtime::spawn(async move {
|
||||||
|
let state = app_handle_for_worker.state::<AppState>();
|
||||||
|
if let Err(error) = incremental_translate(
|
||||||
|
translator,
|
||||||
|
rx,
|
||||||
|
&window_for_worker,
|
||||||
|
&state,
|
||||||
|
&task_id_for_worker,
|
||||||
|
&target_lang_for_worker,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
eprintln!("incremental translation error: {error:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(Some(tx), Some(handle))
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
|
|
||||||
let whisper = WhisperEngine::new(payload.whisper_model_path.clone());
|
let whisper = WhisperEngine::new(payload.whisper_model_path.clone());
|
||||||
let task_id_for_progress = task.id.clone();
|
let task_id_for_progress = task.id.clone();
|
||||||
let task_id_for_segment = task.id.clone();
|
let task_id_for_segment = task.id.clone();
|
||||||
@ -192,7 +229,8 @@ async fn run_pipeline(
|
|||||||
let task_id_for_log = task.id.clone();
|
let task_id_for_log = task.id.clone();
|
||||||
let app_state_for_segment = app_state.clone();
|
let app_state_for_segment = app_state.clone();
|
||||||
let app_state_for_reset = app_state.clone();
|
let app_state_for_reset = app_state.clone();
|
||||||
let mut segments = whisper.infer_segments(
|
let seg_tx_for_callback = segment_tx.clone();
|
||||||
|
let _segments = whisper.infer_segments(
|
||||||
&wav_path,
|
&wav_path,
|
||||||
&task.id,
|
&task.id,
|
||||||
task.source_lang.as_deref(),
|
task.source_lang.as_deref(),
|
||||||
@ -226,6 +264,9 @@ async fn run_pipeline(
|
|||||||
Ok(())
|
Ok(())
|
||||||
},
|
},
|
||||||
|segment| {
|
|segment| {
|
||||||
|
if let Some(ref tx) = seg_tx_for_callback {
|
||||||
|
let _ = tx.try_send(segment.clone());
|
||||||
|
}
|
||||||
if let Ok(mut current_task) = app_state_for_segment.get_task(&task_id_for_segment) {
|
if let Ok(mut current_task) = app_state_for_segment.get_task(&task_id_for_segment) {
|
||||||
upsert_segment(&mut current_task.segments, segment.clone());
|
upsert_segment(&mut current_task.segments, segment.clone());
|
||||||
let _ = app_state_for_segment.upsert_task(current_task);
|
let _ = app_state_for_segment.upsert_task(current_task);
|
||||||
@ -242,70 +283,17 @@ async fn run_pipeline(
|
|||||||
|message| emit_log(&window, &task_id_for_log, message),
|
|message| emit_log(&window, &task_id_for_log, message),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
task.segments = segments.clone();
|
// Close channel to signal translation worker to flush and finish
|
||||||
app_state.upsert_task(task.clone())?;
|
drop(segment_tx);
|
||||||
|
if let Some(handle) = translate_join_handle {
|
||||||
if should_translate {
|
handle.await.unwrap_or_else(|join_error| {
|
||||||
let config = payload
|
eprintln!("translation worker panicked: {join_error:?}");
|
||||||
.translation_config
|
});
|
||||||
.clone()
|
|
||||||
.or_else(load_translation_config)
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("翻译模式需要填写 LLM API 配置,或设置 OPENAI_API_BASE / OPENAI_API_KEY"))?;
|
|
||||||
set_status(&window, &app_state, &mut task, TaskStatus::Translating, 70.0, "正在生成译文")?;
|
|
||||||
let translator = Translator::new(config)?;
|
|
||||||
let task_id_for_translate = task.id.clone();
|
|
||||||
let app_state_for_translate = app_state.clone();
|
|
||||||
let window_for_translate = window.clone();
|
|
||||||
let task_id_for_translate_progress = task.id.clone();
|
|
||||||
let window_for_translate_progress = window.clone();
|
|
||||||
segments = translator
|
|
||||||
.translate_segments_with_progress(
|
|
||||||
&segments,
|
|
||||||
&task.target_lang,
|
|
||||||
|message| {
|
|
||||||
let _ = emit_log(&window_for_translate, &task_id_for_translate, message);
|
|
||||||
},
|
|
||||||
|ratio| {
|
|
||||||
let progress = 70.0 + ratio.clamp(0.0, 1.0) * 25.0;
|
|
||||||
let _ = window_for_translate_progress.emit(
|
|
||||||
"task:progress",
|
|
||||||
ProgressEvent {
|
|
||||||
task_id: task_id_for_translate_progress.clone(),
|
|
||||||
status: TaskStatus::Translating,
|
|
||||||
progress,
|
|
||||||
message: "正在生成译文".to_string(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|segment| {
|
|
||||||
if let Ok(mut current_task) = app_state_for_translate.get_task(&task_id_for_translate) {
|
|
||||||
upsert_segment(&mut current_task.segments, segment.clone());
|
|
||||||
let _ = app_state_for_translate.upsert_task(current_task);
|
|
||||||
}
|
|
||||||
let _ = window_for_translate.emit(
|
|
||||||
"task:segment",
|
|
||||||
crate::models::SegmentEvent {
|
|
||||||
task_id: task_id_for_translate.clone(),
|
|
||||||
segment,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
task.segments = segments.clone();
|
|
||||||
app_state.upsert_task(task.clone())?;
|
|
||||||
|
|
||||||
for segment in segments {
|
|
||||||
window.emit(
|
|
||||||
"task:segment",
|
|
||||||
crate::models::SegmentEvent {
|
|
||||||
task_id: task.id.clone(),
|
|
||||||
segment,
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reload task from state (all segments and translations applied by callbacks)
|
||||||
|
task = app_state.get_task(&task.id)?;
|
||||||
|
|
||||||
task.status = TaskStatus::Completed;
|
task.status = TaskStatus::Completed;
|
||||||
task.progress = 100.0;
|
task.progress = 100.0;
|
||||||
app_state.upsert_task(task.clone())?;
|
app_state.upsert_task(task.clone())?;
|
||||||
@ -313,6 +301,107 @@ async fn run_pipeline(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn incremental_translate(
|
||||||
|
translator: Translator,
|
||||||
|
mut rx: tokio::sync::mpsc::Receiver<SubtitleSegment>,
|
||||||
|
window: &Window,
|
||||||
|
app_state: &AppState,
|
||||||
|
task_id: &str,
|
||||||
|
target_lang: &TargetLanguage,
|
||||||
|
) -> Result<()> {
|
||||||
|
let batch_size = translator.batch_size().clamp(10, 15);
|
||||||
|
let context_size = translator.context_size().min(5);
|
||||||
|
let mut all_segments: Vec<SubtitleSegment> = Vec::new();
|
||||||
|
let mut buffer: Vec<SubtitleSegment> = Vec::new();
|
||||||
|
|
||||||
|
while let Some(segment) = rx.recv().await {
|
||||||
|
all_segments.push(segment.clone());
|
||||||
|
buffer.push(segment);
|
||||||
|
|
||||||
|
if buffer.len() >= batch_size {
|
||||||
|
let batch = std::mem::take(&mut buffer);
|
||||||
|
let context_end = all_segments.len().saturating_sub(batch.len());
|
||||||
|
let context_start = context_end.saturating_sub(context_size);
|
||||||
|
let context = &all_segments[context_start..context_end];
|
||||||
|
|
||||||
|
emit_log(window, task_id, format!(
|
||||||
|
"translation: batch segments={}",
|
||||||
|
batch.iter().map(|s| s.id.as_str()).collect::<Vec<_>>().join(", ")
|
||||||
|
))?;
|
||||||
|
|
||||||
|
let rows = translator
|
||||||
|
.translate_batch_with_retries(context, &batch, target_lang_name(target_lang))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
emit_log(window, task_id, format!("translation: batch done, translated={}", rows.len()))?;
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
if let Some(original) = batch.iter().find(|item| item.id == row.id) {
|
||||||
|
let mut emitted = original.clone();
|
||||||
|
emitted.translated_text = Some(row.text);
|
||||||
|
|
||||||
|
if let Ok(mut current_task) = app_state.get_task(task_id) {
|
||||||
|
upsert_segment(&mut current_task.segments, emitted.clone());
|
||||||
|
let _ = app_state.upsert_task(current_task);
|
||||||
|
}
|
||||||
|
let _ = window.emit(
|
||||||
|
"task:segment",
|
||||||
|
crate::models::SegmentEvent {
|
||||||
|
task_id: task_id.to_string(),
|
||||||
|
segment: emitted,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush remaining segments below batch_size
|
||||||
|
if !buffer.is_empty() {
|
||||||
|
let batch = std::mem::take(&mut buffer);
|
||||||
|
let context_end = all_segments.len().saturating_sub(batch.len());
|
||||||
|
let context_start = context_end.saturating_sub(context_size);
|
||||||
|
let context = &all_segments[context_start..context_end];
|
||||||
|
|
||||||
|
emit_log(window, task_id, format!(
|
||||||
|
"translation: final batch segments={}",
|
||||||
|
batch.iter().map(|s| s.id.as_str()).collect::<Vec<_>>().join(", ")
|
||||||
|
))?;
|
||||||
|
|
||||||
|
let rows = translator
|
||||||
|
.translate_batch_with_retries(context, &batch, target_lang_name(target_lang))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
if let Some(original) = batch.iter().find(|item| item.id == row.id) {
|
||||||
|
let mut emitted = original.clone();
|
||||||
|
emitted.translated_text = Some(row.text);
|
||||||
|
|
||||||
|
if let Ok(mut current_task) = app_state.get_task(task_id) {
|
||||||
|
upsert_segment(&mut current_task.segments, emitted.clone());
|
||||||
|
let _ = app_state.upsert_task(current_task);
|
||||||
|
}
|
||||||
|
let _ = window.emit(
|
||||||
|
"task:segment",
|
||||||
|
crate::models::SegmentEvent {
|
||||||
|
task_id: task_id.to_string(),
|
||||||
|
segment: emitted,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn target_lang_name(target_lang: &TargetLanguage) -> &'static str {
|
||||||
|
match target_lang {
|
||||||
|
TargetLanguage::Zh => "简体中文",
|
||||||
|
TargetLanguage::En => "英文",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn load_translation_config() -> Option<TranslationConfig> {
|
fn load_translation_config() -> Option<TranslationConfig> {
|
||||||
let api_base = std::env::var("OPENAI_API_BASE").ok()?;
|
let api_base = std::env::var("OPENAI_API_BASE").ok()?;
|
||||||
let api_key = std::env::var("OPENAI_API_KEY").ok()?;
|
let api_key = std::env::var("OPENAI_API_KEY").ok()?;
|
||||||
@ -406,6 +495,118 @@ fn emit_log(window: &Window, task_id: &str, message: String) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn retry_translation(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
window: Window,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
task_id: String,
|
||||||
|
translation_config: TranslationConfig,
|
||||||
|
) -> Result<SubtitleTask> {
|
||||||
|
let task = state.get_task(&task_id)?;
|
||||||
|
|
||||||
|
if task.segments.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("任务没有可翻译的字幕片段,请重新添加任务"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut initial_task = task.clone();
|
||||||
|
set_status(
|
||||||
|
&window,
|
||||||
|
&state,
|
||||||
|
&mut initial_task,
|
||||||
|
TaskStatus::Translating,
|
||||||
|
5.0,
|
||||||
|
"正在生成译文",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let app_handle = app.clone();
|
||||||
|
let window_handle = window.clone();
|
||||||
|
let task_id_for_spawn = task.id.clone();
|
||||||
|
let segments = task.segments.clone();
|
||||||
|
let target_lang = task.target_lang.clone();
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let result = async {
|
||||||
|
let state = app_handle.state::<AppState>();
|
||||||
|
let translator = Translator::new(translation_config)?;
|
||||||
|
|
||||||
|
let task_id_for_progress = task_id_for_spawn.clone();
|
||||||
|
let window_for_progress = window_handle.clone();
|
||||||
|
let task_id_for_segment = task_id_for_spawn.clone();
|
||||||
|
let window_for_segment = window_handle.clone();
|
||||||
|
let app_handle_for_closures = app_handle.clone();
|
||||||
|
|
||||||
|
let translated_segments = translator
|
||||||
|
.translate_segments_with_progress(
|
||||||
|
&segments,
|
||||||
|
&target_lang,
|
||||||
|
|message| {
|
||||||
|
let _ = emit_log(&window_for_segment, &task_id_for_segment, message);
|
||||||
|
},
|
||||||
|
|ratio| {
|
||||||
|
let progress = 5.0 + ratio.clamp(0.0, 1.0) * 90.0;
|
||||||
|
let _ = window_for_progress.emit(
|
||||||
|
"task:progress",
|
||||||
|
ProgressEvent {
|
||||||
|
task_id: task_id_for_progress.clone(),
|
||||||
|
status: TaskStatus::Translating,
|
||||||
|
progress,
|
||||||
|
message: "正在生成译文".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|segment| {
|
||||||
|
let state = app_handle_for_closures.state::<AppState>();
|
||||||
|
if let Ok(mut current_task) = state.get_task(&task_id_for_segment) {
|
||||||
|
upsert_segment(&mut current_task.segments, segment.clone());
|
||||||
|
let _ = state.upsert_task(current_task);
|
||||||
|
}
|
||||||
|
let _ = window_for_segment.emit(
|
||||||
|
"task:segment",
|
||||||
|
crate::models::SegmentEvent {
|
||||||
|
task_id: task_id_for_segment.clone(),
|
||||||
|
segment,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut current_task = state.get_task(&task_id_for_spawn)?;
|
||||||
|
current_task.segments = translated_segments.clone();
|
||||||
|
current_task.status = TaskStatus::Completed;
|
||||||
|
current_task.progress = 100.0;
|
||||||
|
state.upsert_task(current_task.clone())?;
|
||||||
|
|
||||||
|
for segment in translated_segments {
|
||||||
|
window_handle.emit(
|
||||||
|
"task:segment",
|
||||||
|
crate::models::SegmentEvent {
|
||||||
|
task_id: task_id_for_spawn.clone(),
|
||||||
|
segment,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
window_handle.emit("task:done", current_task)?;
|
||||||
|
|
||||||
|
Ok::<_, anyhow::Error>(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(error) = result {
|
||||||
|
let state = app_handle.state::<AppState>();
|
||||||
|
if let Ok(mut failed_task) = state.get_task(&task_id_for_spawn) {
|
||||||
|
failed_task.status = TaskStatus::Failed;
|
||||||
|
failed_task.error = Some(error.to_string());
|
||||||
|
let _ = state.upsert_task(failed_task);
|
||||||
|
}
|
||||||
|
let _ = emit_error(&window_handle, &task_id_for_spawn, &error.to_string());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(task)
|
||||||
|
}
|
||||||
|
|
||||||
fn upsert_segment(segments: &mut Vec<SubtitleSegment>, segment: SubtitleSegment) {
|
fn upsert_segment(segments: &mut Vec<SubtitleSegment>, segment: SubtitleSegment) {
|
||||||
if let Some(existing) = segments.iter_mut().find(|item| item.id == segment.id) {
|
if let Some(existing) = segments.iter_mut().find(|item| item.id == segment.id) {
|
||||||
*existing = segment;
|
*existing = segment;
|
||||||
|
|||||||
@ -50,9 +50,9 @@ struct TranslationResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
struct TranslatedRow {
|
pub(crate) struct TranslatedRow {
|
||||||
id: String,
|
pub(crate) id: String,
|
||||||
text: String,
|
pub(crate) text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Translator {
|
pub struct Translator {
|
||||||
@ -70,6 +70,14 @@ impl Translator {
|
|||||||
Ok(Self { client, config })
|
Ok(Self { client, config })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn batch_size(&self) -> usize {
|
||||||
|
self.config.batch_size
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn context_size(&self) -> usize {
|
||||||
|
self.config.context_size
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn translate_segments_with_progress<LF, PF, SF>(
|
pub async fn translate_segments_with_progress<LF, PF, SF>(
|
||||||
&self,
|
&self,
|
||||||
segments: &[SubtitleSegment],
|
segments: &[SubtitleSegment],
|
||||||
@ -125,7 +133,7 @@ impl Translator {
|
|||||||
Ok(translated)
|
Ok(translated)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn translate_batch_with_retries(
|
pub(crate) async fn translate_batch_with_retries(
|
||||||
&self,
|
&self,
|
||||||
context: &[SubtitleSegment],
|
context: &[SubtitleSegment],
|
||||||
batch: &[SubtitleSegment],
|
batch: &[SubtitleSegment],
|
||||||
@ -177,6 +185,31 @@ impl Translator {
|
|||||||
Ok(order_rows(batch, &collected))
|
Ok(order_rows(batch, &collected))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Translate a batch of segments with retries, returning the batch with `translated_text` filled in.
|
||||||
|
pub async fn translate_batch(
|
||||||
|
&self,
|
||||||
|
context: &[SubtitleSegment],
|
||||||
|
batch: &[SubtitleSegment],
|
||||||
|
target_language: &TargetLanguage,
|
||||||
|
) -> Result<Vec<SubtitleSegment>> {
|
||||||
|
let target_language_name = match target_language {
|
||||||
|
TargetLanguage::Zh => "简体中文",
|
||||||
|
TargetLanguage::En => "英文",
|
||||||
|
};
|
||||||
|
let rows = self
|
||||||
|
.translate_batch_with_retries(context, batch, target_language_name)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut result = batch.to_vec();
|
||||||
|
for row in rows {
|
||||||
|
if let Some(segment) = result.iter_mut().find(|item| item.id == row.id) {
|
||||||
|
segment.translated_text = Some(row.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
async fn request_translation(
|
async fn request_translation(
|
||||||
&self,
|
&self,
|
||||||
context: &[SubtitleSegment],
|
context: &[SubtitleSegment],
|
||||||
|
|||||||
21
src/App.vue
21
src/App.vue
@ -241,6 +241,25 @@ async function handleExport(format: 'srt' | 'vtt' | 'ass') {
|
|||||||
const output = await taskStore.exportTask(selectedTask.value.id, format)
|
const output = await taskStore.exportTask(selectedTask.value.id, format)
|
||||||
feedback.value = output
|
feedback.value = output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleRetryTranslate(taskId: string) {
|
||||||
|
persistTranslationConfig()
|
||||||
|
if (!translationConfig.value.apiKey.trim()) {
|
||||||
|
feedback.value = t('app.feedback.noApiKey')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await taskStore.retryTranslation(taskId, translationConfig.value)
|
||||||
|
feedback.value = t('app.feedback.translationStarted')
|
||||||
|
} catch (error) {
|
||||||
|
feedback.value = error instanceof Error ? error.message : t('app.feedback.translationFailed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTranslateFromEditor() {
|
||||||
|
if (!selectedTask.value) return
|
||||||
|
await handleRetryTranslate(selectedTask.value.id)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -352,12 +371,14 @@ async function handleExport(format: 'srt' | 'vtt' | 'ass') {
|
|||||||
:selected-task-id="taskStore.selectedTaskId"
|
:selected-task-id="taskStore.selectedTaskId"
|
||||||
@select="taskStore.selectTask"
|
@select="taskStore.selectTask"
|
||||||
@retry="taskStore.retryTask"
|
@retry="taskStore.retryTask"
|
||||||
|
@retry-translate="handleRetryTranslate"
|
||||||
@delete="taskStore.deleteTask"
|
@delete="taskStore.deleteTask"
|
||||||
/>
|
/>
|
||||||
<SubtitleEditor
|
<SubtitleEditor
|
||||||
:task="selectedTask"
|
:task="selectedTask"
|
||||||
:logs="taskStore.selectedTaskLogs"
|
:logs="taskStore.selectedTaskLogs"
|
||||||
@save="taskStore.updateSegment"
|
@save="taskStore.updateSegment"
|
||||||
|
@translate="handleTranslateFromEditor"
|
||||||
@export="handleExport"
|
@export="handleExport"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -16,9 +16,18 @@ const canExport = computed(() => {
|
|||||||
return props.task?.status === 'completed' && (props.task.segments?.length ?? 0) > 0
|
return props.task?.status === 'completed' && (props.task.segments?.length ?? 0) > 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const canTranslate = computed(() => {
|
||||||
|
if (!props.task) return false
|
||||||
|
return (
|
||||||
|
props.task.segments.length > 0 &&
|
||||||
|
props.task.segments.some((s) => s.sourceText)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
save: [segment: SubtitleSegment]
|
save: [segment: SubtitleSegment]
|
||||||
export: [format: 'srt' | 'vtt' | 'ass']
|
export: [format: 'srt' | 'vtt' | 'ass']
|
||||||
|
translate: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const segments = computed(() =>
|
const segments = computed(() =>
|
||||||
@ -54,6 +63,12 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="task" class="export-actions">
|
<div v-if="task" class="export-actions">
|
||||||
|
<button
|
||||||
|
v-if="canTranslate"
|
||||||
|
class="button primary small"
|
||||||
|
:disabled="isProcessing"
|
||||||
|
@click="emit('translate')"
|
||||||
|
>{{ $t('editor.translate') }}</button>
|
||||||
<button class="button secondary small" :disabled="!canExport" @click="emit('export', 'srt')">SRT</button>
|
<button class="button secondary small" :disabled="!canExport" @click="emit('export', 'srt')">SRT</button>
|
||||||
<button class="button secondary small" :disabled="!canExport" @click="emit('export', 'vtt')">VTT</button>
|
<button class="button secondary small" :disabled="!canExport" @click="emit('export', 'vtt')">VTT</button>
|
||||||
<button class="button secondary small" :disabled="!canExport" @click="emit('export', 'ass')">ASS</button>
|
<button class="button secondary small" :disabled="!canExport" @click="emit('export', 'ass')">ASS</button>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [taskId: string]
|
select: [taskId: string]
|
||||||
retry: [taskId: string]
|
retry: [taskId: string]
|
||||||
|
retryTranslate: [taskId: string]
|
||||||
delete: [taskId: string]
|
delete: [taskId: string]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
@ -51,7 +52,15 @@ const emit = defineEmits<{
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="task.status === 'failed'" class="failed-footer">
|
<div v-if="task.status === 'failed'" class="failed-footer">
|
||||||
<p class="error-text">{{ task.error }}</p>
|
<p class="error-text">{{ task.error }}</p>
|
||||||
<button class="retry-button" type="button" @click.stop="emit('retry', task.id)">{{ $t('taskQueue.retry') }}</button>
|
<div class="retry-actions">
|
||||||
|
<button class="retry-button" type="button" @click.stop="emit('retry', task.id)">{{ $t('taskQueue.retry') }}</button>
|
||||||
|
<button
|
||||||
|
v-if="task.segments.length > 0"
|
||||||
|
class="retry-button secondary"
|
||||||
|
type="button"
|
||||||
|
@click.stop="emit('retryTranslate', task.id)"
|
||||||
|
>{{ $t('taskQueue.retryTranslate') }}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="delete-button"
|
class="delete-button"
|
||||||
|
|||||||
@ -27,6 +27,9 @@ export default {
|
|||||||
submitFailed: 'Task submission failed',
|
submitFailed: 'Task submission failed',
|
||||||
dialogError: 'Failed to open file dialog: {message}',
|
dialogError: 'Failed to open file dialog: {message}',
|
||||||
dialogErrorFallback: 'Failed to open file dialog',
|
dialogErrorFallback: 'Failed to open file dialog',
|
||||||
|
noApiKey: 'Please configure LLM API Key first',
|
||||||
|
translationStarted: 'Translation task started',
|
||||||
|
translationFailed: 'Translation failed',
|
||||||
},
|
},
|
||||||
llm: {
|
llm: {
|
||||||
apiBase: 'LLM API Base',
|
apiBase: 'LLM API Base',
|
||||||
@ -49,6 +52,7 @@ export default {
|
|||||||
subtitle: 'Select a task to view subtitles',
|
subtitle: 'Select a task to view subtitles',
|
||||||
empty: 'No tasks',
|
empty: 'No tasks',
|
||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
|
retryTranslate: 'Retry Translation',
|
||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
status: {
|
status: {
|
||||||
queued: 'Queued',
|
queued: 'Queued',
|
||||||
|
|||||||
@ -27,6 +27,9 @@ export default {
|
|||||||
submitFailed: '任务提交失败',
|
submitFailed: '任务提交失败',
|
||||||
dialogError: '打开文件对话框失败:{message}',
|
dialogError: '打开文件对话框失败:{message}',
|
||||||
dialogErrorFallback: '打开文件对话框失败',
|
dialogErrorFallback: '打开文件对话框失败',
|
||||||
|
noApiKey: '请先配置 LLM API Key',
|
||||||
|
translationStarted: '翻译任务已开始',
|
||||||
|
translationFailed: '翻译失败',
|
||||||
},
|
},
|
||||||
llm: {
|
llm: {
|
||||||
apiBase: 'LLM API Base',
|
apiBase: 'LLM API Base',
|
||||||
@ -49,6 +52,7 @@ export default {
|
|||||||
subtitle: '选择任务查看字幕',
|
subtitle: '选择任务查看字幕',
|
||||||
empty: '暂无任务',
|
empty: '暂无任务',
|
||||||
retry: '重试',
|
retry: '重试',
|
||||||
|
retryTranslate: '重试翻译',
|
||||||
delete: '移除',
|
delete: '移除',
|
||||||
status: {
|
status: {
|
||||||
queued: '排队中',
|
queued: '排队中',
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import type {
|
|||||||
StartTaskPayload,
|
StartTaskPayload,
|
||||||
SubtitleSegment,
|
SubtitleSegment,
|
||||||
SubtitleTask,
|
SubtitleTask,
|
||||||
|
TranslationConfig,
|
||||||
} from '../lib/types'
|
} from '../lib/types'
|
||||||
|
|
||||||
type ExportFormat = 'srt' | 'vtt' | 'ass'
|
type ExportFormat = 'srt' | 'vtt' | 'ass'
|
||||||
@ -130,6 +131,20 @@ export const useTaskStore = defineStore('tasks', {
|
|||||||
await this.startTask(payload)
|
await this.startTask(payload)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async retryTranslation(taskId: string, translationConfig: TranslationConfig) {
|
||||||
|
const task = await invoke<SubtitleTask>('retry_translation', {
|
||||||
|
taskId,
|
||||||
|
translationConfig,
|
||||||
|
})
|
||||||
|
const index = this.tasks.findIndex((t) => t.id === task.id)
|
||||||
|
if (index >= 0) {
|
||||||
|
this.tasks[index] = task
|
||||||
|
} else {
|
||||||
|
this.tasks.unshift(task)
|
||||||
|
}
|
||||||
|
this.selectedTaskId = task.id
|
||||||
|
},
|
||||||
|
|
||||||
selectTask(taskId: string) {
|
selectTask(taskId: string) {
|
||||||
this.selectedTaskId = taskId
|
this.selectedTaskId = taskId
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user