Compare commits
24 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
4a187e3a81 | |
|
|
4b35b7f980 | |
|
|
d9c01f6faf | |
|
|
48a837cb24 | |
|
|
76f873fab8 | |
|
|
2899b2a47d | |
|
|
0821869f42 | |
|
|
e22e304151 | |
|
|
7a1c413331 | |
|
|
94005c7884 | |
|
|
f0e3fbaf6e | |
|
|
5a292998ec | |
|
|
085870b4e6 | |
|
|
1af12c5fb7 | |
|
|
8e48578f1c | |
|
|
624977ef43 | |
|
|
8bf0572348 | |
|
|
79a828928c | |
|
|
7b0d5e6aff | |
|
|
72672a359d | |
|
|
af9bb4eeb4 | |
|
|
8ef8ec403e | |
|
|
367d6d1318 | |
|
|
9ed6bc28a4 |
55
CHANGELOG.md
55
CHANGELOG.md
|
|
@ -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 快捷键设置并搜索本插件命令
|
||||
- 响应超时可配置(5–300s)
|
||||
- Max Tokens 可配置(256–16384,默认 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
|
||||
|
||||
|
|
|
|||
19
MEMORY.md
19
MEMORY.md
|
|
@ -1,7 +1,7 @@
|
|||
# Project Memory
|
||||
|
||||
> 项目记忆 · 随项目成长持续更新 · AI 和开发者共享的上下文
|
||||
> 最后更新:2026-05-26
|
||||
> 最后更新:2026-06-26
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
| Phase 4:打磨发布 | ✅ 已完成 | 离线词库补全 + 迭代记录补全 + 自动记录机制 + VSIX 打包配置 |
|
||||
| Phase 5:F5 测试 | ✅ 已完成 | 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_think,vLLM 不认识 /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_PROMPT(3 行,纯文本),不可能被模型忽略 |
|
||||
| 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 当前阻塞
|
||||
|
||||
|
|
|
|||
|
|
@ -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 Client:response_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 | ⬜ 待处理 |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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 — 多语言 + 社区 🌍
|
||||
|
||||
| 功能 | 优先级 |
|
||||
|
|
|
|||
|
|
@ -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 快捷键设置并搜索本插件命令
|
||||
- 响应超时可配置(5–300s)
|
||||
- Max Tokens 可配置(256–16384,默认 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 翻译的超时时间,超时后自动切换到备用翻译引擎。范围:5–300 秒。"
|
||||
},
|
||||
"vibecoding-english.llmMaxTokens": {
|
||||
"type": "number",
|
||||
"default": 2048,
|
||||
"order": 6,
|
||||
"description": "LLM 最大输出 token 数",
|
||||
"markdownDescription": "本地思考模型(如 Qwen3 / DeepSeek-R1)建议 ≥2048,避免思考过程截断导致内容为空。范围:256–16384。"
|
||||
},
|
||||
"vibecoding-english.baiduAppId": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">单位:秒(5–300s),超时后切换备用翻译引擎</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 Key(Ollama/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; }
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 秒超时
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 Key;custom 也可能无需 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.signal:signal 触发时销毁请求
|
||||
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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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...');
|
||||
|
|
|
|||
|
|
@ -157,6 +157,8 @@ export interface Meaning {
|
|||
isTechnical: boolean;
|
||||
/** 所属技术领域(如 "cs" "ai" "frontend") */
|
||||
domain?: string;
|
||||
/** 音标(IPA 国际音标,如 /ˈwɜː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 读取,不在此对象中明文存储) */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索单词(支持中/英文)。
|
||||
*
|
||||
|
|
|
|||
Loading…
Reference in New Issue