@hedeqiang/dsh-nl2sql 0.11.1 → 0.11.2

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 CHANGED
@@ -4,6 +4,14 @@
4
4
 
5
5
  ## [未发布]
6
6
 
7
+ ## [0.11.2] - 2026-11-12
8
+
9
+ ### 改进
10
+ - 看板:每个图表显示所属数据源(数据源已删除时提示);看板含多个数据源时显示「包含数据源」,并可切换「全部数据源 / 仅当前数据源」(按浏览器记住);只看当前数据源时拖拽排序不会打乱被隐藏的图表。
11
+ - 文档:README 改为以 npm 安装为主(插件页「添加插件」或 `dsh plugin add`),补充升级、卸载和从源码安装说明。
12
+ - 推送大结果时不再静默省略:消息中会注明「共 N 行,消息中显示前 M 行」。飞书卡片表格最多 50 行(客户端内分页),Telegram 20 行,企业微信、钉钉 10 行。
13
+ - 飞书应用机器人、Telegram 会把超出显示范围的结果作为完整数据 Excel 附件随消息发送(可在发送对话框取消,Agent 工具参数 `attachFull`);附件失败不影响消息本身,会在推送记录中提示。
14
+
7
15
  ## [0.11.1] - 2026-11-12
8
16
 
9
17
  ### 修复
package/README.en.md CHANGED
@@ -11,7 +11,8 @@ An enterprise NL2SQL plugin for DeepSeek Harness: connect databases, ask in plai
11
11
  - **Asking:** in a Harness conversation through the `nl2sql_*` tools; results render as ECharts charts or tables.
12
12
  - **Drill-down and "why did it change":** split by a dimension, show rows, rank dimensions by their contribution to a change.
13
13
  - **Metric library and business knowledge:** glossary, governed metrics (`nl2sql_metric`), column notes, verified examples; import / export; accuracy evaluation against the examples.
14
- - **Dashboards:** multiple boards, global filters, drag-to-reorder, three widths, snapshots, Excel export.
14
+ - **Dashboards:** multiple boards, global filters, drag-to-reorder, three widths, snapshots, Excel export; a board may mix data sources — each chart shows its source and the board can be scoped to the current source.
15
+ - **Large results in IM:** messages say how many rows are shown; Lark app bots and Telegram attach the full data as Excel.
15
16
  - **Channels:** Lark / Feishu, Telegram, WeCom and DingTalk through pluggable adapters ([docs/CHANNELS.md](docs/CHANNELS.md)); push Markdown and results (native charts, images or tables per platform), and @-mention bots that need no public URL.
16
17
  - **Automation:** scheduled reports, metric alerts (threshold / change / anomaly / missing), schema change detection.
17
18
  - **Safety:**
@@ -22,14 +23,40 @@ An enterprise NL2SQL plugin for DeepSeek Harness: connect databases, ask in plai
22
23
 
23
24
  ## Install
24
25
 
