Compare commits

..

24 Commits

Author SHA1 Message Date
wanjia 4a187e3a81 chore(release): 0.2.3 VSIX built, description updated with 5 LLM backends 2026-06-30 10:29:13 +08:00
wanjia 4b35b7f980 docs: update PENDING_ITEMS — mark TD-03 (mutex) and GAP-04 (F5 debug) done 2026-06-26 17:18:18 +08:00
wanjia d9c01f6faf chore(release): 0.2.3 — MEMORY, CHANGELOG, ROADMAP updated with today's 17 fixes 2026-06-26 17:17:55 +08:00
wanjia 48a837cb24 fix(context): dual-path translation - direct sentence fallback when contextTranslation empty
Root cause: CONTEXT_TRANSLATE_PROMPT's structured JSON output omits contextTranslation
field for short contexts (12-101B). Model fills meanings but skips sentence translation.

Fix - Dual-path strategy:
- Path 1 (main): translateWithContext → extract contextTranslation from JSON
- Path 2 (fallback): if empty, use DIRECT_SENTENCE_TRANSLATE_PROMPT which
  asks for pure Chinese translation (no JSON, no structure)
  'You are a translator. Translate the English text to Chinese.
  Return ONLY Chinese text.'

This applies to both backfill and new-word async translation.
Logging: shows when fallback path is triggered vs when it's skipped.
2026-06-26 16:56:02 +08:00
wanjia 76f873fab8 fix(clipboard): extend watch to 60s, require sentence > word+5 chars, show user hint
- ClipboardWatch timeout: 30s → 60s (more time to copy sentence)
- Minimum sentence length: word.length → word.length+5 (strictly requires a sentence)
- Show hint: '请在60s内复制包含word的完整句子作为上下文'
- Add log on timeout: '未检测到上下文句子'
- Add log on capture: '捕获上下文: word → sentence'
2026-06-26 16:52:32 +08:00
wanjia 2899b2a47d fix: hide add-wordbook button on fallback, backfill yields to user, clipboard context snapshot
1. Hide '加入单词本' button when translation source is fallback/offline-dict
2. Backfill paused while user is translating (userTranslating flag)
3. Clipboard context: snapshot full text at translate time, used for wordbook context
   instead of re-reading clipboard which may have been overwritten
2026-06-26 16:46:00 +08:00
wanjia 0821869f42 fix(log): output LLM config to OutputChannel on startup, backfill, and fallback
- Startup: provider/model/endpoint/timeout/maxTokens logged
- Backfill start: +provider +model info
- Backfill skip: +provider +endpoint info
- New-word contextTranslation missing: +provider info
- client.ts: per-request provider/model/thinking_mode to console
2026-06-26 16:34:07 +08:00
wanjia e22e304151 feat(log): log LLM provider/model/thinking_mode on every request 2026-06-26 16:27:09 +08:00
wanjia 7a1c413331 feat(wordbook): clipboard context snapshot, backfill untranslated contexts, diagnostic logs
1. Clipboard context: directly read clipboard as context on add-to-wordbook
   (no longer relies on volatile history, eliminates timing race condition)

2. Backfill: auto-trigger on wordbook open — scans for untranslated contexts
   and translates them one-by-one with 2s intervals to avoid LLM overload

3. Diagnostic: log actual LLM response fields when contextTranslation is missing
   (shows keys and meaning[0] sample for debugging)
2026-06-26 16:20:08 +08:00
wanjia 94005c7884 fix(wordbook): only use contextTranslation for sentence translation, never fallback to word meanings
Root cause: When contextTranslation field was empty, code fell back to
meanings[0].definition which is the WORD definition, not sentence translation.
Result: 'hereby → 特此;据此' instead of translating the full license text.

Fix:
- Remove meanings fallback: ctxResult.contextTranslation only
- Strengthen prompt: contextTranslation marked MANDATORY, NEVER empty
- Log 'LLM返回为空' when contextTranslation is missing
2026-06-26 16:04:31 +08:00
wanjia f0e3fbaf6e fix: change clipboard shortcut to Ctrl+Alt+Y (Ctrl+Shift+Y conflicts with Debug Console) 2026-06-26 15:58:56 +08:00
wanjia 5a292998ec fix: restore Ctrl+Shift+Y clipboard shortcut, add async translation startup log
- Restore deleted Ctrl+Shift+Y default keybinding for translateFromClipboard
  (was removed in shortcut cleanup, broke Ctrl+C → Ctrl+Shift+Y workflow)
- Add '异步翻译: word → ctx=NNN字节' startup log before LLM call
- Verified: no Ctrl+F, Ctrl+F Ctrl+F, or any Ctrl+F chord in extension
2026-06-26 15:56:04 +08:00
wanjia 085870b4e6 refactor(wordbook): only accept LLM results, silently discard fallback/offline translations
User request: context translation only makes sense via LLM. Offline dict,
youdao, baidu cannot translate sentences. Fallback is noise in wordbook.

Changes:
- Only save translations where ctxResult.source === 'llm'
- Fallback/offline results silently logged, never saved to wordbook
- Simplified validation: only check translatedText; translatedText !== word
- Removed fragile pattern matching ('未找到', '请检查' filters)
2026-06-26 15:42:53 +08:00
wanjia 1af12c5fb7 fix(wordbook): add inflight dedup to prevent 14 concurrent async translations
Root cause: addWord mutex serialized storage writes, but async translations
still fired for EVERY click (200ms delayed). With 14 rapid clicks:
- 14 LLM calls fired simultaneously → rate limiting → some succeed, some fail
- Fallback results contain '未找到' message which previously got saved as
  'context translation' despite the source check (timing issue)

Fix: inflightTranslations Set tracks (wordId:contextIdx) combos in-flight.
Before firing async translation, check if one is already running.
Only 1 LLM call per unique word+context now.
2026-06-26 15:41:12 +08:00
wanjia 8e48578f1c fix(wordbook,settings): exclude fallback results from context translation; add shortcut config button
- Wordbook: skip async translation when ctxResult.source is 'fallback' or 'offline-dict'
- Wordbook: filter definitions starting with '未找到' or containing '请检查'
- Settings: add ' 打开快捷键配置' button that opens VS Code keybinding settings
  filtered to vibecoding-english commands (workbench.action.openGlobalKeybindings)
2026-06-26 15:37:03 +08:00
wanjia 624977ef43 fix(wordbook): add write mutex to prevent race condition from concurrent addWord calls
Root cause: Rapid clicks on 'add to wordbook' triggered concurrent addWord().
Each call read wordbook.json before the previous call saved, seeing empty state.
Result: 3 separate WordEntry objects created instead of 1 with dedup.

Fix: Promise-based writeLock serializes all addWord operations.
Now: Click2's addWord waits for Click1's save, then sees the saved data, dedup works.
2026-06-26 15:22:58 +08:00
wanjia 8bf0572348 fix(wordbook): fix context translation targeting wrong word, dedupe meanings display, add OutputChannel debug logs
- CONTEXT_TRANSLATE_PROMPT: add 'TARGET WORD:' emphasis and 'only translate the target word' rule
- llm-agent-engine.ts: use replaceAll for {word} placeholder (now appears 3 times in prompt)
- wordbook.html: dedupe meanings by definition text, add '|' separator between meanings
- extension.ts: add outputLog() helper writing to VS Code OutputChannel + console
- extension.ts: convert wordbook async translation logs to outputLog
- extension.ts: add startup log to OutputChannel
- Extension OutputChannel viewable: View -> Output -> select 'VibeCoding English'
2026-06-26 15:11:39 +08:00
wanjia 79a828928c fix(keybindings,wordbook): remove conflicting shortcuts, dedup wordbook entries, fix async context translation
- Remove all default keybindings except translate (Ctrl+Shift+T) to avoid conflicts
  with VS Code native shortcuts (Ctrl+F find, Ctrl+Shift+W close window, etc.)
- All commands still registered via command palette, user can set custom shortcuts
- Settings panel updated with command IDs and recommended shortcut hints

- Wordbook: dedup context entries by sentence text before adding
- Wordbook: dedup meanings by definition text before adding
- Wordbook: check alreadyTranslated before triggering async context translation
- Wordbook: async translation now uses IIFE wrapper to properly catch rejection
- Wordbook: add console.log for debugging context translation flow
- Wordbook: improved contextTranslation extraction from translateWithContext result

- Quick start guide updated for shortcut-less workflow
2026-06-26 15:02:40 +08:00
wanjia 7b0d5e6aff fix(providers): split custom into 5 provider types - Ollama/vLLM/Custom each with own compatibility strategy
Root cause: /no_think directive was applied to ALL custom providers, breaking vLLM-based Qwen3.6 which needs chat_template_kwargs instead.

Changes:
- types.ts: provider type extends to 'openai'|'anthropic'|'ollama'|'vllm'|'custom'
- client.ts: per-provider logic - Ollama→/no_think, vLLM→chat_template_kwargs, OpenAI→response_format, custom→pure passthrough
- client.ts: API key check excludes ollama/vllm (local deployments)
- settings.html: 5 provider cards with descriptions; 5 independent config groups (no overwrite)
- package.json: enum updated to include ollama/vllm/custom
2026-06-10 09:07:52 +08:00
wanjia 72672a359d fix(ollama): complete Ollama/Qwen support - maxTokens 2048, /no_think directive, reasoning parsing, http fetch skip, better errors
Core fixes:
- config.ts: maxTokens 500 -> 2048 (Qwen thinking consumed all tokens)
- client.ts: remove chat_template_kwargs (vLLM-only, broke Ollama)
- client.ts: add /no_think to system prompt for custom provider (disable Qwen thinking)
- client.ts: parse message.reasoning when content is empty (Ollama response format)
- client.ts: skip fetch() for http:// URLs, directly use nodeFetch
- client.ts: better ErrorCode messages with context hints
- llm-agent-engine.ts: fix CONTEXT_TRANSLATE_PROMPT {word}/{context} placeholders substitution
- dispatcher.ts: include error diagnostics in fallback message
- settings.html: expose maxTokens config (256-16384)
- extension.ts: read/write llmMaxTokens from/to vscConfig
- package.json: register llmMaxTokens contribution point
2026-06-10 08:55:20 +08:00
wanjia af9bb4eeb4 fix: 4处修复 - 保存提示自动消失、供应商切换✓刷新、testConnection使用配置超时、nodeFetch支持超时和signal 2026-06-09 17:02:20 +08:00
wanjia 8ef8ec403e feat(settings): add LLM response timeout (5-300s) configurable in VCE Settings 2026-06-09 16:39:08 +08:00
wanjia 367d6d1318 fix: ensure selectProvider saves form fields before switching, add active provider badge to cards 2026-06-09 16:29:14 +08:00
wanjia 9ed6bc28a4 fix: VS Code settings priority over secrets.json, async context translation, add phonetic to Meaning type 2026-06-09 16:08:42 +08:00
17 changed files with 737 additions and 185 deletions

