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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sintbot",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "description": "Drive headless Claude Code (or Codex) from Slack / Feishu / WeCom group chats — one group, one project, swappable model brain. A standalone daemon, not a Claude Code plugin.",
5
5
  "bin": {
6
6
  "sbot": "bin/sbot.js",
package/src/clilang.js ADDED
@@ -0,0 +1,31 @@
1
+ // 命令行输出语言(安装向导、服务安装这些新用户第一眼看到的输出;其余命令仍是中文)。
2
+ // 判据与 install.sh 的 detect_lang 一致:SINTBOT_LANG=zh|en 优先;否则 locale(LC_ALL / LC_MESSAGES / LANG)
3
+ // 或 macOS 系统语言(AppleLanguages 第一项)有一处是中文就用中文,其余英文。
4
+ // 为什么看系统语言:不少人在 ~/.zshenv 里写死 LANG=en_US.UTF-8(工具链兼容),界面却是中文 ——
5
+ // 1.0.11 实装时安装脚本因此出英文、向导是中文,同一屏两种语言。
6
+ const { spawnSync } = require('node:child_process')
7
+
8
+ let macCache
9
+ function macLanguage({ platform = process.platform, run = spawnSync } = {}) {
10
+ if (platform !== 'darwin') return ''
11
+ if (macCache !== undefined) return macCache
12
+ try {
13
+ const r = run('defaults', ['read', '-g', 'AppleLanguages'], { encoding: 'utf8', timeout: 2000 })
14
+ const m = String((r && r.stdout) || '').match(/"?([A-Za-z]{2,3})[-_A-Za-z]*"?/)
15
+ macCache = m ? m[1].toLowerCase() : ''
16
+ } catch { macCache = '' }
17
+ return macCache
18
+ }
19
+
20
+ function cliLang({ env = process.env, platform = process.platform, run } = {}) {
21
+ const forced = String(env.SINTBOT_LANG || '').toLowerCase()
22
+ if (forced === 'zh' || forced === 'en') return forced
23
+ const locale = env.LC_ALL || env.LC_MESSAGES || env.LANG || ''
24
+ if (/^zh/i.test(locale)) return 'zh'
25
+ return macLanguage({ platform, run }) === 'zh' ? 'zh' : 'en'
26
+ }
27
+
28
+ // tr('中文', 'English') —— 按当前语言取一句
29
+ const tr = (zh, en, lang = cliLang()) => (lang === 'zh' ? zh : en)
30
+
31
+ module.exports = { cliLang, tr, _resetCache: () => { macCache = undefined } }
package/src/config.js CHANGED
@@ -11,6 +11,7 @@
11
11
  const fs = require('node:fs')
12
12
  const path = require('node:path')
13
13
  const os = require('node:os')
14
+ const net = require('node:net')
14
15
  const { DEFAULT_TZ, normalizeTz } = require('./core/time')
15
16
  const { resolveProjectsRoots, expandHome } = require('./projectsroot')
16
17
  const { setKnownSecrets } = require('./core/redact')
@@ -210,10 +211,11 @@ function deriveNoProxy(providers) {
210
211
  for (const p of Object.values(providers || {})) {
211
212
  if (!p || !p.baseUrl) continue
212
213
  try {
213
- const h = new URL(p.baseUrl).hostname
214
+ const h = new URL(p.baseUrl).hostname.replace(/^\[|\]$/g, '')
214
215
  hosts.add(h)
216
+ // IP 不取后两段:127.0.0.1 会变出一条 `*.0.1`(1.0.11 实装时 NO_PROXY 里真出现过)
215
217
  const parts = h.split('.')
216
- if (parts.length > 2) hosts.add('*.' + parts.slice(-2).join('.'))
218
+ if (!net.isIP(h) && parts.length > 2) hosts.add('*.' + parts.slice(-2).join('.'))
217
219
  } catch { /* skip malformed baseUrl */ }
218
220
  }
219
221
  return [...hosts].join(',')
package/src/core/proxy.js CHANGED
@@ -417,6 +417,7 @@ function proxyDescribe(env = process.env) {
417
417
  }
418
418
 
419
419
  module.exports = {
420
+ PROXY_ENV_KEYS: [...PROXY_KEYS, ...NO_PROXY_KEYS],
420
421
  proxyFromEnv, noProxyFromEnv, proxyWarning, shouldBypass,
421
422
  TunnelAgent, createProxyFetch,
422
423
  getProxyFetch, getProxyAgent, resetProxy, proxyDescribe,
@@ -85,5 +85,6 @@ function when(at, now = Date.now()) {
85
85
  // 试跑一轮用的提示词(向导试跑与控制台「模型 → 测试」共用)。不让模型自报身份:走 Claude Code 的第三方模型
86
86
  // 常照系统提示自称 Claude,新用户会以为没切成;型号由调用方按配置显示
87
87
  const TRIAL_PROMPT = '用一句话回复,说明你已准备好开始工作。'
88
+ const TRIAL_PROMPT_EN = 'Reply in one sentence that you are ready to start working.' // 英文向导用
88
89
 
89
- module.exports = { fingerprint, providerFp, feishuFp, slackFp, lookup, record, recordTurn, recordPrecheck, when, REFRESH_MS, TRIAL_PROMPT }
90
+ module.exports = { fingerprint, providerFp, feishuFp, slackFp, lookup, record, recordTurn, recordPrecheck, when, REFRESH_MS, TRIAL_PROMPT, TRIAL_PROMPT_EN }
package/src/design.js CHANGED
@@ -3154,16 +3154,18 @@ function _landingSentence(v, { mobile = false } = {}) {
3154
3154
  // 误报成「描边不合规」,而误报会让整份报告被无视(v0.11.29 间距/阴影不测是同一条理由)。
3155
3155
  // ⚠ `dirs` 从字符串数组改成结构:一个目录是什么形态、什么字重、文件名带什么后缀,**都得显式声明**,
3156
3156
  // 不能再靠对路径做正则(phosphor 的 fill 目录叫 `fill` 不叫 `filled`,正则当场就错)。
3157
+ // mb = 装好后实际占的磁盘(2026-10 在 macOS 上 du 实测;几千个小文件按块计,比 npm 报的 unpackedSize 大一倍多)。
3158
+ // 向导问「要不要代装」时报合计,版本变了会有出入
3157
3159
  const ICON_LIBS = {
3158
3160
  // lucide 与 tabler 的分发文件属性逐字相同(24 网格 / fill:none / stroke:currentColor / 2 / round),
3159
3161
  // **参数同构才是混用的依据** —— 检查按参数判、不按库名判,兜底也只在同构的两套之间成立。
3160
- lucide: { pkg: 'lucide-static', label: 'Lucide', license: 'ISC', form: 'stroke', grid: 24, dirs: [{ dir: 'icons', style: 'outlined' }] },
3161
- tabler: { pkg: '@tabler/icons', label: 'Tabler', license: 'MIT', form: 'stroke', grid: 24, dirs: [{ dir: 'icons/outline', style: 'outlined' }, { dir: 'icons/filled', style: 'filled' }] },
3162
+ lucide: { pkg: 'lucide-static', mb: 66, label: 'Lucide', license: 'ISC', form: 'stroke', grid: 24, dirs: [{ dir: 'icons', style: 'outlined' }] },
3163
+ tabler: { pkg: '@tabler/icons', mb: 49, label: 'Tabler', license: 'MIT', form: 'stroke', grid: 24, dirs: [{ dir: 'icons/outline', style: 'outlined' }, { dir: 'icons/filled', style: 'filled' }] },
3162
3164
  // ⚠ phosphor:**六个字重是六套独立几何**(不是同一套图的粗细参数),且 regular 档文件名无后缀、
3163
3165
  // 其余带 `-{字重}` 后缀(`gear.svg` / `gear-bold.svg`)—— 这是 `suffix` 字段存在的唯一理由。
3164
3166
  // duotone 用两条 path + opacity 0.2 做副色,副色仍继承 currentColor(不是第二种颜色)。
3165
3167
  phosphor: {
3166
- pkg: '@phosphor-icons/core', label: 'Phosphor', license: 'MIT', form: 'fill', grid: 256,
3168
+ pkg: '@phosphor-icons/core', mb: 37, label: 'Phosphor', license: 'MIT', form: 'fill', grid: 256,
3167
3169
  dirs: [
3168
3170
  { dir: 'assets/thin', style: 'outlined', weight: 'thin', suffix: '-thin' },
3169
3171
  { dir: 'assets/light', style: 'outlined', weight: 'light', suffix: '-light' },
@@ -3175,7 +3177,7 @@ const ICON_LIBS = {
3175
3177
  },
3176
3178
  // ⚠ pixelart:实心变体是**同目录 `-solid` 后缀**(不是子目录),而且**只覆盖 53/651**(实测)
3177
3179
  // ⇒ 系统那边**不声明支持实心** —— 声明了就是句假话,八成的名字取不到实心。
3178
- pixelart: { pkg: 'pixelarticons', label: 'Pixelarticons', license: 'MIT', form: 'fill', grid: 24, dirs: [{ dir: 'svg', style: 'outlined' }] },
3180
+ pixelart: { pkg: 'pixelarticons', mb: 15, label: 'Pixelarticons', license: 'MIT', form: 'fill', grid: 24, dirs: [{ dir: 'svg', style: 'outlined' }] },
3179
3181
  }
3180
3182
  // ── 图标系统(v0.11.53)= 与设计风格**平级**的实体 ────────────────────────────
3181
3183
  // **图标系统 = 图形从哪来 + 叫什么名 + 怎么起名;风格 = 我用哪套 + 怎么画它。**
package/src/health.js CHANGED
@@ -277,10 +277,10 @@ async function checkHealth(cfg, opts = {}) {
277
277
  if (!cfg.feishu.botOpenId) add('warn', 'feishu.botOpenId', (cfg.feishu.onlyWhenMentioned
278
278
  ? '没填 — 开着「只在 @ 时回复」却认不出哪个 @ 是机器人,群里一条都不会回'
279
279
  : '没填 — 分不清 @机器人 和 @别人,群里 @ 别人的消息也会接') + ';重跑 sintbot setup 选飞书预检可自动填')
280
- } else if (!hasId && !hasSecret) { if (!plat.wecom && !plat.slack) add('warn', 'platforms', '没配任何聊天平台 — sintbot start 收不到消息') }
280
+ } else if (!hasId && !hasSecret) { if (!plat.wecom && !plat.slack) add('warn', 'platforms', '还没接聊天平台 — 在控制台「设置」里接飞书 / Slack / 企业微信') }
281
281
  else add('fail', 'feishu', hasId ? '缺 appSecret' : '缺 appId')
282
282
  } else if (!plat.wecom && !plat.slack) {
283
- add('warn', 'platforms', '没配任何聊天平台 — sintbot start 收不到消息')
283
+ add('warn', 'platforms', '还没接聊天平台 — 在控制台「设置」里接飞书 / Slack / 企业微信')
284
284
  }
285
285
  // 写了 wecom 块就要报:env: 引用解析成空串也算缺(真机踩到 —— 非交互 shell 没 source 凭据文件,doctor 一声不吭地把群报成「没配企微」)
286
286
  if (cfg.wecom) {
@@ -21,8 +21,9 @@ function chooseRegistry(configured, officialReachable) {
21
21
  return { registry: MIRROR, reason: 'mirror' }
22
22
  }
23
23
 
24
+ // pkg 可以是一个包名,也可以是一组(设计图标库四个包一条命令装齐)
24
25
  function installArgs(pkg, registry) {
25
- return ['install', '-g', pkg, ...(registry ? ['--registry', registry] : [])]
26
+ return ['install', '-g', ...[].concat(pkg), ...(registry ? ['--registry', registry] : [])]
26
27
  }
27
28
 
28
29
  function runNpm(args, opts = {}) {
@@ -55,6 +56,11 @@ function globalPrefix(run = runNpm) {
55
56
  function installGlobal(name, { run = runNpm, log = () => {} } = {}) {
56
57
  const pkg = PACKAGES[name]
57
58
  if (!pkg) throw new Error(`unknown package: ${name}`)
59
+ return installPackages(pkg, { run, log })
60
+ }
61
+
62
+ // 按包名装(一个或一组),选源同上。只给向导装确定的包用(Claude Code / Codex / 设计图标库),不接用户输入的名字
63
+ function installPackages(pkg, { run = runNpm, log = () => {} } = {}) {
58
64
  const { registry, reason } = pickRegistry(run)
59
65
  if (reason === 'mirror') log(`npm 官方源连不上,改用国内镜像 ${MIRROR}`)
60
66
  const args = installArgs(pkg, registry)
@@ -63,4 +69,4 @@ function installGlobal(name, { run = runNpm, log = () => {} } = {}) {
63
69
  return { ok: r.status === 0, pkg, registry, code: r.status }
64
70
  }
65
71
 
66
- module.exports = { OFFICIAL, MIRROR, PACKAGES, isOfficial, chooseRegistry, installArgs, pickRegistry, globalPrefix, installGlobal }
72
+ module.exports = { OFFICIAL, MIRROR, PACKAGES, isOfficial, chooseRegistry, installArgs, pickRegistry, globalPrefix, installGlobal, installPackages }
package/src/service.js CHANGED
@@ -78,6 +78,10 @@ function envRefs(obj, out = []) {
78
78
  return out
79
79
  }
80
80
 
81
+ // 服务托管标记:守护进程据此知道「退出了会被 launchd / systemd 拉起」—— 还没接平台时只开控制台,
82
+ // 第一次接上平台就自己退出、重启后连上(bin/sbot.js cmdStart)。前台跑的 sintbot start 没有它
83
+ const SUPERVISED_ENV = 'SBOT_SUPERVISED'
84
+
81
85
  // 服务的全部事实(纯数据):各平台单元文件都从这里生成。env = 额外写进单元文件的环境变量(--pass-env)
82
86
  function serviceSpec({ nodeBin, sbotBin, configPath, uiHost, home, pathEnv, env = {} }) {
83
87
  const args = [sbotBin, 'start', '--ui', '--no-open']
@@ -103,7 +107,8 @@ ${arr}
103
107
  <key>EnvironmentVariables</key>
104
108
  <dict>
105
109
  <key>PATH</key><string>${xmlEsc(spec.pathEnv)}</string>
106
- ${Object.entries(spec.env || {}).map(([k, v]) => ` <key>${xmlEsc(k)}</key><string>${xmlEsc(v)}</string>\n`).join('')} </dict>
110
+ ${Object.entries(spec.env || {}).map(([k, v]) => ` <key>${xmlEsc(k)}</key><string>${xmlEsc(v)}</string>\n`).join('')} <key>${SUPERVISED_ENV}</key><string>1</string>
111
+ </dict>
107
112
  <key>RunAtLoad</key><true/>
108
113
  <key>KeepAlive</key><true/>
109
114
  <key>ThrottleInterval</key><integer>15</integer>
@@ -124,7 +129,8 @@ Wants=network-online.target
124
129
  ExecStart=${[spec.nodeBin, ...spec.args].map(sdQuote).join(' ')}
125
130
  WorkingDirectory=${sdQuote(spec.workdir)}
126
131
  Environment=${sdQuote('PATH=' + spec.pathEnv)}
127
- ${Object.entries(spec.env || {}).map(([k, v]) => `Environment=${sdQuote(k + '=' + v)}\n`).join('')}Restart=always
132
+ ${Object.entries(spec.env || {}).map(([k, v]) => `Environment=${sdQuote(k + '=' + v)}\n`).join('')}Environment=${SUPERVISED_ENV}=1
133
+ Restart=always
128
134
  RestartSec=15
129
135
  KillSignal=SIGTERM
130
136
  TimeoutStopSec=30
@@ -238,4 +244,4 @@ function uninstall({ platform = process.platform, home = os.homedir() } = {}) {
238
244
  return { file, removed, steps }
239
245
  }
240
246
 
241
- module.exports = { LABEL, UNIT, xmlEsc, sdQuote, buildPath, versionedPath, stableNodeBin, envRefs, serviceSpec, launchdPlist, systemdUnit, unitPath, render, windowsHint, status, install, restart, uninstall }
247
+ module.exports = { LABEL, UNIT, SUPERVISED_ENV, xmlEsc, sdQuote, buildPath, versionedPath, stableNodeBin, envRefs, serviceSpec, launchdPlist, systemdUnit, unitPath, render, windowsHint, status, install, restart, uninstall }
@@ -2,19 +2,20 @@
2
2
  // 向导本身在 bin/sbot.js(交互、读写配置);这里只放可单测的部分。
3
3
  //
4
4
  // 推荐顺序(2026-10-19 定):有 Claude 订阅 → 用 Claude;有 ChatGPT(Codex)→ 用 Codex;都没有 → 推荐 DeepSeek。
5
- // 地区只决定两件事:同一家用中国站还是国际站的端点,以及要不要代理。
5
+ // 1.0.12 起向导不问「在不在中国大陆」(用户 10-28 定:那是隐私)。有国际站的厂商先按时区试一个站,认证失败自动换另一个站再试。
6
+ // 文案带英文(en),中英文由 src/clilang.js 决定。
6
7
 
7
8
  // 用 API key 的预设(名字与 sbot.config.example.json 的 providers 键一致)。
8
9
  // intl:有国际站的厂商,key 在哪个站开的就得连哪个站,填错站点会 401。
9
10
  const PROVIDER_INFO = {
10
- deepseek: { label: 'DeepSeek', site: 'https://platform.deepseek.com', billing: '按量付费' },
11
- glm: { label: '智谱 GLM', site: 'https://open.bigmodel.cn', billing: '按量或包月 GLM Coding Plan', intl: { label: 'Z.ai', site: 'https://z.ai', baseUrl: 'https://api.z.ai/api/anthropic' } },
12
- minimax: { label: 'MiniMax', site: 'https://platform.minimaxi.com', billing: '按量或包月 Token Plan', intl: { label: 'MiniMax 国际站', site: 'https://platform.minimax.io', baseUrl: 'https://api.minimax.io/anthropic' } },
13
- volcano: { label: '火山方舟', site: 'https://console.volcengine.com/ark', billing: '包月 Coding Plan,只有中国站' },
11
+ deepseek: { label: 'DeepSeek', site: 'https://platform.deepseek.com', billing: '按量付费', en: { billing: 'pay as you go' } },
12
+ glm: { label: '智谱 GLM', site: 'https://open.bigmodel.cn', billing: '按量或包月 GLM Coding Plan', en: { label: 'Zhipu GLM', billing: 'pay as you go or the monthly GLM Coding Plan' }, intl: { label: 'Z.ai', site: 'https://z.ai', baseUrl: 'https://api.z.ai/api/anthropic' } },
13
+ minimax: { label: 'MiniMax', site: 'https://platform.minimaxi.com', billing: '按量或包月 Token Plan', en: { billing: 'pay as you go or the monthly Token Plan' }, intl: { label: 'MiniMax 国际站', site: 'https://platform.minimax.io', baseUrl: 'https://api.minimax.io/anthropic', en: { label: 'MiniMax (international)' } } },
14
+ volcano: { label: '火山方舟', site: 'https://console.volcengine.com/ark', billing: '包月 Coding Plan,只有中国站', en: { label: 'Volcano Ark', billing: 'monthly Coding Plan, China site only' } },
14
15
  }
15
16
  const RECOMMENDED = 'deepseek'
16
17
 
17
- // 中国大陆常见时区:只用来给「这台机器在中国大陆吗」一个默认值,答案以用户为准
18
+ // 中国大陆常见时区:只用来决定有国际站的厂商先试哪个站、推荐哪个聊天平台(不问用户,也不写进配置)
18
19
  const MAINLAND_TZ = new Set(['Asia/Shanghai', 'Asia/Chongqing', 'Asia/Chungking', 'Asia/Harbin', 'Asia/Urumqi', 'Asia/Kashgar', 'PRC'])
19
20
  function looksMainland(tz) {
20
21
  if (tz === undefined) { try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone } catch { tz = '' } }
@@ -37,23 +38,27 @@ function keyPresent(apiKey, env = process.env) {
37
38
  return true
38
39
  }
39
40
 
41
+ // 一家厂商的显示名 / 计费说明(英文缺省回落中文原文)
42
+ const infoText = (info, key, lang) => (lang === 'en' && info && info.en && info.en[key]) || (info && info[key]) || ''
43
+
40
44
  // 试跑失败的原文 → 一句人话 + 类别。原文来自 claude 命令行(API Error: 401 …)或 spawn / 网络异常。
41
- function explainTestFailure(text, { baseUrl } = {}) {
45
+ function explainTestFailure(text, { baseUrl, lang = 'zh' } = {}) {
42
46
  const t = String(text || '')
43
- const h = baseUrl ? host(baseUrl) : '模型端点'
47
+ const en = lang === 'en'
48
+ const h = baseUrl ? host(baseUrl) : (en ? 'the model endpoint' : '模型端点')
44
49
  if (/\b401\b|authentication|invalid (api )?key|invalid_authentication|unauthori[sz]ed|令牌|token expired|login fail|api key.*invalid|模型认证失败/i.test(t)) {
45
- return { kind: 'auth', text: 'key 不对:检查有没有复制完整;有国际站的厂商,确认 key 和所选站点是同一个站开的' }
50
+ return { kind: 'auth', text: en ? 'The key was rejected: check it was copied in full; for providers with an international site, the key must come from the same site you connect to' : 'key 不对:检查有没有复制完整;有国际站的厂商,确认 key 和所选站点是同一个站开的' }
46
51
  }
47
52
  if (/\b402\b|insufficient|balance|余额|欠费|arrear|quota|实名|recharge|billing/i.test(t)) {
48
- return { kind: 'balance', text: '账户不能用:多半是余额不足或还没实名,到厂商后台充值 / 实名后再试' }
53
+ return { kind: 'balance', text: en ? 'The account cannot be used: most likely no balance or identity verification is pending; top up / verify in the provider console and try again' : '账户不能用:多半是余额不足或还没实名,到厂商后台充值 / 实名后再试' }
49
54
  }
50
55
  if (/ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ECONNRESET|ETIMEDOUT|timed? ?out|fetch failed|socket hang up|network|connection error|aborted/i.test(t)) {
51
- return { kind: 'network', text: `连不上 ${h}:检查网络;开着全局代理的话,把 ${h} 设成直连` }
56
+ return { kind: 'network', text: en ? `Cannot reach ${h}: check the network; if a global proxy is on, let ${h} connect directly` : `连不上 ${h}:检查网络;开着全局代理的话,把 ${h} 设成直连` }
52
57
  }
53
58
  if (/\b404\b|model.*(not found|not exist|does not exist|invalid|not supported)|模型.*不存在/i.test(t)) {
54
- return { kind: 'model', text: '模型名不对或这个 key 没开通该模型:到控制台「模型」页改模型名' }
59
+ return { kind: 'model', text: en ? 'Wrong model name, or this key has no access to that model: change the model name on the console Models page' : '模型名不对或这个 key 没开通该模型:到控制台「模型」页改模型名' }
55
60
  }
56
- return { kind: 'other', text: t.split('\n')[0].slice(0, 200) || '没有返回内容' }
61
+ return { kind: 'other', text: t.split('\n')[0].slice(0, 200) || (en ? 'No output' : '没有返回内容') }
57
62
  }
58
63
 
59
- module.exports = { PROVIDER_INFO, RECOMMENDED, looksMainland, host, subscriptionRoute, keyPresent, explainTestFailure }
64
+ module.exports = { PROVIDER_INFO, RECOMMENDED, looksMainland, host, subscriptionRoute, keyPresent, explainTestFailure, infoText }
@@ -985,6 +985,24 @@ function healthText(h){return h.fails?`${h.fails} 失败 · ${h.warns} 警告`:h
985
985
  /* ── overview ── */
986
986
  // Full structure — rendered once on load / after an action (with the entrance
987
987
  // animation). Live bits carry ids/data-attrs so ticks patch them in place.
988
+ // 开始使用(1.0.12):安装向导不再问聊天平台和绑群,装完第一次打开控制台时在这里说接下来做什么;平台接上、群绑好就不显示
989
+ function startCardHtml(){
990
+ const pl=S.platforms||{},anyPlat=!!(pl.feishu||pl.slack||pl.wecom);
991
+ const anyGroup=(S.groups||[]).length>0;
992
+ if(anyPlat&&anyGroup)return '';
993
+ // 模型这一步按健康行「可用模型」判(能用的,不只是配了的:key 在服务环境里拿不到、试跑失败的都不算)
994
+ const mrow=((S.health&&S.health.rows)||[]).find(r=>r.label==='可用模型');
995
+ const anyModel=mrow?mrow.status!=='fail':(S.providers||[]).some(p=>p.configured&&!p.hidden);
996
+ const step=(done,title,hint,tab,btn)=>`<div class="inline" style="gap:10px;margin:10px 0;align-items:flex-start"><span class="dot ${done?'ok':'warn'}" style="margin-top:6px;flex:none"></span><div style="flex:1;min-width:0"><div>${title}</div><div class="hint">${hint}</div></div>${!done&&tab?`<button class="btn" data-gotab="${tab}" style="flex:none">${btn}</button>`:''}</div>`;
997
+ const sm=S.setupMode;
998
+ return `<div class="card" id="ovStart" style="margin-bottom:16px"><div class="lbl">开始使用</div>
999
+ ${step(anyModel,'配好模型',anyModel?'换模型、加模型在「模型 · 库」':'还没有能用的模型:在「模型 · 库」里添加、测试','providers','去「模型 · 库」')}
1000
+ ${step(anyPlat,'接聊天平台','飞书 / Slack / 企业微信,「设置」里每个平台都有分步指南','settings','去「设置」')}
1001
+ ${step(anyGroup,'绑一个群','把群绑到项目目录,一个群一个项目','groups','去「群 · 项目」')}
1002
+ ${step(false,'在群里 @ 机器人说一句话','不接平台也能先在「试跑」里跑一轮',null,'')}
1003
+ ${sm&&!anyPlat?`<div class="hint" style="margin-top:8px">${sm.supervised?'后台服务现在只开着控制台:平台接好、保存后,它会自动重启并连上(约 15 秒)。':'守护进程现在只开着控制台:接好平台后,在终端里按 Ctrl-C 再运行 sintbot start --ui 连上。'}</div>`:''}
1004
+ </div>`;
1005
+ }
988
1006
  function renderOverview(){
989
1007
  const h=S.health;
990
1008
  const cap=Math.max((S.agent&&S.agent.maxConcurrent)||0,0); // 0 = 不限(无固定槽位 → 不画 gauge)
@@ -1001,7 +1019,7 @@ function renderOverview(){
1001
1019
  ${liveOn?`<button class="iconbtn sm" data-ostop="${esc(g.chatId)}" title="停止当前任务(保留会话)" style="flex:none">${IC.square}</button>
1002
1020
  <button class="iconbtn sm" data-onew="${esc(g.chatId)}" title="开新会话 — 运行中会先中断当前任务并清空队列(同群内 /new)" style="flex:none">${IC.rotate}</button>`:''}</div>`).join('')
1003
1021
  :'<div class="empty">还没有群项目 · 去「群·项目」配置</div>';
1004
- $('#overview').innerHTML=`<div id="ovInboxWrap">${inboxHtml()}</div><div class="grid cols2 stagger">
1022
+ $('#overview').innerHTML=`${startCardHtml()}<div id="ovInboxWrap">${inboxHtml()}</div><div class="grid cols2 stagger">
1005
1023
  <div class="card" id="ovHealth"><div class="lbl">健康</div>
1006
1024
  <span class="hpill ${healthClass(h)}" id="ovHealthPill"><span class="led"></span><span id="ovHealthTxt">${healthText(h)}</span></span>
1007
1025
  <div class="hrows" id="ovHrows">${h.rows.map(r=>`<div class="hrow" data-hk="${esc(r.label)}"><span class="dot ${r.status}"></span>
@@ -3206,7 +3224,7 @@ function renderSettings(){
3206
3224
  ${(pri.dropped||[]).map(d=>`<div class="hint" style="color:var(--fail)">${IC.x} 配置里已忽略 ${esc(d.value)}:${esc(d.why)}</div>`).join('')}
3207
3225
  ${(pri.outside||[]).length?`<div class="hint" style="margin-top:6px">${IC.warn} ${pri.outside.length} 个群在根之外:${esc(pri.outside.map(o=>o.name||o.chatId).join('、'))} —— 照常运行,改目录时须搬进根内</div>`:''}
3208
3226
  </div>
3209
- <div class="hint" style="margin-top:14px;padding-top:12px;border-top:1px solid var(--line);line-height:1.7">这里只读,控制台不提供修改。要改,在服务器上编辑配置文件里的 <code>projectsRoots</code>(一个路径或数组,认 ~),或重新运行安装向导;改后即时生效,不用重启。</div>`;
3227
+ <div class="hint" style="margin-top:14px;padding-top:12px;border-top:1px solid var(--line);line-height:1.7">这里只读,控制台不提供修改。要改,在服务器上编辑配置文件里的 <code>projectsRoots</code>(一个路径或数组,认 ~),或运行 <code>sintbot setup --full</code>;改后即时生效,不用重启。</div>`;
3210
3228
  const urlRow=(label,u)=>`<div class="kv"><span class="kk">${label}</span><span class="vv mono"><a href="${esc(u)}" target="_blank" style="color:var(--ember-soft)">${esc(u)}</a></span></div>`;
3211
3229
  // 「服务器」卡 = 访问地址 + 项目根目录(用户 2026-09-08:两张短卡并成一张,不留空当;本机 127.0.0.1 那行去掉 ——
3212
3230
  // 正在看控制台的人用不着它,而它又是三行里最像「可以点」却最没用的一行)
@@ -3229,6 +3247,19 @@ function renderSettings(){
3229
3247
  ${linkRow('源码与文档','https://github.com/sintlab/sintbot','github.com/sintlab/sintbot')}
3230
3248
  ${linkRow('反馈问题','https://github.com/sintlab/sintbot/issues','github.com/sintlab/sintbot/issues')}
3231
3249
  </div>`;
3250
+ // 机器人名字与网络代理(1.0.12 起安装向导不再问这两样,在这里配):名字是自我介绍 / 升级播报里的自称;
3251
+ // 代理给连 Claude / ChatGPT 用,国内模型端点自动直连(服务端按已配的模型生成直连名单)
3252
+ const envPx=S.envProxy||'';
3253
+ const botCard=`<div class="card" id="botNetCard"><div class="lbl">机器人与网络</div>
3254
+ <div class="field" style="max-width:320px"><label class="f">机器人名字</label><input id="bBrand" placeholder="SintBot" value="${esc(S.brand||'')}"/>
3255
+ <div class="hint" style="margin-top:5px">自我介绍、升级播报里的自称 · 留空 = SintBot</div></div>
3256
+ <div class="field" style="margin:12px 0 0;max-width:360px"><label class="f">网络代理</label><input id="pxHttp" placeholder="http://127.0.0.1:7890" value="${esc((px&&px.http)||'')}"/>
3257
+ <div class="hint" style="margin-top:5px">连不上 Claude / ChatGPT 时填 · 国内模型端点自动直连 · 留空 = 不用代理</div>
3258
+ ${envPx?`<div class="hint" style="margin-top:4px">后台服务的环境里已有代理:<span class="mono">${esc(envPx)}</span></div>`:''}
3259
+ ${px&&px.noProxy?`<div class="hint" style="margin-top:4px">直连:<span class="mono">${esc(px.noProxy)}</span></div>`:''}</div>
3260
+ <button class="btn primary" id="bSave" style="margin-top:14px">保存</button>
3261
+ <div class="hint" style="margin-top:10px">代理第一次填,新的对话轮次就生效;改已有的地址要重启后台服务(sintbot service restart)。</div>
3262
+ </div>`;
3232
3263
  const verhistCard=`<div class="card" style="grid-column:1/-1" id="verhistCard"><div class="lbl">版本历史</div>
3233
3264
  <div id="verhistBox"><div class="empty">加载中…</div></div></div>`;
3234
3265
  $('#settings').innerHTML=`<div class="grid cols2 stagger">
@@ -3248,6 +3279,7 @@ function renderSettings(){
3248
3279
  <button class="btn primary" id="aSave" style="margin-top:14px">保存</button>
3249
3280
  <div class="hint" style="margin-top:10px">执行权限已改<b>按群设</b> —— 见「群·项目」每张卡(缺省 <b>dontAsk 只读 · fail-closed</b>)。</div>
3250
3281
  </div>
3282
+ ${botCard}
3251
3283
  <div class="fx-sub" style="grid-column:1/-1;margin:6px 0 0"><span style="flex:none">聊天平台</span><span class="faint" style="font-weight:400;letter-spacing:0">配了哪个就接哪个,可以同时在线;每个群项目自己选属于哪边。点卡头展开</span></div>
3252
3284
  ${fxFeishuCard(fe)}
3253
3285
  ${fxSlackCard(S.slack||null)}
@@ -3256,6 +3288,11 @@ function renderSettings(){
3256
3288
  ${verhistCard}
3257
3289
  </div>`;
3258
3290
  loadChangelog();
3291
+ $('#bSave').onclick=async()=>{const brand=$('#bBrand').value.trim(),http=$('#pxHttp').value.trim();
3292
+ if(http&&!/^https?:\/\/\S+$/i.test(http)){toast('代理地址要像 http://127.0.0.1:7890','err');return;}
3293
+ try{await api('/api/agent','POST',{brand});S=await api('/api/proxy','POST',{enabled:!!http,http});toast('已保存','ok');renderSettings();}catch(e){toast(e.message,'err');}};
3294
+ // 只开着控制台的守护进程(还没接平台):第一次接上平台时说一句接下来会发生什么(服务托管的会自己重启连上)
3295
+ {const pl=S.platforms||{};if(S.setupMode&&(pl.feishu||pl.slack||pl.wecom)&&!window._sbRestartToast){window._sbRestartToast=1;toast(S.setupMode.supervised?'已接上聊天平台 —— 后台服务几秒后自动重启并连上':'已接上聊天平台 —— 在终端里重启 sintbot start --ui 连上','ok');}}
3259
3296
  bindWecomCard();
3260
3297
  bindFxFolds();
3261
3298
  const lo=$('#logoutBtn');if(lo)lo.onclick=async()=>{try{await api('/api/logout','POST',{});}catch{}dropToken();TOKEN='';location.reload();};
@@ -3609,6 +3646,8 @@ function renderFxCheck(box,checks){
3609
3646
  const splitList=s=>s.split(/[\n,]+/).map(x=>x.trim()).filter(Boolean);
3610
3647
 
3611
3648
  /* ── shell ── */
3649
+ // 「开始使用」卡里的「去某页」按钮:委托到 document,重绘不用重新绑
3650
+ document.addEventListener('click',(e)=>{const b=e.target.closest&&e.target.closest('[data-gotab]');if(!b)return;const t=document.querySelector('[data-tab="'+b.dataset.gotab+'"]');if(t)t.click();});
3612
3651
  function renderAll(){renderOverview();renderProviders();renderGroups();renderPlayground();renderSettings();updateDaemon();updateTopFxApp();}
3613
3652
  // 顶栏应用胶囊。数据同「设置」那张身份条(同一份落盘缓存),没取过就整块不显示 —— 顶栏不适合
3614
3653
  // 放「未获取」这类待办文案。头像 JS 注入并留 🤖 兜底(远端图,控制台可能开在断外网的机器上)。
@@ -3713,6 +3752,32 @@ updateThemeBtn();
3713
3752
  ⚠ 正则一律 ^…$ 整串、不加 g 标志;更具体的条目必须排在泛化条之前。 */
3714
3753
  const I18N_EN=[
3715
3754
  ["控制台","Console"],
3755
+ ["开始使用","Get started"],
3756
+ ["配好模型","Set up a model"],
3757
+ ["换模型、加模型在「模型 · 库」","Switch or add models under Models"],
3758
+ ["还没有能用的模型:在「模型 · 库」里添加、测试","No usable model yet: add and test one under Models"],
3759
+ ["去「模型 · 库」","Open Models"],
3760
+ ["接聊天平台","Connect a chat platform"],
3761
+ ["飞书 / Slack / 企业微信,「设置」里每个平台都有分步指南","Slack, Feishu or WeCom: each has a step-by-step guide under Settings"],
3762
+ ["去「设置」","Open Settings"],
3763
+ ["绑一个群","Bind a group"],
3764
+ ["把群绑到项目目录,一个群一个项目","Bind a group to a project folder, one project per group"],
3765
+ ["去「群 · 项目」","Open Groups"],
3766
+ ["在群里 @ 机器人说一句话","@mention the bot in the group"],
3767
+ ["不接平台也能先在「试跑」里跑一轮","No platform yet? Try a turn in Playground first"],
3768
+ ["后台服务现在只开着控制台:平台接好、保存后,它会自动重启并连上(约 15 秒)。","The background service is running the console only: once you connect a platform and save, it restarts itself and connects (about 15 seconds)."],
3769
+ ["守护进程现在只开着控制台:接好平台后,在终端里按 Ctrl-C 再运行 sintbot start --ui 连上。","The daemon is running the console only: after connecting a platform, press Ctrl-C in the terminal and run sintbot start --ui again to connect."],
3770
+ ["机器人与网络","Bot & network"],
3771
+ ["机器人名字","Bot name"],
3772
+ ["自我介绍、升级播报里的自称 · 留空 = SintBot","How it refers to itself in introductions and upgrade notices · empty = SintBot"],
3773
+ ["网络代理","Network proxy"],
3774
+ ["连不上 Claude / ChatGPT 时填 · 国内模型端点自动直连 · 留空 = 不用代理","Set this if Claude / ChatGPT can't be reached · China model endpoints connect directly · empty = no proxy"],
3775
+ ["后台服务的环境里已有代理:","The background service already has a proxy in its environment: "],
3776
+ ["直连:","Direct: "],
3777
+ ["代理第一次填,新的对话轮次就生效;改已有的地址要重启后台服务(sintbot service restart)。","A first-time proxy applies to new turns right away; changing an existing address needs a service restart (sintbot service restart)."],
3778
+ ["代理地址要像 http://127.0.0.1:7890","The proxy address should look like http://127.0.0.1:7890"],
3779
+ ["已接上聊天平台 —— 后台服务几秒后自动重启并连上","Chat platform connected — the background service restarts in a few seconds and connects"],
3780
+ ["已接上聊天平台 —— 在终端里重启 sintbot start --ui 连上","Chat platform connected — restart sintbot start --ui in the terminal to connect"],
3716
3781
  ["服务器","Server"],
3717
3782
  ["配置模式","Config mode"],
3718
3783
  ["实时连接(SSE)","Live connection (SSE)"],
@@ -4371,7 +4436,8 @@ const I18N_EN=[
4371
4436
  ["群项目都放这里 · 选目录、保存群绑定、从本机目录提炼风格,只认这些目录之内的路径 · 当前是默认值","All group projects live here · choosing a folder, saving a group binding, and extracting a style from a local folder only accept paths inside these folders · currently the default"],
4372
4437
  ["不存在 —— 在服务器上建好即可","Does not exist — just create it on the server"],
4373
4438
  ["这里只读,控制台不提供修改。要改,在服务器上编辑配置文件里的","Read-only here — the console offers no way to change it. To change it, edit "],
4374
- ["(一个路径或数组,认 ~),或重新运行安装向导;改后即时生效,不用重启。"," in the config file on the server (one path, or an array; ~ is accepted), or run the setup wizard again. Changes apply immediately, no restart needed."],
4439
+ ["(一个路径或数组,认 ~),或运行"," in the config file on the server (one path, or an array; ~ is accepted), or run"],
4440
+ [";改后即时生效,不用重启。","; changes apply immediately, no restart needed."],
4375
4441
  ["公网","Public"],
4376
4442
  ["局域网","LAN"],
4377
4443
  ["访问地址","Access URLs"],
package/src/ui/server.js CHANGED
@@ -208,7 +208,7 @@ function bindNoticeText(workdir, platform = 'feishu', cmd = '/help') {
208
208
  return `✅ 本群已绑定项目「${path.basename(String(workdir || '')) || '(未命名)'}」\n${platform === 'wecom' ? '@我 派单(只有 @ 我的消息我才收得到)' : '直接发消息或 @我 派单'};${cmd} 查看命令`
209
209
  }
210
210
 
211
- function createUiServer({ configPath, getLiveState = null, getConnHealth = null, onConfigChange = null, control = null, host = '127.0.0.1', log = () => {}, issuesPath = null, sessionsPath = null, auditPath = null, ledgerPath = null, inboxDismissPath = null, fetchImpl = null, cdpImpl = null, onTakeoverClaim = null, onTakeoverExpire = null, onTakeoverEnd = null, takeoverNow = null, wecomExec = null, verifyFile = null } = {}) {
211
+ function createUiServer({ configPath, getLiveState = null, getConnHealth = null, onConfigChange = null, control = null, host = '127.0.0.1', log = () => {}, issuesPath = null, sessionsPath = null, auditPath = null, ledgerPath = null, inboxDismissPath = null, fetchImpl = null, cdpImpl = null, onTakeoverClaim = null, onTakeoverExpire = null, onTakeoverEnd = null, takeoverNow = null, wecomExec = null, verifyFile = null, setupMode = null } = {}) {
212
212
  // wecomExec:企业微信命令行工具的执行器注入(只为可测;生产走默认 execFile)
213
213
  // verifyFile:凭据验证记录的落点注入(只为可测;生产 = ~/.sbot/verify.json)
214
214
  const vopts = verifyFile ? { file: verifyFile } : undefined
@@ -576,6 +576,8 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
576
576
  },
577
577
  proxy: raw.proxy && raw.proxy.http ? { http: raw.proxy.http, noProxy: raw.proxy.noProxy || '' } : null,
578
578
  live: getLiveState ? getLiveState() : { running: false },
579
+ // 守护进程还没接平台、只开着控制台(cmdStart 配置模式):supervised = 由服务托管,接上平台后会自己重启连上
580
+ setupMode: setupMode ? { supervised: !!setupMode.supervised } : null,
579
581
  conn, // 各平台连接健康度字典 { feishu, wecom }(daemon 运行时);未运行为 null
580
582
  previews: previewsState(raw),
581
583
 
@@ -595,6 +597,9 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
595
597
  return { tz, offsetMin: tzOffsetMin(tz), hostTz, hostOffsetMin: -new Date().getTimezoneOffset(), now: Date.now() }
596
598
  })(),
597
599
  timezone: raw.timezone || '', // 设置页编辑用(空 = 未显式配置,跑默认北京)
600
+ brand: raw.brand || '', // 机器人自称(设置页编辑;空 = SintBot)
601
+ // 本进程环境里的代理(装服务时从终端带进来的 https_proxy 等;带认证的只说「带认证」):设置页据此说明「服务里已有代理」
602
+ envProxy: require('../core/proxy').proxyDescribe(process.env),
598
603
  // 本机访问地址(控制台「设置」页展示):本机 URL 恒有;对外绑定时列出局域网 URL。
599
604
  net: (() => {
600
605
  const addr = server.address()
@@ -1013,6 +1018,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
1013
1018
  }
1014
1019
  if (p === '/api/proxy' && req.method === 'POST') {
1015
1020
  const { http: httpUrl, enabled } = await body(req); const raw = readRaw()
1021
+ if (enabled && httpUrl && !/^https?:\/\/\S+$/i.test(String(httpUrl))) return sendJSON(res, 400, { error: '代理地址要像 http://127.0.0.1:7890' })
1016
1022
  if (enabled && httpUrl) raw.proxy = { http: httpUrl, https: httpUrl, noProxy: deriveNoProxy(raw.providers) }
1017
1023
  else delete raw.proxy
1018
1024
  writeRaw(raw); return sendJSON(res, 200, await buildState())
@@ -1020,6 +1026,8 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
1020
1026
  if (p === '/api/agent' && req.method === 'POST') {
1021
1027
  const patch = await body(req); const raw = readRaw(); raw.agent = raw.agent || {}
1022
1028
  for (const k of ['permissionMode', 'maxConcurrent', 'maxTurnTimeMins']) if (k in patch) raw.agent[k] = patch[k]
1029
+ // 机器人自称(顶层 brand):空或默认名就不落盘,和安装向导 --full 的写法一致
1030
+ if ('brand' in patch) { const b = String(patch.brand || '').trim().slice(0, 40); if (b && b !== 'SintBot') raw.brand = b; else delete raw.brand }
1023
1031
  // 长会话自动压缩阈值:0 = 关,否则限 10万–100万(CLI 旗子的文档区间;区间外它会拒参数,
1024
1032
  // 每轮必死 ⇒ 存前就挡住,别让坏值落盘)。
1025
1033
  if ('autocompactTokens' in patch) {