25
- Requires DeepSeek Harness (desktop or `dsh web`).
26
+ Requires DeepSeek Harness 0.2 or later (desktop or `dsh web`). Published on npm as [`@hedeqiang/dsh-nl2sql`](https://www.npmjs.com/package/@hedeqiang/dsh-nl2sql).
27
+
28
+ [![npm](https://img.shields.io/npm/v/@hedeqiang/dsh-nl2sql)](https://www.npmjs.com/package/@hedeqiang/dsh-nl2sql)
29
+
30
+ **In Harness (recommended)**
31
+
32
+ 1. Sidebar → **Plugins** → **Add plugin**.
33
+ 2. Enter `@hedeqiang/dsh-nl2sql` (optionally with a version, e.g. `@hedeqiang/dsh-nl2sql@0.11.1`), install and enable it.
34
+ 3. **Data Q&A** appears in the sidebar, and the Data Q&A preset is available for new conversations.
35
+
36
+ You can also ask the Harness agent: "install the plugin @hedeqiang/dsh-nl2sql".
37
+
38
+ **Command line**
39
+
40
+ ```bash
41
+ dsh plugin --profile desktop add @hedeqiang/dsh-nl2sql # desktop app profile; dsh web uses "web" by default
42
+ ```
43
+
44
+ Then enable it on the **Plugins** page (or restart Harness).
45
+
46
+ **Upgrade:** update it from the plugin's details on the **Plugins** page, or run `dsh plugin --profile desktop update @hedeqiang/dsh-nl2sql`, then re-enable the plugin. Configuration and data live in `~/.dsh/nl2sql` and survive upgrades; older configuration is migrated on first load.
47
+
48
+ **Uninstall:** remove it on the **Plugins** page or with `dsh plugin --profile desktop remove @hedeqiang/dsh-nl2sql`. Data in `~/.dsh/nl2sql` is kept; delete the directory if you no longer need it. Database passwords live in the Harness credential store.
49
+
50
+ > All dependencies (mysql2, pg, ssh2, echarts, @resvg/resvg-js, @larksuiteoapi/node-sdk) ship prebuilt; no install scripts need to run.
51
+
52
+ **From source (development)**
26
53
 
27
54
  ```bash
28
- git clone <this repo> dsh-nl2sql && cd dsh-nl2sql
55
+ git clone https://github.com/hedeqiang/dsh-nl2sql.git && cd dsh-nl2sql
29
56
  pnpm install --ignore-scripts
30
57
  ```
31
58
 
32
- Install the directory as a bundle (plugin manager `install_bundle` with the absolute path), or add `@hedeqiang/dsh-nl2sql` to your profile's `dsh.profile.bundles` with a `link:` dependency. Once published, install `@hedeqiang/dsh-nl2sql` from npm.
59
+ Enter the directory's absolute path in **Plugins** → **Add plugin** (it is linked, so re-enabling the plugin picks up code changes), or ask the agent to install the directory with the plugin manager.
33
60
 
34
61
  ## Use
35
62
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-nl2sql · 数据问答
2
2
 
3
- [DeepSeek Harness](https://github.com/deepseek-ai) 的企业级 NL2SQL 插件:连接数据库,用自然语言提问,Agent 写只读 SQL、执行并在对话中画图;结果可以固定到看板、推送到飞书,还能在飞书群里直接 @机器人提问。
3
+ [DeepSeek Harness](https://github.com/deepseek-ai) 的企业级 NL2SQL 插件:连接数据库,用自然语言提问,Agent 写只读 SQL、执行并在对话中画图;结果可以固定到看板、推送到飞书 / Telegram / 企业微信 / 钉钉,还能在群里直接 @机器人提问。
4
4
 
5
5
  [English](README.en.md)
6
6
 
@@ -16,6 +16,8 @@
16
16
 
17
17
  **看板与推送**
18
18
  - 多看板、全局筛选(时间范围 / 维度)、拖拽排序、三档宽度、快照、Excel 导出。
19
+ - 一个看板可以放多个数据源的图表:每个图表标注所属数据源,可切换「全部数据源 / 仅当前数据源」。
20
+ - 推送大结果时注明「共 N 行,显示前 M 行」;飞书应用机器人和 Telegram 会附带完整数据 Excel。
19
21
  - IM 渠道:飞书 / Lark、Telegram、企业微信、钉钉(推送 Markdown 与结果,图表按平台能力用原生图表、图片或表格);架构见 [docs/CHANNELS.md](docs/CHANNELS.md),新增平台只需实现一个适配器。
20
22
  - 定时报告(基于 Harness 定时任务)、指标告警(阈值 / 环比 / 异常 / 无数据)、表结构变更检测。
21
23
  - 群聊 @机器人提问:飞书长连接、Telegram 长轮询、企业微信智能机器人长连接、钉钉 Stream 模式,都不需要公网地址。
@@ -28,22 +30,40 @@
28
30
 
29
31
  ## 安装
30
32
 
31
- 需要 DeepSeek Harness 桌面版或 `dsh web`。
33
+ 需要 DeepSeek Harness 0.2 及以上(桌面版或 `dsh web`)。插件已发布到 npm:[`@hedeqiang/dsh-nl2sql`](https://www.npmjs.com/package/@hedeqiang/dsh-nl2sql)。
34
+
35
+ [![npm](https://img.shields.io/npm/v/@hedeqiang/dsh-nl2sql)](https://www.npmjs.com/package/@hedeqiang/dsh-nl2sql)
36
+
37
+ **方式一:在 Harness 里添加(推荐)**
38
+
39
+ 1. 打开左侧栏 **插件** → **添加插件**。
40
+ 2. 输入包名 `@hedeqiang/dsh-nl2sql`(可加版本,如 `@hedeqiang/dsh-nl2sql@0.11.1`),确认安装并启用。
41
+ 3. 左侧栏出现 **数据问答**,新建对话时也可以选择「数据问答」预设。
42
+
43
+ 也可以直接对 Harness 的 Agent 说:「安装插件 @hedeqiang/dsh-nl2sql」。
44
+
45
+ **方式二:命令行**
32
46
 
33
47
  ```bash
34
- # 从源码(开发)
35
- git clone <this repo> dsh-nl2sql && cd dsh-nl2sql
36
- pnpm install --ignore-scripts # 依赖:mysql2、pg、ssh2、echarts、@larksuiteoapi/node-sdk、@resvg/resvg-js
48
+ dsh plugin --profile desktop add @hedeqiang/dsh-nl2sql # 桌面版的 profile 名为 desktop;dsh web 默认为 web
37
49
  ```
38
50
 
39
- 然后在 Harness 中让 Agent 安装本地目录(插件管理器 `install_bundle`,目标为本目录的绝对路径),或在 profile 的 `package.json` 中加入:
51
+ 然后在 **插件** 页启用它(或重启 Harness)。
40
52
 
41
- ```json
42
- { "dsh": { "profile": { "bundles": ["@hedeqiang/dsh-nl2sql"] } },
43
- "dependencies": { "@hedeqiang/dsh-nl2sql": "link:/path/to/dsh-nl2sql" } }
53
+ **升级**:在 **插件** 页的插件详情中更新,或执行 `dsh plugin --profile desktop update @hedeqiang/dsh-nl2sql`,然后重新启用插件。配置和数据保存在 `~/.dsh/nl2sql`,升级不会丢失;旧版本的配置会在首次加载时自动迁移。
54
+
55
+ **卸载**:在 **插件** 页移除,或 `dsh plugin --profile desktop remove @hedeqiang/dsh-nl2sql`。卸载不会删除 `~/.dsh/nl2sql` 中的数据;不再需要时可手动删除该目录,数据库密码等凭据在 Harness 凭据库中,可在设置中清除。
56
+
57
+ > 依赖(mysql2、pg、ssh2、echarts、@resvg/resvg-js、@larksuiteoapi/node-sdk)都使用预编译包,安装时不需要运行构建脚本。
58
+
59
+ **从源码安装(开发)**
60
+
61
+ ```bash
62
+ git clone https://github.com/hedeqiang/dsh-nl2sql.git && cd dsh-nl2sql
63
+ pnpm install --ignore-scripts
44
64
  ```
45
65
 
46
- 发布到 npm 后可直接安装 `@hedeqiang/dsh-nl2sql`。
66
+ 在 **插件** → **添加插件** 中填入本目录的绝对路径(以链接方式安装,改代码后重新启用插件即可生效),或让 Agent 用插件管理器安装该目录。
47
67
 
48
68
  ## 使用
49
69
 
@@ -159,7 +159,7 @@ export function resultAsDingText(r, maxRows = 10, maxCols = 6) {
159
159
  const cell = (c, v) => (c.kind === 'number' ? fmtNum(v) : cellText(v, 30)).replace(/\|/g, '|')
160
160
  const out = [`**${cols.map(c => c.name).join(' | ')}**`, '', ...rows.map((row, i) => `${i + 1}. ${cols.map((c, j) => cell(c, row[j])).join(' | ')}`)]
161
161
  const total = r.rowCount ?? r.rows.length
162
- if (total > rows.length) out.push('', `> 共 ${total} 行,仅显示前 ${rows.length} 行`)
162
+ if (total > rows.length) out.push('', `> 共 ${total} 行,仅显示前 ${rows.length} 行;完整数据可在数据问答页面查看或导出 Excel`)
163
163
  return out.join('\n')
164
164
  }
165
165
 
@@ -508,7 +508,7 @@ const webhookKind = {
508
508
  { key: 'webhookUrl', type: 'secret', secret: true, required: true, label: { zh: 'Webhook 地址', en: 'Webhook URL' }, placeholder: 'https://oapi.dingtalk.com/robot/send?access_token=…', pattern: WEBHOOK_RE.source, help: { zh: '群设置 → 机器人 → 添加机器人 → 自定义,完成后复制 Webhook', en: 'Group settings → Bots → Add → Custom, then copy the webhook' } },
509
509
  { key: 'signSecret', type: 'secret', secret: true, required: false, label: { zh: '加签密钥(可选)', en: 'Signing secret (optional)' }, placeholder: 'SEC…', help: { zh: '安全设置选择「加签」时显示的 SEC 开头字符串', en: 'The SEC… string shown when “Sign” is selected in security settings' } },
510
510
  ],
511
- capabilities: { push: true, markdown: 'basic', charts: 'none', chartModes: ['table'], tables: 'text', buttons: false, bot: false, listTargets: false, mentionAll: true, maxMessageBytes: MD_LIMIT },
511
+ capabilities: { push: true, markdown: 'basic', charts: 'none', chartModes: ['table'], tables: 'text', tableRows: 10, files: false, buttons: false, bot: false, listTargets: false, mentionAll: true, maxMessageBytes: MD_LIMIT },
512
512
  setupSteps: {
513
513
  zh: [
514
514
  '在钉钉群:群设置 → 机器人 → 添加机器人 → 自定义(通过 Webhook 接入)。',
@@ -542,7 +542,7 @@ const appKind = {
542
542
  { key: 'openConversationId', type: 'text', required: false, label: { zh: '推送目标群(openConversationId,可选)', en: 'Push target group (openConversationId, optional)' }, placeholder: 'cid…', help: { zh: '机器人收到该群消息时的 conversationId(以 cid 开头)', en: 'The conversationId (cid…) seen in a message from that group' } },
543
543
  { key: 'userId', type: 'text', required: false, label: { zh: '推送目标成员 userId(可选)', en: 'Push target member userId (optional)' }, help: { zh: '未填群会话 ID 时,推送到该成员与机器人的单聊', en: 'Used when no group is set: pushes to the member’s chat with the bot' } },
544
544
  ],
545
- capabilities: { push: true, markdown: 'basic', charts: 'none', chartModes: ['table'], tables: 'text', buttons: false, bot: true, listTargets: false, mentionAll: false, maxMessageBytes: MD_LIMIT },
545
+ capabilities: { push: true, markdown: 'basic', charts: 'none', chartModes: ['table'], tables: 'text', tableRows: 10, files: false, buttons: false, bot: true, listTargets: false, mentionAll: false, maxMessageBytes: MD_LIMIT },
546
546
  setupSteps: {
547
547
  zh: [
548
548
  '钉钉开发者后台(open-dev.dingtalk.com)→ 应用开发 → 企业内部应用 → 创建应用,记下 Client ID 与 Client Secret。',
package/channels/lark.js CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { createHash, createHmac } from 'node:crypto'
18
18
  import { ChannelError, request } from './http.js'
19
- import { MD_TABLE, cellText, chartOption, chartable, dualAxis, kpiText, isKpi, num, parseMdTable, renderChartPng, resultMeta } from './message.js'
19
+ import { MD_TABLE, cellText, chartOption, chartable, dualAxis, kpiText, isKpi, needsAttachment, num, omittedNote, parseMdTable, renderChartPng, resultMeta } from './message.js'
20
20
 
21
21
  export { ChannelError, ChannelError as ImError, chartOption, chartable, dualAxis, renderChartPng }
22
22
 
@@ -677,6 +677,18 @@ async function uploadImage(channel, secrets, png) {
677
677
  return json.data.image_key
678
678
  }
679
679
 
680
+ /** Upload a file (≤ 30 MB) for a file message; returns file_key. https://open.feishu.cn/document/server-docs/im-v1/file/create */
681
+ async function uploadFile(channel, secrets, buf, fileName, fileType = 'xls') {
682
+ const token = await pushToken(channel, secrets)
683
+ const form = new FormData()
684
+ form.set('file_type', fileType)
685
+ form.set('file_name', fileName)
686
+ form.set('file', new Blob([buf], { type: 'application/octet-stream' }), fileName)
687
+ const { status, json } = await request(`${hostFor(channel)}/open-apis/im/v1/files`, { method: 'POST', headers: { authorization: `Bearer ${token}` }, body: form, timeoutMs: 60000, label: '飞书' })
688
+ if (json.code !== 0) throw larkPushError(json, status)
689
+ return json.data.file_key
690
+ }
691
+
680
692
  // ===========================================================================
681
693
  // Cards (JSON 2.0)
682
694
  // ===========================================================================
@@ -687,6 +699,7 @@ function tableElement(result, maxRows) {
687
699
  const cols = result.columns.slice(0, 8)
688
700
  return {
689
701
  tag: 'table',
702
+ // page_size is 1..10 per the card spec; more rows are paged in the client.
690
703
  page_size: Math.min(10, Math.max(1, Math.min(maxRows, result.rows.length))),
691
704
  row_height: 'low',
692
705
  header_style: { background_style: 'grey', bold: true, text_size: 'normal' },
@@ -820,7 +833,7 @@ export function actionElement(spec) {
820
833
  * Build a JSON 2.0 card from an OutboundMessage.
821
834
  * Results may carry `imageKey` (uploaded PNG) and `actions` (ActionSpec; legacy name `cardActions`).
822
835
  */
823
- export function buildCard(message, { maxRows = 10, charts = true, actions = true } = {}) {
836
+ export function buildCard(message, { maxRows = 50, charts = true, actions = true } = {}) {
824
837
  const elements = [...markdownElements(message.text)]
825
838
  let tables = elements.filter(e => e.tag === 'table').length
826
839
  let chartCount = 0
@@ -844,8 +857,12 @@ export function buildCard(message, { maxRows = 10, charts = true, actions = true
844
857
  } else if (tables < 5) {
845
858
  elements.push(tableElement(r, maxRows))
846
859
  tables++
860
+ const note = omittedNote(r, Math.min(maxRows, r.rows.length), { attached: Boolean(r.attached) })
861
+ if (note) elements.push({ tag: 'markdown', content: `<font color='grey'>${note}</font>`, text_size: 'notation' })
847
862
  } else {
848
863
  elements.push({ tag: 'markdown', content: r.rows.slice(0, 5).map(row => row.slice(0, 6).map(v => cellText(v)).join(' | ')).join('\n') })
864
+ const note = omittedNote(r, Math.min(5, r.rows.length), { attached: Boolean(r.attached) })
865
+ if (note) elements.push({ tag: 'markdown', content: `<font color='grey'>${note}</font>`, text_size: 'notation' })
849
866
  }
850
867
  const spec = r.actions ?? r.cardActions
851
868
  if (spec && actions) {
@@ -863,7 +880,7 @@ export function buildCard(message, { maxRows = 10, charts = true, actions = true
863
880
  }
864
881
  // Card bodies are limited to ~30 KB: shrink tables/charts until it fits.
865
882
  const size = Buffer.byteLength(JSON.stringify(card))
866
- if (size > CARD_LIMIT && maxRows > 3) return buildCard(message, { maxRows: Math.max(3, Math.floor(maxRows / 2)), charts, actions })
883
+ if (size > CARD_LIMIT && maxRows > 5) return buildCard(message, { maxRows: Math.max(5, Math.floor(maxRows / 2)), charts, actions })
867
884
  if (size > CARD_LIMIT && charts) return buildCard(message, { maxRows, charts: false, actions })
868
885
  if (size > CARD_LIMIT) {
869
886
  card.body.elements = elements.filter(e => e.tag !== 'table')
@@ -915,6 +932,7 @@ export async function sendMessage(channel, secrets, message) {
915
932
  }
916
933
  results.push(r)
917
934
  }
935
+ if (channel.type === 'lark_app' && message.attachment?.buffer) for (let i = 0; i < results.length; i++) if (message.attachment.resultIndexes?.includes(i)) results[i] = { ...results[i], attached: true }
918
936
  const card = buildCard({ ...message, results }, { charts: message.chartMode !== 'image' && message.chartMode !== 'table' })
919
937
  if (channel.type === 'lark_webhook') {
920
938
  if (!secrets.webhookUrl) throw new ChannelError('im/config', '缺少 Webhook 地址')
@@ -944,7 +962,17 @@ export async function sendMessage(channel, secrets, message) {
944
962
  if (json.code !== 0 && !fatal.includes(json.code) && card.body.elements.some(e => e.tag === 'chart')) ({ status, json } = await send(buildCard({ ...message, results }, { charts: false })))
945
963
  if (json.code !== 0 && !fatal.includes(json.code)) ({ status, json } = await send(legacyCard(card)))
946
964
  if (json.code !== 0) throw larkPushError(json, status)
947
- return { messageId: json.data?.message_id, images }
965
+ // Full data as an Excel file message after the card (app bots only; webhooks cannot send files).
966
+ let files = 0
967
+ if (message.attachment?.buffer) {
968
+ try {
969
+ const fileKey = await uploadFile(channel, secrets, message.attachment.buffer, message.attachment.fileName)
970
+ const fr = await postJson(url, { receive_id: channel.receiveId, msg_type: 'file', content: JSON.stringify({ file_key: fileKey }) }, { authorization: `Bearer ${token}` })
971
+ if (fr.json.code === 0) files++
972
+ else message.attachmentError = larkPushError(fr.json, fr.status).message
973
+ } catch (error) { message.attachmentError = error.message }
974
+ }
975
+ return { messageId: json.data?.message_id, images, files, ...(message.attachmentError ? { warning: `完整数据附件发送失败:${message.attachmentError}` } : {}) }
948
976
  }
949
977
  throw new ChannelError('im/config', `不支持的渠道类型:${channel.type}`)
950
978
  }
@@ -1104,7 +1132,7 @@ export const kinds = [
1104
1132
  { key: 'webhookUrl', label: { zh: 'Webhook 地址', en: 'Webhook URL' }, type: 'secret', secret: true, required: true, placeholder: 'https://open.feishu.cn/open-apis/bot/v2/hook/…', pattern: WEBHOOK_RE.source },
1105
1133
  { key: 'signSecret', label: { zh: '签名密钥', en: 'Signing secret' }, type: 'secret', secret: true, help: { zh: '机器人开启「签名校验」时填写', en: 'Only when signature verification is enabled' } },
1106
1134
  ],
1107
- capabilities: { push: true, markdown: 'full', charts: 'native', chartModes: ['native', 'table'], tables: 'native', buttons: false, bot: false, listTargets: false, mentionAll: true, maxMessageBytes: 28000 },
1135
+ capabilities: { push: true, markdown: 'full', charts: 'native', chartModes: ['native', 'table'], tables: 'native', tableRows: 50, files: false, buttons: false, bot: false, listTargets: false, mentionAll: true, maxMessageBytes: 28000 },
1108
1136
  setupSteps: WEBHOOK_SETUP,
1109
1137
  validate(input, existing) {
1110
1138
  const config = {}
@@ -1141,7 +1169,7 @@ export const kinds = [
1141
1169
  ] },
1142
1170
  { key: 'receiveId', label: { zh: '接收者 ID', en: 'Receiver ID' }, type: 'text', required: true, placeholder: 'oc_…', target: true, help: { zh: '推送目标;选择群时可点「列出群聊」', en: 'Push target; use "List chats" to pick a chat' } },
1143
1171
  ],
1144
- capabilities: { push: true, markdown: 'full', charts: 'native', chartImages: true, chartModes: ['native', 'image', 'table'], tables: 'native', buttons: true, bot: true, listTargets: true, mentionAll: true, maxMessageBytes: 28000 },
1172
+ capabilities: { push: true, markdown: 'full', charts: 'native', chartImages: true, chartModes: ['native', 'image', 'table'], tables: 'native', tableRows: 50, files: true, buttons: true, bot: true, listTargets: true, mentionAll: true, maxMessageBytes: 28000 },
1145
1173
  setupSteps: APP_SETUP,
1146
1174
  validate(input, existing) {
1147
1175
  const c = input.config ?? {}
@@ -42,6 +42,23 @@ export function isKpi(result) {
42
42
  }
43
43
 
44
44
  /** "12 行(已截断) · 已筛选:…" */
45
+ /**
46
+ * One line telling the reader how much of a result the message shows, or ''
47
+ * when everything is shown. `attached` = a full-data file follows.
48
+ */
49
+ export function omittedNote(result, shown, { attached = false } = {}) {
50
+ const total = result.rowCount ?? result.rows?.length ?? 0
51
+ if (shown >= total && !result.truncated) return ''
52
+ const base = shown < total ? `共 ${total} 行,消息中显示前 ${shown} 行` : `查询结果已按行数上限截断(${total} 行)`
53
+ return attached ? `${base},完整数据见附件 Excel` : `${base};完整数据可在数据问答页面查看或导出 Excel`
54
+ }
55
+
56
+ /** Results whose rows do not all fit in a message (candidates for a full-data attachment). */
57
+ export function needsAttachment(result, shown) {
58
+ if (!result || result.error || !result.rows?.length) return false
59
+ return (result.rowCount ?? result.rows.length) > shown
60
+ }
61
+
45
62
  export function resultMeta(result) {
46
63
  return `${result.rowCount ?? result.rows?.length ?? 0} 行${result.truncated ? '(已截断)' : ''}${result.filters?.length ? ` · 已筛选:${result.filters.map(f => f.description).join(';')}` : ''}`
47
64
  }
@@ -204,7 +221,7 @@ export function resultAsMarkdownTable(result, maxRows = 10, { maxCols = 8 } = {}
204
221
  ...rows.map(r => `| ${cols.map((c, i) => mdCell(c.kind === 'number' && Number.isFinite(Number(r[i])) && r[i] !== null && r[i] !== '' ? Number(r[i]).toLocaleString('en-US', { maximumFractionDigits: 4 }) : r[i])).join(' | ')} |`),
205
222
  ]
206
223
  const total = result.rowCount ?? result.rows?.length ?? 0
207
- if (total > rows.length) lines.push('', `…共 ${total} 行,仅显示前 ${rows.length} 行`)
224
+ if (total > rows.length) lines.push('', `…共 ${total} 行,仅显示前 ${rows.length} 行${result.attached ? ',完整数据见附件 Excel' : ''}`)
208
225
  return lines.join('\n')
209
226
  }
210
227
 
@@ -280,7 +280,7 @@ export function inlineKeyboard(spec) {
280
280
 
281
281
  // ------------------------------------------------------------ rendering
282
282
 
283
- function resultMarkdown(r, { includeSql, maxRows = 10 } = {}) {
283
+ function resultMarkdown(r, { includeSql, maxRows = 20 } = {}) {
284
284
  const head = `**${r.title || '查询结果'}** ${resultMeta(r)}`
285
285
  if (r.error) return `${head}\n${r.error}`
286
286
  const lines = [head]
@@ -353,6 +353,16 @@ function createSender(api) {
353
353
  return msg?.message_id
354
354
  }
355
355
 
356
+ async function sendDocument(chatId, buf, fileName, { caption, threadId } = {}) {
357
+ const form = new FormData()
358
+ form.append('chat_id', String(chatId))
359
+ form.append('document', new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), fileName)
360
+ if (caption) form.append('caption', caption.slice(0, 1024))
361
+ if (threadId) form.append('message_thread_id', String(threadId))
362
+ const msg = await api.call('sendDocument', undefined, { form, timeoutMs: 60000 })
363
+ return msg?.message_id
364
+ }
365
+
356
366
  /** Deliver an OutboundMessage. Returns { messageId, images }. */
357
367
  async function deliver(chatId, message, { replyTo, threadId, renderChartPng = defaultRenderChartPng, logger, buttons = true } = {}) {
358
368
  let images = 0
@@ -376,10 +386,16 @@ function createSender(api) {
376
386
  messageId ??= id
377
387
  reply = undefined
378
388
  }
379
- return { messageId: messageId === undefined ? undefined : String(messageId), images }
389
+ // Full data as an Excel document (Bot API sendDocument, ≤ 50 MB).
390
+ let files = 0
391
+ let warning
392
+ if (message.attachment?.buffer) {
393
+ try { await sendDocument(chatId, message.attachment.buffer, message.attachment.fileName, { caption: '完整数据', threadId }); files++ } catch (error) { warning = `完整数据附件发送失败:${error.message}` }
394
+ }
395
+ return { messageId: messageId === undefined ? undefined : String(messageId), images, files, ...(warning ? { warning } : {}) }
380
396
  }
381
397
 
382
- return { sendText, sendPhoto, deliver }
398
+ return { sendText, sendPhoto, sendDocument, deliver }
383
399
  }
384
400
 
385
401
  // ------------------------------------------------------------ validate
@@ -627,7 +643,7 @@ const telegramBot = {
627
643
  { key: 'chatId', type: 'text', required: false, target: true, label: { zh: 'Chat ID(推送目标)', en: 'Chat ID (push target)' }, placeholder: '-1001234567890 / @mychannel', pattern: CHAT_RE.source, help: { zh: '群组/超级群为负数(通常以 -100 开头),公开频道可填 @用户名;仅作机器人使用时可留空', en: 'Negative number for groups (usually -100…), or @username for public channels; may be empty for bot-only use' } },
628
644
  { key: 'apiBase', type: 'url', required: false, label: { zh: 'API 地址(可选)', en: 'API base URL (optional)' }, placeholder: DEFAULT_API_BASE, help: { zh: '自建 Bot API 服务器或反向代理地址;留空使用官方 api.telegram.org', en: 'Self-hosted Bot API server or proxy; empty = api.telegram.org' } },
629
645
  ],
630
- capabilities: { push: true, markdown: 'basic', charts: 'image', chartModes: ['image', 'table'], tables: 'text', buttons: true, bot: true, listTargets: false, mentionAll: false, maxMessageBytes: TEXT_LIMIT },
646
+ capabilities: { push: true, markdown: 'basic', charts: 'image', chartModes: ['image', 'table'], tables: 'text', tableRows: 20, files: true, buttons: true, bot: true, listTargets: false, mentionAll: false, maxMessageBytes: TEXT_LIMIT },
631
647
  setupSteps: {
632
648
  zh: [
633
649
  '在 Telegram 中打开 @BotFather,发送 /newbot 按提示创建机器人,复制 Bot Token。',
package/channels/wecom.js CHANGED
@@ -522,7 +522,7 @@ const webhookKind = {
522
522
  fields: [
523
523
  { key: 'webhookUrl', type: 'secret', secret: true, required: true, label: { zh: 'Webhook 地址', en: 'Webhook URL' }, placeholder: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=…', pattern: WEBHOOK_RE.source, help: { zh: '群聊 → 右上角 … → 消息推送 → 添加,复制 Webhook 地址', en: 'Group chat → … → Message push → Add, copy the webhook URL' } },
524
524
  ],
525
- capabilities: { push: true, markdown: 'basic', charts: 'image', chartModes: ['image', 'table'], tables: 'markdown', buttons: false, bot: false, listTargets: false, mentionAll: true, maxMessageBytes: WEBHOOK_LIMIT },
525
+ capabilities: { push: true, markdown: 'basic', charts: 'image', chartModes: ['image', 'table'], tables: 'markdown', tableRows: 10, files: false, buttons: false, bot: false, listTargets: false, mentionAll: true, maxMessageBytes: WEBHOOK_LIMIT },
526
526
  setupSteps: {
527
527
  zh: [
528
528
  '在企业微信群聊中点击右上角「…」→「消息推送」(旧版为「群机器人」)→「添加」,创建后复制 Webhook 地址。',
@@ -553,7 +553,7 @@ const aibotKind = {
553
553
  { key: 'chatId', type: 'text', required: false, label: { zh: '推送目标会话 ID(可选)', en: 'Push target chat id (optional)' }, help: { zh: '群聊 chatid(来自机器人收到的消息)或成员 userid;会话需先给机器人发过消息', en: 'Group chatid (from a received message) or member userid; the chat must have messaged the bot first' } },
554
554
  { key: 'chatType', type: 'select', required: false, label: { zh: '会话类型', en: 'Chat type' }, options: [{ value: 'group', label: { zh: '群聊', en: 'Group' } }, { value: 'single', label: { zh: '单聊', en: 'Single' } }] },
555
555
  ],
556
- capabilities: { push: true, markdown: 'full', charts: 'none', chartModes: ['table'], tables: 'markdown', buttons: false, bot: true, listTargets: false, mentionAll: false, maxMessageBytes: AIBOT_LIMIT },
556
+ capabilities: { push: true, markdown: 'full', charts: 'none', chartModes: ['table'], tables: 'markdown', tableRows: 10, files: false, buttons: false, bot: true, listTargets: false, mentionAll: false, maxMessageBytes: AIBOT_LIMIT },
557
557
  setupSteps: {
558
558
  zh: [
559
559
  '企业微信管理后台 → 应用管理 → 智能机器人 → 创建(或选择)机器人 → 开启「API 模式」并选择「长连接」。',
package/index.js CHANGED
@@ -310,6 +310,7 @@ function registerSendTool(ctx, service) {
310
310
  chartMode: { type: 'string', enum: ['native', 'image', 'table'], description: 'How attached results render: native card charts, PNG images, or tables. Omit to use the channel default; unsupported modes fall back to it.' },
311
311
  resultIds: { type: 'array', items: { type: 'string' }, description: 'resultIds from nl2sql_query / nl2sql_chart / nl2sql_overview to include.' },
312
312
  includeSql: { type: 'boolean', description: 'Also include each result\'s SQL.' },
313
+ attachFull: { type: 'boolean', description: 'Where the channel can send files (Lark app bot, Telegram), results with more rows than the message shows get the full data attached as an Excel file. Default true.' },
313
314
  },
314
315
  additionalProperties: false,
315
316
  },
@@ -318,7 +319,7 @@ function registerSendTool(ctx, service) {
318
319
  execute: async (args, exec) => {
319
320
  try {
320
321
  if (args.action === 'list') return { channels: service.listChannels().map(c => ({ ...pick(c, ['id', 'name', 'type', 'platform', 'description', 'isDefault']), ...(c.capabilities ? { capabilities: pick(c.capabilities, ['markdown', 'charts', 'tables', 'chartModes']) } : {}) })) }
321
- return await service.sendToChannel({ channelId: args.channel, title: args.title, text: args.text, resultIds: args.resultIds ?? [], includeSql: args.includeSql, chartMode: args.chartMode, source: 'agent', sessionId: exec.agent?.id })
322
+ return await service.sendToChannel({ channelId: args.channel, title: args.title, text: args.text, resultIds: args.resultIds ?? [], includeSql: args.includeSql, attachFull: args.attachFull, chartMode: args.chartMode, source: 'agent', sessionId: exec.agent?.id })
322
323
  } catch (error) {
323
324
  throw error instanceof UserError ? new Error(error.message) : error
324
325
  }
package/lib/client.js CHANGED
@@ -15,6 +15,18 @@ window.__ModuleLoader__.load({
15
15
  // ------------------------------------------------------------ locale
16
16
 
17
17
  const zh = {
18
+ 'dash.sourceOf': "数据源",
19
+ 'dash.sourceMissing': "该图表的数据源已删除,无法刷新",
20
+ 'dash.sourceMissingShort': "数据源已删除",
21
+ 'dash.scope': "显示范围",
22
+ 'dash.scopeAll': "全部数据源",
23
+ 'dash.scopeCurrent': "仅 {name}",
24
+ 'dash.sources': "包含数据源:{list}",
25
+ 'dash.scopeEmpty': "这个看板里没有「{name}」的图表",
26
+ 'dash.scopeEmptyHint': "另有 {n} 个其他数据源的图表被隐藏。",
27
+ 'im.attachFull': "超过 {n} 行的结果附带完整数据 Excel",
28
+ 'im.rowsLimit': "消息中每个结果最多显示 {n} 行,超出部分会注明;完整数据可在页面导出 Excel",
29
+ 'im.sentFile': "已附带完整数据 Excel",
18
30
  "settings.botDefaultRole": "未登记的用户",
19
31
  "settings.larkDefaultRole": "未登记的用户",
20
32
  "ch.savedTestFailedMsg": "已保存,但测试失败:{msg}",
@@ -1006,6 +1018,18 @@ window.__ModuleLoader__.load({
1006
1018
  'error.generic': '操作失败:{message}',
1007
1019
  }
1008
1020
  const en = {
1021
+ 'dash.sourceOf': "Data source",
1022
+ 'dash.sourceMissing': "This chart's data source was deleted; it cannot refresh",
1023
+ 'dash.sourceMissingShort': "source deleted",
1024
+ 'dash.scope': "Show",
1025
+ 'dash.scopeAll': "All sources",
1026
+ 'dash.scopeCurrent': "Only {name}",
1027
+ 'dash.sources': "Sources: {list}",
1028
+ 'dash.scopeEmpty': "No charts from \"{name}\" on this dashboard",
1029
+ 'dash.scopeEmptyHint': "{n} charts from other sources are hidden.",
1030
+ 'im.attachFull': "Attach full data as Excel for results over {n} rows",
1031
+ 'im.rowsLimit': "Messages show up to {n} rows per result and say when rows are omitted; export Excel on the page for full data",
1032
+ 'im.sentFile': "full data attached as Excel",
1009
1033
  "settings.botDefaultRole": "Unregistered users",
1010
1034
  "settings.larkDefaultRole": "Unregistered users",
1011
1035
  "ch.savedTestFailedMsg": "Saved, but the test failed: {msg}",
@@ -1919,6 +1943,8 @@ window.__ModuleLoader__.load({
1919
1943
  .n2s-morebtn:hover{background:var(--n2s-hover);color:var(--dsw-alias-label-primary)}
1920
1944
  .n2s-btn.danger-text{color:var(--n2s-err)}
1921
1945
  /* ---- dashboard v2 ---- */
1946
+ .n2s-srcbadge{display:inline-flex;align-items:center;gap:3px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:none}
1947
+ .n2s-seg.small button{height:26px;padding:0 10px;font-size:12px}
1922
1948
  .n2s-dash{grid-template-columns:repeat(6,minmax(0,1fr));align-items:start}
1923
1949
  .n2s-widget.w-third{grid-column:span 2}
1924
1950
  .n2s-widget.w-half{grid-column:span 3}
@@ -3993,7 +4019,7 @@ window.__ModuleLoader__.load({
3993
4019
  /** Modal dialog sending a title, text and results to an IM channel. */
3994
4020
  function SendDialog({ t, initial, onClose }) {
3995
4021
  const channels = useChannels()
3996
- const [form, setForm] = useState(() => ({ channelId: '', title: initial.title ?? '', text: initial.text ?? '', includeSql: false, chartMode: '' }))
4022
+ const [form, setForm] = useState(() => ({ channelId: '', title: initial.title ?? '', text: initial.text ?? '', includeSql: false, chartMode: '', attachFull: true }))
3997
4023
  const [resultIds, setResultIds] = useState(initial.resultIds ?? [])
3998
4024
  const [loading, setLoading] = useState(Boolean(initial.message))
3999
4025
  useEffect(() => {
@@ -4021,9 +4047,9 @@ window.__ModuleLoader__.load({
4021
4047
  setState({ busy: true })
4022
4048
  try {
4023
4049
  const res = initial.dashboard
4024
- ? await call('sendDashboard', { channelId: form.channelId, title: form.title, ...(form.chartMode ? { chartMode: form.chartMode } : {}) })
4025
- : await call('sendToChannel', { channelId: form.channelId, title: form.title, text: form.text, resultIds, includeSql: form.includeSql, ...(form.chartMode ? { chartMode: form.chartMode } : {}), sessionId: initial.message?.sessionId })
4026
- setState({ ok: fmt(t, 'im.sent', { channel: res.channel, images: res.images }) })
4050
+ ? await call('sendDashboard', { channelId: form.channelId, title: form.title, attachFull: form.attachFull, ...(form.chartMode ? { chartMode: form.chartMode } : {}) })
4051
+ : await call('sendToChannel', { channelId: form.channelId, title: form.title, text: form.text, resultIds, includeSql: form.includeSql, attachFull: form.attachFull, ...(form.chartMode ? { chartMode: form.chartMode } : {}), sessionId: initial.message?.sessionId })
4052
+ setState({ ok: `${fmt(t, 'im.sent', { channel: res.channel, images: res.images })}${res.files ? ` · ${t('im.sentFile')}` : ''}`, warn: res.warning })
4027
4053
  refreshChannels()
4028
4054
  setTimeout(onClose, 1200)
4029
4055
  } catch (e) {
@@ -4047,9 +4073,14 @@ window.__ModuleLoader__.load({
4047
4073
  h('span', { className: 'n2s-small' }, t('im.chartMode')),
4048
4074
  channel?.capabilities?.charts === 'none' && h('span', { className: 'n2s-faint n2s-small' }, t('ch.tablesOnly')),
4049
4075
  h('div', { className: 'n2s-seg', role: 'group', 'aria-label': t('im.chartMode') }, ['', ...chartModesFor(channel?.capabilities, channel?.type)].map(v => h('button', { key: v || 'def', type: 'button', 'aria-pressed': form.chartMode === v, onClick: () => setForm(f => ({ ...f, chartMode: v })) }, t(v ? `im.cm.${v}` : 'im.cm.default'))))),
4050
- !initial.dashboard && resultIds.length > 0 && h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: form.includeSql, onChange: e => setForm(f => ({ ...f, includeSql: e.target.checked })) }), t('im.includeSql'))),
4076
+ !initial.dashboard && resultIds.length > 0 && h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: form.includeSql, onChange: e => setForm(f => ({ ...f, includeSql: e.target.checked })) }), t('im.includeSql')),
4077
+ (resultIds.length > 0 || initial.dashboard) && h('div', { className: 'n2s-field' },
4078
+ channel?.capabilities?.files
4079
+ ? h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: form.attachFull, onChange: e => setForm(f => ({ ...f, attachFull: e.target.checked })) }), fmt(t, 'im.attachFull', { n: channel.capabilities.tableRows ?? 10 }))
4080
+ : h('span', { className: 'n2s-faint n2s-small' }, fmt(t, 'im.rowsLimit', { n: channel?.capabilities?.tableRows ?? 10 })))),
4051
4081
  state?.error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, state.error),
4052
4082
  state?.ok && h('div', { className: 'n2s-notice ok', style: { marginTop: 10 } }, state.ok),
4083
+ state?.warn && h('div', { className: 'n2s-notice warn', style: { marginTop: 8 } }, state.warn),
4053
4084
  h('div', { className: 'n2s-row', style: { marginTop: 14, justifyContent: 'flex-end' } },
4054
4085
  h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onClose }, t('ds.cancel')),
4055
4086
  h('button', { type: 'button', className: 'n2s-btn primary', disabled: !form.channelId || !form.title.trim() || state?.busy || loading, onClick: send }, state?.busy ? h('span', { className: 'n2s-spin' }) : null, t('im.send'))))), document.body)
@@ -4411,7 +4442,7 @@ window.__ModuleLoader__.load({
4411
4442
  h('td', { title: d.title }, d.title),
4412
4443
  h('td', { className: 'n2s-muted' }, tOpt(t, `im.src.${d.source}`) || d.source),
4413
4444
  h('td', { title: d.error ?? '' }, d.status === 'ok'
4414
- ? h('span', { className: 'n2s-statusdot ok' }, [t('im.sentOk'), d.results ? fmt(t, 'im.resultsN', { count: d.results }) : ''].filter(Boolean).join(' · '))
4445
+ ? h('span', { className: 'n2s-statusdot ok' }, [t('im.sentOk'), d.results ? fmt(t, 'im.resultsN', { count: d.results }) : '', d.files ? t('im.sentFile') : '', d.error ? d.error : ''].filter(Boolean).join(' · '))
4415
4446
  : h('span', { className: 'n2s-statusdot err' }, d.error ?? t('im.lastFailed')))))))))
4416
4447
  }
4417
4448
 
@@ -4465,7 +4496,7 @@ window.__ModuleLoader__.load({
4465
4496
  const HIDDEN_PIN = { hidden: true }
4466
4497
  const WIDGET_WIDTHS = ['third', 'half', 'full']
4467
4498
 
4468
- function DashboardWidget({ t, widget, onRemove, onChanged, onMove, tick, first, last, filters, filterCount, otherBoards = [], onTransfer, onLoaded, drag }) {
4499
+ function DashboardWidget({ t, widget, onRemove, onChanged, onMove, tick, first, last, filters, filterCount, otherBoards = [], onTransfer, onLoaded, drag, source, showSource }) {
4469
4500
  const [state, setState] = useState({ busy: true })
4470
4501
  const [local, setLocal] = useState(0)
4471
4502
  useEffect(() => {
@@ -4491,6 +4522,7 @@ window.__ModuleLoader__.load({
4491
4522
  onKeyDown: (e) => { if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { e.preventDefault(); if (!first) onMove(-1) } if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { e.preventDefault(); if (!last) onMove(1) } },
4492
4523
  }, svg(14, [h('circle', { key: 1, cx: 6, cy: 4, r: .9, fill: 'currentColor' }), h('circle', { key: 2, cx: 10, cy: 4, r: .9, fill: 'currentColor' }), h('circle', { key: 3, cx: 6, cy: 8, r: .9, fill: 'currentColor' }), h('circle', { key: 4, cx: 10, cy: 8, r: .9, fill: 'currentColor' }), h('circle', { key: 5, cx: 6, cy: 12, r: .9, fill: 'currentColor' }), h('circle', { key: 6, cx: 10, cy: 12, r: .9, fill: 'currentColor' })])),
4493
4524
  h('span', { className: 'title', title: widget.title }, widget.title),
4525
+ showSource && h('span', { className: `n2s-badge n2s-srcbadge${source ? '' : ' warn'}`, title: source ? `${t('dash.sourceOf')}: ${source.name} · ${typeLabel(t, source.type)}` : t('dash.sourceMissing') }, Icon.db(11), source ? source.name : t('dash.sourceMissingShort')),
4494
4526
  state.busy ? h('span', { className: 'n2s-spin n2s-faint', 'aria-label': t('dash.loading') }) : ago && h('span', { className: 'n2s-faint n2s-small n2s-hide-narrow', title: state.result?.cached ? fmt(t, 'dash.cachedAt', { time: new Date(state.result.cachedAt).toLocaleTimeString() }) : t('dash.updatedAt') }, state.result?.cached ? `${ago} · ${t('dash.cached')}` : ago),
4495
4527
  state.result?.filters?.length > 0 && h('span', { className: 'n2s-badge blue', title: state.result.filters.map(f => f.description).join('\n') }, fmt(t, 'filter.appliedCount', { count: state.result.filters.length })),
4496
4528
  state.result && filterCount > 0 && !(state.result.filters?.length) && h('span', { className: 'n2s-badge', title: t('filter.notApplied') }, t('filter.notAppliedShort')),
@@ -4511,7 +4543,7 @@ window.__ModuleLoader__.load({
4511
4543
  state.result && h(ResultView, { result: widgetResult, t, compact: true, embedded: true, onPinned: HIDDEN_PIN }))
4512
4544
  }
4513
4545
 
4514
- function DashboardTab({ t }) {
4546
+ function DashboardTab({ t, ds, datasources = [] }) {
4515
4547
  const [boardId, setBoardId] = useState(null)
4516
4548
  const list = useAsync(() => call('listDashboard'), [boardId])
4517
4549
  const boards = useAsync(() => call('listDashboards'), [boardId])
@@ -4526,7 +4558,17 @@ window.__ModuleLoader__.load({
4526
4558
  const [order, setOrder] = useState(null)
4527
4559
  useEffect(() => { setOrder(null) }, [list.value])
4528
4560
  const serverWidgets = list.value ?? []
4529
- const widgets = order ? order.map(id => serverWidgets.find(w => w.id === id)).filter(Boolean) : serverWidgets
4561
+ const allWidgets = order ? order.map(id => serverWidgets.find(w => w.id === id)).filter(Boolean) : serverWidgets
4562
+ // Widgets keep the data source they were pinned from; a board may mix sources.
4563
+ // Scope: all sources, or only the one selected at the top right (remembered per browser).
4564
+ const [scope, setScopeState] = useState(() => { try { return localStorage.getItem('nl2sql:dashScope') === 'current' ? 'current' : 'all' } catch { return 'all' } })
4565
+ const setScope = (v) => { setScopeState(v); try { localStorage.setItem('nl2sql:dashScope', v) } catch {} }
4566
+ const sourceIds = [...new Set(allWidgets.map(w => w.datasourceId))]
4567
+ const mixed = sourceIds.length > 1
4568
+ const dsById = new Map(datasources.map(d => [d.id, d]))
4569
+ const widgets = scope === 'current' && ds ? allWidgets.filter(w => w.datasourceId === ds.id) : allWidgets
4570
+ const hiddenByScope = allWidgets.length - widgets.length
4571
+ const showSource = mixed || (ds && sourceIds.some(id => id !== ds.id))
4530
4572
  const saved = useAsync(() => call('getDashboardFilters'), [boardId])
4531
4573
  const [filters, setFilters] = useState(null)
4532
4574
  const [filtersFor, setFiltersFor] = useState(null)
@@ -4548,7 +4590,11 @@ window.__ModuleLoader__.load({
4548
4590
  // Drag and drop.
4549
4591
  const [dragId, setDragId] = useState(null)
4550
4592
  const [over, setOver] = useState(null) // { id, pos: 'before'|'after' }
4551
- const commitOrder = async (ids) => {
4593
+ const commitOrder = async (visibleIds) => {
4594
+ // Hidden (other-source) widgets keep their slots; visible ones take the new order.
4595
+ const queue = [...visibleIds]
4596
+ const visible = new Set(visibleIds)
4597
+ const ids = allWidgets.map(w => (visible.has(w.id) ? queue.shift() : w.id))
4552
4598
  setOrder(ids)
4553
4599
  try { await call('reorderWidgets', { dashboardId: boards.value?.current, ids }) } catch (e) { notifyError(e); setOrder(null) }
4554
4600
  list.reload()
@@ -4587,9 +4633,13 @@ window.__ModuleLoader__.load({
4587
4633
  }
4588
4634
  return h('div', null,
4589
4635
  h(DashboardSwitcher, { t, value: { id: boardId }, onChange: (id) => setBoardId(`${id}:${Date.now()}`) }),
4590
- widgets.length > 0 && h('div', { className: 'n2s-toolbar n2s-dashbar' },
4636
+ allWidgets.length > 0 && h('div', { className: 'n2s-toolbar n2s-dashbar' },
4591
4637
  h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
4592
- h('div', { className: 'n2s-h2', style: { margin: 0 } }, fmt(t, 'dash.count', { count: widgets.length })),
4638
+ h('div', { className: 'n2s-row', style: { gap: 10 } },
4639
+ h('div', { className: 'n2s-h2', style: { margin: 0 } }, fmt(t, 'dash.count', { count: widgets.length })),
4640
+ (mixed || hiddenByScope > 0 || (ds && sourceIds.some(id => id !== ds.id))) && h('div', { className: 'n2s-seg small', role: 'group', 'aria-label': t('dash.scope') },
4641
+ ['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('、') })),
4593
4643
  h('div', { className: 'n2s-faint n2s-small', role: 'status', 'aria-live': 'polite' },
4594
4644
  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' }) }) : '',
4595
4645
  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,
@@ -4606,11 +4656,12 @@ window.__ModuleLoader__.load({
4606
4656
  widgets.length > 0 && filtersReady && h(FilterBar, { key: `f:${boardId ?? 'b'}`, t, filters, onChange: changeFilters }),
4607
4657
  sendingDash && h(SendDialog, { t, initial: { title: t('im.dashboardTitle'), dashboard: true }, onClose: () => setSendingDash(false) }),
4608
4658
  list.error && h('div', { className: 'n2s-notice err' }, list.error.message),
4609
- !list.loading && widgets.length === 0 && h('div', { className: 'n2s-empty' }, h('h3', null, t('dash.empty')), h('div', null, t('dash.emptyHint')), h('button', { type: 'button', className: 'n2s-btn', onClick: () => fbCtx.navigate?.({ tab: 'ask' }) }, t('dash.goAsk'))),
4659
+ !list.loading && widgets.length === 0 && hiddenByScope > 0 && h('div', { className: 'n2s-empty' }, h('h3', null, fmt(t, 'dash.scopeEmpty', { name: ds?.name ?? '' })), h('div', null, fmt(t, 'dash.scopeEmptyHint', { n: hiddenByScope })), h('button', { type: 'button', className: 'n2s-btn', onClick: () => setScope('all') }, t('dash.scopeAll'))),
4660
+ !list.loading && allWidgets.length === 0 && h('div', { className: 'n2s-empty' }, h('h3', null, t('dash.empty')), h('div', null, t('dash.emptyHint')), h('button', { type: 'button', className: 'n2s-btn', onClick: () => fbCtx.navigate?.({ tab: 'ask' }) }, t('dash.goAsk'))),
4610
4661
  widgets.length > 1 && h('div', { className: 'n2s-faint n2s-small n2s-hide-narrow', style: { margin: '-4px 0 8px' } }, t('dash.dragTip')),
4611
4662
  filtersReady && !list.loading && h('div', { className: `n2s-dash${dragId ? ' is-dragging' : ''}`, key: `d:${boardId ?? 'b'}` }, widgets.map((w, i) => h(DashboardWidget, {
4612
4663
  key: w.id, t, widget: w, tick, first: i === 0, last: i === widgets.length - 1, filters: filters ?? [], filterCount: (filters ?? []).length,
4613
- onChanged: () => list.reload(), onLoaded, drag: dragProps(w),
4664
+ onChanged: () => list.reload(), onLoaded, drag: dragProps(w), source: dsById.get(w.datasourceId), showSource,
4614
4665
  onMove: (delta) => move(w, delta),
4615
4666
  onRemove: async () => { if (!await confirmDialog({ title: fmt(t, 'dash.removeConfirm', { title: w.title }), danger: true })) return; try { await call('unpinWidget', { id: w.id }); notify(t('dash.removed'), 'ok') } catch (e) { notifyError(e) } list.reload() },
4616
4667
  otherBoards, onTransfer: async (to, copy) => { try { await call('transferWidget', { id: w.id, toDashboardId: to, copy }); notify(copy ? t('dash.copied') : t('dash.moved'), 'ok') } catch (e) { notifyError(e) } list.reload(); boards.reload() },
@@ -5441,7 +5492,7 @@ window.__ModuleLoader__.load({
5441
5492
  nav.tab === 'schema' ? h(SchemaTab, { t, ds, onAskTable: (tb) => ask(fmt(t, 'ask.tablePrompt', { name: tb.name, comment: tb.comment ? `(${tb.comment})` : '' })) }) :
5442
5493
  nav.tab === 'knowledge' ? h(KnowledgeTab, { t, ds }) :
5443
5494
  nav.tab === 'history' ? h(HistoryTab, { t, datasources, onOpenSession: (id) => ctx.uiWorkspace.openSession(id) }) :
5444
- nav.tab === 'dashboard' ? h(DashboardTab, { t }) :
5495
+ nav.tab === 'dashboard' ? h(DashboardTab, { t, ds, datasources }) :
5445
5496
  h(AdminArea, { t, ctx, section: ADMIN_SECTIONS.includes(nav.section) ? nav.section : 'sources', sub: nav.sub, onNavigate: setNav, ds, datasources }))),
5446
5497
  h(FeedbackLayer, { t })))
5447
5498
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hedeqiang/dsh-nl2sql",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
4
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.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,7 +11,8 @@
11
11
  * Secrets live in one credential record per channel: `nl2sql/ch-<id>`.
12
12
  */
13
13
  import { ChannelError } from '../channels/http.js'
14
- import { renderChartPng } from '../channels/message.js'
14
+ import { needsAttachment, renderChartPng } from '../channels/message.js'
15
+ import { writeXlsx } from '../xlsx.js'
15
16
  import { getAdapter, kindOf, listCatalog, loadAdapters, requireKind, secretKeys } from '../channels/index.js'
16
17
  import { UserError, newId, pick, requireText, upsert } from './shared.js'
17
18
 
@@ -292,7 +293,7 @@ export const methods = {
292
293
  * process' cache) or given inline; each is re-checked against privacy:
293
294
  * masked values stay masked because results are stored masked.
294
295
  */
295
- async sendToChannel({ channelId, title, text, resultIds = [], results = [], includeSql = false, source = 'ui', sessionId, footer, chartMode, template, mentionAll }) {
296
+ async sendToChannel({ channelId, title, text, resultIds = [], results = [], includeSql = false, source = 'ui', sessionId, footer, chartMode, template, mentionAll, attachFull }) {
296
297
  await loadAdapters()
297
298
  const c = this.requireChannel(channelId)
298
299
  const resolved = []
@@ -319,15 +320,30 @@ export const methods = {
319
320
  ...(template ? { template } : {}),
320
321
  footer: footer ?? `数据问答 · ${new Date().toLocaleString('zh-CN', { hour12: false })}${resolved[0]?.datasourceName ? ` · ${resolved[0].datasourceName}` : ''}`,
321
322
  }
323
+ // Results with more rows than the message can show: attach the full data as Excel
324
+ // where the platform can send files (default on; per send `attachFull: false` to skip).
325
+ const caps = found.kind.capabilities ?? {}
326
+ const shown = caps.tableRows ?? 10
327
+ const wantAttach = attachFull ?? c.attachFull ?? true
328
+ if (caps.files && wantAttach) {
329
+ const idx = message.results.map((r, i) => (needsAttachment(r, shown) ? i : -1)).filter(i => i >= 0)
330
+ if (idx.length) {
331
+ const sheets = idx.map(i => ({ name: (message.results[i].title || `结果${i + 1}`).slice(0, 31), columns: message.results[i].columns, rows: message.results[i].rows }))
332
+ if (includeSql) sheets.push({ name: 'SQL', columns: [{ name: '结果', kind: 'text' }, { name: 'SQL', kind: 'text' }], rows: idx.map(i => [message.results[i].title ?? '', message.results[i].sql ?? '']) })
333
+ const buffer = writeXlsx(sheets, { creator: 'DeepSeek Harness 数据问答' })
334
+ const safe = String(message.title).replace(/[\\/:*?"<>|\s]+/g, '_').slice(0, 60) || '数据'
335
+ message.attachment = { buffer, fileName: `${safe}_${new Date().toISOString().slice(0, 10)}.xlsx`, resultIndexes: idx }
336
+ }
337
+ }
322
338
  const started = Date.now()
323
339
  try {
324
340
  if (c.needsSecret) throw new ChannelError('channel/config', `渠道「${c.name}」需要重新填写密钥后才能使用`)
325
341
  const sent = await found.kind.send(this.adapterChannel(c), await this.channelSecrets(c), message, { renderChartPng, logger: console, fetch: globalThis.fetch })
326
342
  c.lastSentAt = Date.now()
327
343
  delete c.lastError
328
- this.recordDelivery({ channel: c, title: message.title, status: 'ok', results: resolved.length, images: sent?.images, source, sessionId, elapsedMs: Date.now() - started })
344
+ this.recordDelivery({ channel: c, title: message.title, status: 'ok', results: resolved.length, images: sent?.images, files: sent?.files, ...(sent?.warning ? { error: sent.warning } : {}), source, sessionId, elapsedMs: Date.now() - started })
329
345
  await this.save()
330
- return { ok: true, channel: c.name, platform: c.platform, messageId: sent?.messageId ?? null, images: sent?.images ?? 0, results: resolved.length }
346
+ return { ok: true, channel: c.name, platform: c.platform, messageId: sent?.messageId ?? null, images: sent?.images ?? 0, files: sent?.files ?? 0, ...(sent?.warning ? { warning: sent.warning } : {}), results: resolved.length }
331
347
  } catch (error) {
332
348
  c.lastError = error.message
333
349
  this.recordDelivery({ channel: c, title: message.title, status: 'error', error: error.message, results: resolved.length, source, sessionId, elapsedMs: Date.now() - started })
@@ -336,8 +352,8 @@ export const methods = {
336
352
  }
337
353
  },
338
354
 
339
- recordDelivery({ channel, title, status, error, results, images, source, sessionId, elapsedMs }) {
340
- this.records.put('delivery', { id: newId('dl'), at: Date.now(), channelId: channel.id, channelName: channel.name, ...(channel.platform ? { platform: channel.platform } : {}), title, status, ...(error ? { error: String(error).slice(0, 500) } : {}), results, images: images ?? 0, source, sessionId: sessionId ?? null, elapsedMs })
355
+ recordDelivery({ channel, title, status, error, results, images, files, source, sessionId, elapsedMs }) {
356
+ this.records.put('delivery', { id: newId('dl'), at: Date.now(), channelId: channel.id, channelName: channel.name, ...(channel.platform ? { platform: channel.platform } : {}), title, status, ...(error ? { error: String(error).slice(0, 500) } : {}), results, images: images ?? 0, files: files ?? 0, source, sessionId: sessionId ?? null, elapsedMs })
341
357
  },
342
358
 
343
359
  listDeliveries(limit = 100) {
package/service/push.js CHANGED
@@ -64,7 +64,7 @@ export const methods = {
64
64
  },
65
65
 
66
66
  /** Send the current dashboard (with saved global filters) to a channel. */
67
- async sendDashboard({ channelId, title, filters, dashboardId, chartMode }) {
67
+ async sendDashboard({ channelId, title, filters, dashboardId, chartMode, attachFull }) {
68
68
  const results = []
69
69
  const d = dashboardId ? this.requireDashboard(dashboardId) : this.currentDashboardObj()
70
70
  for (const w of d.widgets.slice(0, 10)) {
@@ -76,7 +76,7 @@ export const methods = {
76
76
  }
77
77
  }
78
78
  if (!results.length) throw new UserError('nl2sql/invalid', '看板为空')
79
- const sent = await this.sendToChannel({ channelId, title: title || d.name || '数据看板', results, source: 'dashboard', chartMode })
79
+ const sent = await this.sendToChannel({ channelId, title: title || d.name || '数据看板', results, source: 'dashboard', chartMode, attachFull })
80
80
  await this.saveDashboardSnapshot(d, results, 'push')
81
81
  return sent
82
82
  },