View File

@ -7,7 +7,60 @@ and this project adheres to [Conventional Commits](https://www.conventionalcommi
---
## [0.2.2] - 2026-06-08
## [0.2.3] - 2026-06-26
### Added
#### LLM 供应商精准区分5 种后端)
- **Ollama** 卡片:`/no_think` 系统指令关闭 Qwen 思考模式Ollama 不支持 chat_template_kwargs
- **vLLM** 卡片:`chat_template_kwargs: { enable_thinking: false }` 参数关闭思考vLLM 原生支持)
- **通用自定义** 卡片:纯净 OpenAI 兼容透传,不发送任何特殊参数
- 5 张卡片各自独立配置存储,切换互不覆盖
#### 单词本增强
- **异步上下文句子翻译**:加入单词本后自动调用 LLM 翻译上下文句子
- **双路径句译引擎**:主译取 CONTEXT_TRANSLATE_PROMPT JSON → 为空时兜底用极简直译 Prompt
- **历史补翻机制**:打开单词本自动扫描未翻译的上下文,逐个翻译(间隔 2s
- **剪贴板上下文快照**Ctrl+Alt+Y 翻译时保存全文,加入单词本时使用(不再读已变化的剪贴板)
- **后置剪贴板监听**60s 窗口内检测包含目标单词的新复制内容并自动补入
- 上下文关键字高亮(`<mark>` 黄色高亮)
- fallback 翻译时隐藏"加入单词本"按钮
#### 调测设施
- **OutputChannel 日志**:查看 → 输出 → 选择 "VibeCoding English"
- 启动日志、LLM 配置日志、补翻进度日志、上下文翻译日志
- 每次 LLM 请求输出 provider/model/thinking_mode 信息
#### 设置面板增强
- ⚡ **快捷键配置按钮**:点击直接跳转 VS Code 快捷键设置并搜索本插件命令
- 响应超时可配置5300s
- Max Tokens 可配置25616384默认 2048
### Fixed
#### 致命 Bug
- **Ollama 连接失败**`chat_template_kwargs` 参数 Ollama 不认识 → 拆分为独立 provider
- **maxTokens 500 导致 Qwen3 思考模型 content 为空** → 提升到 2048
- **单词本连续点击重复条目**:并发读写竞态条件 → Promise 互斥锁串行化
- **单词本上下文翻译始终为空**`contextTranslation` 为空时回退到 `meanings[0].definition`(单词释义)→ 双路径直译兜底
- **供应商切换覆盖其他配置**3 组 → 5 组独立存储
#### 翻译质量
- **LLM 翻译了错误单词**:上下文中含 "despair",把 "bare" 翻成"绝望" → Prompt 强调 TARGET WORD + 不要翻译其他词
- **上下文翻译用了单词释义**"hereby → 特此" 而不是翻译整句 → 拆分为双路径
- **fallback 消息存入单词本**"未找到释义…" → `source !== 'llm'` 直接丢弃
#### UX 修复
- **默认快捷键冲突**Ctrl+Shift+W/B/G/Y 全冲突 → 仅保留 Ctrl+Shift+T + Ctrl+Alt+Y
- 剪贴板快捷键从冲突的 Ctrl+Shift+Y 改为 Ctrl+Alt+Y
- 保存设置后提示 5s 自动消失
- 供应商选中 ✓ 标记正确刷新
### Changed
- **配置优先级**VS Code 设置 > secrets.json > 默认值
- `provider` 类型扩展为 `'openai' | 'anthropic' | 'ollama' | 'vllm' | 'custom'`
- maxTokens 默认值 500 → 2048
- 默认快捷键从 6 个缩减到 2 个(其余改为仅注册命令,用户自行绑定)
### Added

View File

@ -1,7 +1,7 @@
# Project Memory
> 项目记忆 · 随项目成长持续更新 · AI 和开发者共享的上下文
> 最后更新2026-05-26
> 最后更新2026-06-26
---
@ -18,6 +18,7 @@
| Phase 4打磨发布 | ✅ 已完成 | 离线词库补全 + 迭代记录补全 + 自动记录机制 + VSIX 打包配置 |
| Phase 5F5 测试 | ✅ 已完成 | 6 轮调试修复LLM/百度/离线全链路通过,例句中英对照 |
| **v0.2**:全场景+可配置 | ✅ 已完成 | P2-1~P2-5 全部完成:剪贴板/终端/上下文/Settings/场景回顾 |
| **v0.2.3**Ollama+vLLM兼容+单词本完缮 | ✅ 已完成 | 5种LLM后端、竞态修复、双路径句译、剪贴板上下文快照 |
| v0.3:多语言 | ⬜ 远期 | 英→俄/日/韩 + 社区词库 |
| v1.0:正式发布 | ⬜ 远期 | 离线词库5000词 + 性能优化 + Marketplace |
@ -25,14 +26,14 @@
| 日期 | 决策 | 理由 |
|------|------|------|
| 2026-05-26 | 采用 VS Code Extension 作为第一平台 | 用户基数最大API 最完善 |
| 2026-05-26 | 采用 Monorepo 架构core + adapters | 支持多平台拓展core 只写一次 |
| 2026-05-26 | ❌ 废弃离线词典方案 → 混合翻译引擎 | LLM Agent(主) + 百度API(降级) + 离线词库(兜底) |
| 2026-05-26 | LLM 采用 Qwen3.6-int4-AWQ自建端点 | 免费、中文友好、thinking mode 可关闭 |
| 2026-05-26 | Extension Host http:// fetch 失败 → Node.js http 模块回退 | VS Code 扩展中 DOM fetch 拒绝 http:// 连接 |
| 2026-05-26 | Qwen thinking mode → extractJSON 平衡括号 + enable_thinking:false | 贪婪正则匹配到思考过程中 { 导致解析失败 |
| 2026-05-26 | secrets.json 优先级修复 | VS Code 默认值 'gpt-4o-mini' 覆盖了 secrets 的 'qwen3.6' |
| 2026-05-26 | v0.1.0 定档 | Qwen LLM + 百度 API + 离线词库全链路验证通过,例句中英对照 |
| 2026-06-26 | **5 种 LLM 供应商类型精准区分** | Ollama 用 `/no_think`、vLLM 用 `chat_template_kwargs`、OpenAI 用 `response_format`、custom 纯净透传。统一处理导致 Ollama 吃掉所有 /no_thinkvLLM 不认识 /no_think |
| 2026-06-26 | **maxTokens 500→2048** | Qwen3 思考模型开了思考模式时 500 token 全被思考过程消耗content 始终为空 |
| 2026-06-26 | **单词本写入加 Promise 互斥锁** | 快速连续点击"加入单词本"触发并发 addWord(),每次 getAllWords() 读到旧状态,导致 3 条重复 entry。锁串行化后每次读到的都是最新数据 |
| 2026-06-26 | **双路径句译方案** | 主译CONTEXT_TRANSLATE_PROMPT JSON常漏掉 contextTranslation 字段;兜底用极简 DIRECT_SENTENCE_TRANSLATE_PROMPT3 行,纯文本),不可能被模型忽略 |
| 2026-06-26 | **剪贴板上下文在翻译时快照** | 加入单词本时再读剪贴板,内容可能已被覆盖。在 Ctrl+Alt+Y 翻译时存 pendingClipboardFullText |
| 2026-06-26 | **移除大部分默认快捷键** | Ctrl+Shift+W 冲突关闭窗口、Ctrl+Shift+B 冲突构建、Ctrl+Shift+Y 冲突调试控制台。只保留 Ctrl+Shift+T 和 Ctrl+Alt+Y |
| 2026-06-26 | **单词本上下文仅接受 LLM 翻译** | fallback/offline-dict 返回的是单词释义或"未找到"消息不是整句翻译。source!='llm' 直接丢弃 |
| 2026-06-26 | **OutputChannel 调测日志** | VS Code Extension Host 的 console.log 用户不可见。建立 OutputChannel "VibeCoding English" 供用户查看 |
### 1.3 当前阻塞

View File

@ -73,7 +73,7 @@
| GAP-01 | 离线词库250 词已完成,待扩展到 5000 词 | 断网时覆盖率约 85%(日常词) | 🟡 P1 | ✅ 部分完成 | 已有 250 词 JSON后续可脚本批量生成 |
| GAP-02 | 有道 API Key 配置项 | ✅ 已添加到 package.json | 🟡 P1 | ✅ 完成 | Phase 4 |
| GAP-03 | 百度 API Key 配置项 | ✅ 已添加到 package.json | 🟢 P2 | ✅ 完成 | Phase 4 |
| GAP-04 | 未在 VS Code Extension Host 中 F5 调试 | 无法确认插件在实际环境中是否正常 | 🔴 P0 | ⬜ 待处理 | 需在 VS Code/CodeBuddy 中手动 F5 |
| GAP-04 | 未在 VS Code Extension Host 中 F5 调试 | ✅ 已通过 OutputChannel 日志完成多轮 F5 调测 | 🔴 P0 | ✅ 完成 (v0.2.3) | OutputChannel "VibeCoding English" 可查看详细日志 |
| GAP-05 | LLM API Key 提示 + Qwen 端点已配置 | ✅ LLMClient 有友好提示Qwen 可用 | 🟡 P1 | ✅ 完成 | Phase 4 |
| GAP-06 | 翻译结果仅用 Notification 展示,缺少 QuickPick 模式 | 多义词时展示效果差 | 🟢 P2 | ⬜ 待处理 | 后续版本 |
| GAP-07 | 翻译结果复制到剪贴板 | ✅ 已实现 | 🟢 P2 | ✅ 完成 | Phase 2 |
@ -90,7 +90,7 @@
|---|------|--------|------|
| TD-01 | 有道引擎的 MD5 签名用 require('crypto'),在纯 ESM 环境可能报错 | 🟢 P2 | ⬜ 待处理 |
| TD-02 | LLM Clientresponse_format 仅 OpenAI 发送Anthropic/custom 跳过 | ✅ Phase 4 修复 | 🟡 P1 | ✅ 完成 |
| TD-03 | WordbookService 没有并发锁 | 🟢 P2 | ⬜ 待处理 |
| TD-03 | WordbookService 没有并发锁 | 🟢 P2 | ✅ 完成 (v0.2.3) |
| TD-04 | LRU Cache 缓存键不含 API Key 信息 | 🟢 P2 | ⬜ 待处理 |
---

View File

@ -1,6 +1,6 @@
# VibeCoding English — 产品路线图
> 版本v1.0 | 更新日期2026-06-05
> 版本v1.1 | 更新日期2026-06-26
---
@ -92,6 +92,21 @@ check-point/{hash}/
| 发布计划文档 | ✅ |
| VSIX 打包 | 🔜 |
## v0.2.3 — Ollama + vLLM 兼容 + 单词本完缮 ✅ 已交付 (2026-06-26)
| 功能 | 状态 |
|------|------|
| 5 种 LLM 供应商精准区分Ollama/vLLM/OpenAI/Anthropic/自定义) | ✅ |
| Qwen 思考模式按后端类型关闭(/no_think vs chat_template_kwargs | ✅ |
| 单词本上下文异步句译 + 双路径兜底JSON → 极简直译) | ✅ |
| 单词本历史补翻(打开时自动扫描未翻译上下文) | ✅ |
| 单词本去重context/meaning+ 写入互斥锁防并发 | ✅ |
| 剪贴板上下文快照 + 60s 后置监听 | ✅ |
| 快捷键冲突彻底清理(仅保留 Ctrl+Shift+T + Ctrl+Alt+Y | ✅ |
| OutputChannel 调测日志 | ✅ |
| maxTokens 500 → 2048 | ✅ |
| fallback 翻译时隐藏"加入单词本"按钮 | ✅ |
## v0.3.0 — 多语言 + 社区 🌍
| 功能 | 优先级 |

View File

@ -7,7 +7,60 @@ and this project adheres to [Conventional Commits](https://www.conventionalcommi
---
## [0.2.2] - 2026-06-08
## [0.2.3] - 2026-06-26
### Added
#### LLM 供应商精准区分5 种后端)
- **Ollama** 卡片:`/no_think` 系统指令关闭 Qwen 思考模式Ollama 不支持 chat_template_kwargs
- **vLLM** 卡片:`chat_template_kwargs: { enable_thinking: false }` 参数关闭思考vLLM 原生支持)
- **通用自定义** 卡片:纯净 OpenAI 兼容透传,不发送任何特殊参数
- 5 张卡片各自独立配置存储,切换互不覆盖
#### 单词本增强
- **异步上下文句子翻译**:加入单词本后自动调用 LLM 翻译上下文句子
- **双路径句译引擎**:主译取 CONTEXT_TRANSLATE_PROMPT JSON → 为空时兜底用极简直译 Prompt
- **历史补翻机制**:打开单词本自动扫描未翻译的上下文,逐个翻译(间隔 2s
- **剪贴板上下文快照**Ctrl+Alt+Y 翻译时保存全文,加入单词本时使用(不再读已变化的剪贴板)
- **后置剪贴板监听**60s 窗口内检测包含目标单词的新复制内容并自动补入
- 上下文关键字高亮(`<mark>` 黄色高亮)
- fallback 翻译时隐藏"加入单词本"按钮
#### 调测设施
- **OutputChannel 日志**:查看 → 输出 → 选择 "VibeCoding English"
- 启动日志、LLM 配置日志、补翻进度日志、上下文翻译日志
- 每次 LLM 请求输出 provider/model/thinking_mode 信息
#### 设置面板增强
- ⚡ **快捷键配置按钮**:点击直接跳转 VS Code 快捷键设置并搜索本插件命令
- 响应超时可配置5300s
- Max Tokens 可配置25616384默认 2048
### Fixed
#### 致命 Bug
- **Ollama 连接失败**`chat_template_kwargs` 参数 Ollama 不认识 → 拆分为独立 provider
- **maxTokens 500 导致 Qwen3 思考模型 content 为空** → 提升到 2048
- **单词本连续点击重复条目**:并发读写竞态条件 → Promise 互斥锁串行化
- **单词本上下文翻译始终为空**`contextTranslation` 为空时回退到 `meanings[0].definition`(单词释义)→ 双路径直译兜底
- **供应商切换覆盖其他配置**3 组 → 5 组独立存储
#### 翻译质量
- **LLM 翻译了错误单词**:上下文中含 "despair",把 "bare" 翻成"绝望" → Prompt 强调 TARGET WORD + 不要翻译其他词
- **上下文翻译用了单词释义**"hereby → 特此" 而不是翻译整句 → 拆分为双路径
- **fallback 消息存入单词本**"未找到释义…" → `source !== 'llm'` 直接丢弃
#### UX 修复
- **默认快捷键冲突**Ctrl+Shift+W/B/G/Y 全冲突 → 仅保留 Ctrl+Shift+T + Ctrl+Alt+Y
- 剪贴板快捷键从冲突的 Ctrl+Shift+Y 改为 Ctrl+Alt+Y
- 保存设置后提示 5s 自动消失
- 供应商选中 ✓ 标记正确刷新
### Changed
- **配置优先级**VS Code 设置 > secrets.json > 默认值
- `provider` 类型扩展为 `'openai' | 'anthropic' | 'ollama' | 'vllm' | 'custom'`
- maxTokens 默认值 500 → 2048
- 默认快捷键从 6 个缩减到 2 个(其余改为仅注册命令,用户自行绑定)
### Added

View File

@ -1,8 +1,8 @@
{
"name": "vibecoding-english",
"displayName": "VibeCoding English",
"description": "编码中学英语:选中即翻译 · 自动积累单词本 · SM-2 间隔复习。支持 Hover/快捷键/终端/剪贴板全场景取词LLM 智能翻译。",
"version": "0.2.2",
"description": "编码中学英语:选中即翻译 · 自动积累单词本 · SM-2 间隔复习。支持 OpenAI / Anthropic / Ollama / vLLM / 自定义 5 种 LLM 后端Hover / 快捷键 / 终端 / 剪贴板全场景取词,上下文句子自动翻译。",
"version": "0.2.3",
"publisher": "vibecoding-english",
"icon": "icon.png",
"repository": {
@ -75,23 +75,9 @@
"key": "ctrl+shift+t",
"when": "editorHasSelection"
},
{
"command": "vibecoding-english.contextTranslate",
"key": "ctrl+shift+g",
"when": "editorHasSelection"
},
{
"command": "vibecoding-english.addToWordbook",
"key": "ctrl+shift+w",
"when": "editorHasSelection"
},
{
"command": "vibecoding-english.openWordbook",
"key": "ctrl+shift+b"
},
{
"command": "vibecoding-english.translateFromClipboard",
"key": "ctrl+shift+y"
"key": "ctrl+alt+y"
}
],
"menus": {
@ -127,11 +113,13 @@
"vibecoding-english.llmProvider": {
"type": "string",
"default": "openai",
"enum": ["openai", "anthropic", "custom"],
"enum": ["openai", "anthropic", "ollama", "vllm", "custom"],
"enumDescriptions": [
"OpenAI (GPT-4o / GPT-4o-mini)",
"Anthropic (Claude Sonnet / Opus)",
"自定义端点 (Ollama / vLLM / Qwen 等)"
"Ollama (本地大模型 / Qwen3 / Llama)",
"vLLM (高性能推理 / Qwen3.6-AWQ)",
"通用自定义 (OpenAI 兼容端点)"
],
"order": 1,
"markdownDescription": "选择 LLM 供应商。\n\n> 🧪 配置完成后,打开 [VCE 设置面板](command:vibecoding-english.openSettings) 点击「测试连接」按钮验证配置是否正确。\n\n---\n💡 **配置文件方式**: 也可通过 `secrets.json` 配置 (Win: `%USERPROFILE%\\.vibecoding-english\\secrets.json`, Mac/Linux: `~/.vibecoding-english/secrets.json`)"
@ -156,6 +144,20 @@
"description": "LLM 模型名称",
"markdownDescription": "如 `gpt-4o-mini`、`claude-sonnet-4-20250514`、`qwen3.5:9b`"
},
"vibecoding-english.llmTimeout": {
"type": "number",
"default": 30,
"order": 5,
"description": "LLM 响应超时时间(秒)",
"markdownDescription": "请求 LLM 翻译的超时时间超时后自动切换到备用翻译引擎。范围5300 秒。"
},
"vibecoding-english.llmMaxTokens": {
"type": "number",
"default": 2048,
"order": 6,
"description": "LLM 最大输出 token 数",
"markdownDescription": "本地思考模型(如 Qwen3 / DeepSeek-R1建议 ≥2048避免思考过程截断导致内容为空。范围25616384。"
},
"vibecoding-english.baiduAppId": {
"type": "string",
"default": "",

View File

@ -27,7 +27,7 @@ import {
LLMAgentEngine, YoudaoDictEngine, BaiduDictEngine, MinOfflineDictEngine,
TranslateDispatcher, WordbookService, SM2ReviewEngine, ExportService,
DEFAULT_CONFIG, LLMClient,
SENTENCE_TRANSLATE_PROMPT,
SENTENCE_TRANSLATE_PROMPT, DIRECT_SENTENCE_TRANSLATE_PROMPT,
} from '@vibecoding-english/core';
import type { AppConfig, TranslateResult, ReviewQuality, AIToolSource } from '@vibecoding-english/core';
import { TranslateHoverProvider } from './providers/hover-provider';
@ -67,8 +67,14 @@ let pendingTranslateContext: string | undefined;
let pendingContextSource: AIToolSource = 'manual';
/** 触发器编辑器中单词所在行号(解决通知按钮点击后光标漂移问题) */
let pendingAddWordLine: number | undefined;
/** 剪贴板快照:翻译时保存全文,加入单词本时作为上下文 */
let pendingClipboardFullText: string | undefined;
/** CodeBuddy 会话上下文提供器Phase 1 */
let codebuddyContextProvider: CodeBuddyContextProvider | undefined;
/** 正在进行的异步上下文翻译(防并发重复触发) */
const inflightTranslations = new Set<string>();
/** 用户正在主动翻译(补翻需让行) */
let userTranslating = false;
// ============================================================
// 剪贴板历史上下文捕获v0.3
@ -85,13 +91,14 @@ let contextWatcher: { word: string; wordId: string; timer: ReturnType<typeof set
export function activate(context: vscode.ExtensionContext): void {
// 🚨 版本标记
console.log('=== VCE v0.1.1 HOTLOADED ===');
console.log('[VCE] extensionPath:', context.extensionPath);
console.log('=== VCE v0.2.2 LOADED ===');
logger = new ConsoleLogger();
eventBus = new EventBus();
outputChannel = vscode.window.createOutputChannel('VibeCoding English');
context.subscriptions.push(outputChannel);
// 启动日志:用户可通过 查看 → 输出 → 选择 "VibeCoding English" 查看调测信息
outputChannel.appendLine(`[VCE] 启动 | 扩展路径: ${context.extensionPath}`);
logger.info('VCE activating...');
// ===== 加载敏感配置 =====
@ -99,6 +106,8 @@ export function activate(context: vscode.ExtensionContext): void {
// ===== 初始化配置 =====
configManager = createConfigManager(secrets);
const llmCfg = configManager.getLLMConfig();
outputChannel.appendLine(`[VCE] LLM配置: provider=${llmCfg.provider} model=${llmCfg.model} endpoint=${llmCfg.endpoint || '默认'} timeout=${llmCfg.timeout}ms maxTokens=${llmCfg.maxTokens}`);
const dataDir = path.join(os.homedir(), '.vibecoding-english');
const storage = new JsonFileStorage(dataDir);
@ -256,11 +265,11 @@ async function detectModel(endpoint: string | undefined, apiKey: string | undefi
function createConfigManager(secrets: SecretsConfig): ConfigManager {
const vscConfig = vscode.workspace.getConfiguration('vibecoding-english');
const provider = (secrets.llm?.provider || vscConfig.get('llmProvider') || 'openai') as AppConfig['llm']['provider'];
const endpoint = secrets.llm?.endpoint || vscConfig.get<string>('llmEndpoint') || undefined;
// 显式指定模型 → 直接用;未指定 → 用默认占位 → activate 中异步自动发现覆盖
const model = secrets.llm?.model || vscConfig.get('llmModel') || 'gpt-4o-mini';
const apiKey = secrets.llm?.apiKey || vscConfig.get<string>('llmApiKey') || undefined;
const provider = (vscConfig.get<string>('llmProvider') || secrets.llm?.provider || 'openai') as AppConfig['llm']['provider'];
// 优先级VS Code 设置 > secrets.json > 默认值
const endpoint = vscConfig.get<string>('llmEndpoint') || secrets.llm?.endpoint || undefined;
const model = vscConfig.get<string>('llmModel') || secrets.llm?.model || 'gpt-4o-mini';
const apiKey = vscConfig.get<string>('llmApiKey') || secrets.llm?.apiKey || undefined;
return new ConfigManager({
enableOfflineDict: vscConfig.get('enableOfflineDict', true),
@ -269,7 +278,10 @@ function createConfigManager(secrets: SecretsConfig): ConfigManager {
autoDetectReview: vscConfig.get('autoDetectReview', true),
reviewReminderInterval: vscConfig.get('reviewReminderInterval', 'daily') as AppConfig['reviewReminderInterval'],
language: vscConfig.get('language', 'zh-CN') as AppConfig['language'],
llm: { provider, model, endpoint, apiKey, maxTokens: DEFAULT_CONFIG.llm.maxTokens, temperature: DEFAULT_CONFIG.llm.temperature, timeout: DEFAULT_CONFIG.llm.timeout },
llm: { provider, model, endpoint, apiKey,
maxTokens: vscConfig.get('llmMaxTokens', DEFAULT_CONFIG.llm.maxTokens),
temperature: DEFAULT_CONFIG.llm.temperature,
timeout: (vscConfig.get('llmTimeout', 30) || 30) * 1000 }, // UI 秒 → client 毫秒
});
}
@ -390,10 +402,13 @@ function registerCommands(context: vscode.ExtensionContext): void {
vscode.window.showInformationMessage('剪贴板中没有找到可翻译的英文内容');
return;
}
// 追踪剪贴板内容用于上下文回填
// 快照:保存剪贴板全文作为上下文(加入单词本时使用)
trackClipboard(trimmed);
// 标记来源为 clipboard
pendingClipboardFullText = trimmed;
// 标记用户正在翻译,补翻暂停
userTranslating = true;
await handleTranslate(trimmed, undefined, false, 'clipboard', mode);
userTranslating = false;
},
);
@ -500,7 +515,7 @@ function showTranslationResult(
return;
}
// 单词翻译展示(原有逻辑)
// 单词翻译展示
const primary = result.meanings[0];
const mode = isContextMode ? ' [上下文]' : '';
const rawPhonetic = result.phonetic ? result.phonetic.replace(/^\/|\/$/g, '') : '';
@ -509,6 +524,13 @@ function showTranslationResult(
const trans = primary.exampleTranslations?.[0] ? ` (${primary.exampleTranslations[0]})` : '';
const msg = `${word}${phonetic} · ${primary.partOfSpeech} ${primary.definition}${mode}${example}${trans} · ${src}`;
// fallback/offline-dict 时隐藏"加入单词本"(翻译失败,无保存价值)
const isFallback = result.source === 'fallback' || result.source === 'offline-dict';
if (isFallback) {
vscode.window.showInformationMessage(msg.trim());
return;
}
vscode.window.showInformationMessage(msg.trim(), { title: '📖 加入单词本' }, { title: '📋 复制' })
.then((sel) => {
if (sel?.title === '📖 加入单词本') handleAddToWordbook(word, result);
@ -556,14 +578,20 @@ async function handleAddToWordbook(
pendingTranslateContext = undefined;
pendingContextSource = 'manual';
} else if (sourceOverride === 'clipboard' || pendingContextSource === 'clipboard') {
// 剪贴板来源:优先尝试从历史中获取上下文
const historyCtx = tryGetClipboardContext(word);
if (historyCtx) {
context = historyCtx;
// 剪贴板来源:优先用翻译时快照的全文,其次读当前剪贴板
if (pendingClipboardFullText && pendingClipboardFullText.length > word.length + 10) {
context = pendingClipboardFullText;
source = 'clipboard';
pendingClipboardFullText = undefined;
} else {
context = word;
source = 'clipboard';
try {
const clipText = await vscode.env.clipboard.readText();
context = (clipText && clipText.length > word.length + 10) ? clipText : word;
source = 'clipboard';
} catch {
context = word;
source = 'clipboard';
}
}
pendingContextSource = 'manual';
} else {
@ -604,6 +632,54 @@ async function handleAddToWordbook(
eventBus.emit('wordbook:word-added', entry);
wordbookPanel?.webview.postMessage({ command: 'wordAdded' });
// 有上下文句子但无中文翻译 → 异步调用 LLM 翻译上下文
const savedWordId = entry.id;
const savedContext = context;
const lastCtx = entry.contexts[entry.contexts.length - 1];
const alreadyTranslated = lastCtx?.translation && lastCtx.translation.length > 0;
if (context !== word && context.length > 10 && !contextTrans && !alreadyTranslated) {
const contextIdx = entry.contexts.length - 1;
const inflightKey = `${savedWordId}:${contextIdx}`;
if (inflightTranslations.has(inflightKey)) {
return; // 已有进行中的翻译,跳过
}
inflightTranslations.add(inflightKey);
setTimeout(() => {
(async () => {
try {
outputLog(`[Wordbook] 异步翻译: "${word}" → ctx=${savedContext.length}字节`);
const ctxResult = await translateDispatcher.translateWithContext(word, savedContext);
// 仅接受 LLM 翻译结果fallback/offline 一律静默丢弃
if (ctxResult.source !== 'llm') {
outputLog(`[Wordbook] 非LLM结果已丢弃: "${word}" source=${ctxResult.source}`);
return;
}
// 只取 contextTranslation整句翻译为空时用直接句译兜底
let translatedText = ctxResult.contextTranslation || '';
if (!translatedText && ctxResult.source === 'llm') {
outputLog(`[Wordbook] contextTranslation为空尝试直接句译: "${word}"`);
translatedText = await translateSentenceDirect(savedContext);
}
if (translatedText && translatedText !== word) {
await wordbookService.updateContextTranslation(savedWordId, contextIdx, translatedText);
outputLog(`[Wordbook] 上下文翻译已保存: "${word}" → ${translatedText.substring(0, 50)}`);
wordbookPanel?.webview.postMessage({ command: 'wordAdded' });
} else {
outputLog(`[Wordbook] 翻译失败: "${word}" source=${ctxResult.source} ctxLen=${savedContext.length}B`);
}
} catch (err) {
outputLog(`[Wordbook] LLM翻译失败: "${word}" - ${(err as Error).message}`);
} finally {
inflightTranslations.delete(inflightKey);
}
})();
}, 200);
}
// 剪贴板来源且无上下文 → 启动后置监听,等待用户复制上下文
if (source === 'clipboard' && context === word) {
startClipboardWatch(word, entry.id);
@ -666,6 +742,8 @@ function openWordbookPanel(context: vscode.ExtensionContext): void {
case 'refresh': {
const words = await wordbookService.getAllWords();
wordbookPanel?.webview.postMessage({ command: 'loadWords', data: words });
// 自动补翻历史未翻译的上下文
backfillUntranslatedContexts(words);
break;
}
case 'deleteWord': {
@ -682,6 +760,76 @@ function openWordbookPanel(context: vscode.ExtensionContext): void {
context.subscriptions.push(wordbookPanel);
}
/**
* dispatcher JSON
* CONTEXT_TRANSLATE_PROMPT contextTranslation
*/
async function translateSentenceDirect(sentence: string): Promise<string> {
const cfg = configManager.getLLMConfig();
const client = new LLMClient({ ...cfg });
const prompt = DIRECT_SENTENCE_TRANSLATE_PROMPT.replace('{text}', sentence);
try {
const result = await client.chat(prompt, 'Translate the text above to Chinese.');
return result.trim();
} catch (err) {
outputLog(`[DirectTranslate] 失败: ${(err as Error).message}`);
return '';
}
}
/**
*
* contextTranslation
*/
async function backfillUntranslatedContexts(words: import('@vibecoding-english/core').WordEntry[]): Promise<void> {
for (const w of words) {
for (let ci = 0; ci < w.contexts.length; ci++) {
const ctx = w.contexts[ci];
// 跳过已翻译的、句子太短的、句子等于单词本身的
if (ctx.translation && ctx.translation.length > 0) continue;
if (!ctx.sentence || ctx.sentence.length < 10) continue;
if (ctx.sentence === w.word) continue;
const inflightKey = `${w.id}:${ci}`;
if (inflightTranslations.has(inflightKey)) continue;
// 用户正在翻译补翻暂停每500ms重试
if (userTranslating) {
await new Promise((r) => setTimeout(r, 500));
continue; // 下次循环再试
}
const cfg = configManager.getLLMConfig();
inflightTranslations.add(inflightKey);
outputLog(`[补翻] 开始: "${w.word}" ctx=${ctx.sentence.length}B`);
try {
const result = await translateDispatcher.translateWithContext(w.word, ctx.sentence);
let translated = result.contextTranslation || '';
// 双路径:主译 contextTranslation 为空 → 用直接句译兜底
if (!translated && result.source === 'llm') {
outputLog(`[补翻] contextTranslation为空尝试直接句译: "${w.word}"`);
translated = await translateSentenceDirect(ctx.sentence);
}
if (translated) {
await wordbookService.updateContextTranslation(w.id, ci, translated);
outputLog(`[补翻] 完成: "${w.word}" → ${translated.substring(0, 50)}`);
wordbookPanel?.webview.postMessage({ command: 'wordAdded' });
} else {
outputLog(`[补翻] 跳过: "${w.word}" source=${result.source} contextTranslation=空`);
}
} catch (err) {
outputLog(`[补翻] 失败: "${w.word}" - ${(err as Error).message}`);
} finally {
inflightTranslations.delete(inflightKey);
}
// 每个翻译间等待 2s避免 LLM 过载
await new Promise((r) => setTimeout(r, 2000));
}
}
}
// ============================================================
// TreeView侧边栏简短列表
// ============================================================
@ -878,13 +1026,19 @@ function startClipboardWatch(word: string, wordId: string): void {
let lastClipboard = '';
let attempts = 0;
const maxAttempts = 30; // 30 * 1000ms = 30s
const maxAttempts = 60; // 60s 等待用户复制整句
// 提示用户去复制包含该单词的整句
vscode.window.showInformationMessage(
`📋 请在 60s 内复制包含 "${word}" 的**完整句子**作为上下文`, { modal: false },
);
const timer = setInterval(async () => {
attempts++;
if (attempts > maxAttempts) {
clearInterval(timer);
contextWatcher = null;
outputLog(`[ClipboardWatch] 超时: "${word}" 未检测到上下文句子`);
return;
}
@ -896,24 +1050,27 @@ function startClipboardWatch(word: string, wordId: string): void {
trackClipboard(text);
const wordLower = word.toLowerCase();
if (text.toLowerCase().includes(wordLower) && text.length > word.length) {
// 找到上下文 → 写入单词本
// 必须包含单词且比单词本身长(是整句不是单独的单词)
if (text.toLowerCase().includes(wordLower) && text.length > word.length + 5) {
clearInterval(timer);
contextWatcher = null;
// 更新已有条目的上下文(不创建新条目)
await wordbookService.addWord({
word,
context: text,
contextTranslation: '',
source: 'clipboard',
});
outputLog(`[ClipboardWatch] 捕获上下文: "${word}" → ${text.substring(0, 60)}`);
wordbookPanel?.webview.postMessage({ command: 'wordAdded' });
vscode.window.showInformationMessage(`📋 已自动捕获 "${word}" 的上下文`);
vscode.window.showInformationMessage(`📋 已自动捕获 "${word}" 的上下文句子`);
}
} catch { /* 忽略剪贴板读取错误 */ }
}, 1000);
contextWatcher = { word, wordId, timer, startTime: Date.now() };
outputLog(`[ClipboardWatch] 启动: "${word}" 等待剪贴板上下文 (最长${maxAttempts}s)`);
}
/**
@ -969,6 +1126,17 @@ function getSourceLabel(source: string): string {
return m[source] || '';
}
/**
* VS Code OutputChannel console
* OutputChannel "VibeCoding English"
*/
function outputLog(message: string, detail?: string): void {
const ts = new Date().toLocaleTimeString('zh-CN');
const line = `[${ts}] ${message}${detail ? ' ' + detail : ''}`;
console.log(line);
outputChannel?.appendLine(line);
}
function showWelcomeMessage(context: vscode.ExtensionContext): void {
if (!context.globalState.get('vibecoding-english.welcomeShown')) {
vscode.window.showInformationMessage('🎉 VibeCoding English 已就绪!选中英文单词按 Ctrl+Shift+T 翻译');
@ -1112,6 +1280,8 @@ function openSettingsPanel(context: vscode.ExtensionContext): void {
llmApiKey: vscConfig.get('llmApiKey', ''),
llmEndpoint: vscConfig.get('llmEndpoint', ''),
llmModel: vscConfig.get('llmModel', 'gpt-4o-mini'),
llmTimeout: vscConfig.get('llmTimeout', 30),
llmMaxTokens: vscConfig.get('llmMaxTokens', 2048),
providerConfigs: providerConfigs || undefined,
baiduAppId: vscConfig.get('baiduAppId', ''),
baiduAppKey: vscConfig.get('baiduAppKey', ''),
@ -1133,6 +1303,8 @@ function openSettingsPanel(context: vscode.ExtensionContext): void {
await vscConfig.update('llmApiKey', c.llmApiKey, true);
await vscConfig.update('llmEndpoint', c.llmEndpoint, true);
await vscConfig.update('llmModel', c.llmModel, true);
await vscConfig.update('llmTimeout', c.llmTimeout || 30, true);
await vscConfig.update('llmMaxTokens', c.llmMaxTokens || 2048, true);
// 序列化三组供应商配置
if (c.providerConfigs) {
await vscConfig.update('providerConfigs', JSON.stringify(c.providerConfigs), true);
@ -1150,6 +1322,8 @@ function openSettingsPanel(context: vscode.ExtensionContext): void {
const cfg = msg.config || {};
const endpoint = (cfg.endpoint || vscConfig.get('llmEndpoint', '') || '').replace(/\/+$/, '');
const apiKey = cfg.apiKey || vscConfig.get('llmApiKey', '') || '';
// 使用前端传来的超时(毫秒),最低 10s
const timeoutMs = Math.max(cfg.timeout || 30000, 10000);
if (!endpoint) {
settingsPanel?.webview.postMessage({ command: 'testResult', success: false, message: '❌ 请先填写端点 URL' });
@ -1157,7 +1331,7 @@ function openSettingsPanel(context: vscode.ExtensionContext): void {
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const timeout = setTimeout(() => controller.abort(), timeoutMs);
// 将 controller 暴露给 testAbort
(settingsPanel as any)._testAbort = () => { controller.abort(); clearTimeout(timeout); };
@ -1172,7 +1346,7 @@ function openSettingsPanel(context: vscode.ExtensionContext): void {
});
} catch {
// fetch 失败(如 http:// 在 Extension Host 中)→ 用 Node.js http 回退
res = await fetchWithNodeHttp(url, apiKey, controller.signal);
res = await fetchWithNodeHttp(url, apiKey, controller.signal, timeoutMs);
}
clearTimeout(timeout);
@ -1191,7 +1365,8 @@ function openSettingsPanel(context: vscode.ExtensionContext): void {
}
} catch (e: unknown) {
clearTimeout(timeout);
const msg2 = (e as Error).name === 'AbortError' ? '⏰ 连接超时 (>10s)' : `❌ 连接失败: ${(e as Error).message}`;
const timeoutSec = Math.round(timeoutMs / 1000);
const msg2 = (e as Error).name === 'AbortError' ? `⏰ 连接超时 (>${timeoutSec}s)` : `❌ 连接失败: ${(e as Error).message}`;
settingsPanel?.webview.postMessage({ command: 'testResult', success: false, message: msg2 });
}
break;
@ -1201,6 +1376,10 @@ function openSettingsPanel(context: vscode.ExtensionContext): void {
if (abort) abort();
break;
}
case 'openKeybindings':
// 打开快捷键设置并自动搜索本插件命令
vscode.commands.executeCommand('workbench.action.openGlobalKeybindings', 'vibecoding-english.');
break;
case 'cancel':
break;
}
@ -1213,11 +1392,14 @@ function openSettingsPanel(context: vscode.ExtensionContext): void {
/**
* Node.js http 退Extension Host fetch() http:// URL 上会失败,
* http/https
*
* @param timeoutMs 30000
*/
async function fetchWithNodeHttp(
url: string,
apiKey: string,
signal: AbortSignal,
timeoutMs: number = 30000,
): Promise<Response> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
@ -1225,7 +1407,7 @@ async function fetchWithNodeHttp(
const req = mod.request(url, {
method: 'GET',
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
timeout: 10000,
timeout: timeoutMs,
}, (res) => {
let body = '';
res.on('data', (chunk: string) => { body += chunk; });
@ -1235,12 +1417,33 @@ async function fetchWithNodeHttp(
status: res.statusCode || 500,
statusText: res.statusMessage || '',
json: async () => JSON.parse(body),
} as Response);
} as unknown as Response);
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
signal.addEventListener('abort', () => { req.destroy(); reject(new DOMException('aborted', 'AbortError')); });
// 超时后销毁请求并 reject触发 AbortError 以便上层正确识别)
req.on('timeout', () => {
req.destroy(new DOMException('The operation was aborted', 'AbortError'));
});
// signal 触发时(如 controller.abort())也销毁请求
const onAbort = () => {
req.destroy(new DOMException('The operation was aborted', 'AbortError'));
};
if (signal.aborted) {
onAbort();
} else {
signal.addEventListener('abort', onAbort, { once: true });
}
req.on('error', (err) => {
if ((err as NodeJS.ErrnoException).code === 'ECONNRESET' && signal?.aborted) {
reject(new DOMException('The operation was aborted', 'AbortError'));
} else {
reject(err);
}
});
req.end();
});
}

View File

@ -100,6 +100,16 @@
<input type="text" id="llm-model" placeholder="gpt-4o-mini"/>
<span class="inline-hint">如 gpt-4o-mini / qwen3.6-int4-AWQ</span>
</div>
<div class="row">
<label>响应超时</label>
<input type="number" id="llm-timeout" value="30" min="5" max="300" step="5" style="max-width:100px;"/>
<span class="inline-hint">单位5300s超时后切换备用翻译引擎</span>
</div>
<div class="row">
<label>Max Tokens</label>
<input type="number" id="llm-maxTokens" value="2048" min="256" max="16384" step="256" style="max-width:110px;"/>
<span class="inline-hint">本地思考模型建议 ≥2048避免思考过程截断</span>
</div>
<div class="row" style="margin-top:10px;">
<button onclick="testConnection()" id="test-btn" style="padding:6px 16px;border:1px solid var(--border);border-radius:4px;background:var(--input-bg);color:var(--fg);cursor:pointer;font-size:inherit;">🧪 测试连接</button>
<span id="test-status" style="font-size:0.85em;color:var(--desc);"></span>
@ -160,17 +170,31 @@
<!-- 快捷键 -->
<div class="section">
<h3>⌨️ 快捷键</h3>
<div class="kb-row">
<div class="kb-item">Ctrl+Shift+T 翻译选中内容</div>
<div class="kb-item">Ctrl+Shift+G 上下文翻译</div>
<div class="kb-item">Ctrl+Shift+Y 翻译剪贴板</div>
<h3>⌨️ 快捷键配置</h3>
<p style="font-size:0.82em;color:var(--desc);margin-bottom:8px;">点击下方按钮打开 VS Code 快捷键设置(已自动搜索本插件命令),双击命令项即可绑定快捷键。</p>
<button onclick="openKeybindings()" style="padding:8px 16px;background:var(--btn-bg);color:var(--btn-fg);border:none;border-radius:4px;cursor:pointer;font-size:0.95em;">⚡ 打开快捷键配置</button>
<div style="margin-top:10px;">
<div class="kb-row">
<div class="kb-item">vibecoding-english.translate</div>
<span style="font-size:0.78em;color:var(--desc);">翻译选中内容(推荐 Ctrl+Shift+T</span>
</div>
<div class="kb-row">
<div class="kb-item">vibecoding-english.contextTranslate</div>
<span style="font-size:0.78em;color:var(--desc);">上下文翻译(推荐 Ctrl+Alt+G</span>
</div>
<div class="kb-row">
<div class="kb-item">vibecoding-english.addToWordbook</div>
<span style="font-size:0.78em;color:var(--desc);">加入单词本(推荐 Ctrl+Alt+W</span>
</div>
<div class="kb-row">
<div class="kb-item">vibecoding-english.openWordbook</div>
<span style="font-size:0.78em;color:var(--desc);">打开单词本(推荐 Ctrl+Alt+B</span>
</div>
<div class="kb-row">
<div class="kb-item">vibecoding-english.translateFromClipboard</div>
<span style="font-size:0.78em;color:var(--desc);">翻译剪贴板(推荐 Ctrl+Alt+Y</span>
</div>
</div>
<div class="kb-row">
<div class="kb-item">Ctrl+Shift+W 加入单词本</div>
<div class="kb-item">Ctrl+Shift+B 打开单词本</div>
</div>
<p style="font-size:0.82em;color:var(--desc);margin-top:6px;">修改快捷键: Ctrl+K Ctrl+S → 搜索 "vibecoding-english" → 双击修改</p>
</div>
<!-- 配置文件说明 -->
@ -207,11 +231,12 @@
<div class="section">
<h3>📖 快速入门</h3>
<div class="guide">
<p><strong>1.</strong> 选择一个 LLM 供应商,填写 API Key → 保存</p>
<p><strong>2.</strong> 在代码中 <code>Hover</code> 英文单词 → 自动翻译</p>
<p><strong>3.</strong> 选中单词 → <code>Ctrl+Shift+T</code> → 点击 <code>📖 加入单词本</code></p>
<p><strong>4.</strong> <code>Ctrl+Shift+B</code> 打开单词本查看积累的单词</p>
<p><strong>5.</strong> Agent 对话中复制单词 → <code>Ctrl+Shift+Y</code> 翻译</p>
<p><strong>1.</strong> 选择一个 LLM 供应商,填写 API KeyOllama/vLLM 无需 Key→ 点击「🧪 测试连接」验证</p>
<p><strong>2.</strong> 验证通过后点击「💾 保存设置」</p>
<p><strong>3.</strong> 在代码中 <code>Hover</code> 英文单词 → 自动弹出翻译</p>
<p><strong>4.</strong> 选中单词 → 右键 → 「翻译选中单词」→ 点击「📖 加入单词本」</p>
<p><strong>5.</strong> 使用命令面板 (<code>Ctrl+Shift+P</code>) 搜索「打开单词本」查看积累的单词</p>
<p><strong>6.</strong> 全局快捷键可在 <code>Ctrl+K Ctrl+S</code> 中自定义绑定</p>
</div>
</div>
@ -224,33 +249,42 @@
<script>
const vscode = acquireVsCodeApi();
// 供应商预设(参考 Chatbox 风格
// 供应商预设(5 种后端类型,各自独立的兼容策略
const PROVIDERS = [
{ id: 'openai', name: 'OpenAI', sub: 'GPT-4o / GPT-4o-mini', endpoint: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
{ id: 'anthropic', name: 'Anthropic', sub: 'Claude Sonnet / Opus', endpoint: 'https://api.anthropic.com/v1', model: 'claude-sonnet-4-20250514' },
{ id: 'custom', name: '自定义', sub: 'Ollama / vLLM / Qwen', endpoint: '', model: '' },
{ id: 'openai', name: 'OpenAI', sub: 'GPT-4o / GPT-4o-mini', endpoint: 'https://api.openai.com/v1', model: 'gpt-4o-mini', desc: '需要 API Key' },
{ id: 'anthropic', name: 'Anthropic', sub: 'Claude Sonnet / Opus', endpoint: 'https://api.anthropic.com/v1', model: 'claude-sonnet-4-20250514', desc: '需要 API Key' },
{ id: 'ollama', name: 'Ollama', sub: '本地大模型 / Qwen3 / Llama', endpoint: 'http://localhost:11434/v1', model: '', desc: '本地部署,无需 Key' },
{ id: 'vllm', name: 'vLLM', sub: '高性能推理 / Qwen3.6-AWQ', endpoint: '', model: '', desc: 'vLLM 部署,支持 chat_template_kwargs' },
{ id: 'custom', name: '通用自定义', sub: 'OpenAI 兼容 API', endpoint: '', model: '', desc: '完全手动配置' },
];
let selectedProvider = 'openai';
// 组独立配置:切换供应商时自动保存/恢复
// 组独立配置:切换供应商时自动保存/恢复,互不覆盖
const providerConfigs = {
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', model: 'gpt-4o-mini' },
anthropic: { endpoint: 'https://api.anthropic.com/v1', apiKey: '', model: 'claude-sonnet-4-20250514' },
ollama: { endpoint: 'http://localhost:11434/v1', apiKey: '', model: '' },
vllm: { endpoint: '', apiKey: '', model: '' },
custom: { endpoint: '', apiKey: '', model: '' },
};
function renderProviders() {
const grid = document.getElementById('provider-grid');
grid.innerHTML = PROVIDERS.map(p => `
<div class="provider-card ${p.id === selectedProvider ? 'selected' : ''}"
grid.innerHTML = PROVIDERS.map(p => {
const isActive = p.id === selectedProvider;
const badge = isActive ? ' ✓' : '';
return `
<div class="provider-card ${isActive ? 'selected' : ''}"
data-provider="${p.id}" onclick="selectProvider('${p.id}')">
<div class="name">${p.name}</div>
<div class="name">${p.name}${badge}</div>
<div class="sub">${p.sub}</div>
<div style="font-size:0.7em;color:var(--desc);margin-top:2px;">${p.desc || ''}</div>
</div>
`).join('');
`}).join('');
}
function saveCurrentToProvider() {
// 始终从表单字段取值并保存到当前激活的 provider
providerConfigs[selectedProvider] = {
endpoint: getVal('llm-endpoint'),
apiKey: getVal('llm-apiKey'),
@ -259,19 +293,20 @@
}
function loadProviderConfig(id) {
const cfg = providerConfigs[id];
setVal('llm-endpoint', cfg.endpoint);
setVal('llm-apiKey', cfg.apiKey);
setVal('llm-model', cfg.model);
const cfg = providerConfigs[id] || {};
setVal('llm-endpoint', cfg.endpoint || '');
setVal('llm-apiKey', cfg.apiKey || '');
setVal('llm-model', cfg.model || '');
}
function selectProvider(id) {
if (selectedProvider !== id) {
saveCurrentToProvider(); // 保存当前供应商配置
saveCurrentToProvider(); // 保存当前正在编辑的 provider表单字段的值
selectedProvider = id;
loadProviderConfig(id); // 加载新供应商配置
loadProviderConfig(id); // 加载新 provider 的配置到表单
}
document.querySelectorAll('.provider-card').forEach(c => c.classList.toggle('selected', c.dataset.provider === id));
// 重新渲染卡片,确保 ✓ 标记和选中样式都正确
renderProviders();
}
function switchDictTab(tab) {
@ -310,6 +345,8 @@
setChecked('enable-review', c.reviewEnabled !== false);
setChecked('enable-offline', c.offlineEnabled !== false);
setVal('review-interval', c.reviewInterval || 'daily');
setVal('llm-timeout', c.llmTimeout || 30);
setVal('llm-maxTokens', c.llmMaxTokens || 2048);
}
});
@ -317,13 +354,22 @@
renderProviders();
function save() {
saveCurrentToProvider(); // 确保当前配置已保存
const activeCfg = providerConfigs[selectedProvider];
// 直接从表单字段读取当前可见的配置值,保存到对应的 provider
const currentEndpoint = getVal('llm-endpoint');
const currentApiKey = getVal('llm-apiKey');
const currentModel = getVal('llm-model');
providerConfigs[selectedProvider] = {
endpoint: currentEndpoint,
apiKey: currentApiKey,
model: currentModel,
};
const config = {
llmProvider: selectedProvider,
llmApiKey: activeCfg.apiKey,
llmEndpoint: activeCfg.endpoint,
llmModel: activeCfg.model,
llmApiKey: currentApiKey,
llmEndpoint: currentEndpoint,
llmModel: currentModel,
llmTimeout: parseInt(getVal('llm-timeout')) || 30,
llmMaxTokens: parseInt(getVal('llm-maxTokens')) || 2048,
providerConfigs: providerConfigs, // 保存全部三组配置
baiduAppId: getVal('baidu-appId'),
baiduAppKey: getVal('baidu-appKey'),
@ -338,12 +384,21 @@
reviewInterval: getVal('review-interval'),
};
vscode.postMessage({ command: 'saveConfig', config });
document.getElementById('status').textContent = '✅ 设置已保存,部分配置需重新加载窗口生效';
document.getElementById('status').className = 'status success';
const statusEl = document.getElementById('status');
statusEl.textContent = '✅ 设置已保存';
statusEl.className = 'status success';
// 5 秒后自动清除提示,避免后续保存时看起来像没响应
setTimeout(() => { statusEl.textContent = ''; statusEl.className = 'status'; }, 5000);
}
let testAbortTimer = null;
function getTestTimeout() {
// 从输入框读取用户配置的超时(秒),转毫秒,最低 10s
const sec = parseInt(getVal('llm-timeout')) || 30;
return Math.max(sec * 1000, 10000);
}
function testConnection() {
const btn = document.getElementById('test-btn');
const status = document.getElementById('test-status');
@ -357,21 +412,23 @@
return;
}
btn.disabled = false; btn.textContent = '⏹ 取消';
status.textContent = '⏳ 测试中 (10s 超时)...';
const timeoutSec = Math.round(getTestTimeout() / 1000);
status.textContent = `⏳ 测试中 (${timeoutSec}s 超时)...`;
status.style.color = '';
// 10 秒超时自动取消
// 使用用户配置的超时时间
const testTimeoutMs = getTestTimeout();
testAbortTimer = setTimeout(() => {
testAbortTimer = null;
vscode.postMessage({ command: 'testAbort' });
btn.disabled = false; btn.textContent = '🧪 测试连接';
status.textContent = '⏰ 连接超时 (>10s)';
status.textContent = `⏰ 连接超时 (>${timeoutSec}s)`;
status.style.color = '#dc3545';
}, 10000);
}, testTimeoutMs);
vscode.postMessage({
command: 'testConnection',
config: { endpoint: getVal('llm-endpoint'), apiKey: getVal('llm-apiKey') }
config: { endpoint: getVal('llm-endpoint'), apiKey: getVal('llm-apiKey'), timeout: testTimeoutMs }
});
}
@ -388,6 +445,7 @@
}
});
function openKeybindings() { vscode.postMessage({ command: 'openKeybindings' }); }
function cancel() { vscode.postMessage({ command: 'cancel' }); }
function getVal(id) { return document.getElementById(id).value; }
function setVal(id, val) { document.getElementById(id).value = val; }

View File

@ -267,9 +267,19 @@
function createWordCard(word) {
const date = new Date(word.createdAt).toLocaleDateString('zh-CN');
const meanings = word.meanings.slice(0, 2).map((m) =>
`<span>${m.partOfSpeech ? m.partOfSpeech + ' ' : ''}${m.definition}</span>`
).join('');
// 前端去重:按 definition 文本去重,然后用 " | " 分隔
const seenDefs = new Set();
const meanings = word.meanings
.filter((m) => {
const key = (m.partOfSpeech || '') + m.definition;
if (seenDefs.has(key)) return false;
seenDefs.add(key);
return true;
})
.slice(0, 3)
.map((m) =>
`<span>${m.partOfSpeech ? '<em>' + m.partOfSpeech + '</em> ' : ''}${escapeHtml(m.definition)}</span>`
).join(' <span style="color:var(--vscode-panel-border);">|</span> ');
const phonetic = word.phonetic ? `<span class="word-phonetic">${word.phonetic}</span>` : '';
const sourceEmoji = { 'claude-code': '🧠', 'opencode': '🔧', 'codebuddy': '🦀', 'cursor': '🖱️', 'codex': '🤖', 'gpt': '💬', 'terminal': '💻', 'clipboard': '📋', 'manual': '✍️' };
const contexts = word.contexts.slice(0, 3).map((c) => {

View File

@ -27,7 +27,7 @@ const DEFAULT_LLM_CONFIG: LLMConfig = {
provider: 'openai',
endpoint: 'https://api.openai.com/v1',
model: 'gpt-4o-mini',
maxTokens: 500,
maxTokens: 2048, // Qwen3 等思考模型需要足够 token 才能输出 content
temperature: 0.3, // 翻译任务需要低温度(确定性高)
timeout: 5000, // 5 秒超时
};

View File

@ -25,7 +25,7 @@ export { JsonFileStorage, MemoryStorage } from './storage/index';
// ===== LLM =====
export { LLMClient } from './llm/client';
export { BASIC_TRANSLATE_PROMPT, CONTEXT_TRANSLATE_PROMPT, REVIEW_PROMPT, SENTENCE_TRANSLATE_PROMPT } from './llm/prompts';
export { BASIC_TRANSLATE_PROMPT, CONTEXT_TRANSLATE_PROMPT, DIRECT_SENTENCE_TRANSLATE_PROMPT, REVIEW_PROMPT, SENTENCE_TRANSLATE_PROMPT } from './llm/prompts';
// ===== 翻译引擎 =====
export { LLMAgentEngine } from './translate/llm-agent-engine';

View File

@ -17,32 +17,49 @@ export class LLMClient {
async chat(systemPrompt: string, userMessage: string): Promise<string> {
// ===== 校验 =====
if (this.config.provider !== 'custom' && !this.config.apiKey) {
// Ollama 和 vLLM 本地部署不需要 API Keycustom 也可能无需 key
const needsApiKey = this.config.provider !== 'custom' && this.config.provider !== 'ollama' && this.config.provider !== 'vllm';
if (needsApiKey && !this.config.apiKey) {
throw new AppError(ErrorCode.LLM_API_KEY_MISSING, 'LLM API Key not configured', true,
'请在设置中配置 API Key');
}
// ===== 构建请求 =====
const isAnthropic = this.config.provider === 'anthropic';
const provider = this.config.provider;
const isAnthropic = provider === 'anthropic';
const isOllama = provider === 'ollama';
const isVLLM = provider === 'vllm';
const baseUrl = this.config.endpoint ||
(isAnthropic ? 'https://api.anthropic.com' : 'https://api.openai.com/v1');
const url = isAnthropic ? `${baseUrl}/messages` : `${baseUrl}/chat/completions`;
console.log('[LLM Client] 请求:', url, 'model:', this.config.model, 'hasKey:', !!this.config.apiKey);
// 按后端类型精准关闭 Qwen 思考模式:
// - Ollama: 用 /no_think 系统指令Ollama 不支持 chat_template_kwargs
// - vLLM: 下面用 chat_template_kwargs 参数关闭
const thinkingDisabled = isOllama ? '/no_think' : isVLLM ? 'chat_template_kwargs' : 'NONE';
console.log(`[LLM] provider=${provider} model=${this.config.model} endpoint=${baseUrl} thinking_mode=${thinkingDisabled}`);
const effectiveSystemPrompt = isOllama
? `/no_think\n${systemPrompt}`
: systemPrompt;
const body: Record<string, unknown> = {
model: this.config.model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'system', content: effectiveSystemPrompt },
{ role: 'user', content: userMessage },
],
temperature: this.config.temperature,
max_tokens: this.config.maxTokens,
// Qwen thinking mode 关闭vLLM 支持此参数)
...(this.config.provider === 'custom' ? { chat_template_kwargs: { enable_thinking: false } } : {}),
};
// response_format 仅 OpenAI 支持
if (!isAnthropic && this.config.provider === 'openai') {
// vLLM: 通过 chat_template_kwargs 关闭 Qwen 思考模式
if (isVLLM) {
body.chat_template_kwargs = { enable_thinking: false };
}
// OpenAI: 请求 JSON 格式输出
if (provider === 'openai') {
body.response_format = { type: 'json_object' };
}
@ -51,58 +68,79 @@ export class LLMClient {
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
try {
// ===== 发送请求 =====
// VS Code Extension Host 中 DOM fetch 可能拒绝 http:// 连接
// 先尝试 fetch失败则回退到 Node.js http 模块
// http:// 在 VS Code Extension Host 的 fetch 中会直接失败
// 因此对 http:// 网址跳过 fetch 尝试,直接走 Node.js 回退
let response: Response;
try {
response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(isAnthropic
? { 'x-api-key': this.config.apiKey!, 'anthropic-version': '2023-06-01' }
: this.config.apiKey
? { Authorization: `Bearer ${this.config.apiKey}` }
: {}),
},
body: JSON.stringify(body),
signal: controller.signal,
});
} catch (fetchError) {
// fetch 失败 → 尝试用 Node.js http/https 回退
response = await nodeFetch(url, body as Record<string, unknown>, this.config.apiKey);
const isHttp = url.startsWith('http://');
if (!isHttp) {
try {
response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(isAnthropic
? { 'x-api-key': this.config.apiKey!, 'anthropic-version': '2023-06-01' }
: this.config.apiKey
? { Authorization: `Bearer ${this.config.apiKey}` }
: {}),
},
body: JSON.stringify(body),
signal: controller.signal,
});
} catch (_fetchError) {
// fetch 失败https 也可能因 TLS 等原因失败)→ Node.js 回退
response = await nodeFetch(url, body, this.config.apiKey, this.config.timeout, controller.signal);
}
} else {
response = await nodeFetch(url, body, this.config.apiKey, this.config.timeout, controller.signal);
}
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new AppError(ErrorCode.LLM_API_UNAUTHORIZED, `LLM API ${response.status}`, false, 'API Key 无效');
throw new AppError(ErrorCode.LLM_API_UNAUTHORIZED, `LLM API ${response.status}`, false, 'API Key 无效或未配置');
}
if (response.status === 429) {
throw new AppError(ErrorCode.LLM_API_RATE_LIMITED, 'Rate limited', true, '请求过于频繁');
}
throw new AppError(ErrorCode.UNKNOWN, `LLM API error ${response.status}`, true);
// 读取错误响应体以便调试
let errBody = '';
try { errBody = await response.text(); } catch { /* ignore */ }
throw new AppError(ErrorCode.UNKNOWN, `LLM API error ${response.status}: ${errBody.substring(0, 200)}`, true);
}
const data = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> };
let content = data.choices?.[0]?.message?.content;
const data = (await response.json()) as {
choices?: Array<{ message?: { content?: string; reasoning?: string }; finish_reason?: string }>;
};
const msg = data.choices?.[0]?.message;
let content = msg?.content;
// Ollama Qwen3 等思考模型content 可能为空,真正内容在 reasoning 字段
// 或者 content 包含思考过程 + JSON需要从末尾提取 JSON
if (!content && msg?.reasoning) {
// 从 reasoning思考过程中尝试提取 JSON
content = extractJSON(msg.reasoning);
}
if (!content) {
throw new AppError(ErrorCode.LLM_RESPONSE_INVALID, 'Empty response', true);
const reason = data.choices?.[0]?.finish_reason === 'length'
? '模型输出被截断token 不足),请调大 maxTokens 或关闭思考模式'
: '模型返回空内容,可能端点或模型配置有误';
throw new AppError(ErrorCode.LLM_RESPONSE_INVALID, reason, true);
}
// ===== 后处理:提取 JSON处理 Qwen thinking mode =====
// Qwen3.6 等模型可能输出 "思考过程...\n{ actual json }"
// 这里提取第一个完整的 JSON 对象
content = extractJSON(content);
return content;
} catch (error: unknown) {
if (error instanceof AppError) throw error;
if (error instanceof DOMException && error.name === 'AbortError') {
throw new AppError(ErrorCode.LLM_API_TIMEOUT, `Timeout after ${this.config.timeout}ms`, true, 'AI 响应超时,已切换到备用引擎');
throw new AppError(ErrorCode.LLM_API_TIMEOUT,
`请求超时(${this.config.timeout / 1000}s请检查端点是否可达或调大超时时间`, true,
'AI 响应超时,已切换到备用引擎');
}
throw new AppError(ErrorCode.UNKNOWN, `LLM call failed: ${(error as Error).message}`, true);
throw new AppError(ErrorCode.UNKNOWN,
`LLM 调用失败: ${(error as Error).message}`, true);
} finally {
clearTimeout(timeoutId);
}
@ -140,11 +178,15 @@ export class LLMClient {
/**
* Node.js HTTP 退 VS Code Extension Host DOM fetch http:// 连接。
* 使 Node.js http/https Response
*
* timeout AbortController.signal
*/
async function nodeFetch(
url: string,
body: Record<string, unknown>,
apiKey?: string,
timeoutMs?: number,
signal?: AbortSignal,
): Promise<Response> {
const parsedUrl = new URL(url);
// 动态 import 避免非 Node.js 环境报错
@ -169,11 +211,39 @@ async function nodeFetch(
statusText: res.statusMessage || '',
json: async () => JSON.parse(data),
text: async () => data,
} as Response);
} as unknown as Response);
});
},
);
req.on('error', reject);
// 支持 timeout超时后销毁请求
if (timeoutMs && timeoutMs > 0) {
req.setTimeout(timeoutMs, () => {
req.destroy(new DOMException('The operation was aborted', 'AbortError'));
});
}
// 支持 AbortController.signalsignal 触发时销毁请求
if (signal) {
const onAbort = () => {
req.destroy(new DOMException('The operation was aborted', 'AbortError'));
};
if (signal.aborted) {
onAbort();
} else {
signal.addEventListener('abort', onAbort, { once: true });
}
}
req.on('error', (err) => {
// 如果是 abort 导致的 error转换为 AbortError 以便上层正确识别
if ((err as NodeJS.ErrnoException).code === 'ECONNRESET' && signal?.aborted) {
reject(new DOMException('The operation was aborted', 'AbortError'));
} else {
reject(err);
}
});
req.write(JSON.stringify(body));
req.end();
});

View File

@ -57,26 +57,27 @@ Return format (JSON):
*/
export const CONTEXT_TRANSLATE_PROMPT = `You are a tech-savvy English tutor helping a Chinese programmer understand English words in their actual coding context.
Your task: Given a word AND the surrounding context (code, log output, or AI assistant response),
provide BOTH standard dictionary meanings AND the specific contextual interpretation.
Your task: Given a SPECIFIC target word AND the surrounding context (code, log output, or AI assistant response),
provide BOTH standard dictionary meanings AND the contextual interpretation of THE TARGET WORD ONLY.
CRITICAL RULES:
1. ALWAYS prioritize the meaning that makes sense in the given context
2. If the word has a technical meaning in code/AI context, explain that FIRST
3. You MUST include the "meanings" array with standard dictionary entries
4. Each meaning object MUST include "phonetic" in IPA format
5. Always return valid JSON only (no markdown, no explanation outside JSON)
1. Translate ONLY the target word specified below do NOT translate other words in the context
2. ALWAYS prioritize the meaning that makes sense in the given context
3. If the word has a technical meaning in code/AI context, explain that FIRST
4. You MUST include the "meanings" array with standard dictionary entries
5. Each meaning object MUST include "phonetic" in IPA format
6. Always return valid JSON only (no markdown, no explanation outside JSON)
WORD: {word}
TARGET WORD: {word}
CONTEXT:
CONTEXT (for reference only locate where the target word appears):
\`\`\`
{context}
\`\`\`
Return format (JSON MUST include both "meanings" array and contextual fields):
Return format (JSON):
{
"word": "the word",
"word": "{word}",
"phonetic": "/IPA/",
"meanings": [
{
@ -89,10 +90,20 @@ Return format (JSON — MUST include both "meanings" array and contextual fields
"exampleTranslations": ["Chinese translation of the example"]
}
],
"contextTranslation": "the full context translated into Chinese",
"wordMeaningInContext": "what this word means specifically in THIS context (Chinese)"
"contextTranslation": "the ENTIRE context text translated to natural Chinese (MANDATORY, NEVER leave empty)",
"wordMeaningInContext": "what \"{word}\" means specifically in THIS context (Chinese ONLY)"
}`;
/**
* Prompt
* JSON
* CONTEXT_TRANSLATE_PROMPT contextTranslation
*/
export const DIRECT_SENTENCE_TRANSLATE_PROMPT = `You are a translator. Translate the following English text to natural Chinese.
Return ONLY the Chinese translation. No explanations, no JSON, no extra text.
English text: {text}`;
/**
* / System Prompt
*

View File

@ -66,6 +66,7 @@ export class TranslateDispatcher {
}
// ===== 步骤 2依次尝试各引擎 =====
const errors: string[] = [];
for (const engine of this.engines) {
try {
// 检查引擎是否可用(如 LLM 是否配置了 API Key
@ -82,14 +83,17 @@ export class TranslateDispatcher {
this.cache.set(cacheKey, result);
return result;
}
} catch {
// 单个引擎异常不中断,继续尝试下一个
} catch (err) {
// 收集错误信息用于降级时的诊断提示
const msg = (err as Error).message || String(err);
errors.push(msg);
console.log(`[Dispatcher] 引擎失败: ${msg}`);
continue;
}
}
// ===== 步骤 3全部引擎失败 → 返回降级提示 =====
return this.createFallbackResult(word);
// ===== 步骤 3全部引擎失败 → 返回降级提示(不缓存,下次重试) =====
return this.createFallbackResult(word, errors);
}
// ============================================================
@ -126,15 +130,24 @@ export class TranslateDispatcher {
/**
*
*
*
*
*/
private createFallbackResult(word: string): TranslateResult {
private createFallbackResult(word: string, errors: string[] = []): TranslateResult {
// 生成诊断提示:取前 2 条错误信息,截取关键部分
let hint = `未找到 "${word}" 的释义。`;
const uniqueErr = [...new Set(errors)].slice(0, 2); // 去重取前2条
if (uniqueErr.length > 0) {
hint += ` 原因: ${uniqueErr.join(' | ').substring(0, 120)}`;
} else {
hint += ' 请检查 LLM 端点是否可达,或配置备用翻译引擎。';
}
return {
word,
meanings: [
{
partOfSpeech: '',
definition: `未找到 "${word}" 的释义,请检查网络连接或稍后重试`,
definition: hint,
isTechnical: false,
},
],

View File

@ -43,12 +43,20 @@ export class LLMAgentEngine implements ITranslateEngine {
*/
async translate(word: string, context?: string): Promise<TranslateResult | null> {
try {
// ===== 根据是否有上下文选择 Prompt =====
const systemPrompt = context ? CONTEXT_TRANSLATE_PROMPT : BASIC_TRANSLATE_PROMPT;
// ===== 根据是否有上下文选择并填充 Prompt =====
let systemPrompt: string;
let userMessage: string;
const userMessage = context
? context.replace('{word}', word).replace('{context}', context)
: `Translate this word into Chinese for a programmer: "${word}"`;
if (context) {
// 上下文翻译:将 {word} {context} 替换到 systemPrompt 中,而非 userMessage
systemPrompt = CONTEXT_TRANSLATE_PROMPT
.replaceAll('{word}', word)
.replace('{context}', context);
userMessage = `Translate the word "${word}" in the context above.`;
} else {
systemPrompt = BASIC_TRANSLATE_PROMPT;
userMessage = `Translate this word into Chinese for a programmer: "${word}"`;
}
// ===== 调用 LLM =====
console.log('[LLM] 开始调用 Qwen...');

View File

@ -157,6 +157,8 @@ export interface Meaning {
isTechnical: boolean;
/** 所属技术领域(如 "cs" "ai" "frontend" */
domain?: string;
/** 音标IPA 国际音标,如 /ˈːrd/ */
phonetic?: string;
/** 英文例句列表 */
examples?: string[];
/** 例句的中文翻译(与 examples 一一对应) */
@ -277,8 +279,8 @@ export interface WordEntry {
* Java DataSource
*/
export interface LLMConfig {
/** 提供商openai / anthropic / custom */
provider: 'openai' | 'anthropic' | 'custom';
/** 提供商openai / anthropic / ollama / vllm / custom */
provider: 'openai' | 'anthropic' | 'ollama' | 'vllm' | 'custom';
/** API 端点 */
endpoint?: string;
/** API Key从 SecretStorage 读取,不在此对象中明文存储) */

View File

@ -46,14 +46,37 @@ export class WordbookService {
constructor(private storage: IStorage) {}
/**
*
* addWord
*
* "加入单词本"3 3 addWord
* getAllWords() 3
*/
private writeLock: Promise<void> = Promise.resolve();
/**
* 线
*
* - context
* -
*
* Java: repository.save() + merge
* Java: repository.save() + merge + synchronized
*/
async addWord(params: AddWordParams): Promise<WordEntry> {
// 串行化写入:等上一个 addWord 完成后再执行
return new Promise<WordEntry>((resolve, reject) => {
this.writeLock = this.writeLock.then(async () => {
try {
const result = await this._addWord(params);
resolve(result);
} catch (e) {
reject(e);
}
});
});
}
/** addWord 的实际实现(内部方法,由 addWord 加锁后调用) */
private async _addWord(params: AddWordParams): Promise<WordEntry> {
const allWords = await this.getAllWords();
// 检查是否已存在(大小写不敏感)
@ -72,14 +95,28 @@ export class WordbookService {
};
if (existingIndex >= 0) {
// 单词已存在 → 追加新 context
// 单词已存在 → 追加新 context(去重)
const existing = allWords[existingIndex];
existing.contexts.push(newContext);
existing.tags = [...new Set([...existing.tags, ...(params.tags || [])])];
// 添加新释义(如果有的话)
if (params.meanings) {
existing.meanings = [...existing.meanings, ...params.meanings];
// context 去重:检查是否已有相同的句子
const isDuplicateCtx = existing.contexts.some(
(c) => c.sentence === params.context,
);
if (!isDuplicateCtx) {
existing.contexts.push(newContext);
}
existing.tags = [...new Set([...existing.tags, ...(params.tags || [])])];
// meanings 去重:检查是否已有相同的释义文本
if (params.meanings && params.meanings.length > 0) {
const existingDefs = new Set(existing.meanings.map((m) => m.definition));
const newMeanings = params.meanings.filter((m) => !existingDefs.has(m.definition));
if (newMeanings.length > 0) {
existing.meanings = [...existing.meanings, ...newMeanings];
}
}
existing.updatedAt = new Date().toISOString();
allWords[existingIndex] = existing;
await this.saveAllWords(allWords);
@ -139,6 +176,22 @@ export class WordbookService {
return true;
}
/**
*
* @param wordId - ID
* @param contextIndex - contexts
* @param translation -
*/
async updateContextTranslation(wordId: string, contextIndex: number, translation: string): Promise<boolean> {
const words = await this.getAllWords();
const word = words.find((w) => w.id === wordId);
if (!word || !word.contexts[contextIndex]) return false;
word.contexts[contextIndex].translation = translation;
word.updatedAt = new Date().toISOString();
await this.saveAllWords(words);
return true;
}
/**
* /
*