feat(llm): add OpenCode Zen polish provider, default deepseek-v4-flash (#1023)

* feat(llm): add OpenCode Zen polish provider, default deepseek-v4-flash

Dictation polish uses OpenAI-compatible chat/completions. For OpenCode Go, change the Base URL to /zen/go/v1.

* fix(macos): 避免 Fn 长按与功能键误触发听写 (#1008)

Auto 和 Toggle 模式只在 Fn 短按松开且未叠加功能层操作时触发听写;长按及亮度、音量等 systemDefined 事件留给 macOS。

保留显式 Hold 模式的按住说话语义,并跳过 Fn 短按释放后的冗余仲裁延迟。

Co-authored-by: Chris233 <h-chris233@outlook.com>

* fix(asr): 百炼 WebSocket 优先走 IPv4,避开本机 IPv6 黑洞 (#1036)

* fix(asr): 百炼 WebSocket 优先走 IPv4,避开本机 IPv6 黑洞

dashscope 的 AAAA 在部分 Windows 双栈网上 TCP 443 会挂死,connect_async 先试 IPv6 会卡满 5 秒超时,听写进不了 Listening。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(asr): preserve websocket ports and retry handshake addresses

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>

* fix(updater): Beta 构建默认跟随 Beta 渠道 (#1041)

* fix(updater): default beta builds to beta channel (#1034)

* fix(updater): persist channel switches atomically

* feat(llm): 支持渠道级 Chat Completions、Responses 和 Messages (#1042)

* feat(llm): add channel request formats and compatibility presets

* fix(llm): harden channel protocol handling

* feat(llm): integrate OpenCode Zen with shared request formats

---------

Co-authored-by: Mark <shawninjuly@gmail.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
Co-authored-by: 浩彬(^-^) <146098469+HKLHaoBin@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
authored and GitHub committed 2026-09-08 20:54:01 +08:00
1 parent 2b7c9d13c1
commit a3fc7171d2
16 files changed
+133 -15

No files matched your search

+1 -1
View File
@@ -200,7 +200,7 @@ Every item below is one more layer sedimented into a default — a capability yo
- **Toggle and push-to-talk** recording modes, plus a **MediaPlayPause trigger** so wired-earbud inline controls can start and stop recording. `Esc` cancels at any phase, including polish and insertion.
- **Cloud ASR**: Volcengine streaming ASR (bigasr), iFlytek realtime ASR (RTASR), Alibaba Cloud Bailian (classic realtime / Qwen3 realtime / Fun-ASR-Flash file transcription), StepFun StepAudio (batch + realtime), Zhipu GLM-ASR, Xiaomi MiMo ASR, ElevenLabs Scribe, OpenAI-compatible batch transcription (OpenAI Whisper / Groq / SiliconFlow SenseVoice / OpenRouter / ZenMux), and Apple Speech (macOS).
- **Local ASR**: bundled Qwen3-ASR (0.6B / 1.7B) via vendored `Open-Less/qwen-asr` (macOS); Windows Foundry Local Whisper and sherpa-onnx (experimental) variants.
- **Polish providers**: Ark (Volcengine), DeepSeek, OpenAI, Google Gemini, Codex OAuth, SiliconFlow, Atlas Cloud, Xiaomi MiMo, CometAPI, OpenRouter, Alibaba Cloud Coding Plan, CodingPlanX, MiniMax, and StepFun — plus any OpenAI-compatible endpoint you bring.
- **Polish providers**: Ark (Volcengine), DeepSeek, OpenAI, Google Gemini, Codex OAuth, SiliconFlow, Atlas Cloud, Xiaomi MiMo, CometAPI, OpenRouter, Alibaba Cloud Coding Plan, CodingPlanX, MiniMax, StepFun, and OpenCode Zen — plus any OpenAI-compatible endpoint you bring.
- **Four output modes**: raw, light polish, structured (**AI-prompt mode**), and formal. Plus a **translation hotkey** that converts speech directly into the configured target language ([#43](../../issues/43)).
- **Selection-ask QA panel** — a separate hotkey opens a floating panel that runs voice Q&A against the highlighted text in any app ([#118](../../issues/118)).
- **Main window**: Overview / History / Vocab / Style / Marketplace / Settings. Persistent tray icon, plus a mini status capsule that floats on screen and follows the display you are typing on (multi-monitor).
+1 -1
View File
@@ -205,7 +205,7 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提
- **切换式与按住说话(push-to-talk)** 两种录音模式,外加 **MediaPlayPause 触发**,让有线耳机的线控也能开始 / 停止录音。`Esc` 可在任意阶段取消,包括润色与插入。
- **云端 ASR**:Volcengine 流式 ASR(bigasr)、讯飞实时语音转写(RTASR)、阿里云百炼(经典实时 / Qwen3 实时 / Fun-ASR-Flash 录音文件)、阶跃星辰 StepAudio(批式 + 实时)、智谱 GLM-ASR、小米 MiMo ASR、ElevenLabs Scribe、OpenAI 兼容批量转写(OpenAI Whisper / Groq / 硅基流动 SenseVoice / OpenRouter / ZenMux),以及 Apple Speech(macOS)。
- **本地 ASR**:通过 vendored 的 `Open-Less/qwen-asr` 内置 Qwen3-ASR(0.6B / 1.7B)(macOS);Windows 上的 Foundry Local Whisper 与 sherpa-onnx(实验性)变体。
- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、CometAPI、OpenRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun,以及你自带的任意 OpenAI 兼容端点。
- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、CometAPI、OpenRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun、OpenCode Zen,以及你自带的任意 OpenAI 兼容端点。
- **四种输出模式**:原文、轻度润色、结构化(**AI 提示词模式**)、正式。另有一个**翻译快捷键**,将语音直接转换为所配置的目标语言([#43](../../issues/43))。
- **选区问答面板**——一个独立快捷键打开浮动面板,针对任意应用中被高亮选中的文本进行语音问答([#118](../../issues/118))。
- **主窗口**:概览 / 历史 / 词典 / 风格 / 市场 / 设置。常驻托盘图标,以及一个浮于屏幕、并跟随你正在输入的显示器的迷你状态胶囊(多显示器)。
@@ -76,6 +76,7 @@ pub const SHARED_CLOUD_LLM_PROVIDER_TYPES: &[&str] = &[
"codingPlanX",
"minimax",
"stepfun",
"opencode",
"custom",
"custom_responses",
"custom_messages",
@@ -187,6 +187,7 @@ fn is_builtin_llm_provider(provider_id: &str) -> bool {
| "codingPlanX"
| "minimax"
| "stepfun"
| "opencode"
)
}
@@ -1649,8 +1650,21 @@ pub(crate) fn apply_openai_compatible_thinking_control(
) {
// 优先按 provider_id 预设分派;custom / 未声明 provider 时回退到 base_url 兜底,
// 让用户用"自定义"preset 接入 MiniMax 也能正确下发 thinking 控制参数。
let control = openai_compatible_thinking_control(provider_id)
.or_else(|| openai_compatible_thinking_control_for_base_url(base_url));
// Zen 是多模型网关,仅 DeepSeek 模型使用 DeepSeek 的思考参数。
let is_opencode = provider_id.trim() == "opencode"
|| (matches!(provider_id.trim(), "custom" | "custom_responses" | "custom_messages")
&& url::Url::parse(base_url.trim())
.ok()
.is_some_and(|url| url.host_str() == Some("opencode.ai")));
let control = if is_opencode {
model
.trim()
.starts_with("deepseek-")
.then_some(ThinkingControl::DeepSeekThinking)
} else {
openai_compatible_thinking_control(provider_id)
.or_else(|| openai_compatible_thinking_control_for_base_url(base_url))
};
match control {
Some(ThinkingControl::ReasoningEffort) => {
// OpenAI 官方 Chat Completions 只在推理模型族接受 reasoning_effort;
@@ -2125,7 +2139,10 @@ mod tests {
#[tokio::test]
async fn all_text_entrypoints_use_the_selected_protocol_over_http() {
for format in LlmRequestFormat::ALL {
for (format, (preset, prefix)) in LlmRequestFormat::ALL.into_iter().flat_map(|format| {
[("custom", "/gateway/v1"), ("opencode", "/zen/v1"), ("opencode", "/zen/go/v1")]
.map(|entry| (format, entry))
}) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let server = thread::spawn(move || {
@@ -2140,7 +2157,7 @@ mod tests {
LlmRequestFormat::Responses => "responses",
LlmRequestFormat::Messages => "messages",
};
assert!(headers.starts_with(&format!("post /gateway/v1/{path}?tenant=1 ")));
assert!(headers.starts_with(&format!("post {prefix}/{path}?tenant=1 ")));
if format == LlmRequestFormat::Messages {
assert!(headers.contains("x-api-key: fixture-key"));
assert!(headers.contains("anthropic-version: 2023-06-01"));
@@ -2189,9 +2206,9 @@ mod tests {
}
});
let config = OpenAICompatibleConfig::new(
"custom",
preset,
"test",
format!("http://{address}/gateway/v1/chat/completions?tenant=1"),
format!("http://{address}{prefix}/chat/completions?tenant=1"),
"fixture-key",
"test",
)
@@ -2283,6 +2300,52 @@ mod tests {
}
}
#[test]
fn opencode_thinking_is_scoped_to_model_host_and_protocol() {
for (preset, endpoint, zen) in [
("opencode", "https://opencode.ai/zen/v1", true),
("opencode", "https://gateway.example/v1", true),
("custom", "https://opencode.ai/zen/v1", true),
("custom_responses", "https://opencode.ai/zen/v1", true),
("custom_messages", "https://opencode.ai/zen/v1", true),
("custom", "https://OPENCODE.AI:443/zen/go/v1/chat/completions", true),
("custom", "https://opencode.ai.example/zen/v1", false),
("custom", "https://fakeopencode.ai/zen/v1", false),
("custom", "https://opencode.ai@example.com/zen/v1", false),
("custom", "https://example.com/opencode.ai", false),
] {
for model in ["deepseek-v4-flash", "minimax-m3", "gateway-model"] {
for enabled in [false, true] {
for format in LlmRequestFormat::ALL {
let provider = OpenAICompatibleLLMProvider::new(
OpenAICompatibleConfig::new(preset, "test", endpoint, "key", model)
.with_thinking_enabled(enabled)
.with_protocol(LlmProtocolConfig { format, ..Default::default() }),
);
let body = provider.chat_body(false, vec![json!({"role":"user","content":"hi"})]);
match format {
LlmRequestFormat::ChatCompletions if zen && model.starts_with("deepseek-") => {
assert_eq!(body["thinking"]["type"], if enabled { "enabled" } else { "disabled" });
}
LlmRequestFormat::Messages if enabled => {
assert_eq!(body["thinking"]["type"], "adaptive");
}
_ => assert!(body.get("thinking").is_none(), "{preset} {endpoint} {model} {format:?}"),
}
assert!(body.get("reasoning_effort").is_none());
assert!(body.get("enable_thinking").is_none());
if format == LlmRequestFormat::Responses {
assert_eq!(body["reasoning"]["effort"], if enabled { "medium" } else { "low" });
assert!(body.get("messages").is_none());
} else {
assert!(body.get("reasoning").is_none());
}
}
}
}
}
}
fn write_chunked_sse_response(stream: &mut std::net::TcpStream, chunks: &[&[u8]]) {
stream
.write_all(
@@ -62,6 +62,7 @@ const LLM_PROVIDER_TYPES: &[(&str, &str)] = &[
("codingPlanX", "codingPlanX"),
("minimax", "minimax"),
("stepfun", "stepfun"),
("opencode", "opencode"),
("custom", "customChatCompletions"),
("custom_responses", "customResponses"),
("custom_messages", "customMessages"),
@@ -481,6 +482,7 @@ pub fn default_asr_model(provider_type: &str) -> Option<&'static str> {
pub fn default_llm_endpoint(provider_type: &str) -> Option<&'static str> {
match provider_type {
"opencode" => Some("https://opencode.ai/zen/v1"),
"ark" => Some("https://ark.cn-beijing.volces.com/api/v3"),
"deepseek" => Some("https://api.deepseek.com/v1"),
"siliconflow" => Some("https://api.siliconflow.cn/v1"),
@@ -501,7 +503,7 @@ pub fn default_llm_endpoint(provider_type: &str) -> Option<&'static str> {
pub fn default_llm_model(provider_type: &str) -> Option<&'static str> {
match provider_type {
"ark" => Some("deepseek-v3-2"),
"deepseek" => Some("deepseek-v4-flash"),
"deepseek" | "opencode" => Some("deepseek-v4-flash"),
"siliconflow" => Some("Qwen/Qwen2.5-7B-Instruct"),
"atlascloud" => Some("qwen/qwen3.5-flash"),
"openai" | "cometapi" => Some("gpt-4o"),
@@ -989,6 +991,29 @@ mod tests {
assert!(parse_extra_headers(r#"{"authorization":"secret"}"#).is_err());
}
#[test]
fn opencode_descriptor_supplies_defaults_formats_and_credentials() {
use crate::llm_protocol::LlmRequestFormat;
assert!(crate::cloud_providers::SHARED_CLOUD_LLM_PROVIDER_TYPES.contains(&"opencode"));
let descriptor = provider_descriptor(ProviderKind::Llm, "opencode").unwrap();
assert_eq!(descriptor.label_key, "opencode");
assert_eq!(descriptor.default_endpoint.as_deref(), Some("https://opencode.ai/zen/v1"));
assert_eq!(descriptor.default_model.as_deref(), Some("deepseek-v4-flash"));
assert_eq!(descriptor.default_request_format, Some(LlmRequestFormat::ChatCompletions));
assert_eq!(descriptor.supported_request_formats, LlmRequestFormat::ALL);
assert_eq!(descriptor.validation_probe, ValidationProbe::LlmText);
assert!(api_key_required(ProviderKind::Llm, "opencode", Some("https://opencode.ai/zen/v1/chat/completions/")));
let mut configuration = CredentialConfiguration {
llm_endpoint: true,
llm_endpoint_matches_default: true,
llm_model: true,
..CredentialConfiguration::default()
};
assert!(!llm_configured("opencode", &configuration));
configuration.llm_api_key = true;
assert!(llm_configured("opencode", &configuration));
}
#[test]
fn descriptors_are_the_single_source_for_defaults_auth_and_probes() {
let compatible = provider_descriptor(ProviderKind::Asr, "openai-compatible").unwrap();
@@ -892,6 +892,9 @@ mod tests {
async fn validation_and_model_lists_use_channel_protocol_and_thinking() {
use crate::llm_protocol::*;
for (format, preset, sse, path) in [
("chat_completions", "opencode", "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n", "/v1/chat/completions"),
("responses", "opencode", "data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\ndata: {\"type\":\"response.completed\"}\n\n", "/v1/responses"),
("messages", "opencode", "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n", "/v1/messages"),
("responses", "custom_responses", "data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\ndata: {\"type\":\"response.completed\"}\n\n", "/v1/responses"),
("messages", "custom_messages", "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n", "/v1/messages"),
] {
@@ -909,13 +912,13 @@ mod tests {
assert!(request.starts_with(&format!("POST {path} ")));
let body: serde_json::Value = serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap();
if format == "responses" { assert_eq!(body["reasoning"]["effort"], if enabled { "medium" } else { "low" }); }
else if enabled { assert_eq!(body["thinking"]["type"], "adaptive"); }
else if format == "messages" && enabled { assert_eq!(body["thinking"]["type"], "adaptive"); }
else { assert!(body.get("thinking").is_none()); }
}
let (endpoint, request) = spawn_http_response("200 OK", "application/json", r#"{"data":[{"id":"model"}]}"#);
let credentials = Arc::new(InMemoryCredentialStore::default());
let channel = create_channel_with_values(&credentials, ChannelKind::Llm, "openai", &[
(LLM_ENDPOINT_ACCOUNT, &format!("{endpoint}/{format}")), (LLM_MODEL_ACCOUNT, "test"),
let channel = create_channel_with_values(&credentials, ChannelKind::Llm, preset, &[
(LLM_ENDPOINT_ACCOUNT, &format!("{endpoint}/{}", if format == "chat_completions" { "chat/completions" } else { format })), (LLM_MODEL_ACCOUNT, "test"),
(LLM_API_KEY_ACCOUNT, "fixture-key"), (REQUEST_FORMAT_ACCOUNT, format),
]).await;
let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner));
+1
View File
@@ -1010,6 +1010,7 @@ export const en: typeof zhCN = {
codingPlanX: 'CodingPlanX',
minimax: 'MiniMax (M3)',
stepfun: 'StepFun',
opencode: 'OpenCode Zen',
customChatCompletions: "Custom · Chat Completions",
customResponses: "Custom · Responses",
customMessages: "Custom · Messages",
+1
View File
@@ -1012,6 +1012,7 @@ export const ja: typeof zhCN = {
codingPlanX: 'CodingPlanX',
minimax: 'MiniMax(M3)',
stepfun: 'StepFun(階躍星辰)',
opencode: 'OpenCode Zen',
customChatCompletions: "カスタム · Chat Completions",
customResponses: "カスタム · Responses",
customMessages: "カスタム · Messages",
+1
View File
@@ -1012,6 +1012,7 @@ export const ko: typeof zhCN = {
codingPlanX: 'CodingPlanX',
minimax: 'MiniMax (M3)',
stepfun: 'StepFun',
opencode: 'OpenCode Zen',
customChatCompletions: "사용자 지정 · Chat Completions",
customResponses: "사용자 지정 · Responses",
customMessages: "사용자 지정 · Messages",
+1
View File
@@ -1008,6 +1008,7 @@ export const zhCN = {
codingPlanX: 'CodingPlanX',
minimax: 'MiniMax(M3)',
stepfun: 'StepFun(阶跃星辰)',
opencode: 'OpenCode Zen',
customChatCompletions: "自定义 · Chat Completions",
customResponses: "自定义 · Responses",
customMessages: "自定义 · Messages",
+1
View File
@@ -1010,6 +1010,7 @@ export const zhTW: typeof zhCN = {
codingPlanX: 'CodingPlanX',
minimax: 'MiniMax(M3)',
stepfun: 'StepFun(階躍星辰)',
opencode: 'OpenCode Zen',
customChatCompletions: "自訂 · Chat Completions",
customResponses: "自訂 · Responses",
customMessages: "自訂 · Messages",
+4 -1
View File
@@ -28,11 +28,14 @@ export interface ProviderDescriptor {
/** Core owns protocol, defaults, and credential requirements. */
export function listProviderDescriptors(kind: ProviderKind): Promise<ProviderDescriptor[]> {
return invokeOrMock('list_provider_descriptors', { kind }, () => kind === 'llm' ? [
['opencode', 'opencode', 'chat_completions'],
['custom', 'customChatCompletions', 'chat_completions'],
['custom_responses', 'customResponses', 'responses'],
['custom_messages', 'customMessages', 'messages'],
].map(([providerType, labelKey, format]) => ({
kind, providerType, labelKey, defaultEndpoint: null, defaultModel: null,
kind, providerType, labelKey,
defaultEndpoint: providerType === 'opencode' ? 'https://opencode.ai/zen/v1' : null,
defaultModel: providerType === 'opencode' ? 'deepseek-v4-flash' : null,
authRequirement: 'api_key_unless_custom_endpoint', validationProbe: 'llm_text', staticModels: [],
defaultRequestFormat: format as LlmRequestFormat,
supportedRequestFormats: ['chat_completions', 'responses', 'messages'],
+1
View File
@@ -52,6 +52,7 @@ const LLM_NAME_KEY_BY_ID: Record<string, string> = {
openrouterFree: 'openrouterFree',
alibabaCoding: 'alibabaCoding',
codingPlanX: 'codingPlanX',
opencode: 'opencode',
custom: 'custom',
};
@@ -18,14 +18,27 @@ assert(protocolValidationError({ ...values, 'ark.messages_thinking': 'budget', '
const descriptors = await listProviderDescriptors('llm');
const presets = presetsFor('llm', 'win', true, undefined, descriptors);
assert(presets.length === 3, 'Browser catalog should expose three compatibility presets');
assert(presets.filter(p => p.id.startsWith('custom')).length === 3, 'Browser catalog should expose three compatibility presets');
const opencode = presets.find(p => p.id === 'opencode');
assert(opencode?.defaultRequestFormat === 'chat_completions'
&& opencode.defaultEndpoint === 'https://opencode.ai/zen/v1'
&& opencode.defaultModel === 'deepseek-v4-flash', 'OpenCode browser preset must retain Core defaults');
assert(presets.find(p => p.id === 'custom_messages')?.defaultRequestFormat === 'messages', 'Picker must retain Core protocol defaults');
for (const preset of presets) assert(preset.supportedRequestFormats?.length === 3, 'All compatibility presets allow switching');
const first = await createChannel('llm', 'custom', 'first');
const first = await createChannel('llm', 'opencode', 'first');
const second = await createChannel('llm', 'custom', 'second');
await setCredential('ark.request_format', 'messages', first);
await setCredential('ark.api_key', 'fixture-key', first);
await setCredential('ark.endpoint', 'https://opencode.ai/zen/go/v1', first);
await setCredential('ark.model_id', 'deepseek-v4-flash', first);
for (const format of ['chat_completions', 'responses', 'messages']) {
await setCredential('ark.request_format', format, first);
assert(await readCredential('ark.request_format', first) === format, 'OpenCode format must survive reload');
assert(await readCredential('ark.api_key', first) === 'fixture-key', 'Format changes preserve the key');
assert(await readCredential('ark.model_id', first) === 'deepseek-v4-flash', 'Format changes preserve the model');
assert(await readCredential('ark.endpoint', first) === 'https://opencode.ai/zen/go/v1', 'Format changes preserve the endpoint');
}
await recordChannelTest('llm', first, true, 1, null);
await setCredential('ark.model_id', 'new-model', first);
assert(await readCredential('ark.request_format', first) === 'messages', 'Changing model must preserve format');
@@ -3,6 +3,9 @@ import { ASR_LABELS } from './shared';
import { presetsFor } from './ChannelList';
const atlascloudPreset = LLM_LABELS.find(p => p.id === 'atlascloud');
if (LLM_LABELS.find(p => p.id === 'opencode')?.nameKey !== 'opencode') {
throw new Error('OpenCode LLM label is missing');
}
if (!atlascloudPreset) {
throw new Error('Atlas Cloud LLM preset is missing');
@@ -91,6 +91,7 @@ export const LLM_LABELS = [
['codex_oauth', 'codexOAuth'], ['mimo', 'mimo'], ['cometapi', 'cometapi'],
['openrouterFree', 'openrouterFree'], ['alibabaCoding', 'alibabaCoding'],
['codingPlanX', 'codingPlanX'], ['minimax', 'minimax'], ['stepfun', 'stepfun'],
['opencode', 'opencode'],
['custom', 'customChatCompletions'], ['custom_responses', 'customResponses'], ['custom_messages', 'customMessages'],
].map(([id, nameKey]) => ({ id, nameKey })) as readonly { id: string; nameKey: string }[];