@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 +8 -0
- package/README.en.md +31 -4
- package/README.md +30 -10
- package/channels/dingtalk.js +3 -3
- package/channels/lark.js +34 -6
- package/channels/message.js +18 -1
- package/channels/telegram.js +20 -4
- package/channels/wecom.js +2 -2
- package/index.js +2 -1
- package/lib/client.js +66 -15
- package/package.json +1 -1
- package/service/channels.js +22 -6
- package/service/push.js +2 -2
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
|
+
[](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
|
|
55
|
+
git clone https://github.com/hedeqiang/dsh-nl2sql.git && cd dsh-nl2sql
|
|
29
56
|
pnpm install --ignore-scripts
|
|
30
57
|
```
|
|
31
58
|
|
|
32
|
-
|
|
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
|
|
33
|
+
需要 DeepSeek Harness 0.2 及以上(桌面版或 `dsh web`)。插件已发布到 npm:[`@hedeqiang/dsh-nl2sql`](https://www.npmjs.com/package/@hedeqiang/dsh-nl2sql)。
|
|
34
|
+
|
|
35
|
+
[](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
|
-
然后在
|
|
51
|
+
然后在 **插件** 页启用它(或重启 Harness)。
|
|
40
52
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
66
|
+
在 **插件** → **添加插件** 中填入本目录的绝对路径(以链接方式安装,改代码后重新启用插件即可生效),或让 Agent 用插件管理器安装该目录。
|
|
47
67
|
|
|
48
68
|
## 使用
|
|
49
69
|
|
package/channels/dingtalk.js
CHANGED
|
@@ -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 =
|
|
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 >
|
|
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
|
-
|
|
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 ?? {}
|
package/channels/message.js
CHANGED
|
@@ -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
|
|
package/channels/telegram.js
CHANGED
|
@@ -280,7 +280,7 @@ export function inlineKeyboard(spec) {
|
|
|
280
280
|
|
|
281
281
|
// ------------------------------------------------------------ rendering
|
|
282
282
|
|
|
283
|
-
function resultMarkdown(r, { includeSql, maxRows =
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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-
|
|
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
|
|
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.
|
|
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": {
|
package/service/channels.js
CHANGED
|
@@ -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
|
},
|