@hedeqiang/dsh-nl2sql 0.11.2 → 0.12.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 +63 -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 +123 -14
- 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",
|
|
@@ -1942,6 +1970,14 @@ window.__ModuleLoader__.load({
|
|
|
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,
|
|
@@ -3491,7 +3531,7 @@ window.__ModuleLoader__.load({
|
|
|
3491
3531
|
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
3532
|
h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
|
|
3493
3533
|
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')}`)),
|
|
3534
|
+
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
3535
|
h('div', { className: 'n2s-list2-side' },
|
|
3496
3536
|
statusDot(r.status),
|
|
3497
3537
|
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 +3709,7 @@ window.__ModuleLoader__.load({
|
|
|
3669
3709
|
|
|
3670
3710
|
// ------------------------------------------- channels: catalog & shapes
|
|
3671
3711
|
// The host may be older (flat Lark-only channels, no channelCatalog /
|
|
3672
|
-
// botStatus RPCs) or newer (docs/
|
|
3712
|
+
// botStatus RPCs) or newer (docs/channels.md). Everything below normalizes
|
|
3673
3713
|
// both so the pages only deal with { platform, type, config, secretsSet,
|
|
3674
3714
|
// capabilities, bot }.
|
|
3675
3715
|
|
|
@@ -4639,7 +4679,7 @@ window.__ModuleLoader__.load({
|
|
|
4639
4679
|
h('div', { className: 'n2s-h2', style: { margin: 0 } }, fmt(t, 'dash.count', { count: widgets.length })),
|
|
4640
4680
|
(mixed || hiddenByScope > 0 || (ds && sourceIds.some(id => id !== ds.id))) && h('div', { className: 'n2s-seg small', role: 'group', 'aria-label': t('dash.scope') },
|
|
4641
4681
|
['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('、') })),
|
|
4682
|
+
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
4683
|
h('div', { className: 'n2s-faint n2s-small', role: 'status', 'aria-live': 'polite' },
|
|
4644
4684
|
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
4685
|
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 +4755,7 @@ window.__ModuleLoader__.load({
|
|
|
4715
4755
|
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
4756
|
h('span', { className: 'n2s-wf-num' }, `${it.delta >= 0 ? '+' : ''}${formatCompact(it.delta)}`),
|
|
4717
4757
|
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')}
|
|
4758
|
+
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
4759
|
}
|
|
4720
4760
|
|
|
4721
4761
|
// ------------------------------------------------------------ alerts
|
|
@@ -4871,7 +4911,7 @@ window.__ModuleLoader__.load({
|
|
|
4871
4911
|
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
4912
|
h('button', { type: 'button', className: 'n2s-btn small', onClick: async () => { await call('ackSchemaChange', { id: c.id }); list.reload() } }, t('sc.ack'))),
|
|
4873
4913
|
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')}
|
|
4914
|
+
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
4915
|
}
|
|
4876
4916
|
|
|
4877
4917
|
// -------------------------------------------------- knowledge io
|
|
@@ -4940,7 +4980,7 @@ window.__ModuleLoader__.load({
|
|
|
4940
4980
|
if (s.kind === 'example') return h('pre', { className: 'n2s-code', style: { marginTop: 6, maxHeight: 140 } }, prettySql(it.sql ?? ''))
|
|
4941
4981
|
if (s.kind === 'metric') return h('div', null,
|
|
4942
4982
|
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(' · ')))
|
|
4983
|
+
(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
4984
|
if (s.kind === 'note') return h('div', { className: 'n2s-small' }, it.text)
|
|
4945
4985
|
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
4986
|
}
|
|
@@ -5046,6 +5086,69 @@ window.__ModuleLoader__.load({
|
|
|
5046
5086
|
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
5087
|
}
|
|
5048
5088
|
|
|
5089
|
+
/**
|
|
5090
|
+
* One-click identity picks: people and groups that already talked to a bot
|
|
5091
|
+
* (recorded by the host, including refused ones), plus the groups a bot is
|
|
5092
|
+
* in (channels whose kind can list targets). No id lookup needed.
|
|
5093
|
+
*/
|
|
5094
|
+
function IdentityPicker({ t, kind, catalog, onPick, chosen }) {
|
|
5095
|
+
const seen = useAsync(() => call('listSeenIdentities', { kind }).catch(() => []), [kind])
|
|
5096
|
+
const channels = useChannels()
|
|
5097
|
+
const listable = channels.filter(c => (c.capabilities ?? findKind(catalog, c.type)?.kind?.capabilities)?.listTargets)
|
|
5098
|
+
const [groups, setGroups] = useState(null)
|
|
5099
|
+
const [loadingGroups, setLoadingGroups] = useState(false)
|
|
5100
|
+
const loadGroups = async () => {
|
|
5101
|
+
setLoadingGroups(true)
|
|
5102
|
+
const out = []
|
|
5103
|
+
for (const c of listable) {
|
|
5104
|
+
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) }
|
|
5105
|
+
}
|
|
5106
|
+
setGroups(out); setLoadingGroups(false)
|
|
5107
|
+
}
|
|
5108
|
+
useEffect(() => { if (kind === 'chat' && listable.length && groups === null) loadGroups() }, [kind, listable.length])
|
|
5109
|
+
const isChosen = (x) => chosen.some(r => r.platform === x.platform && r.id === x.id)
|
|
5110
|
+
const items = [...(seen.value ?? [])]
|
|
5111
|
+
if (kind === 'chat') for (const g of groups ?? []) if (!items.some(x => x.platform === g.platform && x.id === g.id)) items.push(g)
|
|
5112
|
+
const label = (x) => x.name || x.chatName || x.email || x.id
|
|
5113
|
+
return h('div', { className: 'n2s-field full n2s-idpicker' },
|
|
5114
|
+
h('div', { className: 'n2s-row' },
|
|
5115
|
+
h('span', { className: 'n2s-flabel' }, kind === 'chat' ? t('mb.pick.chats') : t('mb.pick.people')),
|
|
5116
|
+
h('span', { className: 'n2s-grow' }),
|
|
5117
|
+
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'))),
|
|
5118
|
+
seen.loading && !seen.value ? h('div', { className: 'n2s-skel', style: { height: 34 } }) :
|
|
5119
|
+
items.length === 0
|
|
5120
|
+
? h('div', { className: 'n2s-faint n2s-small' }, kind === 'chat' ? t('mb.pick.noChats') : t('mb.pick.noPeople'))
|
|
5121
|
+
: h('div', { className: 'n2s-pickchips', role: 'list' }, items.slice(0, 30).map(x => {
|
|
5122
|
+
const taken = x.memberId && !isChosen(x)
|
|
5123
|
+
return h('button', {
|
|
5124
|
+
key: `${x.platform}:${x.id}`, type: 'button', role: 'listitem', className: 'n2s-pickchip', 'aria-pressed': isChosen(x), disabled: Boolean(taken),
|
|
5125
|
+
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'),
|
|
5126
|
+
onClick: () => onPick(x),
|
|
5127
|
+
},
|
|
5128
|
+
h(PlatformMark, { t, platform: x.platform, size: 16 }),
|
|
5129
|
+
h('span', { className: 'n2s-pickchip-name' }, label(x)),
|
|
5130
|
+
x.refused && !x.memberId && h('span', { className: 'n2s-badge warn' }, t('mb.pick.pending')),
|
|
5131
|
+
taken && h('span', { className: 'n2s-faint' }, t('mb.pick.added')),
|
|
5132
|
+
x.lastAt && h('span', { className: 'n2s-faint' }, shortTime(x.lastAt)))
|
|
5133
|
+
})),
|
|
5134
|
+
h('div', { className: 'n2s-faint n2s-small' }, kind === 'chat' ? t('mb.pick.chatsHint') : t('mb.pick.peopleHint')))
|
|
5135
|
+
}
|
|
5136
|
+
|
|
5137
|
+
/** People / groups refused by a bot because they are not members yet: add with one click. */
|
|
5138
|
+
function PendingMembers({ t, onAdd }) {
|
|
5139
|
+
const seen = useAsync(() => call('listSeenIdentities', {}).catch(() => []), [])
|
|
5140
|
+
const { catalog } = useCatalog(t)
|
|
5141
|
+
const pending = (seen.value ?? []).filter(x => x.refused && !x.memberId).slice(0, 6)
|
|
5142
|
+
if (!pending.length) return null
|
|
5143
|
+
return h('div', { className: 'n2s-notice warn n2s-pending', role: 'status', style: { marginBottom: 12 } },
|
|
5144
|
+
h('div', { style: { marginBottom: 6 } }, fmt(t, 'mb.pending.title', { n: pending.length })),
|
|
5145
|
+
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}` },
|
|
5146
|
+
h(PlatformMark, { t, platform: x.platform, size: 16 }),
|
|
5147
|
+
h('span', { className: 'n2s-pickchip-name' }, x.name || x.chatName || x.id),
|
|
5148
|
+
h('span', { className: 'n2s-faint' }, x.kind === 'chat' ? t('mb.kind2.chat') : t('mb.kind2.person')),
|
|
5149
|
+
h('span', { className: 'n2s-link' }, t('mb.pending.add'))))))
|
|
5150
|
+
}
|
|
5151
|
+
|
|
5049
5152
|
function IdentityChips({ t, u, catalog }) {
|
|
5050
5153
|
const ids = identitiesOf(u)
|
|
5051
5154
|
if (!ids.length) return h('span', { className: 'n2s-badge warn' }, t('mb.ids.none'))
|
|
@@ -5053,11 +5156,11 @@ window.__ModuleLoader__.load({
|
|
|
5053
5156
|
h(PlatformMark, { t, platform: x.platform, size: 16 }), h('span', { className: 'n2s-mono' }, x.id || x.email))))
|
|
5054
5157
|
}
|
|
5055
5158
|
|
|
5056
|
-
function UserForm({ t, initial, datasources, onDone, onCancel }) {
|
|
5159
|
+
function UserForm({ t, initial, prefill, datasources, onDone, onCancel }) {
|
|
5057
5160
|
const { catalog } = useCatalog(t)
|
|
5058
5161
|
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)
|
|
5162
|
+
const base = { kind: prefill?.kind ?? 'person', name: prefill ? (prefill.name || prefill.chatName || '') : '', role: 'analyst', allDatasources: initial ? initial.datasourceIds === null : false, datasourceIds: [], note: '', ...(initial ?? {}) }
|
|
5163
|
+
const ids = prefill ? [{ platform: prefill.platform, id: prefill.id, ...(prefill.kind === 'person' && prefill.email ? { email: prefill.email } : {}) }] : identitiesOf(initial)
|
|
5061
5164
|
return { ...base, identities: ids.length ? ids : [{ platform: 'lark', id: '', email: '' }] }
|
|
5062
5165
|
})
|
|
5063
5166
|
const [error, setError] = useState(null)
|
|
@@ -5089,6 +5192,11 @@ window.__ModuleLoader__.load({
|
|
|
5089
5192
|
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
5193
|
h('label', { className: 'n2s-field' }, h('span', null, t('im.name')), h('input', { className: 'n2s-input', value: form.name, onChange: set('name') })),
|
|
5091
5194
|
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}`))),
|
|
5195
|
+
!initial?.id && h(IdentityPicker, { t, kind: form.kind, catalog, chosen: form.identities, onPick: (x) => setForm(f => {
|
|
5196
|
+
const exists = f.identities.some(r => r.platform === x.platform && r.id === x.id)
|
|
5197
|
+
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 } : {}) }]
|
|
5198
|
+
return { ...f, identities: rows.length ? rows : [{ platform: x.platform, id: '', email: '' }], name: f.name || (exists ? f.name : (x.name || x.chatName || '')) }
|
|
5199
|
+
}) }),
|
|
5092
5200
|
h(IdentityEditor, { t, kind: form.kind, rows: form.identities, catalog, errors: idErrors, onChange: (rows) => { setForm(f => ({ ...f, identities: rows })); setIdErrors({}) } }),
|
|
5093
5201
|
h('div', { className: 'n2s-field full' }, h('span', null, t('mb.scope')),
|
|
5094
5202
|
h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: form.allDatasources, onChange: set('allDatasources') }), t('mb.allDs')),
|
|
@@ -5179,7 +5287,8 @@ window.__ModuleLoader__.load({
|
|
|
5179
5287
|
return h('div', null,
|
|
5180
5288
|
h(SectionHead, { title: t('tab.members'), desc: t('mb.hint') }, h('button', { type: 'button', className: 'n2s-btn primary', onClick: () => setEditing({}) }, '+ ', t('mb.add'))),
|
|
5181
5289
|
h(BotsOverview, { t }),
|
|
5182
|
-
editing && h(
|
|
5290
|
+
!editing && h(PendingMembers, { t, onAdd: (x) => setEditing({ prefill: x }) }),
|
|
5291
|
+
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
5292
|
settings.value && h('div', { className: 'n2s-setnote', style: { marginBottom: 10 } },
|
|
5184
5293
|
t('mb.default'), ' ', h('strong', null, t(`mb.defaultRole.${settings.value.botDefaultRole ?? settings.value.larkDefaultRole ?? 'none'}`)), ' · ',
|
|
5185
5294
|
h('button', { type: 'button', className: 'n2s-link', onClick: () => fbCtx.navigate?.({ tab: 'admin', section: 'settings' }) }, t('set.changeInSettings'))),
|
|
@@ -5191,7 +5300,7 @@ window.__ModuleLoader__.load({
|
|
|
5191
5300
|
h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
|
|
5192
5301
|
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
5302
|
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')}
|
|
5303
|
+
(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
5304
|
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
5305
|
h(Dropdown, { label: fmt(t, 'ch.actionsFor', { name: u.name }), trigger: { content: Icon.more() }, items: [
|
|
5197
5306
|
{ label: t('ds.edit'), icon: Icon.edit(14), onClick: () => setEditing(u) },
|
|
@@ -5759,7 +5868,7 @@ window.__ModuleLoader__.load({
|
|
|
5759
5868
|
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
5869
|
h('tbody', null, Object.entries(importState.dry?.report ?? {}).map(([k, r]) => h('tr', { key: k },
|
|
5761
5870
|
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(';')))))),
|
|
5871
|
+
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
5872
|
h('div', { className: 'n2s-row', style: { marginTop: 14, justifyContent: 'flex-end' } },
|
|
5764
5873
|
h('button', { type: 'button', className: 'n2s-btn ghost', onClick: () => setImportState(null) }, t('ds.cancel')),
|
|
5765
5874
|
h('button', { type: 'button', className: 'n2s-btn danger', onClick: () => doImport('replace') }, t('kb.replace')),
|
|
@@ -5775,7 +5884,7 @@ window.__ModuleLoader__.load({
|
|
|
5775
5884
|
row('dataDir', v?.dataDir, true),
|
|
5776
5885
|
row('runtime', v ? `Node ${v.nodeVersion} · SQLite ${v.sqliteVersion}` : null),
|
|
5777
5886
|
row('tests', v?.testsCommand, true),
|
|
5778
|
-
row('docs', '
|
|
5887
|
+
row('docs', 'github.com/hedeqiang/dsh-nl2sql'))))
|
|
5779
5888
|
}
|
|
5780
5889
|
|
|
5781
5890
|
// ------------------------------------------------------------ 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.0",
|
|
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
|