@hedeqiang/dsh-nl2sql 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,223 +0,0 @@
1
- /**
2
- * Nl2SqlService methods: interactive buttons on Lark bot answer cards.
3
- * Mixed into the service prototype by index.js; `this` is the service.
4
- *
5
- * Buttons carry only an opaque token ({ a: <action>, t: <token> }); the token
6
- * maps server-side to { resultId, sessionId, chatId, replyTo, depth, ownerRole }.
7
- * Every click re-resolves the clicking operator and re-checks data source
8
- * scope, row-level rules and role before anything runs.
9
- */
10
- import { randomBytes } from 'node:crypto'
11
- import { parseSelect } from '../analysis.js'
12
- import { buildCard } from '../im.js'
13
- import { getUserInfo, replyMessage, replyText } from '../lark-bot.js'
14
- import { toUserError } from './shared.js'
15
-
16
- export const ACTION_TOKEN_TTL_MS = 7 * 24 * 3600 * 1000
17
- const ACTION_TOKEN_MAX = 5000
18
- export const ACTION_RATE_MS = 2000
19
- /** Cards produced by button actions get buttons up to this depth (0 = answer card). */
20
- export const ACTION_MAX_DEPTH = 2
21
- const ACTIONS = new Set(['drill', 'why', 'detail', 'pin'])
22
- const ACTION_LABEL = { drill: '拆分', why: '为什么变化', detail: '明细', pin: '固定到看板' }
23
-
24
- const toast = (type, content) => ({ toast: { type, content } })
25
-
26
- function hasAggregate(r, dialect) {
27
- const parsed = parseSelect(r.baseSql ?? r.sql, { dialect })
28
- return Boolean(parsed?.select?.some(i => i.isAggregate))
29
- }
30
-
31
- export const methods = {
32
- // ------------------------------------------------------------- tokens
33
-
34
- /** Store context for a card's buttons; returns the opaque token. */
35
- createLarkActionToken(ctx) {
36
- const token = randomBytes(9).toString('base64url')
37
- const now = Date.now()
38
- const body = { resultId: ctx.resultId, sessionId: ctx.sessionId ?? null, chatId: ctx.chatId ?? null, chatType: ctx.chatType === 'p2p' ? 'p2p' : 'group', replyTo: ctx.replyTo ?? null, depth: ctx.depth ?? 0, ownerRole: ctx.ownerRole ?? null }
39
- this.larkActionTokens ??= new Map()
40
- this.larkActionTokens.set(token, { ...body, created: now })
41
- while (this.larkActionTokens.size > ACTION_TOKEN_MAX) this.larkActionTokens.delete(this.larkActionTokens.keys().next().value)
42
- try {
43
- this.store?.putActionToken(token, body, now)
44
- if ((this.larkTokenWrites = (this.larkTokenWrites ?? 0) + 1) % 200 === 0) this.store?.pruneActionTokens(ACTION_TOKEN_TTL_MS, ACTION_TOKEN_MAX)
45
- } catch {}
46
- return token
47
- },
48
-
49
- /** Context for a token, or undefined when unknown / expired. */
50
- lookupLarkActionToken(token, now = Date.now()) {
51
- if (typeof token !== 'string' || !/^[\w-]{8,32}$/.test(token)) return undefined
52
- let ctx = this.larkActionTokens?.get(token)
53
- if (!ctx) { try { ctx = this.store?.getActionToken(token) } catch {} }
54
- if (!ctx || now - ctx.created > ACTION_TOKEN_TTL_MS) return undefined
55
- return ctx
56
- },
57
-
58
- // ------------------------------------------------------------- building
59
-
60
- /**
61
- * Button spec (see im.js actionElement) for one result shown to `actor` in a
62
- * chat, or undefined when nothing applies. Never throws.
63
- */
64
- async larkCardActions(result, { actor, sessionId, chatId, chatType, replyTo, depth = 0 } = {}) {
65
- try {
66
- if (!result?.resultId || result.error || depth >= ACTION_MAX_DEPTH) return undefined
67
- const ds = this.findDatasource(result.datasourceId)
68
- if (!ds) return undefined
69
- const dialect = ds.type ?? 'mysql'
70
- const role = actor?.role ?? 'analyst'
71
- const token = this.createLarkActionToken({ resultId: result.resultId, sessionId, chatId, chatType, replyTo, depth, ownerRole: role })
72
- const aggregate = hasAggregate(result, dialect)
73
- const buttons = []
74
- let select
75
- if (aggregate) {
76
- let dims = []
77
- try { dims = (await this.drillDimensions(result.resultId)).dimensions } catch {}
78
- const xName = result.chart?.x
79
- dims = dims.filter(d => d.column !== xName && d.column.split('.').pop() !== xName).slice(0, 6)
80
- if (dims.length) select = { placeholder: '按维度拆分', value: { a: 'drill', t: token }, options: dims.map(d => ({ text: d.label && d.label !== d.column ? `${d.label}(${d.column})` : d.column, value: d.column })) }
81
- }
82
- const xi = result.chart?.x ? result.columns.findIndex(c => c.name === result.chart.x) : -1
83
- const points = xi >= 0 ? result.rows.filter(r => r[xi] !== null && r[xi] !== undefined).length : 0
84
- const timeLike = xi >= 0 && (result.columns[xi].kind === 'time' || ['line', 'area'].includes(result.chart?.type))
85
- if (aggregate && timeLike && points >= 2) buttons.push({ text: '为什么变化', value: { a: 'why', t: token } })
86
- if (xi >= 0 && points >= 1 && role !== 'viewer') buttons.push({ text: '明细', value: { a: 'detail', t: token } })
87
- if (role === 'admin' || role === 'analyst') buttons.push({ text: '固定到看板', value: { a: 'pin', t: token } })
88
- if (!select && !buttons.length) return undefined
89
- return { ...(select ? { select } : {}), buttons }
90
- } catch (error) {
91
- console.warn('nl2sql lark: cannot build card actions: %s', error.message)
92
- return undefined
93
- }
94
- },
95
-
96
- /** Results with their button specs attached (for buildCard). */
97
- async withLarkCardActions(results, ctx) {
98
- const out = []
99
- for (const r of results) {
100
- const cardActions = await this.larkCardActions(r, ctx)
101
- out.push(cardActions ? { ...r, cardActions } : r)
102
- }
103
- return out
104
- },
105
-
106
- /** Reply with a card, falling back to no charts, then no buttons, then text. */
107
- async replyLarkCard(creds, messageId, message) {
108
- const attempts = [{}, { charts: false }, { charts: false, actions: false }]
109
- let lastError
110
- for (const opts of attempts) {
111
- try { return await replyMessage({ ...creds, messageId, card: buildCard(message, opts) }) } catch (error) { lastError = error }
112
- }
113
- try { return await replyText({ ...creds, messageId, text: String(message.text ?? '').slice(0, 3000) || `回复失败:${lastError?.message}` }) } catch {}
114
- return undefined
115
- },
116
-
117
- // ------------------------------------------------------------- clicks
118
-
119
- /**
120
- * card.action.trigger (normalized by lark-bot.js). Authorizes synchronously
121
- * and returns a toast within Feishu's 3 s window; the analysis runs in the
122
- * background and replies in the chat with a new card.
123
- */
124
- async handleLarkCardAction(evt) {
125
- const creds = this.larkCreds
126
- if (!creds || !evt?.operator?.openId) return undefined
127
- const a = evt.value?.a
128
- const ctx = this.lookupLarkActionToken(evt.value?.t)
129
- const deny = (msg, detail) => { this.auditAdmin('larkbot.action.denied', `${evt.operator.openId} · ${a ?? '?'} · ${detail ?? msg}`); return toast('error', msg) }
130
- // Per-operator rate limit.
131
- this.larkActionRate ??= new Map()
132
- const now = Date.now()
133
- const last = this.larkActionRate.get(evt.operator.openId) ?? 0
134
- if (now - last < ACTION_RATE_MS) return toast('warning', '操作太频繁,请稍后再试。')
135
- this.larkActionRate.set(evt.operator.openId, now)
136
- if (this.larkActionRate.size > 2000) for (const [k, t] of this.larkActionRate) if (now - t > ACTION_RATE_MS) this.larkActionRate.delete(k)
137
-
138
- if (!ACTIONS.has(a) || !ctx) return deny('该按钮已过期或无效,请重新提问。', 'unknown/expired token')
139
- if (ctx.chatId && evt.chatId && ctx.chatId !== evt.chatId) return deny('该按钮不属于当前聊天。', 'chat mismatch')
140
- // Who clicked: resolved from our member list, never from the callback value.
141
- let user = {}
142
- let timer
143
- try { user = await Promise.race([getUserInfo({ ...creds, openId: evt.operator.openId }), new Promise(r => { timer = setTimeout(() => r({}), 1200) })]) } catch {} finally { clearTimeout(timer) }
144
- const chatId = ctx.chatId ?? evt.chatId
145
- const resolved = this.resolveLarkActor({ openId: evt.operator.openId, email: user.email, name: user.name ?? evt.operator.name, chatId, chatType: ctx.chatType ?? 'group' })
146
- if (resolved.refused) return deny(resolved.refused, 'actor refused')
147
- const actor = { ...resolved.actor, kind: 'lark' }
148
- const r = this.lookupResult(ctx.resultId)
149
- if (!r) return deny('结果已过期,请重新提问。', 'result expired')
150
- try { this.assertResultAccess(r, actor) } catch (error) { return deny(toUserError(error).message, `${actor.name}: ${error.message}`) }
151
- if (actor.role === 'viewer') {
152
- // Viewers may only act on governed results (produced under viewer rules, or a verified example / dashboard SQL).
153
- const ds = this.findDatasource(r.datasourceId)
154
- const governed = ctx.ownerRole === 'viewer' || (ds && this.isVerifiedExample(ds, r.baseSql ?? r.sql))
155
- if (!governed || a === 'detail' || a === 'pin') return deny(a === 'pin' ? '只读成员不能固定到看板。' : a === 'detail' ? '只读成员不能查看明细数据。' : '只读成员只能分析指标库或已验证问答的结果。', `${actor.name}: viewer`)
156
- }
157
- if (a === 'pin' && actor.role !== 'admin' && actor.role !== 'analyst') return deny('只有管理员或分析师可以固定到看板。', `${actor.name}: role ${actor.role}`)
158
- let dimension
159
- if (a === 'drill') {
160
- dimension = typeof evt.option === 'string' ? evt.option : undefined
161
- let allowed = []
162
- try { allowed = (await this.drillDimensions(r.resultId)).dimensions.map(d => d.column) } catch {}
163
- if (!dimension || !allowed.includes(dimension)) return deny('请选择一个有效的拆分维度。', `invalid dimension ${String(dimension).slice(0, 40)}`)
164
- }
165
-
166
- if (a === 'pin') {
167
- try {
168
- const widget = await this.pinWidget({ title: r.title || '飞书结果', sql: r.baseSql ?? r.sql, datasourceId: r.datasourceId, chart: r.chart })
169
- const board = this.currentDashboardObj()
170
- this.auditAdmin('larkbot.pin', `${actor.name} · ${widget.title} → ${board.name}`, actor)
171
- this.recordDelivery({ channel: { id: 'larkbot', name: `飞书机器人 · ${actor.name}` }, title: `固定:${widget.title}`.slice(0, 60), status: 'ok', results: 0, source: 'larkbot', sessionId: ctx.sessionId, elapsedMs: Date.now() - now })
172
- await this.save()
173
- return toast('success', `已固定到看板「${board.name}」`)
174
- } catch (error) {
175
- return toast('error', `固定失败:${toUserError(error).message}`.slice(0, 200))
176
- }
177
- }
178
-
179
- const job = this.runLarkCardAction({ a, ctx, r, actor, dimension, evt, creds })
180
- this.larkActionJobs ??= new Set()
181
- this.larkActionJobs.add(job)
182
- job.finally(() => this.larkActionJobs.delete(job)).catch(() => {})
183
- return toast('info', '正在分析…')
184
- },
185
-
186
- async runLarkCardAction({ a, ctx, r, actor, dimension, evt, creds }) {
187
- const started = Date.now()
188
- const replyTo = ctx.replyTo ?? evt.messageId
189
- const label = a === 'drill' ? `拆分:${dimension}` : a === 'why' ? '为什么变化' : `明细:${r.chart?.x ?? ''}`
190
- const footer = `数据问答 · ${r.datasourceName ?? ''} · ${actor.name}`
191
- let status = 'ok'
192
- let error
193
- let resultsCount = 0
194
- try {
195
- let message
196
- if (a === 'drill' || a === 'detail') {
197
- let xValue
198
- if (a === 'detail') {
199
- const xi = r.columns.findIndex(c => c.name === r.chart?.x)
200
- xValue = r.rows.map(row => row[xi]).filter(v => v !== null && v !== undefined).at(-1)
201
- }
202
- const out = await this.drill({ resultId: r.resultId, mode: a === 'drill' ? 'breakdown' : 'detail', dimension, xValue, sessionId: ctx.sessionId ?? undefined, actor, source: 'larkbot' })
203
- const results = await this.withLarkCardActions([out], { actor, sessionId: ctx.sessionId, chatId: ctx.chatId ?? evt.chatId, chatType: ctx.chatType, replyTo, depth: (ctx.depth ?? 0) + 1 })
204
- resultsCount = 1
205
- message = { title: out.title || label, text: a === 'detail' ? `「${r.title || '结果'}」在 ${r.chart?.x}=${xValue} 的明细(最多 200 行)。` : `「${r.title || '结果'}」按 ${dimension} 拆分。`, results, footer }
206
- } else {
207
- const out = await this.explainChange({ resultId: r.resultId, sessionId: ctx.sessionId ?? undefined, actor })
208
- const top = out.dimensions.find(d => d.attribution)
209
- const rows = (top?.attribution.items ?? []).slice(0, 8)
210
- const table = rows.length ? `\n\n| ${top.dimension} | ${out.baseline} | ${out.current} | 变化 |\n|---|---|---|---|\n${rows.map(i => `| ${i.value ?? 'NULL'} | ${i.baseline} | ${i.current} | ${i.delta >= 0 ? '+' : ''}${i.delta} |`).join('\n')}` : ''
211
- message = { title: `为什么变化 · ${r.title || ''}`.slice(0, 80), text: `${out.summary}${table}`, results: [], footer }
212
- }
213
- await this.replyLarkCard(creds, replyTo, message)
214
- } catch (e) {
215
- status = 'error'
216
- error = toUserError(e).message
217
- try { await replyText({ ...creds, messageId: replyTo, text: `${label} 失败:${error}`.slice(0, 1000) }) } catch {}
218
- }
219
- this.auditAdmin('larkbot.action', `${actor.name} · ${label} · ${r.resultId}${error ? ` · ${error}` : ''}`, actor)
220
- this.recordDelivery({ channel: { id: 'larkbot', name: `飞书机器人 · ${actor.name}` }, title: label.slice(0, 60), status, ...(error ? { error } : {}), results: resultsCount, source: 'larkbot', sessionId: ctx.sessionId, elapsedMs: Date.now() - started })
221
- await this.save()
222
- },
223
- }
package/service/lark.js DELETED
@@ -1,250 +0,0 @@
1
- /**
2
- * Nl2SqlService methods: lark.
3
- * Mixed into the service prototype by index.js; `this` is the service.
4
- */
5
- import { addReaction, createLarkBot, getChatInfo, getUserInfo, replyText } from '../lark-bot.js'
6
- import { PRESET_ID, UserError, hashText, oneOf, toUserError } from './shared.js'
7
-
8
- export const methods = {
9
- // ------------------------------------------------------------- Lark bot
10
-
11
- /**
12
- * Inbound Lark bot: people @ the app bot in a group (or DM it); each chat
13
- * gets its own nl2sql session bound to a data source the sender may use.
14
- * Config: { enabled, channelId (lark_app channel providing credentials),
15
- * replyMode: 'card', sessionPerChat: true, allowP2p: true }.
16
- */
17
- larkBotConfig() {
18
- return { enabled: false, channelId: null, allowP2p: true, sessionScope: 'chat', ...(this.state.larkBot ?? {}) }
19
- },
20
-
21
- larkBotStatus() {
22
- const conf = this.larkBotConfig()
23
- const status = this.larkBot?.status() ?? { state: conf.enabled ? 'stopped' : 'idle', since: Date.now() }
24
- return { ...conf, status, bot: this.larkBot?.botInfo?.() ?? null, conversations: Object.values(this.state.larkSessions ?? {}).sort((a, b) => b.lastAt - a.lastAt).slice(0, 50) }
25
- },
26
-
27
- async saveLarkBot(input) {
28
- const conf = this.larkBotConfig()
29
- if (input.channelId !== undefined) {
30
- const ch = this.state.channels.find(c => c.id === input.channelId)
31
- if (input.channelId && (!ch || ch.type !== 'lark_app')) throw new UserError('nl2sql/invalid', '飞书机器人需要选择一个「应用机器人」类型的推送渠道(提供 App ID / Secret)')
32
- conf.channelId = input.channelId || null
33
- }
34
- if (input.enabled !== undefined) conf.enabled = Boolean(input.enabled)
35
- if (input.allowP2p !== undefined) conf.allowP2p = Boolean(input.allowP2p)
36
- if (input.sessionScope !== undefined) conf.sessionScope = oneOf(input.sessionScope, ['chat', 'user'], 'sessionScope')
37
- if (conf.enabled && !conf.channelId) throw new UserError('nl2sql/invalid', '请先选择应用机器人渠道')
38
- this.state.larkBot = conf
39
- this.auditAdmin('larkbot.save', `${conf.enabled ? '启用' : '停用'} · ${conf.channelId ?? ''}`)
40
- await this.save()
41
- await this.restartLarkBot()
42
- return this.larkBotStatus()
43
- },
44
-
45
- async restartLarkBot() {
46
- if (this.larkBot) { try { await this.larkBot.stop() } catch {} this.larkBot = undefined }
47
- const conf = this.larkBotConfig()
48
- if (!conf.enabled || this.disposed) return
49
- const ch = this.state.channels.find(c => c.id === conf.channelId)
50
- if (!ch) return
51
- const secrets = await this.channelSecrets(ch)
52
- this.larkCreds = { appId: ch.appId, appSecret: secrets.appSecret, domain: ch.domain === 'lark' ? 'lark' : 'feishu' }
53
- this.larkBot = createLarkBot({
54
- ...this.larkCreds,
55
- logger: { debug() {}, info() {}, warn: (...a) => console.warn('nl2sql lark:', ...a), error: (...a) => console.warn('nl2sql lark:', ...a) },
56
- onStatus: () => {},
57
- onMessage: (m) => { this.handleLarkMessage(m).catch(error => console.warn('nl2sql lark: handler failed: %s', error.message)) },
58
- onCardAction: (evt) => this.handleLarkCardAction(evt),
59
- })
60
- await this.larkBot.start()
61
- },
62
-
63
- /** One inbound message → permission check → session prompt → reply card. */
64
- async handleLarkMessage(m) {
65
- const conf = this.larkBotConfig()
66
- const creds = this.larkCreds
67
- if (!creds) return
68
- if (m.chatType === 'group' && !m.mentionedBot) return
69
- if (m.chatType === 'p2p' && !conf.allowP2p) return
70
- const text = (m.text ?? '').trim()
71
- if (!text) return
72
- const reply = (t) => replyText({ ...creds, messageId: m.messageId, text: t }).catch(() => {})
73
- try {
74
- await this.handleLarkMessageInner(m, text, conf, creds, reply)
75
- } catch (error) {
76
- console.warn('nl2sql lark: %s', error.message)
77
- await reply(`处理失败:${toUserError(error).message}`)
78
- }
79
- },
80
-
81
- async handleLarkMessageInner(m, text, conf, creds, reply) {
82
- let user = {}
83
- try { user = await getUserInfo({ ...creds, openId: m.sender.openId }) } catch {}
84
- const resolved = this.resolveLarkActor({ openId: m.sender.openId, email: user.email, name: user.name, chatId: m.chatId, chatType: m.chatType })
85
- if (resolved.refused) { await reply(resolved.refused); return }
86
- const actor = resolved.actor
87
- // Commands: 帮助 / 数据源 / 切换 <名称> / 新对话
88
- const cmd = text.match(/^\/?(帮助|help|数据源|切换数据源|切换|新对话|reset)\s*(.*)$/i)
89
- // Members with different scopes must not share a conversation: per-user sessions
90
- // whenever the sender's effective scope or rules differ from the chat's.
91
- const scopeSig = JSON.stringify([actor.datasourceIds, actor.role, actor.rowRules ?? []])
92
- const shared = conf.sessionScope !== 'user'
93
- let key = shared ? `${m.chatId}#${hashText(scopeSig)}` : `${m.chatId}:${m.sender.openId}`
94
- this.state.larkSessions ??= {}
95
- const conv = this.state.larkSessions[key] ?? { key, chatId: m.chatId, chatType: m.chatType, sessionId: null, datasourceId: actor.defaultDatasourceId, createdAt: Date.now() }
96
- const allowedDs = this.state.datasources.filter(d => !Array.isArray(actor.datasourceIds) || actor.datasourceIds.includes(d.id))
97
- if (conv.datasourceId && !allowedDs.some(d => d.id === conv.datasourceId)) conv.datasourceId = allowedDs[0]?.id ?? null
98
- if (cmd) {
99
- const verb = cmd[1].toLowerCase()
100
- if (verb === '帮助' || verb === 'help') { await reply(`直接 @我 提问即可,例如「上周每天的订单量」。\n命令:数据源(查看可用数据源)、切换 <名称>、新对话。\n当前数据源:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}${actor.role === 'viewer' ? '\n你是只读成员:可以查询指标库中的指标、已验证的常见问题和看板。' : ''}`); return }
101
- if (verb === '数据源') { await reply(`可用数据源:${allowedDs.map(d => d.name).join('、') || '无'}\n当前:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}`); return }
102
- if (verb === '新对话' || verb === 'reset') { conv.sessionId = null; this.state.larkSessions[key] = conv; await this.save(); await reply('已开始新对话。'); return }
103
- const target = allowedDs.find(d => d.name === cmd[2].trim() || d.id === cmd[2].trim())
104
- if (!target) { await reply(`找不到可用的数据源「${cmd[2]}」。可用:${allowedDs.map(d => d.name).join('、')}`); return }
105
- conv.datasourceId = target.id
106
- if (conv.sessionId) { this.state.sessions[conv.sessionId] = target.id }
107
- this.state.larkSessions[key] = conv
108
- await this.save()
109
- await reply(`已切换到数据源「${target.name}」。`)
110
- return
111
- }
112
- const ds = this.findDatasource(conv.datasourceId) ?? allowedDs[0]
113
- if (!ds) { await reply('没有可访问的数据源,请联系管理员。'); return }
114
- this.assertAccess(actor, ds)
115
- if (!this.ctx.get('sessionController')) { await reply('当前 Harness 不支持创建会话(缺少 sessionController)。'); return }
116
- void addReaction({ ...creds, messageId: m.messageId, emojiType: 'OnIt' })
117
- if (!conv.chatName && m.chatType === 'group') { try { conv.chatName = (await getChatInfo({ ...creds, chatId: m.chatId })).name } catch {} }
118
- // One prompt at a time per conversation, so each question gets its own answer.
119
- await this.serialize(`lark:${key}`, async () => {
120
- const started = Date.now()
121
- // Another message of this conversation may have created the session meanwhile.
122
- const latest = this.state.larkSessions[key]
123
- if (latest?.sessionId && !conv.sessionId) conv.sessionId = latest.sessionId
124
- const prepare = async (sessionId) => {
125
- conv.sessionId = sessionId
126
- conv.datasourceId = ds.id
127
- conv.lastAt = started
128
- conv.lastQuestion = text.slice(0, 200)
129
- conv.lastActor = actor.name
130
- this.state.larkSessions[key] = conv
131
- this.state.sessions[sessionId] = ds.id
132
- this.state.sessionActors ??= {}
133
- this.state.sessionActors[sessionId] = { id: actor.id, name: actor.name, kind: 'lark', datasourceIds: actor.datasourceIds, role: actor.role, rowRules: actor.rowRules ?? [] }
134
- await this.save()
135
- }
136
- const prompt = `[飞书${m.chatType === 'group' ? `群「${conv.chatName ?? m.chatId}」` : '私聊'} · ${actor.name}${actor.role === 'viewer' ? ' · 只读成员' : ''}] ${text}\n\n(回答会以飞书卡片发回该聊天:请用简洁的中文 Markdown 写结论,必要时附上查询结果;不要调用 nl2sql_send,也不要向用户提问——有歧义时说明你的假设后直接回答。${actor.role === 'viewer' ? '该成员是只读角色:只能用 nl2sql_metric 查询指标库,或复用 nl2sql_knowledge 中已验证的样例 SQL;不能自由编写 SQL。' : ''})`
137
- const turn = await this.runAgentTurn({ sessionId: conv.sessionId, title: `飞书 · ${conv.chatName ?? actor.name}`, requestId: `lark-${m.messageId}`, text: prompt, prepare, timeoutMs: 600000 })
138
- let body = turn.text
139
- if (turn.reason === 'timeout') body = `${body ? `${body}\n\n` : ''}<font color='grey'>(查询超过 10 分钟仍未完成,已停止。请缩小范围后重试。)</font>`
140
- if (turn.reason === 'input-required') body = `${body ? `${body}\n\n` : ''}<font color='grey'>(需要在 Harness 中确认后才能继续,已取消本次回答。)</font>`
141
- const results = turn.resultIds.map(id => this.lookupResult(id)).filter(Boolean).filter(r => { try { this.assertResultAccess(r, { ...actor, kind: 'lark' }); return true } catch { return false } }).slice(-4)
142
- // Buttons (拆分 / 为什么变化 / 明细 / 固定到看板) carry opaque tokens only; see service/lark-actions.js.
143
- const withActions = await this.withLarkCardActions(results, { actor, sessionId: conv.sessionId, chatId: m.chatId, chatType: m.chatType, replyTo: m.messageId, depth: 0 })
144
- await this.replyLarkCard(creds, m.messageId, { title: text.length > 40 ? `${text.slice(0, 40)}…` : text, text: body || '(没有生成文字回答)', results: withActions, footer: `数据问答 · ${ds.name} · ${actor.name}` })
145
- void addReaction({ ...creds, messageId: m.messageId, emojiType: turn.reason === 'idle' ? 'DONE' : 'CrossMark' })
146
- this.recordDelivery({ channel: { id: 'larkbot', name: `飞书机器人 · ${conv.chatName ?? actor.name}` }, title: text.slice(0, 60), status: turn.reason === 'idle' ? 'ok' : 'error', ...(turn.reason === 'idle' ? {} : { error: turn.reason }), results: results.length, source: 'larkbot', sessionId: conv.sessionId, elapsedMs: Date.now() - started })
147
- await this.save()
148
- })
149
- },
150
-
151
- /** Run tasks with the same key one after another. */
152
- serialize(key, task) {
153
- this.queues ??= new Map()
154
- const prev = this.queues.get(key) ?? Promise.resolve()
155
- const next = prev.catch(() => {}).then(task)
156
- this.queues.set(key, next)
157
- next.finally(() => { if (this.queues.get(key) === next) this.queues.delete(key) }).catch(() => {})
158
- return next
159
- },
160
-
161
- /**
162
- * Prompt an nl2sql session and collect exactly the answer of that prompt's
163
- * turn. Creates (or recreates, if archived / missing) the session, handles
164
- * timeouts and pending questions by cancelling the turn.
165
- * @returns {{ sessionId, text, resultIds, reason: 'idle'|'timeout'|'input-required' }}
166
- */
167
- async runAgentTurn({ sessionId, title, requestId, text, prepare, timeoutMs = 300000 }) {
168
- const controller = this.ctx.get('sessionController')
169
- const registry = this.ctx.get('workspaceRegistry')
170
- const create = async () => {
171
- const created = await controller.create({ agentPreset: PRESET_ID, ...(this.state.settings.botCwd ? { cwd: this.state.settings.botCwd } : {}) })
172
- try { await controller.rename?.({ sessionId: created.sessionId, title }) } catch {}
173
- return created.sessionId
174
- }
175
- let sid = sessionId
176
- if (sid && (registry?.archivedSessionIds?.includes?.(sid))) sid = null
177
- if (!sid) sid = await create()
178
- await prepare?.(sid)
179
- const send = (id) => controller.prompt({ requestId, sessionId: id, mode: 'queue', content: [{ type: 'text', text }] }, new AbortController().signal)
180
- try {
181
- await send(sid)
182
- } catch (error) {
183
- const code = error?.code ?? ''
184
- if (!/not-found|archived|writer-held/.test(code) && !/not found|archived/i.test(error?.message ?? '')) throw error
185
- sid = await create()
186
- await prepare?.(sid)
187
- await send(sid)
188
- }
189
- const waited = await controller.wait({ sessionId: sid, timeoutMs, maxMessages: 1 }, new AbortController().signal)
190
- const reason = waited?.reason ?? 'idle'
191
- if (reason !== 'idle') { try { controller.cancel({ sessionId: sid }) } catch {} }
192
- const answer = await this.collectTurnByRequest(sid, requestId)
193
- return { sessionId: sid, reason, ...answer }
194
- },
195
-
196
- /** Session events (live or persisted). */
197
- async sessionEvents(sessionId) {
198
- const live = this.ctx.get('agents')?.get(sessionId)?.session
199
- const read = (session) => session.snapshotEvents()
200
- if (live) return read(live)
201
- const q = this.ctx.get('sessionQuery')
202
- return q ? q.inspectSession(sessionId, read) : []
203
- },
204
-
205
- /**
206
- * Answer of the turn started by the user message carrying `requestId`:
207
- * the last assistant text of that turn and the nl2sql resultIds it produced.
208
- */
209
- async collectTurnByRequest(sessionId, requestId) {
210
- const events = await this.sessionEvents(sessionId)
211
- const at = events.findIndex(e => e.type === 'user/message' && (e.data?.source?.rpcId === requestId || e.data?.source?.requestId === requestId))
212
- if (at < 0) return { text: '', resultIds: [] }
213
- let turn
214
- for (let i = at + 1; i < events.length; i++) {
215
- const e = events[i]
216
- if (e.type === 'turn/start') { turn = e.data.turn; break }
217
- if (e.data?.turn !== undefined) { turn = e.data.turn; break }
218
- }
219
- if (turn === undefined) return { text: '', resultIds: [] }
220
- let text = ''
221
- const resultIds = []
222
- for (const e of events.slice(at + 1)) {
223
- if (e.data?.turn !== undefined && e.data.turn !== turn) { if (e.data.turn > turn) break; continue }
224
- if (e.type === 'assistant/message') {
225
- const t = (e.data.message?.content ?? []).filter(b => b.type === 'text').map(b => b.text).join('\n\n').trim()
226
- if (t) text = t
227
- }
228
- if (e.type === 'tool/result') {
229
- const meta = e.data.meta
230
- if (meta?.resultId) resultIds.push(meta.resultId)
231
- for (const r of meta?.results ?? []) if (r.resultId) resultIds.push(r.resultId)
232
- }
233
- if (e.type === 'turn/end' && e.data.turn === turn) break
234
- }
235
- return { text, resultIds: [...new Set(resultIds)] }
236
- },
237
-
238
- /** Back-compat for callers that only have the live session and a seq anchor. */
239
- collectTurnAnswer(sessionId, afterSeq) {
240
- const session = this.ctx.get('agents')?.get(sessionId)?.session
241
- if (!session) return { text: '', resultIds: [] }
242
- let text = ''
243
- const resultIds = []
244
- for (const e of session.snapshotEvents().filter(x => x.seq > afterSeq)) {
245
- if (e.type === 'assistant/message') { const t = (e.data.message?.content ?? []).filter(b => b.type === 'text').map(b => b.text).join('\n\n').trim(); if (t) text = t }
246
- if (e.type === 'tool/result') { const meta = e.data.meta; if (meta?.resultId) resultIds.push(meta.resultId); for (const r of meta?.results ?? []) if (r.resultId) resultIds.push(r.resultId) }
247
- }
248
- return { text, resultIds: [...new Set(resultIds)] }
249
- },
250
- }