@hedeqiang/dsh-nl2sql 0.12.1 → 0.13.0
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.md +39 -1
- package/README.md +22 -0
- package/README.zh-CN.md +22 -0
- package/analysis.js +4 -1
- package/bin/nl2sql-mcp.mjs +103 -0
- package/channels/dingtalk.js +11 -9
- package/channels/lark.js +17 -9
- package/channels/message.js +48 -12
- package/channels/telegram.js +13 -6
- package/channels/wecom.js +10 -9
- package/cordis.patch.yml +4 -0
- package/docs/mcp.md +119 -0
- package/filters.js +87 -33
- package/index.js +21 -3
- package/lib/client.js +2122 -153
- package/package.json +6 -1
- package/service/alerts.js +60 -26
- package/service/bots.js +38 -12
- package/service/channels.js +26 -5
- package/service/dashboards.js +5 -2
- package/service/datasources.js +16 -3
- package/service/features.js +21 -0
- package/service/forecast.js +252 -0
- package/service/freshness.js +747 -0
- package/service/mcp.js +677 -0
- package/service/pptx.js +362 -0
- package/service/present.js +516 -0
- package/service/proactive.js +531 -0
- package/service/query.js +39 -10
- package/service/reconcile.js +565 -0
- package/service/shared.js +27 -0
- package/service/subscriptions.js +563 -0
- package/service/uploads.js +4 -1
- package/sql-guard.js +33 -2
- package/xlsx.js +12 -2
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hedeqiang/dsh-nl2sql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Ask MySQL, PostgreSQL, Elasticsearch and CSV/Excel in plain language inside DeepSeek Harness: charts, dashboards, alerts, and IM push and bots for Lark/Feishu, Telegram, WeCom and DingTalk.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"nl2sql-mcp": "bin/nl2sql-mcp.mjs"
|
|
8
|
+
},
|
|
6
9
|
"exports": {
|
|
7
10
|
".": "./index.js",
|
|
8
11
|
"./client": "./lib/client.js",
|
|
@@ -10,6 +13,8 @@
|
|
|
10
13
|
"./icon": "./icon.svg"
|
|
11
14
|
},
|
|
12
15
|
"files": [
|
|
16
|
+
"bin",
|
|
17
|
+
"docs/mcp.md",
|
|
13
18
|
"index.js",
|
|
14
19
|
"service/*.js",
|
|
15
20
|
"channels/*.js",
|
package/service/alerts.js
CHANGED
|
@@ -6,6 +6,9 @@ import { evaluateAlert } from '../analysis.js'
|
|
|
6
6
|
import { admitSql } from '../sql-guard.js'
|
|
7
7
|
import { UserError, clampInt, newId, oneOf, requireText, upsert } from './shared.js'
|
|
8
8
|
|
|
9
|
+
/** Fields written by checks (not by the edit form). */
|
|
10
|
+
const ALERT_RUNTIME = ['state', 'lastCheckedAt', 'lastValue', 'lastResultId', 'lastFiredAt', 'lastError']
|
|
11
|
+
|
|
9
12
|
export const methods = {
|
|
10
13
|
// ---------------------------------------------------------------- alerts
|
|
11
14
|
|
|
@@ -53,6 +56,10 @@ export const methods = {
|
|
|
53
56
|
|
|
54
57
|
async saveAlert(input) {
|
|
55
58
|
const a = await this.normalizeAlert(input)
|
|
59
|
+
// A check may have finished between normalize and now: keep its runtime fields
|
|
60
|
+
// (last check / fire time, state) from the stored object, never the stale copy.
|
|
61
|
+
const current = (this.state.alerts ?? []).find(x => x.id === a.id)
|
|
62
|
+
if (current) for (const k of ALERT_RUNTIME) { if (current[k] === undefined) delete a[k]; else a[k] = current[k] }
|
|
56
63
|
this.state.alerts = upsert(this.state.alerts ?? [], a)
|
|
57
64
|
await this.save()
|
|
58
65
|
this.scheduleAlerts()
|
|
@@ -73,33 +80,37 @@ export const methods = {
|
|
|
73
80
|
|
|
74
81
|
/** Evaluate an unsaved alert definition (form "dry run"): nothing is stored or pushed. */
|
|
75
82
|
async previewAlert(input) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
this.state.alerts = [...(saved ?? []), draft]
|
|
81
|
-
return await this.checkAlertInner(tmpId, { dryRun: true })
|
|
82
|
-
} finally {
|
|
83
|
-
this.state.alerts = saved
|
|
84
|
-
}
|
|
83
|
+
// The draft is evaluated on its own: state.alerts is never touched, so concurrent
|
|
84
|
+
// saves are kept and the draft is never persisted.
|
|
85
|
+
const draft = await this.normalizeAlert({ ...input, id: undefined }, `preview-${newId('al')}`)
|
|
86
|
+
return this.checkAlertInner(draft.id, { dryRun: true, draft })
|
|
85
87
|
},
|
|
86
88
|
|
|
87
|
-
async checkAlertInner(id, { dryRun = false, force = false } = {}) {
|
|
88
|
-
const
|
|
89
|
-
if (!
|
|
89
|
+
async checkAlertInner(id, { dryRun = false, force = false, draft } = {}) {
|
|
90
|
+
const found = draft ?? (this.state.alerts ?? []).find(x => x.id === id)
|
|
91
|
+
if (!found) throw new UserError('nl2sql/invalid', '告警不存在')
|
|
92
|
+
// Evaluate a snapshot; runtime fields are applied below to the object stored
|
|
93
|
+
// *after* the query (saveAlert may have replaced it meanwhile).
|
|
94
|
+
let a = draft ? structuredClone(draft) : { ...found }
|
|
90
95
|
const ds = this.requireDatasource(a.datasourceId)
|
|
91
96
|
let result
|
|
97
|
+
// Chat-created alerts (service/subscriptions.js) run as their creator, re-resolved now.
|
|
98
|
+
const actor = a.chatTarget && this.chatAlertActor ? await this.chatAlertActor(a) : undefined
|
|
92
99
|
if (a.source === 'metric') {
|
|
93
100
|
const range = a.grain === 'hour' ? 'last7d' : a.grain === 'month' ? 'thisYear' : a.grain === 'week' ? 'last90d' : 'last30d'
|
|
94
|
-
result = await this.queryMetric({ datasourceId: ds.id, metric: a.metric, grain: a.grain, range, filters: a.filters, title: `告警 · ${a.name}`, source: 'alert' })
|
|
101
|
+
result = await this.queryMetric({ datasourceId: ds.id, metric: a.metric, grain: a.grain, range, filters: a.filters, title: `告警 · ${a.name}`, source: 'alert', actor })
|
|
95
102
|
} else {
|
|
96
|
-
result = await this.runQuery({ sql: a.sql, datasourceId: ds.id, title: `告警 · ${a.name}`, source: 'alert', confirmScan: true })
|
|
103
|
+
result = await this.runQuery({ sql: a.sql, datasourceId: ds.id, title: `告警 · ${a.name}`, source: 'alert', confirmScan: true, actor })
|
|
97
104
|
}
|
|
98
105
|
// Series: first column = time/category, last numeric column = value.
|
|
99
106
|
const vi = [...result.columns.keys()].reverse().find(i => result.columns[i].kind === 'number')
|
|
100
107
|
const series = vi === undefined ? [] : result.rows.map(r => ({ t: r[0], v: r[vi] === null ? null : Number(r[vi]) }))
|
|
101
108
|
if (a.condition.type !== 'missing' && result.columns.length === 1 && vi === 0) series.splice(0, series.length, ...result.rows.map((r, i) => ({ t: i, v: Number(r[0]) })))
|
|
102
109
|
const ev = evaluateAlert(a.condition, series, { now: Date.now() })
|
|
110
|
+
if (!draft) {
|
|
111
|
+
const current = (this.state.alerts ?? []).find(x => x.id === id)
|
|
112
|
+
if (current) a = current
|
|
113
|
+
}
|
|
103
114
|
a.lastCheckedAt = Date.now()
|
|
104
115
|
a.lastValue = ev.value ?? null
|
|
105
116
|
a.lastResultId = result.resultId
|
|
@@ -111,9 +122,12 @@ export const methods = {
|
|
|
111
122
|
a.lastFiredAt = Date.now()
|
|
112
123
|
const event = { id: newId('ae'), alertId: a.id, name: a.name, at: Date.now(), value: ev.value ?? null, message: ev.message, resultId: result.resultId }
|
|
113
124
|
this.records.put('alertEvent', event)
|
|
114
|
-
if (a.channelId) {
|
|
125
|
+
if (a.channelId || (a.chatTarget && this.deliverAlertToChat)) {
|
|
115
126
|
try {
|
|
116
|
-
|
|
127
|
+
// Optional extra Markdown from feature modules (e.g. change attribution, service/proactive.js).
|
|
128
|
+
const extra = await Promise.resolve(this.beforeAlertPush?.(a, result)).catch(() => null)
|
|
129
|
+
const msg = { channelId: a.channelId, title: `⚠️ 数据告警:${a.name}`, text: `${a.mention ? '<at id=all></at> ' : ''}**${ev.message}**${extra ? `\n\n${extra}` : ''}\n\n<font color='grey'>数据源:${ds.name} · 检查时间:${new Date().toLocaleString('zh-CN', { hour12: false })}</font>`, resultIds: [result.resultId], source: 'alert' }
|
|
130
|
+
if (a.chatTarget && this.deliverAlertToChat) await this.deliverAlertToChat(a, msg); else await this.sendToChannel(msg)
|
|
117
131
|
pushed = true
|
|
118
132
|
event.pushed = true
|
|
119
133
|
} catch (error) {
|
|
@@ -124,8 +138,9 @@ export const methods = {
|
|
|
124
138
|
}
|
|
125
139
|
} else if (a.state === 'firing') {
|
|
126
140
|
a.state = 'ok'
|
|
127
|
-
if (!dryRun && a.channelId && a.notifyRecovery !== false) {
|
|
128
|
-
|
|
141
|
+
if (!dryRun && (a.channelId || a.chatTarget) && a.notifyRecovery !== false) {
|
|
142
|
+
const msg = { channelId: a.channelId, title: `✅ 告警恢复:${a.name}`, text: ev.message, source: 'alert' }
|
|
143
|
+
try { if (a.chatTarget && this.deliverAlertToChat) await this.deliverAlertToChat(a, msg); else await this.sendToChannel(msg) } catch {}
|
|
129
144
|
}
|
|
130
145
|
} else {
|
|
131
146
|
a.state = 'ok'
|
|
@@ -163,12 +178,20 @@ export const methods = {
|
|
|
163
178
|
const last = a.lastCheckedAt ?? 0
|
|
164
179
|
if (a.every === '15m') return last + 15 * 60000
|
|
165
180
|
if (a.every === 'hour') return last + 60 * 60000
|
|
166
|
-
// daily at HH:MM local time: the most recent slot not yet covered by lastCheckedAt.
|
|
181
|
+
// daily at HH:MM local wall time: the most recent slot not yet covered by lastCheckedAt.
|
|
182
|
+
// DST (same rule as the Harness schedule service): a slot that does not exist
|
|
183
|
+
// that day (spring forward) is skipped; a repeated one (fall back) is the earlier instant.
|
|
167
184
|
const [hh, mm] = String(a.at ?? '09:00').split(':').map(Number)
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
185
|
+
const today = new Date(now)
|
|
186
|
+
const slotOn = (offset) => {
|
|
187
|
+
const d = new Date(today.getFullYear(), today.getMonth(), today.getDate() + offset, hh, mm, 0, 0)
|
|
188
|
+
return d.getHours() === hh && d.getMinutes() === mm ? d.getTime() : undefined
|
|
189
|
+
}
|
|
190
|
+
let prev
|
|
191
|
+
for (let k = 0; k >= -3 && prev === undefined; k--) { const s = slotOn(k); if (s !== undefined && s <= now) prev = s }
|
|
192
|
+
if (prev !== undefined && last < prev) return prev
|
|
193
|
+
for (let k = 0; k <= 3; k++) { const s = slotOn(k); if (s !== undefined && s > now && s > last) return s }
|
|
194
|
+
return now + 86400000
|
|
172
195
|
},
|
|
173
196
|
|
|
174
197
|
async withJobLock(key, fn) {
|
|
@@ -192,15 +215,26 @@ export const methods = {
|
|
|
192
215
|
this.checkAlert(a.id),
|
|
193
216
|
new Promise((_, rej) => { const t = setTimeout(() => rej(new Error(`检查超过 ${Math.round(timeoutMs / 1000)} 秒`)), timeoutMs); t.unref?.() }),
|
|
194
217
|
])
|
|
195
|
-
|
|
218
|
+
// The stored object may have been replaced by an edit meanwhile.
|
|
219
|
+
const cur = (this.state.alerts ?? []).find(x => x.id === a.id)
|
|
220
|
+
if (cur) cur.lastError = undefined
|
|
196
221
|
ran.push({ id: a.id, ok: true })
|
|
197
222
|
} catch (error) {
|
|
198
223
|
if (error.code === 'nl2sql/busy') return
|
|
199
|
-
a.
|
|
200
|
-
|
|
224
|
+
const cur = (this.state.alerts ?? []).find(x => x.id === a.id) ?? a
|
|
225
|
+
cur.lastError = error.message
|
|
226
|
+
cur.lastCheckedAt = Date.now()
|
|
201
227
|
ran.push({ id: a.id, ok: false, error: error.message })
|
|
202
228
|
}
|
|
203
229
|
}))
|
|
230
|
+
// Jobs registered by other modules (digest, reconciliation, freshness, quality, goals…):
|
|
231
|
+
// service.schedulerJobs() returns [{ id, due(now: Date) -> boolean, run() -> Promise }].
|
|
232
|
+
for (const job of (typeof this.schedulerJobs === 'function' ? this.schedulerJobs(now) : [])) {
|
|
233
|
+
let isDue = false
|
|
234
|
+
try { isDue = Boolean(job.due(now)) } catch {}
|
|
235
|
+
if (!isDue) continue
|
|
236
|
+
try { await this.withJobLock(`job:${job.id}`, () => job.run()); ran.push({ id: job.id, ok: true }) } catch (error) { if (error.code !== 'nl2sql/busy') ran.push({ id: job.id, ok: false, error: error.message }) }
|
|
237
|
+
}
|
|
204
238
|
// Daily schema watch (catches up once after downtime).
|
|
205
239
|
const at = this.state.settings.schemaCheckDaily
|
|
206
240
|
if (at) {
|
package/service/bots.js
CHANGED
|
@@ -35,6 +35,29 @@ const toast = (type, content) => ({ toast: { type, content } })
|
|
|
35
35
|
const label = (platform) => PLATFORM_LABEL[platform] ?? platform ?? 'IM'
|
|
36
36
|
const SOURCE = (platform) => `${platform ?? 'im'}bot`
|
|
37
37
|
|
|
38
|
+
const BARE_COMMANDS = {
|
|
39
|
+
帮助: 'help', help: 'help', start: 'help',
|
|
40
|
+
数据源: 'sources', sources: 'sources', datasources: 'sources',
|
|
41
|
+
自动: 'auto', auto: 'auto',
|
|
42
|
+
新对话: 'reset', new: 'reset', reset: 'reset',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A bot command, or null for a question. Argument-less commands must be the whole
|
|
47
|
+
* message (optionally `/`-prefixed, trailing punctuation ignored), so questions like
|
|
48
|
+
* 「自动续费用户有多少」 reach the agent. `切换` / `切换数据源` / `switch` / `use` take an
|
|
49
|
+
* argument after whitespace; alone they ask for usage.
|
|
50
|
+
* → { verb: 'help'|'sources'|'auto'|'reset'|'switch', arg? }
|
|
51
|
+
*/
|
|
52
|
+
export function parseBotCommand(text) {
|
|
53
|
+
const s = String(text ?? '').trim().replace(/^\//, '').replace(/[\s??。.!!~~]+$/u, '')
|
|
54
|
+
const bare = BARE_COMMANDS[s.toLowerCase()]
|
|
55
|
+
if (bare) return { verb: bare }
|
|
56
|
+
const m = /^(切换数据源|切换|switch|use)(?:\s+([\s\S]+))?$/i.exec(s)
|
|
57
|
+
if (m) return { verb: 'switch', arg: (m[2] ?? '').trim() }
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
38
61
|
function hasAggregate(r, dialect) {
|
|
39
62
|
const parsed = parseSelect(r.baseSql ?? r.sql, { dialect })
|
|
40
63
|
return Boolean(parsed?.select?.some(i => i.isAggregate))
|
|
@@ -286,8 +309,8 @@ export const methods = {
|
|
|
286
309
|
if (m.chatType === 'group' && m.chatId) this.noteSeenIdentity({ platform, kind: 'chat', id: String(m.chatId), name: chatName, chatName, channelId: m.channelId, refused: Boolean(resolved.refused) })
|
|
287
310
|
if (resolved.refused) { await this.save(); await reply(resolved.refused); return }
|
|
288
311
|
const actor = resolved.actor
|
|
289
|
-
// Commands: 帮助 / 数据源 / 切换 <名称> / 新对话
|
|
290
|
-
const cmd = text
|
|
312
|
+
// Commands: 帮助 / 数据源 / 切换 <名称> / 自动 / 新对话 (and English aliases).
|
|
313
|
+
const cmd = parseBotCommand(text)
|
|
291
314
|
// Members with different scopes must not share a conversation: per-user sessions
|
|
292
315
|
// whenever the sender's effective scope or rules differ from the chat's.
|
|
293
316
|
const scopeSig = JSON.stringify([actor.datasourceIds, actor.role, actor.rowRules ?? []])
|
|
@@ -301,14 +324,15 @@ export const methods = {
|
|
|
301
324
|
const allowedDs = this.state.datasources.filter(d => !Array.isArray(actor.datasourceIds) || actor.datasourceIds.includes(d.id))
|
|
302
325
|
if (conv.datasourceId && !allowedDs.some(d => d.id === conv.datasourceId)) conv.datasourceId = allowedDs[0]?.id ?? null
|
|
303
326
|
if (cmd) {
|
|
304
|
-
const verb = cmd
|
|
305
|
-
if (verb === '
|
|
306
|
-
if (verb === '
|
|
307
|
-
if (verb === '
|
|
308
|
-
if (verb === '
|
|
309
|
-
|
|
310
|
-
const
|
|
311
|
-
|
|
327
|
+
const { verb } = cmd
|
|
328
|
+
if (verb === 'help') { await reply(`直接${m.chatType === 'group' ? ' @我 ' : ''}提问即可,例如「上周每天的订单量」。\n命令(单独发送):\n· 数据源 / sources — 查看可用数据源\n· 切换 <名称> / switch <name> — 固定使用某个数据源\n· 自动 / auto — 按问题自动选择数据源\n· 新对话 / new — 开始新对话\n· 帮助 / help — 显示本说明\n当前数据源:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}${conv.datasourcePinned ? '(已指定)' : allowedDs.length > 1 ? '(按问题自动选择)' : ''}${actor.role === 'viewer' ? '\n你是只读成员:可以查询指标库中的指标、已验证的常见问题和看板。' : ''}`); return }
|
|
329
|
+
if (verb === 'sources') { await reply(`可用数据源:\n${allowedDs.map(d => `· ${d.name}${d.description ? ` — ${d.description}` : ''}`).join('\n') || '无'}\n当前:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}${conv.datasourcePinned ? '(已指定,发送「自动」恢复按问题自动选择)' : allowedDs.length > 1 ? '(按问题自动选择;发送「切换 <名称>」固定)' : ''}`); return }
|
|
330
|
+
if (verb === 'auto') { delete conv.datasourcePinned; convs[key] = conv; await this.save(); await reply(`已改为按问题自动选择数据源(可用:${allowedDs.map(d => d.name).join('、')})。`); return }
|
|
331
|
+
if (verb === 'reset') { conv.sessionId = null; convs[key] = conv; await this.save(); await reply('已开始新对话。'); return }
|
|
332
|
+
if (!cmd.arg) { await reply(`用法:切换 <数据源名称>(或 switch <name>)。可用:${allowedDs.map(d => d.name).join('、') || '无'}`); return }
|
|
333
|
+
const want = cmd.arg.toLowerCase()
|
|
334
|
+
const target = allowedDs.find(d => d.name.toLowerCase() === want || d.id === cmd.arg) ?? allowedDs.filter(d => d.name.toLowerCase().includes(want) || (d.database ?? '').toLowerCase() === want).find((_, __, arr) => arr.length === 1)
|
|
335
|
+
if (!target) { await reply(`找不到可用的数据源「${cmd.arg}」。可用:${allowedDs.map(d => d.name).join('、')}`); return }
|
|
312
336
|
conv.datasourceId = target.id
|
|
313
337
|
conv.datasourcePinned = true
|
|
314
338
|
if (conv.sessionId) { this.state.sessions[conv.sessionId] = target.id }
|
|
@@ -417,7 +441,8 @@ export const methods = {
|
|
|
417
441
|
for (const d of list) {
|
|
418
442
|
let tables = ''
|
|
419
443
|
try {
|
|
420
|
-
|
|
444
|
+
// Policy-filtered, like nl2sql_schema: hidden tables never reach the bot prompt.
|
|
445
|
+
const schema = await this.cachedVisibleSchema(d)
|
|
421
446
|
if (schema?.tables?.length) {
|
|
422
447
|
const rank = { fact: 0, dimension: 1, view: 2, other: 3, log: 4, config: 5 }
|
|
423
448
|
const top = [...schema.tables].sort((a, b) => (rank[a.role] ?? 3) - (rank[b.role] ?? 3) || (b.rows ?? 0) - (a.rows ?? 0)).slice(0, 12)
|
|
@@ -475,7 +500,8 @@ export const methods = {
|
|
|
475
500
|
}
|
|
476
501
|
const waited = await controller.wait({ sessionId: sid, timeoutMs, maxMessages: 1 }, new AbortController().signal)
|
|
477
502
|
const reason = waited?.reason ?? 'idle'
|
|
478
|
-
|
|
503
|
+
// cancel() is async on DSH controllers: a rejection must not become unhandled.
|
|
504
|
+
if (reason !== 'idle') void Promise.resolve().then(() => controller.cancel({ sessionId: sid })).catch(() => {})
|
|
479
505
|
const answer = await this.collectTurnByRequest(sid, requestId)
|
|
480
506
|
return { sessionId: sid, reason, ...answer }
|
|
481
507
|
},
|
package/service/channels.js
CHANGED
|
@@ -19,6 +19,15 @@ import { UserError, newId, pick, requireText, upsert } from './shared.js'
|
|
|
19
19
|
const LARK_LEGACY = ['domain', 'appId', 'receiveIdType', 'receiveId']
|
|
20
20
|
const BOT_DEFAULTS = { enabled: false, allowP2p: true, sessionScope: 'chat' }
|
|
21
21
|
|
|
22
|
+
/** Truncate to `max` UTF-16 units with an ellipsis, without splitting a surrogate pair. */
|
|
23
|
+
export function clip(s, max) {
|
|
24
|
+
const str = String(s ?? '')
|
|
25
|
+
if (str.length <= max) return str
|
|
26
|
+
let cut = max - 1
|
|
27
|
+
if (/[\uD800-\uDBFF]/.test(str[cut - 1] ?? '')) cut--
|
|
28
|
+
return `${str.slice(0, cut)}…`
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
/** ChannelError (or legacy ImError) → UserError with the same code and message. */
|
|
23
32
|
export function channelUserError(error, code) {
|
|
24
33
|
if (!(error instanceof ChannelError || (error?.code && /^(im|channel)\//.test(error.code)))) return error
|
|
@@ -172,8 +181,10 @@ export const methods = {
|
|
|
172
181
|
c.platform = adapter.id
|
|
173
182
|
c.description = String(input.description ?? c.description ?? '').slice(0, 300)
|
|
174
183
|
const { config: configIn, secrets: changed } = splitPayload(input, { ...kind, platform: adapter.id })
|
|
175
|
-
const stored = existing && !typeChanged ? await this.channelSecrets(existing) : {}
|
|
176
184
|
const keys = secretKeys(kind)
|
|
185
|
+
// A type change keeps stored secrets whose key the new kind also has (e.g. webhookUrl, token).
|
|
186
|
+
const stored = existing ? await this.channelSecrets(existing) : {}
|
|
187
|
+
if (typeChanged) for (const k of Object.keys(stored)) if (!keys.includes(k)) delete stored[k]
|
|
177
188
|
const merged = {}
|
|
178
189
|
for (const k of keys) {
|
|
179
190
|
if (Object.hasOwn(changed, k)) { if (changed[k] !== null) merged[k] = changed[k] } else if (stored[k] !== undefined) merged[k] = stored[k]
|
|
@@ -224,9 +235,19 @@ export const methods = {
|
|
|
224
235
|
await this.stopBot(id)
|
|
225
236
|
this.state.channels = this.state.channels.filter(x => x.id !== id)
|
|
226
237
|
if (c.isDefault && this.state.channels[0]) this.state.channels[0].isDefault = true
|
|
227
|
-
|
|
238
|
+
// Drop every reference to the channel: alerts, schema notifications, bot conversations, reports.
|
|
239
|
+
for (const a of this.state.alerts ?? []) if (a.channelId === id) a.channelId = null
|
|
240
|
+
if (this.state.settings?.schemaNotifyChannel === id) this.state.settings.schemaNotifyChannel = null
|
|
241
|
+
for (const [k, conv] of Object.entries(this.state.botConversations ?? {})) if (conv?.channelId === id) delete this.state.botConversations[k]
|
|
242
|
+
const reports = (this.state.reports ?? []).filter(r => r.channelId === id)
|
|
243
|
+
for (const r of reports) delete r.channelId
|
|
228
244
|
try { await this.ctx.get('credentials')?.deleteRecord(this.channelSecretKey(id)) } catch {}
|
|
229
245
|
await this.save()
|
|
246
|
+
// Scheduled report prompts name the channel: rewrite them through the normal report save.
|
|
247
|
+
for (const r of reports) {
|
|
248
|
+
if (!r.scheduleId || !this.ctx.get('schedule')) continue
|
|
249
|
+
try { await this.saveReport({ ...r, channelId: undefined }) } catch (error) { console.warn('nl2sql: cannot update report %s after channel delete: %s', r.id, error.message) }
|
|
250
|
+
}
|
|
230
251
|
return { deleted: id }
|
|
231
252
|
},
|
|
232
253
|
|
|
@@ -311,10 +332,10 @@ export const methods = {
|
|
|
311
332
|
const modes = chartModesOf(found.kind)
|
|
312
333
|
const body = text ? String(text).slice(0, 20000) : ''
|
|
313
334
|
const message = {
|
|
314
|
-
title:
|
|
335
|
+
title: clip(String(title || resolved[0]?.title || '').trim() || '数据问答', 100),
|
|
315
336
|
text: body,
|
|
316
337
|
chartMode: modes.includes(chartMode) ? chartMode : modes.includes(c.chartMode) ? c.chartMode : modes[0],
|
|
317
|
-
results: resolved.map(r => pick(r, ['title', 'columns', 'rows', 'rowCount', 'truncated', 'chart', 'sql', 'filters', 'error', ...(found.kind.capabilities?.buttons ? ['actions'] : [])])),
|
|
338
|
+
results: resolved.map(r => ({ ...pick(r, ['title', 'columns', 'rows', 'rowCount', 'truncated', 'chart', 'sql', 'filters', 'error', ...(found.kind.capabilities?.buttons ? ['actions'] : [])]), ...(r.title ? { title: clip(String(r.title), 100) } : {}) })),
|
|
318
339
|
includeSql: Boolean(includeSql),
|
|
319
340
|
mentionAll: mentionAll ?? /<at\s+id\s*=\s*["']?all["']?\s*>/i.test(body),
|
|
320
341
|
...(template ? { template } : {}),
|
|
@@ -328,7 +349,7 @@ export const methods = {
|
|
|
328
349
|
if (caps.files && wantAttach) {
|
|
329
350
|
const idx = message.results.map((r, i) => (needsAttachment(r, shown) ? i : -1)).filter(i => i >= 0)
|
|
330
351
|
if (idx.length) {
|
|
331
|
-
const sheets = idx.map(i => ({ name:
|
|
352
|
+
const sheets = idx.map(i => ({ name: message.results[i].title || `结果${i + 1}`, columns: message.results[i].columns, rows: message.results[i].rows }))
|
|
332
353
|
if (includeSql) sheets.push({ name: 'SQL', columns: [{ name: '结果', kind: 'text' }, { name: 'SQL', kind: 'text' }], rows: idx.map(i => [message.results[i].title ?? '', message.results[i].sql ?? '']) })
|
|
333
354
|
const buffer = writeXlsx(sheets, { creator: 'DeepSeek Harness 数据问答' })
|
|
334
355
|
const safe = String(message.title).replace(/[\\/:*?"<>|\s]+/g, '_').slice(0, 60) || '数据'
|
package/service/dashboards.js
CHANGED
|
@@ -296,8 +296,11 @@ export const methods = {
|
|
|
296
296
|
// --------------------------------------------------------- auto overview
|
|
297
297
|
|
|
298
298
|
/** Build overview widgets for one table from its column roles; runs each query and returns results. */
|
|
299
|
-
async tableOverview({ datasourceId, table, pin = false }) {
|
|
299
|
+
async tableOverview({ datasourceId, table, pin = false, actor, sessionId, source = 'ui' }) {
|
|
300
300
|
const ds = this.requireDatasource(datasourceId)
|
|
301
|
+
// Agent / bot callers run as their actor: scope, row-level rules, viewer limits and audit attribution apply.
|
|
302
|
+
actor ??= sessionId ? this.actorFor(sessionId) : undefined
|
|
303
|
+
if (actor) this.assertAccess(actor, ds)
|
|
301
304
|
const schema = await this.visibleSchema(ds)
|
|
302
305
|
const t = schema.tables.find(x => x.name === table)
|
|
303
306
|
if (!t) throw new UserError('nl2sql/unknown-table', `表不存在或无权访问:${table}`)
|
|
@@ -305,7 +308,7 @@ export const methods = {
|
|
|
305
308
|
const results = []
|
|
306
309
|
for (const plan of plans) {
|
|
307
310
|
try {
|
|
308
|
-
const r = await this.runQuery({ sql: plan.sql, datasourceId: ds.id, title: plan.title, chart: plan.chart, source
|
|
311
|
+
const r = await this.runQuery({ sql: plan.sql, datasourceId: ds.id, sessionId, actor, title: plan.title, chart: plan.chart, source, confirmScan: true })
|
|
309
312
|
results.push(r)
|
|
310
313
|
if (pin) await this.pinWidget({ title: plan.title, sql: plan.sql, datasourceId: ds.id, chart: r.chart, width: plan.width })
|
|
311
314
|
} catch (error) {
|
package/service/datasources.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Mixed into the service prototype by index.js; `this` is the service.
|
|
4
4
|
*/
|
|
5
5
|
import { DIALECTS, createDriver, describeError } from '../drivers.js'
|
|
6
|
-
import { normalizePolicy, policyActive } from '../policy.js'
|
|
6
|
+
import { filterSchema, normalizePolicy, policyActive } from '../policy.js'
|
|
7
7
|
import { sanitizeTableName } from '../sqlite-driver.js'
|
|
8
8
|
import { join } from 'node:path'
|
|
9
9
|
import { CREDENTIAL_SCOPE, DEFAULT_SETTINGS, RETENTION_MAX, RETENTION_MIN, SUPPORTED_TYPES, UserError, clampInt, newId, oneOf, pick, requireText } from './shared.js'
|
|
@@ -308,14 +308,27 @@ export const methods = {
|
|
|
308
308
|
return current
|
|
309
309
|
},
|
|
310
310
|
|
|
311
|
-
|
|
311
|
+
/**
|
|
312
|
+
* The already-analyzed schema (memory or file, no introspection) restricted by the
|
|
313
|
+
* access policy — the same view nl2sql_schema gives the model. Undefined when not analyzed.
|
|
314
|
+
*/
|
|
315
|
+
async cachedVisibleSchema(ds) {
|
|
316
|
+
const schema = this.schemas.get(ds.id) ?? await this.readSchemaFile(ds.id)
|
|
317
|
+
return schema ? filterSchema(schema, this.policyFor(ds)) : undefined
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Bind a session to a data source. The page RPC also makes it the page's current data source
|
|
322
|
+
* (default); agent tools (including bot sessions) pass setCurrent: false so they never move it.
|
|
323
|
+
*/
|
|
324
|
+
async bindSession(sessionId, datasourceId, { setCurrent = true } = {}) {
|
|
312
325
|
if (!sessionId) throw new UserError('nl2sql/invalid', '缺少会话')
|
|
313
326
|
if (datasourceId === null) {
|
|
314
327
|
delete this.state.sessions[sessionId]
|
|
315
328
|
} else {
|
|
316
329
|
const ds = this.requireDatasource(datasourceId)
|
|
317
330
|
this.state.sessions[sessionId] = ds.id
|
|
318
|
-
this.state.current = ds.id
|
|
331
|
+
if (setCurrent) this.state.current = ds.id
|
|
319
332
|
}
|
|
320
333
|
await this.save()
|
|
321
334
|
return this.sessionInfo(sessionId)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature modules. Each entry: { name, methods, rpc?(service), tools?(ctx, service, helpers), start?(ctx, service) }.
|
|
3
|
+
* - methods: mixed into the service prototype (`this` = service). Methods named `jobs_<name>(now)` return
|
|
4
|
+
* scheduler jobs [{ id, due(now), run() }] run by the in-process scheduler (service/alerts.js).
|
|
5
|
+
* - rpc: page RPC methods; tools: agent tools; start: called after apply (e.g. servers).
|
|
6
|
+
* New features add a file under service/ and one line here, so they do not edit index.js.
|
|
7
|
+
*/
|
|
8
|
+
import { FEATURE as subscriptions } from './subscriptions.js'
|
|
9
|
+
import { FEATURE as reconcile } from './reconcile.js'
|
|
10
|
+
import { FEATURE as present } from './present.js'
|
|
11
|
+
import { FEATURE as mcp } from './mcp.js'
|
|
12
|
+
import { FEATURE as proactive } from './proactive.js'
|
|
13
|
+
import { FEATURE as freshness } from './freshness.js'
|
|
14
|
+
export const FEATURES = [
|
|
15
|
+
present,
|
|
16
|
+
mcp,
|
|
17
|
+
reconcile,
|
|
18
|
+
subscriptions,
|
|
19
|
+
proactive,
|
|
20
|
+
freshness,
|
|
21
|
+
]
|