sintbot 1.0.10 → 1.0.12
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/CHANGELOG.en.json +32 -0
- package/CHANGELOG.json +34 -0
- package/README.md +9 -9
- package/README.zh-CN.md +9 -9
- package/bin/sbot.js +433 -286
- package/package.json +1 -1
- package/src/clilang.js +31 -0
- package/src/config.js +4 -2
- package/src/core/proxy.js +1 -0
- package/src/core/verify.js +2 -1
- package/src/design.js +6 -4
- package/src/health.js +2 -2
- package/src/installdeps.js +8 -2
- package/src/service.js +9 -3
- package/src/setupmodels.js +19 -14
- package/src/ui/console.html +69 -3
- package/src/ui/server.js +9 -1
package/bin/sbot.js
CHANGED
|
@@ -12,6 +12,8 @@ const { pathToFileURL } = require('node:url')
|
|
|
12
12
|
const path = require('node:path')
|
|
13
13
|
const readline = require('node:readline')
|
|
14
14
|
const { parseArgs } = require('node:util')
|
|
15
|
+
const { tr } = require('../src/clilang')
|
|
16
|
+
const { PROXY_ENV_KEYS } = require('../src/core/proxy')
|
|
15
17
|
const { loadConfig, findConfigPath, defaultConfigPath, applyProxyEnv, deriveNoProxy, resolveSecret, platformsConfigured, chatPlatform } = require('../src/config')
|
|
16
18
|
const { resolveProjectsRoots, withinRoots, describeRoots, parseRootsInput, expandHome, workdirInput, defaultWorkdir } = require('../src/projectsroot')
|
|
17
19
|
// Runner dispatch (keeps core/engine agnostic — it only sees `{ runTurn }`): a provider
|
|
@@ -124,9 +126,10 @@ const SUBHELP = {
|
|
|
124
126
|
providers: '用法: sintbot providers\n'
|
|
125
127
|
+ ' 列出配置里的模型 provider:型号 / 端点 / key 是否就位;* = 默认那个\n'
|
|
126
128
|
+ ' (改 provider 走控制台「模型」页或直接编辑 sbot.config.json;群里用 /model 切)',
|
|
127
|
-
setup: '用法: sintbot setup [--config <path>]\n'
|
|
128
|
-
+ '
|
|
129
|
-
+ '
|
|
129
|
+
setup: '用法: sintbot setup [--full] [--config <path>]\n'
|
|
130
|
+
+ ' 首次向导:环境检查 → 模型(先看 Claude / Codex 登录,都没有推荐 DeepSeek,填 key 当场试跑)。聊天平台、代理、项目根目录、绑群装完在控制台里配\n'
|
|
131
|
+
+ ' --full:另外在终端里问代理、机器人名与聊天平台凭据(飞书 / Slack / 企业微信)、项目根目录、绑一个群(给没有浏览器的服务器用)\n'
|
|
132
|
+
+ ' 回车即默认;已有配置只补缺项、不动已填的;新建默认写到 ~/.sbot/config.json(当前目录已有 sbot.config.json 就更新它;--config 另指),权限 600',
|
|
130
133
|
ui: '用法: sintbot ui [--port <n>] [--host <ip>] [--no-open] [--config <path>]\n'
|
|
131
134
|
+ ' sintbot ui --set-password\n'
|
|
132
135
|
+ ' 浏览器里的控制台(配置模式,不接 daemon):配置 / 健康 / provider 试跑 / 群绑定;默认 http://127.0.0.1:4600\n'
|
|
@@ -425,6 +428,46 @@ async function cmdChat(rest) {
|
|
|
425
428
|
await platform.start((msg) => engine.handleMessage(msg, platform))
|
|
426
429
|
}
|
|
427
430
|
|
|
431
|
+
// 还没接聊天平台时的 `sbot start --ui`:只开配置模式的控制台(与 `sintbot ui` 同一套),等人在「设置」里接平台。
|
|
432
|
+
// 照拿单实例锁(和正式 daemon 同一把:之后在终端里 sintbot start 会被拦下,不会两个抢一个平台)。
|
|
433
|
+
// 服务托管(SBOT_SUPERVISED=1)时,配置里第一次出现配齐的平台就 3 秒后退出 —— launchd / systemd 拉起来就是正式 daemon、连上平台;
|
|
434
|
+
// 前台跑的只在日志里提示重启。
|
|
435
|
+
async function serveSetupConsole(cfg, { port, host, noOpen }) {
|
|
436
|
+
const configPath = cfg._path || path.join(process.cwd(), 'sbot.config.json')
|
|
437
|
+
if (!isLoopbackHost(host) && !((cfg.ui || {}).auth)) return die(`控制台对外(--ui-host ${host})需先设密码:sintbot ui --set-password`)
|
|
438
|
+
const supervised = process.env[require('../src/service').SUPERVISED_ENV] === '1'
|
|
439
|
+
const stateDir = path.join(os.homedir(), '.sbot')
|
|
440
|
+
try { secureDataDir(stateDir) } catch {}
|
|
441
|
+
const lock = require('../src/core/instancelock').acquire(path.join(stateDir, 'daemon.lock'))
|
|
442
|
+
if (!lock.ok) return die(`另一个 sbot 已在运行(pid ${lock.holder || '?'})—— 先停掉它(装了服务就 sintbot service uninstall / restart)`)
|
|
443
|
+
process.on('exit', lock.release)
|
|
444
|
+
const logTz = normalizeTz(cfg.timezone) // 与正式 daemon 同一口径:按产品时区打戳
|
|
445
|
+
const logStamp = () => fmtLogStamp(Date.now(), logTz)
|
|
446
|
+
const log = (m) => process.stderr.write(gray(`[${logStamp()}] · ` + m) + '\n')
|
|
447
|
+
const ui = createUiServer({ configPath, host, log, setupMode: { supervised } })
|
|
448
|
+
ui.server.on('error', (e) => die(e.code === 'EADDRINUSE' ? `控制台端口 ${port} 被占用 —— 加 --ui-port <其它> 换端口` : 'ui server error: ' + (e && e.message)))
|
|
449
|
+
ui.listen(port, host, () => {
|
|
450
|
+
const url = `http://127.0.0.1:${port}`
|
|
451
|
+
process.stderr.write('\n' + cyan(' sbot 控制台') + dim(' → ') + url + gray(' (还没接聊天平台:在「设置」里接飞书 / Slack / 企业微信)') + '\n\n')
|
|
452
|
+
log(`还没接聊天平台,只开控制台 ${url}` + (supervised ? ';接上平台后自动重启连上' : ''))
|
|
453
|
+
if (!noOpen) tryOpen(url)
|
|
454
|
+
})
|
|
455
|
+
let told = false
|
|
456
|
+
fs.watchFile(configPath, { interval: 1500 }, (cur, prev) => {
|
|
457
|
+
if (cur.mtimeMs === prev.mtimeMs || told) return
|
|
458
|
+
let now
|
|
459
|
+
try { now = platformsConfigured(loadConfig(configPath)) } catch { return } // 写到一半 / 改坏了:等下一次
|
|
460
|
+
const names = Object.keys(now).filter((k) => now[k])
|
|
461
|
+
if (!names.length) return
|
|
462
|
+
told = true
|
|
463
|
+
if (supervised) { log(`已接上 ${names.join(' / ')} —— 3 秒后重启守护进程连上`); setTimeout(() => process.exit(0), 3000) }
|
|
464
|
+
else log(`已接上 ${names.join(' / ')} —— 按 Ctrl-C 后重新运行 sintbot start --ui 连上`)
|
|
465
|
+
})
|
|
466
|
+
const bye = () => { try { ui.close() } catch {} process.exit(0) }
|
|
467
|
+
process.on('SIGINT', bye)
|
|
468
|
+
process.on('SIGTERM', bye)
|
|
469
|
+
}
|
|
470
|
+
|
|
428
471
|
// `sbot start` — the daemon: Feishu WS in, engine routes each group message to
|
|
429
472
|
// its bound project, agent runs, reply goes back to the group.
|
|
430
473
|
async function cmdStart(rest) {
|
|
@@ -443,6 +486,8 @@ async function cmdStart(rest) {
|
|
|
443
486
|
// 两个都配就两个都接,群项目各自带 platform 字段说明属于哪边(config.normalizeGroupPlatforms 归一,路由器按它分发)。
|
|
444
487
|
const plat = platformsConfigured(cfg)
|
|
445
488
|
if (!plat.feishu && !plat.wecom && !plat.slack) {
|
|
489
|
+
// 带 --ui(后台服务就是这么起的):只开控制台,等人在「设置」里接平台 —— 1.0.12 起没平台也装服务,装完就有地方可去
|
|
490
|
+
if (wantUi) return serveSetupConsole(cfg, { port: Number(values['ui-port']) || 4600, host: values['ui-host'] || '127.0.0.1', noOpen })
|
|
446
491
|
if (cfg.wecom && (cfg.wecom.botId || cfg.wecom.secret)) return die('sbot.config.json 的 wecom 块缺 ' + (cfg.wecom.botId ? 'secret' : 'botId') + '(机器人 API 配置页「使用长连接」处)')
|
|
447
492
|
return die('没有配置任何聊天平台 — 在 sbot.config.json 里加 "feishu" 块(appId/appSecret)、"wecom" 块(botId/secret)或 "slack" 块(botToken/appToken),配几个接几个')
|
|
448
493
|
}
|
|
@@ -1214,10 +1259,10 @@ function printPreviewStatus() {
|
|
|
1214
1259
|
async function cmdService(rest) {
|
|
1215
1260
|
const svc = require('../src/service')
|
|
1216
1261
|
const action = rest[0] && !rest[0].startsWith('-') ? rest[0] : 'status'
|
|
1217
|
-
const { values } = parseArgs({ args: rest.slice(action === rest[0] ? 1 : 0), allowPositionals: true, options: { config: { type: 'string' }, 'ui-host': { type: 'string' }, 'dry-run': { type: 'boolean' }, 'pass-env': { type: 'boolean' }, n: { type: 'string' } } })
|
|
1262
|
+
const { values } = parseArgs({ args: rest.slice(action === rest[0] ? 1 : 0), allowPositionals: true, options: { config: { type: 'string' }, 'ui-host': { type: 'string' }, 'dry-run': { type: 'boolean' }, 'pass-env': { type: 'boolean' }, check: { type: 'boolean' }, n: { type: 'string' } } })
|
|
1218
1263
|
const platform = process.platform
|
|
1219
1264
|
const home = os.homedir()
|
|
1220
|
-
const nice = (st) => st.running ? cyan('● 运行中') + (st.pid ? dim(` pid ${st.pid}`) : '') : st.loaded ? yellow(`○ 已加载、未在运行(${st.state || '?'})`) : st.installed ? yellow('○ 已安装、未加载') : gray('○ 未安装')
|
|
1265
|
+
const nice = (st) => st.running ? cyan(tr('● 运行中', '● running')) + (st.pid ? dim(` pid ${st.pid}`) : '') : st.loaded ? yellow(tr(`○ 已加载、未在运行(${st.state || '?'})`, `○ loaded, not running (${st.state || '?'})`)) : st.installed ? yellow(tr('○ 已安装、未加载', '○ installed, not loaded')) : gray(tr('○ 未安装', '○ not installed'))
|
|
1221
1266
|
if (action === 'install') {
|
|
1222
1267
|
const configPath = findConfigPath(values.config)
|
|
1223
1268
|
if (!configPath) return die('没找到配置文件 —— 先跑 sintbot setup(或 --config 指定)')
|
|
@@ -1226,27 +1271,27 @@ async function cmdService(rest) {
|
|
|
1226
1271
|
let raw
|
|
1227
1272
|
try { raw = JSON.parse(fs.readFileSync(configPath, 'utf8')) } catch (e) { return die(`读不了配置 ${configPath}:${e.message}`) }
|
|
1228
1273
|
if (uiHost && !isLoopbackHost(uiHost) && !((raw.ui || {}).auth)) return die(`控制台对外(--ui-host ${uiHost})要先设密码:sintbot ui --set-password${values.config ? ' --config ' + values.config : ''} —— 否则服务一启动就退出、被反复拉起`)
|
|
1229
|
-
//
|
|
1230
|
-
//
|
|
1274
|
+
// 一个聊天平台都没接也照装(1.0.12 起):服务先只开控制台,在「设置」里接好平台后自己重启连上(cmdStart 的配置模式)。
|
|
1275
|
+
// 1.0.10–1.0.11 在这里拦下(退出码 3),结果装完没有任何地方可去 —— 控制台也打不开
|
|
1231
1276
|
const plat = platformsConfigured(loadConfig(configPath))
|
|
1232
|
-
|
|
1233
|
-
const cfgArg = values.config ? ' --config ' + values.config : ''
|
|
1234
|
-
process.stderr.write(red(`sbot: 还没接聊天平台 —— 装成服务也会一启动就退出、被反复拉起。先运行 sintbot ui${cfgArg},在控制台「设置」里填飞书 / Slack / 企业微信的凭据并绑群(或重跑 sintbot setup),再装服务`) + '\n')
|
|
1235
|
-
process.exit(3)
|
|
1236
|
-
}
|
|
1277
|
+
const noPlatform = !plat.feishu && !plat.wecom && !plat.slack
|
|
1237
1278
|
// 服务进程拿不到当前 shell 的环境变量:点名配置里的 env: 引用;--pass-env 才把值写进单元文件。
|
|
1238
|
-
// 终端里装又没给 --pass-env 时问一句(默认是):不带进去,服务里用这些 key 的模型 /
|
|
1279
|
+
// 终端里装又没给 --pass-env 时问一句(默认是):不带进去,服务里用这些 key 的模型 / 平台直接用不了。
|
|
1280
|
+
// 配置里没写代理、终端里有代理变量时一起列进这一问(不另问):不带进去,服务里连 Claude / ChatGPT 会直连失败
|
|
1239
1281
|
const refs = svc.envRefs(raw)
|
|
1240
1282
|
const inShell = refs.filter((v) => process.env[v])
|
|
1241
1283
|
const nowhere = refs.filter((v) => !process.env[v])
|
|
1284
|
+
const shellProxy = PROXY_ENV_KEYS.filter((k) => process.env[k])
|
|
1285
|
+
const proxyVars = (raw.proxy && raw.proxy.http) || !shellProxy.some((k) => !/^no_proxy$/i.test(k)) ? [] : shellProxy // 只有 NO_PROXY 不算有代理
|
|
1286
|
+
const carry = [...inShell, ...proxyVars]
|
|
1242
1287
|
let passEnv = !!values['pass-env']
|
|
1243
|
-
if (!passEnv &&
|
|
1288
|
+
if (!passEnv && carry.length && process.stdin.isTTY) {
|
|
1244
1289
|
const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
|
|
1245
|
-
const a = await new Promise((res) => rl.question(`${cyan('?')}
|
|
1290
|
+
const a = await new Promise((res) => rl.question(`${cyan('?')} ${tr(`终端里的环境变量 ${carry.join(' ')} 服务里拿不到 —— 把它们的值写进服务定义吗?(文件权限 600;以后改了要重跑这条命令)`, `The service can't see these variables from your terminal: ${carry.join(' ')}. Write their values into the service definition? (file mode 600; re-run this command after changing them)`)} ${dim('(Y/n)')} `, res))
|
|
1246
1291
|
rl.close()
|
|
1247
1292
|
passEnv = !/^n/i.test(String(a).trim())
|
|
1248
1293
|
}
|
|
1249
|
-
const env = passEnv ? Object.fromEntries(
|
|
1294
|
+
const env = passEnv ? Object.fromEntries(carry.map((v) => [v, process.env[v]])) : {}
|
|
1250
1295
|
const tools = ['claude', 'codex'].map((n) => whichSync(n)).filter(Boolean)
|
|
1251
1296
|
const node = svc.stableNodeBin({ execPath: process.execPath, candidates: [whichSync('node')] })
|
|
1252
1297
|
const sbotBin = path.resolve(__dirname, 'sbot.js')
|
|
@@ -1262,6 +1307,8 @@ async function cmdService(rest) {
|
|
|
1262
1307
|
if (node.warn) process.stderr.write(yellow('⚠ ' + node.warn) + '\n')
|
|
1263
1308
|
if (svc.versionedPath(sbotBin)) process.stderr.write(yellow(`⚠ sbot 装在带版本号的 node 目录下(${sbotBin}),换 node 版本后要重装 sintbot 并重跑 sintbot service install`) + '\n')
|
|
1264
1309
|
if (inShell.length && !passEnv) process.stderr.write(yellow(`⚠ 配置引用的环境变量 ${inShell.join(' ')} 只在当前终端里有,服务里拿不到 —— 加 --pass-env 把值写进服务定义(文件权限 600),或把值直接写进配置(配置本身是 600)`) + '\n')
|
|
1310
|
+
else if (proxyVars.length && !passEnv) process.stderr.write(dim(tr(` 终端里的代理(${proxyVars.join(' ')})没带进服务;服务里要用代理,在控制台「设置 → 机器人与网络」填`, ` Your terminal's proxy (${proxyVars.join(' ')}) was not carried into the service; to use a proxy there, set it in the console under Settings → Bot & network`)) + '\n')
|
|
1311
|
+
if (noPlatform) process.stderr.write(dim(tr(' 还没接聊天平台:服务先只开控制台,在「设置」里接好平台后会自动重启并连上', 'No chat platform yet: the service starts with just the console; once you connect one under Settings it restarts itself and connects')) + '\n')
|
|
1265
1312
|
if (nowhere.length) process.stderr.write(yellow(`⚠ 配置引用的环境变量 ${nowhere.join(' ')} 当前终端里也没有 —— 用到它们的模型 / 平台不会工作`) + '\n')
|
|
1266
1313
|
if (platform !== 'darwin' && platform !== 'linux') { process.stdout.write(svc.windowsHint(spec) + '\n'); return }
|
|
1267
1314
|
if (!tools.length) process.stderr.write(yellow('⚠ PATH 里没找到 claude / codex —— 服务能起,但群里的轮次会失败;装好后重跑 sintbot service install') + '\n')
|
|
@@ -1270,14 +1317,17 @@ async function cmdService(rest) {
|
|
|
1270
1317
|
process.stdout.write(dim(`# ${svc.unitPath(platform, home)}`) + '\n' + svc.render(platform, shown)); return
|
|
1271
1318
|
}
|
|
1272
1319
|
const r = svc.install(spec, { platform, home })
|
|
1273
|
-
process.stderr.write(cyan('✓ ') + `已写入 ${r.file}` + (r.backup ? dim(`(原文件备份为 ${path.basename(r.backup)})`) : '') + '\n')
|
|
1320
|
+
process.stderr.write(cyan('✓ ') + tr(`已写入 ${r.file}`, `Wrote ${r.file}`) + (r.backup ? dim(tr(`(原文件备份为 ${path.basename(r.backup)})`, ` (previous file backed up as ${path.basename(r.backup)})`)) : '') + '\n')
|
|
1274
1321
|
for (const s of r.steps) process.stderr.write(`${s.ok ? cyan('✓') : red('✗')} ${dim(s.cmd)}${s.err && !s.ok ? ' ' + red(s.err.split('\n')[0]) : ''}\n`)
|
|
1275
1322
|
await new Promise((res) => setTimeout(res, 1500))
|
|
1276
|
-
process.stderr.write(
|
|
1323
|
+
process.stderr.write(`${tr('状态', 'Status')}:${nice(svc.status({ platform, home }))}\n` + dim(`${tr('配置', 'Config')}:${spec.args[spec.args.indexOf('--config') + 1]}\n${tr('日志', 'Log')}:${spec.errLog}\n`))
|
|
1277
1324
|
if (platform === 'linux') process.stderr.write(dim('提示:要开机就起(不必先登录),跑一次 loginctl enable-linger $USER') + '\n')
|
|
1278
1325
|
if (r.steps.some((s) => !s.ok)) process.exit(1)
|
|
1279
1326
|
return
|
|
1280
1327
|
}
|
|
1328
|
+
// --check:只给退出码(0 在运行 / 3 装了但没在跑 / 4 没装或这个平台没有自动服务),install.sh 据此决定问不问装服务、要不要重启。
|
|
1329
|
+
// 不用 1、2:1.0.11 及以前不认 --check,报错退出就是 1,install.sh 得能把「不知道」和「没在跑」分开
|
|
1330
|
+
if (action === 'status' && values.check) { const st = svc.status({ platform, home }); process.exit(st.running ? 0 : st.installed || st.loaded ? 3 : 4) }
|
|
1281
1331
|
if (action === 'status') { const st = svc.status({ platform, home }); process.stdout.write(`${nice(st)}\n` + dim(st.file ? `单元文件:${st.file}${st.installed ? '' : '(不存在)'}\n` : '这个平台没有自动服务(见 sintbot service --help)\n')); return }
|
|
1282
1332
|
if (action === 'restart') { const r = svc.restart({ platform }); if (!r.ok) return die('重启失败:' + (r.err || '?')); process.stderr.write(cyan('✓ ') + '已重启\n'); return }
|
|
1283
1333
|
if (action === 'uninstall') { const r = svc.uninstall({ platform, home }); process.stderr.write((r.removed ? cyan('✓ ') + `已停止并删除 ${r.file}` : dim('没有已安装的服务')) + '\n' + dim('日志与配置都保留在 ~/.sbot/') + '\n'); return }
|
|
@@ -1301,19 +1351,25 @@ async function cmdService(rest) {
|
|
|
1301
1351
|
const SETUP_MAX_ASKS_AFTER_EOF = 60
|
|
1302
1352
|
|
|
1303
1353
|
async function cmdSetup(rest) {
|
|
1304
|
-
const { values } = parseArgs({ args: rest, allowPositionals: true, options: { config: { type: 'string' } } })
|
|
1354
|
+
const { values } = parseArgs({ args: rest, allowPositionals: true, options: { config: { type: 'string' }, full: { type: 'boolean' } } })
|
|
1305
1355
|
const cfgPath = defaultConfigPath(values.config)
|
|
1306
1356
|
const examplePath = path.join(__dirname, '..', 'sbot.config.example.json')
|
|
1357
|
+
// 1.0.12 起默认只问环境和模型(用户 10-28 定:是否在中国大陆、代理、项目根目录、聊天平台凭据都不在终端里要,装完在控制台配)。
|
|
1358
|
+
// --full = 旧的完整问答(平台凭据 / 代理 / 项目根目录 / 绑群),给没有浏览器的服务器用;那部分只有中文。
|
|
1359
|
+
const full = !!values.full
|
|
1360
|
+
const T = (zh, en) => tr(zh, en) // 中文 / 英文按 src/clilang.js
|
|
1361
|
+
const lang = tr('zh', 'en')
|
|
1362
|
+
const fromInstaller = process.env.SINTBOT_INSTALLER === '1' // install.sh 调起:不重复报 Node、不打自己的「下一步」
|
|
1307
1363
|
|
|
1308
1364
|
let raw
|
|
1309
1365
|
let seeded = false
|
|
1310
1366
|
if (fs.existsSync(cfgPath)) {
|
|
1311
1367
|
try { raw = JSON.parse(fs.readFileSync(cfgPath, 'utf8')) } catch (e) { return die(`existing config is invalid JSON: ${e.message}`) }
|
|
1312
|
-
process.stdout.write(dim(`更新已有配置 ${cfgPath}`) + '\n')
|
|
1368
|
+
process.stdout.write(dim(T(`更新已有配置 ${cfgPath}`, `Updating the existing config ${cfgPath}`)) + '\n')
|
|
1313
1369
|
} else if (fs.existsSync(examplePath)) {
|
|
1314
1370
|
raw = JSON.parse(fs.readFileSync(examplePath, 'utf8'))
|
|
1315
1371
|
seeded = true
|
|
1316
|
-
process.stdout.write(dim(
|
|
1372
|
+
process.stdout.write(dim(T(`新建配置,问完再写入 ${cfgPath}`, `New config; it is written to ${cfgPath} at the end`)) + '\n')
|
|
1317
1373
|
} else {
|
|
1318
1374
|
// claude 不钉 model(v0.11.1):写死会覆盖 CLI 自己的默认,每出一代新模型就落后一代(v0.11.0 拍板)
|
|
1319
1375
|
raw = { agent: { bin: 'claude', permissionMode: 'dontAsk', maxTurnTimeMins: 10, maxConcurrent: 2 }, defaultProvider: 'claude', providers: { claude: {} }, groups: {} }
|
|
@@ -1331,6 +1387,9 @@ async function cmdSetup(rest) {
|
|
|
1331
1387
|
// On EOF, pending asks resolve to null → the caller falls back to its default,
|
|
1332
1388
|
// so a short/redirected input just accepts defaults instead of hanging.
|
|
1333
1389
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
1390
|
+
// 问题交给 readline 当提示符写(setPrompt + prompt),不直接写 stdout:readline 一重绘这一行(改窗口大小、退格)
|
|
1391
|
+
// 就按它记的提示符重写 —— 默认是 `> `,1.0.11 实装里问题文字被吞成「> y」、试跑时冒出一个 `>`。问完和空闲时置空。
|
|
1392
|
+
rl.setPrompt('')
|
|
1334
1393
|
const queued = []
|
|
1335
1394
|
let waiter = null
|
|
1336
1395
|
let closed = false
|
|
@@ -1344,8 +1403,11 @@ async function cmdSetup(rest) {
|
|
|
1344
1403
|
// 另给一个「输入已结束」时的答案(endedDef);② 兜底:输入结束后还问了这么多次就中止,配置不落盘。
|
|
1345
1404
|
let askedAfterEnd = 0
|
|
1346
1405
|
const ask = async (q, def, endedDef = def) => {
|
|
1347
|
-
|
|
1406
|
+
const line = `${cyan('?')} ${q}${def ? dim(` [${def}]`) : ''} `
|
|
1407
|
+
if (closed) process.stdout.write(line)
|
|
1408
|
+
else { rl.setPrompt(line); rl.prompt() }
|
|
1348
1409
|
const a = await nextLine()
|
|
1410
|
+
if (!closed) rl.setPrompt('')
|
|
1349
1411
|
if (a == null) {
|
|
1350
1412
|
process.stdout.write('\n')
|
|
1351
1413
|
if (++askedAfterEnd > SETUP_MAX_ASKS_AFTER_EOF) throw new Error('输入已经结束,向导还在提问 —— 已中止,配置没有写入。请在终端里直接运行 sintbot setup')
|
|
@@ -1366,34 +1428,36 @@ async function cmdSetup(rest) {
|
|
|
1366
1428
|
}
|
|
1367
1429
|
|
|
1368
1430
|
try {
|
|
1369
|
-
|
|
1431
|
+
// 安装脚本调起时它自己已经说过「这里只配模型」,不再重复
|
|
1432
|
+
const scope = fromInstaller ? T('(Enter 用默认值)', ' (Enter accepts the default)') : T('配模型(聊天平台、代理、项目目录装完在控制台里配;Enter 用默认值)', ' model setup (chat platform, proxy and project folder are set in the console afterwards; Enter accepts the default)')
|
|
1433
|
+
process.stdout.write('\n' + cyan('sintbot setup') + dim(full ? ' — 完整配置(Enter 用默认值)' : fromInstaller ? scope : T(' — ' + scope, ' —' + scope)) + '\n\n')
|
|
1370
1434
|
|
|
1371
1435
|
// 第 0 步:环境检查 —— 只报不拦(缺了照样能把配置写完,最后的体检会接着报)。
|
|
1372
1436
|
// 缺 claude / codex 时问一句替新用户装(npm i -g;官方源连不上自动换国内镜像,见 src/installdeps.js)。
|
|
1373
|
-
process.stdout.write(gray('— 环境 —') + '\n')
|
|
1437
|
+
process.stdout.write(gray(T('— 环境 —', '— Environment —')) + '\n')
|
|
1374
1438
|
const nodeOk = Number(process.versions.node.split('.')[0]) >= 22
|
|
1375
|
-
process.stdout.write(` ${nodeOk ? cyan('✓') : red('✗')} Node ${process.versions.node}${nodeOk ? '' : red('(需要 ≥ 22)')}\n`)
|
|
1439
|
+
if (!fromInstaller || !nodeOk) process.stdout.write(` ${nodeOk ? cyan('✓') : red('✗')} Node ${process.versions.node}${nodeOk ? '' : red(T('(需要 ≥ 22)', ' (needs 22 or later)'))}\n`)
|
|
1376
1440
|
const installDep = (name, binName) => {
|
|
1377
1441
|
const r = installdeps.installGlobal(name, { log: (l) => process.stdout.write(dim(' ' + l) + '\n') })
|
|
1378
1442
|
const bin = whichSync(binName)
|
|
1379
|
-
if (r.ok && bin) { process.stdout.write(` ${cyan('✓')} 已安装 ${binName} ${dim(bin)}\n`); return bin }
|
|
1443
|
+
if (r.ok && bin) { process.stdout.write(` ${cyan('✓')} ${T('已安装', 'Installed')} ${binName} ${dim(bin)}\n`); return bin }
|
|
1380
1444
|
if (r.ok) {
|
|
1381
1445
|
const pre = installdeps.globalPrefix()
|
|
1382
|
-
const dir = pre ? (process.platform === 'win32' ? pre : path.join(pre, 'bin')) : 'npm prefix -g 下的 bin 目录'
|
|
1383
|
-
process.stdout.write(` ${yellow('!')}
|
|
1446
|
+
const dir = pre ? (process.platform === 'win32' ? pre : path.join(pre, 'bin')) : T('npm prefix -g 下的 bin 目录', 'the bin folder under npm prefix -g')
|
|
1447
|
+
process.stdout.write(` ${yellow('!')} ${T(`装好了,但当前 PATH 里找不到 ${binName} —— 把 ${dir} 加进 PATH,重开终端再跑一次 sintbot setup`, `Installed, but ${binName} is not on PATH: add ${dir} to PATH, open a new terminal and run sintbot setup again`)}\n`)
|
|
1384
1448
|
return null
|
|
1385
1449
|
}
|
|
1386
|
-
process.stdout.write(` ${red('✗')}
|
|
1450
|
+
process.stdout.write(` ${red('✗')} ${T(`安装失败(退出码 ${r.code})—— 多半是 npm 全局目录没有写权限,做法见 https://github.com/sintlab/sintbot/blob/main/docs/zh-CN/install.md「一键安装」`, `Install failed (exit code ${r.code}), most likely the npm global folder is not writable; see https://github.com/sintlab/sintbot/blob/main/docs/install.md`)}\n`)
|
|
1387
1451
|
return null
|
|
1388
1452
|
}
|
|
1389
1453
|
const claudeName = (raw.agent && raw.agent.bin) || 'claude'
|
|
1390
1454
|
let claudeBin = whichSync(claudeName)
|
|
1391
1455
|
if (claudeBin) process.stdout.write(` ${cyan('✓')} claude ${dim(claudeBin)}\n`)
|
|
1392
1456
|
else {
|
|
1393
|
-
process.stdout.write(` ${red('✗')} 没找到 claude(Claude Code CLI)——
|
|
1457
|
+
process.stdout.write(` ${red('✗')} ${T('没找到 claude(Claude Code CLI)—— 每一轮都靠它干活', 'claude (Claude Code CLI) not found; every turn runs on it')}\n`)
|
|
1394
1458
|
// 没人回答(输入已结束)就不替他装软件
|
|
1395
|
-
if (await askYN(`现在安装 Claude Code(npm i -g ${installdeps.PACKAGES.claude})?`, true, false)) claudeBin = installDep('claude', claudeName)
|
|
1396
|
-
else process.stdout.write(dim(` 稍后自己装:npm i -g ${installdeps.PACKAGES.claude}`) + '\n')
|
|
1459
|
+
if (await askYN(T(`现在安装 Claude Code(npm i -g ${installdeps.PACKAGES.claude})?`, `Install Claude Code now (npm i -g ${installdeps.PACKAGES.claude})?`), true, false)) claudeBin = installDep('claude', claudeName)
|
|
1460
|
+
else process.stdout.write(dim(T(` 稍后自己装:npm i -g ${installdeps.PACKAGES.claude}`, ` Install it later: npm i -g ${installdeps.PACKAGES.claude}`)) + '\n')
|
|
1397
1461
|
}
|
|
1398
1462
|
// Claude 登录态留到模型步一起报,那里据此决定推荐走哪条路。官方非交互子命令,0.2s、不联网;
|
|
1399
1463
|
// 老版本 CLI 没有它(ok:false)⇒ null,当作没探到
|
|
@@ -1406,25 +1470,48 @@ async function cmdSetup(rest) {
|
|
|
1406
1470
|
let codexBin = whichSync('codex')
|
|
1407
1471
|
let codexLoggedIn = false
|
|
1408
1472
|
if (!codexBin) {
|
|
1409
|
-
process.stdout.write(` ${gray('○')} 没找到 codex(可选:有 ChatGPT 账号时跑 GPT 群、生图)\n`)
|
|
1410
|
-
if (await askYN(`现在安装 Codex CLI(npm i -g ${installdeps.PACKAGES.codex})?有 ChatGPT 账号才用得上`, false)) codexBin = installDep('codex', 'codex')
|
|
1411
|
-
else process.stdout.write(dim(` 要用时再装:npm i -g ${installdeps.PACKAGES.codex},再 codex login`) + '\n')
|
|
1473
|
+
process.stdout.write(` ${gray('○')} ${T('没找到 codex(可选:有 ChatGPT 账号时跑 GPT 群、生图)', 'codex not found (optional: with a ChatGPT account it runs GPT groups and image generation)')}\n`)
|
|
1474
|
+
if (await askYN(T(`现在安装 Codex CLI(npm i -g ${installdeps.PACKAGES.codex})?有 ChatGPT 账号才用得上`, `Install Codex CLI now (npm i -g ${installdeps.PACKAGES.codex})? Only useful with a ChatGPT account`), false)) codexBin = installDep('codex', 'codex')
|
|
1475
|
+
else process.stdout.write(dim(T(` 要用时再装:npm i -g ${installdeps.PACKAGES.codex},再 codex login`, ` When you need it: npm i -g ${installdeps.PACKAGES.codex}, then codex login`)) + '\n')
|
|
1412
1476
|
}
|
|
1413
1477
|
if (codexBin) {
|
|
1414
1478
|
// 只读本机登录态(不联网);没登录时,GPT 群、生图、claude 额度用完自动切到 codex 都不会成
|
|
1415
1479
|
const codexLogin = await codexstatus.loginStatus('codex').catch(() => null)
|
|
1416
1480
|
const codexOut = codexLogin && codexLogin.ok && codexLogin.loggedIn === false
|
|
1417
1481
|
codexLoggedIn = !!(codexLogin && codexLogin.ok && codexLogin.loggedIn)
|
|
1418
|
-
process.stdout.write(` ${cyan('✓')} codex ${dim(codexBin)}${codexOut ? ' ' + yellow('没登录 —— GPT 群、生图、额度用完时自动切到 codex 都要它:跑一次 codex login') : ''}\n`)
|
|
1482
|
+
process.stdout.write(` ${cyan('✓')} codex ${dim(codexBin)}${codexOut ? ' ' + yellow(T('没登录 —— GPT 群、生图、额度用完时自动切到 codex 都要它:跑一次 codex login', 'not logged in; GPT groups, image generation and switching to codex when quota runs out all need it: run codex login')) : ''}\n`)
|
|
1419
1483
|
}
|
|
1420
|
-
|
|
1484
|
+
// 设计图标库(Lucide / Tabler / Phosphor / Pixelarticons):agent 出设计图时按名字取图标,缺了只能自己画、
|
|
1485
|
+
// 与设计图对不上。缺哪套问一句一次装齐(默认装);没人回答(输入已结束)不装,doctor 会接着提示
|
|
1486
|
+
{
|
|
1487
|
+
const iconRoots = () => design.iconRoots({ base: path.join(__dirname, '..'), extra: (raw.design && raw.design.iconsDir) || null })
|
|
1488
|
+
const missing = () => Object.entries(iconRoots()).filter(([, v]) => !v).map(([k]) => k)
|
|
1489
|
+
const miss = missing()
|
|
1490
|
+
if (!miss.length) process.stdout.write(` ${cyan('✓')} ${T('设计图标库', 'Design icon libraries')} ${dim(Object.values(design.ICON_LIBS).map((l) => l.label).join(' · '))}\n`)
|
|
1491
|
+
else {
|
|
1492
|
+
const libs = miss.map((k) => design.ICON_LIBS[k])
|
|
1493
|
+
const pkgs = libs.map((l) => l.pkg)
|
|
1494
|
+
const mb = Math.round(libs.reduce((n, l) => n + (l.mb || 0), 0))
|
|
1495
|
+
process.stdout.write(` ${gray('○')} ${T(`设计图标库缺 ${libs.map((l) => l.label).join(' / ')}(出设计图时 agent 按名字取图标)`, `Design icon libraries missing: ${libs.map((l) => l.label).join(' / ')} (the agent picks icons by name when drawing designs)`)}\n`)
|
|
1496
|
+
if (await askYN(T(`现在安装(npm i -g ${pkgs.join(' ')},约 ${mb} MB)?`, `Install them now (npm i -g ${pkgs.join(' ')}, about ${mb} MB)?`), true, false)) {
|
|
1497
|
+
const r = installdeps.installPackages(pkgs, { log: (l) => process.stdout.write(dim(' ' + l) + '\n') })
|
|
1498
|
+
const still = missing()
|
|
1499
|
+
if (r.ok && !still.length) process.stdout.write(` ${cyan('✓')} ${T('设计图标库已装', 'Design icon libraries installed')}\n`)
|
|
1500
|
+
else if (r.ok) {
|
|
1501
|
+
const pre = installdeps.globalPrefix()
|
|
1502
|
+
const dir = pre ? (process.platform === 'win32' ? path.join(pre, 'node_modules') : path.join(pre, 'lib', 'node_modules')) : 'npm 全局目录下的 node_modules'
|
|
1503
|
+
process.stdout.write(` ${yellow('!')} ${T(`装好了,但 sbot 找不到 ${still.join(' / ')} —— 在配置里把 design.iconsDir 设成 ${dir}`, `Installed, but sbot cannot find ${still.join(' / ')}; set design.iconsDir to ${dir} in the config`)}\n`)
|
|
1504
|
+
} else process.stdout.write(` ${red('✗')} ${T(`安装失败(退出码 ${r.code})—— 多半是 npm 全局目录没有写权限,之后可以自己装:npm i -g ${pkgs.join(' ')}`, `Install failed (exit code ${r.code}), most likely the npm global folder is not writable; install them later: npm i -g ${pkgs.join(' ')}`)}\n`)
|
|
1505
|
+
} else process.stdout.write(dim(T(` 要用时再装:npm i -g ${pkgs.join(' ')}`, ` When you need them: npm i -g ${pkgs.join(' ')}`)) + '\n')
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
process.stdout.write(dim(T(` 配置文件:${cfgPath}`, ` Config file: ${cfgPath}`)) + '\n\n')
|
|
1421
1509
|
|
|
1422
|
-
//
|
|
1423
|
-
|
|
1424
|
-
const mainland = await askYN('这台机器在中国大陆吗?(决定模型连哪个站点、要不要代理、推荐哪个聊天平台)', setupmodels.looksMainland())
|
|
1510
|
+
// 不问「在不在中国大陆」(隐私,用户 10-28 定):只按时区猜,决定有国际站的厂商先试哪个站、--full 里推荐哪个平台;不写进配置
|
|
1511
|
+
const mainland = setupmodels.looksMainland()
|
|
1425
1512
|
|
|
1426
1513
|
// 模型:推荐顺序 Claude 订阅 → ChatGPT(Codex)→ 都没有推荐 DeepSeek(src/setupmodels.js)
|
|
1427
|
-
process.stdout.write(
|
|
1514
|
+
process.stdout.write(gray(T('— 模型 —', '— Model —')) + '\n')
|
|
1428
1515
|
let example = null
|
|
1429
1516
|
try { example = JSON.parse(fs.readFileSync(examplePath, 'utf8')) } catch {}
|
|
1430
1517
|
const presetOf = (name) => (example && example.providers && example.providers[name]) || null
|
|
@@ -1445,86 +1532,102 @@ async function cmdSetup(rest) {
|
|
|
1445
1532
|
const t0 = Date.now()
|
|
1446
1533
|
try {
|
|
1447
1534
|
const { result, aborted } = await runner.runTurn({
|
|
1448
|
-
bin: claudeBin || claudeName, prompt: verify.TRIAL_PROMPT, provider, model: provider.model,
|
|
1535
|
+
bin: claudeBin || claudeName, prompt: lang === 'en' ? verify.TRIAL_PROMPT_EN : verify.TRIAL_PROMPT, provider, model: provider.model,
|
|
1449
1536
|
workdir: os.tmpdir(), permissionMode: (raw.agent && raw.agent.permissionMode) || 'dontAsk', signal: ac.signal,
|
|
1450
1537
|
extraEnv: { CLAUDE_CODE_MAX_RETRIES: '0' },
|
|
1451
1538
|
})
|
|
1452
1539
|
const text = (result && result.text) || ''
|
|
1453
|
-
if (aborted || ac.signal.aborted) return { ok: false, text: `timeout
|
|
1540
|
+
if (aborted || ac.signal.aborted) return { ok: false, text: `timeout: ${T(`${Math.round((Date.now() - t0) / 1000)} 秒内没有回复`, `no reply within ${Math.round((Date.now() - t0) / 1000)} s`)}`, ms: Date.now() - t0 }
|
|
1454
1541
|
const ok = !!text && !(result && result.isError)
|
|
1455
1542
|
verify.recordTurn(provider, ok, text) // doctor / 控制台据此报「能用」或「key 不对」
|
|
1456
|
-
return { ok, text: text || '没有返回内容', ms: Date.now() - t0, model: provider.model }
|
|
1543
|
+
return { ok, text: text || T('没有返回内容', 'No output'), ms: Date.now() - t0, model: provider.model }
|
|
1457
1544
|
} catch (e) {
|
|
1458
1545
|
verify.recordTurn(provider, false, (e && e.message) || String(e))
|
|
1459
1546
|
return { ok: false, text: (e && e.message) || String(e), ms: Date.now() - t0 }
|
|
1460
1547
|
} finally { clearTimeout(timer) }
|
|
1461
1548
|
}
|
|
1462
1549
|
// 型号取配置里的(r.model),不靠模型自报:走 Claude Code 的第三方模型常自称 Claude,新用户会以为没切成
|
|
1463
|
-
const reportTurn = (label, r) => process.stdout.write(` ${cyan('✓')} ${label}${r.model ? ' · ' + r.model : ''}
|
|
1464
|
-
// 配一家用 API key
|
|
1550
|
+
const reportTurn = (label, r) => process.stdout.write(` ${cyan('✓')} ${label}${r.model ? ' · ' + r.model : ''} ${T(`能用(${(r.ms / 1000).toFixed(1)} 秒)`, `works (${(r.ms / 1000).toFixed(1)} s)`)}: ${r.text.replace(/\s+/g, ' ').slice(0, 80)}\n`)
|
|
1551
|
+
// 配一家用 API key 的:告诉去哪拿 key → 粘贴 → 当场试跑,失败说人话、可重试。
|
|
1552
|
+
// 有国际站的厂商(key 在哪个站开的就得连哪个站):不问人在哪 —— 先试一个站(已有配置按它现在的站,新配按时区),
|
|
1553
|
+
// 认证失败自动换另一个站再试;没装 claude 试跑不了时才问「这个 key 是哪个站开的」(问 key,不问人)
|
|
1465
1554
|
const configureKeyed = async (name) => {
|
|
1466
1555
|
const info = setupmodels.PROVIDER_INFO[name] || { label: name }
|
|
1556
|
+
const label = setupmodels.infoText(info, 'label', lang) || name
|
|
1557
|
+
const billing = setupmodels.infoText(info, 'billing', lang)
|
|
1467
1558
|
const p = ensurePreset(name)
|
|
1468
|
-
if (!p) { process.stdout.write(red(` 没有 ${name}
|
|
1469
|
-
|
|
1470
|
-
if (info.intl)
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
}
|
|
1475
|
-
if (site) process.stdout.write(dim(` 在 ${site} 注册 → 充值 → 创建 API key(${info.billing})`) + '\n')
|
|
1559
|
+
if (!p) { process.stdout.write(red(T(` 没有 ${name} 的预设`, ` No preset for ${name}`)) + '\n'); return false }
|
|
1560
|
+
const cnBase = (presetOf(name) && presetOf(name).baseUrl) || p.baseUrl
|
|
1561
|
+
const useSite = (k) => { if (info.intl) p.baseUrl = k === 'intl' ? info.intl.baseUrl : cnBase }
|
|
1562
|
+
const first = !info.intl ? 'cn' : p.baseUrl === info.intl.baseUrl ? 'intl' : setupmodels.keyPresent(p.apiKey) ? 'cn' : mainland ? 'cn' : 'intl'
|
|
1563
|
+
const sites = info.intl ? [first, first === 'cn' ? 'intl' : 'cn'] : ['cn']
|
|
1564
|
+
useSite(sites[0])
|
|
1565
|
+
if (info.intl) process.stdout.write(dim(T(` 在 ${info.site} 或国际站 ${info.intl.site} 注册 → 充值 → 创建 API key(${billing})`, ` Sign up at ${info.site} or the international site ${info.intl.site} → top up → create an API key (${billing})`)) + '\n')
|
|
1566
|
+
else if (info.site) process.stdout.write(dim(T(` 在 ${info.site} 注册 → 充值 → 创建 API key(${billing})`, ` Sign up at ${info.site} → top up → create an API key (${billing})`)) + '\n')
|
|
1476
1567
|
for (;;) {
|
|
1477
|
-
const
|
|
1478
|
-
const
|
|
1568
|
+
const envRef = setupmodels.keyPresent(p.apiKey) && String(p.apiKey).startsWith('env:') ? p.apiKey.slice(4) : ''
|
|
1569
|
+
const had = setupmodels.keyPresent(p.apiKey) ? (envRef ? T(`环境变量 ${envRef}`, `environment variable ${envRef}`) : T('已设置', 'set')) : ''
|
|
1570
|
+
const key = await askKey(T(`粘贴 ${label} 的 API key`, `Paste your ${label} API key`) + (had ? dim(T(`(当前:${had};留空=保持)`, ` (current: ${had}; empty = keep)`)) : T('(留空=跳过)', ' (empty = skip)')))
|
|
1479
1571
|
if (key) p.apiKey = key
|
|
1480
1572
|
if (!setupmodels.keyPresent(p.apiKey)) return false
|
|
1481
|
-
if (!claudeBin) {
|
|
1482
|
-
|
|
1483
|
-
|
|
1573
|
+
if (!claudeBin) {
|
|
1574
|
+
if (info.intl) useSite(await askYN(T(`这个 key 是在国际站 ${setupmodels.host(info.intl.site)} 开的吗?(否 = ${setupmodels.host(info.site)})`, `Was this key created on the international site ${setupmodels.host(info.intl.site)}? (no = ${setupmodels.host(info.site)})`), first === 'intl') ? 'intl' : 'cn')
|
|
1575
|
+
process.stdout.write(dim(T(' 没装 claude,先不试跑;装好后在控制台「模型」页点测试', ' claude is not installed, so no test run now; once it is, press Test on the console Models page')) + '\n')
|
|
1576
|
+
return true
|
|
1577
|
+
}
|
|
1578
|
+
let r = null
|
|
1579
|
+
for (const k of sites) {
|
|
1580
|
+
useSite(k)
|
|
1581
|
+
process.stdout.write(dim(T(` 用 ${label}${info.intl ? `(${setupmodels.host(p.baseUrl)})` : ''} 试跑一轮…`, ` Test run with ${label}${info.intl ? ` (${setupmodels.host(p.baseUrl)})` : ''}…`)) + '\n')
|
|
1582
|
+
r = await tryTurn(name)
|
|
1583
|
+
if (r.ok || setupmodels.explainTestFailure(r.text).kind !== 'auth') break // 只有认证失败才可能是站点不对
|
|
1584
|
+
}
|
|
1484
1585
|
tried.add(name)
|
|
1485
|
-
if (r.ok) { reportTurn(
|
|
1486
|
-
|
|
1586
|
+
if (r.ok) { reportTurn(label, r); verified.push(name); return true }
|
|
1587
|
+
useSite(sites[0])
|
|
1588
|
+
const why = setupmodels.explainTestFailure(r.text, { baseUrl: p.baseUrl, lang })
|
|
1487
1589
|
process.stdout.write(` ${red('✗')} ${why.text}\n`)
|
|
1488
|
-
if (!(await askYN('再试一次?(可以重新粘贴 key)', why.kind === 'auth' || why.kind === 'network', false))) {
|
|
1489
|
-
process.stdout.write(dim(' key 先保存;跑通之前这个模型的轮次会失败,之后可在控制台「模型」页再测') + '\n')
|
|
1590
|
+
if (!(await askYN(T('再试一次?(可以重新粘贴 key)', 'Try again? (you can paste the key again)'), why.kind === 'auth' || why.kind === 'network', false))) {
|
|
1591
|
+
process.stdout.write(dim(T(' key 先保存;跑通之前这个模型的轮次会失败,之后可在控制台「模型」页再测', ' The key is saved; turns on this model will fail until it works; test it again later on the console Models page')) + '\n')
|
|
1490
1592
|
return true
|
|
1491
1593
|
}
|
|
1492
1594
|
}
|
|
1493
1595
|
}
|
|
1494
1596
|
const others = Object.keys(setupmodels.PROVIDER_INFO).filter((n) => n !== setupmodels.RECOMMENDED)
|
|
1495
1597
|
const pickOthers = async () => {
|
|
1496
|
-
others.forEach((n, i) => process.stdout.write(` ${i + 1}) ${setupmodels.PROVIDER_INFO[n]
|
|
1497
|
-
const sel = await ask('选哪家?序号,可空格分隔多选(留空 = 返回)', '')
|
|
1598
|
+
others.forEach((n, i) => process.stdout.write(` ${i + 1}) ${setupmodels.infoText(setupmodels.PROVIDER_INFO[n], 'label', lang)} ${dim(setupmodels.infoText(setupmodels.PROVIDER_INFO[n], 'billing', lang))}\n`))
|
|
1599
|
+
const sel = await ask(T('选哪家?序号,可空格分隔多选(留空 = 返回)', 'Which one? Number(s), space-separated (empty = back)'), '')
|
|
1498
1600
|
const picked = [...new Set(sel.split(/[\s,]+/).map((x) => others[Number(x) - 1]).filter(Boolean))]
|
|
1499
1601
|
for (const n of picked) await configureKeyed(n)
|
|
1500
1602
|
return picked.length > 0
|
|
1501
1603
|
}
|
|
1502
1604
|
|
|
1503
1605
|
const codexReady = () => !!(codexBin && codexLoggedIn)
|
|
1504
|
-
|
|
1505
|
-
process.stdout.write(`
|
|
1506
|
-
|
|
1606
|
+
const col = (zh, en) => T(zh.padEnd(14 - (zh.length - zh.replace(/[^\x00-\xff]/g, '').length)), en.padEnd(20))
|
|
1607
|
+
process.stdout.write(` ${col('Claude 订阅', 'Claude subscription')} ${claudeLoggedIn ? cyan(T('✓ 已登录', '✓ logged in')) : gray(claudeBin ? T('○ 未登录', '○ not logged in') : T('○ 没装 claude', '○ claude not installed'))}\n`)
|
|
1608
|
+
process.stdout.write(` ${col('ChatGPT(Codex)', 'ChatGPT (Codex)')} ${codexReady() ? cyan(T('✓ 已登录', '✓ logged in')) : gray(codexBin ? T('○ 未登录', '○ not logged in') : T('○ 没装 codex', '○ codex not installed'))}\n`)
|
|
1609
|
+
if (readyKeyed().length) process.stdout.write(` ${col('API key 模型', 'API key models')} ${cyan('✓')} ${readyKeyed().join(' ')}\n`)
|
|
1507
1610
|
let route = setupmodels.subscriptionRoute({ claudeLoggedIn, codexLoggedIn: codexReady() })
|
|
1508
1611
|
if (route || readyKeyed().length) {
|
|
1509
1612
|
// 已经有能用的:不打扰,只问要不要再加一个(群里 /model 可切)
|
|
1510
|
-
if (await askYN('再加一个用 API key 的模型吗?(DeepSeek 等,群里 /model 可切换)', false)) {
|
|
1511
|
-
process.stdout.write(` 1) DeepSeek ${dim('(推荐,按量付费)')}\n 2) 其他:智谱 GLM / MiniMax /
|
|
1512
|
-
if ((await ask('选', '1')) === '2') await pickOthers()
|
|
1613
|
+
if (await askYN(T('再加一个用 API key 的模型吗?(DeepSeek 等,群里 /model 可切换)', 'Add another model that uses an API key? (DeepSeek etc.; switch in a group with /model)'), false)) {
|
|
1614
|
+
process.stdout.write(` 1) DeepSeek ${dim(T('(推荐,按量付费)', '(recommended, pay as you go)'))}\n 2) ${T('其他:智谱 GLM / MiniMax / 火山方舟', 'Others: Zhipu GLM / MiniMax / Volcano Ark')}\n`)
|
|
1615
|
+
if ((await ask(T('选', 'Choose'), '1')) === '2') await pickOthers()
|
|
1513
1616
|
else await configureKeyed(setupmodels.RECOMMENDED)
|
|
1514
1617
|
}
|
|
1515
1618
|
} else {
|
|
1516
|
-
process.stdout.write('\n 没检测到 Claude 订阅或 Codex 登录。' + cyan('推荐用 DeepSeek') + ':注册充值就能用、按量付费,不需要 Claude
|
|
1619
|
+
process.stdout.write('\n ' + T('没检测到 Claude 订阅或 Codex 登录。', 'No Claude subscription or Codex login found. ') + cyan(T('推荐用 DeepSeek', 'DeepSeek is recommended')) + T(':注册充值就能用、按量付费,不需要 Claude 账号。', ': sign up, top up and go, pay as you go, no Claude account needed.') + '\n')
|
|
1517
1620
|
for (;;) {
|
|
1518
|
-
process.stdout.write(` 1) 用 DeepSeek ${dim('(推荐)')}—— 粘贴 API key\n`
|
|
1519
|
-
+
|
|
1520
|
-
+
|
|
1521
|
-
+ ` 4)
|
|
1522
|
-
+
|
|
1523
|
-
const a = await ask('选', '1', '5') // 输入已结束 ⇒ 先跳过(选 1 拿不到 key 会回到这里)
|
|
1621
|
+
process.stdout.write(` 1) ${T('用 DeepSeek', 'DeepSeek')} ${dim(T('(推荐)', '(recommended)'))}${T('—— 粘贴 API key', ': paste an API key')}\n`
|
|
1622
|
+
+ ` 2) ${T('用别家:智谱 GLM / MiniMax / 火山方舟', 'Another provider: Zhipu GLM / MiniMax / Volcano Ark')}\n`
|
|
1623
|
+
+ ` 3) ${T('用 Claude 订阅 —— 另开一个终端跑 claude auth login,登录好回到这里', 'Claude subscription: run claude auth login in another terminal, then come back here')}\n`
|
|
1624
|
+
+ ` 4) ${T(`用 ChatGPT(Codex)—— 另开一个终端跑 codex login(没装先 npm i -g ${installdeps.PACKAGES.codex}),登录好回到这里`, `ChatGPT (Codex): run codex login in another terminal (install first with npm i -g ${installdeps.PACKAGES.codex}), then come back here`)}\n`
|
|
1625
|
+
+ ` 5) ${T('先跳过(之后在控制台「模型」页添加)', 'Skip for now (add one later on the console Models page)')}\n`)
|
|
1626
|
+
const a = await ask(T('选', 'Choose'), '1', '5') // 输入已结束 ⇒ 先跳过(选 1 拿不到 key 会回到这里)
|
|
1524
1627
|
if (a === '1') { if (await configureKeyed(setupmodels.RECOMMENDED)) break; continue }
|
|
1525
1628
|
if (a === '2') { if (await pickOthers() && readyKeyed().length) break; continue }
|
|
1526
1629
|
if (a === '3' || a === '4') {
|
|
1527
|
-
await ask('登录好了按回车', '')
|
|
1630
|
+
await ask(T('登录好了按回车', 'Press Enter once logged in'), '')
|
|
1528
1631
|
if (a === '3') claudeLoggedIn = await claudeLogin()
|
|
1529
1632
|
else {
|
|
1530
1633
|
codexBin = codexBin || whichSync('codex')
|
|
@@ -1532,8 +1635,8 @@ async function cmdSetup(rest) {
|
|
|
1532
1635
|
codexLoggedIn = !!(st && st.ok && st.loggedIn)
|
|
1533
1636
|
}
|
|
1534
1637
|
route = setupmodels.subscriptionRoute({ claudeLoggedIn, codexLoggedIn: codexReady() })
|
|
1535
|
-
if (route) { process.stdout.write(` ${cyan('✓')} ${route === 'claude' ? 'Claude 订阅' : 'Codex'}
|
|
1536
|
-
process.stdout.write(` ${yellow('!')}
|
|
1638
|
+
if (route) { process.stdout.write(` ${cyan('✓')} ${route === 'claude' ? T('Claude 订阅', 'Claude subscription') : 'Codex'} ${T('已登录', 'logged in')}\n`); break }
|
|
1639
|
+
process.stdout.write(` ${yellow('!')} ${T('还是没检测到登录', 'Still no login found')}\n`)
|
|
1537
1640
|
continue
|
|
1538
1641
|
}
|
|
1539
1642
|
break // 5 或其他:先跳过
|
|
@@ -1571,26 +1674,27 @@ async function cmdSetup(rest) {
|
|
|
1571
1674
|
const suggest = !seeded && raw.defaultProvider && usable(raw.defaultProvider) ? raw.defaultProvider : auto
|
|
1572
1675
|
const usableNames = Object.keys(raw.providers).filter(usable)
|
|
1573
1676
|
if (usableNames.length > 1) {
|
|
1574
|
-
const def = await ask(`默认模型(${usableNames.join('/')};群里 /model 随时切)`, suggest)
|
|
1677
|
+
const def = await ask(T(`默认模型(${usableNames.join('/')};群里 /model 随时切)`, `Default model (${usableNames.join('/')}; switch any time in a group with /model)`), suggest)
|
|
1575
1678
|
raw.defaultProvider = raw.providers[def] ? def : suggest
|
|
1576
|
-
if (!raw.providers[def]) process.stdout.write(red(` 没有叫 '${def}' 的模型,用 ${suggest}`) + '\n')
|
|
1679
|
+
if (!raw.providers[def]) process.stdout.write(red(T(` 没有叫 '${def}' 的模型,用 ${suggest}`, ` There is no model called '${def}'; using ${suggest}`)) + '\n')
|
|
1577
1680
|
} else {
|
|
1578
1681
|
raw.defaultProvider = suggest
|
|
1579
|
-
if (usableNames.length) process.stdout.write(dim(` 默认模型:${suggest}`) + '\n')
|
|
1682
|
+
if (usableNames.length) process.stdout.write(dim(T(` 默认模型:${suggest}`, ` Default model: ${suggest}`)) + '\n')
|
|
1580
1683
|
}
|
|
1581
1684
|
// 默认模型还没在本次跑通过(订阅登录那条路):问一句当场试跑
|
|
1582
1685
|
const dp = raw.defaultProvider
|
|
1583
1686
|
const canTry = raw.providers[dp] && usable(dp) && (raw.providers[dp].runner === 'codex' ? codexBin : claudeBin)
|
|
1584
|
-
if (canTry && !tried.has(dp) && await askYN(`用 ${dp} 试跑一轮,确认能用?`, true)) {
|
|
1585
|
-
process.stdout.write(dim(
|
|
1687
|
+
if (canTry && !tried.has(dp) && await askYN(T(`用 ${dp} 试跑一轮,确认能用?`, `Do a test run with ${dp} to make sure it works?`), true)) {
|
|
1688
|
+
process.stdout.write(dim(T(' 试跑中…', ' Running…')) + '\n')
|
|
1586
1689
|
const r = await tryTurn(dp)
|
|
1587
1690
|
if (r.ok) { reportTurn(dp, r); verified.push(dp) }
|
|
1588
|
-
else process.stdout.write(` ${red('✗')} ${setupmodels.explainTestFailure(r.text, { baseUrl: raw.providers[dp].baseUrl }).text}\n`)
|
|
1691
|
+
else process.stdout.write(` ${red('✗')} ${setupmodels.explainTestFailure(r.text, { baseUrl: raw.providers[dp].baseUrl, lang }).text}\n`)
|
|
1589
1692
|
}
|
|
1590
1693
|
|
|
1591
|
-
//
|
|
1694
|
+
// 代理:默认不问(用户 10-28 定;结尾只给一句建议,服务安装时可把终端里的代理带进去)。--full 里用到 Claude / ChatGPT 才问;
|
|
1695
|
+
// 国内模型端点自动直连(noProxy)
|
|
1592
1696
|
const officialInUse = !!(claudeLoggedIn || codexReady() || !(raw.providers[raw.defaultProvider] || {}).baseUrl)
|
|
1593
|
-
if (
|
|
1697
|
+
if (full && officialInUse) {
|
|
1594
1698
|
if (await askYN('配置代理?(Claude / ChatGPT 要走代理;国内模型端点与飞书会自动直连)', !!(raw.proxy && raw.proxy.http))) {
|
|
1595
1699
|
const http = await ask('HTTP 代理地址', (raw.proxy && raw.proxy.http) || 'http://127.0.0.1:7890')
|
|
1596
1700
|
const noProxy = deriveNoProxy(raw.providers)
|
|
@@ -1603,207 +1707,221 @@ async function cmdSetup(rest) {
|
|
|
1603
1707
|
delete raw.proxy // 示例里的代理是占位,没问就不写进新配置;已有配置的代理不动
|
|
1604
1708
|
}
|
|
1605
1709
|
|
|
1606
|
-
//
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
const
|
|
1641
|
-
|
|
1642
|
-
const
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1710
|
+
// 聊天平台 / 机器人名 / 项目根目录 / 绑群:只在 --full 里问。默认流程里这些都在控制台配(「设置」「群 · 项目」),已有配置一律不动
|
|
1711
|
+
if (full) {
|
|
1712
|
+
// 机器人在群里叫什么(自知清单的自称 + 升级播报;企业微信还用它剥正文里的 @)
|
|
1713
|
+
process.stdout.write('\n' + gray('— 聊天平台 —') + '\n')
|
|
1714
|
+
const brand = await ask('机器人在群里叫什么', raw.brand || 'SintBot')
|
|
1715
|
+
if (brand && brand !== 'SintBot') raw.brand = brand
|
|
1716
|
+
else delete raw.brand
|
|
1717
|
+
|
|
1718
|
+
// 接哪些平台:可以同时接;默认 = 已经配好的那些,一个都没有时按地区推荐:中国大陆飞书,其余 Slack。
|
|
1719
|
+
// 飞书只支持中国版(open.feishu.cn,SDK 默认域名);国际版 Lark(larksuite.com)是另一套开放平台,还没接,提示里写明。
|
|
1720
|
+
const realCreds = (v) => v && !String(v).startsWith('env:') && !/x{6,}|XXXX/.test(String(v))
|
|
1721
|
+
const had = [realCreds(raw.feishu && raw.feishu.appId) && 'feishu', realCreds(raw.slack && raw.slack.botToken) && 'slack', realCreds(raw.wecom && raw.wecom.botId) && 'wecom'].filter(Boolean)
|
|
1722
|
+
const platQ = mainland ? '接哪个聊天平台?国内推荐飞书 —— feishu / wecom / slack,空格分隔可多选(none = 先不接)'
|
|
1723
|
+
: '接哪些聊天平台?推荐 Slack —— slack / feishu(飞书中国版,国际版 Lark 还不支持)/ wecom,空格分隔可多选(none = 先不接)'
|
|
1724
|
+
const platIn = (await ask(platQ, had.join(' ') || (mainland ? 'feishu' : 'slack'))).toLowerCase()
|
|
1725
|
+
const plats = platIn === 'none' ? [] : [...new Set(platIn.split(/[\s,]+/).filter((x) => ['feishu', 'slack', 'wecom'].includes(x)))]
|
|
1726
|
+
if (platIn !== 'none' && !plats.length) process.stdout.write(red(' 没认出平台名(feishu / slack / wecom),先跳过 —— 之后可重跑 setup 或去控制台配') + '\n')
|
|
1727
|
+
|
|
1728
|
+
if (plats.includes('feishu')) {
|
|
1729
|
+
process.stdout.write(gray(' 飞书:开放平台建「企业自建应用」→ 加机器人能力 → 事件订阅选长连接;权限清单见 https://github.com/sintlab/sintbot/blob/main/docs/zh-CN/platforms/feishu.md') + '\n')
|
|
1730
|
+
raw.feishu = raw.feishu || { onlyWhenMentioned: false }
|
|
1731
|
+
// 从示例新建:示例里的 env: 占位不是用户的值 —— 当作没填,免得默认值显示成 env:FEISHU_APP_ID、还提示「留空=保持不变」
|
|
1732
|
+
if (seeded) for (const k of ['appId', 'appSecret']) if (String(raw.feishu[k] || '').startsWith('env:')) raw.feishu[k] = ''
|
|
1733
|
+
// 示例里的 botOpenId 占位同理 —— 预检失败时下面那句「没填才问」会把它当成已填,原样写进新配置
|
|
1734
|
+
if (/^ou_x/i.test(String(raw.feishu.botOpenId || ''))) raw.feishu.botOpenId = ''
|
|
1735
|
+
// Default = the CURRENT value, incl. an `env:VAR` ref — Enter keeps it. Previously an env:
|
|
1736
|
+
// ref defaulted to '' so a plain Enter wiped it (broke creds for env-managed deploys).
|
|
1737
|
+
raw.feishu.appId = await ask('飞书 appId', raw.feishu.appId || '')
|
|
1738
|
+
const existingSecret = raw.feishu.appSecret || '' // incl. env: ref — 回车=保持不变
|
|
1739
|
+
const secretIn = await askKey(`飞书 appSecret${existingSecret ? '(留空=保持不变)' : ''}`)
|
|
1740
|
+
raw.feishu.appSecret = secretIn || existingSecret
|
|
1741
|
+
if (raw.feishu.onlyWhenMentioned === undefined) raw.feishu.onlyWhenMentioned = false
|
|
1742
|
+
|
|
1743
|
+
// Precheck (同源 feishuPrecheck):探 bot 身份 + 权限,自动回填 botOpenId。
|
|
1744
|
+
const feUsable = raw.feishu.appId && raw.feishu.appSecret && !String(raw.feishu.appId).startsWith('env:') && !String(raw.feishu.appSecret).startsWith('env:')
|
|
1745
|
+
if (feUsable && await askYN('飞书预检?(探 bot 身份/权限,自动填 botOpenId)', true)) {
|
|
1746
|
+
const mark = { ok: cyan('✓'), warn: '\x1b[33m!\x1b[0m', fail: red('✗'), skip: gray('○') }
|
|
1747
|
+
try {
|
|
1748
|
+
const r = await feishuPrecheck(feishuClient(raw.feishu.appId, raw.feishu.appSecret), { appId: raw.feishu.appId })
|
|
1749
|
+
verify.recordPrecheck('feishu', raw.feishu, r.checks)
|
|
1750
|
+
if (r.botOpenId) { raw.feishu.botOpenId = r.botOpenId; process.stdout.write(cyan(' ✓ ') + `botOpenId = ${r.botOpenId}\n`) }
|
|
1751
|
+
if (r.identity && r.identity.appName) process.stdout.write(cyan(' ✓ ') + `应用 = ${r.identity.appName}${r.identity.activateText ? ` (${r.identity.activateText})` : ''}\n`)
|
|
1752
|
+
for (const c of r.checks) process.stdout.write(` ${mark[c.status] || '?'} ${c.label} ${gray('— ' + (c.detail || '').slice(0, 140))}\n`) // 1.0.4:提示是写好的整句(怎么修),别截在半句
|
|
1753
|
+
} catch (e) { process.stdout.write(red(` 预检失败(${(e && e.message) || e})`) + '\n') }
|
|
1754
|
+
if (!raw.feishu.botOpenId) raw.feishu.botOpenId = await ask('bot openId (可留空)', '')
|
|
1755
|
+
} else {
|
|
1756
|
+
const curOid = String(raw.feishu.botOpenId || '').startsWith('ou_x') ? '' : (raw.feishu.botOpenId || '')
|
|
1757
|
+
raw.feishu.botOpenId = await ask('bot openId (可留空,用于精确 @ 门控)', curOid)
|
|
1758
|
+
}
|
|
1653
1759
|
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1760
|
+
// Admins (可多个,建群自动拉入):从通讯录选(推荐)/ 邮箱·手机号 / ou_ openId。
|
|
1761
|
+
if (feUsable) {
|
|
1762
|
+
const cur = Array.isArray(raw.feishu.admins) ? raw.feishu.admins.slice() : (raw.feishu.adminOpenId ? [{ openId: raw.feishu.adminOpenId, name: raw.feishu.adminName || '' }] : [])
|
|
1763
|
+
if (await askYN(`配置管理员?(可多个,建群自动拉入${cur.length ? ';当前 ' + cur.length + ' 人' : ''})`, !cur.length)) {
|
|
1764
|
+
const admins = cur.slice()
|
|
1765
|
+
const addOne = (openId, name) => { if (openId && !admins.some((a) => a.openId === openId)) { admins.push({ openId, name: name || openId }); process.stdout.write(cyan(' + ') + (name || openId) + '\n') } }
|
|
1766
|
+
const cli = feishuClient(raw.feishu.appId, raw.feishu.appSecret)
|
|
1767
|
+
let users = []
|
|
1768
|
+
try { users = (await fetchOrgUsers(cli)).users } catch {}
|
|
1769
|
+
if (users.length) {
|
|
1770
|
+
process.stdout.write(gray(` 通讯录可见 ${users.length} 人:`) + '\n')
|
|
1771
|
+
users.slice(0, 50).forEach((u, i) => process.stdout.write(` ${cyan(String(i + 1))}) ${u.name}\n`))
|
|
1772
|
+
const pick = await ask('选管理员序号(空格分隔;或输邮箱/手机号/ou_;留空跳过)', '')
|
|
1773
|
+
if (/^[\d\s]+$/.test(pick) && pick.trim()) { for (const n of pick.split(/\s+/).filter(Boolean)) { const u = users[+n - 1]; if (u) addOne(u.openId, u.name) } }
|
|
1774
|
+
else if (pick.trim()) { await setupResolveAdmin(cli, pick.trim(), addOne) }
|
|
1775
|
+
} else {
|
|
1776
|
+
process.stdout.write(gray(' (通讯录取不到 — 需 contact scope + 数据授权范围;可手动输)') + '\n')
|
|
1777
|
+
const q = await ask('管理员 邮箱/手机号/ou_openId(留空跳过)', '')
|
|
1778
|
+
if (q) await setupResolveAdmin(cli, q, addOne)
|
|
1779
|
+
}
|
|
1780
|
+
raw.feishu.admins = admins
|
|
1781
|
+
delete raw.feishu.adminOpenId; delete raw.feishu.adminName
|
|
1782
|
+
process.stdout.write(dim(` 管理员共 ${admins.length} 人`) + '\n')
|
|
1673
1783
|
}
|
|
1674
|
-
raw.feishu.admins = admins
|
|
1675
|
-
delete raw.feishu.adminOpenId; delete raw.feishu.adminName
|
|
1676
|
-
process.stdout.write(dim(` 管理员共 ${admins.length} 人`) + '\n')
|
|
1677
1784
|
}
|
|
1785
|
+
} else if (seeded) {
|
|
1786
|
+
delete raw.feishu // drop the example placeholder so doctor doesn't flag it
|
|
1678
1787
|
}
|
|
1679
|
-
} else if (seeded) {
|
|
1680
|
-
delete raw.feishu // drop the example placeholder so doctor doesn't flag it
|
|
1681
|
-
}
|
|
1682
1788
|
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1789
|
+
if (plats.includes('slack')) {
|
|
1790
|
+
process.stdout.write(gray(' Slack:api.slack.com/apps → Create New App → From a manifest,粘贴 ' + path.join(__dirname, '..', 'docs', 'slack-app-manifest.yaml') + ' 的内容;') + '\n'
|
|
1791
|
+
+ gray(' 装进 workspace 后取 Bot Token(xoxb-),再在 Basic Information 建一个 App-Level Token(xapp-,勾 connections:write)') + '\n')
|
|
1792
|
+
raw.slack = raw.slack || {}
|
|
1793
|
+
raw.slack.botToken = (await askKey(`Slack Bot Token(xoxb-…)${raw.slack.botToken ? '(留空=保持不变)' : ''}`)) || raw.slack.botToken || ''
|
|
1794
|
+
raw.slack.appToken = (await askKey(`Slack App Token(xapp-…)${raw.slack.appToken ? '(留空=保持不变)' : ''}`)) || raw.slack.appToken || ''
|
|
1795
|
+
if (raw.slack.onlyWhenMentioned === undefined) raw.slack.onlyWhenMentioned = true
|
|
1796
|
+
const usable = realCreds(raw.slack.botToken) && realCreds(raw.slack.appToken)
|
|
1797
|
+
if (usable && await askYN('Slack 预检?(验凭据 / 权限 / 长连接 / 已加入的频道)', true)) {
|
|
1798
|
+
const mark = { ok: cyan('✓'), warn: '\x1b[33m!\x1b[0m', fail: red('✗') }
|
|
1799
|
+
try {
|
|
1800
|
+
const r = await require('../src/platform/slack').slackPrecheck(raw.slack.botToken, raw.slack.appToken)
|
|
1801
|
+
verify.recordPrecheck('slack', raw.slack, r.checks)
|
|
1802
|
+
if (r.botUserId) raw.slack.botUserId = r.botUserId
|
|
1803
|
+
if (r.identity) raw.slack.identity = r.identity
|
|
1804
|
+
for (const c of r.checks) process.stdout.write(` ${mark[c.status] || '?'} ${c.label} ${gray('— ' + (c.detail || '').slice(0, 140))}\n`)
|
|
1805
|
+
} catch (e) { process.stdout.write(red(` 预检失败(${(e && e.message) || e})`) + '\n') }
|
|
1806
|
+
}
|
|
1700
1807
|
}
|
|
1701
|
-
}
|
|
1702
1808
|
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1809
|
+
if (plats.includes('wecom')) {
|
|
1810
|
+
process.stdout.write(gray(' 企业微信:管理后台「智能机器人 → 新建 → API 模式 → 使用长连接」拿 Bot ID 与 Secret(见 https://github.com/sintlab/sintbot/blob/main/docs/zh-CN/platforms/wecom.md)') + '\n')
|
|
1811
|
+
raw.wecom = raw.wecom || {}
|
|
1812
|
+
raw.wecom.botId = await ask('企业微信 Bot ID', realCreds(raw.wecom.botId) ? raw.wecom.botId : '')
|
|
1813
|
+
raw.wecom.secret = (await askKey(`企业微信 Secret${raw.wecom.secret ? '(留空=保持不变)' : ''}`)) || raw.wecom.secret || ''
|
|
1814
|
+
raw.wecom.botName = await ask('机器人在企业微信里的名字(用来剥掉正文里的 @)', raw.wecom.botName || brand || 'SintBot')
|
|
1815
|
+
}
|
|
1710
1816
|
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1817
|
+
// 项目根目录:群项目都放这里,控制台选目录 / 保存绑定 / 风格提炼只认这里面的路径(src/projectsroot.js)。
|
|
1818
|
+
// 一行,回车即默认;多个用 ; 分隔;等于没围的值(/、家目录、/Users …)说原因重问;不存在问一句是否创建。
|
|
1819
|
+
// 回车接受默认时**不落盘**(默认值不写进配置,和别的默认项一样);再跑向导时默认值取现有配置。
|
|
1820
|
+
for (;;) {
|
|
1821
|
+
const cur = resolveProjectsRoots(raw)
|
|
1822
|
+
const defText = cur.roots.join(';')
|
|
1823
|
+
const ans = await ask('项目根目录(群项目都放这里;多个用 ; 分隔)', defText)
|
|
1824
|
+
const list = parseRootsInput(ans)
|
|
1825
|
+
const r = resolveProjectsRoots({ projectsRoots: list })
|
|
1826
|
+
if (r.dropped.length) { for (const d of r.dropped) process.stdout.write(red(` ✗ ${d.value}:${d.why}`) + '\n'); process.stdout.write(dim(' 请重输(留空回车 = 用默认)') + '\n'); continue }
|
|
1827
|
+
if (ans !== defText || cur.fromConfig) raw.projectsRoots = list
|
|
1828
|
+
for (const root of r.roots) {
|
|
1829
|
+
if (fs.existsSync(root)) continue
|
|
1830
|
+
if (await askYN(` ${root} 不存在,现在创建?`, true)) {
|
|
1831
|
+
try { fs.mkdirSync(root, { recursive: true }); process.stdout.write(cyan(' ✓ 已创建 ') + root + '\n') }
|
|
1832
|
+
catch (e) { process.stdout.write(red(` 创建失败:${(e && e.message) || e}`) + '\n') }
|
|
1833
|
+
}
|
|
1727
1834
|
}
|
|
1835
|
+
break
|
|
1728
1836
|
}
|
|
1729
|
-
break
|
|
1730
|
-
}
|
|
1731
1837
|
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1838
|
+
if (await askYN('绑定一个 群→项目?', false)) {
|
|
1839
|
+
let chatId = ''
|
|
1840
|
+
let groupName = '' // 从列表里选的群 / 频道名,用来给项目目录出默认值
|
|
1841
|
+
const fe = raw.feishu
|
|
1842
|
+
// 绑到哪个平台的群:只接了一个平台就是它,接了多个先问
|
|
1843
|
+
// 排序按地区(决定默认绑哪边):中国大陆飞书在前,其余 Slack 在前
|
|
1844
|
+
const live = (mainland ? ['feishu', 'wecom', 'slack'] : ['slack', 'feishu', 'wecom']).filter((x) => (x === 'feishu' ? realCreds(fe && fe.appId) : x === 'slack' ? realCreds(raw.slack && raw.slack.botToken) : realCreds(raw.wecom && raw.wecom.botId)))
|
|
1845
|
+
let bindPlat = live[0] || 'feishu'
|
|
1846
|
+
if (live.length > 1) { const a = (await ask(`这个群在哪个平台? (${live.join(' / ')})`, live[0])).toLowerCase(); if (live.includes(a)) bindPlat = a }
|
|
1847
|
+
const usableFeishu = bindPlat === 'feishu' && fe && fe.appId && fe.appSecret && !String(fe.appId).startsWith('env:') && !String(fe.appSecret).startsWith('env:')
|
|
1848
|
+
if (bindPlat === 'slack' && realCreds(raw.slack.botToken)) {
|
|
1849
|
+
try {
|
|
1850
|
+
const chans = await require('../src/platform/slack').fetchSlackChannels(raw.slack.botToken)
|
|
1851
|
+
if (chans.length) {
|
|
1852
|
+
process.stdout.write(gray(' bot 已加入的频道:') + '\n')
|
|
1853
|
+
chans.forEach((c, i) => process.stdout.write(` ${cyan(String(i + 1))}) #${c.name} ${dim(c.id)}\n`))
|
|
1854
|
+
const pick = await ask('选择要绑定的频道(序号,或直接粘贴频道 ID;留空跳过)', '')
|
|
1855
|
+
const hit = /^\d+$/.test(pick) && chans[+pick - 1]
|
|
1856
|
+
chatId = hit ? hit.id : pick
|
|
1857
|
+
if (hit) groupName = hit.name
|
|
1858
|
+
} else chatId = await ask('频道 ID(如 C0123ABCD;bot 还没进任何频道 —— 先在频道里 /invite 它)', '')
|
|
1859
|
+
} catch (e) {
|
|
1860
|
+
// 1.0.4:错误码翻成人话(凭据 / 权限 / 网络各自怎么修),不再只甩一个 invalid_auth
|
|
1861
|
+
process.stdout.write(red(` 拉频道列表失败:${require('../src/platform/slack').slackHint(e)}`) + '\n')
|
|
1862
|
+
chatId = await ask('频道 ID(如 C0123ABCD;修好上面的问题后重跑向导就能从列表里选)', '')
|
|
1863
|
+
}
|
|
1864
|
+
} else if (bindPlat === 'wecom') {
|
|
1865
|
+
chatId = await ask('企业微信群 ID(还不知道就留空:先跑起来,在群里 @ 机器人说一句,再到控制台绑定 —— 那里会列出 @ 过它的群)', '')
|
|
1866
|
+
} else if (usableFeishu) {
|
|
1867
|
+
try {
|
|
1868
|
+
const chats = await fetchChats(feishuClient(fe.appId, fe.appSecret))
|
|
1869
|
+
if (chats.length) {
|
|
1870
|
+
process.stdout.write(gray(' bot 所在的群:') + '\n')
|
|
1871
|
+
chats.forEach((c, i) => process.stdout.write(` ${cyan(String(i + 1))}) ${c.name} ${dim(c.chatId)}\n`))
|
|
1872
|
+
const pick = await ask('选择要绑定的群(序号,或直接粘贴 chatId;留空跳过)', '')
|
|
1873
|
+
const hit = /^\d+$/.test(pick) && chats[+pick - 1]
|
|
1874
|
+
chatId = hit ? hit.chatId : pick
|
|
1875
|
+
if (hit && hit.name !== '(未命名群)') groupName = hit.name
|
|
1876
|
+
} else {
|
|
1877
|
+
process.stdout.write(gray(' (bot 不在任何群里 — 先把 bot 拉进群再重试,或手动输入)') + '\n')
|
|
1878
|
+
chatId = await ask('群 chatId (如 oc_xxx)', '')
|
|
1879
|
+
}
|
|
1880
|
+
} catch (e) {
|
|
1881
|
+
process.stdout.write(red(` 拉群列表失败:${feishuHint(e, 'im:chat')}`) + '\n')
|
|
1772
1882
|
chatId = await ask('群 chatId (如 oc_xxx)', '')
|
|
1773
1883
|
}
|
|
1774
|
-
}
|
|
1775
|
-
|
|
1776
|
-
chatId = await ask('群 chatId (如 oc_xxx)', '')
|
|
1777
|
-
}
|
|
1778
|
-
} else {
|
|
1779
|
-
chatId = await ask('群 chatId (如 oc_xxx;配了飞书可自动列群供选)', '')
|
|
1780
|
-
}
|
|
1781
|
-
if (!chatId) {
|
|
1782
|
-
// 没有群就不往下问了(1.0.4 前照问目录和 provider,问完才说「未绑定」)
|
|
1783
|
-
process.stdout.write(gray(' 没有群 ID,先不绑定 —— 跑起来后在控制台「群 · 项目」页绑定') + '\n')
|
|
1784
|
-
} else {
|
|
1785
|
-
// 认 `~`、存展开后的绝对路径;写错了当场重问(1.0.2 及以前原样收下 `~/x`,在当前目录建出一个叫 `~` 的文件夹)。
|
|
1786
|
-
// 1.0.4 起有默认值:项目根目录下以群名命名的子目录,回车即用
|
|
1787
|
-
const pr = resolveProjectsRoots(raw)
|
|
1788
|
-
const def = defaultWorkdir(groupName, pr.roots)
|
|
1789
|
-
let workdir = ''
|
|
1790
|
-
for (;;) {
|
|
1791
|
-
const wd = workdirInput(await ask(`项目目录(在 ${describeRoots(pr.roots)} 下,可以用 ~ 开头;回车用默认)`, def))
|
|
1792
|
-
if (wd.path) { workdir = wd.path; break }
|
|
1793
|
-
process.stdout.write(red(' ' + wd.error) + '\n')
|
|
1884
|
+
} else {
|
|
1885
|
+
chatId = await ask('群 chatId (如 oc_xxx;配了飞书可自动列群供选)', '')
|
|
1794
1886
|
}
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1887
|
+
if (!chatId) {
|
|
1888
|
+
// 没有群就不往下问了(1.0.4 前照问目录和 provider,问完才说「未绑定」)
|
|
1889
|
+
process.stdout.write(gray(' 没有群 ID,先不绑定 —— 跑起来后在控制台「群 · 项目」页绑定') + '\n')
|
|
1890
|
+
} else {
|
|
1891
|
+
// 认 `~`、存展开后的绝对路径;写错了当场重问(1.0.2 及以前原样收下 `~/x`,在当前目录建出一个叫 `~` 的文件夹)。
|
|
1892
|
+
// 1.0.4 起有默认值:项目根目录下以群名命名的子目录,回车即用
|
|
1893
|
+
const pr = resolveProjectsRoots(raw)
|
|
1894
|
+
const def = defaultWorkdir(groupName, pr.roots)
|
|
1895
|
+
let workdir = ''
|
|
1896
|
+
for (;;) {
|
|
1897
|
+
const wd = workdirInput(await ask(`项目目录(在 ${describeRoots(pr.roots)} 下,可以用 ~ 开头;回车用默认)`, def))
|
|
1898
|
+
if (wd.path) { workdir = wd.path; break }
|
|
1899
|
+
process.stdout.write(red(' ' + wd.error) + '\n')
|
|
1900
|
+
}
|
|
1901
|
+
// 项目根目录围栏在向导里只提醒不拦:向导与手改配置文件是同一条 operator 路,但要让他知道
|
|
1902
|
+
// 控制台不会认这样的绑定、doctor 会一直提示(围栏判据与控制台同源,src/projectsroot.js)
|
|
1903
|
+
if (!withinRoots(workdir, pr.roots)) process.stdout.write(yellow(` ⚠ 不在项目根目录(${describeRoots(pr.roots)})下 —— 控制台不会允许这样的绑定,doctor 会提示;向导照记`) + '\n')
|
|
1904
|
+
if (!fs.existsSync(workdir) && await askYN(` ${workdir} 不存在,现在创建?`, true)) {
|
|
1905
|
+
try { fs.mkdirSync(workdir, { recursive: true }); process.stdout.write(cyan(' ✓ 已创建 ') + workdir + '\n') }
|
|
1906
|
+
catch (e) { process.stdout.write(red(` 创建失败:${(e && e.message) || e}`) + '\n') }
|
|
1907
|
+
}
|
|
1908
|
+
const gp = await ask('该群 provider', raw.defaultProvider)
|
|
1909
|
+
delete raw.groups.oc_example_chat_id
|
|
1910
|
+
raw.groups[chatId] = { workdir, provider: gp, ...(live.length > 1 || bindPlat !== 'feishu' ? { platform: bindPlat } : {}) }
|
|
1801
1911
|
}
|
|
1802
|
-
const gp = await ask('该群 provider', raw.defaultProvider)
|
|
1803
|
-
delete raw.groups.oc_example_chat_id
|
|
1804
|
-
raw.groups[chatId] = { workdir, provider: gp, ...(live.length > 1 || bindPlat !== 'feishu' ? { platform: bindPlat } : {}) }
|
|
1805
1912
|
}
|
|
1806
1913
|
}
|
|
1914
|
+
// 默认流程从示例新建:示例里的飞书块是 env: 占位(FEISHU_APP_ID …),当前终端里两个都有值才留(环境变量管凭据的部署),
|
|
1915
|
+
// 否则不写进配置 —— 不然体检一上来就报「feishu 块缺 appSecret」
|
|
1916
|
+
// 示例里的 projectsRoots 就是默认值(~/Projects):不落盘,和 --full 里回车接受默认一样 —— 写进去就成了「配置里指定的根」,
|
|
1917
|
+
// 目录还不存在时体检报失败;没写的默认根在控制台第一次浏览时自动创建
|
|
1918
|
+
if (!full && seeded && JSON.stringify(raw.projectsRoots) === JSON.stringify(['~/Projects'])) delete raw.projectsRoots
|
|
1919
|
+
if (!full && seeded && raw.feishu) {
|
|
1920
|
+
const fe = raw.feishu
|
|
1921
|
+
const envOk = (v) => String(v || '').startsWith('env:') && !!process.env[String(v).slice(4)]
|
|
1922
|
+
if (envOk(fe.appId) && envOk(fe.appSecret)) { if (/^ou_x/i.test(String(fe.botOpenId || ''))) fe.botOpenId = '' }
|
|
1923
|
+
else delete raw.feishu
|
|
1924
|
+
}
|
|
1807
1925
|
} finally {
|
|
1808
1926
|
rl.close()
|
|
1809
1927
|
}
|
|
@@ -1814,26 +1932,45 @@ async function cmdSetup(rest) {
|
|
|
1814
1932
|
const cfgDir = path.dirname(cfgPath)
|
|
1815
1933
|
if (posix && !fs.existsSync(cfgDir)) fs.mkdirSync(cfgDir, { recursive: true, mode: 0o700 })
|
|
1816
1934
|
atomicWriteJSON(cfgPath, raw, posix ? { mode: 0o600 } : {})
|
|
1817
|
-
process.stdout.write('\n' + cyan('✓ ') + `已保存 ${cfgPath}
|
|
1935
|
+
process.stdout.write('\n' + cyan('✓ ') + T(`已保存 ${cfgPath}`, `Saved ${cfgPath}`) + '\n')
|
|
1818
1936
|
|
|
1819
1937
|
const cfg = loadConfig(cfgPath)
|
|
1820
|
-
await runDoctor(cfg)
|
|
1821
1938
|
const cfgArg = cfgPath === path.join(os.homedir(), '.sbot', 'config.json') ? '' : ` --config ${cfgPath}`
|
|
1822
|
-
|
|
1939
|
+
if (full) {
|
|
1940
|
+
process.stdout.write('\n' + gray('— 体检 —') + '\n')
|
|
1941
|
+
await runDoctor(cfg)
|
|
1942
|
+
} else {
|
|
1943
|
+
// 新装时「没接平台 / 没绑群 / Claude 没登录」都是正常状态,整屏警告看着像出错:只报失败项,完整体检留给 sintbot doctor
|
|
1944
|
+
const { rows } = await checkHealth(cfg)
|
|
1945
|
+
const failed = rows.filter((r) => r.status === 'fail')
|
|
1946
|
+
if (failed.length) for (const r of failed) process.stdout.write(`${red('✗')} ${r.label.padEnd(22)} ${gray(r.detail)}\n`)
|
|
1947
|
+
else process.stdout.write(cyan('✓ ') + T('体检没有失败项', 'Health check: nothing failed') + '\n')
|
|
1948
|
+
process.stdout.write(dim(T(` 完整体检:sintbot doctor${cfgArg}`, ` Full check: sintbot doctor${cfgArg}`)) + '\n')
|
|
1949
|
+
}
|
|
1950
|
+
if (!((cfg.providers || {})[cfg.defaultProvider] || {}).baseUrl) {
|
|
1951
|
+
process.stdout.write('\n' + yellow(T('⚠ 订阅条款:', '⚠ Subscription terms: ')) + T('用个人的 Claude / ChatGPT 订阅登录来服务多人群聊,可能不符合提供方的使用条款;团队使用请改用 API key 或企业账号,并自行确认合规。', 'serving a multi-person group chat from a personal Claude / ChatGPT subscription may break the provider\'s terms; for team use switch to an API key or a business account and check compliance yourself.') + '\n')
|
|
1952
|
+
}
|
|
1953
|
+
if (fromInstaller) return // 后台服务、控制台地址、接下来去哪:install.sh 结尾统一给
|
|
1954
|
+
// 终端里有代理、配置里没有:只给建议(服务进程拿不到终端的环境变量)
|
|
1955
|
+
const shellProxy = !(raw.proxy && raw.proxy.http) && require('../src/core/proxy').proxyDescribe(process.env) // 带认证的只说「带认证」,不打印账号密码
|
|
1823
1956
|
const plat = platformsConfigured(cfg)
|
|
1824
1957
|
const noPlatform = !plat.feishu && !plat.wecom && !plat.slack
|
|
1825
|
-
process.stdout.write('\n' + gray('— 下一步 —') + '\n'
|
|
1826
|
-
+ ` sintbot
|
|
1827
|
-
+ (noPlatform
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1958
|
+
process.stdout.write('\n' + gray(T('— 下一步 —', '— Next —')) + '\n'
|
|
1959
|
+
+ ` sintbot service install${cfgArg} ${dim(T('装成后台服务,控制台常开在 http://127.0.0.1:4600(登录后自启,崩溃自动拉起)', 'install the background service; the console stays at http://127.0.0.1:4600 (starts at login, restarts on crash)'))}\n`
|
|
1960
|
+
+ ` sintbot ui${cfgArg} ${dim(noPlatform ? T('现在就打开控制台:在「设置」里接飞书 / Slack / 企业微信,在「群 · 项目」里绑群', 'open the console now: connect Slack, Feishu or WeCom under Settings, bind a group under Groups') : T('打开控制台:绑群、切模型、看实时活动', 'open the console: bind groups, switch models, watch live activity'))}\n`
|
|
1961
|
+
+ ` sintbot chat${cfgArg} ${dim(T('不接平台也能先在终端里和它聊几句', 'chat with it in the terminal first, no platform needed'))}\n`
|
|
1962
|
+
+ (shellProxy ? '\n' + dim(T(` 建议:终端里设了代理(${shellProxy}),后台服务拿不到;装服务时会问要不要带进去,也可以在控制台「设置 → 机器人与网络」填。`, ` Tip: your terminal uses a proxy (${shellProxy}) that the background service can't see; service install offers to carry it over, or set it in the console under Settings → Bot & network.`)) + '\n' : ''))
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
// 能不能在这台机器上弹浏览器:SSH 进来的会话弹到的是对方屏幕(或根本没有屏幕),Linux 没有图形界面也不弹 —— 只打印地址
|
|
1966
|
+
function canOpenBrowser(env = process.env, platform = process.platform) {
|
|
1967
|
+
if (env.SSH_CONNECTION || env.SSH_TTY) return false
|
|
1968
|
+
if (platform === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY) return false
|
|
1969
|
+
return true
|
|
1834
1970
|
}
|
|
1835
1971
|
|
|
1836
1972
|
function tryOpen(url) {
|
|
1973
|
+
if (!canOpenBrowser()) return
|
|
1837
1974
|
const isWin = process.platform === 'win32'
|
|
1838
1975
|
const cmd = isWin ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open'
|
|
1839
1976
|
try { require('node:child_process').spawn(cmd, isWin ? ['', url] : [url], { shell: isWin, stdio: 'ignore', detached: true }).unref() } catch { /* just print the URL */ }
|
|
@@ -1875,7 +2012,17 @@ async function cmdUi(rest) {
|
|
|
1875
2012
|
}
|
|
1876
2013
|
const ui = createUiServer({ configPath, host, log: (m) => process.stderr.write(gray('· ' + m) + '\n') })
|
|
1877
2014
|
ui.server.on('error', (e) => {
|
|
1878
|
-
if (e.code === 'EADDRINUSE')
|
|
2015
|
+
if (e.code === 'EADDRINUSE') {
|
|
2016
|
+
// 后台服务在跑 = 控制台已经开着(1.0.12 起没平台也装服务):直接打开它,别报「端口被占用」
|
|
2017
|
+
const st = (() => { try { return require('../src/service').status({ platform: process.platform, home: os.homedir() }) } catch { return {} } })()
|
|
2018
|
+
if (st.running) {
|
|
2019
|
+
const url = `http://127.0.0.1:${port}`
|
|
2020
|
+
process.stderr.write(cyan(' ') + tr(`控制台已在后台服务里运行 → ${url}`, `The console is already running in the background service → ${url}`) + '\n')
|
|
2021
|
+
if (!noOpen) tryOpen(url)
|
|
2022
|
+
process.exit(0)
|
|
2023
|
+
}
|
|
2024
|
+
return die(`端口 ${port} 被占用 — 换端口:sintbot ui --port <其它>`)
|
|
2025
|
+
}
|
|
1879
2026
|
die('ui server error: ' + (e && e.message))
|
|
1880
2027
|
})
|
|
1881
2028
|
ui.listen(port, host, () => {
|