openclawapi 1.0.0 → 1.1.1

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.
Files changed (4) hide show
  1. package/README.md +120 -26
  2. package/cli.js +1243 -104
  3. package/lib/config-manager.js +405 -54
  4. package/package.json +3 -2
package/cli.js CHANGED
@@ -2,35 +2,249 @@
2
2
 
3
3
  const inquirer = require('inquirer');
4
4
  const chalk = require('chalk');
5
+ const fs = require('fs');
6
+ const JSON5 = require('json5');
5
7
  const path = require('path');
6
8
  const os = require('os');
7
- const { ConfigManager } = require('./lib/config-manager');
8
- const { displayMenu, displaySuccess, displayError, displayInfo } = require('./lib/ui');
9
+ const { ConfigManager, getDefaultWorkspace } = require('./lib/config-manager');
10
+ const { displayMenu, displaySuccess, displayError, displayInfo, displayWarning } = require('./lib/ui');
9
11
  const { testMultipleRelays, sortBySpeed, formatLatency } = require('./lib/speed-test');
10
12
 
13
+ const RELAY_ENDPOINT_PRESETS = {
14
+ 'yunyi-claude': [
15
+ { name: '国内节点1', url: 'https://yunyi.rdzhvip.com' },
16
+ { name: '国内节点2', url: 'https://yunyi.skem.cn' },
17
+ { name: 'CF国外节点1', url: 'https://yunyi.cfd' },
18
+ { name: 'CF国外节点2', url: 'https://cdn1.yunyi.cfd' },
19
+ { name: 'CF国外节点3', url: 'https://cdn2.yunyi.cfd' },
20
+ { name: '备用节点1', url: 'http://47.99.42.193' },
21
+ { name: '备用节点2', url: 'http://47.97.100.10' }
22
+ ],
23
+ 'yunyi-codex': [
24
+ { name: '国内节点1', url: 'https://yunyi.rdzhvip.com' },
25
+ { name: '国内节点2', url: 'https://yunyi.skem.cn' },
26
+ { name: 'CF国外节点1', url: 'https://yunyi.cfd' },
27
+ { name: 'CF国外节点2', url: 'https://cdn1.yunyi.cfd' },
28
+ { name: 'CF国外节点3', url: 'https://cdn2.yunyi.cfd' },
29
+ { name: '备用节点1', url: 'http://47.99.42.193' },
30
+ { name: '备用节点2', url: 'http://47.97.100.10' }
31
+ ]
32
+ };
33
+
34
+ const MODEL_PRESETS = {
35
+ 'yunyi-claude': [
36
+ { id: 'claude-opus-4-5', name: 'Claude Opus 4.5' },
37
+ { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5' },
38
+ { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' }
39
+ ],
40
+ 'yunyi-codex': [
41
+ { id: 'gpt-5.2', name: 'GPT 5.2' },
42
+ { id: 'gpt-5.2-codex', name: 'GPT 5.2 Codex' }
43
+ ]
44
+ };
45
+
46
+ function normalizeRelayKey(relayName) {
47
+ if (!relayName) return '';
48
+ if (relayName.startsWith('yunyi-claude')) return 'yunyi-claude';
49
+ if (relayName.startsWith('yunyi-codex')) return 'yunyi-codex';
50
+ return relayName;
51
+ }
52
+
53
+ function getRelaySuffix(relayName) {
54
+ const key = normalizeRelayKey(relayName);
55
+ if (key === 'yunyi-claude') return '/claude/v1/messages';
56
+ if (key === 'yunyi-codex') return '/codex/response';
57
+ return '';
58
+ }
59
+
60
+ function buildRelayBaseUrl(baseUrl, relayName) {
61
+ const suffix = getRelaySuffix(relayName);
62
+ if (!suffix) return baseUrl;
63
+ if (!baseUrl) return baseUrl;
64
+ const trimmed = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
65
+
66
+ if (suffix === '/claude/v1/messages') {
67
+ if (/\/claude\/v1\/messages$/.test(trimmed) || /\/claude\/v1\/message$/.test(trimmed)) {
68
+ return trimmed;
69
+ }
70
+ if (/\/claude\/v1$/.test(trimmed)) {
71
+ return `${trimmed}/messages`;
72
+ }
73
+ if (/\/claude$/.test(trimmed)) {
74
+ return `${trimmed}/v1/messages`;
75
+ }
76
+ }
77
+
78
+ if (suffix === '/codex/response') {
79
+ if (/\/codex\/response$/.test(trimmed)) {
80
+ return trimmed;
81
+ }
82
+ if (/\/codex$/.test(trimmed)) {
83
+ return `${trimmed}/response`;
84
+ }
85
+ }
86
+
87
+ if (/\/claude(\/|$)/.test(trimmed) || /\/codex(\/|$)/.test(trimmed)) {
88
+ return trimmed;
89
+ }
90
+
91
+ return `${trimmed}${suffix}`;
92
+ }
93
+
94
+ function getRelayEndpoints(relayName) {
95
+ return RELAY_ENDPOINT_PRESETS[normalizeRelayKey(relayName)] || [];
96
+ }
97
+
98
+ function getRelayModelPresets(relayName) {
99
+ return MODEL_PRESETS[normalizeRelayKey(relayName)] || [];
100
+ }
101
+
102
+ function buildModelEntry(model, apiType) {
103
+ const defaults = apiType === 'openai-responses'
104
+ ? { contextWindow: 128000, maxTokens: 32768, reasoning: true, input: ['text', 'image'] }
105
+ : { contextWindow: 200000, maxTokens: 8192, reasoning: false, input: ['text'] };
106
+
107
+ return {
108
+ id: model.id,
109
+ name: model.name || model.id,
110
+ reasoning: defaults.reasoning,
111
+ input: defaults.input,
112
+ cost: {
113
+ input: 0,
114
+ output: 0,
115
+ cacheRead: 0,
116
+ cacheWrite: 0
117
+ },
118
+ contextWindow: defaults.contextWindow,
119
+ maxTokens: defaults.maxTokens
120
+ };
121
+ }
122
+
11
123
  // 获取配置文件路径(跨平台)
12
124
  function getConfigPath() {
13
125
  const homeDir = os.homedir();
14
- const configDir = path.join(homeDir, '.clawdbot');
126
+ const openclawStateDir = process.env.OPENCLAW_STATE_DIR || path.join(homeDir, '.openclaw');
127
+ const clawdbotStateDir = process.env.CLAWDBOT_STATE_DIR || path.join(homeDir, '.clawdbot');
128
+
129
+ const envConfig =
130
+ process.env.OPENCLAW_CONFIG_PATH ||
131
+ process.env.CLAWDBOT_CONFIG_PATH ||
132
+ process.env.OPENCLAW_CONFIG;
133
+
134
+ const openclawConfig = envConfig || ([
135
+ path.join(openclawStateDir, 'openclaw.json'),
136
+ path.join(clawdbotStateDir, 'openclaw.json'),
137
+ path.join(clawdbotStateDir, 'clawdbot.json'),
138
+ path.join(clawdbotStateDir, 'moltbot.json')
139
+ ].find(p => fs.existsSync(p)) || path.join(openclawStateDir, 'openclaw.json'));
140
+
141
+ const configDir = path.dirname(openclawConfig);
142
+ const authProfiles = resolveAuthProfilesPath({
143
+ configDir,
144
+ openclawStateDir,
145
+ clawdbotStateDir
146
+ });
147
+
15
148
  return {
16
- openclawConfig: path.join(configDir, 'openclaw.json'),
17
- authProfiles: path.join(configDir, 'agents', 'main', 'agent', 'auth-profiles.json')
149
+ openclawConfig,
150
+ authProfiles
18
151
  };
19
152
  }
20
153
 
154
+ function resolveAuthProfilesPath({ configDir, openclawStateDir, clawdbotStateDir }) {
155
+ const envAgentDir =
156
+ process.env.OPENCLAW_AGENT_DIR ||
157
+ process.env.CLAWDBOT_AGENT_DIR ||
158
+ process.env.PI_CODING_AGENT_DIR;
159
+ const envAgentName =
160
+ process.env.OPENCLAW_AGENT ||
161
+ process.env.CLAWDBOT_AGENT ||
162
+ process.env.PI_CODING_AGENT;
163
+
164
+ const candidates = [];
165
+
166
+ if (envAgentDir) {
167
+ candidates.push(path.join(envAgentDir, 'auth-profiles.json'));
168
+ }
169
+
170
+ if (envAgentName) {
171
+ candidates.push(path.join(openclawStateDir, 'agents', envAgentName, 'agent', 'auth-profiles.json'));
172
+ candidates.push(path.join(clawdbotStateDir, 'agents', envAgentName, 'agent', 'auth-profiles.json'));
173
+ }
174
+
175
+ // OpenClaw default + legacy paths
176
+ candidates.push(path.join(openclawStateDir, 'agents', 'main', 'agent', 'auth-profiles.json'));
177
+ candidates.push(path.join(openclawStateDir, 'agent', 'auth-profiles.json'));
178
+
179
+ // Clawdbot default + legacy paths
180
+ candidates.push(path.join(clawdbotStateDir, 'agent', 'auth-profiles.json'));
181
+ candidates.push(path.join(clawdbotStateDir, 'agents', 'main', 'agent', 'auth-profiles.json'));
182
+
183
+ // Fallback to config directory if user stores alongside config
184
+ candidates.push(path.join(configDir, 'agent', 'auth-profiles.json'));
185
+ candidates.push(path.join(configDir, 'agents', 'main', 'agent', 'auth-profiles.json'));
186
+
187
+ const inferred = inferSingleAgentAuthPath(openclawStateDir, clawdbotStateDir);
188
+ if (inferred) {
189
+ candidates.unshift(inferred);
190
+ }
191
+
192
+ return candidates.find(p => fs.existsSync(p)) || candidates[0];
193
+ }
194
+
195
+ function inferSingleAgentAuthPath(openclawStateDir, clawdbotStateDir) {
196
+ const openclawAgentsDir = path.join(openclawStateDir, 'agents');
197
+ if (fs.existsSync(openclawAgentsDir)) {
198
+ const dirs = fs.readdirSync(openclawAgentsDir, { withFileTypes: true })
199
+ .filter(entry => entry.isDirectory())
200
+ .map(entry => entry.name);
201
+ if (dirs.length === 1) {
202
+ return path.join(openclawAgentsDir, dirs[0], 'agent', 'auth-profiles.json');
203
+ }
204
+ }
205
+
206
+ const clawdbotAgentsDir = path.join(clawdbotStateDir, 'agents');
207
+ if (fs.existsSync(clawdbotAgentsDir)) {
208
+ const dirs = fs.readdirSync(clawdbotAgentsDir, { withFileTypes: true })
209
+ .filter(entry => entry.isDirectory())
210
+ .map(entry => entry.name);
211
+ if (dirs.length === 1) {
212
+ return path.join(clawdbotAgentsDir, dirs[0], 'agent', 'auth-profiles.json');
213
+ }
214
+ }
215
+
216
+ return '';
217
+ }
218
+
21
219
  async function main() {
22
220
  console.clear();
23
221
  console.log(chalk.cyan.bold('\n🔧 OpenClaw 配置管理工具\n'));
24
222
 
223
+ // 显示平台信息
224
+ const platformInfo = {
225
+ win32: 'Windows',
226
+ darwin: 'macOS',
227
+ linux: 'Linux'
228
+ };
229
+ console.log(chalk.gray(`平台: ${platformInfo[process.platform] || process.platform}`));
25
230
  const configPaths = getConfigPath();
231
+ console.log(chalk.gray(`配置目录: ${path.dirname(configPaths.openclawConfig)}`));
232
+ console.log(chalk.gray(`配置文件: ${configPaths.openclawConfig}\n`));
233
+ console.log(chalk.gray(`认证文件: ${configPaths.authProfiles}\n`));
26
234
  const configManager = new ConfigManager(configPaths);
27
235
 
28
- // 检查配置文件是否存在
236
+ // 检查配置文件是否存在,如果不存在则自动初始化
29
237
  const exists = await configManager.checkConfigExists();
30
238
  if (!exists.openclaw) {
31
- displayError(`配置文件不存在: ${configPaths.openclawConfig}`);
32
- displayInfo('请先运行 clawdbot onboard 初始化配置');
33
- process.exit(1);
239
+ displayInfo('配置文件不存在,正在初始化...');
240
+ try {
241
+ await configManager.initializeConfig();
242
+ displaySuccess('配置文件初始化成功!');
243
+ } catch (error) {
244
+ displayError(`初始化失败: ${error.message}`);
245
+ displayInfo('请检查目录权限或手动创建配置文件');
246
+ process.exit(1);
247
+ }
34
248
  }
35
249
 
36
250
  while (true) {
@@ -40,9 +254,13 @@ async function main() {
40
254
  name: 'action',
41
255
  message: '请选择操作:',
42
256
  choices: [
257
+ { name: '🚀 快速配置向导 (URL/Key/Token/Workspace)', value: 'quicksetup' },
258
+ new inquirer.Separator(),
43
259
  { name: '📡 管理中转站配置', value: 'relay' },
260
+ { name: '🧭 快速切换中转端点', value: 'endpoint' },
44
261
  { name: '🤖 管理模型配置', value: 'model' },
45
- { name: '🔑 管理 API Keys', value: 'apikey' },
262
+ { name: '🧩 快速切换模型', value: 'modelSwitch' },
263
+ { name: '🔑 管理 API Keys / Tokens', value: 'apikey' },
46
264
  { name: '⚡ 测速并切换中转站', value: 'speedtest' },
47
265
  { name: '⚙️ 高级设置', value: 'advanced' },
48
266
  { name: '📄 查看当前配置', value: 'view' },
@@ -59,12 +277,21 @@ async function main() {
59
277
 
60
278
  try {
61
279
  switch (action) {
280
+ case 'quicksetup':
281
+ await quickSetup(configManager);
282
+ break;
62
283
  case 'relay':
63
284
  await manageRelay(configManager);
64
285
  break;
286
+ case 'endpoint':
287
+ await quickSwitchEndpoint(configManager);
288
+ break;
65
289
  case 'model':
66
290
  await manageModel(configManager);
67
291
  break;
292
+ case 'modelSwitch':
293
+ await quickSwitchModel(configManager);
294
+ break;
68
295
  case 'apikey':
69
296
  await manageApiKey(configManager);
70
297
  break;
@@ -86,6 +313,604 @@ async function main() {
86
313
  }
87
314
  }
88
315
 
316
+ // 快速配置向导 - 一次性配置 URL、Key、Token、Workspace
317
+ async function quickSetup(configManager) {
318
+ console.log(chalk.cyan.bold('\n🚀 快速配置向导\n'));
319
+ console.log(chalk.gray('此向导将帮助您快速配置中转站的 URL、API Key、Token 和工作区\n'));
320
+
321
+ const defaultWorkspace = getDefaultWorkspace();
322
+
323
+ const presetChoices = [
324
+ { name: 'Claude (云逸 国内) https://yunyi.skem.cn/claude', value: 'yunyi-claude-cn' },
325
+ { name: 'Claude (云逸 海外) https://yunyi.cfd/claude', value: 'yunyi-claude-global' },
326
+ { name: 'Codex (云逸 国内) https://yunyi.skem.cn/codex', value: 'yunyi-codex-cn' },
327
+ { name: 'Codex (云逸 海外) https://yunyi.cfd/codex', value: 'yunyi-codex-global' },
328
+ { name: '自定义 (其他模型)', value: 'custom' }
329
+ ];
330
+
331
+ const presetMap = {
332
+ 'yunyi-claude-cn': {
333
+ name: 'yunyi-claude',
334
+ baseUrl: 'https://yunyi.skem.cn/claude/v1/messages',
335
+ modelType: 'claude',
336
+ api: 'anthropic-messages',
337
+ defaultModelId: 'claude-opus-4-5',
338
+ compactionMode: 'safeguard'
339
+ },
340
+ 'yunyi-claude-global': {
341
+ name: 'yunyi-claude',
342
+ baseUrl: 'https://yunyi.cfd/claude/v1/messages',
343
+ modelType: 'claude',
344
+ api: 'anthropic-messages',
345
+ defaultModelId: 'claude-opus-4-5',
346
+ compactionMode: 'safeguard'
347
+ },
348
+ 'yunyi-codex-cn': {
349
+ name: 'yunyi-codex',
350
+ baseUrl: 'https://yunyi.skem.cn/codex/response',
351
+ modelType: 'codex',
352
+ api: 'openai-responses',
353
+ defaultModelId: 'gpt-5.2',
354
+ defaultModelName: 'GPT 5.2',
355
+ compactionMode: 'safeguard'
356
+ },
357
+ 'yunyi-codex-global': {
358
+ name: 'yunyi-codex',
359
+ baseUrl: 'https://yunyi.cfd/codex/response',
360
+ modelType: 'codex',
361
+ api: 'openai-responses',
362
+ defaultModelId: 'gpt-5.2',
363
+ defaultModelName: 'GPT 5.2',
364
+ compactionMode: 'safeguard'
365
+ },
366
+ custom: {}
367
+ };
368
+
369
+ const { preset } = await inquirer.prompt([
370
+ { type: 'list', name: 'preset', message: '选择模板:', choices: presetChoices }
371
+ ]);
372
+ const presetConfig = presetMap[preset] || {};
373
+
374
+ // 步骤 1: 中转站名称和 URL
375
+ const step1 = await inquirer.prompt([
376
+ {
377
+ type: 'input',
378
+ name: 'name',
379
+ message: '中转站名称 (例如: my-relay):',
380
+ default: presetConfig.name || 'default-relay',
381
+ validate: (input) => input.trim() !== '' || '名称不能为空'
382
+ },
383
+ {
384
+ type: 'input',
385
+ name: 'baseUrl',
386
+ message: '中转站 URL (例如: https://api.example.com/v1):',
387
+ default: presetConfig.baseUrl || '',
388
+ validate: (input) => {
389
+ try {
390
+ const url = new URL(input);
391
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
392
+ return '请输入有效的 HTTP/HTTPS URL';
393
+ }
394
+ return true;
395
+ } catch {
396
+ return '请输入有效的 URL';
397
+ }
398
+ }
399
+ }
400
+ ]);
401
+
402
+ step1.baseUrl = buildRelayBaseUrl(step1.baseUrl, step1.name);
403
+
404
+ let backupConfig = null;
405
+ const { enableBackup } = await inquirer.prompt([
406
+ {
407
+ type: 'confirm',
408
+ name: 'enableBackup',
409
+ message: '是否添加备用中转端点(用于主备切换)?',
410
+ default: false
411
+ }
412
+ ]);
413
+
414
+ if (enableBackup) {
415
+ const endpoints = getRelayEndpoints(step1.name);
416
+ const fallbackEndpoint = endpoints.find(item => item.url !== step1.baseUrl);
417
+ const backupAnswers = await inquirer.prompt([
418
+ {
419
+ type: 'input',
420
+ name: 'name',
421
+ message: '备用中转站名称:',
422
+ default: `${step1.name}-backup`,
423
+ validate: (input) => input.trim() !== '' || '名称不能为空'
424
+ },
425
+ {
426
+ type: 'input',
427
+ name: 'baseUrl',
428
+ message: '备用端点 Base URL:',
429
+ default: fallbackEndpoint ? fallbackEndpoint.url : '',
430
+ validate: (input) => {
431
+ try {
432
+ const url = new URL(input);
433
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
434
+ return '请输入有效的 HTTP/HTTPS URL';
435
+ }
436
+ return true;
437
+ } catch {
438
+ return '请输入有效的 URL';
439
+ }
440
+ }
441
+ }
442
+ ]);
443
+ backupConfig = {
444
+ name: backupAnswers.name.trim(),
445
+ baseUrl: buildRelayBaseUrl(backupAnswers.baseUrl.trim(), step1.name)
446
+ };
447
+ }
448
+
449
+ // 步骤 2: 模型类型(仅支持 Claude / Codex)
450
+ let modelType = presetConfig.modelType;
451
+ if (!modelType) {
452
+ const result = await inquirer.prompt([
453
+ {
454
+ type: 'list',
455
+ name: 'modelType',
456
+ message: '选择模型类型:',
457
+ choices: [
458
+ { name: 'Claude (Anthropic)', value: 'claude' },
459
+ { name: 'Codex / GPT (OpenAI Responses)', value: 'codex' },
460
+ { name: '其他/自定义', value: 'other' }
461
+ ]
462
+ }
463
+ ]);
464
+ modelType = result.modelType;
465
+ }
466
+
467
+ if (modelType === 'other') {
468
+ displayWarning('非 GPT/Claude 模型仅做配置写入,不保证可用,请优先用 OpenClaw 配置页面。');
469
+ }
470
+
471
+ // 步骤 3: API 类型
472
+ let apiType = presetConfig.api;
473
+ if (!apiType) {
474
+ if (modelType === 'codex') {
475
+ apiType = 'openai-responses';
476
+ } else if (modelType === 'claude') {
477
+ apiType = 'anthropic-messages';
478
+ } else {
479
+ const result = await inquirer.prompt([
480
+ {
481
+ type: 'list',
482
+ name: 'apiType',
483
+ message: 'API 接口类型:',
484
+ choices: [
485
+ { name: 'Anthropic Messages (Claude)', value: 'anthropic-messages' },
486
+ { name: 'OpenAI Responses (Codex / GPT-5+)', value: 'openai-responses' },
487
+ { name: 'OpenAI Completions', value: 'openai-completions' },
488
+ { name: 'Google Generative AI (Gemini)', value: 'google-generative-ai' },
489
+ { name: '自定义', value: 'custom' }
490
+ ]
491
+ }
492
+ ]);
493
+ apiType = result.apiType;
494
+ if (apiType === 'custom') {
495
+ const custom = await inquirer.prompt([
496
+ { type: 'input', name: 'customApi', message: '输入 API 类型:', validate: (i) => i.trim() !== '' || '不能为空' }
497
+ ]);
498
+ apiType = custom.customApi;
499
+ }
500
+ }
501
+ }
502
+
503
+ // 步骤 4: 模型选择
504
+ let modelConfig;
505
+ if (modelType === 'claude') {
506
+ const { modelChoice } = await inquirer.prompt([
507
+ {
508
+ type: 'list',
509
+ name: 'modelChoice',
510
+ message: '选择 Claude 模型:',
511
+ choices: [
512
+ { name: 'Claude Opus 4.5', value: { id: 'claude-opus-4-5', name: 'Claude Opus 4.5' } },
513
+ { name: 'Claude Sonnet 4.5', value: { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5' } },
514
+ { name: 'Claude Haiku 4.5', value: { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' } }
515
+ ],
516
+ default: presetConfig.defaultModelId
517
+ }
518
+ ]);
519
+ modelConfig = modelChoice;
520
+ } else if (modelType === 'codex') {
521
+ const { modelChoice } = await inquirer.prompt([
522
+ {
523
+ type: 'list',
524
+ name: 'modelChoice',
525
+ message: '选择 Codex / GPT 模型:',
526
+ choices: [
527
+ { name: 'GPT 5.2', value: { id: 'gpt-5.2', name: 'GPT 5.2' } },
528
+ { name: 'GPT 5.2 Codex', value: { id: 'gpt-5.2-codex', name: 'GPT 5.2 Codex' } }
529
+ ]
530
+ }
531
+ ]);
532
+ modelConfig = modelChoice;
533
+ } else {
534
+ const custom = await inquirer.prompt([
535
+ { type: 'input', name: 'id', message: '模型 ID:', validate: (i) => i.trim() !== '' || '不能为空' },
536
+ { type: 'input', name: 'name', message: '模型名称:', validate: (i) => i.trim() !== '' || '不能为空' }
537
+ ]);
538
+ modelConfig = { id: custom.id.trim(), name: custom.name.trim() };
539
+ }
540
+
541
+ const includeModelsList = apiType !== 'anthropic-messages';
542
+ let modelEntry = null;
543
+
544
+ if (includeModelsList) {
545
+ const defaults = modelType === 'codex'
546
+ ? { contextWindow: 128000, maxTokens: 32768 }
547
+ : { contextWindow: 200000, maxTokens: 8192 };
548
+ const { contextWindow, maxTokens } = await inquirer.prompt([
549
+ {
550
+ type: 'number',
551
+ name: 'contextWindow',
552
+ message: '上下文窗口大小:',
553
+ default: defaults.contextWindow
554
+ },
555
+ {
556
+ type: 'number',
557
+ name: 'maxTokens',
558
+ message: '最大输出 tokens:',
559
+ default: defaults.maxTokens
560
+ }
561
+ ]);
562
+ modelEntry = {
563
+ id: modelConfig.id,
564
+ name: modelConfig.name,
565
+ reasoning: apiType === 'openai-responses',
566
+ input: apiType === 'openai-responses' ? ['text', 'image'] : ['text'],
567
+ cost: {
568
+ input: 0,
569
+ output: 0,
570
+ cacheRead: 0,
571
+ cacheWrite: 0
572
+ },
573
+ contextWindow,
574
+ maxTokens
575
+ };
576
+ }
577
+
578
+ // 步骤 5: API Key / Token
579
+ const step3 = await inquirer.prompt([
580
+ {
581
+ type: 'password',
582
+ name: 'apiKey',
583
+ message: 'API Key (留空跳过):',
584
+ mask: '*'
585
+ },
586
+ {
587
+ type: 'password',
588
+ name: 'providerToken',
589
+ message: '中转站 Token (留空跳过):',
590
+ mask: '*'
591
+ },
592
+ {
593
+ type: 'password',
594
+ name: 'gatewayToken',
595
+ message: '网关 Token (留空跳过):',
596
+ mask: '*'
597
+ }
598
+ ]);
599
+
600
+ // 步骤 6: 工作区
601
+ const step4 = await inquirer.prompt([
602
+ {
603
+ type: 'input',
604
+ name: 'workspace',
605
+ message: `工作区路径 (留空使用默认):`,
606
+ default: defaultWorkspace
607
+ }
608
+ ]);
609
+
610
+ // 执行配置
611
+ console.log(chalk.cyan('\n正在保存配置...\n'));
612
+
613
+ try {
614
+ // 添加中转站
615
+ await configManager.addRelay({
616
+ name: step1.name,
617
+ baseUrl: step1.baseUrl,
618
+ auth: 'api-key',
619
+ api: apiType,
620
+ headers: {},
621
+ authHeader: false,
622
+ modelsMode: 'merge',
623
+ model: {
624
+ id: modelConfig.id,
625
+ name: modelConfig.name,
626
+ contextWindow: includeModelsList ? modelEntry.contextWindow : undefined,
627
+ maxTokens: includeModelsList ? modelEntry.maxTokens : undefined,
628
+ reasoning: includeModelsList ? modelEntry.reasoning : undefined,
629
+ input: includeModelsList ? modelEntry.input : undefined,
630
+ cost: includeModelsList ? modelEntry.cost : undefined
631
+ },
632
+ models: includeModelsList ? [modelEntry] : []
633
+ });
634
+ console.log(chalk.green(' ✓ 中转站已添加'));
635
+
636
+ // 设置为主模型
637
+ await configManager.setPrimaryModel(step1.name, modelConfig.id);
638
+ console.log(chalk.green(' ✓ 已设为主模型'));
639
+
640
+ if (backupConfig) {
641
+ try {
642
+ await configManager.addRelay({
643
+ name: backupConfig.name,
644
+ baseUrl: backupConfig.baseUrl,
645
+ auth: 'api-key',
646
+ api: apiType,
647
+ headers: {},
648
+ authHeader: false,
649
+ modelsMode: 'merge',
650
+ model: {
651
+ id: modelConfig.id,
652
+ name: modelConfig.name,
653
+ contextWindow: includeModelsList ? modelEntry.contextWindow : undefined,
654
+ maxTokens: includeModelsList ? modelEntry.maxTokens : undefined,
655
+ reasoning: includeModelsList ? modelEntry.reasoning : undefined,
656
+ input: includeModelsList ? modelEntry.input : undefined,
657
+ cost: includeModelsList ? modelEntry.cost : undefined
658
+ },
659
+ models: includeModelsList ? [modelEntry] : []
660
+ });
661
+ console.log(chalk.green(' ✓ 备用中转站已添加'));
662
+
663
+ const existingFallbacks = await configManager.getFallbackModels();
664
+ const fallbackKey = `${backupConfig.name}/${modelConfig.id}`;
665
+ const mergedFallbacks = Array.from(new Set([...existingFallbacks, fallbackKey]));
666
+ await configManager.setFallbackModels(mergedFallbacks);
667
+ console.log(chalk.green(' ✓ 已设置备用模型'));
668
+
669
+ if (step3.apiKey && step3.apiKey.trim() !== '') {
670
+ const { useSameKey } = await inquirer.prompt([
671
+ {
672
+ type: 'confirm',
673
+ name: 'useSameKey',
674
+ message: '是否给备用中转站使用相同 API Key?',
675
+ default: true
676
+ }
677
+ ]);
678
+ if (useSameKey) {
679
+ await configManager.setApiKey(backupConfig.name, step3.apiKey);
680
+ console.log(chalk.green(' ✓ 备用中转站 API Key 已保存'));
681
+ } else {
682
+ const { backupApiKey } = await inquirer.prompt([
683
+ {
684
+ type: 'password',
685
+ name: 'backupApiKey',
686
+ message: '输入备用中转站 API Key:',
687
+ mask: '*',
688
+ validate: (input) => input.trim() !== '' || 'API Key 不能为空'
689
+ }
690
+ ]);
691
+ await configManager.setApiKey(backupConfig.name, backupApiKey);
692
+ console.log(chalk.green(' ✓ 备用中转站 API Key 已保存'));
693
+ }
694
+ }
695
+ } catch (error) {
696
+ displayWarning(`备用中转站添加失败: ${error.message}`);
697
+ }
698
+ }
699
+
700
+ // 保存 API Key
701
+ if (step3.apiKey && step3.apiKey.trim() !== '') {
702
+ await configManager.setApiKey(step1.name, step3.apiKey);
703
+ console.log(chalk.green(' ✓ API Key 已保存'));
704
+ }
705
+
706
+ // 保存中转站 Token
707
+ if (step3.providerToken && step3.providerToken.trim() !== '') {
708
+ await configManager.setToken(step1.name, step3.providerToken);
709
+ console.log(chalk.green(' ✓ 中转站 Token 已保存'));
710
+ }
711
+
712
+ // 设置工作区 / 网关 Token / 压缩模式
713
+ await configManager.setAdvancedSettings({
714
+ workspace: step4.workspace || defaultWorkspace,
715
+ compactionMode: presetConfig.compactionMode,
716
+ gatewayToken: step3.gatewayToken
717
+ });
718
+ console.log(chalk.green(' ✓ 高级设置已更新'));
719
+
720
+ displaySuccess('\n🎉 配置完成!您现在可以开始使用了。');
721
+
722
+ // 显示配置摘要
723
+ console.log(chalk.cyan('\n配置摘要:'));
724
+ console.log(` 中转站: ${step1.name}`);
725
+ console.log(` URL: ${step1.baseUrl}`);
726
+ console.log(` 模型: ${modelConfig.name}`);
727
+ console.log(` API: ${apiType}`);
728
+ console.log(` API Key: ${step3.apiKey ? chalk.green('已设置') : chalk.gray('未设置')}`);
729
+ console.log(` 网关 Token: ${step3.gatewayToken ? chalk.green('已设置') : chalk.gray('未设置')}`);
730
+ console.log(` 工作区: ${step4.workspace || defaultWorkspace}`);
731
+
732
+ } catch (error) {
733
+ displayError(`配置失败: ${error.message}`);
734
+ }
735
+ }
736
+
737
+ // 快速切换中转端点
738
+ async function quickSwitchEndpoint(configManager) {
739
+ const relays = await configManager.listRelays();
740
+ if (relays.length === 0) {
741
+ displayInfo('请先添加中转站');
742
+ return;
743
+ }
744
+
745
+ const { relayName } = await inquirer.prompt([
746
+ {
747
+ type: 'list',
748
+ name: 'relayName',
749
+ message: '选择要切换端点的中转站:',
750
+ choices: relays.map(r => ({ name: `${r.name} (${r.baseUrl})`, value: r.name }))
751
+ }
752
+ ]);
753
+
754
+ const relay = relays.find(r => r.name === relayName);
755
+ const endpoints = getRelayEndpoints(relayName);
756
+ let selectedUrl = '';
757
+
758
+ if (endpoints.length > 0) {
759
+ const { endpoint } = await inquirer.prompt([
760
+ {
761
+ type: 'list',
762
+ name: 'endpoint',
763
+ message: '选择接入端点:',
764
+ choices: [
765
+ ...endpoints.map(item => ({ name: item.name, value: item.url })),
766
+ { name: '自定义', value: '__custom__' }
767
+ ]
768
+ }
769
+ ]);
770
+
771
+ if (endpoint === '__custom__') {
772
+ const custom = await inquirer.prompt([
773
+ {
774
+ type: 'input',
775
+ name: 'url',
776
+ message: '输入 Base URL:',
777
+ validate: (input) => {
778
+ try {
779
+ const url = new URL(input);
780
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
781
+ return '请输入有效的 HTTP/HTTPS URL';
782
+ }
783
+ return true;
784
+ } catch {
785
+ return '请输入有效的 URL';
786
+ }
787
+ }
788
+ }
789
+ ]);
790
+ selectedUrl = custom.url;
791
+ } else {
792
+ selectedUrl = endpoint;
793
+ }
794
+ } else {
795
+ const custom = await inquirer.prompt([
796
+ {
797
+ type: 'input',
798
+ name: 'url',
799
+ message: '输入 Base URL:',
800
+ default: relay.baseUrl,
801
+ validate: (input) => {
802
+ try {
803
+ const url = new URL(input);
804
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
805
+ return '请输入有效的 HTTP/HTTPS URL';
806
+ }
807
+ return true;
808
+ } catch {
809
+ return '请输入有效的 URL';
810
+ }
811
+ }
812
+ }
813
+ ]);
814
+ selectedUrl = custom.url;
815
+ }
816
+
817
+ const finalUrl = buildRelayBaseUrl(selectedUrl, relayName);
818
+ if (finalUrl && finalUrl !== relay.baseUrl) {
819
+ await configManager.updateRelay(relayName, { baseUrl: finalUrl });
820
+ displaySuccess(`✅ 已切换 "${relayName}" 的端点`);
821
+ } else {
822
+ displayInfo('端点未变化');
823
+ }
824
+ }
825
+
826
+ // 快速切换模型
827
+ async function quickSwitchModel(configManager) {
828
+ const relays = await configManager.listRelays();
829
+ if (relays.length === 0) {
830
+ displayInfo('请先添加中转站');
831
+ return;
832
+ }
833
+
834
+ const { relayName } = await inquirer.prompt([
835
+ {
836
+ type: 'list',
837
+ name: 'relayName',
838
+ message: '选择要切换模型的中转站:',
839
+ choices: relays.map(r => ({ name: `${r.name} (${r.modelName})`, value: r.name }))
840
+ }
841
+ ]);
842
+
843
+ const config = await configManager.readOpenclawConfig();
844
+ const provider = config.models?.providers?.[relayName] || {};
845
+ const apiType = provider.api || (normalizeRelayKey(relayName) === 'yunyi-codex'
846
+ ? 'openai-responses'
847
+ : 'anthropic-messages');
848
+ const providerModels = Array.isArray(provider.models) ? provider.models : [];
849
+ const presetModels = getRelayModelPresets(relayName);
850
+
851
+ const choiceMap = new Map();
852
+ const choices = [];
853
+
854
+ const allowedIds = new Set(presetModels.map(m => m.id));
855
+
856
+ providerModels.forEach(m => {
857
+ const key = m.id;
858
+ if (allowedIds.has(key) && !choiceMap.has(key)) {
859
+ choiceMap.set(key, m);
860
+ choices.push({ name: `${m.name || m.id} (${m.id})`, value: m.id });
861
+ }
862
+ });
863
+
864
+ presetModels.forEach(m => {
865
+ if (!choiceMap.has(m.id)) {
866
+ choiceMap.set(m.id, m);
867
+ choices.push({ name: `${m.name} (${m.id})`, value: m.id });
868
+ }
869
+ });
870
+
871
+ if (choices.length === 0) {
872
+ displayInfo('没有可用的模型选项');
873
+ return;
874
+ }
875
+
876
+ const { selected } = await inquirer.prompt([
877
+ {
878
+ type: 'list',
879
+ name: 'selected',
880
+ message: '选择模型:',
881
+ choices
882
+ }
883
+ ]);
884
+
885
+ const selectedModel = choiceMap.get(selected);
886
+ const model = {
887
+ id: selected,
888
+ name: selectedModel?.name || selected
889
+ };
890
+
891
+ await configManager.setPrimaryModel(relayName, model.id);
892
+ displaySuccess(`✅ 已切换主模型为 "${relayName}/${model.id}"`);
893
+
894
+ if (apiType !== 'anthropic-messages' && Array.isArray(provider.models)) {
895
+ const exists = providerModels.some(m => m.id === model.id);
896
+ if (!exists) {
897
+ const { shouldAdd } = await inquirer.prompt([
898
+ {
899
+ type: 'confirm',
900
+ name: 'shouldAdd',
901
+ message: '是否将模型写入 providers.models 列表?',
902
+ default: false
903
+ }
904
+ ]);
905
+ if (shouldAdd) {
906
+ provider.models.push(buildModelEntry(model, apiType));
907
+ await configManager.writeOpenclawConfig(config);
908
+ displaySuccess('✅ 已更新模型列表');
909
+ }
910
+ }
911
+ }
912
+ }
913
+
89
914
  // 管理中转站配置
90
915
  async function manageRelay(configManager) {
91
916
  const { relayAction } = await inquirer.prompt([
@@ -151,88 +976,133 @@ async function addRelay(configManager) {
151
976
  message: '模型类型:',
152
977
  choices: [
153
978
  { name: 'Claude (Anthropic)', value: 'claude' },
154
- { name: 'GPT (OpenAI)', value: 'gpt' },
155
- { name: 'Gemini (Google)', value: 'gemini' },
156
- { name: '其他', value: 'other' }
979
+ { name: 'Codex / GPT (OpenAI Responses)', value: 'codex' },
980
+ { name: '其他/自定义', value: 'other' }
157
981
  ]
158
982
  }
159
983
  ]);
160
984
 
985
+ answers.baseUrl = buildRelayBaseUrl(answers.baseUrl, answers.name);
986
+
987
+ let apiType = answers.modelType === 'codex'
988
+ ? 'openai-responses'
989
+ : 'anthropic-messages';
990
+ if (answers.modelType === 'other') {
991
+ displayWarning('非 GPT/Claude 模型仅做配置写入,不保证可用,请优先用 OpenClaw 配置页面。');
992
+ const result = await inquirer.prompt([
993
+ {
994
+ type: 'list',
995
+ name: 'apiType',
996
+ message: 'API 接口类型:',
997
+ choices: [
998
+ { name: 'Anthropic Messages (Claude)', value: 'anthropic-messages' },
999
+ { name: 'OpenAI Responses (Codex / GPT-5+)', value: 'openai-responses' },
1000
+ { name: 'OpenAI Completions', value: 'openai-completions' },
1001
+ { name: 'Google Generative AI (Gemini)', value: 'google-generative-ai' },
1002
+ { name: '自定义', value: 'custom' }
1003
+ ]
1004
+ }
1005
+ ]);
1006
+ apiType = result.apiType;
1007
+ if (apiType === 'custom') {
1008
+ const custom = await inquirer.prompt([
1009
+ { type: 'input', name: 'customApi', message: '输入 API 类型:', validate: (i) => i.trim() !== '' || '不能为空' }
1010
+ ]);
1011
+ apiType = custom.customApi;
1012
+ }
1013
+ }
1014
+
161
1015
  let modelConfig;
162
1016
  if (answers.modelType === 'claude') {
163
- const { modelId } = await inquirer.prompt([
1017
+ const { modelChoice } = await inquirer.prompt([
164
1018
  {
165
1019
  type: 'list',
166
- name: 'modelId',
1020
+ name: 'modelChoice',
167
1021
  message: '选择 Claude 模型:',
168
1022
  choices: [
169
- { name: 'Claude Sonnet 4.5', value: 'claude-sonnet-4-5-20250929' },
170
- { name: 'Claude Sonnet 3.7', value: 'claude-3-7-sonnet-latest' },
171
- { name: 'Claude Opus 4.5', value: 'claude-opus-4-5-20251101' },
172
- { name: '自定义', value: 'custom' }
1023
+ { name: 'Claude Opus 4.5', value: { id: 'claude-opus-4-5', name: 'Claude Opus 4.5' } },
1024
+ { name: 'Claude Sonnet 4.5', value: { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5' } },
1025
+ { name: 'Claude Haiku 4.5', value: { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' } }
173
1026
  ]
174
1027
  }
175
1028
  ]);
176
1029
 
177
- if (modelId === 'custom') {
178
- const { customId } = await inquirer.prompt([
179
- {
180
- type: 'input',
181
- name: 'customId',
182
- message: '输入自定义模型 ID:',
183
- validate: (input) => input.trim() !== '' || '模型 ID 不能为空'
184
- }
185
- ]);
186
- modelConfig = { id: customId, name: customId };
187
- } else {
188
- modelConfig = { id: modelId, name: 'Claude Sonnet 4.5' };
189
- }
190
- } else if (answers.modelType === 'gpt') {
191
- modelConfig = { id: 'gpt-4o', name: 'GPT-4o' };
192
- } else if (answers.modelType === 'gemini') {
193
- modelConfig = { id: 'gemini-3-pro', name: 'Gemini 3 Pro' };
1030
+ modelConfig = modelChoice;
1031
+ } else if (answers.modelType === 'codex') {
1032
+ const { modelChoice } = await inquirer.prompt([
1033
+ {
1034
+ type: 'list',
1035
+ name: 'modelChoice',
1036
+ message: '选择 Codex / GPT 模型:',
1037
+ choices: [
1038
+ { name: 'GPT 5.2', value: { id: 'gpt-5.2', name: 'GPT 5.2' } },
1039
+ { name: 'GPT 5.2 Codex', value: { id: 'gpt-5.2-codex', name: 'GPT 5.2 Codex' } }
1040
+ ]
1041
+ }
1042
+ ]);
1043
+ modelConfig = modelChoice;
194
1044
  } else {
195
- const { customId, customName } = await inquirer.prompt([
1045
+ const custom = await inquirer.prompt([
1046
+ { type: 'input', name: 'id', message: '模型 ID:', validate: (i) => i.trim() !== '' || '不能为空' },
1047
+ { type: 'input', name: 'name', message: '模型名称:', validate: (i) => i.trim() !== '' || '不能为空' }
1048
+ ]);
1049
+ modelConfig = { id: custom.id.trim(), name: custom.name.trim() };
1050
+ }
1051
+
1052
+ const includeModelsList = apiType !== 'anthropic-messages';
1053
+ let modelEntry = null;
1054
+ if (includeModelsList) {
1055
+ const defaults = answers.modelType === 'codex'
1056
+ ? { contextWindow: 128000, maxTokens: 32768 }
1057
+ : { contextWindow: 200000, maxTokens: 8192 };
1058
+ const { contextWindow, maxTokens } = await inquirer.prompt([
196
1059
  {
197
- type: 'input',
198
- name: 'customId',
199
- message: '模型 ID:',
200
- validate: (input) => input.trim() !== '' || '模型 ID 不能为空'
1060
+ type: 'number',
1061
+ name: 'contextWindow',
1062
+ message: '上下文窗口大小:',
1063
+ default: defaults.contextWindow
201
1064
  },
202
1065
  {
203
- type: 'input',
204
- name: 'customName',
205
- message: '模型显示名称:',
206
- validate: (input) => input.trim() !== '' || '名称不能为空'
1066
+ type: 'number',
1067
+ name: 'maxTokens',
1068
+ message: '最大输出 tokens:',
1069
+ default: defaults.maxTokens
207
1070
  }
208
1071
  ]);
209
- modelConfig = { id: customId, name: customName };
1072
+ modelEntry = {
1073
+ id: modelConfig.id,
1074
+ name: modelConfig.name,
1075
+ reasoning: apiType === 'openai-responses',
1076
+ input: apiType === 'openai-responses' ? ['text', 'image'] : ['text'],
1077
+ cost: {
1078
+ input: 0,
1079
+ output: 0,
1080
+ cacheRead: 0,
1081
+ cacheWrite: 0
1082
+ },
1083
+ contextWindow,
1084
+ maxTokens
1085
+ };
210
1086
  }
211
1087
 
212
- const { contextWindow, maxTokens } = await inquirer.prompt([
213
- {
214
- type: 'number',
215
- name: 'contextWindow',
216
- message: '上下文窗口大小:',
217
- default: 200000
218
- },
219
- {
220
- type: 'number',
221
- name: 'maxTokens',
222
- message: '最大输出 tokens:',
223
- default: 8192
224
- }
225
- ]);
226
-
227
1088
  await configManager.addRelay({
228
1089
  name: answers.name,
229
1090
  baseUrl: answers.baseUrl,
1091
+ auth: 'api-key',
1092
+ api: apiType,
1093
+ headers: {},
1094
+ authHeader: false,
1095
+ modelsMode: 'merge',
230
1096
  model: {
231
1097
  id: modelConfig.id,
232
1098
  name: modelConfig.name,
233
- contextWindow,
234
- maxTokens
235
- }
1099
+ contextWindow: includeModelsList ? modelEntry.contextWindow : undefined,
1100
+ maxTokens: includeModelsList ? modelEntry.maxTokens : undefined,
1101
+ reasoning: includeModelsList ? modelEntry.reasoning : undefined,
1102
+ input: includeModelsList ? modelEntry.input : undefined,
1103
+ cost: includeModelsList ? modelEntry.cost : undefined
1104
+ },
1105
+ models: includeModelsList ? [modelEntry] : []
236
1106
  });
237
1107
 
238
1108
  displaySuccess(`✅ 中转站 "${answers.name}" 添加成功!`);
@@ -256,8 +1126,29 @@ async function editRelay(configManager) {
256
1126
  ]);
257
1127
 
258
1128
  const relay = relays.find(r => r.name === relayName);
1129
+ const config = await configManager.readOpenclawConfig();
1130
+ const provider = config.models?.providers?.[relayName] || {};
1131
+ const apiTypeDefault = provider.api || (normalizeRelayKey(relayName) === 'yunyi-codex'
1132
+ ? 'openai-responses'
1133
+ : 'anthropic-messages');
1134
+ const hasModelList = Array.isArray(provider.models) && provider.models.length > 0;
1135
+ if (!['yunyi-claude', 'yunyi-codex'].includes(normalizeRelayKey(relayName))) {
1136
+ displayWarning('该中转站不在本工具保障范围,仅做配置写入。');
1137
+ }
259
1138
 
260
- const answers = await inquirer.prompt([
1139
+ const apiChoices = [
1140
+ { name: 'Anthropic Messages (Claude)', value: 'anthropic-messages' },
1141
+ { name: 'OpenAI Responses (Codex / GPT-5+)', value: 'openai-responses' },
1142
+ { name: 'OpenAI Completions', value: 'openai-completions' },
1143
+ { name: 'Google Generative AI (Gemini)', value: 'google-generative-ai' },
1144
+ { name: '自定义', value: 'custom' }
1145
+ ];
1146
+
1147
+ if (apiTypeDefault && !apiChoices.some(choice => choice.value === apiTypeDefault)) {
1148
+ apiChoices.unshift({ name: `当前 (${apiTypeDefault})`, value: apiTypeDefault });
1149
+ }
1150
+
1151
+ const questions = [
261
1152
  {
262
1153
  type: 'input',
263
1154
  name: 'baseUrl',
@@ -265,18 +1156,76 @@ async function editRelay(configManager) {
265
1156
  default: relay.baseUrl
266
1157
  },
267
1158
  {
268
- type: 'number',
269
- name: 'contextWindow',
270
- message: '上下文窗口:',
271
- default: relay.contextWindow
1159
+ type: 'list',
1160
+ name: 'api',
1161
+ message: 'API 接口类型:',
1162
+ default: apiTypeDefault,
1163
+ choices: apiChoices
272
1164
  },
273
1165
  {
274
- type: 'number',
275
- name: 'maxTokens',
276
- message: '最大输出 tokens:',
277
- default: relay.maxTokens
1166
+ type: 'confirm',
1167
+ name: 'authHeader',
1168
+ message: '是否启用 authHeader?',
1169
+ default: !!provider.authHeader
1170
+ },
1171
+ {
1172
+ type: 'input',
1173
+ name: 'headers',
1174
+ message: 'Headers (JSON/JSON5,留空保持不变):',
1175
+ default: provider.headers ? JSON.stringify(provider.headers) : ''
1176
+ },
1177
+ {
1178
+ type: 'password',
1179
+ name: 'apiKey',
1180
+ message: 'API Key (留空保持不变):',
1181
+ mask: '*'
278
1182
  }
279
- ]);
1183
+ ];
1184
+
1185
+ if (hasModelList) {
1186
+ questions.push(
1187
+ {
1188
+ type: 'number',
1189
+ name: 'contextWindow',
1190
+ message: '上下文窗口:',
1191
+ default: relay.contextWindow
1192
+ },
1193
+ {
1194
+ type: 'number',
1195
+ name: 'maxTokens',
1196
+ message: '最大输出 tokens:',
1197
+ default: relay.maxTokens
1198
+ }
1199
+ );
1200
+ }
1201
+
1202
+ const answers = await inquirer.prompt(questions);
1203
+
1204
+ if (answers.api === 'custom') {
1205
+ const custom = await inquirer.prompt([
1206
+ { type: 'input', name: 'customApi', message: '输入 API 类型:', validate: (i) => i.trim() !== '' || '不能为空' }
1207
+ ]);
1208
+ answers.api = custom.customApi;
1209
+ }
1210
+
1211
+ if (answers.headers && answers.headers.trim() !== '') {
1212
+ try {
1213
+ answers.headers = JSON5.parse(answers.headers);
1214
+ } catch (error) {
1215
+ displayError(`Headers 解析失败: ${error.message}`);
1216
+ return;
1217
+ }
1218
+ } else {
1219
+ delete answers.headers;
1220
+ }
1221
+
1222
+ if (answers.baseUrl) {
1223
+ answers.baseUrl = buildRelayBaseUrl(answers.baseUrl, relayName);
1224
+ }
1225
+
1226
+ if (!answers.apiKey || answers.apiKey.trim() === '') {
1227
+ delete answers.apiKey;
1228
+ }
280
1229
 
281
1230
  await configManager.updateRelay(relayName, answers);
282
1231
  displaySuccess(`✅ 中转站 "${relayName}" 更新成功!`);
@@ -336,7 +1285,21 @@ async function switchRelay(configManager) {
336
1285
  }
337
1286
  ]);
338
1287
 
339
- await configManager.setPrimaryModel(relayName);
1288
+ const selected = relays.find(r => r.name === relayName);
1289
+ let modelId = selected?.modelId;
1290
+ if (!modelId) {
1291
+ const result = await inquirer.prompt([
1292
+ {
1293
+ type: 'input',
1294
+ name: 'modelId',
1295
+ message: '该中转站未配置模型 ID,请输入模型 ID:',
1296
+ validate: (input) => input.trim() !== '' || '模型 ID 不能为空'
1297
+ }
1298
+ ]);
1299
+ modelId = result.modelId;
1300
+ }
1301
+
1302
+ await configManager.setPrimaryModel(relayName, modelId);
340
1303
  displaySuccess(`✅ 已切换到 "${relayName}"`);
341
1304
  }
342
1305
 
@@ -371,13 +1334,18 @@ async function manageModel(configManager) {
371
1334
  async function manageFallback(configManager) {
372
1335
  const relays = await configManager.listRelays();
373
1336
  const currentFallbacks = await configManager.getFallbackModels();
1337
+ const availableRelays = relays.filter(r => r.modelId);
1338
+
1339
+ if (availableRelays.length !== relays.length) {
1340
+ displayWarning('部分中转站未配置模型 ID,已跳过');
1341
+ }
374
1342
 
375
1343
  const { selected } = await inquirer.prompt([
376
1344
  {
377
1345
  type: 'checkbox',
378
1346
  name: 'selected',
379
1347
  message: '选择备用模型(按优先级排序):',
380
- choices: relays.map(r => ({
1348
+ choices: availableRelays.map(r => ({
381
1349
  name: `${r.name} - ${r.modelName}`,
382
1350
  value: `${r.name}/${r.modelId}`,
383
1351
  checked: currentFallbacks.includes(`${r.name}/${r.modelId}`)
@@ -389,17 +1357,19 @@ async function manageFallback(configManager) {
389
1357
  displaySuccess('✅ 备用模型配置已更新');
390
1358
  }
391
1359
 
392
- // 管理 API Keys
1360
+ // 管理 API Keys 和 Tokens
393
1361
  async function manageApiKey(configManager) {
394
1362
  const { keyAction } = await inquirer.prompt([
395
1363
  {
396
1364
  type: 'list',
397
1365
  name: 'keyAction',
398
- message: 'API Key 管理:',
1366
+ message: 'API Key / Token 管理:',
399
1367
  choices: [
400
1368
  { name: '➕ 添加/更新 API Key', value: 'add' },
401
- { name: '👁️ 查看已配置的 Keys', value: 'view' },
1369
+ { name: '🔐 添加/更新 Token', value: 'addToken' },
1370
+ { name: '👁️ 查看已配置的 Keys/Tokens', value: 'view' },
402
1371
  { name: '🗑️ 删除 API Key', value: 'delete' },
1372
+ { name: '🗑️ 删除 Token', value: 'deleteToken' },
403
1373
  { name: '⬅️ 返回', value: 'back' }
404
1374
  ]
405
1375
  }
@@ -411,12 +1381,18 @@ async function manageApiKey(configManager) {
411
1381
  case 'add':
412
1382
  await addApiKey(configManager);
413
1383
  break;
1384
+ case 'addToken':
1385
+ await addToken(configManager);
1386
+ break;
414
1387
  case 'view':
415
1388
  await viewApiKeys(configManager);
416
1389
  break;
417
1390
  case 'delete':
418
1391
  await deleteApiKey(configManager);
419
1392
  break;
1393
+ case 'deleteToken':
1394
+ await deleteToken(configManager);
1395
+ break;
420
1396
  }
421
1397
  }
422
1398
 
@@ -424,6 +1400,11 @@ async function manageApiKey(configManager) {
424
1400
  async function addApiKey(configManager) {
425
1401
  const relays = await configManager.listRelays();
426
1402
 
1403
+ if (relays.length === 0) {
1404
+ displayInfo('请先添加中转站');
1405
+ return;
1406
+ }
1407
+
427
1408
  const { relayName } = await inquirer.prompt([
428
1409
  {
429
1410
  type: 'list',
@@ -447,27 +1428,97 @@ async function addApiKey(configManager) {
447
1428
  displaySuccess(`✅ API Key 已保存到 "${relayName}"`);
448
1429
  }
449
1430
 
450
- // 查看 API Keys
1431
+ // 添加 Token
1432
+ async function addToken(configManager) {
1433
+ const relays = await configManager.listRelays();
1434
+
1435
+ if (relays.length === 0) {
1436
+ displayInfo('请先添加中转站');
1437
+ return;
1438
+ }
1439
+
1440
+ const { relayName } = await inquirer.prompt([
1441
+ {
1442
+ type: 'list',
1443
+ name: 'relayName',
1444
+ message: '选择中转站:',
1445
+ choices: relays.map(r => ({ name: r.name, value: r.name }))
1446
+ }
1447
+ ]);
1448
+
1449
+ const { token } = await inquirer.prompt([
1450
+ {
1451
+ type: 'password',
1452
+ name: 'token',
1453
+ message: '输入 Token:',
1454
+ mask: '*',
1455
+ validate: (input) => input.trim() !== '' || 'Token 不能为空'
1456
+ }
1457
+ ]);
1458
+
1459
+ await configManager.setToken(relayName, token);
1460
+ displaySuccess(`✅ Token 已保存到 "${relayName}"`);
1461
+ }
1462
+
1463
+ // 查看 API Keys 和 Tokens
451
1464
  async function viewApiKeys(configManager) {
452
1465
  const keys = await configManager.listApiKeys();
453
1466
 
454
1467
  if (keys.length === 0) {
455
- displayInfo('没有配置任何 API Key');
1468
+ displayInfo('没有配置任何 API Key 或 Token');
456
1469
  return;
457
1470
  }
458
1471
 
459
- console.log(chalk.cyan('\n已配置的 API Keys:\n'));
1472
+ console.log(chalk.cyan('\n已配置的 API Keys / Tokens:\n'));
460
1473
  keys.forEach(k => {
461
- const masked = k.key ? `${k.key.substring(0, 8)}...${k.key.substring(k.key.length - 4)}` : '未设置';
462
- console.log(` ${chalk.green('●')} ${k.provider}: ${masked}`);
1474
+ const maskedKey = k.key ? `${k.key.substring(0, 8)}...${k.key.substring(k.key.length - 4)}` : '未设置';
1475
+ const maskedToken = k.token ? `${k.token.substring(0, 8)}...${k.token.substring(k.token.length - 4)}` : '未设置';
1476
+ console.log(` ${chalk.green('●')} ${k.provider}`);
1477
+ console.log(` API Key: ${k.key ? chalk.green(maskedKey) : chalk.gray(maskedKey)}`);
1478
+ console.log(` Token: ${k.token ? chalk.green(maskedToken) : chalk.gray(maskedToken)}`);
463
1479
  });
464
1480
  }
465
1481
 
1482
+ // 删除 Token
1483
+ async function deleteToken(configManager) {
1484
+ const keys = await configManager.listApiKeys();
1485
+ const keysWithToken = keys.filter(k => k.token);
1486
+
1487
+ if (keysWithToken.length === 0) {
1488
+ displayInfo('没有可删除的 Token');
1489
+ return;
1490
+ }
1491
+
1492
+ const { provider } = await inquirer.prompt([
1493
+ {
1494
+ type: 'list',
1495
+ name: 'provider',
1496
+ message: '选择要删除的 Token:',
1497
+ choices: keysWithToken.map(k => ({ name: k.provider, value: k.provider }))
1498
+ }
1499
+ ]);
1500
+
1501
+ const { confirm } = await inquirer.prompt([
1502
+ {
1503
+ type: 'confirm',
1504
+ name: 'confirm',
1505
+ message: `确定要删除 "${provider}" 的 Token 吗?`,
1506
+ default: false
1507
+ }
1508
+ ]);
1509
+
1510
+ if (confirm) {
1511
+ await configManager.deleteToken(provider);
1512
+ displaySuccess(`✅ Token 已删除`);
1513
+ }
1514
+ }
1515
+
466
1516
  // 删除 API Key
467
1517
  async function deleteApiKey(configManager) {
468
1518
  const keys = await configManager.listApiKeys();
1519
+ const keysWithApiKey = keys.filter(k => k.key);
469
1520
 
470
- if (keys.length === 0) {
1521
+ if (keysWithApiKey.length === 0) {
471
1522
  displayInfo('没有可删除的 API Key');
472
1523
  return;
473
1524
  }
@@ -477,7 +1528,7 @@ async function deleteApiKey(configManager) {
477
1528
  type: 'list',
478
1529
  name: 'provider',
479
1530
  message: '选择要删除的 API Key:',
480
- choices: keys.map(k => ({ name: k.provider, value: k.provider }))
1531
+ choices: keysWithApiKey.map(k => ({ name: k.provider, value: k.provider }))
481
1532
  }
482
1533
  ]);
483
1534
 
@@ -553,13 +1604,26 @@ async function speedTestAndSwitch(configManager) {
553
1604
 
554
1605
  if (action === 'fastest') {
555
1606
  // 切换到最快的
556
- await configManager.setPrimaryModel(fastest.name);
1607
+ const fastestRelay = relays.find(rel => rel.name === fastest.name);
1608
+ let fastestModelId = fastestRelay?.modelId;
1609
+ if (!fastestModelId) {
1610
+ const result = await inquirer.prompt([
1611
+ {
1612
+ type: 'input',
1613
+ name: 'modelId',
1614
+ message: `未找到 "${fastest.name}" 的模型 ID,请输入:`,
1615
+ validate: (input) => input.trim() !== '' || '模型 ID 不能为空'
1616
+ }
1617
+ ]);
1618
+ fastestModelId = result.modelId;
1619
+ }
1620
+ await configManager.setPrimaryModel(fastest.name, fastestModelId);
557
1621
 
558
1622
  // 设置其他可用的为备用
559
- const fallbacks = sorted.slice(1).map(r => {
560
- const relay = relays.find(rel => rel.name === r.name);
561
- return `${relay.name}/${relay.modelId}`;
562
- });
1623
+ const fallbacks = sorted.slice(1)
1624
+ .map(r => relays.find(rel => rel.name === r.name))
1625
+ .filter(relay => relay && relay.modelId)
1626
+ .map(relay => `${relay.name}/${relay.modelId}`);
563
1627
  await configManager.setFallbackModels(fallbacks);
564
1628
 
565
1629
  displaySuccess(`✅ 已切换到 "${fastest.name}" (${fastest.latency}ms)`);
@@ -580,15 +1644,27 @@ async function speedTestAndSwitch(configManager) {
580
1644
  }
581
1645
  ]);
582
1646
 
583
- await configManager.setPrimaryModel(selected);
1647
+ const selectedRelay = relays.find(rel => rel.name === selected);
1648
+ let selectedModelId = selectedRelay?.modelId;
1649
+ if (!selectedModelId) {
1650
+ const result = await inquirer.prompt([
1651
+ {
1652
+ type: 'input',
1653
+ name: 'modelId',
1654
+ message: `未找到 "${selected}" 的模型 ID,请输入:`,
1655
+ validate: (input) => input.trim() !== '' || '模型 ID 不能为空'
1656
+ }
1657
+ ]);
1658
+ selectedModelId = result.modelId;
1659
+ }
1660
+ await configManager.setPrimaryModel(selected, selectedModelId);
584
1661
 
585
1662
  // 设置其他为备用
586
1663
  const fallbacks = sorted
587
1664
  .filter(r => r.name !== selected)
588
- .map(r => {
589
- const relay = relays.find(rel => rel.name === r.name);
590
- return `${relay.name}/${relay.modelId}`;
591
- });
1665
+ .map(r => relays.find(rel => rel.name === r.name))
1666
+ .filter(relay => relay && relay.modelId)
1667
+ .map(relay => `${relay.name}/${relay.modelId}`);
592
1668
  await configManager.setFallbackModels(fallbacks);
593
1669
 
594
1670
  const selectedResult = results.find(r => r.name === selected);
@@ -602,29 +1678,91 @@ async function speedTestAndSwitch(configManager) {
602
1678
  // 高级设置
603
1679
  async function manageAdvanced(configManager) {
604
1680
  const currentConfig = await configManager.getAdvancedSettings();
1681
+ const defaultWorkspace = getDefaultWorkspace();
1682
+
1683
+ console.log(chalk.cyan('\n当前高级设置:'));
1684
+ console.log(` 最大并发: ${currentConfig.maxConcurrent}`);
1685
+ console.log(` 子代理并发: ${currentConfig.subagentMaxConcurrent}`);
1686
+ console.log(` 工作区: ${currentConfig.workspace || chalk.gray('(使用默认)')}`);
1687
+ console.log(` 压缩模式: ${currentConfig.compactionMode || chalk.gray('(未设置)')}`);
1688
+ if (currentConfig.gatewayToken) {
1689
+ const token = currentConfig.gatewayToken;
1690
+ const masked = `${token.substring(0, 6)}...${token.substring(token.length - 4)}`;
1691
+ console.log(` 网关 Token: ${chalk.gray(masked)}`);
1692
+ } else {
1693
+ console.log(` 网关 Token: ${chalk.gray('(未设置)')}`);
1694
+ }
1695
+ console.log(` 默认工作区: ${chalk.gray(defaultWorkspace)}\n`);
605
1696
 
606
1697
  const answers = await inquirer.prompt([
607
1698
  {
608
1699
  type: 'number',
609
1700
  name: 'maxConcurrent',
610
- message: '最大并发任务数:',
611
- default: currentConfig.maxConcurrent || 4
1701
+ message: '最大并发任务数 (1-100):',
1702
+ default: currentConfig.maxConcurrent || 4,
1703
+ validate: (input) => {
1704
+ const num = Number(input);
1705
+ if (isNaN(num) || num < 1 || num > 100) {
1706
+ return '请输入 1 到 100 之间的数字';
1707
+ }
1708
+ return true;
1709
+ }
612
1710
  },
613
1711
  {
614
1712
  type: 'number',
615
1713
  name: 'subagentMaxConcurrent',
616
- message: '子代理最大并发数:',
617
- default: currentConfig.subagentMaxConcurrent || 8
1714
+ message: '子代理最大并发数 (1-100):',
1715
+ default: currentConfig.subagentMaxConcurrent || 8,
1716
+ validate: (input) => {
1717
+ const num = Number(input);
1718
+ if (isNaN(num) || num < 1 || num > 100) {
1719
+ return '请输入 1 到 100 之间的数字';
1720
+ }
1721
+ return true;
1722
+ }
618
1723
  },
619
1724
  {
620
1725
  type: 'input',
621
1726
  name: 'workspace',
622
- message: '工作区路径:',
623
- default: currentConfig.workspace || path.join(os.homedir(), '.openclaw', 'workspace')
1727
+ message: `工作区路径 (留空使用默认: ${defaultWorkspace}):`,
1728
+ default: currentConfig.workspace || ''
1729
+ },
1730
+ {
1731
+ type: 'list',
1732
+ name: 'compactionMode',
1733
+ message: '压缩模式:',
1734
+ choices: [
1735
+ { name: '保持不变', value: '__keep__' },
1736
+ { name: 'safeguard', value: 'safeguard' },
1737
+ { name: 'auto', value: 'auto' },
1738
+ { name: 'off', value: 'off' }
1739
+ ],
1740
+ default: currentConfig.compactionMode ? currentConfig.compactionMode : '__keep__'
1741
+ },
1742
+ {
1743
+ type: 'password',
1744
+ name: 'gatewayToken',
1745
+ message: '网关 Token (留空保持不变):',
1746
+ mask: '*'
624
1747
  }
625
1748
  ]);
626
1749
 
627
- await configManager.setAdvancedSettings(answers);
1750
+ // 如果用户没有输入工作区路径,使用默认值
1751
+ if (!answers.workspace || answers.workspace.trim() === '') {
1752
+ answers.workspace = defaultWorkspace;
1753
+ }
1754
+
1755
+ const advancedUpdate = {
1756
+ maxConcurrent: answers.maxConcurrent,
1757
+ subagentMaxConcurrent: answers.subagentMaxConcurrent,
1758
+ workspace: answers.workspace,
1759
+ gatewayToken: answers.gatewayToken
1760
+ };
1761
+ if (answers.compactionMode !== '__keep__') {
1762
+ advancedUpdate.compactionMode = answers.compactionMode;
1763
+ }
1764
+
1765
+ await configManager.setAdvancedSettings(advancedUpdate);
628
1766
  displaySuccess('✅ 高级设置已更新');
629
1767
  }
630
1768
 
@@ -646,7 +1784,8 @@ async function viewConfig(configManager) {
646
1784
  console.log(` ${chalk.green('●')} ${r.name}`);
647
1785
  console.log(` URL: ${r.baseUrl}`);
648
1786
  console.log(` 模型: ${r.modelName} (${r.modelId})`);
649
- console.log(` 上下文: ${r.contextWindow} tokens`);
1787
+ const contextInfo = r.contextWindow ? `${r.contextWindow} tokens` : '-';
1788
+ console.log(` 上下文: ${contextInfo}`);
650
1789
  });
651
1790
 
652
1791
  console.log(chalk.yellow('\n高级设置:'));