sintbot 1.4.16 → 1.4.18
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 +62 -0
- package/CHANGELOG.json +62 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/bin/sintbot-v2.js +82 -4
- package/docs/AI-ASSISTED-INSTALL.md +4 -4
- package/docs/install.md +62 -10
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +4 -4
- package/docs/zh-CN/install.md +62 -10
- package/install.sh +1 -1
- package/package.json +4 -1
- package/src/agent/claudecode.js +41 -3
- package/src/agent/claudeusage.js +239 -5
- package/src/agent/envconflicts.js +74 -0
- package/src/agent/privatecore.js +86 -4
- package/src/agent/privatecore.wasm +0 -0
- package/src/agent/runner.js +9 -1
- package/src/c2configcontrol.js +7 -0
- package/src/c2console.js +3 -0
- package/src/c2install.js +1 -1
- package/src/c2multigroupservice.js +1 -1
- package/src/connect.js +566 -0
- package/src/core/c2runnerinput.js +47 -1
- package/src/core/v2engine.js +211 -11
- package/src/platform/feishu.js +22 -7
- package/src/setupserver.js +447 -0
- package/src/ui/console.html +161 -128
- package/src/ui/serverbase.js +19 -10
- package/src/usage.js +131 -38
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Setup mode: `sintbot start --ui` with no configuration serves a local page
|
|
3
|
+
// that runs the scan-to-connect flow in this process. The page collects the
|
|
4
|
+
// inputs `sintbot connect` would take as flags, shows the QR code, streams the
|
|
5
|
+
// step notices, and offers the resume paths (existing app, hand-made group)
|
|
6
|
+
// as buttons. Once the configuration is written and initialized, the server
|
|
7
|
+
// closes and the caller continues into the normal start.
|
|
8
|
+
//
|
|
9
|
+
// Everything that decides the flow stays in src/connect.js and the core; this
|
|
10
|
+
// module only adapts it to HTTP: arguments in, captured notices out.
|
|
11
|
+
const http = require('node:http')
|
|
12
|
+
const path = require('node:path')
|
|
13
|
+
const { spawnSync } = require('node:child_process')
|
|
14
|
+
const { runConnect, qrPage, probeRunners: connectProbe, EXIT_INTERRUPTED } = require('./connect')
|
|
15
|
+
|
|
16
|
+
const QR_URL = /^https:\/\/[^\s]+/
|
|
17
|
+
const APP_ID_LINE = /^应用 ID:(cli_[A-Za-z0-9]{6,64})$/m
|
|
18
|
+
const CHAT_HINT = /--chat <oc_…>/
|
|
19
|
+
const APP_ID = /^cli_[A-Za-z0-9]{6,64}$/
|
|
20
|
+
const CHAT_ID = /^oc_[A-Za-z0-9_-]{1,124}$/
|
|
21
|
+
const PROJECT_NAME = /^[\p{L}\p{N}][\p{L}\p{N}._-]{0,59}$/u
|
|
22
|
+
const RUNNERS = Object.freeze(['codex', 'claude'])
|
|
23
|
+
const BODY_LIMIT = 16 * 1024
|
|
24
|
+
|
|
25
|
+
// The same probe runConnect uses, so the page never offers a runner the run
|
|
26
|
+
// would then refuse.
|
|
27
|
+
function probeRunners(spawn = spawnSync, env = process.env) {
|
|
28
|
+
try { return connectProbe(spawn, env) } catch { return Object.fromEntries(RUNNERS.map(r => [r, false])) }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function svgOf(url) {
|
|
32
|
+
const m = /<svg[\s\S]*?<\/svg>/.exec(qrPage(url))
|
|
33
|
+
return m ? m[0] : ''
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// One attempt = one `runConnect` run with captured output. The state object
|
|
37
|
+
// is what the page polls; it carries no secrets (the app secret never reaches
|
|
38
|
+
// stdout/stderr by design of runConnect).
|
|
39
|
+
function createSetupSession({ configFile, dataDir, home, version, runners, connect = runConnect, deps = {},
|
|
40
|
+
initialize = (file, options) => require('./c2install').initializeInstallation(file, options), log = () => {} }) {
|
|
41
|
+
const state = {
|
|
42
|
+
phase: 'form', // form | running | waitingChat | done | failed
|
|
43
|
+
lines: [], // notices in order (stderr of runConnect)
|
|
44
|
+
qr: null, // inline SVG once the scan link is known
|
|
45
|
+
appId: null, // printed by runConnect after the scan; reused by every retry
|
|
46
|
+
notice: '', // last notice when stopped or failed
|
|
47
|
+
groupAppId: null, // app owning the confirmed group
|
|
48
|
+
chatId: null, // a confirmed created group, reused after cancellation or retry
|
|
49
|
+
initOnly: false, // the configuration is written; only the journals failed
|
|
50
|
+
modelDefaults: null, // the live recommendation confirmed before scanning
|
|
51
|
+
runners, // which executors answered --version
|
|
52
|
+
defaults: { name: 'sintbot', projectsRoot: path.join(home, 'Projects') },
|
|
53
|
+
startedAt: null, endedAt: null,
|
|
54
|
+
}
|
|
55
|
+
let signalHandlers = new Map()
|
|
56
|
+
let current = null
|
|
57
|
+
let finish
|
|
58
|
+
const completed = new Promise(resolve => { finish = resolve })
|
|
59
|
+
|
|
60
|
+
const argvFor = (input) => {
|
|
61
|
+
const argv = ['--config', configFile, '--no-open']
|
|
62
|
+
if (dataDir) argv.push('--data-dir', dataDir)
|
|
63
|
+
if (input.workdir) argv.push('--workdir', input.workdir)
|
|
64
|
+
else argv.push('--name', input.name || 'sintbot')
|
|
65
|
+
if (input.provider) argv.push('--provider', input.provider)
|
|
66
|
+
if (input.basic) argv.push('--basic')
|
|
67
|
+
if (input.appId) argv.push('--app-id', input.appId)
|
|
68
|
+
if (input.chatId) argv.push('--chat', input.chatId)
|
|
69
|
+
return argv
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const validate = (input) => {
|
|
73
|
+
if (!input || typeof input !== 'object') throw new Error('请填写接入信息')
|
|
74
|
+
const out = {}
|
|
75
|
+
if (input.workdir !== undefined && input.workdir !== '') {
|
|
76
|
+
if (typeof input.workdir !== 'string' || !path.isAbsolute(input.workdir)) throw new Error('项目目录必须是绝对路径')
|
|
77
|
+
out.workdir = path.resolve(input.workdir)
|
|
78
|
+
} else {
|
|
79
|
+
const name = input.name === undefined || input.name === '' ? 'sintbot' : String(input.name)
|
|
80
|
+
if (!PROJECT_NAME.test(name) || name === '.' || name === '..') throw new Error('项目名只能用字母、数字、"."、"_" 和 "-"(最长 60)')
|
|
81
|
+
out.name = name
|
|
82
|
+
}
|
|
83
|
+
if (input.provider !== undefined && input.provider !== '' && input.provider !== 'auto') {
|
|
84
|
+
if (!RUNNERS.includes(input.provider)) throw new Error('执行器只能是 codex 或 claude')
|
|
85
|
+
out.provider = input.provider
|
|
86
|
+
}
|
|
87
|
+
out.basic = input.basic === true
|
|
88
|
+
if (input.appId !== undefined && input.appId !== '') {
|
|
89
|
+
if (!APP_ID.test(input.appId)) throw new Error('应用 ID 应形如 cli_…')
|
|
90
|
+
out.appId = input.appId
|
|
91
|
+
}
|
|
92
|
+
if (input.chatId !== undefined && input.chatId !== '') {
|
|
93
|
+
if (!CHAT_ID.test(input.chatId)) throw new Error('群 ID 应形如 oc_…')
|
|
94
|
+
out.chatId = input.chatId
|
|
95
|
+
}
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function start(rawInput) {
|
|
100
|
+
if (state.phase === 'running') throw new Error('接入正在进行')
|
|
101
|
+
if (state.phase === 'done') throw new Error('接入已完成')
|
|
102
|
+
if (state.initOnly) return retryInit()
|
|
103
|
+
// A resume (hand-made group) keeps the directory and options of the run
|
|
104
|
+
// that failed, even if the page was reloaded in between.
|
|
105
|
+
if (rawInput && rawInput.chatId && state.input) {
|
|
106
|
+
const keep = state.input
|
|
107
|
+
rawInput = { name: keep.name || undefined, workdir: keep.workdir || undefined, provider: keep.provider || undefined, basic: keep.basic, ...rawInput }
|
|
108
|
+
for (const k of ['name', 'workdir', 'provider']) if (rawInput[k] === '' || rawInput[k] === null) rawInput[k] = keep[k] || undefined
|
|
109
|
+
}
|
|
110
|
+
const input = validate(rawInput)
|
|
111
|
+
// Every retry reuses the app the first scan created: no second app in the
|
|
112
|
+
// organization, and the same --app-id resume the terminal route offers.
|
|
113
|
+
if (!input.appId && state.appId) input.appId = state.appId
|
|
114
|
+
if (!input.chatId && state.chatId && input.appId === state.groupAppId) input.chatId = state.chatId
|
|
115
|
+
state.phase = 'running'
|
|
116
|
+
state.modelDefaults = null
|
|
117
|
+
state.lines = []
|
|
118
|
+
state.qr = null
|
|
119
|
+
state.notice = ''
|
|
120
|
+
state.startedAt = Date.now()
|
|
121
|
+
state.endedAt = null
|
|
122
|
+
state.input = { name: input.name || null, workdir: input.workdir || null, provider: input.provider || null, basic: input.basic }
|
|
123
|
+
signalHandlers = new Map()
|
|
124
|
+
const stderrText = { buf: '' }
|
|
125
|
+
const write = (sink) => (chunk) => {
|
|
126
|
+
sink.buf += String(chunk)
|
|
127
|
+
let nl
|
|
128
|
+
while ((nl = sink.buf.indexOf('\n')) >= 0) {
|
|
129
|
+
const line = sink.buf.slice(0, nl)
|
|
130
|
+
sink.buf = sink.buf.slice(nl + 1)
|
|
131
|
+
sink.onLine(line)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const stdout = { buf: '', onLine: line => {
|
|
135
|
+
if (QR_URL.test(line)) { if (!state.qr) { state.qr = svgOf(line); state.lines.push('请用手机飞书扫描页面上的二维码。') }; return }
|
|
136
|
+
// The final notice arrives on stdout; the start command does not apply here.
|
|
137
|
+
if (line.trim() && !/\bstart --config\b/.test(line)) state.lines.push(line)
|
|
138
|
+
} }
|
|
139
|
+
stderrText.onLine = line => {
|
|
140
|
+
if (!line.trim()) return
|
|
141
|
+
state.lines.push(line)
|
|
142
|
+
const m = APP_ID_LINE.exec(line)
|
|
143
|
+
if (m) { state.appId = m[1]; state.qr = null }
|
|
144
|
+
}
|
|
145
|
+
const run = (async () => {
|
|
146
|
+
let code
|
|
147
|
+
try {
|
|
148
|
+
code = await connect(argvFor(input), { ...deps, stdout: { write: write(stdout) }, stderr: { write: write(stderrText) },
|
|
149
|
+
isTTY: false, version, env: process.env, home,
|
|
150
|
+
onModelDefaults: value => { state.modelDefaults = { provider: value.provider, model: value.model, effort: value.effort, name: value.name, source: value.source } },
|
|
151
|
+
onGroupReady: ({ appId, chatId }) => { state.appId = appId; state.groupAppId = appId; state.chatId = chatId },
|
|
152
|
+
signals: { on: (sig, fn) => signalHandlers.set(sig, fn), off: (sig) => signalHandlers.delete(sig) } })
|
|
153
|
+
} catch (error) {
|
|
154
|
+
code = 1
|
|
155
|
+
state.lines.push(error?.message || String(error))
|
|
156
|
+
}
|
|
157
|
+
if (stdout.buf.trim()) stdout.onLine(stdout.buf)
|
|
158
|
+
if (stderrText.buf.trim()) stderrText.onLine(stderrText.buf)
|
|
159
|
+
if (code === EXIT_INTERRUPTED) state.lines.push('已取消,没有写入任何文件。')
|
|
160
|
+
state.qr = null
|
|
161
|
+
state.endedAt = Date.now()
|
|
162
|
+
const last = [...state.lines].reverse().find(l => l.trim()) || ''
|
|
163
|
+
state.notice = last
|
|
164
|
+
if (code === 0) {
|
|
165
|
+
state.phase = 'done'
|
|
166
|
+
log('setup: configuration written and initialized')
|
|
167
|
+
finish(true)
|
|
168
|
+
} else if (state.appId && state.lines.some(l => CHAT_HINT.test(l))) {
|
|
169
|
+
state.phase = 'waitingChat'
|
|
170
|
+
} else {
|
|
171
|
+
state.phase = 'failed'
|
|
172
|
+
// The configuration exists but the journals do not: retry only that step.
|
|
173
|
+
if (state.lines.some(l => /^初始化失败/.test(l))) state.initOnly = true
|
|
174
|
+
}
|
|
175
|
+
current = null
|
|
176
|
+
})()
|
|
177
|
+
current = run
|
|
178
|
+
return state
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function retryInit() {
|
|
182
|
+
state.phase = 'running'; state.notice = ''; state.startedAt = Date.now()
|
|
183
|
+
const run = (async () => {
|
|
184
|
+
try {
|
|
185
|
+
initialize(configFile, { dataDir })
|
|
186
|
+
state.initOnly = false
|
|
187
|
+
state.lines.push('初始化完成。')
|
|
188
|
+
state.phase = 'done'
|
|
189
|
+
state.endedAt = Date.now()
|
|
190
|
+
finish(true)
|
|
191
|
+
} catch (error) {
|
|
192
|
+
state.lines.push(`初始化失败(${error?.message || error})。`)
|
|
193
|
+
state.notice = state.lines[state.lines.length - 1]
|
|
194
|
+
state.phase = 'failed'
|
|
195
|
+
state.endedAt = Date.now()
|
|
196
|
+
}
|
|
197
|
+
current = null
|
|
198
|
+
})()
|
|
199
|
+
current = run
|
|
200
|
+
return state
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Signals the run exactly like Ctrl-C; runConnect stops at its next step
|
|
204
|
+
// boundary and writes nothing. False when nothing can be cancelled.
|
|
205
|
+
function cancel() {
|
|
206
|
+
if (state.phase !== 'running') return false
|
|
207
|
+
const handler = signalHandlers.get('SIGINT') || signalHandlers.get('SIGTERM')
|
|
208
|
+
if (!handler) return false
|
|
209
|
+
handler()
|
|
210
|
+
return true
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function reset() {
|
|
214
|
+
if (state.phase === 'running' || state.phase === 'done') return false
|
|
215
|
+
state.phase = 'form'; state.lines = []; state.qr = null; state.notice = ''
|
|
216
|
+
return true
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const snapshot = () => ({ ...state, lines: state.lines.slice(-60) })
|
|
220
|
+
void EXIT_INTERRUPTED
|
|
221
|
+
return { state, snapshot, start, cancel, reset, completed, wait: () => current }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function createSetupServer(options) {
|
|
225
|
+
const { host = '127.0.0.1', port = 4600, version = '', log = () => {}, page = setupPage } = options
|
|
226
|
+
if (!['127.0.0.1', 'localhost', '::1'].includes(host)) throw new Error('Setup mode only listens on this Mac')
|
|
227
|
+
const session = createSetupSession(options)
|
|
228
|
+
const json = (res, status, body) => {
|
|
229
|
+
const text = JSON.stringify(body)
|
|
230
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'content-length': Buffer.byteLength(text) })
|
|
231
|
+
res.end(text)
|
|
232
|
+
}
|
|
233
|
+
const readBody = (req) => new Promise((resolve, reject) => {
|
|
234
|
+
let size = 0, raw = ''
|
|
235
|
+
if (Number(req.headers['content-length']) > BODY_LIMIT) { const e = new Error('请求过大'); e.status = 413; return reject(e) }
|
|
236
|
+
req.on('data', chunk => { size += chunk.length; if (size > BODY_LIMIT) { const e = new Error('请求过大'); e.status = 413; reject(e) } else raw += chunk })
|
|
237
|
+
req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : {}) } catch { reject(new Error('请求不是 JSON')) } })
|
|
238
|
+
req.on('error', reject)
|
|
239
|
+
})
|
|
240
|
+
let boundPort = port
|
|
241
|
+
const localHost = value => /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/.test(value || '') &&
|
|
242
|
+
(Number((value.match(/:(\d+)$/) || [])[1] || 80) === boundPort)
|
|
243
|
+
const server = http.createServer(async (req, res) => {
|
|
244
|
+
try {
|
|
245
|
+
// Local page only: the Host must be this listener (no DNS rebinding) and
|
|
246
|
+
// a browser Origin, when present, must be the same host and port.
|
|
247
|
+
if (!localHost(req.headers.host)) return json(res, 403, { error: 'forbidden' })
|
|
248
|
+
const origin = req.headers.origin
|
|
249
|
+
if (origin) {
|
|
250
|
+
let originHost = null
|
|
251
|
+
try { const parsed = new URL(origin); if (parsed.protocol === 'http:') originHost = parsed.host } catch {}
|
|
252
|
+
if (!originHost || originHost !== req.headers.host) return json(res, 403, { error: 'forbidden' })
|
|
253
|
+
}
|
|
254
|
+
const url = new URL(req.url, 'http://127.0.0.1')
|
|
255
|
+
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/setup')) {
|
|
256
|
+
const html = page({ version })
|
|
257
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
|
|
258
|
+
return res.end(html)
|
|
259
|
+
}
|
|
260
|
+
if (req.method === 'GET' && url.pathname === '/api/setup/state') return json(res, 200, session.snapshot())
|
|
261
|
+
if (req.method === 'POST' && url.pathname === '/api/setup/start') {
|
|
262
|
+
if (!/^application\/json/.test(req.headers['content-type'] || '')) return json(res, 415, { error: '需要 JSON' })
|
|
263
|
+
const body = await readBody(req)
|
|
264
|
+
return json(res, 200, { ok: true, state: session.start(body) && session.snapshot() })
|
|
265
|
+
}
|
|
266
|
+
if (req.method === 'POST' && url.pathname === '/api/setup/cancel') return json(res, 200, { ok: session.cancel() })
|
|
267
|
+
if (req.method === 'POST' && url.pathname === '/api/setup/reset') return json(res, 200, { ok: session.reset() })
|
|
268
|
+
if (req.method === 'GET') { res.writeHead(302, { location: '/' }); return res.end() }
|
|
269
|
+
return json(res, 404, { error: 'not found' })
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (res.headersSent || res.destroyed) return
|
|
272
|
+
return json(res, error?.status || 400, { error: error?.message || String(error) })
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
server.keepAliveTimeout = 1000
|
|
276
|
+
server.on('clientError', (error, socket) => {
|
|
277
|
+
if (!socket.destroyed) socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
|
|
278
|
+
})
|
|
279
|
+
return {
|
|
280
|
+
server, session,
|
|
281
|
+
listen: () => new Promise((resolve, reject) => {
|
|
282
|
+
server.once('error', reject)
|
|
283
|
+
server.listen(port, host, () => {
|
|
284
|
+
server.off('error', reject)
|
|
285
|
+
boundPort = server.address().port
|
|
286
|
+
server.on('error', error => log(`setup server error: ${error.message}`))
|
|
287
|
+
resolve(server.address())
|
|
288
|
+
})
|
|
289
|
+
}),
|
|
290
|
+
// Resolves once the configuration is written; the server is then closed
|
|
291
|
+
// after a short grace so the page can read the final state.
|
|
292
|
+
completed: session.completed,
|
|
293
|
+
close: () => new Promise(resolve => { server.closeAllConnections?.(); server.close(() => resolve()) }),
|
|
294
|
+
log,
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Display-only translations: the core's original notices still drive the session.
|
|
299
|
+
// Unknown upstream details retain machine-readable error codes without mixing UI languages.
|
|
300
|
+
function translateSetupNotice(value, lang = 'en') {
|
|
301
|
+
const line = String(value || '')
|
|
302
|
+
if (lang === 'zh' && /^Could not read live model defaults for /.test(line)) return '无法读取执行器实时推荐的型号和思考深度。请检查执行器登录和网络后重试;本次没有新建飞书应用。'
|
|
303
|
+
if (lang === 'zh' && /^The live model catalog did not provide/.test(line)) return '实时模型清单没有提供有效的型号和思考深度,请检查执行器登录后重试。'
|
|
304
|
+
if (lang === 'zh') return line.replace(/然后执行 sintbot connect.*$/, '然后在此页面填写群 ID 并继续。')
|
|
305
|
+
.replace(/重新执行 sintbot connect(?: --app-id \S+)?[。]?/g, '在此页面重试。')
|
|
306
|
+
.replace(/可手动执行 sintbot init 重试。/, '请点击「重试初始化」。')
|
|
307
|
+
const exact = {
|
|
308
|
+
'请填写接入信息': 'Enter the project details.',
|
|
309
|
+
'项目目录必须是绝对路径': 'Enter an absolute path for the project folder.',
|
|
310
|
+
'项目名只能用字母、数字、"."、"_" 和 "-"(最长 60)': 'Use letters, digits, ".", "_" or "-" for the project name (up to 60 characters).',
|
|
311
|
+
'执行器只能是 codex 或 claude': 'Choose Codex or Claude Code.',
|
|
312
|
+
'应用 ID 应形如 cli_…': 'An app ID must start with cli_.',
|
|
313
|
+
'群 ID 应形如 oc_…': 'A group ID must start with oc_.',
|
|
314
|
+
'正在读取执行器推荐的型号和思考深度…': 'Reading the executor’s recommended model and thinking depth…',
|
|
315
|
+
'接入正在进行': 'Setup is already running.', '接入已完成': 'Setup is already complete.',
|
|
316
|
+
'请求过大': 'The request is too large.', '请求不是 JSON': 'The request is not valid JSON.', '需要 JSON': 'Send a JSON request.',
|
|
317
|
+
'请用手机飞书扫描页面上的二维码。': 'Scan this QR code with Feishu on your phone.',
|
|
318
|
+
'请先确认手机飞书当前在企业/团队身份下;没有组织的个人身份扫不了,需先新建一个团队(免费)。': 'Switch Feishu on your phone to a company or team account. A personal account without an organization cannot connect; create a free team first.',
|
|
319
|
+
'等待扫码…': 'Waiting for you to scan…', '等待扫码…(已放慢轮询)': 'Waiting for you to scan… (checking less often)',
|
|
320
|
+
'已切换到 Lark 国际版域名。': 'Switched to the international Lark service.',
|
|
321
|
+
'已取消,没有写入任何文件。': 'Cancelled. No configuration or project files were written.',
|
|
322
|
+
'已获取应用凭证。': 'App credentials received.', '应用已启用。': 'The app is enabled.',
|
|
323
|
+
'当前是个人身份,飞书不允许它调用接口。请新建一个团队,切换过去后重新扫码。': 'Feishu does not allow this personal account to use the API. Create a team, switch to it, and scan again.',
|
|
324
|
+
'已提交,等组织管理员审核通过。': 'Submitted. Waiting for your organization administrator to approve the app.',
|
|
325
|
+
'应用未启用:请到飞书开发者后台启用该应用后重新执行 sintbot connect。': 'Enable the app in the Feishu developer console, then retry here.',
|
|
326
|
+
'自检通过。': 'Readiness checks passed.', '基础模式,跳过权限开通。': 'Basic setup: skipping permission changes.',
|
|
327
|
+
'基础模式,跳过发布。': 'Basic setup: skipping publication.', '权限已开通。': 'Permissions enabled.',
|
|
328
|
+
'已提交发布。': 'App publication submitted.', '使用你提供的群。': 'Using the group you provided.',
|
|
329
|
+
'群已创建并拉入机器人。': 'Group created and the bot added.', '初始化完成。': 'Initialization complete.',
|
|
330
|
+
'连接完成。': 'Connected.', '连接完成,去飞书群里 @机器人 试试吧。': 'Connected. Send your first task to the bot in the Feishu group.',
|
|
331
|
+
}
|
|
332
|
+
if (exact[line]) return exact[line]
|
|
333
|
+
let match
|
|
334
|
+
if ((match = /^新群默认:(.*) · (.*?)。接入后可在控制台修改。$/.exec(line))) return `New group defaults: ${match[1]} · ${match[2]}. You can change them in the console after setup.`
|
|
335
|
+
if ((match = /^二维码 (\d+) 秒内有效。$/.exec(line))) return `The QR code expires in ${match[1]} seconds.`
|
|
336
|
+
if ((match = /^应用 ID:(.*)$/.exec(line))) return `App ID: ${match[1]}`
|
|
337
|
+
if ((match = /^项目目录 (.*) 就绪。$/.exec(line))) return `Project folder ready: ${match[1]}`
|
|
338
|
+
if ((match = /^配置已写入 (.*)。$/.exec(line))) return `Configuration saved to ${match[1]}.`
|
|
339
|
+
if ((match = /^目录 (.*) 已存在且非空。/.exec(line))) return `The folder ${match[1]} already contains files. Choose another project name, or enter that folder in the existing-project field.`
|
|
340
|
+
if (/^目录 /.test(line)) return 'This folder is too broad to use as a project. Choose a specific project folder.'
|
|
341
|
+
const codes = [...line.matchAll(/(?:code[ ::]+|HTTP\s+|()(\d{3,})/g)].map(m => m[1])
|
|
342
|
+
const suffix = codes.length ? ` (code ${[...new Set(codes)].join(', ')})` : ''
|
|
343
|
+
const detail = /(([^)]+))/.exec(line)?.[1]
|
|
344
|
+
const extra = detail && !/[\u3400-\u9fff]/.test(detail) ? ` (${detail})` : suffix
|
|
345
|
+
if (/^二维码页面未能打开/.test(line)) return 'The separate QR window could not open. Use the QR code on this page.'
|
|
346
|
+
if (/^获取 tenant_access_token 失败/.test(line)) return `Could not obtain an app access token${extra}. Check that the app is available, then retry.`
|
|
347
|
+
if (/^查询应用状态失败/.test(line)) return `Could not read the app status${extra}. Retry after checking the app in the Feishu developer console.`
|
|
348
|
+
if (/^审核超时/.test(line)) return 'Approval is still pending. Retry after your organization administrator approves the app.'
|
|
349
|
+
if (/^自检未通过|^自检失败/.test(line)) return `Readiness checks failed${extra}. Review the bot, event subscriptions and permissions in the Feishu developer console, then retry.`
|
|
350
|
+
if (/^自检有提醒/.test(line)) return `Readiness checks reported warnings${extra}. Setup can continue, but some bot features may be unavailable. Review permissions and event subscriptions in the Feishu developer console.`
|
|
351
|
+
if (/^权限开通失败/.test(line)) return `Could not enable permissions${extra}. Setup can continue; ask an administrator to enable messaging and card permissions in the Feishu developer console.`
|
|
352
|
+
if (/^发布失败(210304)/.test(line)) return 'Publication failed (210304). Enable the bot as the default app ability in the Feishu developer console, then create and publish a version. Until publication, only the developer can use it.'
|
|
353
|
+
if (/^发布失败/.test(line)) return `Publication failed${extra}. Setup can continue. Publish a version in the Feishu developer console; until then, only the developer can use the bot.`
|
|
354
|
+
if (/^应用可用范围不含你|^自动建群失败/.test(line)) return `Could not create the group${extra}. Create a Feishu group, add the bot, then enter its group ID here to continue.`
|
|
355
|
+
if (/^创建项目目录失败/.test(line)) return `Could not create the project folder${extra}. Check folder permissions and retry.`
|
|
356
|
+
if (/^写入 .* 失败/.test(line)) return `Could not save the configuration${extra}. Check folder permissions and retry.`
|
|
357
|
+
if (/^初始化失败/.test(line)) return `Initialization failed${extra}. Your configuration is saved. Choose Retry initialization.`
|
|
358
|
+
if (/[\u3400-\u9fff]/.test(line)) return `Setup could not complete this step${suffix}. Check the app in the Feishu developer console and retry.`
|
|
359
|
+
return line
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function setupPage({ version = '' } = {}) {
|
|
363
|
+
return String.raw`<!doctype html>
|
|
364
|
+
<html lang="en" data-sintbot-setup="true"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
365
|
+
<title>SintBot · Connect Feishu</title>
|
|
366
|
+
<style>
|
|
367
|
+
:root{--ink:#13171c;--muted:#6b7280;--line:#e5e7eb;--paper:#f6f5f1;--copper:#dd7d3d;--fail:#b91c1c;--ok:#15803d}
|
|
368
|
+
*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.6 -apple-system,"PingFang SC","Helvetica Neue",sans-serif}
|
|
369
|
+
.wrap{max-width:720px;margin:0 auto;padding:40px 20px 60px}h1{font-size:24px;margin:0 0 6px}.sub{color:var(--muted);margin:0 0 26px}
|
|
370
|
+
.card{background:#fff;border:1px solid var(--line);border-radius:12px;padding:22px 24px;margin-bottom:18px}
|
|
371
|
+
label.f{display:block;font-size:13px;font-weight:600;margin:14px 0 6px}input[type=text]{width:100%;padding:10px 12px;border:1px solid var(--line);border-radius:8px;font:inherit}
|
|
372
|
+
.hint{color:var(--muted);font-size:13px;margin:6px 0 0}.row{display:flex;gap:16px;flex-wrap:wrap;align-items:center}
|
|
373
|
+
.btn{display:inline-block;padding:10px 18px;border-radius:8px;border:1px solid var(--line);background:#fff;font:inherit;cursor:pointer;min-height:40px}
|
|
374
|
+
.btn.primary{background:var(--copper);border-color:var(--copper);color:#fff}.btn:disabled{opacity:.5;cursor:default}
|
|
375
|
+
.qr{display:flex;justify-content:center;padding:10px 0}.qr svg{width:min(300px,100%);height:auto;aspect-ratio:1}
|
|
376
|
+
.log{font-size:13px;line-height:1.7;max-height:320px;overflow:auto;background:var(--paper);border-radius:8px;padding:12px 14px;white-space:pre-wrap;word-break:break-word}
|
|
377
|
+
.status{font-weight:600}.status.failed{color:var(--fail)}.status.done{color:var(--ok)}
|
|
378
|
+
.steps{margin:0;padding-left:20px;color:var(--muted);font-size:13px}.hidden{display:none}
|
|
379
|
+
</style></head><body><div class="wrap">
|
|
380
|
+
<div class="row" style="justify-content:space-between"><h1 data-i18n="title">Connect your project to Feishu</h1><label class="hint">Language <select id="language" aria-label="Language"><option value="en">English</option><option value="zh">简体中文</option></select></label></div><p class="sub">SintBot ${String(version).replace(/[&<>"\']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c])} · <span data-i18n="intro">This page is only available on this Mac. After you confirm the scan, setup creates the app, project folder and group, then saves your configuration.</span></p>
|
|
381
|
+
|
|
382
|
+
<div class="card" id="form">
|
|
383
|
+
<ol class="steps"><li data-i18n="step1">Name a new project, or choose an existing folder</li><li data-i18n="step2">Scan with Feishu on your phone and confirm (a company or team account is required)</li><li data-i18n="step3">Wait for setup to create the app and group, then send your first task in the new group</li></ol>
|
|
384
|
+
<label class="f" for="name" data-i18n="name">Project name</label><input type="text" id="name" value="sintbot" autocomplete="off">
|
|
385
|
+
<div class="hint"><span data-i18n="rootBefore">A new folder with a README will be created under</span> <span id="root"></span><span data-i18n="rootAfter">.</span></div>
|
|
386
|
+
<label class="f" for="workdir" data-i18n="workdir">Or use an existing project (absolute path; leave blank to create one)</label><input type="text" id="workdir" placeholder="/Users/you/Projects/my-app" autocomplete="off">
|
|
387
|
+
<label class="f" data-i18n="executor">Executor</label>
|
|
388
|
+
<div class="row" id="providers"></div>
|
|
389
|
+
<div class="hint"><span data-i18n="executorHint">Choose if both are installed. A single available executor is selected automatically.</span></div>
|
|
390
|
+
<div class="hint" data-i18n="modelHint">Before scanning, SintBot reads the executor’s live recommended model and thinking depth and saves them for the new group. You can change them in the console after setup.</div>
|
|
391
|
+
<label class="f"><input type="checkbox" id="basic"> <span data-i18n="basic">I am not an organization administrator (basic setup)</span></label>
|
|
392
|
+
<div class="hint"><span data-i18n="basicHint">Skip availability changes and publication. Ask an administrator to finish these steps in the Feishu developer console.</span></div>
|
|
393
|
+
<div class="row" style="margin-top:18px"><button class="btn primary" id="start" disabled data-i18n="start">Start scan</button><span class="hint" id="formErr"></span></div>
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
<div class="card hidden" id="run">
|
|
397
|
+
<div class="row" style="justify-content:space-between"><span class="status" id="status">Preparing…</span><button class="btn" id="cancel" data-i18n="cancel">Cancel</button></div>
|
|
398
|
+
<div class="hint hidden" id="modelDefaults" style="margin:10px 0;overflow-wrap:anywhere"></div>
|
|
399
|
+
<div class="qr" id="qr"></div>
|
|
400
|
+
<div class="log" id="log"></div>
|
|
401
|
+
<div id="chat" class="hidden">
|
|
402
|
+
<div class="hint" style="margin-top:12px"><span data-i18n="chatHint">The group could not be created automatically. Create a Feishu group, add the bot, then paste its group ID (oc_…) here:</span></div>
|
|
403
|
+
<div class="row" style="margin-top:8px"><input type="text" id="chatId" placeholder="oc_…" style="flex:1;min-width:0;width:100%"><button class="btn primary" id="resume" data-i18n="resume">Continue</button></div>
|
|
404
|
+
</div>
|
|
405
|
+
<div id="retry" class="hidden"><div class="row" style="margin-top:12px"><button class="btn" id="back" data-i18n="back">Edit details</button><button class="btn primary" id="again" data-i18n="again">Retry</button></div></div>
|
|
406
|
+
<div id="chatBack" class="hidden"><div class="row" style="margin-top:8px"><button class="btn" id="back2" data-i18n="back2">Back to project details</button></div></div>
|
|
407
|
+
<div id="doneBox" class="hidden"><div class="hint" style="margin-top:12px"><span data-i18n="doneHint">Your configuration is saved. SintBot is connecting to Feishu and this page will open the console. Try asking the bot in the new group to read the first line of README.</span></div></div>
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
<script>
|
|
411
|
+
(function(){
|
|
412
|
+
const $=s=>document.querySelector(s);
|
|
413
|
+
const translate=${translateSetupNotice.toString()};
|
|
414
|
+
const words={en:{modelHint:'Before scanning, SintBot reads the executor’s live recommended model and thinking depth and saves them for the new group. You can change them in the console after setup.',modelDefaults:'New group defaults',thinking:'thinking depth',title:'Connect your project to Feishu',intro:'This page is only available on this Mac. After you confirm the scan, setup creates the app, project folder and group, then saves your configuration.',step1:'Name a new project, or choose an existing folder',step2:'Scan with Feishu on your phone and confirm (a company or team account is required)',step3:'Wait for setup to create the app and group, then send your first task in the new group',name:'Project name',rootBefore:'A new folder with a README will be created under',rootAfter:'.',workdir:'Or use an existing project (absolute path; leave blank to create one)',executor:'Executor',executorHint:'Choose if both are installed. A single available executor is selected automatically.',basic:'I am not an organization administrator (basic setup)',basicHint:'Skip availability changes and publication. Ask an administrator to finish these steps in the Feishu developer console.',start:'Start scan',cancel:'Cancel',chatHint:'The group could not be created automatically. Create a Feishu group, add the bot, then paste its group ID (oc_…) here:',resume:'Continue',back:'Edit details',again:'Retry',back2:'Back to project details',doneHint:'Your configuration is saved. SintBot is connecting to Feishu and this page will open the console. Try asking the bot in the new group to read the first line of README.',noRunner:'No available executor was detected. Install Codex or Claude Code and sign in, then reopen this page.',waiting:'Waiting for scan confirmation…',preparing:'Preparing…',chat:'Create a group to continue',done:'Connected',failed:'Setup did not complete',init:'Retry initialization',launching:'Connected. Starting the console…',switching:'Opening the console…',cannotCancel:'This step cannot be cancelled yet. Please wait.',cancelling:'Cancelling at the next step boundary…',offline:'Cannot reach setup. Check that SintBot is still running, then reload this page.',handoffFailed:'The console did not become ready. Check the terminal for a startup error, then reload this page.',retryConnection:'Reload page',requestFailed:'The request failed. Please retry.'},
|
|
415
|
+
zh:{modelHint:'扫码前读取执行器实时推荐的型号和思考深度,并保存为新群的初始设置;接入后可在控制台修改。',modelDefaults:'新群默认',thinking:'思考深度',title:'把项目接进飞书',intro:'这一页只在这台 Mac 上可见。扫码确认后会自动创建机器人应用、项目目录和群,并写好配置。',step1:'填项目名,或选一个已有目录',step2:'用手机飞书扫码并确认(需要企业或团队身份)',step3:'等它自动建应用、建群、写配置,然后在新群里发第一条任务',name:'项目名',rootBefore:'会在',rootAfter:'下新建这个目录并放一份 README。',workdir:'或者接入已有项目(绝对路径,留空则按上面新建)',executor:'执行器',executorHint:'两个都装了才需要选;只装了一个会自动用它。',basic:'我不是组织管理员(精简接入)',basicHint:'跳过放开范围和发布版本,之后请管理员在开放平台补这两步。',start:'开始扫码',cancel:'取消',chatHint:'自动建群没有成功。请在飞书里手动建一个群,把机器人拉进去,复制群 ID(oc_…)粘贴到这里继续:',resume:'继续',back:'返回修改',again:'重试',back2:'返回项目设置',doneHint:'配置已写好,SintBot 正在连接飞书。这一页几秒后会切到控制台;去新群里发「读取 README 第一行」试试。',noRunner:'未检测到可用执行器,请先安装 Codex 或 Claude Code 并登录,再重新打开此页面。',waiting:'等待扫码确认…',preparing:'正在准备…',chat:'需要你手动建群',done:'接入完成',failed:'接入没有完成',init:'重试初始化',launching:'接入完成,正在启动控制台…',switching:'正在切换到控制台…',cannotCancel:'现在这一步不能取消,请稍等。',cancelling:'正在取消,等待当前步骤结束…',offline:'暂时无法连接接入页。请确认 SintBot 仍在运行,然后刷新页面。',handoffFailed:'控制台未能就绪,请查看终端中的启动错误后刷新页面。',retryConnection:'刷新页面',requestFailed:'请求失败,请重试。'}};
|
|
416
|
+
let lang='en';try{const saved=localStorage.getItem('sbot-lang');lang=saved==='zh'||saved==='en'?saved:(navigator.language||'').toLowerCase().startsWith('zh')?'zh':'en'}catch(e){lang=(navigator.language||'').toLowerCase().startsWith('zh')?'zh':'en'}
|
|
417
|
+
const t=k=>words[lang][k]||k;
|
|
418
|
+
const api=async(p,b)=>{const r=await fetch(p,b?{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(b),signal:AbortSignal.timeout(5000)}:{cache:'no-store',signal:AbortSignal.timeout(5000)});const j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||String(r.status));return j};
|
|
419
|
+
let last=null,timer=null,formError='',actionError='',actionKey='',polling=false,handoffStarted=false;
|
|
420
|
+
function displayError(error){return translate(error&&error.message||String(error),lang)||t('requestFailed')}
|
|
421
|
+
function renderForm(st){$('#root').textContent=st.defaults.projectsRoot;const box=$('#providers');const chosen=document.querySelector('input[name=prov]:checked')?.value;box.replaceChildren();const avail=Object.keys(st.runners||{}).filter(k=>st.runners[k]);$('#start').disabled=!avail.length;
|
|
422
|
+
if(!avail.length){const hint=document.createElement('span');hint.className='hint';hint.style.color='var(--fail)';hint.textContent=t('noRunner');box.appendChild(hint);return}
|
|
423
|
+
if(avail.length===1){box.textContent=avail[0]==='claude'?'Claude Code':'Codex';return}
|
|
424
|
+
for(const r of avail){const l=document.createElement('label'),radio=document.createElement('input');radio.type='radio';radio.name='prov';radio.value=r;radio.checked=r===(chosen||avail[0]);l.append(radio,document.createTextNode(' '+(r==='claude'?'Claude Code':'Codex')));box.appendChild(l)}}
|
|
425
|
+
function restore(st){if(!st.input)return;$('#name').value=st.input.name||'sintbot';$('#workdir').value=st.input.workdir||'';$('#basic').checked=!!st.input.basic;if(st.input.provider){const r=document.querySelector('input[name=prov][value="'+st.input.provider+'"]');if(r)r.checked=true}}
|
|
426
|
+
function render(st){last=st;if(st.phase!=='running')actionKey='';const running=st.phase!=='form';$('#form').classList.toggle('hidden',running);$('#run').classList.toggle('hidden',!running);$('#formErr').textContent=formError?translate(formError,lang):'';if(!running)return;
|
|
427
|
+
$('#status').textContent=t(st.phase==='running'?(st.qr?'waiting':'preparing'):st.phase==='waitingChat'?'chat':st.phase==='done'?'done':'failed');
|
|
428
|
+
$('#modelDefaults').classList.toggle('hidden',!st.modelDefaults);$('#modelDefaults').textContent=st.modelDefaults?t('modelDefaults')+': '+(st.modelDefaults.name||st.modelDefaults.model)+' · '+t('thinking')+': '+st.modelDefaults.effort:'';
|
|
429
|
+
$('#status').className='status '+st.phase;$('#qr').innerHTML=st.phase==='running'&&st.qr?st.qr:'';$('#log').textContent=st.lines.map(l=>translate(l,lang)).concat(actionKey?[t(actionKey)]:actionError?[translate(actionError,lang)]:[]).join('\n');$('#log').scrollTop=1e9;
|
|
430
|
+
$('#cancel').classList.toggle('hidden',st.phase!=='running');$('#chat').classList.toggle('hidden',st.phase!=='waitingChat');$('#chatBack').classList.toggle('hidden',st.phase!=='waitingChat');$('#retry').classList.toggle('hidden',st.phase!=='failed');$('#doneBox').classList.toggle('hidden',st.phase!=='done');$('#again').textContent=t(st.initOnly?'init':'again')}
|
|
431
|
+
function setLanguage(value){lang=value==='zh'?'zh':'en';$('#language').value=lang;document.documentElement.lang=lang==='zh'?'zh-CN':'en';document.title='SintBot · '+(lang==='zh'?'接入飞书':'Connect Feishu');document.querySelectorAll('[data-i18n]').forEach(el=>{el.textContent=t(el.dataset.i18n)});if(last){renderForm(last);render(last)}try{localStorage.setItem('sbot-lang',lang)}catch(e){}}
|
|
432
|
+
$('#language').onchange=()=>setLanguage($('#language').value);setLanguage(lang);
|
|
433
|
+
// A valid console response must be HTML without the setup marker. A failed
|
|
434
|
+
// request can mean normal handoff or a stopped process; the wait is bounded.
|
|
435
|
+
async function handoff(){if(handoffStarted)return;handoffStarted=true;clearInterval(timer);$('#status').textContent=t(last?.phase==='done'?'launching':'switching');for(let i=0;i<60;i++){try{const r=await fetch('/',{cache:'no-store',signal:AbortSignal.timeout(3000)});if(r.ok&&/text\/html/.test(r.headers.get('content-type')||'')){const html=await r.text();if(!html.includes('data-sintbot-setup=')&&html.includes('<html')){location.replace('/');return}}}catch(e){}await new Promise(r=>setTimeout(r,2000))}actionError=t('handoffFailed');$('#status').textContent=t('failed');$('#log').textContent+='\n'+actionError;const reload=document.createElement('button');reload.className='btn';reload.textContent=t('retryConnection');reload.onclick=()=>location.reload();$('#run').appendChild(reload)}
|
|
436
|
+
let gone=0;async function poll(){if(polling||handoffStarted)return;polling=true;try{const st=await api('/api/setup/state');if(!st.phase){await handoff();return}gone=0;if(!last){renderForm(st);restore(st)}formError='';render(st);if(st.phase==='done')void handoff()}catch(e){if(last&&last.phase!=='form'&&++gone>=2)void handoff();else if(!last||last.phase==='form'){formError=t('offline');$('#formErr').textContent=formError}}finally{polling=false}}
|
|
437
|
+
async function start(extra){formError='';actionError='';actionKey='';$('#formErr').textContent='';const prov=document.querySelector('input[name=prov]:checked');const input={name:$('#name').value.trim(),workdir:$('#workdir').value.trim(),provider:prov?prov.value:'',basic:$('#basic').checked,...extra};
|
|
438
|
+
try{const r=await api('/api/setup/start',input);render(r.state)}catch(e){formError=e.message;actionError=e.message;if(last)render(last);else $('#formErr').textContent=displayError(e)}}
|
|
439
|
+
$('#start').onclick=()=>start({});$('#again').onclick=()=>start({});$('#resume').onclick=()=>start({chatId:$('#chatId').value.trim()});
|
|
440
|
+
$('#cancel').onclick=async()=>{try{const r=await api('/api/setup/cancel',{});actionKey=r.ok?'cancelling':'cannotCancel';render(last)}catch(e){actionError=e.message;render(last)}};
|
|
441
|
+
const back=async()=>{try{await api('/api/setup/reset',{});formError='';actionError='';actionKey='';render(await api('/api/setup/state'))}catch(e){actionError=e.message;render(last)}};$('#back').onclick=back;$('#back2').onclick=back;
|
|
442
|
+
api('/api/setup/state').then(st=>{renderForm(st);restore(st);render(st)}).catch(e=>{formError=t('offline');$('#formErr').textContent=formError});timer=setInterval(poll,1500);
|
|
443
|
+
})();
|
|
444
|
+
</script></body></html>`
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
module.exports = { createSetupServer, createSetupSession, probeRunners, setupPage, svgOf, translateSetupNotice }
|