@hedeqiang/dsh-nl2sql 0.11.2 → 0.12.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.
- package/CHANGELOG.md +69 -36
- package/README.md +136 -70
- package/README.zh-CN.md +175 -0
- package/channels/index.js +1 -1
- package/channels/lark.js +1 -1
- package/channels/message.js +1 -1
- package/im.js +1 -1
- package/index.js +1 -0
- package/lark-bot.js +1 -1
- package/lib/client.js +130 -20
- package/package.json +8 -4
- package/service/access.js +38 -1
- package/service/bots.js +77 -10
- package/service/channels.js +1 -1
- package/service/suggestions.js +1 -1
- package/README.en.md +0 -90
- /package/docs/{CHANNELS.md → channels.md} +0 -0
package/lib/client.js
CHANGED
|
@@ -15,6 +15,20 @@ window.__ModuleLoader__.load({
|
|
|
15
15
|
// ------------------------------------------------------------ locale
|
|
16
16
|
|
|
17
17
|
const zh = {
|
|
18
|
+
'mb.pick.people': "从和机器人说过话的人中选择",
|
|
19
|
+
'mb.pick.chats': "从机器人所在的群中选择",
|
|
20
|
+
'mb.pick.refresh': "刷新",
|
|
21
|
+
'mb.pick.noPeople': "还没有人和机器人说过话。先让对方在群里 @机器人 或私聊发一句话(会提示未开通),这里就会出现,点一下即可添加。",
|
|
22
|
+
'mb.pick.noChats': "还没有可选的群。把机器人拉进群并 @它一次,或确认应用已开通「获取群信息」权限。",
|
|
23
|
+
'mb.pick.peopleHint': "点选即可自动填入账号 ID 和名称,也可以在下方手动填写。",
|
|
24
|
+
'mb.pick.chatsHint': "点选群即可自动填入群 ID 和群名;群内所有成员按该群的权限使用。",
|
|
25
|
+
'mb.pick.via': "来自渠道:{name}",
|
|
26
|
+
'mb.pick.taken': "已是成员「{name}」",
|
|
27
|
+
'mb.pick.refused': "曾因未开通被拒绝",
|
|
28
|
+
'mb.pick.pending': "待开通",
|
|
29
|
+
'mb.pick.added': "已添加",
|
|
30
|
+
'mb.pending.title': "有 {n} 个人或群向机器人提问时因未开通被拒绝,点一下即可添加为成员:",
|
|
31
|
+
'mb.pending.add': "添加",
|
|
18
32
|
'dash.sourceOf': "数据源",
|
|
19
33
|
'dash.sourceMissing': "该图表的数据源已删除,无法刷新",
|
|
20
34
|
'dash.sourceMissingShort': "数据源已删除",
|
|
@@ -1018,6 +1032,20 @@ window.__ModuleLoader__.load({
|
|
|
1018
1032
|
'error.generic': '操作失败:{message}',
|
|
1019
1033
|
}
|
|
1020
1034
|
const en = {
|
|
1035
|
+
'mb.pick.people': "Pick someone who talked to a bot",
|
|
1036
|
+
'mb.pick.chats': "Pick a group the bot is in",
|
|
1037
|
+
'mb.pick.refresh': "Refresh",
|
|
1038
|
+
'mb.pick.noPeople': "Nobody has talked to a bot yet. Ask them to @-mention the bot or DM it once (they will be told they have no access); they then appear here for one-click adding.",
|
|
1039
|
+
'mb.pick.noChats': "No groups to pick yet. Add the bot to a group and @-mention it once, or check the app can read chat info.",
|
|
1040
|
+
'mb.pick.peopleHint': "Picking fills in the account id and name; you can also type them below.",
|
|
1041
|
+
'mb.pick.chatsHint': "Picking fills in the chat id and name; everyone in the group uses its access.",
|
|
1042
|
+
'mb.pick.via': "via channel: {name}",
|
|
1043
|
+
'mb.pick.taken': "already member \"{name}\"",
|
|
1044
|
+
'mb.pick.refused': "was refused (no access)",
|
|
1045
|
+
'mb.pick.pending': "pending",
|
|
1046
|
+
'mb.pick.added': "added",
|
|
1047
|
+
'mb.pending.title': "{n} people or groups were refused by a bot because they are not members. Click to add:",
|
|
1048
|
+
'mb.pending.add': "Add",
|
|
1021
1049
|
'dash.sourceOf': "Data source",
|
|
1022
1050
|
'dash.sourceMissing': "This chart's data source was deleted; it cannot refresh",
|
|
1023
1051
|
'dash.sourceMissingShort': "source deleted",
|
|
@@ -1704,8 +1732,10 @@ window.__ModuleLoader__.load({
|
|
|
1704
1732
|
.n2s-hist{border:.5px solid var(--n2s-line-2);border-radius:var(--n2s-radius);overflow:hidden;background:var(--dsw-alias-bg-layer-1)}
|
|
1705
1733
|
.n2s-hist-row{border-bottom:.5px solid var(--n2s-line)}
|
|
1706
1734
|
.n2s-hist-row:last-child{border-bottom:0}
|
|
1707
|
-
.n2s-hist-
|
|
1708
|
-
.n2s-hist-
|
|
1735
|
+
.n2s-hist-head{display:flex;align-items:center}
|
|
1736
|
+
.n2s-hist-head:hover{background:var(--n2s-hover)}
|
|
1737
|
+
.n2s-hist-head .n2s-rowdel{flex:none;margin-right:10px}
|
|
1738
|
+
.n2s-hist-main{display:grid;grid-template-columns:76px minmax(0,1fr) 150px 84px 20px;align-items:center;gap:14px;flex:1;min-width:0;width:100%;padding:11px 6px 11px 16px;border:0;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}
|
|
1709
1739
|
.n2s-hist-main:focus-visible{outline:2px solid var(--n2s-accent);outline-offset:-2px}
|
|
1710
1740
|
.n2s-hist-what{display:grid;gap:2px;min-width:0}
|
|
1711
1741
|
.n2s-hist-title{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
@@ -1937,11 +1967,17 @@ window.__ModuleLoader__.load({
|
|
|
1937
1967
|
.n2s-recent-row:hover .n2s-recent-del,.n2s-recent-del:focus-visible,.n2s-hist-row:hover .n2s-rowdel,.n2s-rowdel:focus-visible{opacity:1}
|
|
1938
1968
|
.n2s-recent-del:hover,.n2s-rowdel:hover{color:var(--n2s-err)}
|
|
1939
1969
|
@media (hover:none){.n2s-recent-del,.n2s-rowdel{opacity:1}}
|
|
1940
|
-
.n2s-hist-row.has-del{position:relative}
|
|
1941
|
-
.n2s-rowdel{position:absolute;top:10px;right:40px;z-index:1}
|
|
1942
1970
|
.n2s-morebtn{display:flex;align-items:center;justify-content:center;gap:6px;width:100%;height:36px;margin-top:6px;border:.5px dashed var(--n2s-line-2);border-radius:var(--n2s-radius-sm);background:transparent;color:var(--n2s-muted);font:inherit;font-size:13px;cursor:pointer}
|
|
1943
1971
|
.n2s-morebtn:hover{background:var(--n2s-hover);color:var(--dsw-alias-label-primary)}
|
|
1944
1972
|
.n2s-btn.danger-text{color:var(--n2s-err)}
|
|
1973
|
+
/* ---- member identity picker ---- */
|
|
1974
|
+
.n2s-pickchips{display:flex;flex-wrap:wrap;gap:6px}
|
|
1975
|
+
.n2s-pickchip{display:inline-flex;align-items:center;gap:6px;max-width:100%;height:30px;padding:0 10px;border:.5px solid var(--n2s-line-2);border-radius:999px;background:var(--n2s-surface);color:inherit;font:inherit;font-size:12.5px;cursor:pointer}
|
|
1976
|
+
.n2s-pickchip:hover:not(:disabled){background:var(--n2s-hover)}
|
|
1977
|
+
.n2s-pickchip[aria-pressed=true]{border-color:var(--n2s-accent);background:var(--n2s-accent-soft,rgba(51,112,255,.08));color:var(--n2s-accent)}
|
|
1978
|
+
.n2s-pickchip:disabled{opacity:.55;cursor:default}
|
|
1979
|
+
.n2s-pickchip-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:220px}
|
|
1980
|
+
.n2s-idpicker{gap:6px}
|
|
1945
1981
|
/* ---- dashboard v2 ---- */
|
|
1946
1982
|
.n2s-srcbadge{display:inline-flex;align-items:center;gap:3px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:none}
|
|
1947
1983
|
.n2s-seg.small button{height:26px;padding:0 10px;font-size:12px}
|
|
@@ -2122,6 +2158,10 @@ window.__ModuleLoader__.load({
|
|
|
2122
2158
|
/** Current UI language ('zh' | 'en'), updated by the page/tool views on render. */
|
|
2123
2159
|
let uiLang = 'zh'
|
|
2124
2160
|
// LocaleSnapshot is { active, locales, revision } (dsh-client-locale); older hosts passed a string.
|
|
2161
|
+
/** Join a list for display in the UI language: 「a、b」 / "a, b". */
|
|
2162
|
+
const joinList = (items, sep) => (items ?? []).filter(x => x !== undefined && x !== null && x !== '').join(sep ?? (uiLang === 'zh' ? '、' : ', '))
|
|
2163
|
+
/** Label + colon in the UI language: 「标签:」 / "Label: ". */
|
|
2164
|
+
const colon = (label) => (uiLang === 'zh' ? `${label}:` : `${label}: `)
|
|
2125
2165
|
const setUiLang = (snapshot) => {
|
|
2126
2166
|
const id = typeof snapshot === 'string' ? snapshot : (snapshot?.active ?? snapshot?.locale ?? 'zh')
|
|
2127
2167
|
uiLang = /^zh/i.test(String(id)) ? 'zh' : 'en'
|
|
@@ -3306,7 +3346,7 @@ window.__ModuleLoader__.load({
|
|
|
3306
3346
|
if (section === 'glossary') list = k.glossary.length ? k.glossary.map(g => item(g.id, h(React.Fragment, null, g.term, g.synonyms?.length ? h('span', { className: 'n2s-muted n2s-small' }, ` ${g.synonyms.join(' / ')}`) : null),
|
|
3307
3347
|
h('div', null, h('div', null, g.definition), g.sql && h('code', { className: 'n2s-md-code', style: { display: 'inline-block', marginTop: 6 } }, g.sql)), 'glossary', g)) : empty(t('kb.glossaryEmpty'))
|
|
3308
3348
|
if (section === 'metric') list = k.metrics.length ? k.metrics.map(m => item(m.id, h(React.Fragment, null, m.name, m.unit ? h('span', { className: 'n2s-badge', style: { marginLeft: 6 } }, m.unit) : null),
|
|
3309
|
-
h('div', null, h('div', { className: 'n2s-mono n2s-small' }, `${m.expression}${m.table ? ` FROM ${m.table}` : ''}${m.filter ? ` WHERE ${m.filter}` : ''}`), m.timeColumn && h('div', { className: 'n2s-muted n2s-small' }, `${t('kb.timeColumn')}: ${m.timeColumn}${m.dimensions?.length ? ` · ${t('kb.dimensions')}: ${m.dimensions.join('、')}` : ''}${m.synonyms?.length ? ` · ${t('kb.synonymsLabel')}: ${m.synonyms.join('、')}` : ''}`), m.description && h('div', { className: 'n2s-muted n2s-small' }, m.description)), 'metric', m)) : empty(t('kb.metricsEmpty'))
|
|
3349
|
+
h('div', null, h('div', { className: 'n2s-mono n2s-small' }, `${m.expression}${m.table ? ` FROM ${m.table}` : ''}${m.filter ? ` WHERE ${m.filter}` : ''}`), m.timeColumn && h('div', { className: 'n2s-muted n2s-small' }, `${t('kb.timeColumn')}: ${m.timeColumn}${m.dimensions?.length ? ` · ${t('kb.dimensions')}: ${m.dimensions.join(uiLang === 'zh' ? '、' : ', ')}` : ''}${m.synonyms?.length ? ` · ${t('kb.synonymsLabel')}: ${m.synonyms.join(uiLang === 'zh' ? '、' : ', ')}` : ''}`), m.description && h('div', { className: 'n2s-muted n2s-small' }, m.description)), 'metric', m)) : empty(t('kb.metricsEmpty'))
|
|
3310
3350
|
if (section === 'note') {
|
|
3311
3351
|
const entries = Object.entries(k.notes).sort(([a], [b]) => a.localeCompare(b))
|
|
3312
3352
|
list = h(React.Fragment, null,
|
|
@@ -3385,8 +3425,8 @@ window.__ModuleLoader__.load({
|
|
|
3385
3425
|
!list.loading && rows.length === 0 && h('div', { className: 'n2s-empty' }, h('h3', null, t('hist.empty')), h('div', null, t('hist.emptyHint'))),
|
|
3386
3426
|
rows.length > 0 && h('div', { className: 'n2s-hist', role: 'list' }, rows.map(r => {
|
|
3387
3427
|
const isOpen = open === r.id
|
|
3388
|
-
return h('div', { key: r.id ?? `${r.at}`, className: 'n2s-hist-row
|
|
3389
|
-
h('
|
|
3428
|
+
return h('div', { key: r.id ?? `${r.at}`, className: 'n2s-hist-row', role: 'listitem' },
|
|
3429
|
+
h('div', { className: 'n2s-hist-head' },
|
|
3390
3430
|
h('button', { type: 'button', className: 'n2s-hist-main', 'aria-expanded': isOpen, onClick: () => setOpen(isOpen ? null : r.id) },
|
|
3391
3431
|
statusDot(r.status),
|
|
3392
3432
|
h('span', { className: 'n2s-hist-what' },
|
|
@@ -3395,6 +3435,7 @@ window.__ModuleLoader__.load({
|
|
|
3395
3435
|
h('span', { className: 'n2s-hist-when' }, h('span', { title: new Date(r.at).toLocaleString() }, rel(r.at)), h('span', { className: 'src' }, `${t(`hist.source.${r.source}`)} · ${r.datasourceName ?? ''}`)),
|
|
3396
3436
|
h('span', { className: 'n2s-hist-num' }, r.rows !== undefined ? fmt(t, 'result.rows', { count: r.rows }) : '—', h('br'), r.elapsedMs !== undefined ? `${r.elapsedMs} ms` : ''),
|
|
3397
3437
|
h('span', { className: 'n2s-hist-chev' }, Icon.chev(12))),
|
|
3438
|
+
h('button', { type: 'button', className: 'n2s-iconbtn n2s-rowdel', title: t('hist.deleteOne'), 'aria-label': `${t('hist.deleteOne')}: ${r.title || t('hist.untitled')}`, onClick: () => removeRow(r) }, Icon.trash(14))),
|
|
3398
3439
|
isOpen && h('div', { className: 'n2s-hist-detail' },
|
|
3399
3440
|
r.error && h('div', { className: `n2s-notice ${r.status === 'blocked' ? 'warn' : 'err'}` }, r.error),
|
|
3400
3441
|
h('div', { style: { position: 'relative' } },
|
|
@@ -3491,7 +3532,7 @@ window.__ModuleLoader__.load({
|
|
|
3491
3532
|
h('span', { className: 'n2s-tile', 'aria-hidden': true }, svg(16, [h('circle', { key: 1, cx: 8, cy: 8, r: 5.5 }), h('path', { key: 2, d: 'M8 5v3.2l2 1.3' })])),
|
|
3492
3533
|
h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
|
|
3493
3534
|
h('div', { className: 'n2s-row', style: { gap: 6 } }, h('span', { className: 'n2s-list2-title' }, r.title), r.channelName && h('span', { className: 'n2s-badge' }, Icon.send(11), r.channelName)),
|
|
3494
|
-
h('div', { className: 'n2s-list2-sub' }, `${t(`rep.period.${r.period}`)} ${r.period === 'weekly' ? r.weekdays.map(d => t(`rep.wd.${d}`)).join('、') + ' ' : ''}${r.time.slice(0, 5)} · ${r.datasourceName} · ${r.scope === 'question' ? r.question : t('rep.scope.dashboard')}`)),
|
|
3535
|
+
h('div', { className: 'n2s-list2-sub' }, `${t(`rep.period.${r.period}`)} ${r.period === 'weekly' ? r.weekdays.map(d => t(`rep.wd.${d}`)).join(uiLang === 'zh' ? '、' : ', ') + ' ' : ''}${r.time.slice(0, 5)} · ${r.datasourceName} · ${r.scope === 'question' ? r.question : t('rep.scope.dashboard')}`)),
|
|
3495
3536
|
h('div', { className: 'n2s-list2-side' },
|
|
3496
3537
|
statusDot(r.status),
|
|
3497
3538
|
h('span', { className: 'n2s-faint n2s-small' }, r.scheduledAt && r.status === 'active' ? `${t('rep.next')} ${new Date(r.scheduledAt).toLocaleString([], { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}` : '')),
|
|
@@ -3669,7 +3710,7 @@ window.__ModuleLoader__.load({
|
|
|
3669
3710
|
|
|
3670
3711
|
// ------------------------------------------- channels: catalog & shapes
|
|
3671
3712
|
// The host may be older (flat Lark-only channels, no channelCatalog /
|
|
3672
|
-
// botStatus RPCs) or newer (docs/
|
|
3713
|
+
// botStatus RPCs) or newer (docs/channels.md). Everything below normalizes
|
|
3673
3714
|
// both so the pages only deal with { platform, type, config, secretsSet,
|
|
3674
3715
|
// capabilities, bot }.
|
|
3675
3716
|
|
|
@@ -4639,7 +4680,7 @@ window.__ModuleLoader__.load({
|
|
|
4639
4680
|
h('div', { className: 'n2s-h2', style: { margin: 0 } }, fmt(t, 'dash.count', { count: widgets.length })),
|
|
4640
4681
|
(mixed || hiddenByScope > 0 || (ds && sourceIds.some(id => id !== ds.id))) && h('div', { className: 'n2s-seg small', role: 'group', 'aria-label': t('dash.scope') },
|
|
4641
4682
|
['all', 'current'].map(v => h('button', { key: v, type: 'button', 'aria-pressed': scope === v, disabled: v === 'current' && !ds, onClick: () => setScope(v) }, v === 'all' ? t('dash.scopeAll') : fmt(t, 'dash.scopeCurrent', { name: ds?.name ?? '' }))))),
|
|
4642
|
-
showSource && h('div', { className: 'n2s-faint n2s-small' }, fmt(t, 'dash.sources', { list: sourceIds.map(id => dsById.get(id)?.name ?? t('dash.sourceMissingShort')).join('、') })),
|
|
4683
|
+
showSource && h('div', { className: 'n2s-faint n2s-small' }, fmt(t, 'dash.sources', { list: sourceIds.map(id => dsById.get(id)?.name ?? t('dash.sourceMissingShort')).join(uiLang === 'zh' ? '、' : ', ') })),
|
|
4643
4684
|
h('div', { className: 'n2s-faint n2s-small', role: 'status', 'aria-live': 'polite' },
|
|
4644
4685
|
loadingN > 0 ? fmt(t, 'dash.loadingN', { n: loadingN }) : lastAt ? fmt(t, 'dash.lastRefresh', { time: new Date(lastAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) }) : '',
|
|
4645
4686
|
cachedN > 0 && loadingN === 0 ? h('span', null, ' · ', h('button', { type: 'button', className: 'n2s-link', title: t('dash.cachedHint'), onClick: () => setTick(x => x + 1) }, fmt(t, 'dash.cachedN', { n: cachedN }))) : null,
|
|
@@ -4715,7 +4756,7 @@ window.__ModuleLoader__.load({
|
|
|
4715
4756
|
h('span', { className: 'n2s-wf-bar' }, h('span', { className: `n2s-wf-fill ${it.delta > 0 ? 'up' : it.delta < 0 ? 'down' : ''}`, style: { width: `${Math.max(2, Math.abs(it.delta) / max * 100)}%` } })),
|
|
4716
4757
|
h('span', { className: 'n2s-wf-num' }, `${it.delta >= 0 ? '+' : ''}${formatCompact(it.delta)}`),
|
|
4717
4758
|
h('span', { className: 'n2s-wf-share n2s-faint' }, it.share === null || Math.abs(it.share) > 3 ? '' : `${Math.round(it.share * 100)}%`)))),
|
|
4718
|
-
why.dimensions.filter(d => !d.attribution).length > 0 && h('div', { className: 'n2s-faint n2s-small' }, `${t('drill.failedDims')}
|
|
4759
|
+
why.dimensions.filter(d => !d.attribution).length > 0 && h('div', { className: 'n2s-faint n2s-small' }, `${colon(t('drill.failedDims'))}${why.dimensions.filter(d => !d.attribution).map(d => d.dimension).join(uiLang === 'zh' ? '、' : ', ')}`))
|
|
4719
4760
|
}
|
|
4720
4761
|
|
|
4721
4762
|
// ------------------------------------------------------------ alerts
|
|
@@ -4871,7 +4912,7 @@ window.__ModuleLoader__.load({
|
|
|
4871
4912
|
h('div', { className: 'n2s-row' }, h('strong', null, fmt(t, 'sc.title', { count: c.changes.length, time: new Date(c.at).toLocaleString([], { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) })), h('span', { className: 'n2s-grow' }),
|
|
4872
4913
|
h('button', { type: 'button', className: 'n2s-btn small', onClick: async () => { await call('ackSchemaChange', { id: c.id }); list.reload() } }, t('sc.ack'))),
|
|
4873
4914
|
h('div', { className: 'n2s-small', style: { marginTop: 4 } }, c.changes.slice(0, 8).map(x => `${kindLabel(x.kind)} ${x.table}${x.column ? `.${x.column}` : ''}${x.detail ? `(${x.detail})` : ''}`).join(';')),
|
|
4874
|
-
c.impacts.length > 0 && h('div', { className: 'n2s-small', style: { marginTop: 4 } }, h('strong', null, `${t('sc.impacts')}
|
|
4915
|
+
c.impacts.length > 0 && h('div', { className: 'n2s-small', style: { marginTop: 4 } }, h('strong', null, `${colon(t('sc.impacts'))}`), c.impacts.slice(0, 8).map(i => `${t(`sc.imp.${i.kind}`)}「${i.name}」`).join(uiLang === 'zh' ? '、' : ', ')))))
|
|
4875
4916
|
}
|
|
4876
4917
|
|
|
4877
4918
|
// -------------------------------------------------- knowledge io
|
|
@@ -4940,7 +4981,7 @@ window.__ModuleLoader__.load({
|
|
|
4940
4981
|
if (s.kind === 'example') return h('pre', { className: 'n2s-code', style: { marginTop: 6, maxHeight: 140 } }, prettySql(it.sql ?? ''))
|
|
4941
4982
|
if (s.kind === 'metric') return h('div', null,
|
|
4942
4983
|
h('div', { className: 'n2s-mono n2s-small' }, `${it.expression}${it.table ? ` FROM ${it.table}` : ''}${it.filter ? ` WHERE ${it.filter}` : ''}`),
|
|
4943
|
-
(it.timeColumn || it.dimensions?.length) && h('div', { className: 'n2s-muted n2s-small' }, [it.timeColumn && `${t('kb.timeColumn')}: ${it.timeColumn}`, it.dimensions?.length && `${t('kb.dimensions')}: ${it.dimensions.join('、')}`].filter(Boolean).join(' · ')))
|
|
4984
|
+
(it.timeColumn || it.dimensions?.length) && h('div', { className: 'n2s-muted n2s-small' }, [it.timeColumn && `${t('kb.timeColumn')}: ${it.timeColumn}`, it.dimensions?.length && `${t('kb.dimensions')}: ${it.dimensions.join(uiLang === 'zh' ? '、' : ', ')}`].filter(Boolean).join(' · ')))
|
|
4944
4985
|
if (s.kind === 'note') return h('div', { className: 'n2s-small' }, it.text)
|
|
4945
4986
|
return h('div', null, h('div', { className: 'n2s-small' }, it.definition), it.sql && h('code', { className: 'n2s-md-code', style: { display: 'inline-block', marginTop: 4 } }, it.sql))
|
|
4946
4987
|
}
|
|
@@ -5046,6 +5087,69 @@ window.__ModuleLoader__.load({
|
|
|
5046
5087
|
h('button', { type: 'button', className: 'n2s-btn small ghost', style: { justifySelf: 'start' }, onClick: () => onChange([...rows, { platform: rows[rows.length - 1]?.platform ?? platforms[0], id: '', email: '' }]) }, '+ ', t('mb.ids.add')))
|
|
5047
5088
|
}
|
|
5048
5089
|
|
|
5090
|
+
/**
|
|
5091
|
+
* One-click identity picks: people and groups that already talked to a bot
|
|
5092
|
+
* (recorded by the host, including refused ones), plus the groups a bot is
|
|
5093
|
+
* in (channels whose kind can list targets). No id lookup needed.
|
|
5094
|
+
*/
|
|
5095
|
+
function IdentityPicker({ t, kind, catalog, onPick, chosen }) {
|
|
5096
|
+
const seen = useAsync(() => call('listSeenIdentities', { kind }).catch(() => []), [kind])
|
|
5097
|
+
const channels = useChannels()
|
|
5098
|
+
const listable = channels.filter(c => (c.capabilities ?? findKind(catalog, c.type)?.kind?.capabilities)?.listTargets)
|
|
5099
|
+
const [groups, setGroups] = useState(null)
|
|
5100
|
+
const [loadingGroups, setLoadingGroups] = useState(false)
|
|
5101
|
+
const loadGroups = async () => {
|
|
5102
|
+
setLoadingGroups(true)
|
|
5103
|
+
const out = []
|
|
5104
|
+
for (const c of listable) {
|
|
5105
|
+
try { for (const g of await call('listChannelTargets', { id: c.id })) out.push({ platform: c.platform, kind: 'chat', id: String(g.id), name: g.name, via: c.name }) } catch (e) { notifyError(e) }
|
|
5106
|
+
}
|
|
5107
|
+
setGroups(out); setLoadingGroups(false)
|
|
5108
|
+
}
|
|
5109
|
+
useEffect(() => { if (kind === 'chat' && listable.length && groups === null) loadGroups() }, [kind, listable.length])
|
|
5110
|
+
const isChosen = (x) => chosen.some(r => r.platform === x.platform && r.id === x.id)
|
|
5111
|
+
const items = [...(seen.value ?? [])]
|
|
5112
|
+
if (kind === 'chat') for (const g of groups ?? []) if (!items.some(x => x.platform === g.platform && x.id === g.id)) items.push(g)
|
|
5113
|
+
const label = (x) => x.name || x.chatName || x.email || x.id
|
|
5114
|
+
return h('div', { className: 'n2s-field full n2s-idpicker' },
|
|
5115
|
+
h('div', { className: 'n2s-row' },
|
|
5116
|
+
h('span', { className: 'n2s-flabel' }, kind === 'chat' ? t('mb.pick.chats') : t('mb.pick.people')),
|
|
5117
|
+
h('span', { className: 'n2s-grow' }),
|
|
5118
|
+
kind === 'chat' && listable.length > 0 && h('button', { type: 'button', className: 'n2s-link n2s-small', disabled: loadingGroups, onClick: loadGroups }, loadingGroups ? h('span', { className: 'n2s-spin' }) : Icon.refresh(12), t('mb.pick.refresh'))),
|
|
5119
|
+
seen.loading && !seen.value ? h('div', { className: 'n2s-skel', style: { height: 34 } }) :
|
|
5120
|
+
items.length === 0
|
|
5121
|
+
? h('div', { className: 'n2s-faint n2s-small' }, kind === 'chat' ? t('mb.pick.noChats') : t('mb.pick.noPeople'))
|
|
5122
|
+
: h('div', { className: 'n2s-pickchips', role: 'list' }, items.slice(0, 30).map(x => {
|
|
5123
|
+
const taken = x.memberId && !isChosen(x)
|
|
5124
|
+
return h('button', {
|
|
5125
|
+
key: `${x.platform}:${x.id}`, type: 'button', role: 'listitem', className: 'n2s-pickchip', 'aria-pressed': isChosen(x), disabled: Boolean(taken),
|
|
5126
|
+
title: [`${platformName(t, x.platform, catalog)} · ${x.id}`, x.email, x.via ? fmt(t, 'mb.pick.via', { name: x.via }) : '', taken ? fmt(t, 'mb.pick.taken', { name: x.memberName }) : '', x.refused ? t('mb.pick.refused') : ''].filter(Boolean).join('\n'),
|
|
5127
|
+
onClick: () => onPick(x),
|
|
5128
|
+
},
|
|
5129
|
+
h(PlatformMark, { t, platform: x.platform, size: 16 }),
|
|
5130
|
+
h('span', { className: 'n2s-pickchip-name' }, label(x)),
|
|
5131
|
+
x.refused && !x.memberId && h('span', { className: 'n2s-badge warn' }, t('mb.pick.pending')),
|
|
5132
|
+
taken && h('span', { className: 'n2s-faint' }, t('mb.pick.added')),
|
|
5133
|
+
x.lastAt && h('span', { className: 'n2s-faint' }, shortTime(x.lastAt)))
|
|
5134
|
+
})),
|
|
5135
|
+
h('div', { className: 'n2s-faint n2s-small' }, kind === 'chat' ? t('mb.pick.chatsHint') : t('mb.pick.peopleHint')))
|
|
5136
|
+
}
|
|
5137
|
+
|
|
5138
|
+
/** People / groups refused by a bot because they are not members yet: add with one click. */
|
|
5139
|
+
function PendingMembers({ t, onAdd }) {
|
|
5140
|
+
const seen = useAsync(() => call('listSeenIdentities', {}).catch(() => []), [])
|
|
5141
|
+
const { catalog } = useCatalog(t)
|
|
5142
|
+
const pending = (seen.value ?? []).filter(x => x.refused && !x.memberId).slice(0, 6)
|
|
5143
|
+
if (!pending.length) return null
|
|
5144
|
+
return h('div', { className: 'n2s-notice warn n2s-pending', role: 'status', style: { marginBottom: 12 } },
|
|
5145
|
+
h('div', { style: { marginBottom: 6 } }, fmt(t, 'mb.pending.title', { n: pending.length })),
|
|
5146
|
+
h('div', { className: 'n2s-pickchips' }, pending.map(x => h('button', { key: `${x.platform}:${x.kind}:${x.id}`, type: 'button', className: 'n2s-pickchip', onClick: () => onAdd(x), title: `${platformName(t, x.platform, catalog)} · ${x.id}` },
|
|
5147
|
+
h(PlatformMark, { t, platform: x.platform, size: 16 }),
|
|
5148
|
+
h('span', { className: 'n2s-pickchip-name' }, x.name || x.chatName || x.id),
|
|
5149
|
+
h('span', { className: 'n2s-faint' }, x.kind === 'chat' ? t('mb.kind2.chat') : t('mb.kind2.person')),
|
|
5150
|
+
h('span', { className: 'n2s-link' }, t('mb.pending.add'))))))
|
|
5151
|
+
}
|
|
5152
|
+
|
|
5049
5153
|
function IdentityChips({ t, u, catalog }) {
|
|
5050
5154
|
const ids = identitiesOf(u)
|
|
5051
5155
|
if (!ids.length) return h('span', { className: 'n2s-badge warn' }, t('mb.ids.none'))
|
|
@@ -5053,11 +5157,11 @@ window.__ModuleLoader__.load({
|
|
|
5053
5157
|
h(PlatformMark, { t, platform: x.platform, size: 16 }), h('span', { className: 'n2s-mono' }, x.id || x.email))))
|
|
5054
5158
|
}
|
|
5055
5159
|
|
|
5056
|
-
function UserForm({ t, initial, datasources, onDone, onCancel }) {
|
|
5160
|
+
function UserForm({ t, initial, prefill, datasources, onDone, onCancel }) {
|
|
5057
5161
|
const { catalog } = useCatalog(t)
|
|
5058
5162
|
const [form, setForm] = useState(() => {
|
|
5059
|
-
const base = { kind: 'person', name: '', role: 'analyst', allDatasources: initial ? initial.datasourceIds === null : false, datasourceIds: [], note: '', ...(initial ?? {}) }
|
|
5060
|
-
const ids = identitiesOf(initial)
|
|
5163
|
+
const base = { kind: prefill?.kind ?? 'person', name: prefill ? (prefill.name || prefill.chatName || '') : '', role: 'analyst', allDatasources: initial ? initial.datasourceIds === null : false, datasourceIds: [], note: '', ...(initial ?? {}) }
|
|
5164
|
+
const ids = prefill ? [{ platform: prefill.platform, id: prefill.id, ...(prefill.kind === 'person' && prefill.email ? { email: prefill.email } : {}) }] : identitiesOf(initial)
|
|
5061
5165
|
return { ...base, identities: ids.length ? ids : [{ platform: 'lark', id: '', email: '' }] }
|
|
5062
5166
|
})
|
|
5063
5167
|
const [error, setError] = useState(null)
|
|
@@ -5089,6 +5193,11 @@ window.__ModuleLoader__.load({
|
|
|
5089
5193
|
h('div', { className: 'n2s-field full' }, h('span', { id: 'n2s-mb-kind' }, t('mb.kind')), h('div', { className: 'n2s-seg', role: 'group', 'aria-labelledby': 'n2s-mb-kind', style: { justifySelf: 'start' } }, ['person', 'chat'].map(v => h('button', { key: v, type: 'button', 'aria-pressed': form.kind === v, disabled: Boolean(initial?.id), onClick: () => setForm(f => ({ ...f, kind: v })) }, t(`mb.kind2.${v}`))))),
|
|
5090
5194
|
h('label', { className: 'n2s-field' }, h('span', null, t('im.name')), h('input', { className: 'n2s-input', value: form.name, onChange: set('name') })),
|
|
5091
5195
|
h('label', { className: 'n2s-field' }, h('span', null, t('mb.role')), h('select', { className: 'n2s-select', value: form.role, onChange: set('role') }, ['admin', 'analyst', 'viewer'].map(r => h('option', { key: r, value: r }, t(`mb.role.${r}`)))), h('span', { className: 'n2s-faint n2s-small' }, t(`mb.roleHint.${form.role}`))),
|
|
5196
|
+
!initial?.id && h(IdentityPicker, { t, kind: form.kind, catalog, chosen: form.identities, onPick: (x) => setForm(f => {
|
|
5197
|
+
const exists = f.identities.some(r => r.platform === x.platform && r.id === x.id)
|
|
5198
|
+
const rows = exists ? f.identities.filter(r => !(r.platform === x.platform && r.id === x.id)) : [...f.identities.filter(r => r.id || r.email), { platform: x.platform, id: x.id, ...(f.kind === 'person' && x.email ? { email: x.email } : {}) }]
|
|
5199
|
+
return { ...f, identities: rows.length ? rows : [{ platform: x.platform, id: '', email: '' }], name: f.name || (exists ? f.name : (x.name || x.chatName || '')) }
|
|
5200
|
+
}) }),
|
|
5092
5201
|
h(IdentityEditor, { t, kind: form.kind, rows: form.identities, catalog, errors: idErrors, onChange: (rows) => { setForm(f => ({ ...f, identities: rows })); setIdErrors({}) } }),
|
|
5093
5202
|
h('div', { className: 'n2s-field full' }, h('span', null, t('mb.scope')),
|
|
5094
5203
|
h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: form.allDatasources, onChange: set('allDatasources') }), t('mb.allDs')),
|
|
@@ -5179,7 +5288,8 @@ window.__ModuleLoader__.load({
|
|
|
5179
5288
|
return h('div', null,
|
|
5180
5289
|
h(SectionHead, { title: t('tab.members'), desc: t('mb.hint') }, h('button', { type: 'button', className: 'n2s-btn primary', onClick: () => setEditing({}) }, '+ ', t('mb.add'))),
|
|
5181
5290
|
h(BotsOverview, { t }),
|
|
5182
|
-
editing && h(
|
|
5291
|
+
!editing && h(PendingMembers, { t, onAdd: (x) => setEditing({ prefill: x }) }),
|
|
5292
|
+
editing && h(UserForm, { key: editing.id ?? (editing.prefill ? `p:${editing.prefill.id}` : 'new'), t, datasources, prefill: editing.prefill, initial: editing.id ? editing : null, onCancel: () => setEditing(null), onDone: () => { setEditing(null); users.reload() } }),
|
|
5183
5293
|
settings.value && h('div', { className: 'n2s-setnote', style: { marginBottom: 10 } },
|
|
5184
5294
|
t('mb.default'), ' ', h('strong', null, t(`mb.defaultRole.${settings.value.botDefaultRole ?? settings.value.larkDefaultRole ?? 'none'}`)), ' · ',
|
|
5185
5295
|
h('button', { type: 'button', className: 'n2s-link', onClick: () => fbCtx.navigate?.({ tab: 'admin', section: 'settings' }) }, t('set.changeInSettings'))),
|
|
@@ -5191,7 +5301,7 @@ window.__ModuleLoader__.load({
|
|
|
5191
5301
|
h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
|
|
5192
5302
|
h('div', { className: 'n2s-row', style: { gap: 6 } }, h('span', { className: 'n2s-list2-title' }, u.name), h('span', { className: 'n2s-badge' }, t(`mb.role.${u.role}`)), u.disabled && h('span', { className: 'n2s-badge err' }, t('mb.disabled'))),
|
|
5193
5303
|
h('div', { className: 'n2s-row', style: { gap: 6, marginTop: 4 } }, h(IdentityChips, { t, u, catalog }), h('span', { className: 'n2s-muted n2s-small' }, u.datasourceIds === null ? t('mb.allDs') : (u.datasourceNames ?? []).join(uiLang === 'zh' ? '、' : ', ') || t('mb.noDs'))),
|
|
5194
|
-
(u.rowRules ?? []).length > 0 && h('div', { className: 'n2s-list2-sub' }, `${t('mb.rowRules')}
|
|
5304
|
+
(u.rowRules ?? []).length > 0 && h('div', { className: 'n2s-list2-sub' }, `${colon(t('mb.rowRules'))}${u.rowRules.map(r => `${r.column} ∈ {${r.values.join(', ')}}`).join(';')}`)),
|
|
5195
5305
|
h('div', { className: 'n2s-list2-side' }, h('span', { className: 'n2s-small' }, us ? fmt(t, 'mb.queries', { n: us.queries }) : t('mb.noUsage')), us?.lastAt && h('span', { className: 'n2s-faint n2s-small' }, new Date(us.lastAt).toLocaleDateString())),
|
|
5196
5306
|
h(Dropdown, { label: fmt(t, 'ch.actionsFor', { name: u.name }), trigger: { content: Icon.more() }, items: [
|
|
5197
5307
|
{ label: t('ds.edit'), icon: Icon.edit(14), onClick: () => setEditing(u) },
|
|
@@ -5759,7 +5869,7 @@ window.__ModuleLoader__.load({
|
|
|
5759
5869
|
h('table', { className: 'n2s-table' }, h('thead', null, h('tr', null, [t('set.section'), t('set.toAdd'), t('set.toUpdate'), t('set.note')].map((x, i) => h('th', { key: i }, x)))),
|
|
5760
5870
|
h('tbody', null, Object.entries(importState.dry?.report ?? {}).map(([k, r]) => h('tr', { key: k },
|
|
5761
5871
|
h('td', null, t(`set.sec.${k}`)), h('td', null, r.add ?? r.added ?? 0), h('td', null, r.update ?? r.updated ?? 0),
|
|
5762
|
-
h('td', { className: 'n2s-muted n2s-small', style: { whiteSpace: 'normal' } }, [r.needsSecret?.length ? fmt(t, 'set.needsSecret', { names: r.needsSecret.join('、') }) : '', r.paused ? t('set.reportsPaused') : '', r.skipped ? fmt(t, 'set.skipped', { n: r.skipped }) : ''].filter(Boolean).join(';')))))),
|
|
5872
|
+
h('td', { className: 'n2s-muted n2s-small', style: { whiteSpace: 'normal' } }, [r.needsSecret?.length ? fmt(t, 'set.needsSecret', { names: r.needsSecret.join(uiLang === 'zh' ? '、' : ', ') }) : '', r.paused ? t('set.reportsPaused') : '', r.skipped ? fmt(t, 'set.skipped', { n: r.skipped }) : ''].filter(Boolean).join(';')))))),
|
|
5763
5873
|
h('div', { className: 'n2s-row', style: { marginTop: 14, justifyContent: 'flex-end' } },
|
|
5764
5874
|
h('button', { type: 'button', className: 'n2s-btn ghost', onClick: () => setImportState(null) }, t('ds.cancel')),
|
|
5765
5875
|
h('button', { type: 'button', className: 'n2s-btn danger', onClick: () => doImport('replace') }, t('kb.replace')),
|
|
@@ -5775,7 +5885,7 @@ window.__ModuleLoader__.load({
|
|
|
5775
5885
|
row('dataDir', v?.dataDir, true),
|
|
5776
5886
|
row('runtime', v ? `Node ${v.nodeVersion} · SQLite ${v.sqliteVersion}` : null),
|
|
5777
5887
|
row('tests', v?.testsCommand, true),
|
|
5778
|
-
row('docs', '
|
|
5888
|
+
row('docs', 'github.com/hedeqiang/dsh-nl2sql'))))
|
|
5779
5889
|
}
|
|
5780
5890
|
|
|
5781
5891
|
// ------------------------------------------------------------ apply
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hedeqiang/dsh-nl2sql",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.12.1",
|
|
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
6
|
"exports": {
|
|
7
7
|
".": "./index.js",
|
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
"locale/*.json",
|
|
30
30
|
"icon.svg",
|
|
31
31
|
"README.md",
|
|
32
|
-
"README.en.md",
|
|
33
32
|
"LICENSE",
|
|
34
33
|
"CHANGELOG.md",
|
|
35
|
-
"
|
|
34
|
+
"README.zh-CN.md",
|
|
35
|
+
"docs/channels.md"
|
|
36
36
|
],
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@larksuiteoapi/node-sdk": "1.76.0",
|
|
@@ -93,5 +93,9 @@
|
|
|
93
93
|
},
|
|
94
94
|
"publishConfig": {
|
|
95
95
|
"access": "public"
|
|
96
|
+
},
|
|
97
|
+
"homepage": "https://github.com/hedeqiang/dsh-nl2sql#readme",
|
|
98
|
+
"bugs": {
|
|
99
|
+
"url": "https://github.com/hedeqiang/dsh-nl2sql/issues"
|
|
96
100
|
}
|
|
97
101
|
}
|
package/service/access.js
CHANGED
|
@@ -99,7 +99,7 @@ export const methods = {
|
|
|
99
99
|
/**
|
|
100
100
|
* Members: people and group chats on IM platforms, each with a role
|
|
101
101
|
* (admin | analyst | viewer), a data source scope and platform-scoped
|
|
102
|
-
* identities [{ platform, id, email? }] (see docs/
|
|
102
|
+
* identities [{ platform, id, email? }] (see docs/channels.md). The local
|
|
103
103
|
* owner is implicit. Bot messages from unknown people fall back to
|
|
104
104
|
* settings.botDefaultRole (none = refuse).
|
|
105
105
|
*/
|
|
@@ -212,6 +212,43 @@ export const methods = {
|
|
|
212
212
|
}
|
|
213
213
|
},
|
|
214
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Remember an IM identity that talked to a bot (refused or not), so admins can
|
|
217
|
+
* add it as a member with one click instead of looking up ids. Bounded list,
|
|
218
|
+
* most recent first; keyed by (platform, kind, id).
|
|
219
|
+
*/
|
|
220
|
+
noteSeenIdentity({ platform, kind, id, name, email, chatName, channelId, refused }) {
|
|
221
|
+
if (!platform || !id) return
|
|
222
|
+
this.state.seenIdentities ??= []
|
|
223
|
+
const list = this.state.seenIdentities
|
|
224
|
+
const key = `${platform}:${kind}:${id}`
|
|
225
|
+
const i = list.findIndex(x => `${x.platform}:${x.kind}:${x.id}` === key)
|
|
226
|
+
const prev = i >= 0 ? list.splice(i, 1)[0] : {}
|
|
227
|
+
list.unshift({ ...prev, platform, kind, id: String(id), ...(name ? { name: String(name).slice(0, 60) } : {}), ...(email ? { email: String(email).slice(0, 200) } : {}), ...(chatName ? { chatName: String(chatName).slice(0, 80) } : {}), ...(channelId ? { channelId } : {}), lastAt: Date.now(), count: (prev.count ?? 0) + 1, refused: Boolean(refused) })
|
|
228
|
+
if (list.length > 200) list.length = 200
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* People and group chats that talked to a bot recently, for the member form:
|
|
233
|
+
* [{ platform, kind:'person'|'chat', id, name?, email?, chatName?, lastAt, count, refused, memberId? }].
|
|
234
|
+
* `memberId` is set when the identity already belongs to a member.
|
|
235
|
+
*/
|
|
236
|
+
listSeenIdentities({ kind, platform } = {}) {
|
|
237
|
+
const users = (this.state.users ?? []).map(u => normalizeUserRecord(u))
|
|
238
|
+
// Group chats seen in bot conversations count too (older data has no seen list).
|
|
239
|
+
const seen = [...(this.state.seenIdentities ?? [])]
|
|
240
|
+
for (const c of Object.values(this.state.botConversations ?? {})) {
|
|
241
|
+
if (c.chatType === 'group' && c.chatId && !seen.some(x => x.kind === 'chat' && x.platform === c.platform && x.id === String(c.chatId))) seen.push({ platform: c.platform ?? 'lark', kind: 'chat', id: String(c.chatId), chatName: c.chatName, name: c.chatName, lastAt: c.lastAt ?? c.createdAt, count: 1, refused: false })
|
|
242
|
+
}
|
|
243
|
+
return seen
|
|
244
|
+
.filter(x => (!kind || x.kind === kind) && (!platform || x.platform === platform))
|
|
245
|
+
.map(x => {
|
|
246
|
+
const owner = users.find(u => u.kind === x.kind && u.identities.some(i => i.platform === x.platform && i.id === x.id))
|
|
247
|
+
return { ...x, ...(owner ? { memberId: owner.id, memberName: owner.name } : {}) }
|
|
248
|
+
})
|
|
249
|
+
.sort((a, b) => (b.lastAt ?? 0) - (a.lastAt ?? 0))
|
|
250
|
+
},
|
|
251
|
+
|
|
215
252
|
/** Back-compat alias of resolveActor for Lark. */
|
|
216
253
|
resolveLarkActor({ openId, email, name, chatId, chatType }) {
|
|
217
254
|
return this.resolveActor({ platform: 'lark', userId: openId, email, name, chatId, chatType })
|
package/service/bots.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Nl2SqlService methods: inbound IM bots (see docs/
|
|
2
|
+
* Nl2SqlService methods: inbound IM bots (see docs/channels.md).
|
|
3
3
|
* Mixed into the service prototype by index.js; `this` is the service.
|
|
4
4
|
*
|
|
5
5
|
* Every channel whose kind has `capabilities.bot` may carry
|
|
@@ -279,10 +279,15 @@ export const methods = {
|
|
|
279
279
|
const name = label(platform)
|
|
280
280
|
const userId = m.sender?.id != null ? String(m.sender.id) : undefined
|
|
281
281
|
const resolved = this.resolveActor({ platform, userId, email: m.sender?.email, name: m.sender?.name, chatId: m.chatId != null ? String(m.chatId) : m.chatId, chatType: m.chatType })
|
|
282
|
-
|
|
282
|
+
// Remember who/which group talked to the bot: the member form offers them as one-click picks.
|
|
283
|
+
let chatName
|
|
284
|
+
if (m.chatType === 'group') { try { chatName = typeof m.chatName === 'function' ? await m.chatName() : m.chatName } catch {} }
|
|
285
|
+
if (userId) this.noteSeenIdentity({ platform, kind: 'person', id: userId, name: m.sender?.name, email: m.sender?.email, channelId: m.channelId, refused: Boolean(resolved.refused) })
|
|
286
|
+
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
|
+
if (resolved.refused) { await this.save(); await reply(resolved.refused); return }
|
|
283
288
|
const actor = resolved.actor
|
|
284
289
|
// Commands: 帮助 / 数据源 / 切换 <名称> / 新对话
|
|
285
|
-
const cmd = text.match(/^\/?(帮助|help
|
|
290
|
+
const cmd = text.match(/^\/?(帮助|help|数据源|切换数据源|切换|自动|auto|新对话|reset|start)\s*(.*)$/i)
|
|
286
291
|
// Members with different scopes must not share a conversation: per-user sessions
|
|
287
292
|
// whenever the sender's effective scope or rules differ from the chat's.
|
|
288
293
|
const scopeSig = JSON.stringify([actor.datasourceIds, actor.role, actor.rowRules ?? []])
|
|
@@ -292,23 +297,35 @@ export const methods = {
|
|
|
292
297
|
this.state.botConversations ??= {}
|
|
293
298
|
const convs = this.state.botConversations
|
|
294
299
|
const conv = convs[key] ?? { key, channelId: m.channelId ?? null, platform, chatId: m.chatId, chatType: m.chatType, sessionId: null, datasourceId: actor.defaultDatasourceId, createdAt: Date.now() }
|
|
300
|
+
if (chatName && !conv.chatName) conv.chatName = chatName
|
|
295
301
|
const allowedDs = this.state.datasources.filter(d => !Array.isArray(actor.datasourceIds) || actor.datasourceIds.includes(d.id))
|
|
296
302
|
if (conv.datasourceId && !allowedDs.some(d => d.id === conv.datasourceId)) conv.datasourceId = allowedDs[0]?.id ?? null
|
|
297
303
|
if (cmd) {
|
|
298
304
|
const verb = cmd[1].toLowerCase()
|
|
299
|
-
if (verb === '帮助' || verb === 'help' || verb === 'start') { await reply(`直接${m.chatType === 'group' ? ' @我 ' : ''}提问即可,例如「上周每天的订单量」。\n命令:数据源(查看可用数据源)、切换
|
|
300
|
-
if (verb === '数据源') { await reply(
|
|
305
|
+
if (verb === '帮助' || verb === 'help' || verb === 'start') { await reply(`直接${m.chatType === 'group' ? ' @我 ' : ''}提问即可,例如「上周每天的订单量」。\n命令:数据源(查看可用数据源)、切换 <名称>、自动(按问题自动选择数据源)、新对话。\n当前数据源:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}${conv.datasourcePinned ? '(已指定)' : allowedDs.length > 1 ? '(按问题自动选择)' : ''}${actor.role === 'viewer' ? '\n你是只读成员:可以查询指标库中的指标、已验证的常见问题和看板。' : ''}`); return }
|
|
306
|
+
if (verb === '数据源') { 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 }
|
|
307
|
+
if (verb === '自动' || verb === 'auto') { delete conv.datasourcePinned; convs[key] = conv; await this.save(); await reply(`已改为按问题自动选择数据源(可用:${allowedDs.map(d => d.name).join('、')})。`); return }
|
|
301
308
|
if (verb === '新对话' || verb === 'reset') { conv.sessionId = null; convs[key] = conv; await this.save(); await reply('已开始新对话。'); return }
|
|
302
|
-
const
|
|
309
|
+
const want = cmd[2].trim().toLowerCase()
|
|
310
|
+
const target = allowedDs.find(d => d.name.toLowerCase() === want || d.id === cmd[2].trim()) ?? (want ? allowedDs.filter(d => d.name.toLowerCase().includes(want) || (d.database ?? '').toLowerCase() === want) : []).find((_, __, arr) => arr.length === 1)
|
|
303
311
|
if (!target) { await reply(`找不到可用的数据源「${cmd[2]}」。可用:${allowedDs.map(d => d.name).join('、')}`); return }
|
|
304
312
|
conv.datasourceId = target.id
|
|
313
|
+
conv.datasourcePinned = true
|
|
305
314
|
if (conv.sessionId) { this.state.sessions[conv.sessionId] = target.id }
|
|
306
315
|
convs[key] = conv
|
|
307
316
|
await this.save()
|
|
308
|
-
await reply(`已切换到数据源「${target.name}
|
|
317
|
+
await reply(`已切换到数据源「${target.name}」。之后的提问都会查询这个数据源;发送「自动」恢复按问题自动选择。`)
|
|
309
318
|
return
|
|
310
319
|
}
|
|
311
|
-
|
|
320
|
+
// Which data source answers: an explicit 切换 wins; otherwise the question picks the most
|
|
321
|
+
// relevant one among those the member may use (schema + business knowledge), so people
|
|
322
|
+
// don't need to know data source names. The agent can still switch with nl2sql_datasource.
|
|
323
|
+
let ds = this.findDatasource(conv.datasourceId) ?? allowedDs[0]
|
|
324
|
+
let routed = null
|
|
325
|
+
if (!conv.datasourcePinned && allowedDs.length > 1) {
|
|
326
|
+
routed = await this.routeDatasource(text, allowedDs).catch(() => null)
|
|
327
|
+
if (routed?.ds) ds = routed.ds
|
|
328
|
+
}
|
|
312
329
|
if (!ds) { await reply('没有可访问的数据源,请联系管理员。'); return }
|
|
313
330
|
this.assertAccess(actor, ds)
|
|
314
331
|
if (!this.ctx.get('sessionController')) { await reply('当前 Harness 不支持创建会话(缺少 sessionController)。'); return }
|
|
@@ -338,9 +355,17 @@ export const methods = {
|
|
|
338
355
|
await this.save()
|
|
339
356
|
}
|
|
340
357
|
const where = m.chatType === 'group' ? `群「${conv.chatName ?? m.chatId}」` : '私聊'
|
|
341
|
-
const
|
|
358
|
+
const dsLine = allowedDs.length > 1
|
|
359
|
+
? (conv.datasourcePinned
|
|
360
|
+
? `数据源:${ds.name}(群内已用「切换」指定,请只查询这个数据源)。`
|
|
361
|
+
: `当前数据源:${ds.name}${routed?.ds ? '(按问题自动选择)' : ''}。该成员可用的数据源:\n${await this.datasourceCatalog(allowedDs)}\n请先判断问题属于哪个数据源:若不是当前数据源,先用 nl2sql_datasource action=use 切换再查询;在回答开头注明使用的数据源。`)
|
|
362
|
+
: `数据源:${ds.name}。`
|
|
363
|
+
const prompt = `[${name}${where} · ${actor.name}${actor.role === 'viewer' ? ' · 只读成员' : ''}] ${text}\n\n(${dsLine}\n回答会以${name}消息发回该聊天:请用简洁的中文 Markdown 写结论,必要时附上查询结果;不要调用 nl2sql_send,也不要向用户提问——有歧义时说明你的假设后直接回答。${actor.role === 'viewer' ? '该成员是只读角色:只能用 nl2sql_metric 查询指标库,或复用 nl2sql_knowledge 中已验证的样例 SQL;不能自由编写 SQL。' : ''})`
|
|
342
364
|
const requestId = platform === 'lark' ? `lark-${m.messageId}` : `${platform}-${m.chatId}-${m.messageId}`
|
|
365
|
+
if (conv.sessionId && this.state.sessions[conv.sessionId] !== ds.id) { this.state.sessions[conv.sessionId] = ds.id; await this.save() }
|
|
343
366
|
const turn = await this.runAgentTurn({ sessionId: conv.sessionId, title: `${name} · ${conv.chatName ?? actor.name}`, requestId, text: prompt, prepare, timeoutMs: 600000 })
|
|
367
|
+
const usedDs = this.findDatasource(this.state.sessions[conv.sessionId]) ?? ds
|
|
368
|
+
if (usedDs.id !== conv.datasourceId) { conv.datasourceId = usedDs.id; convs[key] = conv }
|
|
344
369
|
let body = turn.text
|
|
345
370
|
if (turn.reason === 'timeout') body = `${body ? `${body}\n\n` : ''}<font color='grey'>(查询超过 10 分钟仍未完成,已停止。请缩小范围后重试。)</font>`
|
|
346
371
|
if (turn.reason === 'input-required') body = `${body ? `${body}\n\n` : ''}<font color='grey'>(需要在 Harness 中确认后才能继续,已取消本次回答。)</font>`
|
|
@@ -350,7 +375,7 @@ export const methods = {
|
|
|
350
375
|
let status = turn.reason === 'idle' ? 'ok' : 'error'
|
|
351
376
|
let error = turn.reason === 'idle' ? undefined : turn.reason
|
|
352
377
|
try {
|
|
353
|
-
await m.reply({ title: text.length > 40 ? `${text.slice(0, 40)}…` : text, text: body || '(没有生成文字回答)', results: shown, footer: `数据问答 · ${
|
|
378
|
+
await m.reply({ title: text.length > 40 ? `${text.slice(0, 40)}…` : text, text: body || '(没有生成文字回答)', results: shown, footer: `数据问答 · ${usedDs.name} · ${actor.name}` })
|
|
354
379
|
} catch (e) {
|
|
355
380
|
status = 'error'
|
|
356
381
|
error = e.message
|
|
@@ -362,6 +387,48 @@ export const methods = {
|
|
|
362
387
|
})
|
|
363
388
|
},
|
|
364
389
|
|
|
390
|
+
/**
|
|
391
|
+
* Pick the data source a question is about, among `candidates`, only on
|
|
392
|
+
* strong evidence: the question names the source (name / database /
|
|
393
|
+
* description), or it matches that source's business knowledge (verified
|
|
394
|
+
* examples, metrics, glossary). Schema keyword overlap alone is too weak
|
|
395
|
+
* (English table names vs Chinese questions), so otherwise returns null and
|
|
396
|
+
* the agent chooses from the catalog in the prompt.
|
|
397
|
+
*/
|
|
398
|
+
async routeDatasource(question, candidates) {
|
|
399
|
+
const q = String(question ?? '').toLowerCase()
|
|
400
|
+
const scored = candidates.map(d => {
|
|
401
|
+
const named = [d.name, d.database, d.description].filter(x => x && String(x).length >= 2).some(x => q.includes(String(x).toLowerCase()))
|
|
402
|
+
let know = 0
|
|
403
|
+
try {
|
|
404
|
+
const f = this.searchKnowledge(d.id, question, 5)
|
|
405
|
+
know = Math.max(0, ...f.examples.map(x => x.score), ...f.metrics.map(x => x.score), ...f.glossary.map(x => x.score))
|
|
406
|
+
} catch {}
|
|
407
|
+
return { ds: d, score: (named ? 2 : 0) + know }
|
|
408
|
+
}).sort((a, b) => b.score - a.score)
|
|
409
|
+
const [top, second] = scored
|
|
410
|
+
if (!top || top.score < 0.5) return null
|
|
411
|
+
return top.score - (second?.score ?? 0) >= 0.3 ? { ds: top.ds, score: top.score } : null
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
/** One line per data source for the bot prompt: name, type, description and main tables. */
|
|
415
|
+
async datasourceCatalog(list) {
|
|
416
|
+
const lines = []
|
|
417
|
+
for (const d of list) {
|
|
418
|
+
let tables = ''
|
|
419
|
+
try {
|
|
420
|
+
const schema = this.schemas.get(d.id) ?? await this.readSchemaFile(d.id)
|
|
421
|
+
if (schema?.tables?.length) {
|
|
422
|
+
const rank = { fact: 0, dimension: 1, view: 2, other: 3, log: 4, config: 5 }
|
|
423
|
+
const top = [...schema.tables].sort((a, b) => (rank[a.role] ?? 3) - (rank[b.role] ?? 3) || (b.rows ?? 0) - (a.rows ?? 0)).slice(0, 12)
|
|
424
|
+
tables = `;主要表:${top.map(t => `${t.name}${t.comment ? `(${String(t.comment).split(/[\s,,。;;((]/)[0].slice(0, 12)})` : ''}`).join(', ')}${schema.tables.length > 12 ? ` 等 ${schema.tables.length} 张` : ''}`
|
|
425
|
+
}
|
|
426
|
+
} catch {}
|
|
427
|
+
lines.push(`- ${d.name}(${d.type ?? 'mysql'}${d.database ? ` · ${d.database}` : ''})${d.description ? `:${d.description.slice(0, 60)}` : ''}${tables}`)
|
|
428
|
+
}
|
|
429
|
+
return lines.join('\n')
|
|
430
|
+
},
|
|
431
|
+
|
|
365
432
|
/** Run tasks with the same key one after another. */
|
|
366
433
|
serialize(key, task) {
|
|
367
434
|
this.queues ??= new Map()
|
package/service/channels.js
CHANGED
package/service/suggestions.js
CHANGED
|
@@ -14,7 +14,7 @@ import { admitSql, lexSql } from '../sql-guard.js'
|
|
|
14
14
|
import { textGrams } from './schema.js'
|
|
15
15
|
import { SENSITIVE_NAME, UserError, hashText } from './shared.js'
|
|
16
16
|
|
|
17
|
-
/** Thresholds (see
|
|
17
|
+
/** Thresholds (see docs/development.md). */
|
|
18
18
|
export const SUGGEST = {
|
|
19
19
|
shapeMin: 2, // agent queries with the same literal-free shape
|
|
20
20
|
metricMin: 3, // distinct successful queries using the same aggregate + filter on one table
|