攝影或3C

使用 VS Code LM API 調用 GitHub Copilot 模型進行三語同義詞批次擴展; Ctrl + Shift + P vs Ctrl + P 然後輸入 >

## 從免管理員 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 主程式真正的大腦負責 activateregisterCommand呼叫 vscode.lm讀寫檔案執行三個 Demo
package.json       VS Code extension 設定檔負責宣告 extension 名稱版本入口 mainVS 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 輸出資料夾保存 checkpointfinal 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 shimPowerShell 可以直接執行
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,
};
儲蓄保險王

儲蓄險是板主最喜愛的儲蓄工具,最喜愛的投資理財工具則是ETF,最喜愛的省錢工具則是信用卡