From 56fd2ef78c222a51539aba33c7676bd9a600b529 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Wed, 22 Oct 2025 07:44:32 +0800 Subject: [PATCH 1/9] ci(workflows): fix sync-release-to-gitcode.yml automatic triggering Replace release event trigger with workflow_run mechanism to ensure reliable automatic triggering after semantic-release creates GitHub releases. - Change trigger from release event to workflow_run listening to Release workflow - Add workflow_run event handling with GitHub API fetching - Maintain backward compatibility and manual dispatch functionality - Improve trigger type identification in summary display This resolves the issue where semantic-release created releases would not automatically trigger the GitCode sync workflow. --- .github/workflows/sync-release-to-gitcode.yml | 69 ++++++++++++++----- 1 file changed, 52 insertions(+), 17 deletions(-) diff --git a/.github/workflows/sync-release-to-gitcode.yml b/.github/workflows/sync-release-to-gitcode.yml index e686757..3ccc4e4 100644 --- a/.github/workflows/sync-release-to-gitcode.yml +++ b/.github/workflows/sync-release-to-gitcode.yml @@ -1,8 +1,11 @@ name: Sync Release to GitCode on: - release: - types: [created, published, prereleased] + workflow_run: + workflows: ['Release'] + types: + - completed + branches: [main, alpha, beta] workflow_dispatch: inputs: release_name: @@ -118,8 +121,36 @@ jobs: echo "prerelease=${{ github.event.inputs.prerelease }}" >> $GITHUB_OUTPUT echo "draft=${{ github.event.inputs.draft }}" >> $GITHUB_OUTPUT echo "test_mode=${{ github.event.inputs.test_mode }}" >> $GITHUB_OUTPUT + elif [ "${{ github.event_name }}" = "workflow_run" ]; then + # Automatic trigger from workflow_run - get release info from API + echo "🚀 Workflow run trigger detected, fetching latest release from GitHub API" + + # Get the latest release from GitHub API + latest_release=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + "https://api.github.com/repos/${{ github.repository }}/releases/latest") + + if [ "$(echo "$latest_release" | jq -r '.message // empty')" = "Not Found" ]; then + echo "❌ No releases found in repository" + exit 1 + fi + + TAG_NAME=$(echo "$latest_release" | jq -r '.tag_name') + RELEASE_NAME=$(echo "$latest_release" | jq -r '.name') + RELEASE_BODY=$(echo "$latest_release" | jq -r '.body // ""') + PRERELEASE=$(echo "$latest_release" | jq -r '.prerelease') + DRAFT=$(echo "$latest_release" | jq -r '.draft') + + echo "Found latest release: $TAG_NAME" + echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT + echo "release_name=$RELEASE_NAME" >> $GITHUB_OUTPUT + echo "release_body<> $GITHUB_OUTPUT + echo "$RELEASE_BODY" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + echo "prerelease=$PRERELEASE" >> $GITHUB_OUTPUT + echo "draft=$DRAFT" >> $GITHUB_OUTPUT + echo "test_mode=false" >> $GITHUB_OUTPUT else - # Automatic trigger - use release event data + # Fallback - use release event data (for backward compatibility) echo "🚀 Release event detected, using release data" echo "tag_name=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT echo "release_name=${{ github.event.release.name }}" >> $GITHUB_OUTPUT @@ -205,22 +236,17 @@ jobs: run: | mkdir -p ./release-assets - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - # Manual trigger - fetch release data from GitHub API - echo "📦 Fetching release assets for tag: ${{ steps.release.outputs.tag_name }}" + # Always fetch release data from GitHub API using the tag name + echo "📦 Fetching release assets for tag: ${{ steps.release.outputs.tag_name }}" - release_response=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ - "https://api.github.com/repos/${{ github.repository }}/releases/tags/${{ steps.release.outputs.tag_name }}") + release_response=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + "https://api.github.com/repos/${{ github.repository }}/releases/tags/${{ steps.release.outputs.tag_name }}") - if [ "$(echo "$release_response" | jq -r '.message // empty')" = "Not Found" ]; then - echo "⚠️ Release not found for tag: ${{ steps.release.outputs.tag_name }}" - assets_json='[]' - else - assets_json=$(echo "$release_response" | jq '.assets') - fi + if [ "$(echo "$release_response" | jq -r '.message // empty')" = "Not Found" ]; then + echo "⚠️ Release not found for tag: ${{ steps.release.outputs.tag_name }}" + assets_json='[]' else - # Automatic trigger - use event data - assets_json='${{ toJson(github.event.release.assets) }}' + assets_json=$(echo "$release_response" | jq '.assets') fi echo "Assets to download:" @@ -470,7 +496,16 @@ jobs: fi echo "" >> $GITHUB_STEP_SUMMARY - echo "**Trigger:** ${{ github.event_name == 'workflow_dispatch' && '🔧 Manual' || '🚀 Automatic' }}" >> $GITHUB_STEP_SUMMARY + # Determine trigger type + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TRIGGER_TYPE="🔧 Manual" + elif [ "${{ github.event_name }}" = "workflow_run" ]; then + TRIGGER_TYPE="🔄 Automatic (Workflow Run)" + else + TRIGGER_TYPE="🚀 Automatic" + fi + + echo "**Trigger:** $TRIGGER_TYPE" >> $GITHUB_STEP_SUMMARY echo "**Release:** ${{ steps.release.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY echo "**Name:** ${{ steps.release.outputs.release_name }}" >> $GITHUB_STEP_SUMMARY echo "**GitCode Repository:** $GITCODE_OWNER/$GITCODE_REPO" >> $GITHUB_STEP_SUMMARY From 035a31b0833aa3cd76d63d4a595d3a727a1da87a Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Wed, 22 Oct 2025 07:45:04 +0800 Subject: [PATCH 2/9] fix: Resolve styled-components unknown prop warnings (#227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * conductor-checkpoint-start * fix(styled-components): resolve unknown props warnings in settings page - Convert HStack layout props (gap, alignItems, justifyContent) to transient props with $ prefix - Convert ColorCircle isActive prop to transient prop $isActive - Update all usage across settings components to use transient props - Remove duplicate gap property in Box component - Build passes without styled-components warnings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- src/renderer/src/components/Layout/index.ts | 17 ++++++++--------- .../src/pages/settings/AboutSettings.tsx | 2 +- .../src/pages/settings/AppearanceSettings.tsx | 10 +++++----- .../src/pages/settings/ShortcutSettings.tsx | 4 ++-- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/components/Layout/index.ts b/src/renderer/src/components/Layout/index.ts index 7853481..644704c 100644 --- a/src/renderer/src/components/Layout/index.ts +++ b/src/renderer/src/components/Layout/index.ts @@ -22,7 +22,7 @@ export interface BoxProps { opacity?: string | number borderRadius?: PxValue border?: string - gap?: PxValue + $gap?: PxValue mt?: PxValue marginTop?: PxValue mb?: PxValue @@ -46,9 +46,9 @@ export interface BoxProps { } export interface StackProps extends BoxProps { - justifyContent?: 'center' | 'flex-start' | 'flex-end' | 'space-between' - alignItems?: 'center' | 'flex-start' | 'flex-end' | 'space-between' - flexDirection?: 'row' | 'row-reverse' | 'column' | 'column-reverse' + $justifyContent?: 'center' | 'flex-start' | 'flex-end' | 'space-between' + $alignItems?: 'center' | 'flex-start' | 'flex-end' | 'space-between' + $flexDirection?: 'row' | 'row-reverse' | 'column' | 'column-reverse' } export interface ButtonProps extends StackProps { @@ -90,12 +90,11 @@ export const Box = styled.div` right: ${(props) => getElementValue(props.right) || 'auto'}; bottom: ${(props) => getElementValue(props.bottom) || 'auto'}; top: ${(props) => getElementValue(props.top) || 'auto'}; - gap: ${(p) => (p.gap ? getElementValue(p.gap) : 0)}; + gap: ${(p) => (p.$gap ? getElementValue(p.$gap) : 0)}; opacity: ${(props) => props.opacity ?? 1}; border-radius: ${(props) => getElementValue(props.borderRadius) || 0}; box-sizing: border-box; border: ${(props) => props?.border || 'none'}; - gap: ${(p) => (p.gap ? getElementValue(p.gap) : 0)}; margin: ${(props) => (props.m || props.margin ? (props.m ?? props.margin) : 'none')}; margin-top: ${(props) => props.mt || props.marginTop ? getElementValue(props.mt || props.marginTop) : 'default'}; @@ -118,9 +117,9 @@ export const Box = styled.div` export const Stack = styled(Box)` display: flex; - justify-content: ${(props) => props.justifyContent ?? 'flex-start'}; - align-items: ${(props) => props.alignItems ?? 'flex-start'}; - flex-direction: ${(props) => props.flexDirection ?? 'row'}; + justify-content: ${(props) => props.$justifyContent ?? 'flex-start'}; + align-items: ${(props) => props.$alignItems ?? 'flex-start'}; + flex-direction: ${(props) => props.$flexDirection ?? 'row'}; ` export const Center = styled(Stack)` diff --git a/src/renderer/src/pages/settings/AboutSettings.tsx b/src/renderer/src/pages/settings/AboutSettings.tsx index 17646cb..1c3140f 100644 --- a/src/renderer/src/pages/settings/AboutSettings.tsx +++ b/src/renderer/src/pages/settings/AboutSettings.tsx @@ -193,7 +193,7 @@ const AboutSettings: FC = () => { {t('settings.about.title')} - + ` +const ColorCircle = styled.div<{ color: string; $isActive?: boolean }>` position: absolute; top: 50%; left: 50%; @@ -37,7 +37,7 @@ const ColorCircle = styled.div<{ color: string; isActive?: boolean }>` background-color: ${(props) => props.color}; cursor: pointer; transform: translate(-50%, -50%); - border: 2px solid ${(props) => (props.isActive ? 'var(--color-border)' : 'transparent')}; + border: 2px solid ${(props) => (props.$isActive ? 'var(--color-border)' : 'transparent')}; transition: opacity 0.2s; &:hover { @@ -169,13 +169,13 @@ export function AppearanceSettings(): React.JSX.Element { {t('settings.theme.color_primary')} - - + + {THEME_COLOR_PRESETS.map((color) => ( handleColorPrimaryChange(color)} /> diff --git a/src/renderer/src/pages/settings/ShortcutSettings.tsx b/src/renderer/src/pages/settings/ShortcutSettings.tsx index 3d36830..d2e5f7f 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -330,7 +330,7 @@ const ShortcutSettings: FC = () => { alignItems: 'center' }} > - + {isEditing ? ( { @@ -417,7 +417,7 @@ const ShortcutSettings: FC = () => { showHeader={false} /> - + From 2a6957756b97bc7c4b339651221529a1ffb263c8 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Wed, 22 Oct 2025 18:57:41 +0800 Subject: [PATCH 3/9] feat(i18n): implement comprehensive subtitle translation service with Zhipu AI integration - Add SubtitleTranslationService with batch processing and intelligent context handling - Extend ConfigManager to support Zhipu API Key configuration with secure storage - Refactor ASR settings page to "Subtitle Generation" with translation configuration group - Add comprehensive TypeScript types for translation service interfaces - Implement database layer support for batch subtitle translation updates - Add IPC channel for API Key validation between frontend and backend - Update dependencies with AI SDK packages for translation functionality - Add OpenSpec specifications for config management, translation service, and UI integration - Enable automatic background translation after subtitle generation - Add comprehensive internationalization support for translation UI components Key Features: - Intelligent translation with video filename context and subtitle batching - Automatic background translation without blocking main workflow - Robust error handling with exponential backoff retry mechanism - Comprehensive UI for API Key configuration and validation - Full internationalization support in Chinese and English Technical Improvements: - Follow project conventions with styled-components and lucide-react icons - Implement proper Zustand state management patterns - Add comprehensive logging using loggerService - Maintain type safety throughout the translation pipeline --- .gitignore | 1 + openspec/specs/config-management/spec.md | 96 ++++ openspec/specs/translation-service/spec.md | 168 +++++++ openspec/specs/ui-integration/spec.md | 151 ++++++ package.json | 4 +- packages/shared/IpcChannel.ts | 3 + packages/shared/types/index.ts | 1 + packages/shared/types/translation.ts | 89 ++++ pnpm-lock.yaml | 83 ++++ src/main/db/dao/SubtitleLibraryDAO.ts | 187 ++++++++ src/main/ipc.ts | 7 + src/main/services/ASRSubtitleService.ts | 87 ++++ src/main/services/ConfigManager.ts | 17 +- .../services/SubtitleTranslationService.ts | 441 ++++++++++++++++++ src/preload/index.ts | 4 + src/renderer/src/i18n/locales/en-us.json | 100 ++-- src/renderer/src/i18n/locales/zh-cn.json | 100 ++-- .../player/components/ASRSubtitlePrompt.tsx | 18 +- .../src/pages/settings/ASRSettings.tsx | 162 +++++-- .../src/pages/settings/SettingsPage.tsx | 6 +- stagewise.json | 6 + 21 files changed, 1602 insertions(+), 129 deletions(-) create mode 100644 openspec/specs/config-management/spec.md create mode 100644 openspec/specs/translation-service/spec.md create mode 100644 openspec/specs/ui-integration/spec.md create mode 100644 packages/shared/types/translation.ts create mode 100644 src/main/services/SubtitleTranslationService.ts create mode 100644 stagewise.json diff --git a/.gitignore b/.gitignore index 508717b..f4f61d6 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ resources/media-server .ffmpeg-cache .ffprobe-cache .cursor +openspec/changes diff --git a/openspec/specs/config-management/spec.md b/openspec/specs/config-management/spec.md new file mode 100644 index 0000000..871df3f --- /dev/null +++ b/openspec/specs/config-management/spec.md @@ -0,0 +1,96 @@ +## Purpose + +配置管理服务 SHALL 为应用程序提供统一的配置项管理功能,包括 API Key、用户偏好设置等的存储、读取和更新。系统 SHALL 支持多种配置类型的安全存储和实时通知机制。 + +## Requirements + +### Requirement: Zhipu API Key 配置管理 + +ConfigManager SHALL 支持 Zhipu API Key 的配置管理功能,包括配置枚举扩展、默认值设置、getter/setter 方法实现,以及配置变更通知机制。 + +#### Scenario: 配置枚举扩展 + +- **WHEN** 系统初始化 ConfigManager 时 +- **THEN** ConfigKeys 枚举 SHALL 包含 `ZhipuApiKey = 'zhipuApiKey'` 配置项 +- **AND** 类型系统 SHALL 正确识别新的配置项 + +#### Scenario: 配置方法实现 + +- **WHEN** 调用 `configManager.setZhipuApiKey('zhipu-api-key')` 时 +- **THEN** 系统 SHALL 正确保存 API Key +- **AND** 调用 `configManager.getZhipuApiKey()` SHALL 返回设置的值 + +#### Scenario: 默认值配置 + +- **WHEN** 新安装应用程序或配置未设置时 +- **THEN** `configManager.getZhipuApiKey()` SHALL 返回空字符串作为默认值 + +### Requirement: 配置持久化存储 + +Zhipu API Key SHALL 使用 electron-conf 进行安全存储,支持配置变更时的自动通知,并提供配置验证和错误处理功能。 + +#### Scenario: 配置持久化 + +- **WHEN** 应用程序重启后 +- **THEN** 之前设置的 Zhipu API Key SHALL 能够正确恢复 +- **AND** 配置值 SHALL 保持不变 + +#### Scenario: 配置变更通知 + +- **WHEN** 调用 `configManager.setZhipuApiKey('new-key')` 时 +- **THEN** 系统 SHALL 触发订阅者回调通知配置变更 +- **AND** 回调函数 SHALL 接收到新的配置值 + +### Requirement: 默认值管理 + +ConfigManager SHALL 在 defaultValues 对象中包含所有配置项的默认值,新添加的 Zhipu API Key 配置项 SHALL 有相应的默认值设置。 + +#### Scenario: 默认值配置更新 + +- **WHEN** 初始化 defaultValues 对象时 +- **THEN** 对象 SHALL 包含 `[ConfigKeys.ZhipuApiKey]: ''` 配置项 +- **AND** 默认值 SHALL 为空字符串 + +### Requirement: 配置类型定义 + +配置管理 SHALL 确保所有配置项都有明确的 TypeScript 类型定义,保证编译时类型安全。 + +#### Scenario: 类型定义完整性 + +- **WHEN** 使用 `ConfigKeys.ZhipuApiKey` 时 +- **THEN** TypeScript 编译器 SHALL 正确识别类型 +- **AND** 不应产生类型错误 + +## 现有配置项 + +### Deepgram 配置 + +- **DeepgramApiKey**: Deepgram 语音识别 API Key +- **DefaultLanguage**: 默认转写语言 +- **Model**: 转写模型选择 + +### 通用配置 + +- 各种用户偏好设置 +- 应用程序行为配置 +- 界面显示选项 + +## 技术要求 + +### 存储机制 + +- 使用 electron-conf 进行配置持久化 +- 支持配置的原子性更新 +- 提供配置变更的事件通知 + +### 类型安全 + +- 所有配置项都有明确的 TypeScript 类型定义 +- 枚举值确保配置键的一致性 +- 编译时类型检查防止配置错误 + +### 错误处理 + +- 配置读取失败时提供合理的默认值 +- 配置写入失败时记录错误日志 +- 支持配置验证和格式检查 diff --git a/openspec/specs/translation-service/spec.md b/openspec/specs/translation-service/spec.md new file mode 100644 index 0000000..16dbd3e --- /dev/null +++ b/openspec/specs/translation-service/spec.md @@ -0,0 +1,168 @@ +## Purpose + +字幕翻译服务 SHALL 使用 Zhipu AI 的 glm-4.5-flash 模型将生成的字幕自动翻译成中文,为语言学习者提供更好的学习体验。系统 SHALL 支持批量处理、智能翻译、错误重试和数据库集成。 + +## Requirements + +### Requirement: 翻译服务核心功能 + +SubtitleTranslationService SHALL 提供字幕翻译的核心功能,包括服务初始化、批量翻译处理和翻译结果管理。 + +#### Scenario: 服务初始化 + +- **WHEN** 创建新的 SubtitleTranslationService 实例时 +- **THEN** 服务 SHALL 能够正确初始化而不抛出异常 +- **AND** SHALL 准备好接收翻译请求 + +#### Scenario: 批量翻译调用 + +- **WHEN** 调用 `translationService.translateSubtitles()` 时 +- **THEN** 系统 SHALL 返回包含成功状态和翻译映射的结果 +- **AND** SHALL 支持指定目标语言、API Key 和视频文件名 + +### Requirement: 智能翻译处理 + +翻译服务 SHALL 构建包含上下文信息的智能翻译提示词,提供高质量的本土化翻译。 + +#### Scenario: 智能提示词构建 + +- **WHEN** 构建翻译提示词时 +- **THEN** 系统 SHALL 包含视频文件名作为上下文 +- **AND** SHALL 为每条字幕提供前后字幕作为翻译参考 +- **AND** SHALL 明确要求本土化翻译,避免直译 + +#### Scenario: 批量翻译处理 + +- **WHEN** 处理大量字幕时 +- **THEN** 系统 SHALL 将字幕分成 10-20 条的小批次 +- **AND** SHALL 为每批字幕提供前后上下文 +- **AND** SHALL 最多并发处理 2 个批次 + +### Requirement: 错误处理和重试机制 + +翻译服务 SHALL 实现完善的错误处理机制,确保系统稳定性。 + +#### Scenario: 网络错误重试 + +- **WHEN** 遇到网络错误时 +- **THEN** 系统 SHALL 自动重试最多 3 次 +- **AND** SHALL 使用指数退避策略 +- **AND** 最终失败时 SHALL 返回错误信息 + +#### Scenario: API Key 错误处理 + +- **WHEN** API Key 无效时 +- **THEN** 系统 SHALL 立即失败并返回适当的错误信息 +- **AND** SHALL 不进行重试操作 + +### Requirement: 数据库集成 + +翻译服务 SHALL 支持将翻译结果批量更新到数据库中。 + +#### Scenario: 批量更新翻译 + +- **WHEN** 翻译完成时 +- **THEN** 系统 SHALL 使用字幕 ID 映射翻译结果 +- **AND** SHALL 批量更新数据库中的 translatedText 字段 +- **AND** 更新失败时 SHALL 记录详细错误信息 + +### Requirement: ASR 服务集成 + +翻译服务 SHALL 与 ASR 字幕生成流程无缝集成。 + +#### Scenario: 自动翻译触发 + +- **WHEN** ASR 字幕保存到数据库后且配置了 Zhipu API Key 时 +- **THEN** 系统 SHALL 自动启动后台翻译任务 +- **AND** SHALL 不阻塞字幕生成的主流程 + +#### Scenario: 翻译失败隔离 + +- **WHEN** 翻译任务失败时 +- **THEN** 系统 SHALL 记录错误日志 +- **AND** SHALL 不影响字幕生成的成功状态 +- **AND** 原始字幕 SHALL 正常显示给用户 + +### Requirement: 数据库层扩展 + +数据访问层 SHALL 支持字幕翻译的批量更新操作。 + +#### Scenario: 批量更新方法 + +- **WHEN** 调用 `updateSubtitleTranslations()` 方法时 +- **THEN** 系统 SHALL 接收字幕 ID 和翻译文本的映射 +- **AND** SHALL 支持事务处理确保数据一致性 +- **AND** SHALL 返回详细的更新统计信息 + +## 与其他服务的集成 + +### ASR 服务集成 + +#### Requirement: ASR-015: 在 ASR 流程中集成翻译功能 + +**Description**: 在字幕生成完成后自动启动后台翻译任务 + +**Acceptance Criteria**: + +- 在字幕保存到数据库后检查 Zhipu API Key 配置 +- 使用 Promise.resolve().then() 启动后台任务,不阻塞主流程 +- 翻译过程完全静默进行,不影响用户操作 +- 翻译完成后更新数据库中的 translatedText 字段 + +**File**: `src/main/services/ASRSubtitleService.ts` + +#### Scenario: ASR-015-01: 自动翻译触发 + +```typescript +// 在 generateSubtitle 方法的字幕保存后 +if (subtitleLibraryId && configManager.getZhipuApiKey()) { + // 应该启动后台翻译任务 + // 不应该阻塞字幕生成流程 +} +``` + +#### Scenario: ASR-015-02: 翻译失败处理 + +```typescript +// 翻译任务失败时 +// 应该记录错误日志 +// 不应该影响字幕生成的成功状态 +// 原始字幕应该正常显示给用户 +``` + +### 数据库层集成 + +#### Requirement: DB-010: 添加批量更新翻译方法 + +**Description**: 在数据访问层添加批量更新字幕翻译的方法 + +**Acceptance Criteria**: + +- 添加 `updateSubtitleTranslations(subtitleLibraryId, translations)` 方法 +- 接收字幕 ID 和翻译文本的映射 +- 支持事务处理,确保数据一致性 +- 提供详细的错误信息和统计结果 + +**File**: `src/main/db/dao.ts` + +#### Scenario: DB-010-01: 批量更新实现 + +```typescript +const result = await db.subtitleLibrary.updateSubtitleTranslations(subtitleLibraryId, translations) +// 应该返回更新统计信息 +// 应该处理部分更新的情况 +``` + +## 技术约束 + +- **当前版本仅支持翻译为中文**(代码中需要 TODO 标记未来扩展) +- **使用 zhipu-ai-provider npm 包**(已安装) +- **遵循项目的异步文件操作规范** +- **使用 loggerService 记录日志** + +## 性能要求 + +- 批量处理大小:10-20 条字幕/批次 +- 并发批次数量:最多 2 个 +- 重试策略:指数退避,最多 3 次重试 +- 翻译过程必须在后台进行,不阻塞用户操作 diff --git a/openspec/specs/ui-integration/spec.md b/openspec/specs/ui-integration/spec.md new file mode 100644 index 0000000..90430a9 --- /dev/null +++ b/openspec/specs/ui-integration/spec.md @@ -0,0 +1,151 @@ +## Purpose + +用户界面集成 SHALL 为字幕翻译功能提供完整的前端界面实现,包括设置页面重构、配置界面、状态管理、国际化支持和 IPC 通信。系统 SHALL 确保用户体验的一致性和易用性。 + +## Requirements + +### Requirement: 设置页面重构 + +应用程序 SHALL 将 ASR 设置页面重构为"字幕生成"设置页面,包含语音识别和翻译两个功能分组。 + +#### Scenario: 页面标题更新 + +- **WHEN** 用户访问设置页面时 +- **THEN** 页面 SHALL 显示"字幕生成"作为主标题 +- **AND** SHALL 支持中英文国际化 + +#### Scenario: 分组结构重构 + +- **WHEN** 页面渲染时 +- **THEN** 系统 SHALL 显示两个主要分组 +- **AND** 第一个分组 SHALL 为"语音识别 (Deepgram)" +- **AND** 第二个分组 SHALL 为"字幕翻译 (Zhipu AI)" +- **AND** SHALL 保持现有 Deepgram 配置功能不变 + +### Requirement: 翻译配置界面 + +系统 SHALL 为字幕翻译功能提供完整的 Zhipu API Key 配置界面。 + +#### Scenario: API Key 输入组件 + +- **WHEN** 用户配置翻译功能时 +- **THEN** 系统 SHALL 提供密码类型的输入框 +- **AND** SHALL 支持实时验证状态显示 +- **AND** SHALL 在失焦时自动保存配置 + +#### Scenario: API Key 验证功能 + +- **WHEN** 用户点击验证按钮时 +- **THEN** 系统 SHALL 调用后端验证 API +- **AND** SHALL 显示验证结果反馈 +- **AND** 验证过程中 SHALL 显示加载状态 + +#### Scenario: 获取 API Key 链接 + +- **WHEN** 用户需要获取 API Key 时 +- **THEN** 系统 SHALL 提供指向 Zhipu 官网的链接 +- **AND** 链接 SHALL 在新窗口中打开 + +### Requirement: 状态管理 + +设置组件 SHALL 实现完整的翻译配置状态管理。 + +#### Scenario: 状态定义 + +- **WHEN** 组件初始化时 +- **THEN** 系统 SHALL 定义 zhipuApiKey 状态 +- **AND** SHALL 定义 zhipuApiKeyValid 验证状态 +- **AND** SHALL 定义 validatingZhipuApiKey 加载状态 + +#### Scenario: 配置加载和保存 + +- **WHEN** 组件挂载时 +- **THEN** 系统 SHALL 自动从后端加载现有配置 +- **AND** SHALL 在配置变更时自动保存 +- **AND** SHALL 遵循项目的状态管理规范 + +### Requirement: 国际化支持 + +翻译功能界面 SHALL 完整支持中英文国际化。 + +#### Scenario: 中文国际化文本 + +- **WHEN** 应用程序使用中文界面时 +- **THEN** 所有翻译相关文本 SHALL 显示为中文 +- **AND** SHALL 包含页面标题、分组名称、输入框标签等 + +#### Scenario: 英文国际化文本 + +- **WHEN** 应用程序使用英文界面时 +- **THEN** 所有翻译相关文本 SHALL 显示为英文 +- **AND** SHALL 保持与中文版本功能一致 + +### Requirement: IPC 通信 + +前后端 SHALL 通过 IPC 通道实现翻译功能的通信。 + +#### Scenario: IPC 通道定义 + +- **WHEN** 前端需要验证 API Key 时 +- **THEN** 主进程 SHALL 提供 'translation:validateApiKey' 处理器 +- **AND** SHALL 调用翻译服务进行验证 + +#### Scenario: Preload API 暴露 + +- **WHEN** 前端组件需要访问翻译功能时 +- **THEN** preload 脚本 SHALL 暴露 translation API +- **AND** SHALL 提供 validateApiKey 方法 + +### Requirement: 用户体验设计 + +界面 SHALL 遵循一致的设计语言和用户体验标准。 + +#### Scenario: 错误处理反馈 + +- **WHEN** 配置操作失败时 +- **THEN** 系统 SHALL 提供清晰的错误提示 +- **AND** SHALL 指导用户如何解决问题 + +#### Scenario: 成功状态反馈 + +- **WHEN** 配置操作成功时 +- **THEN** 系统 SHALL 提供适当的成功反馈 +- **AND** SHALL 在适当时机自动消失 + +## 设计要求 + +### 界面布局 + +- 使用 Ant Design 组件库保持设计一致性 +- 采用清晰的分组结构,便于用户理解 +- 提供适当的间距和视觉层次 + +### 用户体验 + +- 配置变更即时保存,无需手动确认 +- 提供清晰的错误提示和成功反馈 +- API Key 验证过程显示加载状态 + +### 响应式设计 + +- 支持不同屏幕尺寸的适配 +- 确保在移动设备上的可用性 + +## 技术约束 + +### 状态管理规范 + +- 使用 React Hooks 进行状态管理 +- 遵循项目中关于 Zustand 使用的规定 +- 避免在 useEffect 中调用 store Hook + +### 样式实现 + +- 优先使用 styled-components 而非全局 SCSS +- 使用 CSS 变量而非硬编码样式值 +- 保持与项目整体设计系统的一致性 + +### 图标使用 + +- 统一使用 lucide-react 图标库 +- 避免使用 emoji 作为图标 diff --git a/package.json b/package.json index e98099c..e661acc 100644 --- a/package.json +++ b/package.json @@ -59,12 +59,14 @@ "ffmpeg:test": "tsx scripts/test-ffmpeg-integration.ts" }, "dependencies": { + "@ai-sdk/openai": "^2.0.53", "@ant-design/icons": "^6.0.1", "@ant-design/v5-patch-for-react-19": "^1.0.3", "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", "@remotion/media-parser": "^4.0.344", "@sentry/electron": "^5.12.0", + "ai": "^5.0.76", "antd": "^5.27.3", "better-sqlite3": "^12.2.0", "dompurify": "^3.2.6", @@ -103,12 +105,12 @@ "@types/better-sqlite3": "^7.6.13", "@types/cli-progress": "^3.11.6", "@types/dompurify": "^3.2.0", + "@types/hls.js": "^1.0.0", "@types/node": "^22.18.1", "@types/react": "^19.1.12", "@types/react-dom": "^19.1.9", "@types/react-virtualized": "^9.22.2", "@types/yaml": "^1.9.7", - "@types/hls.js": "^1.0.0", "@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react-swc": "^3.11.0", "@vitest/coverage-v8": "^2.1.9", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 920a320..6f66036 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -146,6 +146,9 @@ export enum IpcChannel { ASR_Cancel = 'asr:cancel', ASR_ValidateApiKey = 'asr:validate-api-key', + // 翻译相关 IPC 通道 / Translation related IPC channels + Translation_ValidateApiKey = 'translation:validate-api-key', + // 文件系统相关 IPC 通道 / File system related IPC channels Fs_CheckFileExists = 'fs:check-file-exists', Fs_ReadFile = 'fs:read-file', diff --git a/packages/shared/types/index.ts b/packages/shared/types/index.ts index 3c5eaa1..f92114c 100644 --- a/packages/shared/types/index.ts +++ b/packages/shared/types/index.ts @@ -6,3 +6,4 @@ export * from './database' export * from './media-server' export * from './mediainfo' export * from './system' +export * from './translation' diff --git a/packages/shared/types/translation.ts b/packages/shared/types/translation.ts new file mode 100644 index 0000000..78be00f --- /dev/null +++ b/packages/shared/types/translation.ts @@ -0,0 +1,89 @@ +/** + * Translation related types + */ + +export interface TranslationOptions { + /** Target language for translation (currently only supports 'zh-CN') */ + targetLanguage: string + /** Source language (auto-detected if not specified) */ + sourceLanguage?: string + /** Batch size for translation requests */ + batchSize?: number + /** Maximum number of concurrent requests */ + maxConcurrency?: number + /** Video filename for context */ + videoFilename?: string +} + +export interface TranslationResult { + /** Original text */ + originalText: string + /** Translated text */ + translatedText: string + /** Source language (detected) */ + sourceLanguage?: string + /** Target language */ + targetLanguage: string + /** Whether translation was successful */ + success: boolean + /** Error message if translation failed */ + error?: string +} + +export interface TranslationBatchResult { + /** Array of translation results */ + results: TranslationResult[] + /** Number of successful translations */ + successCount: number + /** Number of failed translations */ + failureCount: number + /** Total processing time in milliseconds */ + processingTime: number +} + +export interface TranslationProgress { + /** Current batch being processed */ + currentBatch: number + /** Total number of batches */ + totalBatches: number + /** Number of items processed in current batch */ + currentBatchProgress: number + /** Total number of items in current batch */ + currentBatchSize: number + /** Overall progress percentage (0-100) */ + overallProgress: number +} + +export interface TranslationServiceConfig { + /** Zhipu API Key */ + apiKey: string + /** Default translation options */ + defaultOptions: TranslationOptions + /** Request timeout in milliseconds */ + timeout?: number + /** Maximum retry attempts */ + maxRetries?: number + /** Retry delay in milliseconds */ + retryDelay?: number +} + +/** + * Translation status for subtitle items + */ +export type TranslationStatus = 'pending' | 'processing' | 'completed' | 'failed' + +/** + * Translation metadata for tracking translation state + */ +export interface TranslationMetadata { + /** ID of the subtitle item */ + subtitleId: string + /** Current translation status */ + status: TranslationStatus + /** Last error message */ + lastError?: string + /** Translation timestamp */ + translatedAt?: number + /** Translation attempt count */ + attemptCount: number +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbe50fe..2697c31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: .: dependencies: + '@ai-sdk/openai': + specifier: ^2.0.53 + version: 2.0.53(zod@3.25.76) '@ant-design/icons': specifier: ^6.0.1 version: 6.0.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -32,6 +35,9 @@ importers: '@sentry/electron': specifier: ^5.12.0 version: 5.12.0 + ai: + specifier: ^5.0.76 + version: 5.0.76(zod@3.25.76) antd: specifier: ^5.27.3 version: 5.27.3(moment@2.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -315,6 +321,28 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ai-sdk/gateway@2.0.0': + resolution: {integrity: sha512-Gj0PuawK7NkZuyYgO/h5kDK/l6hFOjhLdTq3/Lli1FTl47iGmwhH1IZQpAL3Z09BeFYWakcwUmn02ovIm2wy9g==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/openai@2.0.53': + resolution: {integrity: sha512-GIkR3+Fyif516ftXv+YPSPstnAHhcZxNoR2s8uSHhQ1yBT7I7aQYTVwpjAuYoT3GR+TeP50q7onj2/nDRbT2FQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@3.0.12': + resolution: {integrity: sha512-ZtbdvYxdMoria+2SlNarEk6Hlgyf+zzcznlD55EAl+7VZvJaSg2sqPvwArY7L6TfDEDJsnCq0fdhBSkYo0Xqdg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@2.0.0': + resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==} + engines: {node: '>=18'} + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -1897,6 +1925,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@svta/common-media-library@0.12.4': resolution: {integrity: sha512-9EuOoaNmz7JrfGwjsrD9SxF9otU5TNMnbLu1yU4BeLK0W5cDxVXXR58Z89q9u2AnHjIctscjMTYdlqQ1gojTuw==} @@ -2220,6 +2251,10 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@vercel/oidc@3.0.3': + resolution: {integrity: sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg==} + engines: {node: '>= 20'} + '@vimeo/player@2.29.0': resolution: {integrity: sha512-9JjvjeqUndb9otCCFd0/+2ESsLk7VkDE6sxOBy9iy2ukezuQbplVRi+g9g59yAurKofbmTi/KcKxBGO/22zWRw==} @@ -2337,6 +2372,12 @@ packages: resolution: {integrity: sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==} engines: {node: '>=18'} + ai@5.0.76: + resolution: {integrity: sha512-ZCxi1vrpyCUnDbtYrO/W8GLvyacV9689f00yshTIQ3mFFphbD7eIv40a2AOZBv3GGRA7SSRYIDnr56wcS/gyQg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ajv-keywords@3.5.2: resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: @@ -3505,6 +3546,10 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -7374,6 +7419,30 @@ snapshots: '@adobe/css-tools@4.4.4': {} + '@ai-sdk/gateway@2.0.0(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.12(zod@3.25.76) + '@vercel/oidc': 3.0.3 + zod: 3.25.76 + + '@ai-sdk/openai@2.0.53(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.12(zod@3.25.76) + zod: 3.25.76 + + '@ai-sdk/provider-utils@3.0.12(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 2.0.0 + '@standard-schema/spec': 1.0.0 + eventsource-parser: 3.0.6 + zod: 3.25.76 + + '@ai-sdk/provider@2.0.0': + dependencies: + json-schema: 0.4.0 + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -9329,6 +9398,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/spec@1.0.0': {} + '@svta/common-media-library@0.12.4': {} '@swc/core-darwin-arm64@1.13.5': @@ -9688,6 +9759,8 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@vercel/oidc@3.0.3': {} + '@vimeo/player@2.29.0': dependencies: native-promise-only: 0.8.1 @@ -9844,6 +9917,14 @@ snapshots: clean-stack: 5.2.0 indent-string: 5.0.0 + ai@5.0.76(zod@3.25.76): + dependencies: + '@ai-sdk/gateway': 2.0.0(zod@3.25.76) + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.12(zod@3.25.76) + '@opentelemetry/api': 1.9.0 + zod: 3.25.76 + ajv-keywords@3.5.2(ajv@6.12.6): dependencies: ajv: 6.12.6 @@ -11394,6 +11475,8 @@ snapshots: eventemitter3@5.0.1: {} + eventsource-parser@3.0.6: {} + execa@5.1.1: dependencies: cross-spawn: 7.0.6 diff --git a/src/main/db/dao/SubtitleLibraryDAO.ts b/src/main/db/dao/SubtitleLibraryDAO.ts index b87912b..2269356 100644 --- a/src/main/db/dao/SubtitleLibraryDAO.ts +++ b/src/main/db/dao/SubtitleLibraryDAO.ts @@ -199,4 +199,191 @@ export class SubtitleLibraryDAO extends BaseDAO< const whereCondition = videoId ? { field: 'videoId', value: videoId } : undefined return await this.getRecordCount(whereCondition) } + + /** + * 获取视频的所有字幕项(解析 JSON 后返回) + */ + async getSubtitlesByVideoId(videoId: number): Promise< + Array<{ + id: string + text: string + startTime: number + endTime: number + translatedText?: string + }> + > { + const subtitleRecords = await this.findByVideoId(videoId) + const allSubtitles: Array<{ + id: string + text: string + startTime: number + endTime: number + translatedText?: string + }> = [] + + for (const record of subtitleRecords) { + if (record.subtitles) { + try { + const subtitles = JSON.parse(record.subtitles) as Array<{ + id: string + text: string + startTime: number + endTime: number + translatedText?: string + }> + allSubtitles.push(...subtitles) + } catch (error) { + // JSON 解析失败,跳过此记录 + } + } + } + + return allSubtitles + } + + /** + * 更新单个字幕的翻译文本 + */ + async updateSubtitleTranslation(subtitleId: string, translatedText: string): Promise { + try { + // 查找包含该字幕的记录 + const allRecords = await this.findAll() + + for (const record of allRecords) { + if (record.subtitles) { + try { + const subtitles = JSON.parse(record.subtitles) as Array<{ + id: string + text: string + startTime: number + endTime: number + translatedText?: string + }> + + // 查找并更新对应的字幕 + const subtitleIndex = subtitles.findIndex((sub) => sub.id === subtitleId) + if (subtitleIndex !== -1) { + subtitles[subtitleIndex].translatedText = translatedText + + // 更新数据库记录 + await this.updateSubtitle(record.id, { + subtitles: JSON.stringify(subtitles) + }) + + return true + } + } catch (error) { + // JSON 解析失败,跳过此记录 + } + } + } + + return false + } catch (error) { + return false + } + } + + /** + * 批量更新字幕翻译文本 + */ + async updateSubtitleTranslations( + translations: Array<{ + subtitleId: string + translatedText: string + }> + ): Promise<{ + successCount: number + failureCount: number + errors: string[] + }> { + const result = { + successCount: 0, + failureCount: 0, + errors: [] as string[] + } + + try { + // 获取所有字幕记录 + const allRecords = await this.findAll() + + // 按 subtitleId 对翻译进行分组,以提高效率 + const translationsByRecord = new Map< + number, + Array<{ + subtitleId: string + translatedText: string + }> + >() + + // 将翻译分配到对应的记录 + for (const record of allRecords) { + if (record.subtitles) { + try { + const subtitles = JSON.parse(record.subtitles) as Array<{ + id: string + text: string + startTime: number + endTime: number + translatedText?: string + }> + + const matchingTranslations = translations.filter((translation) => + subtitles.some((subtitle) => subtitle.id === translation.subtitleId) + ) + + if (matchingTranslations.length > 0) { + translationsByRecord.set(record.id, matchingTranslations) + } + } catch (error) { + result.errors.push(`Failed to parse subtitles JSON for record ${record.id}: ${error}`) + result.failureCount++ + } + } + } + + // 批量更新每个记录 + for (const [recordId, recordTranslations] of translationsByRecord) { + try { + const record = allRecords.find((r) => r.id === recordId) + if (record && record.subtitles) { + const subtitles = JSON.parse(record.subtitles) as Array<{ + id: string + text: string + startTime: number + endTime: number + translatedText?: string + }> + + // 更新匹配的字幕翻译 + let updatedCount = 0 + for (const translation of recordTranslations) { + const subtitleIndex = subtitles.findIndex((sub) => sub.id === translation.subtitleId) + if (subtitleIndex !== -1) { + subtitles[subtitleIndex].translatedText = translation.translatedText + updatedCount++ + } + } + + // 如果有更新,保存到数据库 + if (updatedCount > 0) { + await this.updateSubtitle(recordId, { + subtitles: JSON.stringify(subtitles) + }) + result.successCount += updatedCount + } + } + } catch (error) { + result.errors.push(`Failed to update record ${recordId}: ${error}`) + result.failureCount++ + } + } + + return result + } catch (error) { + result.errors.push(`Batch update failed: ${error}`) + result.failureCount += translations.length + return result + } + } } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index b7de6de..feb729a 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -32,6 +32,7 @@ import NotificationService from './services/NotificationService' import { pythonVenvService } from './services/PythonVenvService' import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService' import SubtitleExtractorService from './services/SubtitleExtractorService' +import { subtitleTranslationService } from './services/SubtitleTranslationService' import { themeService } from './services/ThemeService' import { uvBootstrapperService } from './services/UvBootstrapperService' import { calculateDirectorySize, getResourcePath } from './utils' @@ -742,6 +743,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { return await asrSubtitleService.validateApiKey(apiKey) }) + // 翻译相关处理程序 + ipcMain.handle(IpcChannel.Translation_ValidateApiKey, async (_, apiKey: string) => { + logger.info('验证 Zhipu API Key') + return await subtitleTranslationService.validateApiKey(apiKey) + }) + // 文件系统相关 IPC 处理程序 / File system-related IPC handlers ipcMain.handle(IpcChannel.Fs_CheckFileExists, async (_, filePath: string) => { try { diff --git a/src/main/services/ASRSubtitleService.ts b/src/main/services/ASRSubtitleService.ts index 038296f..ea2b55b 100644 --- a/src/main/services/ASRSubtitleService.ts +++ b/src/main/services/ASRSubtitleService.ts @@ -25,6 +25,7 @@ import SubtitleFormatter from './asr/SubtitleFormatter' import AudioPreprocessor from './audio/AudioPreprocessor' import { configManager } from './ConfigManager' import { loggerService } from './LoggerService' +import { subtitleTranslationService } from './SubtitleTranslationService' const logger = loggerService.withContext('ASRSubtitleService') @@ -223,6 +224,9 @@ class ASRSubtitleService { }) subtitleLibraryId = result.id logger.info('字幕保存到数据库成功', { subtitleLibraryId }) + + // 启动后台翻译任务 + this.startBackgroundTranslation(options.videoId, options.videoPath) } catch (error) { logger.error('保存字幕到数据库失败', { error: error instanceof Error ? error.message : String(error) @@ -524,6 +528,89 @@ class ASRSubtitleService { } } + /** + * 启动后台翻译任务 + */ + private async startBackgroundTranslation(videoId: number, videoPath: string): Promise { + try { + // 检查是否配置了 Zhipu API Key + const zhipuApiKey = configManager.getZhipuApiKey() + if (!zhipuApiKey) { + logger.debug('未配置 Zhipu API Key,跳过翻译任务') + return + } + + // 检查翻译服务是否可用 + if (!subtitleTranslationService.isServiceAvailable()) { + logger.warn('翻译服务不可用,跳过翻译任务') + return + } + + // 提取视频文件名作为上下文 + const videoFilename = path.basename(videoPath, path.extname(videoPath)) + + logger.info('启动后台翻译任务', { videoId, videoFilename }) + + // 异步启动翻译任务,不阻塞主流程 + this.translateSubtitlesInBackground(videoId, videoFilename).catch((error) => { + logger.error('后台翻译任务失败', { + videoId, + error: error instanceof Error ? error.message : String(error) + }) + }) + } catch (error) { + logger.error('启动后台翻译任务失败', { + videoId, + error: error instanceof Error ? error.message : String(error) + }) + } + } + + /** + * 后台翻译字幕的具体实现 + */ + private async translateSubtitlesInBackground( + videoId: number, + videoFilename: string + ): Promise { + try { + const translationOptions = { + targetLanguage: 'zh-CN', // 当前版本仅支持翻译为中文 + batchSize: 15, + maxConcurrency: 2, + videoFilename + } + + const result = await subtitleTranslationService.translateSubtitles( + videoId, + translationOptions + ) + + logger.info('后台翻译任务完成', { + videoId, + successCount: result.successCount, + failureCount: result.failureCount, + processingTime: `${(result.processingTime / 1000).toFixed(2)}s` + }) + + // 如果有翻译失败的情况,记录详细信息 + if (result.failureCount > 0) { + const failedTranslations = result.results.filter((r) => !r.success) + logger.warn('部分字幕翻译失败', { + videoId, + failureCount: result.failureCount, + errors: failedTranslations.map((r) => r.error).filter(Boolean) + }) + } + } catch (error) { + logger.error('后台翻译任务执行失败', { + videoId, + error: error instanceof Error ? error.message : String(error) + }) + throw error + } + } + /** * 获取错误代码 */ diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index ad8b3de..2055891 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -43,7 +43,9 @@ export enum ConfigKeys { // ASR 相关配置 DeepgramApiKey = 'deepgramApiKey', ASRDefaultLanguage = 'asrDefaultLanguage', - ASRModel = 'asrModel' + ASRModel = 'asrModel', + // Zhipu 翻译配置 + ZhipuApiKey = 'zhipuApiKey' } // 获取基于版本的动态默认值 @@ -64,7 +66,9 @@ const defaultValues: Record = { // ASR 默认配置 [ConfigKeys.DeepgramApiKey]: '', [ConfigKeys.ASRDefaultLanguage]: 'en', - [ConfigKeys.ASRModel]: 'nova-3' + [ConfigKeys.ASRModel]: 'nova-3', + // Zhipu 翻译默认配置 + [ConfigKeys.ZhipuApiKey]: '' } export class ConfigManager { @@ -241,6 +245,15 @@ export class ConfigManager { setASRModel(model: string) { this.setAndNotify(ConfigKeys.ASRModel, model) } + + // Zhipu 翻译相关配置方法 + getZhipuApiKey(): string { + return this.get(ConfigKeys.ZhipuApiKey, '') + } + + setZhipuApiKey(apiKey: string) { + this.setAndNotify(ConfigKeys.ZhipuApiKey, apiKey) + } } export const configManager = new ConfigManager() diff --git a/src/main/services/SubtitleTranslationService.ts b/src/main/services/SubtitleTranslationService.ts new file mode 100644 index 0000000..5e26d5b --- /dev/null +++ b/src/main/services/SubtitleTranslationService.ts @@ -0,0 +1,441 @@ +/** + * 字幕翻译服务 + * 使用 Zhipu AI 进行字幕翻译 + */ + +import { createOpenAI } from '@ai-sdk/openai' +import type { + TranslationBatchResult, + TranslationOptions, + TranslationProgress, + TranslationResult +} from '@shared/types' +import { generateText } from 'ai' + +import { db } from '../db/dao' +import { configManager } from './ConfigManager' +import { loggerService } from './LoggerService' + +const logger = loggerService.withContext('SubtitleTranslationService') + +export type TranslationProgressCallback = (progress: TranslationProgress) => void + +class SubtitleTranslationService { + private client: ReturnType | null = null + private model: string = 'glm-4.5-flash' + private baseUrl = 'https://open.bigmodel.cn/api/paas/v4' + + constructor() { + this.initializeLLMClient() + logger.info('字幕翻译服务初始化完成') + } + + /** + * 初始化 Zhipu 客户端 + */ + private initializeLLMClient(): void { + const apiKey = configManager.getZhipuApiKey() + if (apiKey) { + this.client = createOpenAI({ + apiKey, + baseURL: this.baseUrl, + name: 'zhipu' + }) + logger.debug('Zhipu 客户端初始化成功') + } else { + logger.warn('Zhipu API Key 未配置,翻译服务不可用') + } + } + + /** + * 重新初始化 Zhipu 客户端(用于配置更新后) + */ + public reinitializeClient(): void { + this.initializeLLMClient() + } + + /** + * 检查翻译服务是否可用 + */ + public isServiceAvailable(): boolean { + return !!this.client + } + + /** + * 构建翻译提示词(统一使用批量格式) + */ + private buildTranslationPrompt( + subtitles: Array<{ text: string; index: number }>, + videoFilename?: string + ): string { + const count = subtitles.length + + let prompt = `请将以下${count}条字幕翻译成自然流畅的中文,保持上下文的连贯性和一致性:\n\n` + + // 添加视频文件名作为上下文 + if (videoFilename) { + prompt += `视频文件:${videoFilename}\n\n` + } + + prompt += `字幕列表:\n` + subtitles.forEach((subtitle, index) => { + prompt += `${index + 1}. ${subtitle.text}\n` + }) + + prompt += `\n请以JSON格式返回翻译结果,格式如下:\n` + if (count === 1) { + prompt += `{\n "translations": [\n "字幕的中文翻译"\n ]\n}\n\n` + } else { + prompt += `{\n "translations": [\n "第1条字幕的中文翻译",\n "第2条字幕的中文翻译",\n "第3条字幕的中文翻译"\n ]\n}\n\n` + } + + prompt += `要求:\n1. 翻译要准确、自然、符合中文表达习惯\n2. 保留原文的情感色彩和语境\n3. 保持整个序列的翻译风格一致\n4. 如果有专业术语,请使用对应的中文专业词汇\n5. 只返回JSON格式的翻译结果,不要包含其他内容` + + return prompt + } + + /** + * 翻译字幕(统一处理单条和批量) + */ + private async translate( + subtitles: Array<{ text: string; index: number }>, + options: TranslationOptions, + videoFilename?: string, + retryCount: number = 0 + ): Promise { + try { + if (!this.client) { + throw new Error('Zhipu 客户端未初始化') + } + + const prompt = this.buildTranslationPrompt(subtitles, videoFilename) + + logger.debug('开始翻译字幕', { + count: subtitles.length, + firstText: subtitles[0]?.text?.substring(0, 50) + }) + + const { text: aiGeneratedText } = await generateText({ + model: this.client.chat(this.model), + prompt: prompt, + temperature: 0.3 + }) + + const responseText = aiGeneratedText.trim() + if (!responseText) { + throw new Error('翻译结果为空') + } + + // 解析JSON响应 + let translations: string[] + try { + const parsed = JSON.parse(responseText) + translations = parsed.translations || [] + } catch (parseError) { + logger.warn('JSON解析失败,尝试提取翻译内容', { + error: parseError instanceof Error ? parseError.message : String(parseError), + response: responseText.substring(0, 200) + }) + + // 如果JSON解析失败,尝试简单的文本提取 + translations = this.extractTranslationsFromText(responseText, subtitles.length) + } + + if (translations.length !== subtitles.length) { + throw new Error( + `翻译数量不匹配,期望 ${subtitles.length} 条,实际 ${translations.length} 条` + ) + } + + logger.debug('翻译成功', { + count: subtitles.length, + firstOriginal: subtitles[0]?.text?.substring(0, 30), + firstTranslated: translations[0]?.substring(0, 30) + }) + + // 返回翻译结果 + return subtitles.map((subtitle, index) => ({ + originalText: subtitle.text, + translatedText: translations[index] || '', + sourceLanguage: 'auto', + targetLanguage: options.targetLanguage, + success: true + })) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + const maxRetries = 3 + const retryDelay = 1000 // 1秒基础延迟 + + // 判断是否应该重试 + const shouldRetry = + retryCount < maxRetries && + (errorMessage.includes('network') || + errorMessage.includes('timeout') || + errorMessage.includes('rate limit') || + errorMessage.includes('quota') || + errorMessage.includes('ECONNRESET') || + errorMessage.includes('ETIMEDOUT')) + + if (shouldRetry) { + const nextRetryCount = retryCount + 1 + const delay = retryDelay * Math.pow(2, retryCount) // 指数退避 + + logger.warn(`翻译失败,将在 ${delay}ms 后进行第 ${nextRetryCount} 次重试`, { + count: subtitles.length, + error: errorMessage, + retryCount: nextRetryCount + }) + + // 等待重试延迟 + await new Promise((resolve) => setTimeout(resolve, delay)) + + // 递归重试 + return this.translate(subtitles, options, videoFilename, nextRetryCount) + } + + // 重试次数用完或不可重试的错误,返回失败结果 + logger.error('翻译最终失败', { + count: subtitles.length, + error: errorMessage, + retryCount + }) + + return subtitles.map((subtitle) => ({ + originalText: subtitle.text, + translatedText: '', + sourceLanguage: 'auto', + targetLanguage: options.targetLanguage, + success: false, + error: errorMessage + })) + } + } + + /** + * 从文本中提取翻译内容(JSON解析失败时的备用方案) + */ + private extractTranslationsFromText(text: string, expectedCount: number): string[] { + const translations: string[] = [] + + // 尝试按行分割并提取引号内容 + const lines = text.split('\n') + for (const line of lines) { + const match = line.match(/"([^"]+)"/) + if (match && translations.length < expectedCount) { + translations.push(match[1]) + } + } + + // 如果提取数量不够,尝试按数字序号分割 + if (translations.length < expectedCount) { + const numberedLines = text.split(/\d+\.\s*/) + for (let i = 1; i < numberedLines.length && translations.length < expectedCount; i++) { + const cleaned = numberedLines[i].trim().replace(/^["']|["']$/g, '') + if (cleaned) { + translations.push(cleaned) + } + } + } + + return translations + } + + /** + * 批量翻译字幕 + */ + public async translateSubtitles( + videoId: string | number, + options: TranslationOptions, + progressCallback?: TranslationProgressCallback + ): Promise { + const startTime = Date.now() + + if (!this.client) { + throw new Error('翻译服务不可用,请检查 Zhipu API Key 配置') + } + + logger.info('开始批量翻译字幕', { videoId, targetLanguage: options.targetLanguage }) + + try { + // 获取未翻译的字幕 + const subtitles = await db.subtitleLibrary.getSubtitlesByVideoId(videoId as number) + const untranslatedSubtitles = subtitles.filter((subtitle) => !subtitle.translatedText) + + if (untranslatedSubtitles.length === 0) { + logger.info('没有需要翻译的字幕') + return { + results: [], + successCount: 0, + failureCount: 0, + processingTime: Date.now() - startTime + } + } + + logger.info('找到需要翻译的字幕', { count: untranslatedSubtitles.length }) + + const batchSize = options.batchSize || 15 + const maxConcurrency = options.maxConcurrency || 2 + const totalBatches = Math.ceil(untranslatedSubtitles.length / batchSize) + + let successCount = 0 + let failureCount = 0 + const allResults: TranslationResult[] = [] + + // 分批处理 + for (let i = 0; i < totalBatches; i += maxConcurrency) { + const batchPromises: Promise[] = [] + + // 创建并发批次 + for (let j = i; j < Math.min(i + maxConcurrency, totalBatches); j++) { + const batchStart = j * batchSize + const batchEnd = Math.min(batchStart + batchSize, untranslatedSubtitles.length) + const batch = untranslatedSubtitles.slice(batchStart, batchEnd) + + const batchPromise = this.processBatch( + batch, + options, + j + 1, + totalBatches, + progressCallback + ) + + batchPromises.push(batchPromise) + } + + // 等待当前批次组完成 + const batchResults = await Promise.all(batchPromises) + + // 合并结果 + for (const results of batchResults) { + allResults.push(...results) + + // 统计成功/失败数量 + results.forEach((result) => { + if (result.success) { + successCount++ + } else { + failureCount++ + } + }) + } + } + + logger.info('字幕翻译完成', { + total: untranslatedSubtitles.length, + success: successCount, + failure: failureCount, + processingTime: Date.now() - startTime + }) + + return { + results: allResults, + successCount, + failureCount, + processingTime: Date.now() - startTime + } + } catch (error) { + logger.error('批量翻译失败', { + videoId, + error: error instanceof Error ? error.message : String(error) + }) + throw error + } + } + + /** + * 处理单个批次 + */ + private async processBatch( + batch: Array<{ id: string; text: string; startTime: number; endTime: number }>, + options: TranslationOptions, + batchNumber: number, + totalBatches: number, + progressCallback?: TranslationProgressCallback + ): Promise { + logger.debug('处理翻译批次', { + batchNumber, + totalBatches, + batchSize: batch.length + }) + + // 准备字幕数据 + const subtitlesForTranslation = batch.map((subtitle, index) => ({ + text: subtitle.text, + index + })) + + // 使用统一的翻译方法 + const translationResults = await this.translate( + subtitlesForTranslation, + options, + options.videoFilename + ) + + const results: TranslationResult[] = [] + + // 处理翻译结果并更新数据库 + for (let i = 0; i < batch.length; i++) { + const subtitle = batch[i] + const result = translationResults[i] + + results.push(result) + + // 如果翻译成功,立即更新数据库 + if (result.success) { + try { + await db.subtitleLibrary.updateSubtitleTranslation(subtitle.id, result.translatedText) + } catch (error) { + logger.error('更新字幕翻译失败', { + subtitleId: subtitle.id, + error: error instanceof Error ? error.message : String(error) + }) + } + } + + // 更新进度 + if (progressCallback) { + progressCallback({ + currentBatch: batchNumber, + totalBatches, + currentBatchProgress: i + 1, + currentBatchSize: batch.length, + overallProgress: Math.round( + (((batchNumber - 1) * (options.batchSize || 15) + i + 1) / + (totalBatches * (options.batchSize || 15))) * + 100 + ) + }) + } + } + + return results + } + + /** + * 验证 API Key 是否有效 + */ + public async validateApiKey(apiKey: string): Promise { + try { + const testClient = createOpenAI({ + apiKey, + baseURL: this.baseUrl, + name: 'zhipu' + }) + + const { text } = await generateText({ + model: testClient.chat(this.model), + prompt: '请回复"API Key验证成功"', + temperature: 0.1 + }) + + const result = text.trim() + + return (result && result.includes('成功')) || false + } catch (error) { + logger.error('API Key 验证失败', { error }) + return false + } + } +} + +export const subtitleTranslationService = new SubtitleTranslationService() diff --git a/src/preload/index.ts b/src/preload/index.ts index d77bd60..1a3841b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -283,6 +283,10 @@ const api = { } } }, + translation: { + validateApiKey: (apiKey: string): Promise => + ipcRenderer.invoke(IpcChannel.Translation_ValidateApiKey, apiKey) + }, uv: { checkInstallation: (): Promise<{ exists: boolean diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index b885094..2c4e339 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -7,7 +7,18 @@ "language": "language", "selectedItems": "{{count}} items selected", "selectedItems_one": "{{count}} item selected", - "selectedItems_other": "{{count}} items selected" + "selectedItems_other": "{{count}} items selected", + "apiKey": { + "label": "API Key", + "placeholder": "Enter your API Key", + "invalid": "API Key is invalid", + "valid": "API Key is valid", + "saveFailed": "Failed to save", + "saved": "API Key saved", + "validate": "Validate", + "validating": "Validating...", + "getKey": "Get API Key" + } }, "docs": { "title": "Documentation" @@ -21,6 +32,11 @@ "networkError": "Network error, please check your connection and retry", "noApiKey": "Please configure Deepgram API Key in settings first", "transcriptionFailed": "Transcription failed, please retry", + "translationApiKeyInvalid": "Invalid Zhipu API Key, please check settings", + "translationApiKeyMissing": "Please configure Zhipu API Key in settings first", + "translationFailed": "Translation failed, please retry", + "translationNetworkError": "Translation network error, please check your connection and retry", + "translationQuotaExceeded": "Translation API quota exceeded, please check your Zhipu account", "unknown": "Generation failed: {{message}}" }, "progress": { @@ -235,43 +251,6 @@ "appearance": { "title": "Appearance Settings" }, - "asr": { - "apiKey": { - "description": "Get an API Key from Deepgram to use AI subtitle generation features", - "getKey": "Get API Key", - "invalid": "API Key is invalid", - "label": "Deepgram API Key", - "placeholder": "Enter your Deepgram API Key", - "saveFailed": "Failed to save", - "saved": "API Key saved", - "valid": "API Key is valid", - "validate": "Validate", - "validating": "Validating..." - }, - "defaultLanguage": { - "description": "Default language for automatic subtitle generation", - "label": "Default Language" - }, - "description": "Configure AI subtitle auto-generation using Deepgram speech recognition technology to generate accurate subtitles for videos", - "languages": { - "auto": "Auto Detect", - "de": "German", - "en": "English", - "es": "Spanish", - "fr": "French", - "ja": "Japanese", - "ko": "Korean", - "ru": "Russian", - "zh": "Chinese" - }, - "model": { - "description": "Select Deepgram transcription model", - "label": "Transcription Model", - "nova2": "Nova 2 (Recommended)", - "nova3": "Nova 3 (Latest)" - }, - "title": "Speech Recognition" - }, "developer": { "enable_developer_mode": "Enable developer mode", "title": "Developer mode" @@ -452,6 +431,51 @@ "zoom_out": "Minimize interface", "zoom_reset": "Reset zoom" }, + "subtitleGeneration": { + "description": "Configure AI subtitle generation features, supporting speech recognition and automatic translation", + "speechRecognition": { + "apiKey": { + "description": "Get an API Key from Deepgram to use AI subtitle generation features", + "getKey": "Get API Key", + "label": "API Key", + "placeholder": "Enter your Deepgram API Key" + }, + "defaultLanguage": { + "description": "Default language for automatic subtitle generation", + "label": "Default Language" + }, + "description": "Automatically generate accurate subtitles using Deepgram speech recognition technology, supporting multiple languages and word-level timestamps", + "languages": { + "auto": "Auto Detect", + "de": "German", + "en": "English", + "es": "Spanish", + "fr": "French", + "ja": "Japanese", + "ko": "Korean", + "ru": "Russian", + "zh": "Chinese" + }, + "model": { + "description": "Select Deepgram transcription model", + "label": "Transcription Model", + "nova2": "Nova 2 (Recommended)", + "nova3": "Nova 3 (Latest)" + }, + "title": "Speech Recognition" + }, + "title": "Subtitle Generation", + "translation": { + "apiKey": { + "description": "Get an API Key from Zhipu AI to use subtitle translation features", + "getKey": "Get API Key", + "label": "Zhipu API Key", + "placeholder": "Enter your Zhipu API Key" + }, + "description": "Automatically translate generated subtitles to Chinese using Zhipu AI, providing localized translation results", + "title": "Subtitle Translation" + } + }, "theme": { "color_primary": "Primary color", "dark": "dark color", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index a3138f5..d1eccf4 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -18,7 +18,18 @@ "search": "搜索", "search_no_results": "暂无搜索结果", "search_placeholder": "搜索视频...", - "selectedItems": "已选择 {{count}} 项" + "selectedItems": "已选择 {{count}} 项", + "apiKey": { + "label": "API Key", + "placeholder": "输入您的 API Key", + "invalid": "API Key 无效", + "valid": "API Key 有效", + "saveFailed": "保存失败", + "saved": "API Key 已保存", + "validate": "验证", + "validating": "验证中...", + "getKey": "获取 API Key" + } }, "docs": { "title": "帮助文档" @@ -53,6 +64,11 @@ "networkError": "网络错误,请检查连接后重试", "noApiKey": "请先在设置中配置 Deepgram API Key", "transcriptionFailed": "转写失败,请重试", + "translationApiKeyInvalid": "Zhipu API Key 无效,请检查设置", + "translationApiKeyMissing": "请先在设置中配置 Zhipu API Key", + "translationFailed": "翻译失败,请重试", + "translationNetworkError": "翻译网络错误,请检查连接后重试", + "translationQuotaExceeded": "翻译 API 配额已用尽,请检查您的 Zhipu 账户", "unknown": "生成失败:{{message}}" }, "progress": { @@ -382,43 +398,6 @@ "appearance": { "title": "外观设置" }, - "asr": { - "apiKey": { - "description": "从 Deepgram 获取 API Key 以使用 AI 字幕生成功能", - "getKey": "获取 API Key", - "invalid": "API Key 无效", - "label": "Deepgram API Key", - "placeholder": "输入您的 Deepgram API Key", - "saveFailed": "保存失败", - "saved": "API Key 已保存", - "valid": "API Key 有效", - "validate": "验证", - "validating": "验证中..." - }, - "defaultLanguage": { - "description": "自动生成字幕时使用的默认语言", - "label": "默认语言" - }, - "description": "配置 AI 字幕自动生成功能,使用 Deepgram 语音识别技术为视频生成准确的字幕", - "languages": { - "auto": "自动检测", - "de": "德语", - "en": "英语", - "es": "西班牙语", - "fr": "法语", - "ja": "日语", - "ko": "韩语", - "ru": "俄语", - "zh": "中文" - }, - "model": { - "description": "选择 Deepgram 转写模型", - "label": "转写模型", - "nova2": "Nova 2(推荐)", - "nova3": "Nova 3(最新)" - }, - "title": "语音识别" - }, "developer": { "enable_developer_mode": "启用开发者模式", "title": "开发者模式" @@ -647,6 +626,51 @@ "zoom_out": "缩小界面", "zoom_reset": "重置缩放" }, + "subtitleGeneration": { + "description": "配置 AI 字幕生成功能,支持语音识别和自动翻译", + "speechRecognition": { + "apiKey": { + "description": "从 Deepgram 获取 API Key 以使用 AI 字幕生成功能", + "getKey": "获取 API Key", + "label": "API Key", + "placeholder": "输入您的 Deepgram API Key" + }, + "defaultLanguage": { + "description": "自动生成字幕时使用的默认语言", + "label": "默认语言" + }, + "description": "使用 Deepgram 语音识别技术自动生成准确的字幕,支持多语言和词级时间戳", + "languages": { + "auto": "自动检测", + "de": "德语", + "en": "英语", + "es": "西班牙语", + "fr": "法语", + "ja": "日语", + "ko": "韩语", + "ru": "俄语", + "zh": "中文" + }, + "model": { + "description": "选择 Deepgram 转写模型", + "label": "转写模型", + "nova2": "Nova 2(推荐)", + "nova3": "Nova 3(最新)" + }, + "title": "语音识别" + }, + "title": "字幕生成", + "translation": { + "apiKey": { + "description": "从智谱 AI 获取 API Key 以使用字幕翻译功能", + "getKey": "获取 API Key", + "label": "API Key", + "placeholder": "输入您的 Zhipu API Key" + }, + "description": "使用智谱 AI 自动将生成的字幕翻译成中文,提供本土化翻译结果", + "title": "字幕翻译" + } + }, "theme": { "color_primary": "主题色", "dark": "深色", diff --git a/src/renderer/src/pages/player/components/ASRSubtitlePrompt.tsx b/src/renderer/src/pages/player/components/ASRSubtitlePrompt.tsx index aea74e2..9c6a96c 100644 --- a/src/renderer/src/pages/player/components/ASRSubtitlePrompt.tsx +++ b/src/renderer/src/pages/player/components/ASRSubtitlePrompt.tsx @@ -21,15 +21,15 @@ const ASRSubtitlePrompt: FC = ({ const [selectedLanguage, setSelectedLanguage] = useState('auto') const languageOptions = [ - { value: 'auto', label: t('settings.asr.languages.auto') }, - { value: 'en', label: t('settings.asr.languages.en') }, - { value: 'zh', label: t('settings.asr.languages.zh') }, - { value: 'ja', label: t('settings.asr.languages.ja') }, - { value: 'es', label: t('settings.asr.languages.es') }, - { value: 'fr', label: t('settings.asr.languages.fr') }, - { value: 'de', label: t('settings.asr.languages.de') }, - { value: 'ko', label: t('settings.asr.languages.ko') }, - { value: 'ru', label: t('settings.asr.languages.ru') } + { value: 'auto', label: t('settings.subtitleGeneration.speechRecognition.languages.auto') }, + { value: 'en', label: t('settings.subtitleGeneration.speechRecognition.languages.en') }, + { value: 'zh', label: t('settings.subtitleGeneration.speechRecognition.languages.zh') }, + { value: 'ja', label: t('settings.subtitleGeneration.speechRecognition.languages.ja') }, + { value: 'es', label: t('settings.subtitleGeneration.speechRecognition.languages.es') }, + { value: 'fr', label: t('settings.subtitleGeneration.speechRecognition.languages.fr') }, + { value: 'de', label: t('settings.subtitleGeneration.speechRecognition.languages.de') }, + { value: 'ko', label: t('settings.subtitleGeneration.speechRecognition.languages.ko') }, + { value: 'ru', label: t('settings.subtitleGeneration.speechRecognition.languages.ru') } ] const handleGenerate = () => { diff --git a/src/renderer/src/pages/settings/ASRSettings.tsx b/src/renderer/src/pages/settings/ASRSettings.tsx index f21ffab..90762c0 100644 --- a/src/renderer/src/pages/settings/ASRSettings.tsx +++ b/src/renderer/src/pages/settings/ASRSettings.tsx @@ -1,17 +1,18 @@ import { loggerService } from '@logger' +import { HStack } from '@renderer/components/Layout' import Selector from '@renderer/components/Selector' import { useTheme } from '@renderer/contexts' import { Button, Flex, Input, message } from 'antd' -import { ExternalLink } from 'lucide-react' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { - HelpText, SettingContainer, SettingDescription, SettingDivider, SettingGroup, + SettingHelpLink, + SettingHelpTextRow, SettingRow, SettingRowTitle, SettingTitle @@ -30,21 +31,28 @@ const ASRSettings: FC = () => { const [validatingApiKey, setValidatingApiKey] = useState(false) const [apiKeyValid, setApiKeyValid] = useState(null) + // Translation settings state + const [zhipuApiKey, setZhipuApiKey] = useState('') + const [validatingZhipuApiKey, setValidatingZhipuApiKey] = useState(false) + const [zhipuApiKeyValid, setZhipuApiKeyValid] = useState(null) + // ASR language options const asrLanguageOptions = [ - { value: 'auto', label: t('settings.asr.languages.auto') }, - { value: 'en', label: t('settings.asr.languages.en') }, - { value: 'zh', label: t('settings.asr.languages.zh') }, - { value: 'ja', label: t('settings.asr.languages.ja') }, - { value: 'es', label: t('settings.asr.languages.es') }, - { value: 'fr', label: t('settings.asr.languages.fr') }, - { value: 'de', label: t('settings.asr.languages.de') }, - { value: 'ko', label: t('settings.asr.languages.ko') }, - { value: 'ru', label: t('settings.asr.languages.ru') } + { value: 'auto', label: t('settings.subtitleGeneration.speechRecognition.languages.auto') }, + { value: 'en', label: t('settings.subtitleGeneration.speechRecognition.languages.en') }, + { value: 'zh', label: t('settings.subtitleGeneration.speechRecognition.languages.zh') }, + { value: 'ja', label: t('settings.subtitleGeneration.speechRecognition.languages.ja') }, + { value: 'es', label: t('settings.subtitleGeneration.speechRecognition.languages.es') }, + { value: 'fr', label: t('settings.subtitleGeneration.speechRecognition.languages.fr') }, + { value: 'de', label: t('settings.subtitleGeneration.speechRecognition.languages.de') }, + { value: 'ko', label: t('settings.subtitleGeneration.speechRecognition.languages.ko') }, + { value: 'ru', label: t('settings.subtitleGeneration.speechRecognition.languages.ru') } ] // ASR model options - const asrModelOptions = [{ value: 'nova-3', label: t('settings.asr.model.nova3') }] + const asrModelOptions = [ + { value: 'nova-3', label: t('settings.subtitleGeneration.speechRecognition.model.nova3') } + ] // Load ASR settings on mount useEffect(() => { @@ -53,12 +61,14 @@ const ASRSettings: FC = () => { const apiKey = await window.api.config.get('deepgramApiKey') const lang = await window.api.config.get('asrDefaultLanguage') const model = await window.api.config.get('asrModel') + const zhipuKey = await window.api.config.get('zhipuApiKey') setDeepgramApiKey(apiKey || '') setAsrDefaultLanguage(lang || 'en') setAsrModel(model || 'nova-3') + setZhipuApiKey(zhipuKey || '') } catch (error) { - logger.error('加载 ASR 设置失败', { error }) + logger.error('加载字幕生成设置失败', { error }) } } @@ -73,15 +83,15 @@ const ASRSettings: FC = () => { const handleApiKeySave = async () => { try { await window.api.config.set('deepgramApiKey', deepgramApiKey) - message.success(t('settings.asr.apiKey.saved') || '保存成功') + message.success(t('common.apiKey.saved')) } catch (error) { - message.error(t('settings.asr.apiKey.saveFailed') || '保存失败') + message.error(t('common.apiKey.saveFailed') || '保存失败') } } const handleValidateApiKey = async () => { if (!deepgramApiKey.trim()) { - message.warning(t('settings.asr.apiKey.invalid') || 'API Key 无效') + message.warning(t('common.apiKey.invalid') || 'API Key 无效') return } @@ -90,15 +100,15 @@ const ASRSettings: FC = () => { const isValid = await window.api.asr.validateApiKey(deepgramApiKey) setApiKeyValid(isValid) if (isValid) { - message.success(t('settings.asr.apiKey.valid') || 'API Key 有效') + message.success(t('common.apiKey.valid') || 'API Key 有效') // Save the validated key await window.api.config.set('deepgramApiKey', deepgramApiKey) } else { - message.error(t('settings.asr.apiKey.invalid') || 'API Key 无效') + message.error(t('common.apiKey.invalid') || 'API Key 无效') } } catch (error) { setApiKeyValid(false) - message.error(t('settings.asr.apiKey.invalid') || 'API Key 无效') + message.error(t('common.apiKey.invalid') || 'API Key 无效') } finally { setValidatingApiKey(false) } @@ -122,22 +132,65 @@ const ASRSettings: FC = () => { } } + const handleZhipuApiKeyChange = (e: React.ChangeEvent) => { + setZhipuApiKey(e.target.value) + setZhipuApiKeyValid(null) // Reset validation state + } + + const handleZhipuApiKeySave = async () => { + try { + await window.api.config.set('zhipuApiKey', zhipuApiKey) + message.success(t('common.apiKey.saved') || '保存成功') + } catch (error) { + message.error(t('common.apiKey.saveFailed') || '保存失败') + } + } + + const handleValidateZhipuApiKey = async () => { + if (!zhipuApiKey.trim()) { + message.warning(t('common.apiKey.invalid') || 'API Key 无效') + return + } + + setValidatingZhipuApiKey(true) + try { + const isValid = await window.api.translation.validateApiKey(zhipuApiKey) + setZhipuApiKeyValid(isValid) + if (isValid) { + message.success(t('common.apiKey.valid') || 'API Key 有效') + // Save the validated key + await window.api.config.set('zhipuApiKey', zhipuApiKey) + } else { + message.error(t('common.apiKey.invalid') || 'API Key 无效') + } + } catch (error) { + setZhipuApiKeyValid(false) + message.error(t('common.apiKey.invalid') || 'API Key 无效') + } finally { + setValidatingZhipuApiKey(false) + } + } + const openDeepgramWebsite = () => { window.api.openWebsite('https://console.deepgram.com/signup') } + const openZhipuWebsite = () => { + window.api.openWebsite('https://open.bigmodel.cn') + } + return ( - {t('settings.asr.title')} - {t('settings.asr.description')} + {t('settings.subtitleGeneration.title')} + {t('settings.subtitleGeneration.description')} + {/* 语音识别分组 */} - {t('settings.asr.apiKey.label')} - {t('settings.asr.apiKey.description')} + {t('settings.subtitleGeneration.speechRecognition.apiKey.label')} @@ -145,31 +198,30 @@ const ASRSettings: FC = () => { - + + + + {t('settings.subtitleGeneration.speechRecognition.apiKey.getKey')} + + + - {t('settings.asr.defaultLanguage.label')} - {t('settings.asr.defaultLanguage.description')} + {t('settings.subtitleGeneration.speechRecognition.defaultLanguage.label')} { - - {t('settings.asr.model.label')} - {t('settings.asr.model.description')} - + {t('settings.subtitleGeneration.speechRecognition.model.label')} { /> + + + {/* 字幕翻译分组 */} + + {t('settings.subtitleGeneration.translation.title')} + + + {t('settings.subtitleGeneration.translation.description')} + + + + + + {t('settings.subtitleGeneration.translation.apiKey.label')} + + + + + + + + + + + + {t('settings.subtitleGeneration.speechRecognition.apiKey.getKey')} + + + ) } diff --git a/src/renderer/src/pages/settings/SettingsPage.tsx b/src/renderer/src/pages/settings/SettingsPage.tsx index a470ae1..6c2da85 100644 --- a/src/renderer/src/pages/settings/SettingsPage.tsx +++ b/src/renderer/src/pages/settings/SettingsPage.tsx @@ -1,5 +1,5 @@ import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' -import { Command, Eye, Info, Mic, Monitor, PlayCircle, Settings2 } from 'lucide-react' +import { Captions, Command, Eye, Info, Monitor, PlayCircle, Settings2 } from 'lucide-react' import React from 'react' import { useTranslation } from 'react-i18next' import { Link, Route, Routes, useLocation } from 'react-router-dom' @@ -55,8 +55,8 @@ export function SettingsPage(): React.JSX.Element { - - {t('settings.asr.title')} + + {t('settings.subtitleGeneration.title')} diff --git a/stagewise.json b/stagewise.json new file mode 100644 index 0000000..b848333 --- /dev/null +++ b/stagewise.json @@ -0,0 +1,6 @@ +{ + "port": 3100, + "appPort": 5173, + "autoPlugins": true, + "plugins": [] +} From 456e608bb73cb6c9d0f455d0d88f1d883dfbb75a Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Wed, 22 Oct 2025 19:11:27 +0800 Subject: [PATCH 4/9] fix(db): resolve inaccurate error counting in updateSubtitleTranslations Track subtitle ID processing status to ensure each translation item is counted exactly once, replacing the previous record-based counting logic. Key improvements: - Use Set to track processed and failed subtitle IDs - Count failures based on individual translation items rather than records - Prevent double counting by tracking each subtitleId's processing state - Provide detailed error messages with specific subtitle IDs - Ensure successCount + failureCount equals input translations.length This resolves the issue where successCount + failureCount could exceed the total number of input translations, making it difficult for callers to determine actual processing results. --- src/main/db/dao/SubtitleLibraryDAO.ts | 66 +++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/src/main/db/dao/SubtitleLibraryDAO.ts b/src/main/db/dao/SubtitleLibraryDAO.ts index 2269356..b066447 100644 --- a/src/main/db/dao/SubtitleLibraryDAO.ts +++ b/src/main/db/dao/SubtitleLibraryDAO.ts @@ -303,6 +303,10 @@ export class SubtitleLibraryDAO extends BaseDAO< errors: [] as string[] } + // 跟踪每个 subtitleId 的处理状态 + const processedSubtitleIds = new Set() + const failedSubtitleIds = new Set() + try { // 获取所有字幕记录 const allRecords = await this.findAll() @@ -334,10 +338,26 @@ export class SubtitleLibraryDAO extends BaseDAO< if (matchingTranslations.length > 0) { translationsByRecord.set(record.id, matchingTranslations) + // 标记这些翻译项为已处理(无论成功失败) + matchingTranslations.forEach((translation) => { + processedSubtitleIds.add(translation.subtitleId) + }) } } catch (error) { result.errors.push(`Failed to parse subtitles JSON for record ${record.id}: ${error}`) - result.failureCount++ + // 计算该记录中受影响的翻译项数量 + const affectedTranslations = translations.filter((translation) => { + try { + const subtitles = JSON.parse(record.subtitles!) + return subtitles.some((subtitle: any) => subtitle.id === translation.subtitleId) + } catch { + return false // 如果无法解析,无法确定具体哪些翻译项受影响 + } + }) + affectedTranslations.forEach((translation) => { + failedSubtitleIds.add(translation.subtitleId) + processedSubtitleIds.add(translation.subtitleId) + }) } } } @@ -357,11 +377,19 @@ export class SubtitleLibraryDAO extends BaseDAO< // 更新匹配的字幕翻译 let updatedCount = 0 + const recordFailedTranslations: string[] = [] + for (const translation of recordTranslations) { const subtitleIndex = subtitles.findIndex((sub) => sub.id === translation.subtitleId) if (subtitleIndex !== -1) { subtitles[subtitleIndex].translatedText = translation.translatedText updatedCount++ + // 从失败列表中移除(如果之前因为JSON解析失败而被标记为失败) + failedSubtitleIds.delete(translation.subtitleId) + } else { + // 如果在记录中找不到对应的字幕,标记为失败 + recordFailedTranslations.push(translation.subtitleId) + failedSubtitleIds.add(translation.subtitleId) } } @@ -372,17 +400,49 @@ export class SubtitleLibraryDAO extends BaseDAO< }) result.successCount += updatedCount } + + // 记录找不到对应字幕的错误 + if (recordFailedTranslations.length > 0) { + result.errors.push( + `Subtitle IDs not found in record ${recordId}: ${recordFailedTranslations.join(', ')}` + ) + } } } catch (error) { result.errors.push(`Failed to update record ${recordId}: ${error}`) - result.failureCount++ + // 将该记录的所有翻译项标记为失败 + recordTranslations.forEach((translation) => { + failedSubtitleIds.add(translation.subtitleId) + }) } } + // 计算最终失败数量(基于失败的字幕ID而非记录数) + result.failureCount = failedSubtitleIds.size + + // 检查是否有未被处理的翻译项(这些翻译对应的subtitleId在所有记录中都找不到) + const unprocessedTranslations = translations.filter( + (translation) => !processedSubtitleIds.has(translation.subtitleId) + ) + if (unprocessedTranslations.length > 0) { + result.failureCount += unprocessedTranslations.length + result.errors.push( + `Subtitle IDs not found in any record: ${unprocessedTranslations.map((t) => t.subtitleId).join(', ')}` + ) + } + return result } catch (error) { result.errors.push(`Batch update failed: ${error}`) - result.failureCount += translations.length + // 只有在完全没有处理任何翻译时才将全部计入失败 + if (processedSubtitleIds.size === 0) { + result.failureCount = translations.length + } else { + // 否则保持当前的失败计数 + result.failureCount = + failedSubtitleIds.size + + translations.filter((t) => !processedSubtitleIds.has(t.subtitleId)).length + } return result } } From 42cdc7722386654b73f75f4ebfe517d55dca41ff Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Wed, 22 Oct 2025 19:32:10 +0800 Subject: [PATCH 5/9] refactor: improve translation result type and options - refine `TranslationOptions` to enforce `targetLanguage` as 'zh-CN' - improve `TranslationResult` type using discriminated union for success/failure - make `results` property in `TranslationBatchResult` readonly --- packages/shared/types/translation.ts | 43 +++++++++++++++++----------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/packages/shared/types/translation.ts b/packages/shared/types/translation.ts index 78be00f..d25b049 100644 --- a/packages/shared/types/translation.ts +++ b/packages/shared/types/translation.ts @@ -4,7 +4,7 @@ export interface TranslationOptions { /** Target language for translation (currently only supports 'zh-CN') */ - targetLanguage: string + targetLanguage: 'zh-CN' /** Source language (auto-detected if not specified) */ sourceLanguage?: string /** Batch size for translation requests */ @@ -15,24 +15,35 @@ export interface TranslationOptions { videoFilename?: string } -export interface TranslationResult { - /** Original text */ - originalText: string - /** Translated text */ - translatedText: string - /** Source language (detected) */ - sourceLanguage?: string - /** Target language */ - targetLanguage: string - /** Whether translation was successful */ - success: boolean - /** Error message if translation failed */ - error?: string -} +export type TranslationResult = + | { + /** Original text */ + originalText: string + /** Translated text */ + translatedText: string + /** Source language (detected) */ + sourceLanguage?: string + /** Target language */ + targetLanguage: string + /** Whether translation was successful */ + success: true + } + | { + /** Original text */ + originalText: string + /** Source language (detected) */ + sourceLanguage?: string + /** Target language */ + targetLanguage: string + /** Whether translation was successful */ + success: false + /** Error message */ + error: string + } export interface TranslationBatchResult { /** Array of translation results */ - results: TranslationResult[] + readonly results: readonly TranslationResult[] /** Number of successful translations */ successCount: number /** Number of failed translations */ From 1d5f65913010bf222da0bbe9ba7a3dd7c7dced48 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Wed, 22 Oct 2025 19:32:34 +0800 Subject: [PATCH 6/9] refactor(asr): move background translation task after completion - move startBackgroundTranslation after ASR process completion - ensure translation starts only after successful ASR and DB save --- src/main/services/ASRSubtitleService.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/services/ASRSubtitleService.ts b/src/main/services/ASRSubtitleService.ts index ea2b55b..10054b0 100644 --- a/src/main/services/ASRSubtitleService.ts +++ b/src/main/services/ASRSubtitleService.ts @@ -224,9 +224,6 @@ class ASRSubtitleService { }) subtitleLibraryId = result.id logger.info('字幕保存到数据库成功', { subtitleLibraryId }) - - // 启动后台翻译任务 - this.startBackgroundTranslation(options.videoId, options.videoPath) } catch (error) { logger.error('保存字幕到数据库失败', { error: error instanceof Error ? error.message : String(error) @@ -234,6 +231,9 @@ class ASRSubtitleService { // 不抛出错误,继续返回结果 } + // 启动后台翻译任务 + this.startBackgroundTranslation(options.videoId, options.videoPath) + // 完成 const processingTime = (Date.now() - startTime) / 1000 this.reportProgress(taskId, ASRProgressStage.Complete, 100, progressCallback) From 79ac87b2115b4ff7e0b0addf747c150ec81774d6 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Wed, 22 Oct 2025 19:33:00 +0800 Subject: [PATCH 7/9] refactor: enhance subtitle translation service - add sourceLanguage option to translation results - improve API key validation logic --- src/main/services/SubtitleTranslationService.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/services/SubtitleTranslationService.ts b/src/main/services/SubtitleTranslationService.ts index 5e26d5b..ef2fd02 100644 --- a/src/main/services/SubtitleTranslationService.ts +++ b/src/main/services/SubtitleTranslationService.ts @@ -157,7 +157,7 @@ class SubtitleTranslationService { return subtitles.map((subtitle, index) => ({ originalText: subtitle.text, translatedText: translations[index] || '', - sourceLanguage: 'auto', + sourceLanguage: options.sourceLanguage || 'auto', targetLanguage: options.targetLanguage, success: true })) @@ -203,7 +203,7 @@ class SubtitleTranslationService { return subtitles.map((subtitle) => ({ originalText: subtitle.text, translatedText: '', - sourceLanguage: 'auto', + sourceLanguage: options.sourceLanguage || 'auto', targetLanguage: options.targetLanguage, success: false, error: errorMessage @@ -428,9 +428,8 @@ class SubtitleTranslationService { temperature: 0.1 }) - const result = text.trim() - - return (result && result.includes('成功')) || false + // 只要请求成功且返回了内容,就认为 API Key 有效 + return !!text && text.trim().length > 0 } catch (error) { logger.error('API Key 验证失败', { error }) return false From 332079c4edf4564ef232fc16577db53142e596a2 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Wed, 22 Oct 2025 20:47:56 +0800 Subject: [PATCH 8/9] fix(types): resolve TranslationOptions type mismatch in ASRSubtitleService Import TranslationOptions type and add explicit type annotation to fix TypeScript compilation error where targetLanguage string was incompatible with 'zh-CN' literal type. --- src/main/services/ASRSubtitleService.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/services/ASRSubtitleService.ts b/src/main/services/ASRSubtitleService.ts index 10054b0..aeeb7c6 100644 --- a/src/main/services/ASRSubtitleService.ts +++ b/src/main/services/ASRSubtitleService.ts @@ -11,7 +11,8 @@ import type { ASRSubtitleItem, DeepgramResponse, DeepgramUtterance, - DeepgramWord + DeepgramWord, + TranslationOptions } from '@shared/types' import { ASRProgressStage } from '@shared/types' import { app } from 'electron' @@ -574,7 +575,7 @@ class ASRSubtitleService { videoFilename: string ): Promise { try { - const translationOptions = { + const translationOptions: TranslationOptions = { targetLanguage: 'zh-CN', // 当前版本仅支持翻译为中文 batchSize: 15, maxConcurrency: 2, From c84167195996b0a8708b00e1bd53e2243000cc55 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Wed, 22 Oct 2025 21:16:47 +0800 Subject: [PATCH 9/9] test: fix missing mock methods in test files - Add getZhipuApiKey method to ConfigManager mocks in both test files - Add debug method to LoggerService mock in ipc.database.test.ts - Resolves TypeError: configManager.getZhipuApiKey is not a function - Resolves TypeError: logger.debug is not a function - All 665 tests now pass successfully --- src/main/__tests__/ipc.database.test.ts | 4 +++- .../__tests__/ASRSubtitleService.shouldBreakSentence.test.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/__tests__/ipc.database.test.ts b/src/main/__tests__/ipc.database.test.ts index 994251a..65fdc84 100644 --- a/src/main/__tests__/ipc.database.test.ts +++ b/src/main/__tests__/ipc.database.test.ts @@ -105,6 +105,7 @@ vi.mock('../services/LoggerService', () => ({ loggerService: { withContext: vi.fn(() => ({ info: vi.fn(), + debug: vi.fn(), error: vi.fn(), warn: vi.fn(), getLogsDir: vi.fn(() => '/logs') @@ -125,7 +126,8 @@ vi.mock('../services/ConfigManager', () => ({ setTrayOnClose: vi.fn(), setLaunchToTray: vi.fn(), setAutoUpdate: vi.fn(), - setShortcuts: vi.fn() + setShortcuts: vi.fn(), + getZhipuApiKey: vi.fn(() => 'mock-zhipu-api-key') } })) diff --git a/src/main/services/__tests__/ASRSubtitleService.shouldBreakSentence.test.ts b/src/main/services/__tests__/ASRSubtitleService.shouldBreakSentence.test.ts index 53d89af..9ca759e 100644 --- a/src/main/services/__tests__/ASRSubtitleService.shouldBreakSentence.test.ts +++ b/src/main/services/__tests__/ASRSubtitleService.shouldBreakSentence.test.ts @@ -41,7 +41,8 @@ vi.mock('../ConfigManager', () => ({ configManager: { getDeepgramApiKey: vi.fn(() => 'mock-api-key'), getASRDefaultLanguage: vi.fn(() => 'en'), - getASRModel: vi.fn(() => 'nova-2') + getASRModel: vi.fn(() => 'nova-2'), + getZhipuApiKey: vi.fn(() => 'mock-zhipu-api-key') } }))