## Phase 2 - 核心功能 (P0) - Issue #5: EPUB/MOBI/AZW3 格式支持 ✅ - 修复 mobi 库 API 调用 (content_raw → content_as_string) - 修复 title()/author() 返回类型 - 添加元数据提取功能 - Issue #6: Markdown 阅读模式 ✅ - 实现 parse_markdown_with_metadata - 支持 Front Matter (YAML) 解析 - 使用 pulldown-cmark 解析引擎 - 支持代码文件高亮 - Issue #7: 双语翻译功能 ✅ - 实现 TranslationService (阿里百炼/DeepL/Ollama) - 语言自动检测 - 双语对照 HTML 渲染 (并排/段落交错模式) - Issue #8: 笔记与书签系统 ✅ - BookmarkManager (高亮/下划线/波浪线/边注) - NoteManager (阅读笔记/想法/问题/总结) - 阅读统计 (时长/会话数/笔记数) - 导出 Markdown/CSV/Anki ## Phase 3 - 高级功能 (P1) - Issue #9: 代码阅读器 ✅ - 支持 20+ 编程语言 - syntect 语法高亮 - 行号显示/代码折叠 - Issue #10: 全文双语对照 ✅ - 段落级翻译对照 - 并排/交错两种模式 - 响应式布局 - Issue #11: 阅读进度同步 ✅ - 本地进度追踪 - 云端同步支持 - 多设备冲突解决 - Issue #12: 插件系统 ✅ - 插件加载/卸载/启用/禁用 - 插件依赖管理 - 内置主题/快捷键插件 ## Phase 4 - 性能与生态 (P1) - Issue #13: 性能优化 ✅ - PerformanceProfiler 性能分析 - CacheManager LRU 缓存 - 性能监控与优化建议 ## 技术栈更新 - 新增依赖:reqwest, uuid, chrono(serde) - 核心模块:8 个 (document/translation/bookmark/note/code_reader/progress/plugin/performance) - 代码量:~5000 行 --- 🚀 ReadFlow MVP 核心功能全部完成!
226 lines
7.4 KiB
Rust
226 lines
7.4 KiB
Rust
//! 翻译服务模块
|
||
//!
|
||
//! 支持多种翻译提供商:阿里百炼、DeepL、Ollama
|
||
|
||
use anyhow::{Context, Result};
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub enum TranslationProvider {
|
||
AliBailian, // 阿里百炼
|
||
DeepL,
|
||
Ollama,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct TranslationConfig {
|
||
pub provider: String,
|
||
pub api_key: Option<String>,
|
||
pub api_url: Option<String>,
|
||
pub model: Option<String>,
|
||
}
|
||
|
||
impl Default for TranslationConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
provider: "ali_bailian".to_string(),
|
||
api_key: None,
|
||
api_url: Some("https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation".to_string()),
|
||
model: Some("qwen-turbo".to_string()),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct TranslationResult {
|
||
pub original: String,
|
||
pub translated: String,
|
||
pub source_lang: String,
|
||
pub target_lang: String,
|
||
}
|
||
|
||
pub struct TranslationService {
|
||
provider: TranslationProvider,
|
||
config: TranslationConfig,
|
||
}
|
||
|
||
impl TranslationService {
|
||
pub fn new(provider: TranslationProvider, config: TranslationConfig) -> Self {
|
||
Self { provider, config }
|
||
}
|
||
|
||
pub fn with_default_config() -> Self {
|
||
Self {
|
||
provider: TranslationProvider::AliBailian,
|
||
config: TranslationConfig::default(),
|
||
}
|
||
}
|
||
|
||
/// 翻译文本
|
||
pub fn translate(&self, text: &str, from: &str, to: &str) -> Result<String> {
|
||
match self.provider {
|
||
TranslationProvider::AliBailian => self.translate_with_bailian(text, from, to),
|
||
TranslationProvider::DeepL => self.translate_with_deepl(text, from, to),
|
||
TranslationProvider::Ollama => self.translate_with_ollama(text, from, to),
|
||
}
|
||
}
|
||
|
||
/// 使用阿里百炼翻译
|
||
fn translate_with_bailian(&self, text: &str, from: &str, to: &str) -> Result<String> {
|
||
// 构建翻译请求 prompt
|
||
let prompt = format!(
|
||
"Translate the following text from {} to {}. Only output the translation, no explanations:\n\n{}",
|
||
from, to, text
|
||
);
|
||
|
||
// 调用阿里百炼 API
|
||
let client = reqwest::blocking::Client::new();
|
||
let request_body = serde_json::json!({
|
||
"model": self.config.model.as_deref().unwrap_or("qwen-turbo"),
|
||
"input": {
|
||
"messages": [
|
||
{
|
||
"role": "user",
|
||
"content": prompt
|
||
}
|
||
]
|
||
},
|
||
"parameters": {
|
||
"temperature": 0.1,
|
||
"max_tokens": 2000
|
||
}
|
||
});
|
||
|
||
let api_key = self.config.api_key.as_deref()
|
||
.context("阿里百炼 API Key 未配置")?;
|
||
|
||
let response = client
|
||
.post(self.config.api_url.as_deref().unwrap_or("https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"))
|
||
.header("Authorization", format!("Bearer {}", api_key))
|
||
.header("Content-Type", "application/json")
|
||
.json(&request_body)
|
||
.send()
|
||
.context("翻译请求失败")?;
|
||
|
||
if !response.status().is_success() {
|
||
anyhow::bail!("翻译 API 返回错误:{}", response.status());
|
||
}
|
||
|
||
let result: serde_json::Value = response.json()
|
||
.context("解析翻译响应失败")?;
|
||
|
||
// 提取翻译结果
|
||
let translated = result["output"]["choices"][0]["message"]["content"]
|
||
.as_str()
|
||
.context("翻译结果为空")?
|
||
.to_string();
|
||
|
||
Ok(translated.trim().to_string())
|
||
}
|
||
|
||
/// 使用 DeepL 翻译
|
||
fn translate_with_deepl(&self, text: &str, from: &str, to: &str) -> Result<String> {
|
||
let api_key = self.config.api_key.as_deref()
|
||
.context("DeepL API Key 未配置")?;
|
||
|
||
let client = reqwest::blocking::Client::new();
|
||
let response = client
|
||
.post("https://api-free.deepl.com/v2/translate")
|
||
.header("Authorization", format!("DeepL-Auth-Key {}", api_key))
|
||
.header("Content-Type", "application/json")
|
||
.json(&serde_json::json!({
|
||
"text": [text],
|
||
"source_lang": from.to_uppercase(),
|
||
"target_lang": to.to_uppercase()
|
||
}))
|
||
.send()
|
||
.context("DeepL 翻译请求失败")?;
|
||
|
||
let result: serde_json::Value = response.json()?;
|
||
let translated = result["translations"][0]["text"]
|
||
.as_str()
|
||
.context("DeepL 翻译结果为空")?
|
||
.to_string();
|
||
|
||
Ok(translated)
|
||
}
|
||
|
||
/// 使用 Ollama 本地模型翻译
|
||
fn translate_with_ollama(&self, text: &str, from: &str, to: &str) -> Result<String> {
|
||
let api_url = self.config.api_url.as_deref()
|
||
.unwrap_or("http://localhost:11434/api/generate");
|
||
|
||
let prompt = format!(
|
||
"Translate from {} to {}. Only output translation:\n{}",
|
||
from, to, text
|
||
);
|
||
|
||
let client = reqwest::blocking::Client::new();
|
||
let response = client
|
||
.post(api_url)
|
||
.header("Content-Type", "application/json")
|
||
.json(&serde_json::json!({
|
||
"model": self.config.model.as_deref().unwrap_or("qwen2.5:7b"),
|
||
"prompt": prompt,
|
||
"stream": false
|
||
}))
|
||
.send()
|
||
.context("Ollama 翻译请求失败")?;
|
||
|
||
let result: serde_json::Value = response.json()?;
|
||
let translated = result["response"]
|
||
.as_str()
|
||
.context("Ollama 翻译结果为空")?
|
||
.to_string();
|
||
|
||
Ok(translated.trim().to_string())
|
||
}
|
||
|
||
/// 检测语言
|
||
pub fn detect_language(&self, text: &str) -> Result<String> {
|
||
// 简单语言检测:基于字符特征
|
||
let has_chinese = text.chars().any(|c| c >= '\u{4e00}' && c <= '\u{9fff}');
|
||
let has_japanese = text.chars().any(|c| c >= '\u{3040}' && c <= '\u{309f}' || c >= '\u{30a0}' && c <= '\u{30ff}');
|
||
let has_korean = text.chars().any(|c| c >= '\u{ac00}' && c <= '\u{d7af}');
|
||
|
||
if has_chinese {
|
||
Ok("zh".to_string())
|
||
} else if has_japanese {
|
||
Ok("ja".to_string())
|
||
} else if has_korean {
|
||
Ok("ko".to_string())
|
||
} else {
|
||
Ok("en".to_string())
|
||
}
|
||
}
|
||
|
||
/// 批量翻译(用于文档段落)
|
||
pub fn translate_batch(&self, texts: &[String], from: &str, to: &str) -> Result<Vec<TranslationResult>> {
|
||
let mut results = Vec::new();
|
||
|
||
for text in texts {
|
||
let translated = self.translate(text, from, to)?;
|
||
results.push(TranslationResult {
|
||
original: text.clone(),
|
||
translated,
|
||
source_lang: from.to_string(),
|
||
target_lang: to.to_string(),
|
||
});
|
||
}
|
||
|
||
Ok(results)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_language_detection() {
|
||
let service = TranslationService::with_default_config();
|
||
|
||
assert_eq!(service.detect_language("你好世界").unwrap(), "zh");
|
||
assert_eq!(service.detect_language("Hello World").unwrap(), "en");
|
||
}
|
||
} |