@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/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-main{display:grid;grid-template-columns:76px minmax(0,1fr) 150px 84px 20px;align-items:center;gap:14px;width:100%;padding:11px 16px;border:0;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}
1708
- .n2s-hist-main:hover{background:var(--n2s-hover)}
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 has-del', role: 'listitem' },
3389
- 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)),
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/CHANNELS.md). Everything below normalizes
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')}:${why.dimensions.filter(d => !d.attribution).map(d => d.dimension).join('、')}`))
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')}:`), c.impacts.slice(0, 8).map(i => `${t(`sc.imp.${i.kind}`)}「${i.name}」`).join('、')))))
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(UserForm, { key: editing.id ?? 'new', t, datasources, initial: editing.id ? editing : null, onCancel: () => setEditing(null), onDone: () => { setEditing(null); users.reload() } }),
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')}:${u.rowRules.map(r => `${r.column} ∈ {${r.values.join(', ')}}`).join(';')}`)),
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', 'DEVELOPMENT.md'))))
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.11.2",
4
- "description": "Data Q&A (NL2SQL) for DeepSeek Harness: ask MySQL / PostgreSQL / Elasticsearch / CSV in plain language, charts, dashboards, alerts, IM push and bots (Lark/Feishu, Telegram, WeCom, DingTalk), access control.",
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
- "docs/CHANNELS.md"
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/CHANNELS.md). The local
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/CHANNELS.md).
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
- if (resolved.refused) { await reply(resolved.refused); return }
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|数据源|切换数据源|切换|新对话|reset|start)\s*(.*)$/i)
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命令:数据源(查看可用数据源)、切换 <名称>、新对话。\n当前数据源:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}${actor.role === 'viewer' ? '\n你是只读成员:可以查询指标库中的指标、已验证的常见问题和看板。' : ''}`); return }
300
- if (verb === '数据源') { await reply(`可用数据源:${allowedDs.map(d => d.name).join('、') || '无'}\n当前:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}`); return }
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 target = allowedDs.find(d => d.name === cmd[2].trim() || d.id === cmd[2].trim())
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
- const ds = this.findDatasource(conv.datasourceId) ?? allowedDs[0]
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 prompt = `[${name}${where} · ${actor.name}${actor.role === 'viewer' ? ' · 只读成员' : ''}] ${text}\n\n(回答会以${name}消息发回该聊天:请用简洁的中文 Markdown 写结论,必要时附上查询结果;不要调用 nl2sql_send,也不要向用户提问——有歧义时说明你的假设后直接回答。${actor.role === 'viewer' ? '该成员是只读角色:只能用 nl2sql_metric 查询指标库,或复用 nl2sql_knowledge 中已验证的样例 SQL;不能自由编写 SQL。' : ''})`
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: `数据问答 · ${ds.name} · ${actor.name}` })
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()
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Nl2SqlService methods: IM channels (see docs/CHANNELS.md).
2
+ * Nl2SqlService methods: IM channels (see docs/channels.md).
3
3
  * Mixed into the service prototype by index.js; `this` is the service.
4
4
  *
5
5
  * A channel record (state.channels[]):
@@ -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 DEVELOPMENT.md). */
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