zen-gitsync 2.15.34 → 2.16.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -3
- package/package.json +1 -1
- package/src/cli/ai/agent.js +493 -83
- package/src/cli/ai/agent.test.js +190 -0
- package/src/cli/ai/images.js +211 -0
- package/src/cli/ai/images.test.js +98 -0
- package/src/cli/ai/modelSetup.js +594 -0
- package/src/cli/ai/modelSetup.test.js +496 -0
- package/src/cli/ai/platformGuard.js +200 -0
- package/src/cli/ai/platformGuard.test.js +253 -0
- package/src/cli/ai/streamFilter.js +82 -0
- package/src/cli/ai/streamFilter.test.js +88 -0
- package/src/cli/ai/termui.js +453 -0
- package/src/cli/ai/termui.test.js +253 -0
- package/src/cli/ai/tools.js +54 -18
- package/src/cli/ai/tools.test.js +28 -0
- package/src/ui/public/assets/AppVersionBadge-BTznOVSJ.js +2 -0
- package/src/ui/public/assets/AppVersionBadge-CS7Trvp-.css +1 -0
- package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +1 -0
- package/src/ui/public/assets/BranchSelector-BISqgXoZ.js +1 -0
- package/src/ui/public/assets/CommitForm-C7FmI0Sl.js +6 -0
- package/src/ui/public/assets/CommitForm-DdK8dymc.css +1 -0
- package/src/ui/public/assets/CommonDialog-BA4xg5l3.js +1 -0
- package/src/ui/public/assets/CommonDialog-ChSKsEz2.css +1 -0
- package/src/ui/public/assets/EditorView-BVod-rjn.css +1 -0
- package/src/ui/public/assets/EditorView-CSWy4q7G.js +0 -0
- package/src/ui/public/assets/FlowExecutionViewer-CZdF1f1e.js +1 -0
- package/src/ui/public/assets/FlowExecutionViewer-jNHI5zdm.css +1 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-BECFOAcQ.js +21 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +1 -0
- package/src/ui/public/assets/LogList-CeFFytjB.css +1 -0
- package/src/ui/public/assets/LogList-DYoGtOou.js +15 -0
- package/src/ui/public/assets/MindmapView-3rMfOoNm.css +1 -0
- package/src/ui/public/assets/MindmapView-CU3tXgbR.js +1 -0
- package/src/ui/public/assets/MonitorView-CSAqU7oV.js +1 -0
- package/src/ui/public/assets/MonitorView-TaS4kL0Y.css +1 -0
- package/src/ui/public/assets/ProjectStartupButton-2By0-fuq.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +1 -0
- package/src/ui/public/assets/RemoteRepoCard-BRGNlbeO.css +1 -0
- package/src/ui/public/assets/RemoteRepoCard-DHsJwTEX.js +5 -0
- package/src/ui/public/assets/SourceMapView-BNRjvgzH.css +1 -0
- package/src/ui/public/assets/SourceMapView-DskfUia5.js +3 -0
- package/src/ui/public/assets/SvgIcon-BqWp2WS8.js +1 -0
- package/src/ui/public/assets/{SvgIcon-BQ6bR7l0.css → SvgIcon-CDNsk4-Q.css} +1 -1
- package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +1 -0
- package/src/ui/public/assets/UserInputNode-Ccnh0e-0.js +1 -0
- package/src/ui/public/assets/WorkbenchView-1q0xaqyV.js +28 -0
- package/src/ui/public/assets/{WorkbenchView-CQfjF0Gr.css → WorkbenchView-DbF4hH5s.css} +1 -1
- package/src/ui/public/assets/{_plugin-vue_export-helper-B7s-K_Aj.js → _plugin-vue_export-helper-BQRl7WAH.js} +1 -1
- package/src/ui/public/assets/{configStore-DPGao81A.js → configStore-BkxDLXGa.js} +1 -1
- package/src/ui/public/assets/element-plus-DNnePjco.js +32 -0
- package/src/ui/public/assets/{flow-mindmap-5SDymYbY.js → flow-mindmap-DRmzYE4F.js} +1 -1
- package/src/ui/public/assets/index-Br2VZQBT.js +68 -0
- package/src/ui/public/assets/{index-CWoujklW.css → index-CaIu73UM.css} +1 -1
- package/src/ui/public/assets/socket-io-pmu5tRPo.js +1 -0
- package/src/ui/public/assets/{vendor-BteTDIoK.js → vendor-CcK8n8T4.js} +44 -37
- package/src/ui/public/assets/vue-flow-pAz--378.js +39 -0
- package/src/ui/public/index.html +12 -12
- package/src/ui/public/assets/AppVersionBadge-DL0Y81fU.js +0 -2
- package/src/ui/public/assets/AppVersionBadge-Dj6H729N.css +0 -1
- package/src/ui/public/assets/BranchSelector-B_VMrSKv.js +0 -1
- package/src/ui/public/assets/BranchSelector-T_rJm-JY.css +0 -1
- package/src/ui/public/assets/CommitForm-BNuwp3ha.js +0 -6
- package/src/ui/public/assets/CommitForm-CUvl0VcF.css +0 -1
- package/src/ui/public/assets/CommonDialog-C2dbNSrw.js +0 -1
- package/src/ui/public/assets/CommonDialog-Cyh3xu0p.css +0 -1
- package/src/ui/public/assets/EditorView-BdTQsQw7.js +0 -0
- package/src/ui/public/assets/EditorView-CRYGVw_r.css +0 -1
- package/src/ui/public/assets/FlowExecutionViewer-B5kh7VxS.css +0 -1
- package/src/ui/public/assets/FlowExecutionViewer-D5DBcy8S.js +0 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-BAQDU9Jd.css +0 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-omHN8HaT.js +0 -21
- package/src/ui/public/assets/LogList-DJYpDUTZ.css +0 -1
- package/src/ui/public/assets/LogList-Dc1RMEFq.js +0 -7
- package/src/ui/public/assets/MindmapView-BPTh-XAf.js +0 -1
- package/src/ui/public/assets/MindmapView-CWoqfZRL.css +0 -1
- package/src/ui/public/assets/MonitorView-DxFogQIt.css +0 -1
- package/src/ui/public/assets/MonitorView-RTbnLZlO.js +0 -1
- package/src/ui/public/assets/ProjectStartupButton-B7zvqRj_.css +0 -1
- package/src/ui/public/assets/ProjectStartupButton-CcQ8SaPb.js +0 -1
- package/src/ui/public/assets/RemoteRepoCard-Cr1yQ4pF.js +0 -1
- package/src/ui/public/assets/RemoteRepoCard-d0klonVb.css +0 -1
- package/src/ui/public/assets/SourceMapView-BbiYmCHB.js +0 -3
- package/src/ui/public/assets/SourceMapView-DG_a_076.css +0 -1
- package/src/ui/public/assets/SvgIcon-D3jL_b3c.js +0 -1
- package/src/ui/public/assets/UserInputNode-DCk_cvaT.js +0 -1
- package/src/ui/public/assets/UserInputNode-DDtvv9Iq.css +0 -1
- package/src/ui/public/assets/WorkbenchView-KX3nKGZc.js +0 -12
- package/src/ui/public/assets/element-plus-BAcS68uS.js +0 -24
- package/src/ui/public/assets/index-B8oXZO4w.js +0 -38
- package/src/ui/public/assets/socket-io-BC2cap_-.js +0 -1
- package/src/ui/public/assets/vue-flow-CtZ_ZWKZ.js +0 -39
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// g ai 交互式模型配置向导。
|
|
16
|
+
//
|
|
17
|
+
// 当 ~/.git-commit-tool.json 顶层 models 为空时,由 agent.js 调用本模块
|
|
18
|
+
// 引导用户在终端中完成首个 AI 模型的配置,参照 g ui 设置里的"添加模型"
|
|
19
|
+
// 功能(ai-model-form 包的 AddModelForm 组件)。
|
|
20
|
+
//
|
|
21
|
+
// 交互流程:
|
|
22
|
+
// 1. 询问是否现在配置
|
|
23
|
+
// 2. 选择服务商(预设列表 / 自定义)
|
|
24
|
+
// 3. 确认接口地址(baseURL)
|
|
25
|
+
// 4. 选择模型名称(常用模型列表 / 手动输入)
|
|
26
|
+
// 5. 输入 API Key(本地模型可留空)
|
|
27
|
+
// 6. 输入显示名称(可选)
|
|
28
|
+
// 7. 测试连接
|
|
29
|
+
// 8. 保存到配置文件并返回模型对象
|
|
30
|
+
//
|
|
31
|
+
// 预设数据(PROVIDERS / BUILTIN_MODELS)与 ai-model-form 包的
|
|
32
|
+
// server/middleware.js 保持同步,确保 CLI 与 GUI 体验一致。
|
|
33
|
+
|
|
34
|
+
import readline from 'node:readline'
|
|
35
|
+
import chalk from 'chalk'
|
|
36
|
+
import config from '../../config.js'
|
|
37
|
+
import { startSpinner } from './termui.js'
|
|
38
|
+
|
|
39
|
+
// ──────────────────────────────────────────────
|
|
40
|
+
// 预设服务商列表
|
|
41
|
+
// 来源:ai-model-form 包 server/middleware.js 的 PROVIDERS
|
|
42
|
+
// 修改时需同步更新两边
|
|
43
|
+
// ──────────────────────────────────────────────
|
|
44
|
+
export const PROVIDERS = [
|
|
45
|
+
{ id: 'openai', label: 'OpenAI', url: 'https://api.openai.com/v1' },
|
|
46
|
+
{ id: 'anthropic', label: 'Anthropic (Claude)', url: 'https://api.anthropic.com/v1' },
|
|
47
|
+
{ id: 'deepseek', label: 'DeepSeek', url: 'https://api.deepseek.com/v1' },
|
|
48
|
+
{ id: 'gemini', label: 'Google (Gemini)', url: 'https://generativelanguage.googleapis.com/v1beta/openai' },
|
|
49
|
+
{ id: 'xai', label: 'xAI (Grok)', url: 'https://api.x.ai/v1' },
|
|
50
|
+
{ id: 'meta', label: 'Meta (Llama)', url: 'https://api.llama-api.com/v1' },
|
|
51
|
+
{ id: 'mistral', label: 'Mistral AI', url: 'https://api.mistral.ai/v1' },
|
|
52
|
+
{ id: 'minimax', label: 'MiniMax', url: 'https://api.minimaxi.com/v1' },
|
|
53
|
+
{ id: 'moonshot', label: 'Moonshot (Kimi)', url: 'https://api.moonshot.cn/v1' },
|
|
54
|
+
{ id: 'zhipu', label: '智谱 (GLM)', url: 'https://open.bigmodel.cn/api/paas/v4' },
|
|
55
|
+
{ id: 'qwen', label: '阿里 (Qwen)', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1' },
|
|
56
|
+
{ id: 'cohere', label: 'Cohere', url: 'https://api.cohere.com/v2' },
|
|
57
|
+
{ id: 'groq', label: 'Groq', url: 'https://api.groq.com/openai/v1' },
|
|
58
|
+
{ id: 'together', label: 'Together AI', url: 'https://api.together.xyz/v1' },
|
|
59
|
+
{ id: 'openrouter', label: 'OpenRouter', url: 'https://openrouter.ai/api/v1' },
|
|
60
|
+
{ id: 'ollama', label: 'Ollama (本地)', url: 'http://localhost:11434/v1' },
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
// ──────────────────────────────────────────────
|
|
64
|
+
// 内置常用模型列表
|
|
65
|
+
// 来源:ai-model-form 包 server/middleware.js 的 BUILTIN_MODELS
|
|
66
|
+
// 修改时需同步更新两边
|
|
67
|
+
// ──────────────────────────────────────────────
|
|
68
|
+
export const BUILTIN_MODELS = {
|
|
69
|
+
'https://api.openai.com/v1': [
|
|
70
|
+
'gpt-5.5', 'gpt-5.5-instant', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano',
|
|
71
|
+
'gpt-4.1', 'gpt-4.1-mini', 'o3', 'o4-mini', 'gpt-4o',
|
|
72
|
+
],
|
|
73
|
+
'https://api.anthropic.com/v1': [
|
|
74
|
+
'claude-opus-4-7', 'claude-sonnet-4-6', 'claude-haiku-4-5',
|
|
75
|
+
],
|
|
76
|
+
'https://api.deepseek.com/v1': [
|
|
77
|
+
'deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-chat', 'deepseek-reasoner',
|
|
78
|
+
],
|
|
79
|
+
'https://generativelanguage.googleapis.com/v1beta/openai': [
|
|
80
|
+
'gemini-3.5-flash', 'gemini-3.5-pro', 'gemini-3.1-pro', 'gemini-3-flash',
|
|
81
|
+
'gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite',
|
|
82
|
+
],
|
|
83
|
+
'https://api.x.ai/v1': [
|
|
84
|
+
'grok-4', 'grok-4.3', 'grok-3', 'grok-3-mini',
|
|
85
|
+
],
|
|
86
|
+
'https://api.llama-api.com/v1': [
|
|
87
|
+
'llama-4-maverick', 'llama-4-scout', 'llama-3.1-70b', 'llama-3.1-8b',
|
|
88
|
+
],
|
|
89
|
+
'https://api.mistral.ai/v1': [
|
|
90
|
+
'mistral-medium-3.5', 'mistral-large-3', 'codestral-latest', 'open-mixtral-8x22b',
|
|
91
|
+
],
|
|
92
|
+
'https://api.minimaxi.com/v1': [
|
|
93
|
+
'minimax-m2.7',
|
|
94
|
+
],
|
|
95
|
+
'https://api.moonshot.cn/v1': [
|
|
96
|
+
'kimi-k2.6', 'kimi-k2.5', 'moonshot-v1-128k', 'moonshot-v1-32k', 'moonshot-v1-8k',
|
|
97
|
+
],
|
|
98
|
+
'https://open.bigmodel.cn/api/paas/v4': [
|
|
99
|
+
'glm-5.1', 'glm-5v-turbo', 'glm-4-plus', 'glm-4-flash',
|
|
100
|
+
],
|
|
101
|
+
'https://dashscope.aliyuncs.com/compatible-mode/v1': [
|
|
102
|
+
'qwen3.7-max', 'qwen3.6-max-preview', 'qwen3.6-plus', 'qwen3.6-flash',
|
|
103
|
+
'qwen3.5-omni-plus', 'qwen3-vl-plus', 'qwen3-235b-a22b', 'qwen3-32b',
|
|
104
|
+
],
|
|
105
|
+
'https://api.cohere.com/v2': [
|
|
106
|
+
'command-r-plus-4', 'command-r-4', 'command-r', 'command-r-plus',
|
|
107
|
+
],
|
|
108
|
+
'https://api.groq.com/openai/v1': [
|
|
109
|
+
'llama-3.1-70b-versatile', 'llama-3.1-8b-instant',
|
|
110
|
+
'llama3-70b-8192', 'mixtral-8x7b-32768', 'gemma2-9b-it',
|
|
111
|
+
],
|
|
112
|
+
'https://api.together.xyz/v1': [
|
|
113
|
+
'meta-llama/Llama-4-Maverick', 'Qwen/Qwen3-72B',
|
|
114
|
+
'meta-llama/Llama-3.3-70B-Instruct-Turbo',
|
|
115
|
+
'deepseek-ai/DeepSeek-V3',
|
|
116
|
+
],
|
|
117
|
+
'http://localhost:11434/v1': [
|
|
118
|
+
'qwen2.5', 'llama3.1', 'mistral', 'deepseek-r1:7b', 'qwen3', 'llama4',
|
|
119
|
+
],
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ──────────────────────────────────────────────
|
|
123
|
+
// i18n 字符串表
|
|
124
|
+
// ──────────────────────────────────────────────
|
|
125
|
+
const STRINGS = {
|
|
126
|
+
zh: {
|
|
127
|
+
title: 'AI 模型配置向导',
|
|
128
|
+
noModelDetected: '未检测到已配置的 AI 模型。',
|
|
129
|
+
askSetup: '是否现在配置?(Y/n)',
|
|
130
|
+
setupCancelled: '已取消配置。请运行 g ui 在设置中添加模型,或再次运行 g ai。',
|
|
131
|
+
selectProvider: '选择 AI 服务商:',
|
|
132
|
+
customOption: '自定义',
|
|
133
|
+
selectPrompt: (min, max) => `请选择 (${min}-${max}): `,
|
|
134
|
+
invalidChoice: (min, max) => `无效选择,请输入 ${min}-${max} 之间的数字`,
|
|
135
|
+
endpointLabel: '接口地址',
|
|
136
|
+
endpointPrompt: (def) => `接口地址 [${def}]: `,
|
|
137
|
+
endpointInvalid: '接口地址格式不正确(需为 http/https URL)',
|
|
138
|
+
modelLabel: '模型名称',
|
|
139
|
+
builtinModels: '常用模型:',
|
|
140
|
+
manualInput: '手动输入',
|
|
141
|
+
modelPrompt: '请输入模型名称: ',
|
|
142
|
+
modelRequired: '模型名称不能为空',
|
|
143
|
+
apiKeyLabel: 'API Key',
|
|
144
|
+
apiKeyPrompt: 'API Key (本地模型可留空): ',
|
|
145
|
+
displayNameLabel: '显示名称',
|
|
146
|
+
displayNamePrompt: (def) => `显示名称 [${def}]: `,
|
|
147
|
+
testing: '正在测试连接…',
|
|
148
|
+
testOk: '连接成功',
|
|
149
|
+
testFail: (msg) => `连接失败: ${msg}`,
|
|
150
|
+
testSkip: '跳过测试',
|
|
151
|
+
askSave: '是否保存此模型配置?(Y/n)',
|
|
152
|
+
saveCancelled: '已取消保存。',
|
|
153
|
+
cancelled: '已取消。',
|
|
154
|
+
saved: (name) => `✓ 模型 "${name}" 已保存,正在启动 g ai…`,
|
|
155
|
+
saveError: (msg) => `保存配置失败: ${msg}`,
|
|
156
|
+
selectModelForUse: '请选择要使用的模型:',
|
|
157
|
+
useModel: (name) => `使用模型: ${name}`,
|
|
158
|
+
},
|
|
159
|
+
en: {
|
|
160
|
+
title: 'AI Model Setup Wizard',
|
|
161
|
+
noModelDetected: 'No AI model configured.',
|
|
162
|
+
askSetup: 'Set up now? (Y/n)',
|
|
163
|
+
setupCancelled: 'Setup cancelled. Run `g ui` to add a model in Settings, or run `g ai` again.',
|
|
164
|
+
selectProvider: 'Select an AI provider:',
|
|
165
|
+
customOption: 'Custom',
|
|
166
|
+
selectPrompt: (min, max) => `Choose (${min}-${max}): `,
|
|
167
|
+
invalidChoice: (min, max) => `Invalid choice, enter a number between ${min} and ${max}`,
|
|
168
|
+
endpointLabel: 'Endpoint URL',
|
|
169
|
+
endpointPrompt: (def) => `Endpoint [${def}]: `,
|
|
170
|
+
endpointInvalid: 'Invalid endpoint URL (must be http/https)',
|
|
171
|
+
modelLabel: 'Model name',
|
|
172
|
+
builtinModels: 'Popular models:',
|
|
173
|
+
manualInput: 'Manual input',
|
|
174
|
+
modelPrompt: 'Enter model name: ',
|
|
175
|
+
modelRequired: 'Model name cannot be empty',
|
|
176
|
+
apiKeyLabel: 'API Key',
|
|
177
|
+
apiKeyPrompt: 'API Key (leave empty for local models): ',
|
|
178
|
+
displayNameLabel: 'Display name',
|
|
179
|
+
displayNamePrompt: (def) => `Display name [${def}]: `,
|
|
180
|
+
testing: 'Testing connection…',
|
|
181
|
+
testOk: 'Connection successful',
|
|
182
|
+
testFail: (msg) => `Connection failed: ${msg}`,
|
|
183
|
+
testSkip: 'Skipped test',
|
|
184
|
+
askSave: 'Save this model? (Y/n)',
|
|
185
|
+
saveCancelled: 'Save cancelled.',
|
|
186
|
+
cancelled: 'Cancelled.',
|
|
187
|
+
saved: (name) => `✓ Model "${name}" saved, starting g ai…`,
|
|
188
|
+
saveError: (msg) => `Failed to save: ${msg}`,
|
|
189
|
+
selectModelForUse: 'Select a model to use:',
|
|
190
|
+
useModel: (name) => `Using model: ${name}`,
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function makeStrings(locale) {
|
|
195
|
+
return String(locale || '').startsWith('en') ? STRINGS.en : STRINGS.zh
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ──────────────────────────────────────────────
|
|
199
|
+
// 纯函数(便于单测)
|
|
200
|
+
// ──────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 根据 baseURL 获取内置模型列表。
|
|
204
|
+
* @param {string} baseURL
|
|
205
|
+
* @returns {string[]} 模型名数组(可能为空)
|
|
206
|
+
*/
|
|
207
|
+
export function getBuiltinModels(baseURL) {
|
|
208
|
+
if (!baseURL || typeof baseURL !== 'string') return []
|
|
209
|
+
const normalized = baseURL.replace(/\/$/, '')
|
|
210
|
+
return BUILTIN_MODELS[normalized] || []
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 根据 URL 查找匹配的预设 provider。
|
|
215
|
+
* @param {string} url
|
|
216
|
+
* @returns {object|null} provider 对象或 null
|
|
217
|
+
*/
|
|
218
|
+
export function findProviderByUrl(url) {
|
|
219
|
+
if (!url || typeof url !== 'string') return null
|
|
220
|
+
const normalized = url.replace(/\/$/, '')
|
|
221
|
+
return PROVIDERS.find(p => p.url === normalized) || null
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 验证 endpoint URL 是否合法(http/https)。
|
|
226
|
+
* @param {string} url
|
|
227
|
+
* @returns {boolean}
|
|
228
|
+
*/
|
|
229
|
+
export function validateEndpoint(url) {
|
|
230
|
+
if (!url || typeof url !== 'string') return false
|
|
231
|
+
try {
|
|
232
|
+
const u = new URL(url)
|
|
233
|
+
return u.protocol === 'http:' || u.protocol === 'https:'
|
|
234
|
+
} catch {
|
|
235
|
+
return false
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 构建模型配置对象(与 UI ModelInfo 结构一致)。
|
|
241
|
+
* @param {object} opts
|
|
242
|
+
* @param {string} opts.baseURL
|
|
243
|
+
* @param {string} opts.model
|
|
244
|
+
* @param {string} [opts.apiKey]
|
|
245
|
+
* @param {string} [opts.name] - 显示名,默认用 model
|
|
246
|
+
* @param {boolean} [opts.isDefault]
|
|
247
|
+
* @returns {object} ModelInfo
|
|
248
|
+
*/
|
|
249
|
+
export function buildModelConfig({ baseURL, model, apiKey, name, isDefault }) {
|
|
250
|
+
const finalName = (name && name.trim()) || model
|
|
251
|
+
const id = `model-${finalName.toLowerCase().replace(/[^a-z0-9]/g, '-').slice(0, 24)}-${Date.now().toString(36)}`
|
|
252
|
+
return {
|
|
253
|
+
id,
|
|
254
|
+
name: finalName,
|
|
255
|
+
baseURL: baseURL.replace(/\/$/, ''),
|
|
256
|
+
model,
|
|
257
|
+
apiKey: apiKey || '',
|
|
258
|
+
isDefault: Boolean(isDefault),
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 测试模型连接(OpenAI 兼容 /chat/completions)。
|
|
264
|
+
*
|
|
265
|
+
* 逻辑与 ai-model-form 包 server/middleware.js 的 POST /ai-model/test 一致:
|
|
266
|
+
* - 发送一条 max_tokens=1 的 "hi" 消息
|
|
267
|
+
* - 200 或 400 视为成功(400 可能是参数细节问题但连接是通的)
|
|
268
|
+
* - 401 = API Key 无效, 404 = 模型不存在
|
|
269
|
+
*
|
|
270
|
+
* @param {object} opts
|
|
271
|
+
* @param {string} opts.baseURL
|
|
272
|
+
* @param {string} opts.model
|
|
273
|
+
* @param {string} [opts.apiKey]
|
|
274
|
+
* @param {number} [opts.timeoutMs=8000]
|
|
275
|
+
* @param {typeof fetch} [opts.fetchFn=globalThis.fetch] - 可注入 fetch(测试用)
|
|
276
|
+
* @returns {Promise<{ok: boolean, message: string, status?: number}>}
|
|
277
|
+
*/
|
|
278
|
+
export async function testModelConnection({ baseURL, model, apiKey, timeoutMs = 8000, fetchFn } = {}) {
|
|
279
|
+
const fetch = fetchFn || globalThis.fetch
|
|
280
|
+
if (!fetch) throw new Error('fetch is not available')
|
|
281
|
+
|
|
282
|
+
const url = `${String(baseURL || '').replace(/\/$/, '')}/chat/completions`
|
|
283
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
284
|
+
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
|
285
|
+
|
|
286
|
+
const body = JSON.stringify({
|
|
287
|
+
model,
|
|
288
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
289
|
+
max_tokens: 1,
|
|
290
|
+
stream: false,
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
const controller = new AbortController()
|
|
294
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
295
|
+
try {
|
|
296
|
+
const res = await fetch(url, {
|
|
297
|
+
method: 'POST',
|
|
298
|
+
headers,
|
|
299
|
+
body,
|
|
300
|
+
signal: controller.signal,
|
|
301
|
+
})
|
|
302
|
+
if (res.ok || res.status === 400) {
|
|
303
|
+
return { ok: true, message: 'OK', status: res.status }
|
|
304
|
+
}
|
|
305
|
+
if (res.status === 401) {
|
|
306
|
+
return { ok: false, message: 'API Key 无效或未授权 (401)', status: 401 }
|
|
307
|
+
}
|
|
308
|
+
if (res.status === 404) {
|
|
309
|
+
return { ok: false, message: `模型 "${model}" 不存在 (404)`, status: 404 }
|
|
310
|
+
}
|
|
311
|
+
return { ok: false, message: `服务器返回错误: ${res.status}`, status: res.status }
|
|
312
|
+
} catch (err) {
|
|
313
|
+
if (err?.name === 'AbortError') {
|
|
314
|
+
return { ok: false, message: `连接超时 (>${Math.round(timeoutMs / 1000)}s)` }
|
|
315
|
+
}
|
|
316
|
+
return { ok: false, message: `连接失败: ${err?.message || String(err)}` }
|
|
317
|
+
} finally {
|
|
318
|
+
clearTimeout(timer)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ──────────────────────────────────────────────
|
|
323
|
+
// readline 交互辅助
|
|
324
|
+
// ──────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* 创建一个基于 readline 的问答辅助对象。
|
|
328
|
+
* @param {import('node:readline').Interface} rl
|
|
329
|
+
* @returns {{ ask: (prompt: string, defaultValue?: string) => Promise<string> }}
|
|
330
|
+
*/
|
|
331
|
+
function createAsker(rl) {
|
|
332
|
+
/**
|
|
333
|
+
* 提问并等待用户输入。
|
|
334
|
+
* - 空输入时返回 defaultValue(如果提供了)
|
|
335
|
+
* - Ctrl+C / Ctrl+D 时抛出 { cancelled: true }
|
|
336
|
+
*/
|
|
337
|
+
function ask(prompt, defaultValue) {
|
|
338
|
+
return new Promise((resolve, reject) => {
|
|
339
|
+
const display = defaultValue != null && defaultValue !== ''
|
|
340
|
+
? chalk.cyan(prompt) + chalk.dim(` (${defaultValue})`)
|
|
341
|
+
: chalk.cyan(prompt)
|
|
342
|
+
rl.question(display + ' ', (answer) => {
|
|
343
|
+
const trimmed = (answer || '').trim()
|
|
344
|
+
if (trimmed === '' && defaultValue != null && defaultValue !== '') {
|
|
345
|
+
resolve(defaultValue)
|
|
346
|
+
} else {
|
|
347
|
+
resolve(trimmed)
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
// Ctrl+C / Ctrl+D: 用户取消
|
|
351
|
+
rl.once('close', () => {
|
|
352
|
+
reject({ cancelled: true })
|
|
353
|
+
})
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
return { ask }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* 询问 Yes/No,默认 Yes。
|
|
361
|
+
* @returns {Promise<boolean>} true = yes
|
|
362
|
+
*/
|
|
363
|
+
async function askYesNo(asker, prompt) {
|
|
364
|
+
const answer = await asker.ask(prompt, 'y')
|
|
365
|
+
const lower = answer.toLowerCase()
|
|
366
|
+
return lower === '' || lower === 'y' || lower === 'yes'
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* 让用户从数字列表中选择一项。
|
|
371
|
+
* @param {object} asker
|
|
372
|
+
* @param {string} title - 列表标题
|
|
373
|
+
* @param {Array<{label: string, value: any}>} items
|
|
374
|
+
* @param {object} t - i18n strings
|
|
375
|
+
* @param {number} [extraOption] - 额外选项的序号(如"手动输入"用 0)
|
|
376
|
+
* @param {string} [extraLabel]
|
|
377
|
+
* @returns {Promise<{index: number, value: any}|null>} 选中的项,null = 用户选了额外选项
|
|
378
|
+
*/
|
|
379
|
+
async function selectFromList(asker, title, items, t, extraOptionLabel) {
|
|
380
|
+
console.log()
|
|
381
|
+
console.log(chalk.bold(title))
|
|
382
|
+
items.forEach((item, i) => {
|
|
383
|
+
console.log(` ${chalk.cyan(String(i + 1).padStart(2))}. ${item.label}`)
|
|
384
|
+
})
|
|
385
|
+
if (extraOptionLabel) {
|
|
386
|
+
console.log(` ${chalk.dim('0')}. ${chalk.dim(extraOptionLabel)}`)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const min = extraOptionLabel ? 0 : 1
|
|
390
|
+
const max = items.length
|
|
391
|
+
while (true) {
|
|
392
|
+
const answer = await asker.ask(t.selectPrompt(min, max))
|
|
393
|
+
const idx = Number.parseInt(answer, 10)
|
|
394
|
+
if (Number.isFinite(idx) && idx >= min && idx <= max) {
|
|
395
|
+
if (idx === 0) return null // 额外选项
|
|
396
|
+
return { index: idx - 1, value: items[idx - 1].value }
|
|
397
|
+
}
|
|
398
|
+
console.log(chalk.yellow(t.invalidChoice(min, max)))
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ──────────────────────────────────────────────
|
|
403
|
+
// 交互式配置主流程
|
|
404
|
+
// ──────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* 交互式收集单个模型的配置(服务商 → 接口地址 → 模型名 → API Key → 显示名 → 测试连接)。
|
|
408
|
+
*
|
|
409
|
+
* 这是 runModelSetup(首次配置向导)与 agent.js 的 /addmodel 斜杠命令共用的核心交互逻辑。
|
|
410
|
+
* 本函数只负责"收集 + 测试",不负责保存 —— 调用方拿到返回值后自行 buildModelConfig +
|
|
411
|
+
* 持久化,以便在不同场景下灵活控制 isDefault / 状态更新等后续行为。
|
|
412
|
+
*
|
|
413
|
+
* @param {object} opts
|
|
414
|
+
* @param {string} [opts.locale='zh-CN']
|
|
415
|
+
* @param {import('node:readline').Interface} [opts.rl] - 可注入 readline(测试用 / REPL 复用)
|
|
416
|
+
* @param {typeof fetch} [opts.fetchFn] - 可注入 fetch(测试用)
|
|
417
|
+
* @param {string} [opts.cancelMessage] - 用户取消(Ctrl+C/Ctrl+D)时打印的提示文案,
|
|
418
|
+
* 默认用通用的 t.cancelled;首次配置向导可传入 t.setupCancelled 以保留原有提示
|
|
419
|
+
* @returns {Promise<{baseURL: string, model: string, apiKey: string, displayName: string}|null>}
|
|
420
|
+
* 成功返回收集到的字段;用户取消或测试失败后选择不保存时返回 null(已打印相应提示)
|
|
421
|
+
*/
|
|
422
|
+
export async function collectModelInput({ locale = 'zh-CN', rl: injectedRl, fetchFn, cancelMessage } = {}) {
|
|
423
|
+
const t = makeStrings(locale)
|
|
424
|
+
const ownRl = !injectedRl
|
|
425
|
+
const rl = injectedRl || readline.createInterface({
|
|
426
|
+
input: process.stdin,
|
|
427
|
+
output: process.stdout,
|
|
428
|
+
})
|
|
429
|
+
const asker = createAsker(rl)
|
|
430
|
+
const cancelMsg = cancelMessage || t.cancelled
|
|
431
|
+
|
|
432
|
+
try {
|
|
433
|
+
// 1. 选择服务商
|
|
434
|
+
const providerItems = PROVIDERS.map(p => ({
|
|
435
|
+
label: `${p.label} ${chalk.dim(p.url)}`,
|
|
436
|
+
value: p,
|
|
437
|
+
}))
|
|
438
|
+
const providerChoice = await selectFromList(asker, t.selectProvider, providerItems, t, t.customOption)
|
|
439
|
+
|
|
440
|
+
let baseURL
|
|
441
|
+
if (providerChoice) {
|
|
442
|
+
baseURL = providerChoice.value.url
|
|
443
|
+
} else {
|
|
444
|
+
// 自定义:手动输入 baseURL
|
|
445
|
+
while (true) {
|
|
446
|
+
baseURL = await asker.ask(t.endpointPrompt('https://...'))
|
|
447
|
+
if (validateEndpoint(baseURL)) break
|
|
448
|
+
console.log(chalk.red(t.endpointInvalid))
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// 2. 确认/输入接口地址(选了预设的也允许用户修改)
|
|
453
|
+
const confirmedBaseURL = await asker.ask(t.endpointPrompt(baseURL), baseURL)
|
|
454
|
+
if (!validateEndpoint(confirmedBaseURL)) {
|
|
455
|
+
console.log(chalk.red(t.endpointInvalid))
|
|
456
|
+
// 再给一次机会
|
|
457
|
+
while (true) {
|
|
458
|
+
const retry = await asker.ask(t.endpointPrompt('https://...'))
|
|
459
|
+
if (validateEndpoint(retry)) { baseURL = retry; break }
|
|
460
|
+
console.log(chalk.red(t.endpointInvalid))
|
|
461
|
+
}
|
|
462
|
+
} else {
|
|
463
|
+
baseURL = confirmedBaseURL
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// 3. 选择/输入模型名称
|
|
467
|
+
const builtin = getBuiltinModels(baseURL)
|
|
468
|
+
let model
|
|
469
|
+
if (builtin.length > 0) {
|
|
470
|
+
const modelItems = builtin.map(m => ({ label: m, value: m }))
|
|
471
|
+
const modelChoice = await selectFromList(asker, t.builtinModels, modelItems, t, t.manualInput)
|
|
472
|
+
if (modelChoice) {
|
|
473
|
+
model = modelChoice.value
|
|
474
|
+
} else {
|
|
475
|
+
// 手动输入
|
|
476
|
+
while (true) {
|
|
477
|
+
model = await asker.ask(t.modelPrompt)
|
|
478
|
+
if (model) break
|
|
479
|
+
console.log(chalk.yellow(t.modelRequired))
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
} else {
|
|
483
|
+
// 没有预设模型,直接手动输入
|
|
484
|
+
while (true) {
|
|
485
|
+
model = await asker.ask(t.modelPrompt)
|
|
486
|
+
if (model) break
|
|
487
|
+
console.log(chalk.yellow(t.modelRequired))
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// 4. 输入 API Key
|
|
492
|
+
const apiKey = await asker.ask(t.apiKeyPrompt)
|
|
493
|
+
|
|
494
|
+
// 5. 输入显示名称(默认用 model 名)
|
|
495
|
+
const displayName = await asker.ask(t.displayNamePrompt(model), model)
|
|
496
|
+
|
|
497
|
+
// 6. 测试连接
|
|
498
|
+
console.log()
|
|
499
|
+
const spinner = startSpinner(t.testing)
|
|
500
|
+
const testResult = await testModelConnection({ baseURL, model, apiKey, fetchFn })
|
|
501
|
+
spinner.stop()
|
|
502
|
+
|
|
503
|
+
if (testResult.ok) {
|
|
504
|
+
console.log(chalk.green(`✓ ${t.testOk}`))
|
|
505
|
+
} else {
|
|
506
|
+
console.log(chalk.yellow(`⚠ ${t.testFail(testResult.message)}`))
|
|
507
|
+
const proceed = await askYesNo(asker, t.askSave)
|
|
508
|
+
if (!proceed) {
|
|
509
|
+
console.log(chalk.dim(t.saveCancelled))
|
|
510
|
+
return null
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return { baseURL, model, apiKey, displayName }
|
|
515
|
+
} catch (err) {
|
|
516
|
+
// 用户 Ctrl+C / Ctrl+D 取消
|
|
517
|
+
if (err && err.cancelled) {
|
|
518
|
+
console.log(chalk.dim('\n' + cancelMsg))
|
|
519
|
+
return null
|
|
520
|
+
}
|
|
521
|
+
throw err
|
|
522
|
+
} finally {
|
|
523
|
+
if (ownRl) rl.close()
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* 运行交互式模型配置向导。
|
|
529
|
+
*
|
|
530
|
+
* 当 models 为空时由 agent.js 调用。完成后将新模型保存到配置文件并返回,
|
|
531
|
+
* 调用方可直接用返回的 models / model 启动 agent 会话。
|
|
532
|
+
*
|
|
533
|
+
* @param {object} opts
|
|
534
|
+
* @param {string} [opts.locale='zh-CN']
|
|
535
|
+
* @param {import('node:readline').Interface} [opts.rl] - 可注入 readline(测试用)
|
|
536
|
+
* @param {typeof fetch} [opts.fetchFn] - 可注入 fetch(测试用)
|
|
537
|
+
* @returns {Promise<{models: object[], model: object}|null>}
|
|
538
|
+
* 成功返回 { models, model };用户取消返回 null
|
|
539
|
+
*/
|
|
540
|
+
export async function runModelSetup({ locale = 'zh-CN', rl: injectedRl, fetchFn } = {}) {
|
|
541
|
+
const t = makeStrings(locale)
|
|
542
|
+
const ownRl = !injectedRl
|
|
543
|
+
const rl = injectedRl || readline.createInterface({
|
|
544
|
+
input: process.stdin,
|
|
545
|
+
output: process.stdout,
|
|
546
|
+
})
|
|
547
|
+
const asker = createAsker(rl)
|
|
548
|
+
|
|
549
|
+
try {
|
|
550
|
+
// 1. 询问是否配置
|
|
551
|
+
console.log()
|
|
552
|
+
console.log(chalk.yellow(t.noModelDetected))
|
|
553
|
+
const wantSetup = await askYesNo(asker, t.askSetup)
|
|
554
|
+
if (!wantSetup) {
|
|
555
|
+
console.log(chalk.dim(t.setupCancelled))
|
|
556
|
+
return null
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// 2-6. 交互式收集模型配置(复用 collectModelInput)
|
|
560
|
+
const collected = await collectModelInput({ locale, rl, fetchFn, cancelMessage: t.setupCancelled })
|
|
561
|
+
if (!collected) return null
|
|
562
|
+
|
|
563
|
+
// 7. 保存配置(测试成功直接保存;失败时 collectModelInput 内已确认过)
|
|
564
|
+
// 加载现有配置,添加新模型
|
|
565
|
+
const { baseURL, model, apiKey, displayName } = collected
|
|
566
|
+
const cfg = await config.loadConfig()
|
|
567
|
+
const models = Array.isArray(cfg.models) ? cfg.models : []
|
|
568
|
+
const isFirst = models.length === 0
|
|
569
|
+
const newModel = buildModelConfig({ baseURL, model, apiKey, name: displayName, isDefault: isFirst })
|
|
570
|
+
models.push(newModel)
|
|
571
|
+
cfg.models = models
|
|
572
|
+
|
|
573
|
+
try {
|
|
574
|
+
await config.saveConfig(cfg)
|
|
575
|
+
} catch (err) {
|
|
576
|
+
console.log(chalk.red(t.saveError(err.message)))
|
|
577
|
+
return null
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
console.log(chalk.green(t.saved(displayName || model)))
|
|
581
|
+
return { models, model: newModel }
|
|
582
|
+
} catch (err) {
|
|
583
|
+
// 用户 Ctrl+C 取消(askSetup 阶段;collectModelInput 内部的取消已自行处理)
|
|
584
|
+
if (err && err.cancelled) {
|
|
585
|
+
console.log(chalk.dim('\n' + t.setupCancelled))
|
|
586
|
+
return null
|
|
587
|
+
}
|
|
588
|
+
throw err
|
|
589
|
+
} finally {
|
|
590
|
+
if (ownRl) rl.close()
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export default { runModelSetup, collectModelInput, testModelConnection, getBuiltinModels, findProviderByUrl, validateEndpoint, buildModelConfig, PROVIDERS, BUILTIN_MODELS }
|