## 從免管理員 Node.js 環境建置到 `vscode.lm` 模型偵測與 checkpoint 輸出
這份教學記錄如何從零開始
執行這個 VS Code extension demo,
包含公司電腦常見的安裝權限限制、
免管理員 Node.js 安裝方式、
三語同義詞批次擴展,
以及如何列出目前可用的 LLM model。
## 目標
這個專案示範三件事:
1. 使用 VS Code extension 呼叫 `vscode.lm`。
2. 讀取 `keywords.json`,
批次產生英文、簡中、西語墨西哥同義詞。
3. 偵測目前 VS Code Copilot LM API 實際可用的模型。
目前命令如下:
“`text
Demo: Ask LM and Save Markdown
Demo: Expand Synonyms Batch
Demo: List Available LM Models
“`
定義於 package.json中:
{
"name": "vscode-lm-demo-js",
"displayName": "VS Code LM Demo JS",
"description": "Minimal JavaScript demo for asking vscode.lm and saving the answer as Markdown.",
"version": "0.0.1",
"engines": {
"vscode": "^1.90.0"
},
"categories": [
"Other"
],
"main": "./extension.js",
"contributes": {
"commands": [
{
"command": "demo.askLmAndSaveMd",
"title": "Demo: Ask LM and Save Markdown"
},
{
"command": "demo.expandSynonymsBatch",
"title": "Demo: Expand Synonyms Batch"
},
{
"command": "demo.listAvailableLmModels",
"title": "Demo: List Available LM Models"
}
]
},
"devDependencies": {
"@types/vscode": "^1.90.0"
}
}最基本必備
這些是讓 VS Code 知道
「這是一個 extension、要載入哪裡、
支援哪個 VS Code 版本」:
{
"name": "vscode-lm-demo-js",
"version": "0.0.1",
"engines": {
"vscode": "^1.90.0"
},
"main": "./extension.js"
}意思是:
name extension / npm package 的名稱
version extension 版本
engines.vscode 宣告最低支援的 VS Code 版本
main extension 入口檔,這裡是 extension.js這個專案也必備
因為功能是從 Ctrl + Shift + P 命令面板選三個 Demo,
所以一定要有 contributes.commands:
"contributes": {
"commands": [
{
"command": "demo.askLmAndSaveMd",
"title": "Demo: Ask LM and Save Markdown"
},
{
"command": "demo.expandSynonymsBatch",
"title": "Demo: Expand Synonyms Batch"
},
{
"command": "demo.listAvailableLmModels",
"title": "Demo: List Available LM Models"
}
]
}意思是:
command 程式內部用的命令 ID,要跟 extension.js 的 registerCommand 一樣
title 使用者在命令面板看到的名稱可以直接記:
package.json 的 contributes.commands
= 讓命令出現在 Ctrl + Shift + P
extension.js 的 vscode.commands.registerCommand(...)
= 使用者點命令後,真正執行對應 function專案最小可用版大概是:
{
"name": "vscode-lm-demo-js",
"version": "0.0.1",
"engines": {
"vscode": "^1.90.0"
},
"main": "./extension.js",
"contributes": {
"commands": [
{
"command": "demo.askLmAndSaveMd",
"title": "Demo: Ask LM and Save Markdown"
},
{
"command": "demo.expandSynonymsBatch",
"title": "Demo: Expand Synonyms Batch"
},
{
"command": "demo.listAvailableLmModels",
"title": "Demo: List Available LM Models"
}
]
}
}不是執行絕對必備,但建議保留
這些是顯示、分類、開發體驗用:
{
"displayName": "VS Code LM Demo JS",
"description": "Minimal JavaScript demo for asking vscode.lm and saving the answer as Markdown.",
"categories": ["Other"],
"devDependencies": {
"@types/vscode": "^1.90.0"
}
}意思是:
displayName VS Code UI / Marketplace 顯示名稱
description extension 描述
categories 分類
devDependencies 開發時依賴,@types/vscode 提供 VS Code API 型別提示name / displayName差別:
name 套件 ID / extension ID,通常要求小寫、用連字號,像 vscode-lm-demo-js
displayName 友善顯示名稱,可以有空格和大小寫,像 VS Code LM Demo JS## 專案位置
D:\user\Python\vscode-lm-demo-js
主要檔案:
extension.js VS Code extension 主程式;真正的大腦,負責 activate、registerCommand、呼叫 vscode.lm、讀寫檔案、執行三個 Demo
package.json VS Code extension 設定檔;負責宣告 extension 名稱、版本、入口 main、VS Code 版本需求,以及 contributes.commands 命令清單
keywords.json expand synonyms 的輸入 keyword
lm_config.json 預設 LLM model 設定;batch 會優先讀這裡的 defaultModelId,沒有才用 extension.js 裡的 DEFAULT_MODEL_ID
export\ batch/state/final/model list 輸出資料夾;保存 checkpoint、final result、可用模型清單## 1. 安裝 Node.js
VS Code extension 開發需要 Node.js 和 npm。
Node.js 可以理解成
「讓 JavaScript 在瀏覽器外執行的環境」。
平常 JavaScript 常見於瀏覽器裡,例如網頁互動;
但 VS Code extension 不是跑在一般網頁裡,
而是跑在 VS Code 的 extension host 裡,
因此需要 Node.js 這個 JavaScript 執行環境
來開發、安裝套件、執行工具。
用 Python 類比的話,可以先這樣理解:
Python 語言 你寫 .py 時使用的程式語言
python.exe 負責執行 .py 程式的執行器 / interpreter
pip 負責安裝 Python 套件
requirements.txt 常用來記錄 Python 專案依賴
JavaScript 語言 你寫 .js 時使用的程式語言
node.exe / Node.js 負責執行 .js 程式的執行器 / runtime
npm 負責安裝 JavaScript / Node.js 套件
package.json 記錄 Node.js 專案資訊、命令、依賴與 VS Code extension 設定
VS Code 編輯器,不是 JavaScript 的執行器本身所以在 terminal 裡,兩邊概念大概像這樣:
python my_script.py
node my_script.js在這個專案裡,真正的 extension 主程式是 `extension.js`。
Node.js 提供開發時需要的 JavaScript 執行環境,
npm 則根據 `package.json` 安裝開發依賴。
npm 是 JavaScript / Node.js 專案的套件管理工具,
角色接近 Python 裡的 `pip`。
Python 常用 `pip install -r requirements.txt`
安裝 `requirements.txt` 裡列出的套件;
Node.js 專案則常用 `npm install`
安裝 `package.json` 裡列出的套件。
以本專案的 `package.json` 來看:
{
"main": "./extension.js",
"contributes": {
"commands": [
{
"command": "demo.expandSynonymsBatch",
"title": "Demo: Expand Synonyms Batch"
}
]
},
"devDependencies": {
"@types/vscode": "^1.90.0"
}
}其中:
main 告訴 VS Code extension 的入口檔是 extension.js
contributes 告訴 VS Code 這個 extension 要貢獻哪些功能,例如命令面板命令
devDependencies 開發時需要安裝的套件,這裡的 @types/vscode 用來提供 VS Code API 的型別提示`”@types/vscode”: “^1.90.0″`
可以拆成兩部分看:
@types/vscode 套件名稱
^1.90.0 版本範圍`@types/vscode` 前面的 `@` 不是 email 的小老鼠意思,
而是 npm 的「scope」命名規則。
白話來說,scope 可以先理解成
npm 套件名稱前面的
「群組名稱」或「分類資料夾」。
它的格式通常是:
“`text
@群組名稱/套件名稱
“`
所以:
“`text
@types/vscode
“`
可以理解成:
“`text
@types 這個群組底下的 vscode 套件
“`
這裡的 `@types` 不是這個專案自己隨便取的名字,
而是 npm 生態裡常見的型別定義套件群組。
`@types/*` 這類套件通常是
TypeScript/JavaScript 開發時用的型別定義檔,
讓 VS Code 知道
`vscode.window`、`vscode.commands`、`vscode.lm`
這些 API 大概有哪些方法和屬性。
例如:
@types/node
@types/react
@types/express
@types/vscode這些通常都代表「替某個 JavaScript 套件或環境補型別提示」。
但是 `@xxx/yyy` 這種格式本身不限定只能叫 `@types`。
其他團隊或工具也可以有自己的群組名稱,例如:
@babel/core
@vitejs/plugin-react
@microsoft/signalr
@angular/core所以可以這樣記:
@types 是一個常見且有固定用途的群組名稱,用來放型別定義套件。
@xxx/yyy 這種寫法是 npm 的 scoped package 格式,xxx 可以是不同組織或工具自己的名稱。那 `1.90.0` 是哪來的?它不是隨便寫的,
而是跟 `package.json` 裡的這段對齊:
"engines": {
"vscode": "^1.90.0"
}`engines.vscode` 表示
這個 extension 預期支援的 VS Code 版本範圍。
這裡寫 `^1.90.0`,
意思是這個 extension 以 VS Code `1.90.0`
這個 API 版本作為最低相容基準。
所以 `devDependencies` 裡的 `@types/vscode` 也用 `^1.90.0`,
讓開發時看到的 VS Code API 型別提示,
跟 extension 宣告支援的 VS Code API 版本一致。
它跟你目前安裝的 VS Code 版本有關,
但不是一定要完全一樣。
你現在的 VS Code 版本是 `1.122.1`,
比 `1.90.0` 新很多,所以符合 `^1.90.0` 的要求,
可以執行這個 extension。
可以把它想成 Python 套件常見的最低版本要求:
需要 Python >= 3.9
你電腦是 Python 3.12
所以可以跑對應到這個 extension 就是:
需要 VS Code >= 1.90.0
你電腦是 VS Code 1.122.1
所以可以跑那為什麼不是直接寫 `1.122.1`?
因為 `1.90.0` 是這個 demo extension 建立時採用的相容基準,
意思是「不要要求使用者一定要裝到最新 VS Code,
只要版本不低於這個基準就好」。
如果未來真的需要使用 VS Code `1.122` 才有的新 API,
才需要把 `engines.vscode` 和
`@types/vscode` 一起提高到接近 `^1.122.0`。
`1.90.0` 和 `1.122.1` 的差別,
可以理解成 VS Code API 的新舊差距。
VS Code 每個版本可能會新增 extension 能使用的 API,
例如新的 editor 功能、
新的 Copilot/Language Model API 能力、
或新的資料格式。
如果某個功能是在 `1.90.0` 之後才加入的,
那用 `@types/vscode@1.90.0` 開發時可能看不到那個 API,
程式也不應該宣告自己支援 VS Code `1.90.0`。
例如以後如果想把圖片送給 LLM,
這就很可能需要比較新的 VS Code Language Model API
支援圖片或 binary data 的 message part。
如果圖片輸入 API 是在比較新的 VS Code 才支援,
那就應該做兩件事:
1. 確認目前安裝的 VS Code 版本真的支援該 API
2. 把 package.json 裡的 engines.vscode 和 @types/vscode 提高到支援該 API 的版本也就是說,不能只看你電腦現在是 `1.122.1`
就直接放心使用新 API;
extension 的 `package.json` 也要誠實宣告
「這個 extension 至少需要哪個 VS Code 版本」。
否則如果 package 寫 `^1.90.0`,
但程式使用只有 `1.122` 才有的 API,
那在舊版 VS Code 上就可能出現
找不到 API、命令失敗、或 extension 啟動錯誤。
`^1.90.0` 前面的 `^` 是 npm 的版本範圍語法,
意思是允許安裝相容的新版本。
以 `^1.90.0` 來說,npm 可以安裝
`1.90.0`、`1.91.0`、`1.92.3` 這類
仍在 `1.x.x` 範圍內的版本,
但不會自動升到 `2.0.0`,
因為主版本改成 2 可能代表不相容。
如果寫成:
"@types/vscode": "1.90.0"就表示固定只能用 `1.90.0`,彈性比較小。
所以執行:
npm.cmd install它會讀取 `package.json`,
把 `devDependencies` 裡的
`@types/vscode` 安裝到 `node_modules`。
這個動作類似 Python 專案裡
用 `pip install` 安裝開發或執行所需的套件。
一般情況可以直接安裝 Node.js LTS MSI,
但公司電腦可能沒有管理員權限,或 `winget` 不存在。
=npm.cmd Windows command shim,PowerShell 可以直接執行
npm.ps1 PowerShell script,可能被執行原則擋住
npm 在某些 shell 裡可用### 一般安裝方式
到 Node.js 官網下載 Windows Installer:
“`text
https://nodejs.org/en/download
“`
選:
“`text
Windows Installer (.msi)
“`
安裝完成後重新開啟 PowerShell / VS Code,檢查:
node -v
npm -v
# 若公司環境不允許 PowerShell 執行 `npm.ps1` (PowerShell script version 1),
# 則改用:
npm.cmd -v### 如果 winget 不存在
如果執行:
“`powershell
winget install OpenJS.NodeJS.LTS
“`
出現:
“`text
winget : 無法辨識 ‘winget’
“`
代表 Windows 沒有安裝 winget / App Installer。
這時不用卡在 winget,
直接用 Node.js 官網下載 MSI 或 ZIP。
### 如果不想用 MSI:直接下載 ZIP
其實最簡單的免管理員方式,
是直接下載 Windows ZIP 版 Node.js。
ZIP 版不需要正式安裝,
也比較不會碰到公司電腦的
管理員權限、registry 或 MSI 安裝政策限制。
到 Node.js 官網下載 Windows Binary ZIP,例如:
“`text
node-v24.16.0-win-x64.zip
“`
解壓縮到一個自己有權限的資料夾,例如:
“`text
D:\tools\nodejs
“`
或:
“`text
C:\Users\SavingKing\AppData\Local\Programs\nodejs
“`
重點是解壓縮後的那個資料夾裡面要看得到:
“`text
node.exe
npm.cmd
npx.cmd
“`
接著把這個資料夾加到使用者 PATH。
如果用 PowerShell,可以這樣:
$nodeDir = "D:\tools\nodejs"
$currentUserPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if (-not (($currentUserPath -split ';') -contains $nodeDir)) {
[Environment]::SetEnvironmentVariable('Path', (($currentUserPath.TrimEnd(';') + ';' + $nodeDir).TrimStart(';')), 'User')
}如果不想用指令,
也可以用 Windows 圖形介面:
“`text
Windows 搜尋「環境變數」
編輯使用者環境變數
Path
新增
貼上 node.exe 所在資料夾
確定
重開 PowerShell / VS Code
“`
重開 terminal 後檢查:
“`powershell
node -v
npm.cmd -v
“`
可以這樣記:
“`text
MSI 安裝法 像一般安裝軟體,可能需要權限或被公司政策擋住
MSI 抽取法 手上只有 MSI 時,把 MSI 內容拆出來用
ZIP 解壓縮法 最單純,下載、解壓縮、加 PATH
“`
## 2. 繞過安裝權限的 Node.js 安裝方式
這次實際採用的是「用 MSI 抽出檔案,不正式安裝」的方式。
這適合 MSI 正式安裝時
被權限、公司政策或檔案寫入限制擋住的情境。
### 2.1 先下載 MSI
從 Node.js 官網下載:
“`text
node-v24.16.0-x64.msi
“`
假設下載到:
C:\Users\SavingKing\Downloads\node-v24.16.0-x64.msi### 2.2 嘗試正式 per-user 安裝
這一步不一定成功,但可以先試:
$msi = "$env:USERPROFILE\Downloads\node-v24.16.0-x64.msi"
$log = Join-Path $env:TEMP 'node-msi-user-install.log'
Start-Process msiexec.exe -ArgumentList @('/i', $msi, 'ALLUSERS=2', 'MSIINSTALLPERUSER=1', '/passive', '/norestart', '/L*v', $log) -Wait如果最後 `node -v` / `npm -v` 還是找不到,或 log 裡看到類似:
Error 1310. Error writing to file: ...\nodejs\corepack
Verify that you have access to that directory.就改用 MSI 抽取法。
### 2.3 用 msiexec 抽出 MSI 內容
這個方式不走正式安裝,
不需要寫系統層 registry,也比較能避開權限問題。
$msi = "$env:USERPROFILE\Downloads\node-v24.16.0-x64.msi"
$target = "$env:LOCALAPPDATA\Programs\nodejs-msi-extract"
$log = Join-Path $env:TEMP 'node-msi-admin-extract.log'
if (Test-Path $target) {
Remove-Item $target -Recurse -Force
}
New-Item -ItemType Directory -Force -Path $target | Out-Null
Start-Process msiexec.exe -ArgumentList @('/a', $msi, '/qn', "TARGETDIR=$target", '/L*v', $log) -Wait抽出後,真正的 Node.js 目錄會在:
C:\Users\SavingKing\AppData\Local\Programs\nodejs-msi-extract\PFiles64\nodejs檢查:
$nodeDir = "$env:LOCALAPPDATA\Programs\nodejs-msi-extract\PFiles64\nodejs"
& "$nodeDir\node.exe" -v
& "$nodeDir\npm.cmd" -v這次成功的版本是:
“`text
node v24.16.0
npm 11.13.0
“`
### 2.4 加到使用者 PATH
$nodeDir = "$env:LOCALAPPDATA\Programs\nodejs-msi-extract\PFiles64\nodejs"
$currentUserPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if (-not (($currentUserPath -split ';') -contains $nodeDir)) {
[Environment]::SetEnvironmentVariable('Path', (($currentUserPath.TrimEnd(';') + ';' + $nodeDir).TrimStart(';')), 'User')
}這段比較像 Python 的:
os.environ["PATH"] += r";C:\Users\SavingKing\AppData\Local\Programs\nodejs-msi-extract\PFiles64\nodejs"但它不是只改目前 process,
而是寫到 Windows 的 User PATH,
重開 PowerShell / VS Code 後仍然有效。
它不是 `sys.path.append(…)`。
`sys.path` 是 Python `import` 模組時搜尋 `.py` 檔的路徑;
這裡要改的是 Windows shell 尋找
`node.exe` / `npm.cmd` 這類可執行檔的路徑。
然後關掉並重開 PowerShell / VS Code。
## 3. npm 被 PowerShell 執行原則擋住怎麼辦
如果執行:
npm install
出現:
因為這個系統上已停用指令碼執行,所以無法載入 npm.ps1
不要改系統政策,
直接用 Windows command shim:
npm.cmd install或用變數組出 npm.cmd 的完整路徑:
$nodeDir = "$env:LOCALAPPDATA\Programs\nodejs-msi-extract\PFiles64\nodejs"
& "$nodeDir\npm.cmd" install## 4. 安裝本專案依賴
進入專案:
cd D:\user\Python\vscode-lm-demo-js
npm.cmd install成功時會看到類似:
added 1 package
found 0 vulnerabilities## 5. 啟動 Extension Development Host
在 VS Code 開啟此資料夾:
“`text
D:\user\Python\vscode-lm-demo-js
“`
按:
“`text
F5
“`
如果 F5 沒反應,可以從上方選:
Start Debugging => Run Extension
Run Extension
成功後會開一個新 VS Code 視窗,標題通常包含:
Extension Development Host可以這樣理解:
第一個 VS Code 視窗
= 你開發 extension 的地方
= 編輯 extension.js、package.json、教學 md
第二個 VS Code 視窗
= Extension Development Host
= 用來實際測試你寫的 extension
後面要執行
`Demo: Ask LM and Save Markdown`、
`Demo: Expand Synonyms Batch`、
`Demo: List Available LM Models`,
都要在 `Extension Development Host`
這個新視窗裡按 `Ctrl + Shift + P` 來執行。
## 6. 注意 Ctrl+P 和 Ctrl+Shift+P 的差別
如果你看到的是:
“`text
Search files by name
“`
那是 `Ctrl+P`,只能找檔案,
不會列出 extension command。
要執行命令,請按:
“`text
Ctrl + Shift + P
“`
或在搜尋框最前面輸入:
>
換句話說,下面兩種方式效果相同:
“`text
Ctrl + Shift + P
“`
等於:
“`text
Ctrl + P
再在最前面輸入 >
“`
所以要找本專案的 extension command,可以用:
“`text
Ctrl + Shift + P -> Demo
“`
也可以用:
“`text
Ctrl + P 然後輸入: >Demo
“`
## 7. 單題 demo
命令:
“`text
Demo: Ask LM and Save Markdown
“`
它會問一個固定問題:
“`text
請用繁體中文簡單說明 Python list comprehension 是什麼。
“`
輸出:
“`text
llm_answer.md
“`
如果看到 `llm_answer.md`,
代表你跑的是單題 demo,不是三語同義詞批次。
## 8. 三語同義詞批次 demo
命令:
“`text
Demo: Expand Synonyms Batch
“`
輸入檔:
“`text
keywords.json
“`
目前範例內容:
[
"bmc",
"fw",
"uut",
"tpm",
"list comprehension"
]也可以改成 `token -> record` 的物件格式,
接近 `expand_synonyms_04.py` 的資料形狀:
{
"bmc": {
"unique_classes": ["BmcFirmwareUpdate"],
"appeared_in_meta": ["BMC firmware update test"],
"appeared_in_highlight": [],
"token_sources": {
"is_meta_token": true
},
"families": []
}
}輸出會放在:
export\
每處理完一筆 keyword,
就會更新 state 檔,所以中途停止後可以續跑。
輸出格式範例:
{
"bmc": {
"token": "bmc",
"synonyms_by_vscode_lm": {
"en": ["baseboard management controller"],
"zh_cn": ["基板管理控制器"],
"es_mx": ["controlador de administracion de placa base"],
"confidence": "high"
},
"workflow": {
"llm_state": "done",
"route": "llm",
"attempt_count": 1,
"updated_at": "2026-06-10T01:54:39.000Z"
}
}
}## 9. 設定預設模型
設定檔:
lm_config.json
目前內容:
{
"defaultModelId": "gpt-5.5"
}執行 batch 時,程式會先自動找這個模型:
“`text
GPT-5.5 | id=gpt-5.5
“`
找到就直接使用,不跳選單。找不到才會跳模型選單。
要改 Claude,例如如果偵測到 id 是 `claude-opus-4.8`,就改成:
“`json
{
“defaultModelId”: “claude-opus-4.8”
}
“`
## 10. 偵測可用 LLM model
命令:
“`text
Demo: List Available LM Models
“`
它會呼叫:
vscode.lm.selectChatModels({ vendor: "copilot" })然後把目前 extension API 實際可用的模型列出來。
你可以用這個檔案確認到底有哪些模型能給 extension 使用。
## 11. 目前曾看到的模型
之前 Output / UI 曾看到這些模型,
實際可用清單仍以 `Demo: List Available LM Models` 輸出的 JSON 為準。
Auto
GPT-5.5
GPT-5.4
GPT-5.4 mini
GPT-5 mini
GPT-5.3-Codex
GPT-4o mini
Claude Opus 4.8
Claude Sonnet 4.6
Claude Sonnet 4.5
Claude Opus 4.5
Claude Haiku 4.5
Gemini 3.5 Flash
Gemini 3 Flash Preview
Gemini 2.5 Pro有些模型可能只在 Copilot Chat UI 裡出現,
不一定會暴露給 `vscode.lm` extension API。
請以 `export\lm_models_available.json` 為準。
## 13. 常見問題
### 執行後只產生 llm_answer.md
代表你選到:
“`text
Demo: Ask LM and Save Markdown
“`
三語同義詞要選:
“`text
Demo: Expand Synonyms Batch
“`
### 看不到新命令
如果剛改過 `package.json` 或 `extension.js`,
需要關掉 Extension Development Host,
回原本開發視窗重新 F5。
### Output 沒有動靜
打開:
“`text
View -> Output
“`
右側下拉選:
“`text
VS Code LM Demo JS
“`
### 想改輸出資料夾
目前寫在 `extension.js`:
“`js
const EXPORT_DIR = “export”;
“`
所以 state/final/model list 都會輸出到:
“`text
D:\user\Python\vscode-lm-demo-js\export
“`
### 想改 retry 次數
目前寫在 `extension.js`:
“`js
const MAX_RETRIES = 2;
“`
## 14. 建議操作順序
第一次使用建議照這樣跑:
1. 確認 node -v / npm.cmd -v
2. npm.cmd install
3. F5 / Start Debugging
4. Demo: List Available LM Models
5. 檢查 export\lm_models_available.json
6. 修改 lm_config.json 選模型
7. 修改 keywords.json 放測試 keyword
8. Demo: Expand Synonyms Batch
9. 檢查 export\synonyms_state_*.json 與 export\synonyms_final_*.json推薦hahow線上學習python: https://igrape.net/30afN
extension.js
const vscode = require("vscode");
const crypto = require("crypto");
const OUTPUT_CHANNEL_NAME = "VS Code LM Demo JS";
const BATCH_INPUT_FILE = "keywords.json";
const LM_CONFIG_FILE = "lm_config.json";
const EXPORT_DIR = "export";
const DEFAULT_MODEL_ID = "gpt-5.5";
const MAX_RETRIES = 2;
const PROMPT_VERSION = 2;
const KNOWN_TOKEN_HINTS = {
bmc: "baseboard management controller",
fw: "firmware",
uut: "unit under test",
tpm: "trusted platform module",
};
const SYNONYM_PROMPT = `你現在要為技術 keyword 生成三語同義詞擴展,用於 BM25 檢索增強。
要求:
1. 只輸出合法 JSON,不要輸出 Markdown、說明文字或 code fence。
2. JSON schema 固定為:
{"en":["..."],"zh_cn":["..."],"es_mx":["..."],"confidence":"high|medium|low"}
3. en 必須全小寫。
4. zh_cn 必須使用簡體中文,並可保守涵蓋台灣常用詞的簡體寫法。
5. es_mx 必須全小寫,不使用重音符號。
6. 只做保守擴展。不能確認的縮寫不要硬展開。
7. 如果 payload 提供 derived_context_hint,代表人工已確認 token 語義,應視為強證據,
可以用 high confidence 生成保守同義詞。
8. 如果沒有安全同義詞,回空陣列,confidence 用 low。`;
function activate(context) {
const output = vscode.window.createOutputChannel(OUTPUT_CHANNEL_NAME);
context.subscriptions.push(output);
context.subscriptions.push(
vscode.commands.registerCommand("demo.askLmAndSaveMd", async () => {
await askLmAndSaveMd(context, output);
})
);
context.subscriptions.push(
vscode.commands.registerCommand("demo.expandSynonymsBatch", async () => {
await expandSynonymsBatch(context, output);
})
);
context.subscriptions.push(
vscode.commands.registerCommand("demo.listAvailableLmModels", async () => {
await listAvailableLmModels(context, output);
})
);
}
async function listAvailableLmModels(context, output) {
output.show(true);
output.appendLine("[lm-list] Command started: Demo: List Available LM Models");
const outputBaseUri = getOutputBaseUri(context);
const exportUri = vscode.Uri.joinPath(outputBaseUri, EXPORT_DIR);
await vscode.workspace.fs.createDirectory(exportUri);
const models = await vscode.lm.selectChatModels({ vendor: "copilot" });
const modelRecords = models.map((model, index) => ({
index: index + 1,
name: model.name ?? null,
id: model.id ?? null,
displayName: getModelDisplayName(model),
tag: getModelTag(model),
vendor: model.vendor ?? "copilot",
family: model.family ?? null,
version: model.version ?? null,
maxInputTokens: model.maxInputTokens ?? null,
}));
output.appendLine(`[lm-list] Found ${modelRecords.length} available Copilot LM models.`);
for (const record of modelRecords) {
output.appendLine(
`[lm-list] ${record.index}. ${record.displayName} | id=${record.id ?? "unknown"} | tag=${record.tag} | maxInputTokens=${record.maxInputTokens ?? "unknown"}`
);
}
const outputUri = vscode.Uri.joinPath(exportUri, "lm_models_available.json");
await writeJsonFile(outputUri, {
generated_at: new Date().toISOString(),
count: modelRecords.length,
models: modelRecords,
});
output.appendLine(`[lm-list] Saved model list: ${outputUri.fsPath}`);
vscode.window.showInformationMessage(`已輸出 ${modelRecords.length} 個可用 LLM 到 export/lm_models_available.json`);
await vscode.window.showTextDocument(outputUri);
}
async function askLmAndSaveMd(context, output) {
output.show(true);
output.appendLine("[demo] Command started: Demo: Ask LM and Save Markdown");
const question = "請用繁體中文簡單說明 Python list comprehension 是什麼。";
output.appendLine(`[demo] Question: ${question}`);
const model = await selectCopilotModel(output, DEFAULT_MODEL_ID);
if (!model) {
return;
}
output.appendLine("[demo] Sending request to language model...");
const answer = await sendTextRequest(model, question, output, "demo");
const markdown = [
"# LLM 回答",
"",
"## 問題",
"",
question,
"",
"## 回答",
"",
answer,
"",
].join("\n");
const outputBaseUri = getOutputBaseUri(context);
const outputUri = vscode.Uri.joinPath(outputBaseUri, "llm_answer.md");
output.appendLine(`[demo] Writing Markdown file: ${outputUri.fsPath}`);
await writeTextFile(outputUri, markdown);
output.appendLine("[demo] Done. Markdown file saved successfully.");
vscode.window.showInformationMessage("已將 LLM 回答存成 llm_answer.md");
await vscode.window.showTextDocument(outputUri);
}
async function expandSynonymsBatch(context, output) {
output.show(true);
output.appendLine("[batch] Command started: Demo: Expand Synonyms Batch");
const outputBaseUri = getOutputBaseUri(context);
const exportUri = vscode.Uri.joinPath(outputBaseUri, EXPORT_DIR);
const inputUri = vscode.Uri.joinPath(outputBaseUri, BATCH_INPUT_FILE);
const configUri = vscode.Uri.joinPath(outputBaseUri, LM_CONFIG_FILE);
const config = await loadJsonFile(configUri, {});
const preferredModel = config?.defaultModelId ?? config?.defaultModel ?? DEFAULT_MODEL_ID;
output.appendLine(`[batch] Input file: ${inputUri.fsPath}`);
output.appendLine(`[batch] Export folder: ${exportUri.fsPath}`);
output.appendLine(`[batch] Model preference: ${preferredModel}`);
const inputRecords = await loadKeywordRecords(inputUri);
const tokens = Object.keys(inputRecords).filter((token) => token !== "__meta__");
if (tokens.length === 0) {
vscode.window.showWarningMessage(`${BATCH_INPUT_FILE} 沒有可處理的 keyword。`);
output.appendLine("[batch] No tokens found.");
return;
}
const model = await selectCopilotModel(output, preferredModel);
if (!model) {
return;
}
await vscode.workspace.fs.createDirectory(exportUri);
const stateUri = vscode.Uri.joinPath(exportUri, makeStateFileName(model));
output.appendLine(`[batch] State file: ${stateUri.fsPath}`);
let state = await loadJsonFile(stateUri, createInitialState(inputUri, model));
const runtimeFingerprint = buildRuntimeFingerprint(inputUri, model);
if (!isStateCompatible(state, runtimeFingerprint)) {
output.appendLine("[batch] Existing state is not compatible with current prompt/model/input. Starting fresh and overwriting this state file.");
state = createInitialState(inputUri, model);
}
state.__meta__ = {
...state.__meta__,
input_file: inputUri.fsPath,
export_folder: exportUri.fsPath,
state_file: stateUri.fsPath,
model: model.name ?? model.id ?? "unknown",
prompt_version: PROMPT_VERSION,
runtime_fingerprint: runtimeFingerprint,
updated_at: new Date().toISOString(),
};
let completed = 0;
let skipped = 0;
let failed = 0;
let consecutiveErrors = 0;
output.appendLine(`[batch] Loaded ${tokens.length} tokens.`);
for (const [index, token] of tokens.entries()) {
const existing = state[token];
if (existing?.workflow?.llm_state === "done") {
skipped += 1;
output.appendLine(`[batch] ${index + 1}/${tokens.length} skip done: ${token}`);
continue;
}
output.appendLine(`[batch] ${index + 1}/${tokens.length} processing: ${token}`);
const originalRecord = normalizeOriginalRecord(token, inputRecords[token]);
let lastError = null;
let llmBlock = null;
let attemptCount = existing?.workflow?.attempt_count ?? 0;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt += 1) {
attemptCount += 1;
try {
output.appendLine(`[batch] token=${token} attempt=${attempt}/${MAX_RETRIES} send request`);
llmBlock = await requestSynonyms(model, token, originalRecord, output);
lastError = null;
consecutiveErrors = 0;
break;
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
output.appendLine(`[batch] token=${token} attempt=${attempt}/${MAX_RETRIES} error=${lastError}`);
}
}
if (llmBlock) {
completed += 1;
state[token] = {
...originalRecord,
synonyms_by_vscode_lm: llmBlock,
workflow: {
llm_state: "done",
route: "llm",
attempt_count: attemptCount,
updated_at: new Date().toISOString(),
},
};
} else {
failed += 1;
consecutiveErrors += 1;
state[token] = {
...originalRecord,
synonyms_by_vscode_lm: emptyLlmBlock("error_fallback"),
workflow: {
llm_state: "retry_limit_reached",
route: "llm",
state_reason: lastError ?? "unknown_error",
attempt_count: attemptCount,
updated_at: new Date().toISOString(),
},
};
}
state.__meta__.updated_at = new Date().toISOString();
state.__meta__.processed_tokens = countProcessedTokens(state);
await writeJsonFile(stateUri, state);
output.appendLine(`[batch] checkpoint saved. done_this_run=${completed}, skipped=${skipped}, failed_this_run=${failed}`);
if (consecutiveErrors >= 5) {
output.appendLine("[batch] Stop: 5 consecutive LLM/API errors. Keep state file for resume.");
vscode.window.showWarningMessage(`連續 5 次 LLM/API 錯誤,已停止;可稍後重跑續接 ${makeStateFileName(model)}。`);
return;
}
}
const finalUri = vscode.Uri.joinPath(exportUri, makeFinalFileName(model));
await writeJsonFile(finalUri, state);
output.appendLine(`[batch] Final output: ${finalUri.fsPath}`);
output.appendLine("[batch] Done.");
vscode.window.showInformationMessage(`批次同義詞擴展完成:${finalUri.fsPath}`);
await vscode.window.showTextDocument(finalUri);
}
async function selectCopilotModel(output, preferredModel) {
output.appendLine("[lm] Selecting Copilot language model...");
const models = await vscode.lm.selectChatModels({ vendor: "copilot" });
if (models.length === 0) {
output.appendLine("[lm] No Copilot language model is available.");
vscode.window.showErrorMessage("找不到可用的 Copilot / Language Model。");
return null;
}
for (const model of models) {
output.appendLine(`[lm] Available model: ${getModelDisplayName(model)} | id=${model.id ?? "unknown"}`);
}
const preferred = findPreferredModel(models, preferredModel);
if (preferred) {
output.appendLine(`[lm] Auto-selected preferred model: ${getModelDisplayName(preferred)} | id=${preferred.id ?? "unknown"}`);
return preferred;
}
if (preferredModel) {
output.appendLine(`[lm] Preferred model not found: ${preferredModel}. Falling back to manual selection.`);
}
const selected = await vscode.window.showQuickPick(
models.map((model) => ({
label: getModelDisplayName(model),
description: model.id ?? "",
model,
})),
{
placeHolder: "Select a Copilot language model, e.g. GPT 5.5 if available",
matchOnDescription: true,
}
);
if (!selected) {
output.appendLine("[lm] Model selection cancelled.");
return null;
}
const model = selected.model;
output.appendLine(`[lm] Selected model: ${getModelDisplayName(model)} | id=${model.id ?? "unknown"}`);
return model;
}
async function requestSynonyms(model, token, record, output) {
const derivedContextHint = getDerivedContextHint(token, record);
const payload = {
token,
unique_classes: toArray(record.unique_classes),
appeared_in_meta: toArray(record.appeared_in_meta),
appeared_in_highlight: toArray(record.appeared_in_highlight),
token_sources: record.token_sources ?? {},
families: toArray(record.families),
};
if (derivedContextHint) {
payload.derived_context_hint = derivedContextHint;
}
const requestText = `${SYNONYM_PROMPT}\n\nToken input:\n${JSON.stringify(payload, null, 2)}`;
const responseText = await sendTextRequest(model, requestText, output, "batch");
const parsed = extractJsonObject(responseText);
if (!parsed) {
throw new Error("LLM response is not valid JSON");
}
return normalizeLlmBlock(parsed);
}
async function sendTextRequest(model, text, output, logPrefix) {
const response = await model.sendRequest(
[vscode.LanguageModelChatMessage.User(text)],
{},
new vscode.CancellationTokenSource().token
);
let answer = "";
let chunkCount = 0;
for await (const chunk of response.text) {
answer += chunk;
chunkCount += 1;
output.appendLine(`[${logPrefix}] received chunk ${chunkCount}, total characters: ${answer.length}`);
}
output.appendLine(`[${logPrefix}] response complete. chunks=${chunkCount}, characters=${answer.length}`);
return answer;
}
async function loadKeywordRecords(inputUri) {
const input = await loadJsonFile(inputUri, null);
if (!input) {
throw new Error(`找不到 ${BATCH_INPUT_FILE},請先建立 keyword 清單。`);
}
if (Array.isArray(input)) {
return Object.fromEntries(
input
.filter((item) => typeof item === "string" && item.trim())
.map((token) => [token.trim(), { token: token.trim() }])
);
}
if (typeof input === "object") {
return input;
}
throw new Error(`${BATCH_INPUT_FILE} 必須是 string array 或 object[token, record]。`);
}
function normalizeOriginalRecord(token, value) {
if (value && typeof value === "object" && !Array.isArray(value)) {
return { ...value };
}
return { token };
}
function getDerivedContextHint(token, record) {
if (typeof record.derived_context_hint === "string" && record.derived_context_hint.trim()) {
return record.derived_context_hint.trim();
}
return KNOWN_TOKEN_HINTS[token.toLowerCase()] ?? null;
}
function normalizeLlmBlock(block) {
const confidence = ["high", "medium", "low"].includes(block.confidence) ? block.confidence : "low";
return {
en: normalizeStringArray(block.en, (value) => value.toLowerCase()),
zh_cn: normalizeStringArray(block.zh_cn, (value) => value),
es_mx: normalizeStringArray(block.es_mx, stripAccentsAndLower),
confidence,
};
}
function emptyLlmBlock(confidence) {
return { en: [], zh_cn: [], es_mx: [], confidence };
}
function normalizeStringArray(values, transform) {
const seen = new Set();
const result = [];
for (const value of toArray(values)) {
if (typeof value !== "string") {
continue;
}
const normalized = transform(value.trim());
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
result.push(normalized);
}
return result;
}
function toArray(value) {
return Array.isArray(value) ? value : [];
}
function stripAccentsAndLower(value) {
return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
}
function extractJsonObject(text) {
const trimmed = (text ?? "").trim();
if (!trimmed) {
return null;
}
try {
return JSON.parse(trimmed);
} catch (_) {
const start = trimmed.indexOf("{");
const end = trimmed.lastIndexOf("}");
if (start === -1 || end === -1 || end <= start) {
return null;
}
try {
return JSON.parse(trimmed.slice(start, end + 1));
} catch (_) {
return null;
}
}
}
function createInitialState(inputUri, model) {
return {
__meta__: {
schema_version: 1,
input_file: inputUri.fsPath,
model: model.name ?? model.id ?? "unknown",
created_at: new Date().toISOString(),
},
};
}
function buildRuntimeFingerprint(inputUri, model) {
const payload = JSON.stringify({
input_file: inputUri.fsPath,
model_id: model.id ?? null,
model_name: model.name ?? null,
prompt_version: PROMPT_VERSION,
prompt: SYNONYM_PROMPT,
known_token_hints: KNOWN_TOKEN_HINTS,
});
return crypto.createHash("sha256").update(payload).digest("hex").slice(0, 16);
}
function isStateCompatible(state, runtimeFingerprint) {
if (!state || typeof state !== "object") {
return false;
}
const meta = state.__meta__;
if (!meta || typeof meta !== "object") {
return false;
}
if (meta.prompt_version !== PROMPT_VERSION) {
return false;
}
if (meta.runtime_fingerprint && meta.runtime_fingerprint !== runtimeFingerprint) {
return false;
}
return true;
}
function countProcessedTokens(state) {
return Object.entries(state).filter(([token, record]) => token !== "__meta__" && record?.workflow?.llm_state).length;
}
function makeStateFileName(model) {
return `synonyms_state_${getModelTag(model)}_prompt_v${PROMPT_VERSION}.json`;
}
function makeFinalFileName(model) {
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z").replace("T", "_");
return `synonyms_final_${getModelTag(model)}_prompt_v${PROMPT_VERSION}_${stamp}.json`;
}
function getModelDisplayName(model) {
return model.name ?? model.id ?? "unknown";
}
function getModelTag(model) {
return getModelDisplayName(model).toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "unknown";
}
function findPreferredModel(models, preferredModel) {
if (!preferredModel) {
return null;
}
const preferred = normalizeModelSelector(preferredModel);
return models.find((model) => {
const candidates = [model.id, model.name, getModelDisplayName(model), getModelTag(model)];
return candidates.some((candidate) => normalizeModelSelector(candidate) === preferred);
}) ?? null;
}
function normalizeModelSelector(value) {
return String(value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
}
function getOutputBaseUri(context) {
return vscode.workspace.workspaceFolders?.[0]?.uri ?? context.extensionUri;
}
async function loadJsonFile(uri, fallbackValue) {
try {
const bytes = await vscode.workspace.fs.readFile(uri);
return JSON.parse(Buffer.from(bytes).toString("utf8"));
} catch (error) {
if (fallbackValue !== null) {
return fallbackValue;
}
if (error instanceof vscode.FileSystemError && error.code === "FileNotFound") {
return null;
}
throw error;
}
}
async function writeJsonFile(uri, data) {
await writeTextFile(uri, JSON.stringify(data, null, 2));
}
async function writeTextFile(uri, text) {
await vscode.workspace.fs.writeFile(uri, Buffer.from(text, "utf8"));
}
function deactivate() {}
module.exports = {
activate,
deactivate,
};