@linkaicli/linkai-cli 1.0.22 → 1.0.23
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/GUIDE.md +1 -0
- package/MANUAL.md +239 -0
- package/README.md +1 -0
- package/THIRD_PARTY_NOTICES.txt +1 -1
- package/cli-manifest.json +44 -0
- package/clis/linkai/commands.js +3 -3
- package/clis/linkai/plan-shot.js +32 -0
- package/package.json +3 -2
- package/skills/agent-prompt.md +1 -0
- package/skills/linkai-browser/SKILL.md +14 -1
- package/skills/linkai-report-builder/SKILL.md +4 -3
package/GUIDE.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
让 AI 助手(Codex、Claude Code、WorkBuddy、CodeBuddy、DeepSeek 等)帮你在领谋 BI 里自动搭建报表。
|
|
4
4
|
整个过程你只需要:**安装一次、登录一次、接入一次 AI 工具**,之后直接对 AI 说需求。
|
|
5
|
+
能做什么、怎么说、有哪些边界,见安装包内的用户手册 `MANUAL.md`。
|
|
5
6
|
|
|
6
7
|
> 所有命令都在「终端」里执行:Mac 打开「终端」(启动台搜索“终端”);Windows 打开「PowerShell」(开始菜单搜索“PowerShell”)。
|
|
7
8
|
> 每一步都是:**复制命令 → 按说明修改尖括号或中文部分 → 回车执行**。
|
package/MANUAL.md
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# LinkAI-CLI 用户手册
|
|
2
|
+
|
|
3
|
+
> 适用版本:1.0.22(2026-11-06)。安装、登录、接入 AI 工具见 `GUIDE.md`;本手册说明**能做什么、怎么说、会发生什么、有哪些边界**。
|
|
4
|
+
|
|
5
|
+
LinkAI-CLI(命令 `linkai`)是领谋 BI 的 AI 命令行。你不需要记命令:在 WorkBuddy、Claude Code、Codex、CodeBuddy 等 AI 助手里**用中文说需求**,
|
|
6
|
+
AI 会按领谋的规则、用**你自己的领谋账号和权限**去做,做完把结果和依据交给你。
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 一、能力一览
|
|
11
|
+
|
|
12
|
+
| 能力 | 一句话说明 | 典型说法 |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| 问数 | 直接问业务数据,拿到数字或小表,并说明口径和数据截至时间 | 「今天采购了多少、入库了多少?」 |
|
|
15
|
+
| 自动搭建看板 | 从需求到数据集、卡片、看板、筛选器、报表一次搭好,并逐项验收 | 「帮我搭一个销售看板……先给我看计划」 |
|
|
16
|
+
| 修改已有看板 | 加卡片、改图表、调排序、改筛选器、改取数口径 | 「经营看板加一张按渠道的退货率趋势」 |
|
|
17
|
+
| 方案知识库 | 每个看板一份带版本的说明:解决什么问题、每张卡片怎么取数 | 「把这个看板的方案知识给我」 |
|
|
18
|
+
| 数据导入与填报 | 把 Excel / CSV 按规则写进填报卡片,写前预览、写后回读 | 「把这份店铺预算导进经营看板」 |
|
|
19
|
+
| 复用已有成果 | 建之前先查本租户已有的数据集 / 卡片,能复用就不重复建 | (自动进行) |
|
|
20
|
+
| 数据核对与排查 | 卡片空白、数字对不上时,沿来源一步步查原因 | 「这张卡片为什么是空的?」 |
|
|
21
|
+
| 多会话协同 | 多个 AI 会话并行做同一个方案时自动分工,不互相覆盖 | 「开两个子任务并行搭这三张卡」 |
|
|
22
|
+
| 浏览器操作(可选) | 打开领谋页面截图验收、做网页操作 | 「搭完截个图给我看看」 |
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## 二、使用场景
|
|
27
|
+
|
|
28
|
+
### 场景 1:问数——直接问一个数
|
|
29
|
+
|
|
30
|
+
**怎么说**:只想知道一个数或一张小表时直接问,越具体越好(指标、时间范围、维度)。
|
|
31
|
+
|
|
32
|
+
- 「今天采购数量有多少,入库了多少?」
|
|
33
|
+
- 「本月各二级品类销售额排名前 20 的款」
|
|
34
|
+
- 「上个月各店铺的退货率」
|
|
35
|
+
|
|
36
|
+
**会发生什么**:
|
|
37
|
+
|
|
38
|
+
1. 问题有歧义时,AI 先问你。比如「入库」指物料还是成品,「销售额」含不含退货。
|
|
39
|
+
2. AI 把问题交给领谋服务端的**小满问数**:检索口径、选数据源、生成查询,并按你的数据权限执行。
|
|
40
|
+
3. 小满答不了时,AI 改为**兜底**:读当前租户的数据字典,自己写只读查询。查询要先过领谋的数据集校验,再由服务端执行。
|
|
41
|
+
回答里会注明「小满未能回答,以下由 AI 按数据字典口径查询」。
|
|
42
|
+
4. 回答直接写在对话里,包括:
|
|
43
|
+
- 数字或小表;
|
|
44
|
+
- 口径:数据来源、用到的视图和字段;
|
|
45
|
+
- 时间范围;
|
|
46
|
+
- 「数据截至 X」:数仓多为每天凌晨更新,「今天」的数可能还不完整。
|
|
47
|
+
|
|
48
|
+
**边界**:
|
|
49
|
+
|
|
50
|
+
- **只能看到你在领谋里本来就能看到的数据。**
|
|
51
|
+
- 明细最多返回 1000 行;没有导出权限的账号最多 50 行。需要全量明细,请改问汇总,或找管理员导出。
|
|
52
|
+
- 有频率限制:每人每分钟 6 次、每小时 60 次、每天 300 次,每人每小时最多返回 5000 行。
|
|
53
|
+
- 不支持「翻页取数」,同一类问题反复换条件也会被暂停。这是防批量导出,不是故障。
|
|
54
|
+
- 同一个问题 5 分钟内再问,直接返回上次的结果。
|
|
55
|
+
- 没查到时 AI 会说「没有查到」,不会说成 0。
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
### 场景 2:自动搭建一个新看板
|
|
60
|
+
|
|
61
|
+
**怎么说**:说清楚想看什么、给谁看,并让 AI 先出计划。
|
|
62
|
+
|
|
63
|
+
- 「帮我搭一个销售看板:本期销售金额、件数、按渠道的日趋势、商品明细,先给我看计划」
|
|
64
|
+
- 「在 XX 租户建一个聚水潭销售明细看板,支持按订单 / 付款 / 发货时间和品牌、店铺、渠道筛选」
|
|
65
|
+
|
|
66
|
+
**会发生什么**:
|
|
67
|
+
|
|
68
|
+
1. AI 确认**当前租户**和**新建还是修改**。没说清楚时,它会问你。
|
|
69
|
+
2. 读领谋的搭建规则和本租户的数据字典,按你确认的口径写数据集查询。
|
|
70
|
+
3. **先演练、不写入**:给你看要建哪些数据集、卡片、看板、筛选器,以及执行计划。
|
|
71
|
+
4. 你确认后才写入,依次建数据集、卡片、看板、筛选器和报表,并逐项回读验收。
|
|
72
|
+
- 数据集保存前,先在领谋做「运行」生成字段,再试取数,取数失败不保存。
|
|
73
|
+
- 卡片和筛选器按领谋界面的保存规则校验。
|
|
74
|
+
5. 交付一份**方案知识库**:方案地址、版本、方案内容、解决的问题,以及每张卡片的内容、数据集、数据来源、粒度和取数规则。
|
|
75
|
+
|
|
76
|
+
**要你明确同意的动作**(AI 会先问):
|
|
77
|
+
|
|
78
|
+
- 使用正式名称:对所有成员可见;
|
|
79
|
+
- 覆盖看板筛选器;
|
|
80
|
+
- 修改已被其它看板或报表引用的卡片、数据集;
|
|
81
|
+
- 删除非试验对象;
|
|
82
|
+
- 切换到你没提到的租户后写入。
|
|
83
|
+
|
|
84
|
+
试验对象统一用 `zz-` 前缀,可以一键清理。
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
### 场景 3:修改已有看板
|
|
89
|
+
|
|
90
|
+
**怎么说**:说出看板名称和要改的内容。
|
|
91
|
+
|
|
92
|
+
- 「经营看板加一张按渠道的退货率趋势」
|
|
93
|
+
- 「明细表按支付时间倒序」「销售看板的店铺筛选改成多选」
|
|
94
|
+
- 「把趋势图的销售额改成不含运费」
|
|
95
|
+
|
|
96
|
+
**会发生什么**:
|
|
97
|
+
|
|
98
|
+
1. AI 定位看板,先读它现有的方案知识;改动前备份卡片、方案或数据集。
|
|
99
|
+
2. 按规则修改,并回读确认生效。
|
|
100
|
+
3. 同步更新方案知识库,版本加一并写明本次修改。没有方案知识的历史看板,会自动补建 v1。
|
|
101
|
+
|
|
102
|
+
**系统分析方案(领谋下发的看板)只能看不能改。**
|
|
103
|
+
AI 可以读它的方案知识,回答「这个数是怎么取的」,但不能改它的卡片、筛选器、排序,也不能删除。
|
|
104
|
+
需要按本租户调整时,请在领谋网页上把它**另存为本租户的分析方案**,再让 AI 改新方案。
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
### 场景 4:查看与维护方案知识库
|
|
109
|
+
|
|
110
|
+
- 「把经营看板的方案知识给我」:返回完整说明,包括每张卡片的取数逻辑。
|
|
111
|
+
- 「这个看板的销售额是怎么算的?」:AI 按方案知识和卡片配置回答。
|
|
112
|
+
|
|
113
|
+
方案知识只写口径与规则,**不向你输出完整的查询 SQL**。
|
|
114
|
+
服务器暂时不可用时,CLI 先在本机暂存,恢复后自动补传。
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
### 场景 5:数据导入与填报
|
|
119
|
+
|
|
120
|
+
**怎么说**:
|
|
121
|
+
|
|
122
|
+
- 「把这份店铺预算导进经营看板」
|
|
123
|
+
- 「本月实际数据更新到经营看板」「重传一下,看看数据对不对」
|
|
124
|
+
- 「新建一张填报卡片,用来录入每月目标」
|
|
125
|
+
|
|
126
|
+
**会发生什么**:
|
|
127
|
+
|
|
128
|
+
1. AI 找到对应看板和本租户的导入规范,核对文件的字段、单位、业务键和更新方式。
|
|
129
|
+
2. 先预览将写入的记录和范围,你确认后才写入。
|
|
130
|
+
3. 写完回读填报数据和业务汇总,报告实际写入的数量和范围。
|
|
131
|
+
没把握的地方会写「未确认」,不会把「请求条数」当成「成功条数」。
|
|
132
|
+
|
|
133
|
+
**边界**:
|
|
134
|
+
|
|
135
|
+
- 「看看数据」不算导入授权。
|
|
136
|
+
- 删除旧数据、改变口径、扩大写入范围,都要你另外同意。
|
|
137
|
+
- 删除填报记录前,必须先备份并经你同意。
|
|
138
|
+
- 只往**当前登录租户**写,目标卡片不属于当前租户时会被拦下。
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
### 场景 6:数据核对与问题排查
|
|
143
|
+
|
|
144
|
+
- 「这张卡片为什么是空的 / 一直转圈?」:AI 按页面方式查询这张卡片,看数据库报错和实际执行的查询。
|
|
145
|
+
- 「看板上的数和我 Excel 里的对不上」:AI 沿来源单元格、转换、业务键、聚合、筛选逐步溯源,给出具体差异和证据,不擅自改源数。
|
|
146
|
+
- 「这个数据集被哪些卡片用了?」:列出引用关系,改之前先评估影响。
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
### 场景 7:多个 AI 会话一起做
|
|
151
|
+
|
|
152
|
+
一个看板由多个会话或子代理并行推进时,用本机任务板分工:
|
|
153
|
+
- 读取、校验、预览全部并行;
|
|
154
|
+
- 改同一个对象之前先「认领」,别人正在改的对象会被拒绝写入,避免互相覆盖;
|
|
155
|
+
- 做完的成果(查询文件、对象 id、口径结论)随时共享,避免重复做。
|
|
156
|
+
|
|
157
|
+
只在同一台电脑上生效。单个会话做一个方案时,不需要这些。
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
### 场景 8:截图验收与网页操作(可选)
|
|
162
|
+
|
|
163
|
+
「把经营看板截个图给我」「搭完截个图看看」:AI 执行 `linkai plan-shot --plan <方案名>`,按名称找到方案,
|
|
164
|
+
**等全部卡片取完数后把整个方案截成一张图**(不只是首屏),并告诉你哪些卡片没有数据。同名方案有多个时,AI 会先问你是哪一个。
|
|
165
|
+
|
|
166
|
+
- 截图期间 LinkAI 自己的 Chrome 窗口会短暂到前台(窗口在后台时浏览器不画图表),不动你自己的标签页。
|
|
167
|
+
|
|
168
|
+
- 需要 Chrome 和 LinkAI 浏览器扩展。
|
|
169
|
+
- 操作在 LinkAI 管理的独立标签页里进行,**不会动你正在看的页面**,扩展也不会把浏览数据传到外部。
|
|
170
|
+
- 搭建和修改本身是纯接口操作,不需要浏览器。
|
|
171
|
+
- 检查浏览器连接:`linkai doctor`。
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## 三、权限与安全
|
|
176
|
+
|
|
177
|
+
- **用你自己的账号**:首次在领谋网页完成授权,之后自动续期;AI 不会、也不应该索要你的密码。
|
|
178
|
+
- **只做你本来能做的事**:取数按你的数据权限,写入按你的账号权限;租户之间严格隔离。
|
|
179
|
+
- **不能操作数据库**:不能直连数据库、执行任意 SQL、管理数据源或改数仓。数据集只能是单条只读查询,只能用本租户开放的视图;需要新视图,请联系领谋数据团队。
|
|
180
|
+
- **部分接口不对 CLI 开放**:例如知识库文档维护、小满调试接口。口径的补充和纠正,请在领谋网页上维护。
|
|
181
|
+
- **关键操作先确认**:见场景 2、5 的清单。
|
|
182
|
+
- **全程留痕**:每次操作都记入领谋的 CLI 操作日志,问数还会记录问题、口径和查询,网页「CLI 记录」可查,便于追溯。查询结果本身不记录。
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## 四、常用说法与小技巧
|
|
187
|
+
|
|
188
|
+
| 想要 | 可以这样说 |
|
|
189
|
+
|---|---|
|
|
190
|
+
| 换租户 | 「切到 XX 租户」→ AI 执行 `linkai login --tenant <租户名>`,不用重新授权 |
|
|
191
|
+
| 换账号 | 「切换账号」→ AI 先问你,确认后重新授权 |
|
|
192
|
+
| 先看不写 | 「先给我看计划 / 先预览,别写入」 |
|
|
193
|
+
| 只改试验版 | 「用 zz- 前缀先建一套试试」,之后「清理 zz- 试验对象」 |
|
|
194
|
+
| 要依据 | 「这个数的口径是什么?数据截至什么时候?」 |
|
|
195
|
+
| 新会话继续上次的活 | 直接说继续。AI 会看到上次没完成的任务,按提示续做或结束 |
|
|
196
|
+
|
|
197
|
+
**给 AI 的建议**:每个任务一开始,AI 应先开一个「工作流」并写明目标,修改已有看板时还要带上看板 id,结束时关闭。
|
|
198
|
+
这样领谋网页上能看清每个任务做了什么。AI 漏了这一步时,CLI 会提醒它补上。
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## 五、常见问题
|
|
203
|
+
|
|
204
|
+
| 问题 | 说明 |
|
|
205
|
+
|---|---|
|
|
206
|
+
| AI 说「需要登录」 | 在终端执行 `linkai login`,在领谋网页完成授权,然后告诉 AI「好了」 |
|
|
207
|
+
| AI 说「被限流了」 | 问数超过频率或数据量限制,按提示的时间再试;需要大量数据请改问汇总 |
|
|
208
|
+
| AI 说「这是系统分析方案,不能改」 | 在网页上另存为本租户的分析方案,再让 AI 改新方案 |
|
|
209
|
+
| AI 说「没有可用视图 / 不能操作数据库」 | 正常的安全限制;需要的数据请联系领谋数据团队开放 |
|
|
210
|
+
| AI 说「需要重新读取数据字典」 | 字典在服务端更新过,或换了租户;AI 重读后会继续,不用你操作 |
|
|
211
|
+
| 终端里的版本和 AI 用的版本不一样 | 电脑上装了两份(常见于 WorkBuddy 自带的 Node)。执行 `linkai doctor` 查看各份位置,只保留一份 |
|
|
212
|
+
| 怎么升级 | 自动进行。发布了必须升级的版本时,下一条命令会先自动升级再执行,技能随之更新 |
|
|
213
|
+
| 公共电脑用完 | `linkai logout`,撤销授权并删除本机登录信息 |
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## 附:命令参考(AI 使用,供了解)
|
|
218
|
+
|
|
219
|
+
所有命令输出 JSON。查看参数:`linkai <命令> --help -f json`。标「写」的命令会改动领谋里的数据。
|
|
220
|
+
|
|
221
|
+
| 类别 | 命令 |
|
|
222
|
+
|---|---|
|
|
223
|
+
| 登录与环境 | `login`、`logout`、`status`、`tenants`、`doctor` |
|
|
224
|
+
| 问数 | `ask`(读) |
|
|
225
|
+
| 数据字典(只读) | `kb-sources`、`kb-search`、`kb-catalog`、`kb-object`、`kb-docs`、`kb-doc` |
|
|
226
|
+
| 搭建知识库 | `kb`、`kinds`(74 种图表)、`template` |
|
|
227
|
+
| 自动搭建 | `build`(默认演练;`--execute` 写入;正式名还需 `--confirm`) |
|
|
228
|
+
| 数据集 | `ds-list`、`ds-tables`、`ds-fields`、`ds-sql`、`ds-refs`、`ds-match`、`sql-check`、`fmt`;写:`ds-create`、`ds-update` |
|
|
229
|
+
| 卡片 | `card-list`、`card-get`、`query`;写:`card-chart`、`card-table`、`card-meter`、`card-gantt`、`card-save`、`card-sort` |
|
|
230
|
+
| 看板(分析方案) | `plan-list`、`plan-get`;写:`plan-create`、`plan-add`、`plan-filter`、`reset-filter` |
|
|
231
|
+
| 报表 | `report-list`、`report-card`;写:`report-publish` |
|
|
232
|
+
| 方案知识库 | `plan-knowledge-get`;写:`plan-knowledge` |
|
|
233
|
+
| 填报 | 写:`fill-write`(默认预览,`--execute` 写入) |
|
|
234
|
+
| 维护 | `backup`;写:`remove`(需授权)、`clean`(只清 `zz-` 试验对象) |
|
|
235
|
+
| 工作流 | `flow-start`、`flow-end`、`flow-status` |
|
|
236
|
+
| 多会话协同 | `collab-start`、`collab-status`、`collab-task`、`collab-claim`、`collab-release`、`collab-note`、`collab-close` |
|
|
237
|
+
| 截图与浏览器(可选) | `plan-shot`(方案整页截图)、`shot`(任意页面首屏)、`browser …` |
|
|
238
|
+
| 接入 AI 工具 | `skills install <workbuddy|workbuddy-intl|claude|codex|codebuddy>`、`skills prompt`(无技能机制的工具,如 DeepSeek API) |
|
|
239
|
+
| 未封装接口 | `api-get`、`api`(数据源、SQL 类等接口不开放) |
|
package/README.md
CHANGED
package/THIRD_PARTY_NOTICES.txt
CHANGED
package/cli-manifest.json
CHANGED
|
@@ -1657,6 +1657,50 @@
|
|
|
1657
1657
|
"modulePath": "linkai/commands.js",
|
|
1658
1658
|
"sourceFile": "linkai/commands.js"
|
|
1659
1659
|
},
|
|
1660
|
+
{
|
|
1661
|
+
"site": "linkai",
|
|
1662
|
+
"name": "plan-shot",
|
|
1663
|
+
"description": "分析方案整页截图:按方案名称或 id 打开方案,等全部卡片加载完后把整个方案截成一张 PNG(需要 Chrome 与 LinkAI 扩展;在独立标签中进行)",
|
|
1664
|
+
"access": "read",
|
|
1665
|
+
"domain": "bi.linkmore.com",
|
|
1666
|
+
"strategy": "ui",
|
|
1667
|
+
"browser": true,
|
|
1668
|
+
"args": [
|
|
1669
|
+
{
|
|
1670
|
+
"name": "plan",
|
|
1671
|
+
"type": "string",
|
|
1672
|
+
"required": true,
|
|
1673
|
+
"help": "方案名称或方案 id(名称按 plan-list 精确匹配,多个同名时报出候选让用户选)"
|
|
1674
|
+
},
|
|
1675
|
+
{
|
|
1676
|
+
"name": "output",
|
|
1677
|
+
"type": "string",
|
|
1678
|
+
"required": false,
|
|
1679
|
+
"help": "截图保存路径(默认 ~/.linkai/artifacts/linkai/shots/<方案名>-<时间>.png)"
|
|
1680
|
+
},
|
|
1681
|
+
{
|
|
1682
|
+
"name": "width",
|
|
1683
|
+
"type": "int",
|
|
1684
|
+
"default": 1600,
|
|
1685
|
+
"required": false,
|
|
1686
|
+
"help": "截图宽度(像素),默认 1600"
|
|
1687
|
+
},
|
|
1688
|
+
{
|
|
1689
|
+
"name": "timeout",
|
|
1690
|
+
"type": "int",
|
|
1691
|
+
"default": 120,
|
|
1692
|
+
"required": false,
|
|
1693
|
+
"help": "整个截图最多用多少秒(打开方案、等卡片取数、截图),默认 120;方案很大时加大"
|
|
1694
|
+
}
|
|
1695
|
+
],
|
|
1696
|
+
"defaultFormat": "json",
|
|
1697
|
+
"type": "js",
|
|
1698
|
+
"modulePath": "linkai/commands.js",
|
|
1699
|
+
"sourceFile": "linkai/commands.js",
|
|
1700
|
+
"navigateBefore": false,
|
|
1701
|
+
"siteSession": "persistent",
|
|
1702
|
+
"defaultWindowMode": "foreground"
|
|
1703
|
+
},
|
|
1660
1704
|
{
|
|
1661
1705
|
"site": "linkai",
|
|
1662
1706
|
"name": "query",
|
package/clis/linkai/commands.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
var ce=Object.defineProperty;var r=(e,t)=>ce(e,"name",{value:t,configurable:!0});import y from"node:fs";import w from"node:path";import le from"node:os";import{fileURLToPath as de}from"node:url";import{cli as W,Strategy as ue}from"@linkaicli/linkai-cli/registry";import{ArgumentError as h,AuthRequiredError as pe,CliError as k}from"@linkaicli/linkai-cli/errors";import*as a from"./core.js";import*as Z from"./build.js";import*as G from"./deliverable.js";import*as ee from"./fill-card.js";import*as C from"./collab.js";import*as v from"./reuse.js";import*as x from"./dictionary.js";import*as f from"@linkaicli/linkai-cli/oplog";const l={site:a.SITE_ID,browser:!1,defaultFormat:"json"},fe={site:a.SITE_ID,domain:"bi.linkmore.com",strategy:ue.UI,browser:!0,siteSession:"persistent",navigateBefore:!1,defaultFormat:"json"},n=r((e,t,s=!0)=>({name:e,type:"string",required:s,help:t}),"str"),M=r((e,t)=>t?e.filter(s=>String(s.name).includes(t)):e,"filter"),A=r(e=>JSON.parse(a.readInputFile(e)),"json"),te=r(()=>new Date().toISOString().replace(/[-:]/g,"").replace("T","-").slice(0,15),"stamp"),P=r((e,...t)=>e||a.artifactsDir(...t),"outPath");function d(e){const t=e.func,s=r(async i=>{try{return await i()}catch(u){throw u instanceof a.LoginRequiredError?new pe("bi.linkmore.com",u.message,"请用户执行 linkai login,在浏览器完成领谋授权"):u instanceof a.TenantMismatchError?new k("TENANT_MISMATCH",u.message,"先用 linkai login --tenant <租户名> 切换到方案规范指定的租户,再用 linkai status 确认;无法切换就停止并告诉用户"):u instanceof a.NetworkError?new k("NETWORK_ERROR",u.message,"网络暂时不通(已自动重试):等 1~2 分钟或请用户检查网络 / 代理后再执行;只读命令可直接重试,写命令先用只读命令确认结果再决定是否重做",1):(u instanceof a.ApiError||u instanceof a.KindError)&&u.kind?new k(u.kind,u.message,me[u.kind],1):u instanceof C.CollabConflictError?new k("COLLAB_CONFLICT",u.message,"别的执行者正在改这个对象:先 linkai collab-status --board <任务板> 看分工,改做别的任务,或等对方释放;不要绕过",1):u}},"guard"),o=r(i=>{e.access==="write"&&!e.name.startsWith("collab-")&&C.assertWritable(C.objectsForCommand(e.name,i||{},A))},"checkCollab"),c=e.browser?async(i,u)=>s(()=>(o(u),t(i,u))):async i=>s(()=>(o(i),t(i)));return W({...e,func:c})}r(d,"site");const me={SQL_INVALID:"SQL 里有视图中不存在的字段 / 表或语法错误:用 linkai kb-object / ds-tables 核对字段名后改 SQL,先 sql-check 通过再建或改数据集",IN_USE:"对象仍被引用:先用 linkai ds-refs --ds <数据集> 找到引用它的卡片,确认后先删卡片,再删数据集",NOT_FOUND:"对象不存在或已删除:用 plan-list / card-list / ds-list 重新确认精确 id,不要重复执行写命令",FORBIDDEN:"当前用户没有该对象的权限(如私有数据集):换用自己可见的对象,或请对象负责人共享",DUPLICATE_NAME:"同名对象已存在——很可能上一次已经创建成功:先用 card-list / ds-list / plan-list --search <名称> 确认并复用,不要换个名字或重复执行同样的创建",SQL_READONLY:"数据集只能是单条只读查询(SELECT 或 WITH … SELECT),不能改数据或建表;本 CLI 没有操作数据库的能力",SQL_TABLE_NOT_ALLOWED:"只能查询 linkai ds-tables 列出的租户视图:换用清单里的视图;确实需要新视图时停下,告诉用户联系领谋数据团队开放",READ_ONLY:"这是领谋下发的系统分析方案 / 系统报表(只读):不能通过 CLI 修改(卡片、筛选器、排序、删除都不行),不要重试或换个接口再试。停下来告诉用户:如需按本租户调整,请在领谋网页上把它另存为本租户的分析方案,再对新方案操作",SYSTEM_PLAN_READONLY:"这是领谋下发的系统分析方案(只读):可以读取方案知识,但不能写入或修改。停下来告诉用户:如需按本租户调整,请在领谋网页上把它另存为本租户的分析方案,再对新方案操作",CLI_FORBIDDEN:"LinkAI CLI 不能调用这个接口:把 message 原样告诉用户,不要重试、不要换路径再试;问数请用 linkai ask,口径补充或纠正请用户在领谋网页上维护",BAD_REQUEST:"请求体结构不对:先查 references/api-catalog.md,或回读一条现有数据照抄结构;优先用已封装的命令,不要反复试",KNOWLEDGE_INVALID:"方案知识文件不完整:按 message 补齐(每张线上业务卡片一段、cardId 必填、修改方案必须写 changeSummary),格式见 templates/report-spec/plan-knowledge.example.yaml",ASK_UNAVAILABLE:"问数服务还没有上线:告诉用户这个功能暂不可用;需要数据可以先搭看板或在领谋界面查看",CONFIG_INVALID:"配置违反领谋界面的保存规则(与在界面上点保存时的校验一致):按 message 逐条修正后再保存;规则见知识库 references/card.md、filters.md",FILE_NOT_FOUND:"文件路径不对:确认文件已写出、路径相对当前目录;不要原样重复执行"},De=d({...l,name:"login",access:"write",description:"首次打开领谋 OAuth 授权页;同账号切换租户复用授权,切换账号需显式 --switch-account",args:[n("tenant","目标租户(名称、编码或 id);已有授权时无需再打开浏览器",!1),{name:"timeout",type:"int",default:300,help:"等待浏览器完成 OAuth 授权的秒数"},{name:"no-browser",type:"boolean",default:!1,help:"只打印授权地址,不自动打开系统浏览器"},{name:"switch-account",type:"boolean",default:!1,help:"用户确认切换领谋账号后,重新打开 OAuth 授权页"}],func:r(async e=>{const t=Number(e.timeout||300);if(!Number.isInteger(t)||t<1||t>1800)throw new h("timeout 必须是 1-1800 秒");if(!e["switch-account"]){let o=null;try{o=await a.ensureSession()}catch(c){if(!(c instanceof a.LoginRequiredError))throw c}if(o){const c=e.tenant?await a.switchTenant(o,e.tenant):{session:o,switched:!1};return c.switched&&(x.resetReads("switch-tenant"),a.clearShortCache()),{login:c.switched?"oauth_tenant_switch":"oauth_existing",autoRelogin:!0,...a.spaceOf(c.session)}}}x.resetReads("login"),a.clearShortCache();const s=await a.oauthLogin({tenant:e.tenant,timeoutSeconds:t,...e["no-browser"]?{browser:r(async()=>!1,"browser")}:{},switchAccount:e["switch-account"]});return{login:"oauth",autoRelogin:!0,...a.spaceOf(s)}},"func")}),Oe=W({...l,name:"logout",access:"write",description:"退出登录:撤销 OAuth 会话并删除本机登录信息",args:[],func:r(async()=>{const e=await a.oauthLogout();return a.forgetCredentials(),a.clearSession(),{logout:!0,oauthRevoked:e}},"func")}),Te=d({...l,name:"status",access:"read",description:"查看登录状态、当前租户、是否自动续登、数据源与可用表数量",args:[],func:r(async()=>{const e=await a.ensureSession(),t=a.tokenPayload(e.token),s=await a.ctx(),o=await a.allowedTables();return{loggedIn:!0,...a.spaceOf(e),autoRelogin:!0,authMode:"oauth",tokenHoursLeft:t.exp?Math.round((t.exp*1e3-Date.now())/36e4)/10:null,dataSourceId:s.dataSourceId,dataSources:s.dataSources,tables:o.size,...we()}},"func")});function ae(e){const t=e.status==="interrupted"&&e.reason==="build-incomplete"?"用同一个 spec 再执行 build --execute 会断点续跑;不再继续就清理试搭对象后 flow-end --status abandoned":e.status==="interrupted"&&e.reason==="outcome-unknown"?"上次写操作结果未知:先用只读命令回读确认现状,不要直接重复执行写命令":`继续做就在命令上加 --flow ${e.id};不再继续就 linkai flow-end --flow ${e.id} --status abandoned`;return{flow:e.id,goal:e.goal,type:e.type,plan:e.plan,source:e.source,status:e.status,reason:e.reason,lastActivity:new Date(e.lastActivity).toISOString(),lastCommand:e.lastCommand,lastErrorCode:e.lastErrorCode,build:e.build,next:t}}r(ae,"describeFlow");function we(){try{const e=f.unfinishedFlows().filter(t=>t.id!==f.currentFlow()?.id).slice(0,5).map(ae);return e.length?{unfinishedFlows:e}:{}}catch{return{}}}r(we,"unfinishedFlowsHint");const Fe=d({...l,name:"tenants",access:"read",description:"查看可用租户、当前租户及下一步;用户完成 OAuth 授权后执行",args:[],func:r(async()=>{const e=await a.ensureSession(),t=await a.tenantList(e),s=t.find(i=>i.tenantId===e.tenantId),o=t.map(i=>({name:i.name,tenantId:i.tenantId,current:i.tenantId===e.tenantId}));let c;return t.length===1?c=`只有一个租户,当前租户是「${t[0].name}」。`:c=`当前租户是「${s?.name||""}」。同账号可直接执行 linkai login --tenant <租户名> 切换,无需重新授权。`,{tenantCount:t.length,single:t.length===1,current:s?.name||a.spaceOf(e).tenantName,tenants:o,next:c}},"func")}),Ne=d({...l,name:"kinds",access:"read",defaultFormat:"table",description:"列出 74 种图表类型、字段建议与可用状态",args:[],func:r(async()=>a.kinds(),"func")}),be={answered:"回答用户:给出数字或小表,写明口径(数据源 dataSource、用到的视图 / 字段中文名见 caliber)、时间范围和「数据截至」(dataAsOf);结果被截断时说明只看了部分行。直接在对话里回答,不要另生成文件",empty:"告诉用户「没有查到符合条件的数据」,不要说成 0;可以确认时间范围或条件是否太窄",refused:"小满答不了:按 linkai-ask 技能「兜底」读当前租户数据字典、写只读 SQL,再执行 linkai ask --question <同一问题> --sql <文件> --fallback <本次 askId>;字典里也没有相关数据就如实告诉用户",failed:"小满出错:按 linkai-ask 技能「兜底」读当前租户数据字典、写只读 SQL,再执行 linkai ask --question <同一问题> --sql <文件> --fallback <本次 askId>",rejected:"按 message 告诉用户原因(翻页取数、同类问题反复换条件被暂停、排队已满等);不要换个问法或改用兜底绕过"},he={answered:"回答用户:给出数字,写明「由 AI 按数据字典口径查询」和口径(用到的视图 / 字段见 caliber)、时间范围、「数据截至」(dataAsOf);结果被截断时说明只看了部分行",empty:"告诉用户「没有查到符合条件的数据」,不要说成 0;可以确认时间范围或条件是否太窄",rejected:"按 message 修改(换用可用视图、去掉分页偏移、改为汇总等);不要换个写法绕过安全规则",estimate_rejected:"预计扫描量过大:加上时间范围或筛选条件,或改为汇总后再查",failed:"按 message 里的数据库报错改 SQL(用 kb-object 核对字段名)后再查",timeout:"查询超时:缩小时间范围、加筛选条件或改为汇总后再查"},ne=new Set(["refused","failed"]),$e=d({...l,name:"ask",access:"read",description:"问数:先把问题交给领谋服务端(与小满问数同一套)查询;小满答不了时,带 --sql 和 --fallback 由 AI 按数据字典写只读 SQL 兜底。按当前用户的数据权限执行,有频率和行数限制",args:[n("question","用户的问题(一次一个问题;有歧义先和用户确认,再把确认后的完整问题传入)"),n("keywords","从问题里提炼的关键词,逗号分隔,如「采购数量,入库数量,今天」(用于统计)",!1),n("source","指定数据源 id(可选,仅问小满时;不传由服务端按问题自动选)",!1),n("sql","兜底:AI 写的 SQL 文件,1~3 个逗号分隔(必须同时给 --fallback)",!1),n("fallback","兜底:小满没答出来的那次 askId(refused / failed)",!1),n("template","兜底:使用的指标模板编号(如 DP-PUR-002),没用不填",!1),{name:"rows",type:"int",default:30,help:"每个结果最多返回几行给 AI(1~200);更多的写到 --output 目录的文件里"},n("output","完整结果保存目录(默认 ~/.linkai/artifacts/linkai/ask)",!1)],func:r(async e=>{const t=String(e.question||"").trim();if(!t)throw new h("--question 必填:传用户的问题");if(t.length>2e3)throw new h("--question 最长 2000 字");const s=String(e.keywords||"").split(/[,,、]/).map(m=>m.trim()).filter(Boolean).slice(0,20),o=Math.min(200,Math.max(1,Number(e.rows)||30)),c=await a.ensureSession(),i=f.currentCaller(),u=f.currentFlow()?.id??null,S={name:i.agent,version:i.toolVersion,model:i.model,sessionId:i.toolSessionId,flowId:u,runId:f.currentRunId(),cliVersion:i.cliVersion},p=P(e.output,"ask"),g=r((m,B)=>{const q=m.rows||[];let j=null;return q.length>o&&(y.mkdirSync(p,{recursive:!0}),j=a.writeJsonFile(w.join(p,`${m.askId}.json`),{question:t,columns:m.columns,rows:q})),{askId:m.askId??null,status:m.status??null,message:m.message??null,columns:m.columns||[],rows:q.slice(0,o),rowCount:m.rowCount??0,rowLimit:m.rowLimit??null,truncated:!!m.truncated,...j?{file:j,shownRows:o}:{},dataAsOf:m.dataAsOf??null,caliber:m.explain??null,cached:!!m.cached,next:B[m.status]??null}},"shape"),E=r(m=>{if(m?.rateLimited)throw new k("RATE_LIMITED",m.message||"问数超过频率或数据量限制",`${m.retryAfterSeconds??60} 秒后再试,告诉用户原因;不要循环重试、换账号或拆分问题绕过。需要大量数据请改为汇总`,1)},"rateLimited");if(e.sql||e.fallback){if(!e.sql||!e.fallback)throw new h("兜底需要同时给 --sql(AI 写的 SQL 文件)和 --fallback(小满没答出来的那次 askId)");const m=a.xiaomanAttempt(e.fallback);if(!m||!ne.has(m.status))throw new k("ASK_XIAOMAN_FIRST",m?`这次小满已经回答(${m.status}),不需要兜底`:"没有找到这次小满问数记录(或已超过 2 小时)",'先执行 linkai ask --question "<问题>"(不带 --sql)问小满;只有返回 refused / failed 时,才带上它的 askId 兜底',1);const B=String(e.sql).split(/[,,]/).map(I=>I.trim()).filter(Boolean);if(!B.length||B.length>3)throw new h("--sql 需要 1~3 个 SQL 文件,逗号分隔");const q=[];for(const[I,z]of B.entries()){const D=a.readInputFile(z);if(a.hasOffset(D))throw new k("ASK_REJECTED",`第 ${I+1} 条 SQL 带分页偏移(OFFSET / LIMIT m,n):问数不支持翻页`,"改为汇总,或加时间范围 / 筛选条件缩小范围;需要完整明细请联系管理员导出",1);q.push(await a.localCheckSql(D))}await _({enforce:!0});for(const[I,z]of q.entries())try{await a.checkSql(z)}catch(D){throw D instanceof a.LoginRequiredError||D instanceof a.NetworkError?D:new k("SQL_INVALID",`第 ${I+1} 条 SQL 没有通过数据集运行校验:${String(D.message).slice(0,300)}`,"按报错核对视图与字段(kb-object),改好后再执行;不要绕过",1)}const j=await a.ctx(),F=await a.askQuery({question:t,dataSourceId:j.dataSourceId,queries:q.map(I=>({sql:I})),keywords:s,templateId:e.template||null,knowledgeRefs:[`xiaoman-ask:${e.fallback}`,...x.recentRefs({tenantId:c.tenantId,flowId:u})].slice(0,50),agent:S}),X=F?.results||[];return f.emit("ask",{requestId:F?.requestId??null,mode:"fallback",fallbackOf:e.fallback,queries:q.length,rateLimited:!!F?.rateLimited,results:X.map(I=>({askId:I.askId,status:I.status,rows:I.rowCount,cached:I.cached}))}),E(F),{mode:"fallback",fallbackOf:e.fallback,requestId:F.requestId,results:X.map(I=>g(I,he)),quota:F.quota??null}}const L=await a.askQuestion({question:t,keywords:s,dataSourceId:e.source||null,agent:S}),b=L?.results?.[0]||{};return f.emit("ask",{requestId:L?.requestId??null,mode:"question",rateLimited:!!L?.rateLimited,results:[{askId:b.askId??null,status:b.status??null,rows:b.rowCount??0,cached:!!b.cached,ms:b.elapsedMs??null}]}),E(L),b.askId&&a.recordXiaomanAttempt({askId:b.askId,status:b.status,question:t}),{mode:"question",...g(b,be),dataSource:b.dataSourceName??null,sql:b.sql??null,...ne.has(b.status)?{fallback:{allowed:!0,askId:b.askId}}:{},quota:L?.quota??null}},"func")}),_e=d({...l,name:"sql-check",access:"read",defaultFormat:"table",description:"校验数据集 SQL(只读、可用表)并列出字段;多个文件用逗号分隔一次校验(省去多次启动)",args:[n("file","SQL 文件路径;多个用逗号分隔,如 sql/a.sql,sql/b.sql")],func:r(async e=>{const t=String(e.file).split(/[,,]/).map(i=>i.trim()).filter(Boolean),s=r(i=>i.map(u=>({sort:u.sort_num,field:u.field_name,type:u.field_type,dbType:u.type_name})),"fields"),o=r(async()=>{try{await _({enforce:!1})}catch(i){if(i instanceof a.LoginRequiredError)throw i}},"dictNotice");if(t.length===1){ye();const i=a.readInputFile(t[0]);return a.precheckSql(i),await o(),s(await a.checkSql(i))}await o();const c=[];for(const i of t)try{c.push({file:i,ok:!0,fields:s(await a.checkSql(a.readInputFile(i)))})}catch(u){if(u instanceof a.LoginRequiredError)throw u;c.push({file:i,ok:!1,error:String(u.message).slice(0,500)})}return{total:t.length,failed:c.filter(i=>!i.ok).length,results:c}},"func")});function ye(){try{const e=w.join(le.homedir(),".linkai","oplog","hints.json");let t={};try{t=JSON.parse(y.readFileSync(e,"utf8"))||{}}catch{}const s=Date.now(),o=[...(t.sqlCheck||[]).filter(c=>s-c<5*6e4),s];t.sqlCheck=o.slice(-10),o.length>=3&&s-(t.sqlCheckNoticeAt||0)>10*6e4&&(t.sqlCheckNoticeAt=s,f.addNotice("最近几分钟在逐个校验 SQL:可以一次校验多个文件,如 linkai sql-check --file sql/a.sql,sql/b.sql,sql/c.sql(每条命令都有启动开销,逐个跑更慢),结果按文件分别列出。","sql-check-batch")),y.mkdirSync(w.dirname(e),{recursive:!0}),y.writeFileSync(e,JSON.stringify(t),{mode:384})}catch{}}r(ye,"nudgeBatchSqlCheck");const Re=d({...l,name:"fmt",access:"read",defaultFormat:"plain",description:"把 SQL 格式化为易读的多行文本",args:[n("file","SQL 文件路径")],func:r(async e=>a.formatSql(a.readInputFile(e.file)),"func")}),Be=d({...l,name:"ds-list",access:"read",defaultFormat:"table",description:"列出当前租户的数据集",args:[n("search","按名称关键字过滤(可选)",!1)],func:r(async e=>M(await a.dsList(),e.search),"func")}),je=d({...l,name:"ds-tables",access:"read",defaultFormat:"table",description:"列出数据集 SQL 可以使用的表(视图)",args:[n("search","按表名关键字过滤(可选)",!1)],func:r(async e=>[...await a.allowedTables()].sort().filter(t=>!e.search||t.includes(String(e.search).toLowerCase())).map(t=>({table:t})),"func")}),ve=d({...l,name:"ds-fields",access:"read",defaultFormat:"table",description:"列出数据集字段(列名就是卡片引用的映射键)",args:[n("ds","数据集的精确 id")],func:r(async e=>(await a.dsFields(e.ds)).map(t=>({sort:t.sortNum,column:t.tableFieldComment,type:t.tableFieldType,argumentType:t.argumentType})),"func")}),Pe=d({...l,name:"ds-sql",access:"read",description:"把数据集当前保存的 SQL 读出到文件",args:[n("ds","数据集的精确 id"),n("output","输出 SQL 路径(默认 ~/.linkai/artifacts/linkai/sql/<dsId>.sql)",!1)],func:r(async e=>{const t=await a.dsDetail(e.ds),s=a.writeJsonFile(P(e.output,"sql",`${e.ds}.sql`),t.sql||"");return{name:t.name,lines:(t.sql||"").split(`
|
|
2
|
-
`).length,output:s}},"func")}),Qe=d({...l,name:"ds-refs",access:"read",defaultFormat:"table",description:"列出引用该数据集的卡片",args:[n("ds","数据集的精确 id")],func:r(async e=>a.dsRefs(e.ds),"func")}),Ke=d({...l,name:"ds-match",access:"read",description:"复用查重:在当前租户里找与这段 SQL 完全一样或相似的数据集(建数据集前先查,不含 zz- 试搭对象)",args:[n("file","SQL 文件路径"),{name:"top",type:"int",default:3,help:"相似候选最多几个"}],func:r(async e=>{const t=v.matchDataset(a.readInputFile(e.file),await v.datasetIndex(),{top:Number(e.top)||3});return{...t,next:t.identical.length?`有完全一样的数据集,直接复用 ${t.identical[0].dsId}(build spec 的 datasets 写 id,或 spec 加 reuse: auto)`:t.similar.length?"有相似数据集:比较 columns 与口径后决定复用、在原数据集上调整(先 ds-refs 看影响),还是新建":"没有可复用的数据集,可以新建"}},"func")}),Ue=d({...l,name:"ds-create",access:"write",description:"新建 SQL 数据集(先做只读与可用表校验,并格式化 SQL);当前租户已有完全一样的数据集时拒绝,除非 --allow-duplicate",args:[n("name","数据集名称(租户内唯一;试验用 zz- 前缀)"),n("file","SQL 文件路径"),{name:"allow-duplicate",type:"boolean",default:!1,help:"已有完全一样的数据集仍要新建(需说明理由,如要独立演进口径)"}],func:r(async e=>{const t=a.readInputFile(e.file);a.precheckSql(t),await _({enforce:!0});let s=null;try{s=v.matchDataset(t,await v.datasetIndex())}catch(i){if(i instanceof a.LoginRequiredError)throw i}if(s?.identical.length&&!e["allow-duplicate"])throw new k("DUPLICATE_DATASET",`当前租户已有完全一样的数据集:${s.identical.map(i=>`「${i.name}」(${i.dsId})`).join("、")}`,"直接复用该数据集(build spec 的 datasets 写 id,或 spec 加 reuse: auto);确需独立一份才加 --allow-duplicate",1);const o=await a.dsSave({name:e.name,sql:t}),c=await a.dsFields(o);return{dsId:o,name:e.name,fields:c.map(i=>i.tableFieldComment),...s?.similar.length?{similar:s.similar,next:"已有相似数据集:确认口径确实不同;如可合并,后续改为复用"}:{}}},"func")}),Me=d({...l,name:"ds-update",access:"write",description:"原地更新数据集(id 不变,卡片映射不断)",args:[n("ds","数据集的精确 id"),n("name","数据集名称"),n("file","SQL 文件路径")],func:r(async e=>{const t=a.readInputFile(e.file);a.precheckSql(t);const s=await a.dsDetail(e.ds);if(s?.name===e.name&&s?.sql&&v.sameSql(t,s.sql)){const i=await a.dsFields(e.ds);return{dsId:e.ds,sameId:!0,unchanged:!0,fields:i.map(u=>u.tableFieldComment),next:"SQL 和名称与线上一致,未重复保存;不要再次执行同样的更新"}}await _({enforce:!0});const o=await a.dsSave({id:e.ds,name:e.name,sql:t,current:s}),c=await a.dsFields(o);return{dsId:o,sameId:o===e.ds,fields:c.map(i=>i.tableFieldComment)}},"func")}),Je=d({...l,name:"card-list",access:"read",defaultFormat:"table",description:"列出当前租户的卡片",args:[n("search","按名称关键字过滤(可选)",!1)],func:r(async e=>M(await a.cardList(),e.search),"func")}),ze=d({...l,name:"card-get",access:"read",description:"把卡片完整配置保存为 JSON 文件(修改前的基线,或 card-save 的输入)",args:[n("card","卡片的精确 id"),n("output","输出 JSON 路径(默认 ~/.linkai/artifacts/linkai/cards/<cardId>.json)",!1)],func:r(async e=>{const t=await a.cardDetail(e.card),s=a.writeJsonFile(P(e.output,"cards",`${e.card}.json`),t);return{name:t.name,fields:(t.indexBlockDataSettingDtos||[]).length,output:s}},"func")}),We=d({...l,name:"card-save",access:"write",description:"保存完整的卡片配置 JSON(唯一能让配置生效的保存方式)",args:[n("file","修改后的卡片 JSON 文件路径")],func:r(async e=>{const t=A(e.file);if(!t.id)throw new Error("Card payload must contain id");const s=typeof t.chartSetting=="string"?JSON.parse(t.chartSetting||"{}"):t.chartSetting||{};t.chartSetting=JSON.stringify(s),t.chartType=t.chartType||s.id,t.chartParentType=t.chartParentType||s.parentId,await a.post("/index-block/update-new",t),String(t.chartType||s.id)==="agtable"&&s.config?.fitTableWidth!==!0&&f.addNotice("这张 AG 表格没有开启自适应宽度(chartSetting.config.fitTableWidth)。按 BI 操作规则应设为 true,列宽 agColumnWidths 只作为比例(如等分统一 50);用户明确要求固定宽度时除外。","ag-fit-width");const o=await a.cardDetail(t.id);return{cardId:t.id,name:o.name,fields:(o.indexBlockDataSettingDtos||[]).length}},"func")}),Ge=d({...l,name:"fill-write",access:"write",description:"写自由填报数据:一条记录一个元素、字段一次带齐(默认只校验并预览,加 --execute 才写入)",args:[n("card","填报卡片的精确 id(usedIndexBlockId)"),n("file",'记录 JSON 数组文件:[{"字段名":"值",…,"_id":"已有记录ID(可选)"}]'),n("key","填报块:fillfieldid(第一个块)、fillfieldid1(第二个块)…",!1),{name:"batch",type:"int",default:100,help:"每次请求的记录数(1-500)"},{name:"execute",type:"boolean",default:!1,help:"确认后真正写入"}],func:r(async e=>{const t=Number(e.batch||100);if(!Number.isInteger(t)||t<1||t>500)throw new h("batch 必须是 1-500");await a.assertCardInTenant(e.card);const s=await a.cardDetail(e.card);if(s?.isFreeFill===!1)throw new Error("该卡片不是自由填报卡片;fill-write 只支持自由填报");const o=ee.fillBlockOf(s,e.key||"fillfieldid"),c=ee.buildFillValues(o,A(e.file)),i=[];for(let p=0;p<c.length;p+=t)i.push(c.slice(p,p+t));const u={cardId:e.card,name:s.name,key:o.key,fields:o.fields,uniqueFields:o.uniqueFields,records:c.length,updates:c.filter(p=>p.value).length,batches:i.length,sample:c.slice(0,2)};if(!e.execute)return{...u,executed:!1,next:"核对字段与记录数后,用户确认再加 --execute"};const S=[];for(const[p,g]of i.entries())try{await a.post("/index-block/new-fill-field",{usedIndexBlockId:e.card,fillValues:g}),S.push(g.length)}catch(E){throw new Error(`第 ${p+1}/${i.length} 批写入失败(已成功 ${S.length} 批、${S.reduce((L,b)=>L+b,0)} 条):${E.message}。先回读填报表按依据字段核对,再只补未成功的部分,不要整批重跑`)}return{...u,executed:!0,written:S.reduce((p,g)=>p+g,0),next:"回读填报表本身:行数、依据字段无空值无重复、抽查整行字段完整;再核对看板"}},"func")}),Ve=d({...l,name:"card-chart",access:"write",description:"按图表类型新建或覆盖图表卡片(指定维度、数值、系列列)",args:[n("ds","数据集的精确 id"),n("name","卡片名称"),n("kind","图表类型,如 column/stacked(linkai kinds 查看全部)"),n("dim","维度列名,多个用逗号分隔",!1),n("meas","数值列名,多个用逗号分隔"),n("series","系列列名(可选)",!1),n("card","要覆盖的已有卡片 id(可选)",!1)],func:r(async e=>a.cardChart({dsId:e.ds,name:e.name,kind:e.kind,dim:e.dim,meas:e.meas,series:e.series,id:e.card}),"func")}),He=d({...l,name:"card-meter",access:"write",description:"新建或覆盖单数值指标卡(meter/list)",args:[n("ds","数据集的精确 id"),n("name","卡片名称"),n("col","数值列名"),n("subtitle","副标题",!1),n("color","数值颜色(十六进制,如 #E8611C)",!1),n("card","要覆盖的已有卡片 id(可选)",!1)],func:r(async e=>a.cardMeter({dsId:e.ds,name:e.name,col:e.col,subtitle:e.subtitle,color:e.color||void 0,id:e.card}),"func")}),Ye=d({...l,name:"card-table",access:"write",description:"按 spec JSON 新建或覆盖 AG 明细表(见 references/card.md)",args:[n("ds","数据集的精确 id"),n("spec","spec JSON 文件路径"),n("card","要覆盖的已有卡片 id(可选)",!1)],func:r(async e=>a.cardTable({dsId:e.ds,spec:A(e.spec),id:e.card}),"func")}),Xe=d({...l,name:"card-gantt",access:"write",description:"按 spec JSON 新建或覆盖甘特图卡片(见 references/gantt.md)",args:[n("ds","数据集的精确 id"),n("spec","spec JSON 文件路径"),n("card","要覆盖的已有卡片 id(可选)",!1)],func:r(async e=>a.cardGantt({dsId:e.ds,spec:A(e.spec),id:e.card}),"func")}),Ze=d({...l,name:"card-sort",access:"write",description:'设置卡片持久排序("列:asc,列:desc"、.json 文件或 clear),并回读确认排序生效',args:[n("card","卡片的精确 id"),n("spec",'"waxis3:desc,ID:asc" | sort.json | clear')],func:r(async e=>{const t=/\.json$/i.test(String(e.spec))?A(e.spec):e.spec;return a.cardSort(e.card,Array.isArray(t)?t:t.sortField||t)},"func")}),et=d({...l,name:"query",access:"read",description:"按页面方式查询卡片数据;返回状态码、报错信息、行数和实际执行的 SQL",args:[n("card","卡片的精确 id"),{name:"page-size",type:"int",default:20,help:"取数行数"},n("filters","筛选条件 JSON 文件路径",!1),n("output","数据行保存路径(不在屏幕上输出数据)",!1)],func:r(async e=>{const t=await a.queryCard({cardId:e.card,pageSize:e["page-size"],filters:e.filters?A(e.filters):[]});return e.output&&a.writeJsonFile(e.output,t.data),delete t.data,t},"func")}),tt=d({...l,name:"plan-list",access:"read",defaultFormat:"table",description:"列出分析方案(看板)",args:[n("search","按名称关键字过滤(可选)",!1)],func:r(async e=>M(await a.planList(),e.search),"func")}),at=d({...l,name:"plan-get",access:"read",description:"把方案详情保存为 JSON 文件(含租户模板筛选器)",args:[n("plan","方案的精确 id"),n("output","输出 JSON 路径(默认 ~/.linkai/artifacts/linkai/plans/<planId>.json)",!1),{name:"personal",type:"boolean",default:!1,help:"读取自己的个人筛选器副本"}],func:r(async e=>{const t=await a.planDetail(e.plan,!e.personal),s=a.writeJsonFile(P(e.output,"plans",`${e.plan}.json`),t);return{planId:e.plan,cards:(t.pageCards||[]).map(o=>({pageCardId:o.id,name:o.indexBlock?.name})),output:s}},"func")}),nt=d({...l,name:"plan-create",access:"write",description:"新建空的分析方案(看板)",args:[n("name","方案名称")],func:r(async e=>{const t=await a.post("/page-setting/new-create-empty-plan",{name:e.name,isPrivate:!1,pageType:0});return f.addNotice(`手工搭建方案:完成后必须用 linkai plan-knowledge --plan ${t} --mode create --file <知识文件> 交付方案知识库(铁律 2)。新建看板优先用 linkai build(自动生成知识库并验收)。`,"plan-knowledge"),{planId:t,name:e.name}},"func")}),st=d({...l,name:"plan-add",access:"write",description:"把卡片放到方案的指定位置;返回 pageCardId(绑定筛选器时使用)",args:[n("plan","方案的精确 id"),n("card","卡片的精确 id"),{name:"x",type:"int",default:0,help:"横向位置(格,0–23)"},{name:"y",type:"int",default:0,help:"纵向位置(格)"},{name:"w",type:"int",default:24,help:"宽度(格,满宽 24)"},{name:"h",type:"int",default:20,help:"高度(格)"}],func:r(async e=>a.planAdd({planId:e.plan,cardId:e.card,x:e.x,y:e.y,w:e.w,h:e.h}),"func")}),ot=d({...l,name:"plan-filter",access:"write",description:"保存方案筛选器为租户模板(全量覆盖),并重置自己的个人副本",args:[n("plan","方案的精确 id"),n("file","筛选器请求体 JSON 文件路径")],func:r(async e=>a.planFilter(e.plan,A(e.file)),"func")}),rt=d({...l,name:"report-list",access:"read",defaultFormat:"table",description:"列出数据报表(每个报表对应一张卡片)",args:[n("search","按名称关键字过滤(可选)",!1)],func:r(async e=>M(await a.reportList(),e.search),"func")}),it=d({...l,name:"report-card",access:"read",description:"查询卡片对应报表的 userPageCardId(重置报表筛选器时使用)",args:[n("card","卡片的精确 id")],func:r(async e=>{const t=await a.reportDetail(e.card);return{cardId:e.card,userPageCardId:t.id}},"func")}),ct=d({...l,name:"report-publish",access:"write",description:"把卡片发布为数据报表",args:[n("card","卡片的精确 id"),n("name","报表名称")],func:r(async e=>{await a.post("/index-block/operate-report",{indexBlockId:e.card,name:e.name,tag:"",isPrivate:!1});const t=(await a.reportList()).filter(s=>s.cardId===e.card);return{cardId:e.card,published:t.length===1,url:`https://bi.linkmore.com/view/table?indexBlockId=${e.card}&status=view`}},"func")}),lt=d({...l,name:"reset-filter",access:"write",description:"丢弃自己的个人筛选器副本,按模板重建(方案或报表卡片)",args:[n("plan","方案 id(方案筛选器)",!1),n("page-card","报表 userPageCardId 或方案 pageCardId(卡片筛选器)",!1)],func:r(async e=>{if(!!e.plan==!!e["page-card"])throw new Error("Pass exactly one of --plan or --page-card");const t=e.plan?{userPageSettingId:e.plan,filterSourceType:4}:{userPageCardId:e["page-card"],filterSourceType:2};return{filters:(await a.post("/page-setting/reset-user-filter",t)||[]).map(o=>o.labelName)}},"func")}),V=w.resolve(w.dirname(de(import.meta.url)),"..","..");try{a.setClientVersion(JSON.parse(y.readFileSync(w.join(V,"package.json"),"utf8")).version)}catch{}const Q={knowledge:w.join(V,"knowledge"),templates:w.join(V,"templates")};function J(e,t=e){return y.existsSync(e)?y.readdirSync(e,{withFileTypes:!0}).flatMap(s=>{const o=w.join(e,s.name);return s.isDirectory()?J(o,t):s.name.startsWith(".")?[]:[w.relative(t,o)]}):[]}r(J,"walk");function H(e,t){const s=Q[e],o=w.resolve(s,t);if(o!==s&&!o.startsWith(s+w.sep))throw new h("路径必须在 knowledge/ 或 templates/ 之内");if(!y.existsSync(o)||y.statSync(o).isDirectory())throw new h(`文件不存在:${e}/${t}`,"执行 linkai kb 查看可用文件");return y.readFileSync(o,"utf8")}r(H,"readLocal");function Y(e){return new k("KB_UNAVAILABLE",`知识库服务暂不可用:${e}`,"稍后重试;持续出现请联系领谋",1)}r(Y,"kbUnavailable");function N(e){if(!(e instanceof a.AiKbError))return e;const t={400:"AI_KB_BAD_REQUEST",403:"AI_KB_FORBIDDEN",404:"AI_KB_NOT_FOUND",409:"AI_KB_CONFLICT",503:"AI_KB_UNAVAILABLE"},s={404:"确认 source、对象或文档属于当前租户;数据字典不会回退本地内容",409:"知识元数据未就绪时先联系知识库维护方;文档持续变化时稍后重试",503:"稍后重试;数据字典没有本地兜底,持续出现请联系领谋"};return new k(t[e.code]||"AI_KB_ERROR",e.message,s[e.code],1)}r(N,"aiKbFailure");function O(e,t,s,o,c){const i=e==null||e===""?t:Number(e);if(!Number.isInteger(i)||i<o||i>c)throw new h(`${s} 必须是 ${o}~${c} 的整数`);return i}r(O,"intOption");async function K(e){const t=await a.ctx();if(String(e).toLowerCase()!==String(t.dataSourceId).toLowerCase())throw new k("AI_KB_SOURCE_MISMATCH",`数据字典 source ${e} 与当前数据集 source ${t.dataSourceId} 不一致`,"不要跨 source 使用元数据;先确认服务端已为当前数据源完成探库,或确认 CLI 的数据源配置",1)}r(K,"requireDatasetSource");function se(){return J(w.join(Q.knowledge,"report-builder")).map(e=>({file:`knowledge/report-builder/${e}`,title:"",summary:"",library:"bi-builder",scope:"public",source:"bundled-fallback"}))}r(se,"localBiDocs");function ke(e){return{file:`knowledge/${e.canonicalPath}`,title:"",library:e.library,scope:e.scope,source:"bundled-fallback",content:H("knowledge",e.canonicalPath)}}r(ke,"localBiDoc");function Se(e,t){const s=Array.isArray(t)?t:null,o=s?"bi-builder":t?.library||(String(t?.file||"").startsWith("templates/")?"templates":String(t?.file||"").includes("browser/")?"cli-docs":"bi-builder");return f.emit("kb.read",s?{library:o,op:e,docs:s.length,sources:[...new Set(s.map(c=>c?.source).filter(Boolean))]}:{library:o,op:e,file:t?.file??null,source:t?.source??null,chars:typeof t?.content=="string"?t.content.length:void 0}),t}r(Se,"logKb");async function $(e,t,s,o={}){if(f.emit("kb.read",{library:"data-dictionary",op:e,source:t.source??null,...o,...e!=="sources"?{cached:a.kbCacheHit()}:{}}),e!=="sources"&&t.source){const c=a.readSession()?.tenantId,i=f.currentFlow()?.id??null;let u=null;try{u=(await a.dictSourcesCached()).find(S=>String(S.source).toLowerCase()===String(t.source).toLowerCase())?.revision||null}catch{}x.recordRead({tenantId:c,source:t.source,flowId:i,op:e,revision:u}),e==="doc"&&t.doc&&x.recordRef({tenantId:c,flowId:i,ref:`doc:${t.doc}${o.title?` ${o.title}`:""}`}),e==="object"&&o.object&&x.recordRef({tenantId:c,flowId:i,ref:`object:${o.object}`})}return s}r($,"logDict");async function _({enforce:e}){const t=await a.ensureSession(),s=await a.ctx(),o=await x.dictionaryStatus({tenantId:t.tenantId,source:s.dataSourceId,flowId:f.currentFlow()?.id??null,fetchSources:r(()=>a.dictSourcesCached(),"fetchSources"),fetchDocs:r(async()=>(await a.dictDocsHead(s.dataSourceId)).items,"fetchDocs")});if(a.logGuard("dictionary-fresh",o.ok,{tenantId:t.tenantId,source:s.dataSourceId,reason:o.reason??o.skipped??null,readAt:o.readAt??null,dictUpdatedAt:o.dictUpdatedAt??null,enforce:e}),o.skipped&&f.addNotice(`数据字典:${o.skipped}。`,"dictionary-skipped"),!o.ok){if(e)throw new k("DICTIONARY_REQUIRED",o.reason,x.readHint(s.dataSourceId),1);f.addNotice(`数据字典:${o.reason}。正式写入(ds-create / ds-update / build --execute)前会被拒绝。${x.readHint(s.dataSourceId)}`,"dictionary-stale")}return o}r(_,"checkDictionary");const oe=r((e,t,s)=>Array.isArray(e)?e.slice(0,t).map(s):[],"top"),dt=d({...l,name:"kb",access:"read",description:"读取文件型 BI 搭建知识库(服务器优先、本地兜底);数据字典请使用 kb-sources / kb-search 等结构化命令",args:[n("file","文件路径,如 knowledge/report-builder/build-spec.md 或 templates/report-spec/report.example.yaml",!1),n("search","按关键字过滤目录",!1),n("library","文件型知识库:bi-builder;data-dictionary 请使用 kb-sources 等结构化命令;另预留 integration-flow | user",!1)],func:r(async e=>Se(e.file?"doc":"catalog",await(async()=>{const t=String(e.file||"").replace(/^\.?\//,"");if(t.startsWith("templates/"))return{file:t,source:"bundled",content:H("templates",t.slice(10))};if(t==="knowledge/browser/README.md"||t==="browser/README.md")return{file:"knowledge/browser/README.md",source:"bundled-cli",content:H("knowledge","browser/README.md")};const s=e.search?String(e.search):"",o=e.library?String(e.library):"";if(o&&!a.KB_LIBRARIES[o])throw new h(`未知知识库:${o}`);if(o==="data-dictionary"||/^(?:knowledge\/)?data-dictionary(?:\/|$)/.test(t))throw new k("KB_STRUCTURED_API","数据字典不再按 Markdown 文件路径读取","先执行 linkai kb-sources,再使用 kb-search / kb-catalog / kb-object / kb-docs / kb-doc",1);if(o&&!a.KB_LIBRARIES[o].enabled)throw new k("KB_NOT_IMPLEMENTED",`知识库 ${o} 已预留,当前版本尚未启用`,void 0,1);if(!t){const i=o?[o]:["bi-builder"],u=[];for(const p of i)try{const g=await a.kbCatalog(p);p==="bi-builder"&&!(g.docs||[]).length?u.push(...se()):u.push(...(g.docs||[]).map(E=>{const L=a.KB_LIBRARIES[p].pathPrefix,b=a.kbRequest(E.path,p).path;return{file:`knowledge/${L}/${b}`,title:E.title||"",summary:E.summary||"",library:p,scope:a.KB_LIBRARIES[p].scope,source:"server"}}))}catch(g){if(p==="bi-builder"&&g instanceof a.KbUnavailableError)u.push(...se());else throw g instanceof a.KbUnavailableError?Y(p):g}const S=!o||o==="bi-builder"?J(Q.templates).map(p=>({file:`templates/${p}`,title:"报表模版",summary:"",source:"bundled"})):[];return[...u,...S].filter(p=>!s||p.file.includes(s)||p.title.includes(s))}const c=a.kbRequest(t,o||void 0);try{const i=await a.kbDoc(t,o||void 0);return{file:`knowledge/${i.path}`,title:i.title||"",library:i.library,scope:i.scope,source:"server",content:i.content||""}}catch(i){if(c.library==="bi-builder"&&(i instanceof a.KbUnavailableError||i instanceof a.KbDocumentNotFoundError)){if(!y.existsSync(w.join(Q.knowledge,"report-builder")))throw Y(c.library);return ke(c)}throw i instanceof a.KbUnavailableError?Y(c.library):i}})()),"func")}),ut=d({...l,name:"kb-sources",access:"read",defaultFormat:"table",description:"列出当前租户可读的数据字典数据源,并标记当前数据集所用 source;后续命令必须显式传该 ID",args:[],func:r(async()=>{try{const[e,t]=await Promise.all([a.dictSourcesCached(),a.ctx()]),s=e.map(c=>({...c,selectedForDatasets:String(c.source).toLowerCase()===String(t.dataSourceId).toLowerCase()})),o=s.some(c=>c.selectedForDatasets);return $("sources",{},null,{sources:s.map(c=>({source:c.source,name:c.name})),aligned:o}),{datasetSourceId:t.dataSourceId,aligned:o,sources:s,...o?{}:{warning:"数据字典没有返回当前数据集 source;不要跨 source 使用元数据,请先确认服务端探库或数据源配置"}}}catch(e){throw N(e)}},"func")}),pt=d({...l,name:"kb-catalog",access:"read",description:"分页列出指定 source 的可用视图/对象元数据",args:[n("source","数据源 ID(来自 kb-sources,必须与后续数据集 source 一致)"),n("search","表名、中文名或说明关键字",!1),n("page","页码,默认 1",!1),n("page-size","每页 1~100,默认 30",!1)],func:r(async e=>{try{await K(e.source);const t={source:e.source,keyword:e.search,pageIndex:O(e.page,1,"page",1,2147483647),pageSize:O(e["page-size"],30,"page-size",1,100)},s=await a.kbCached(e.source,`catalog|${t.keyword??""}|${t.pageIndex}|${t.pageSize}`,()=>a.aiKbCatalog(t));return $("catalog",e,s,{keyword:e.search??null,total:s?.totalCount??null})}catch(t){throw N(t)}},"func")}),ft=d({...l,name:"kb-object",access:"read",description:"读取指定 source 中一个可见对象的字段与关联关系",args:[n("source","数据源 ID(来自 kb-sources)"),n("db","数据库/schema 名"),n("object","对象/视图名")],func:r(async e=>{try{await K(e.source);const t=await a.kbCached(e.source,`object|${String(e.db).toLowerCase()}.${String(e.object).toLowerCase()}`,()=>a.aiKbObject({source:e.source,db:e.db,object:e.object}));return $("object",e,t,{object:`${e.db}.${e.object}`})}catch(t){throw N(t)}},"func")}),mt=d({...l,name:"kb-docs",access:"read",description:"分页列出指定 source 的业务口径、枚举、关系、数据点等知识文档",args:[n("source","数据源 ID(来自 kb-sources)"),n("category","caliber | enum | relation | datapoint | other",!1),n("search","标题或正文关键字",!1),n("page","页码,默认 1",!1),n("page-size","每页 1~100,默认 30",!1)],func:r(async e=>{const t=e.category?String(e.category):void 0;if(t&&!["caliber","enum","relation","datapoint","other"].includes(t))throw new h("category 必须是 caliber、enum、relation、datapoint、other 之一");try{await K(e.source);const s={source:e.source,category:t,keyword:e.search,pageIndex:O(e.page,1,"page",1,2147483647),pageSize:O(e["page-size"],30,"page-size",1,100)},o=await a.kbCached(e.source,`docs|${t??""}|${s.keyword??""}|${s.pageIndex}|${s.pageSize}`,()=>a.aiKbDocs(s));return $("docs",e,o,{category:t??null,keyword:e.search??null,total:o?.totalCount??null})}catch(s){throw N(s)}},"func")}),wt=d({...l,name:"kb-doc",access:"read",description:"一致性读取一篇完整数据字典文档;自动按 nextOffset 分页,版本变化时从头重读",args:[n("source","数据源 ID(来自 kb-sources)"),n("doc","文档 ID(来自 kb-docs 或 kb-search)"),n("page-size","单次正文字符数,1~20000,默认 20000",!1)],func:r(async e=>{try{await K(e.source);const t=await a.kbCached(e.source,`doc|${String(e.doc).toLowerCase()}`,()=>a.aiKbDoc({source:e.source,doc:e.doc,limit:O(e["page-size"],2e4,"page-size",1,2e4)}));return $("doc",e,t,{doc:e.doc,title:t?.title??null,category:t?.category??null,chars:t?.totalChars??null})}catch(t){throw N(t)}},"func")}),bt=d({...l,name:"kb-search",access:"read",description:"按自然语言检索指定 source 的候选视图与业务知识;相关度不是正确率,使用前继续回读对象/文档核对",args:[n("source","数据源 ID(来自 kb-sources)"),n("question","业务问题,最长 2000 字"),n("top-tables","最多返回表数,1~10,默认 5",!1),n("top-docs","最多返回文档数,1~10,默认 3",!1)],func:r(async e=>{try{await K(e.source);const t={source:e.source,question:e.question,topTables:O(e["top-tables"],5,"top-tables",1,10),topDocs:O(e["top-docs"],3,"top-docs",1,10)},s=await a.kbCached(e.source,`search|${t.topTables}|${t.topDocs}|${a.normalizeQuestion(t.question)}`,()=>a.aiKbSearch(t));return $("search",e,s,{question:String(e.question||"").slice(0,200),hitTables:oe(s?.tables,10,o=>`${o.db}.${o.object}`),hitDocs:oe(s?.docs,10,o=>({doc:o.doc,title:o.title??null,category:o.category??null})),emptyReason:s?.emptyReason??null})}catch(t){throw N(t)}},"func")}),ht=W({...l,name:"template",access:"write",description:"复制报表 spec 与示例文件;SQL 示例必须由 AI 按用户需求、租户数据字典和确认口径重写,CLI 不生成业务 SQL",args:[n("output","目标目录(默认 ./linkai-report),需不存在或为空",!1)],func:r(async e=>{const t=w.resolve(String(e.output||"linkai-report"));if(y.existsSync(t)&&y.readdirSync(t).length)throw new h(`目录已存在且不为空:${t}`,"换一个 --output 目录");return y.cpSync(w.join(Q.templates,"report-spec"),t,{recursive:!0}),{output:t,files:J(t),next:`修改 ${w.join(t,"report.example.yaml")} 与 sql/ 下的 SQL,然后 linkai build --spec ${w.join(t,"report.example.yaml")}`}},"func")}),yt=d({...l,name:"build",access:"write",description:"严格按 BI 公共知识库执行 AI 提供的 SQL 与报表 spec(CLI 不生成业务 SQL)。默认只校验并给出执行计划;--execute 才写入;含正式名还需 --confirm",args:[n("spec","报表 spec 文件(.yaml/.json),模版用 linkai template 复制"),{name:"execute",type:"boolean",default:!1,help:"真正写入(默认只校验并输出执行计划)"},{name:"confirm",type:"boolean",default:!1,help:"用户已明确授权使用正式名(非 zz- 前缀)"},{name:"no-style",type:"boolean",default:!1,help:"跳过页面样式步骤(暖色背景、卡位透明)"}],func:r(async e=>{const t=Z.loadSpec(e.spec),s=(t.spec.datasets||[]).some(i=>!i.id&&i.sql),o=r(async()=>{const i=await a.ensureSession(),u=a.spaceOf(i);if(t.spec.tenant&&![u.tenantId,u.tenantName,u.tenantCode].includes(String(t.spec.tenant)))throw new h(`当前登录的租户是「${u.tenantName}」,与 spec.tenant「${t.spec.tenant}」不一致`,"确认目标租户后执行 linkai login --tenant <租户名>,或修改 spec.tenant");s&&await _({enforce:!0})},"beforeWrite"),c=await Z.runBuild(t,{execute:!!e.execute,confirm:!!e.confirm,style:!e["no-style"],beforeWrite:o});if(!e.execute&&s&&c.ok!==!1)try{c.dictionary=await _({enforce:!1})}catch(i){if(i instanceof a.LoginRequiredError)c.dictionary={ok:!1,reason:"未登录"};else throw i}if(c.ok===!1&&(c.stage==="validate"||c.stage==="authorize"))throw new h(`build ${c.stage} failed:
|
|
1
|
+
var ue=Object.defineProperty;var r=(e,t)=>ue(e,"name",{value:t,configurable:!0});import I from"node:fs";import b from"node:path";import pe from"node:os";import{fileURLToPath as me}from"node:url";import{cli as G,Strategy as fe}from"@linkaicli/linkai-cli/registry";import{ArgumentError as S,AuthRequiredError as te,CliError as k}from"@linkaicli/linkai-cli/errors";import*as a from"./core.js";import*as ae from"./build.js";import*as W from"./deliverable.js";import*as ne from"./fill-card.js";import*as L from"./collab.js";import*as P from"./reuse.js";import*as E from"./dictionary.js";import*as V from"./plan-shot.js";import*as f from"@linkaicli/linkai-cli/oplog";const l={site:a.SITE_ID,browser:!1,defaultFormat:"json"},se={site:a.SITE_ID,domain:"bi.linkmore.com",strategy:fe.UI,browser:!0,siteSession:"persistent",navigateBefore:!1,defaultFormat:"json"},s=r((e,t,n=!0)=>({name:e,type:"string",required:n,help:t}),"str"),U=r((e,t)=>t?e.filter(n=>String(n.name).includes(t)):e,"filter"),q=r(e=>JSON.parse(a.readInputFile(e)),"json"),H=r(()=>new Date().toISOString().replace(/[-:]/g,"").replace("T","-").slice(0,15),"stamp"),$=r((e,...t)=>e||a.artifactsDir(...t),"outPath");function d(e){const t=e.func,n=r(async i=>{try{return await i()}catch(u){throw u instanceof a.LoginRequiredError?new te("bi.linkmore.com",u.message,"请用户执行 linkai login,在浏览器完成领谋授权"):u instanceof a.TenantMismatchError?new k("TENANT_MISMATCH",u.message,"先用 linkai login --tenant <租户名> 切换到方案规范指定的租户,再用 linkai status 确认;无法切换就停止并告诉用户"):u instanceof a.NetworkError?new k("NETWORK_ERROR",u.message,"网络暂时不通(已自动重试):等 1~2 分钟或请用户检查网络 / 代理后再执行;只读命令可直接重试,写命令先用只读命令确认结果再决定是否重做",1):(u instanceof a.ApiError||u instanceof a.KindError)&&u.kind?new k(u.kind,u.message,we[u.kind],1):u instanceof L.CollabConflictError?new k("COLLAB_CONFLICT",u.message,"别的执行者正在改这个对象:先 linkai collab-status --board <任务板> 看分工,改做别的任务,或等对方释放;不要绕过",1):u}},"guard"),o=r(i=>{e.access==="write"&&!e.name.startsWith("collab-")&&L.assertWritable(L.objectsForCommand(e.name,i||{},q))},"checkCollab"),c=e.browser?async(i,u)=>n(()=>(o(u),t(i,u))):async i=>n(()=>(o(i),t(i)));return G({...e,func:c})}r(d,"site");const we={SQL_INVALID:"SQL 里有视图中不存在的字段 / 表或语法错误:用 linkai kb-object / ds-tables 核对字段名后改 SQL,先 sql-check 通过再建或改数据集",IN_USE:"对象仍被引用:先用 linkai ds-refs --ds <数据集> 找到引用它的卡片,确认后先删卡片,再删数据集",NOT_FOUND:"对象不存在或已删除:用 plan-list / card-list / ds-list 重新确认精确 id,不要重复执行写命令",FORBIDDEN:"当前用户没有该对象的权限(如私有数据集):换用自己可见的对象,或请对象负责人共享",DUPLICATE_NAME:"同名对象已存在——很可能上一次已经创建成功:先用 card-list / ds-list / plan-list --search <名称> 确认并复用,不要换个名字或重复执行同样的创建",SQL_READONLY:"数据集只能是单条只读查询(SELECT 或 WITH … SELECT),不能改数据或建表;本 CLI 没有操作数据库的能力",SQL_TABLE_NOT_ALLOWED:"只能查询 linkai ds-tables 列出的租户视图:换用清单里的视图;确实需要新视图时停下,告诉用户联系领谋数据团队开放",READ_ONLY:"这是领谋下发的系统分析方案 / 系统报表(只读):不能通过 CLI 修改(卡片、筛选器、排序、删除都不行),不要重试或换个接口再试。停下来告诉用户:如需按本租户调整,请在领谋网页上把它另存为本租户的分析方案,再对新方案操作",SYSTEM_PLAN_READONLY:"这是领谋下发的系统分析方案(只读):可以读取方案知识,但不能写入或修改。停下来告诉用户:如需按本租户调整,请在领谋网页上把它另存为本租户的分析方案,再对新方案操作",CLI_FORBIDDEN:"LinkAI CLI 不能调用这个接口:把 message 原样告诉用户,不要重试、不要换路径再试;问数请用 linkai ask,口径补充或纠正请用户在领谋网页上维护",BAD_REQUEST:"请求体结构不对:先查 references/api-catalog.md,或回读一条现有数据照抄结构;优先用已封装的命令,不要反复试",KNOWLEDGE_INVALID:"方案知识文件不完整:按 message 补齐(每张线上业务卡片一段、cardId 必填、修改方案必须写 changeSummary),格式见 templates/report-spec/plan-knowledge.example.yaml",ASK_UNAVAILABLE:"问数服务还没有上线:告诉用户这个功能暂不可用;需要数据可以先搭看板或在领谋界面查看",CONFIG_INVALID:"配置违反领谋界面的保存规则(与在界面上点保存时的校验一致):按 message 逐条修正后再保存;规则见知识库 references/card.md、filters.md",FILE_NOT_FOUND:"文件路径不对:确认文件已写出、路径相对当前目录;不要原样重复执行"},De=d({...l,name:"login",access:"write",description:"首次打开领谋 OAuth 授权页;同账号切换租户复用授权,切换账号需显式 --switch-account",args:[s("tenant","目标租户(名称、编码或 id);已有授权时无需再打开浏览器",!1),{name:"timeout",type:"int",default:300,help:"等待浏览器完成 OAuth 授权的秒数"},{name:"no-browser",type:"boolean",default:!1,help:"只打印授权地址,不自动打开系统浏览器"},{name:"switch-account",type:"boolean",default:!1,help:"用户确认切换领谋账号后,重新打开 OAuth 授权页"}],func:r(async e=>{const t=Number(e.timeout||300);if(!Number.isInteger(t)||t<1||t>1800)throw new S("timeout 必须是 1-1800 秒");if(!e["switch-account"]){let o=null;try{o=await a.ensureSession()}catch(c){if(!(c instanceof a.LoginRequiredError))throw c}if(o){const c=e.tenant?await a.switchTenant(o,e.tenant):{session:o,switched:!1};return c.switched&&(E.resetReads("switch-tenant"),a.clearShortCache()),{login:c.switched?"oauth_tenant_switch":"oauth_existing",autoRelogin:!0,...a.spaceOf(c.session)}}}E.resetReads("login"),a.clearShortCache();const n=await a.oauthLogin({tenant:e.tenant,timeoutSeconds:t,...e["no-browser"]?{browser:r(async()=>!1,"browser")}:{},switchAccount:e["switch-account"]});return{login:"oauth",autoRelogin:!0,...a.spaceOf(n)}},"func")}),Te=G({...l,name:"logout",access:"write",description:"退出登录:撤销 OAuth 会话并删除本机登录信息",args:[],func:r(async()=>{const e=await a.oauthLogout();return a.forgetCredentials(),a.clearSession(),{logout:!0,oauthRevoked:e}},"func")}),Ne=d({...l,name:"status",access:"read",description:"查看登录状态、当前租户、是否自动续登、数据源与可用表数量",args:[],func:r(async()=>{const e=await a.ensureSession(),t=a.tokenPayload(e.token),n=await a.ctx(),o=await a.allowedTables();return{loggedIn:!0,...a.spaceOf(e),autoRelogin:!0,authMode:"oauth",tokenHoursLeft:t.exp?Math.round((t.exp*1e3-Date.now())/36e4)/10:null,dataSourceId:n.dataSourceId,dataSources:n.dataSources,tables:o.size,...he()}},"func")});function oe(e){const t=e.status==="interrupted"&&e.reason==="build-incomplete"?"用同一个 spec 再执行 build --execute 会断点续跑;不再继续就清理试搭对象后 flow-end --status abandoned":e.status==="interrupted"&&e.reason==="outcome-unknown"?"上次写操作结果未知:先用只读命令回读确认现状,不要直接重复执行写命令":`继续做就在命令上加 --flow ${e.id};不再继续就 linkai flow-end --flow ${e.id} --status abandoned`;return{flow:e.id,goal:e.goal,type:e.type,plan:e.plan,source:e.source,status:e.status,reason:e.reason,lastActivity:new Date(e.lastActivity).toISOString(),lastCommand:e.lastCommand,lastErrorCode:e.lastErrorCode,build:e.build,next:t}}r(oe,"describeFlow");function he(){try{const e=f.unfinishedFlows().filter(t=>t.id!==f.currentFlow()?.id).slice(0,5).map(oe);return e.length?{unfinishedFlows:e}:{}}catch{return{}}}r(he,"unfinishedFlowsHint");const Fe=d({...l,name:"tenants",access:"read",description:"查看可用租户、当前租户及下一步;用户完成 OAuth 授权后执行",args:[],func:r(async()=>{const e=await a.ensureSession(),t=await a.tenantList(e),n=t.find(i=>i.tenantId===e.tenantId),o=t.map(i=>({name:i.name,tenantId:i.tenantId,current:i.tenantId===e.tenantId}));let c;return t.length===1?c=`只有一个租户,当前租户是「${t[0].name}」。`:c=`当前租户是「${n?.name||""}」。同账号可直接执行 linkai login --tenant <租户名> 切换,无需重新授权。`,{tenantCount:t.length,single:t.length===1,current:n?.name||a.spaceOf(e).tenantName,tenants:o,next:c}},"func")}),$e=d({...l,name:"kinds",access:"read",defaultFormat:"table",description:"列出 74 种图表类型、字段建议与可用状态",args:[],func:r(async()=>a.kinds(),"func")}),be={answered:"回答用户:给出数字或小表,写明口径(数据源 dataSource、用到的视图 / 字段中文名见 caliber)、时间范围和「数据截至」(dataAsOf);结果被截断时说明只看了部分行。直接在对话里回答,不要另生成文件",empty:"告诉用户「没有查到符合条件的数据」,不要说成 0;可以确认时间范围或条件是否太窄",refused:"小满答不了:按 linkai-ask 技能「兜底」读当前租户数据字典、写只读 SQL,再执行 linkai ask --question <同一问题> --sql <文件> --fallback <本次 askId>;字典里也没有相关数据就如实告诉用户",failed:"小满出错:按 linkai-ask 技能「兜底」读当前租户数据字典、写只读 SQL,再执行 linkai ask --question <同一问题> --sql <文件> --fallback <本次 askId>",rejected:"按 message 告诉用户原因(翻页取数、同类问题反复换条件被暂停、排队已满等);不要换个问法或改用兜底绕过"},ye={answered:"回答用户:给出数字,写明「由 AI 按数据字典口径查询」和口径(用到的视图 / 字段见 caliber)、时间范围、「数据截至」(dataAsOf);结果被截断时说明只看了部分行",empty:"告诉用户「没有查到符合条件的数据」,不要说成 0;可以确认时间范围或条件是否太窄",rejected:"按 message 修改(换用可用视图、去掉分页偏移、改为汇总等);不要换个写法绕过安全规则",estimate_rejected:"预计扫描量过大:加上时间范围或筛选条件,或改为汇总后再查",failed:"按 message 里的数据库报错改 SQL(用 kb-object 核对字段名)后再查",timeout:"查询超时:缩小时间范围、加筛选条件或改为汇总后再查"},re=new Set(["refused","failed"]),_e=d({...l,name:"ask",access:"read",description:"问数:先把问题交给领谋服务端(与小满问数同一套)查询;小满答不了时,带 --sql 和 --fallback 由 AI 按数据字典写只读 SQL 兜底。按当前用户的数据权限执行,有频率和行数限制",args:[s("question","用户的问题(一次一个问题;有歧义先和用户确认,再把确认后的完整问题传入)"),s("keywords","从问题里提炼的关键词,逗号分隔,如「采购数量,入库数量,今天」(用于统计)",!1),s("source","指定数据源 id(可选,仅问小满时;不传由服务端按问题自动选)",!1),s("sql","兜底:AI 写的 SQL 文件,1~3 个逗号分隔(必须同时给 --fallback)",!1),s("fallback","兜底:小满没答出来的那次 askId(refused / failed)",!1),s("template","兜底:使用的指标模板编号(如 DP-PUR-002),没用不填",!1),{name:"rows",type:"int",default:30,help:"每个结果最多返回几行给 AI(1~200);更多的写到 --output 目录的文件里"},s("output","完整结果保存目录(默认 ~/.linkai/artifacts/linkai/ask)",!1)],func:r(async e=>{const t=String(e.question||"").trim();if(!t)throw new S("--question 必填:传用户的问题");if(t.length>2e3)throw new S("--question 最长 2000 字");const n=String(e.keywords||"").split(/[,,、]/).map(m=>m.trim()).filter(Boolean).slice(0,20),o=Math.min(200,Math.max(1,Number(e.rows)||30)),c=await a.ensureSession(),i=f.currentCaller(),u=f.currentFlow()?.id??null,g={name:i.agent,version:i.toolVersion,model:i.model,sessionId:i.toolSessionId,flowId:u,runId:f.currentRunId(),cliVersion:i.cliVersion},p=$(e.output,"ask"),h=r((m,A)=>{const O=m.rows||[];let v=null;return O.length>o&&(I.mkdirSync(p,{recursive:!0}),v=a.writeJsonFile(b.join(p,`${m.askId}.json`),{question:t,columns:m.columns,rows:O})),{askId:m.askId??null,status:m.status??null,message:m.message??null,columns:m.columns||[],rows:O.slice(0,o),rowCount:m.rowCount??0,rowLimit:m.rowLimit??null,truncated:!!m.truncated,...v?{file:v,shownRows:o}:{},dataAsOf:m.dataAsOf??null,caliber:m.explain??null,cached:!!m.cached,next:A[m.status]??null}},"shape"),x=r(m=>{if(m?.rateLimited)throw new k("RATE_LIMITED",m.message||"问数超过频率或数据量限制",`${m.retryAfterSeconds??60} 秒后再试,告诉用户原因;不要循环重试、换账号或拆分问题绕过。需要大量数据请改为汇总`,1)},"rateLimited");if(e.sql||e.fallback){if(!e.sql||!e.fallback)throw new S("兜底需要同时给 --sql(AI 写的 SQL 文件)和 --fallback(小满没答出来的那次 askId)");const m=a.xiaomanAttempt(e.fallback);if(!m||!re.has(m.status))throw new k("ASK_XIAOMAN_FIRST",m?`这次小满已经回答(${m.status}),不需要兜底`:"没有找到这次小满问数记录(或已超过 2 小时)",'先执行 linkai ask --question "<问题>"(不带 --sql)问小满;只有返回 refused / failed 时,才带上它的 askId 兜底',1);const A=String(e.sql).split(/[,,]/).map(C=>C.trim()).filter(Boolean);if(!A.length||A.length>3)throw new S("--sql 需要 1~3 个 SQL 文件,逗号分隔");const O=[];for(const[C,z]of A.entries()){const D=a.readInputFile(z);if(a.hasOffset(D))throw new k("ASK_REJECTED",`第 ${C+1} 条 SQL 带分页偏移(OFFSET / LIMIT m,n):问数不支持翻页`,"改为汇总,或加时间范围 / 筛选条件缩小范围;需要完整明细请联系管理员导出",1);O.push(await a.localCheckSql(D))}await B({enforce:!0});for(const[C,z]of O.entries())try{await a.checkSql(z)}catch(D){throw D instanceof a.LoginRequiredError||D instanceof a.NetworkError?D:new k("SQL_INVALID",`第 ${C+1} 条 SQL 没有通过数据集运行校验:${String(D.message).slice(0,300)}`,"按报错核对视图与字段(kb-object),改好后再执行;不要绕过",1)}const v=await a.ctx(),F=await a.askQuery({question:t,dataSourceId:v.dataSourceId,queries:O.map(C=>({sql:C})),keywords:n,templateId:e.template||null,knowledgeRefs:[`xiaoman-ask:${e.fallback}`,...E.recentRefs({tenantId:c.tenantId,flowId:u})].slice(0,50),agent:g}),ee=F?.results||[];return f.emit("ask",{requestId:F?.requestId??null,mode:"fallback",fallbackOf:e.fallback,queries:O.length,rateLimited:!!F?.rateLimited,results:ee.map(C=>({askId:C.askId,status:C.status,rows:C.rowCount,cached:C.cached}))}),x(F),{mode:"fallback",fallbackOf:e.fallback,requestId:F.requestId,results:ee.map(C=>h(C,ye)),quota:F.quota??null}}const w=await a.askQuestion({question:t,keywords:n,dataSourceId:e.source||null,agent:g}),y=w?.results?.[0]||{};return f.emit("ask",{requestId:w?.requestId??null,mode:"question",rateLimited:!!w?.rateLimited,results:[{askId:y.askId??null,status:y.status??null,rows:y.rowCount??0,cached:!!y.cached,ms:y.elapsedMs??null}]}),x(w),y.askId&&a.recordXiaomanAttempt({askId:y.askId,status:y.status,question:t}),{mode:"question",...h(y,be),dataSource:y.dataSourceName??null,sql:y.sql??null,...re.has(y.status)?{fallback:{allowed:!0,askId:y.askId}}:{},quota:w?.quota??null}},"func")}),Re=d({...l,name:"sql-check",access:"read",defaultFormat:"table",description:"校验数据集 SQL(只读、可用表)并列出字段;多个文件用逗号分隔一次校验(省去多次启动)",args:[s("file","SQL 文件路径;多个用逗号分隔,如 sql/a.sql,sql/b.sql")],func:r(async e=>{const t=String(e.file).split(/[,,]/).map(i=>i.trim()).filter(Boolean),n=r(i=>i.map(u=>({sort:u.sort_num,field:u.field_name,type:u.field_type,dbType:u.type_name})),"fields"),o=r(async()=>{try{await B({enforce:!1})}catch(i){if(i instanceof a.LoginRequiredError)throw i}},"dictNotice");if(t.length===1){ke();const i=a.readInputFile(t[0]);return a.precheckSql(i),await o(),n(await a.checkSql(i))}await o();const c=[];for(const i of t)try{c.push({file:i,ok:!0,fields:n(await a.checkSql(a.readInputFile(i)))})}catch(u){if(u instanceof a.LoginRequiredError)throw u;c.push({file:i,ok:!1,error:String(u.message).slice(0,500)})}return{total:t.length,failed:c.filter(i=>!i.ok).length,results:c}},"func")});function ke(){try{const e=b.join(pe.homedir(),".linkai","oplog","hints.json");let t={};try{t=JSON.parse(I.readFileSync(e,"utf8"))||{}}catch{}const n=Date.now(),o=[...(t.sqlCheck||[]).filter(c=>n-c<5*6e4),n];t.sqlCheck=o.slice(-10),o.length>=3&&n-(t.sqlCheckNoticeAt||0)>10*6e4&&(t.sqlCheckNoticeAt=n,f.addNotice("最近几分钟在逐个校验 SQL:可以一次校验多个文件,如 linkai sql-check --file sql/a.sql,sql/b.sql,sql/c.sql(每条命令都有启动开销,逐个跑更慢),结果按文件分别列出。","sql-check-batch")),I.mkdirSync(b.dirname(e),{recursive:!0}),I.writeFileSync(e,JSON.stringify(t),{mode:384})}catch{}}r(ke,"nudgeBatchSqlCheck");const Be=d({...l,name:"fmt",access:"read",defaultFormat:"plain",description:"把 SQL 格式化为易读的多行文本",args:[s("file","SQL 文件路径")],func:r(async e=>a.formatSql(a.readInputFile(e.file)),"func")}),je=d({...l,name:"ds-list",access:"read",defaultFormat:"table",description:"列出当前租户的数据集",args:[s("search","按名称关键字过滤(可选)",!1)],func:r(async e=>U(await a.dsList(),e.search),"func")}),ve=d({...l,name:"ds-tables",access:"read",defaultFormat:"table",description:"列出数据集 SQL 可以使用的表(视图)",args:[s("search","按表名关键字过滤(可选)",!1)],func:r(async e=>[...await a.allowedTables()].sort().filter(t=>!e.search||t.includes(String(e.search).toLowerCase())).map(t=>({table:t})),"func")}),Pe=d({...l,name:"ds-fields",access:"read",defaultFormat:"table",description:"列出数据集字段(列名就是卡片引用的映射键)",args:[s("ds","数据集的精确 id")],func:r(async e=>(await a.dsFields(e.ds)).map(t=>({sort:t.sortNum,column:t.tableFieldComment,type:t.tableFieldType,argumentType:t.argumentType})),"func")}),Qe=d({...l,name:"ds-sql",access:"read",description:"把数据集当前保存的 SQL 读出到文件",args:[s("ds","数据集的精确 id"),s("output","输出 SQL 路径(默认 ~/.linkai/artifacts/linkai/sql/<dsId>.sql)",!1)],func:r(async e=>{const t=await a.dsDetail(e.ds),n=a.writeJsonFile($(e.output,"sql",`${e.ds}.sql`),t.sql||"");return{name:t.name,lines:(t.sql||"").split(`
|
|
2
|
+
`).length,output:n}},"func")}),Ke=d({...l,name:"ds-refs",access:"read",defaultFormat:"table",description:"列出引用该数据集的卡片",args:[s("ds","数据集的精确 id")],func:r(async e=>a.dsRefs(e.ds),"func")}),Me=d({...l,name:"ds-match",access:"read",description:"复用查重:在当前租户里找与这段 SQL 完全一样或相似的数据集(建数据集前先查,不含 zz- 试搭对象)",args:[s("file","SQL 文件路径"),{name:"top",type:"int",default:3,help:"相似候选最多几个"}],func:r(async e=>{const t=P.matchDataset(a.readInputFile(e.file),await P.datasetIndex(),{top:Number(e.top)||3});return{...t,next:t.identical.length?`有完全一样的数据集,直接复用 ${t.identical[0].dsId}(build spec 的 datasets 写 id,或 spec 加 reuse: auto)`:t.similar.length?"有相似数据集:比较 columns 与口径后决定复用、在原数据集上调整(先 ds-refs 看影响),还是新建":"没有可复用的数据集,可以新建"}},"func")}),Ue=d({...l,name:"ds-create",access:"write",description:"新建 SQL 数据集(先做只读与可用表校验,并格式化 SQL);当前租户已有完全一样的数据集时拒绝,除非 --allow-duplicate",args:[s("name","数据集名称(租户内唯一;试验用 zz- 前缀)"),s("file","SQL 文件路径"),{name:"allow-duplicate",type:"boolean",default:!1,help:"已有完全一样的数据集仍要新建(需说明理由,如要独立演进口径)"}],func:r(async e=>{const t=a.readInputFile(e.file);a.precheckSql(t),await B({enforce:!0});let n=null;try{n=P.matchDataset(t,await P.datasetIndex())}catch(i){if(i instanceof a.LoginRequiredError)throw i}if(n?.identical.length&&!e["allow-duplicate"])throw new k("DUPLICATE_DATASET",`当前租户已有完全一样的数据集:${n.identical.map(i=>`「${i.name}」(${i.dsId})`).join("、")}`,"直接复用该数据集(build spec 的 datasets 写 id,或 spec 加 reuse: auto);确需独立一份才加 --allow-duplicate",1);const o=await a.dsSave({name:e.name,sql:t}),c=await a.dsFields(o);return{dsId:o,name:e.name,fields:c.map(i=>i.tableFieldComment),...n?.similar.length?{similar:n.similar,next:"已有相似数据集:确认口径确实不同;如可合并,后续改为复用"}:{}}},"func")}),Je=d({...l,name:"ds-update",access:"write",description:"原地更新数据集(id 不变,卡片映射不断)",args:[s("ds","数据集的精确 id"),s("name","数据集名称"),s("file","SQL 文件路径")],func:r(async e=>{const t=a.readInputFile(e.file);a.precheckSql(t);const n=await a.dsDetail(e.ds);if(n?.name===e.name&&n?.sql&&P.sameSql(t,n.sql)){const i=await a.dsFields(e.ds);return{dsId:e.ds,sameId:!0,unchanged:!0,fields:i.map(u=>u.tableFieldComment),next:"SQL 和名称与线上一致,未重复保存;不要再次执行同样的更新"}}await B({enforce:!0});const o=await a.dsSave({id:e.ds,name:e.name,sql:t,current:n}),c=await a.dsFields(o);return{dsId:o,sameId:o===e.ds,fields:c.map(i=>i.tableFieldComment)}},"func")}),ze=d({...l,name:"card-list",access:"read",defaultFormat:"table",description:"列出当前租户的卡片",args:[s("search","按名称关键字过滤(可选)",!1)],func:r(async e=>U(await a.cardList(),e.search),"func")}),Ge=d({...l,name:"card-get",access:"read",description:"把卡片完整配置保存为 JSON 文件(修改前的基线,或 card-save 的输入)",args:[s("card","卡片的精确 id"),s("output","输出 JSON 路径(默认 ~/.linkai/artifacts/linkai/cards/<cardId>.json)",!1)],func:r(async e=>{const t=await a.cardDetail(e.card),n=a.writeJsonFile($(e.output,"cards",`${e.card}.json`),t);return{name:t.name,fields:(t.indexBlockDataSettingDtos||[]).length,output:n}},"func")}),We=d({...l,name:"card-save",access:"write",description:"保存完整的卡片配置 JSON(唯一能让配置生效的保存方式)",args:[s("file","修改后的卡片 JSON 文件路径")],func:r(async e=>{const t=q(e.file);if(!t.id)throw new Error("Card payload must contain id");const n=typeof t.chartSetting=="string"?JSON.parse(t.chartSetting||"{}"):t.chartSetting||{};t.chartSetting=JSON.stringify(n),t.chartType=t.chartType||n.id,t.chartParentType=t.chartParentType||n.parentId,await a.post("/index-block/update-new",t),String(t.chartType||n.id)==="agtable"&&n.config?.fitTableWidth!==!0&&f.addNotice("这张 AG 表格没有开启自适应宽度(chartSetting.config.fitTableWidth)。按 BI 操作规则应设为 true,列宽 agColumnWidths 只作为比例(如等分统一 50);用户明确要求固定宽度时除外。","ag-fit-width");const o=await a.cardDetail(t.id);return{cardId:t.id,name:o.name,fields:(o.indexBlockDataSettingDtos||[]).length}},"func")}),Ve=d({...l,name:"fill-write",access:"write",description:"写自由填报数据:一条记录一个元素、字段一次带齐(默认只校验并预览,加 --execute 才写入)",args:[s("card","填报卡片的精确 id(usedIndexBlockId)"),s("file",'记录 JSON 数组文件:[{"字段名":"值",…,"_id":"已有记录ID(可选)"}]'),s("key","填报块:fillfieldid(第一个块)、fillfieldid1(第二个块)…",!1),{name:"batch",type:"int",default:100,help:"每次请求的记录数(1-500)"},{name:"execute",type:"boolean",default:!1,help:"确认后真正写入"}],func:r(async e=>{const t=Number(e.batch||100);if(!Number.isInteger(t)||t<1||t>500)throw new S("batch 必须是 1-500");await a.assertCardInTenant(e.card);const n=await a.cardDetail(e.card);if(n?.isFreeFill===!1)throw new Error("该卡片不是自由填报卡片;fill-write 只支持自由填报");const o=ne.fillBlockOf(n,e.key||"fillfieldid"),c=ne.buildFillValues(o,q(e.file)),i=[];for(let p=0;p<c.length;p+=t)i.push(c.slice(p,p+t));const u={cardId:e.card,name:n.name,key:o.key,fields:o.fields,uniqueFields:o.uniqueFields,records:c.length,updates:c.filter(p=>p.value).length,batches:i.length,sample:c.slice(0,2)};if(!e.execute)return{...u,executed:!1,next:"核对字段与记录数后,用户确认再加 --execute"};const g=[];for(const[p,h]of i.entries())try{await a.post("/index-block/new-fill-field",{usedIndexBlockId:e.card,fillValues:h}),g.push(h.length)}catch(x){throw new Error(`第 ${p+1}/${i.length} 批写入失败(已成功 ${g.length} 批、${g.reduce((w,y)=>w+y,0)} 条):${x.message}。先回读填报表按依据字段核对,再只补未成功的部分,不要整批重跑`)}return{...u,executed:!0,written:g.reduce((p,h)=>p+h,0),next:"回读填报表本身:行数、依据字段无空值无重复、抽查整行字段完整;再核对看板"}},"func")}),He=d({...l,name:"card-chart",access:"write",description:"按图表类型新建或覆盖图表卡片(指定维度、数值、系列列)",args:[s("ds","数据集的精确 id"),s("name","卡片名称"),s("kind","图表类型,如 column/stacked(linkai kinds 查看全部)"),s("dim","维度列名,多个用逗号分隔",!1),s("meas","数值列名,多个用逗号分隔"),s("series","系列列名(可选)",!1),s("card","要覆盖的已有卡片 id(可选)",!1)],func:r(async e=>a.cardChart({dsId:e.ds,name:e.name,kind:e.kind,dim:e.dim,meas:e.meas,series:e.series,id:e.card}),"func")}),Ye=d({...l,name:"card-meter",access:"write",description:"新建或覆盖单数值指标卡(meter/list)",args:[s("ds","数据集的精确 id"),s("name","卡片名称"),s("col","数值列名"),s("subtitle","副标题",!1),s("color","数值颜色(十六进制,如 #E8611C)",!1),s("card","要覆盖的已有卡片 id(可选)",!1)],func:r(async e=>a.cardMeter({dsId:e.ds,name:e.name,col:e.col,subtitle:e.subtitle,color:e.color||void 0,id:e.card}),"func")}),Xe=d({...l,name:"card-table",access:"write",description:"按 spec JSON 新建或覆盖 AG 明细表(见 references/card.md)",args:[s("ds","数据集的精确 id"),s("spec","spec JSON 文件路径"),s("card","要覆盖的已有卡片 id(可选)",!1)],func:r(async e=>a.cardTable({dsId:e.ds,spec:q(e.spec),id:e.card}),"func")}),Ze=d({...l,name:"card-gantt",access:"write",description:"按 spec JSON 新建或覆盖甘特图卡片(见 references/gantt.md)",args:[s("ds","数据集的精确 id"),s("spec","spec JSON 文件路径"),s("card","要覆盖的已有卡片 id(可选)",!1)],func:r(async e=>a.cardGantt({dsId:e.ds,spec:q(e.spec),id:e.card}),"func")}),et=d({...l,name:"card-sort",access:"write",description:'设置卡片持久排序("列:asc,列:desc"、.json 文件或 clear),并回读确认排序生效',args:[s("card","卡片的精确 id"),s("spec",'"waxis3:desc,ID:asc" | sort.json | clear')],func:r(async e=>{const t=/\.json$/i.test(String(e.spec))?q(e.spec):e.spec;return a.cardSort(e.card,Array.isArray(t)?t:t.sortField||t)},"func")}),tt=d({...l,name:"query",access:"read",description:"按页面方式查询卡片数据;返回状态码、报错信息、行数和实际执行的 SQL",args:[s("card","卡片的精确 id"),{name:"page-size",type:"int",default:20,help:"取数行数"},s("filters","筛选条件 JSON 文件路径",!1),s("output","数据行保存路径(不在屏幕上输出数据)",!1)],func:r(async e=>{const t=await a.queryCard({cardId:e.card,pageSize:e["page-size"],filters:e.filters?q(e.filters):[]});return e.output&&a.writeJsonFile(e.output,t.data),delete t.data,t},"func")}),at=d({...l,name:"plan-list",access:"read",defaultFormat:"table",description:"列出分析方案(看板)",args:[s("search","按名称关键字过滤(可选)",!1)],func:r(async e=>U(await a.planList(),e.search),"func")}),nt=d({...l,name:"plan-get",access:"read",description:"把方案详情保存为 JSON 文件(含租户模板筛选器)",args:[s("plan","方案的精确 id"),s("output","输出 JSON 路径(默认 ~/.linkai/artifacts/linkai/plans/<planId>.json)",!1),{name:"personal",type:"boolean",default:!1,help:"读取自己的个人筛选器副本"}],func:r(async e=>{const t=await a.planDetail(e.plan,!e.personal),n=a.writeJsonFile($(e.output,"plans",`${e.plan}.json`),t);return{planId:e.plan,cards:(t.pageCards||[]).map(o=>({pageCardId:o.id,name:o.indexBlock?.name})),output:n}},"func")}),st=d({...l,name:"plan-create",access:"write",description:"新建空的分析方案(看板)",args:[s("name","方案名称")],func:r(async e=>{const t=await a.post("/page-setting/new-create-empty-plan",{name:e.name,isPrivate:!1,pageType:0});return f.addNotice(`手工搭建方案:完成后必须用 linkai plan-knowledge --plan ${t} --mode create --file <知识文件> 交付方案知识库(铁律 2)。新建看板优先用 linkai build(自动生成知识库并验收)。`,"plan-knowledge"),{planId:t,name:e.name}},"func")}),ot=d({...l,name:"plan-add",access:"write",description:"把卡片放到方案的指定位置;返回 pageCardId(绑定筛选器时使用)",args:[s("plan","方案的精确 id"),s("card","卡片的精确 id"),{name:"x",type:"int",default:0,help:"横向位置(格,0–23)"},{name:"y",type:"int",default:0,help:"纵向位置(格)"},{name:"w",type:"int",default:24,help:"宽度(格,满宽 24)"},{name:"h",type:"int",default:20,help:"高度(格)"}],func:r(async e=>a.planAdd({planId:e.plan,cardId:e.card,x:e.x,y:e.y,w:e.w,h:e.h}),"func")}),rt=d({...l,name:"plan-filter",access:"write",description:"保存方案筛选器为租户模板(全量覆盖),并重置自己的个人副本",args:[s("plan","方案的精确 id"),s("file","筛选器请求体 JSON 文件路径")],func:r(async e=>a.planFilter(e.plan,q(e.file)),"func")}),it=d({...l,name:"report-list",access:"read",defaultFormat:"table",description:"列出数据报表(每个报表对应一张卡片)",args:[s("search","按名称关键字过滤(可选)",!1)],func:r(async e=>U(await a.reportList(),e.search),"func")}),ct=d({...l,name:"report-card",access:"read",description:"查询卡片对应报表的 userPageCardId(重置报表筛选器时使用)",args:[s("card","卡片的精确 id")],func:r(async e=>{const t=await a.reportDetail(e.card);return{cardId:e.card,userPageCardId:t.id}},"func")}),lt=d({...l,name:"report-publish",access:"write",description:"把卡片发布为数据报表",args:[s("card","卡片的精确 id"),s("name","报表名称")],func:r(async e=>{await a.post("/index-block/operate-report",{indexBlockId:e.card,name:e.name,tag:"",isPrivate:!1});const t=(await a.reportList()).filter(n=>n.cardId===e.card);return{cardId:e.card,published:t.length===1,url:`https://bi.linkmore.com/view/table?indexBlockId=${e.card}&status=view`}},"func")}),dt=d({...l,name:"reset-filter",access:"write",description:"丢弃自己的个人筛选器副本,按模板重建(方案或报表卡片)",args:[s("plan","方案 id(方案筛选器)",!1),s("page-card","报表 userPageCardId 或方案 pageCardId(卡片筛选器)",!1)],func:r(async e=>{if(!!e.plan==!!e["page-card"])throw new Error("Pass exactly one of --plan or --page-card");const t=e.plan?{userPageSettingId:e.plan,filterSourceType:4}:{userPageCardId:e["page-card"],filterSourceType:2};return{filters:(await a.post("/page-setting/reset-user-filter",t)||[]).map(o=>o.labelName)}},"func")}),Y=b.resolve(b.dirname(me(import.meta.url)),"..","..");try{a.setClientVersion(JSON.parse(I.readFileSync(b.join(Y,"package.json"),"utf8")).version)}catch{}const Q={knowledge:b.join(Y,"knowledge"),templates:b.join(Y,"templates")};function J(e,t=e){return I.existsSync(e)?I.readdirSync(e,{withFileTypes:!0}).flatMap(n=>{const o=b.join(e,n.name);return n.isDirectory()?J(o,t):n.name.startsWith(".")?[]:[b.relative(t,o)]}):[]}r(J,"walk");function X(e,t){const n=Q[e],o=b.resolve(n,t);if(o!==n&&!o.startsWith(n+b.sep))throw new S("路径必须在 knowledge/ 或 templates/ 之内");if(!I.existsSync(o)||I.statSync(o).isDirectory())throw new S(`文件不存在:${e}/${t}`,"执行 linkai kb 查看可用文件");return I.readFileSync(o,"utf8")}r(X,"readLocal");function Z(e){return new k("KB_UNAVAILABLE",`知识库服务暂不可用:${e}`,"稍后重试;持续出现请联系领谋",1)}r(Z,"kbUnavailable");function _(e){if(!(e instanceof a.AiKbError))return e;const t={400:"AI_KB_BAD_REQUEST",403:"AI_KB_FORBIDDEN",404:"AI_KB_NOT_FOUND",409:"AI_KB_CONFLICT",503:"AI_KB_UNAVAILABLE"},n={404:"确认 source、对象或文档属于当前租户;数据字典不会回退本地内容",409:"知识元数据未就绪时先联系知识库维护方;文档持续变化时稍后重试",503:"稍后重试;数据字典没有本地兜底,持续出现请联系领谋"};return new k(t[e.code]||"AI_KB_ERROR",e.message,n[e.code],1)}r(_,"aiKbFailure");function T(e,t,n,o,c){const i=e==null||e===""?t:Number(e);if(!Number.isInteger(i)||i<o||i>c)throw new S(`${n} 必须是 ${o}~${c} 的整数`);return i}r(T,"intOption");async function K(e){const t=await a.ctx();if(String(e).toLowerCase()!==String(t.dataSourceId).toLowerCase())throw new k("AI_KB_SOURCE_MISMATCH",`数据字典 source ${e} 与当前数据集 source ${t.dataSourceId} 不一致`,"不要跨 source 使用元数据;先确认服务端已为当前数据源完成探库,或确认 CLI 的数据源配置",1)}r(K,"requireDatasetSource");function ie(){return J(b.join(Q.knowledge,"report-builder")).map(e=>({file:`knowledge/report-builder/${e}`,title:"",summary:"",library:"bi-builder",scope:"public",source:"bundled-fallback"}))}r(ie,"localBiDocs");function Se(e){return{file:`knowledge/${e.canonicalPath}`,title:"",library:e.library,scope:e.scope,source:"bundled-fallback",content:X("knowledge",e.canonicalPath)}}r(Se,"localBiDoc");function ge(e,t){const n=Array.isArray(t)?t:null,o=n?"bi-builder":t?.library||(String(t?.file||"").startsWith("templates/")?"templates":String(t?.file||"").includes("browser/")?"cli-docs":"bi-builder");return f.emit("kb.read",n?{library:o,op:e,docs:n.length,sources:[...new Set(n.map(c=>c?.source).filter(Boolean))]}:{library:o,op:e,file:t?.file??null,source:t?.source??null,chars:typeof t?.content=="string"?t.content.length:void 0}),t}r(ge,"logKb");async function R(e,t,n,o={}){if(f.emit("kb.read",{library:"data-dictionary",op:e,source:t.source??null,...o,...e!=="sources"?{cached:a.kbCacheHit()}:{}}),e!=="sources"&&t.source){const c=a.readSession()?.tenantId,i=f.currentFlow()?.id??null;let u=null;try{u=(await a.dictSourcesCached()).find(g=>String(g.source).toLowerCase()===String(t.source).toLowerCase())?.revision||null}catch{}E.recordRead({tenantId:c,source:t.source,flowId:i,op:e,revision:u}),e==="doc"&&t.doc&&E.recordRef({tenantId:c,flowId:i,ref:`doc:${t.doc}${o.title?` ${o.title}`:""}`}),e==="object"&&o.object&&E.recordRef({tenantId:c,flowId:i,ref:`object:${o.object}`})}return n}r(R,"logDict");async function B({enforce:e}){const t=await a.ensureSession(),n=await a.ctx(),o=await E.dictionaryStatus({tenantId:t.tenantId,source:n.dataSourceId,flowId:f.currentFlow()?.id??null,fetchSources:r(()=>a.dictSourcesCached(),"fetchSources"),fetchDocs:r(async()=>(await a.dictDocsHead(n.dataSourceId)).items,"fetchDocs")});if(a.logGuard("dictionary-fresh",o.ok,{tenantId:t.tenantId,source:n.dataSourceId,reason:o.reason??o.skipped??null,readAt:o.readAt??null,dictUpdatedAt:o.dictUpdatedAt??null,enforce:e}),o.skipped&&f.addNotice(`数据字典:${o.skipped}。`,"dictionary-skipped"),!o.ok){if(e)throw new k("DICTIONARY_REQUIRED",o.reason,E.readHint(n.dataSourceId),1);f.addNotice(`数据字典:${o.reason}。正式写入(ds-create / ds-update / build --execute)前会被拒绝。${E.readHint(n.dataSourceId)}`,"dictionary-stale")}return o}r(B,"checkDictionary");const ce=r((e,t,n)=>Array.isArray(e)?e.slice(0,t).map(n):[],"top"),ut=d({...l,name:"kb",access:"read",description:"读取文件型 BI 搭建知识库(服务器优先、本地兜底);数据字典请使用 kb-sources / kb-search 等结构化命令",args:[s("file","文件路径,如 knowledge/report-builder/build-spec.md 或 templates/report-spec/report.example.yaml",!1),s("search","按关键字过滤目录",!1),s("library","文件型知识库:bi-builder;data-dictionary 请使用 kb-sources 等结构化命令;另预留 integration-flow | user",!1)],func:r(async e=>ge(e.file?"doc":"catalog",await(async()=>{const t=String(e.file||"").replace(/^\.?\//,"");if(t.startsWith("templates/"))return{file:t,source:"bundled",content:X("templates",t.slice(10))};if(t==="knowledge/browser/README.md"||t==="browser/README.md")return{file:"knowledge/browser/README.md",source:"bundled-cli",content:X("knowledge","browser/README.md")};const n=e.search?String(e.search):"",o=e.library?String(e.library):"";if(o&&!a.KB_LIBRARIES[o])throw new S(`未知知识库:${o}`);if(o==="data-dictionary"||/^(?:knowledge\/)?data-dictionary(?:\/|$)/.test(t))throw new k("KB_STRUCTURED_API","数据字典不再按 Markdown 文件路径读取","先执行 linkai kb-sources,再使用 kb-search / kb-catalog / kb-object / kb-docs / kb-doc",1);if(o&&!a.KB_LIBRARIES[o].enabled)throw new k("KB_NOT_IMPLEMENTED",`知识库 ${o} 已预留,当前版本尚未启用`,void 0,1);if(!t){const i=o?[o]:["bi-builder"],u=[];for(const p of i)try{const h=await a.kbCatalog(p);p==="bi-builder"&&!(h.docs||[]).length?u.push(...ie()):u.push(...(h.docs||[]).map(x=>{const w=a.KB_LIBRARIES[p].pathPrefix,y=a.kbRequest(x.path,p).path;return{file:`knowledge/${w}/${y}`,title:x.title||"",summary:x.summary||"",library:p,scope:a.KB_LIBRARIES[p].scope,source:"server"}}))}catch(h){if(p==="bi-builder"&&h instanceof a.KbUnavailableError)u.push(...ie());else throw h instanceof a.KbUnavailableError?Z(p):h}const g=!o||o==="bi-builder"?J(Q.templates).map(p=>({file:`templates/${p}`,title:"报表模版",summary:"",source:"bundled"})):[];return[...u,...g].filter(p=>!n||p.file.includes(n)||p.title.includes(n))}const c=a.kbRequest(t,o||void 0);try{const i=await a.kbDoc(t,o||void 0);return{file:`knowledge/${i.path}`,title:i.title||"",library:i.library,scope:i.scope,source:"server",content:i.content||""}}catch(i){if(c.library==="bi-builder"&&(i instanceof a.KbUnavailableError||i instanceof a.KbDocumentNotFoundError)){if(!I.existsSync(b.join(Q.knowledge,"report-builder")))throw Z(c.library);return Se(c)}throw i instanceof a.KbUnavailableError?Z(c.library):i}})()),"func")}),pt=d({...l,name:"kb-sources",access:"read",defaultFormat:"table",description:"列出当前租户可读的数据字典数据源,并标记当前数据集所用 source;后续命令必须显式传该 ID",args:[],func:r(async()=>{try{const[e,t]=await Promise.all([a.dictSourcesCached(),a.ctx()]),n=e.map(c=>({...c,selectedForDatasets:String(c.source).toLowerCase()===String(t.dataSourceId).toLowerCase()})),o=n.some(c=>c.selectedForDatasets);return R("sources",{},null,{sources:n.map(c=>({source:c.source,name:c.name})),aligned:o}),{datasetSourceId:t.dataSourceId,aligned:o,sources:n,...o?{}:{warning:"数据字典没有返回当前数据集 source;不要跨 source 使用元数据,请先确认服务端探库或数据源配置"}}}catch(e){throw _(e)}},"func")}),mt=d({...l,name:"kb-catalog",access:"read",description:"分页列出指定 source 的可用视图/对象元数据",args:[s("source","数据源 ID(来自 kb-sources,必须与后续数据集 source 一致)"),s("search","表名、中文名或说明关键字",!1),s("page","页码,默认 1",!1),s("page-size","每页 1~100,默认 30",!1)],func:r(async e=>{try{await K(e.source);const t={source:e.source,keyword:e.search,pageIndex:T(e.page,1,"page",1,2147483647),pageSize:T(e["page-size"],30,"page-size",1,100)},n=await a.kbCached(e.source,`catalog|${t.keyword??""}|${t.pageIndex}|${t.pageSize}`,()=>a.aiKbCatalog(t));return R("catalog",e,n,{keyword:e.search??null,total:n?.totalCount??null})}catch(t){throw _(t)}},"func")}),ft=d({...l,name:"kb-object",access:"read",description:"读取指定 source 中一个可见对象的字段与关联关系",args:[s("source","数据源 ID(来自 kb-sources)"),s("db","数据库/schema 名"),s("object","对象/视图名")],func:r(async e=>{try{await K(e.source);const t=await a.kbCached(e.source,`object|${String(e.db).toLowerCase()}.${String(e.object).toLowerCase()}`,()=>a.aiKbObject({source:e.source,db:e.db,object:e.object}));return R("object",e,t,{object:`${e.db}.${e.object}`})}catch(t){throw _(t)}},"func")}),wt=d({...l,name:"kb-docs",access:"read",description:"分页列出指定 source 的业务口径、枚举、关系、数据点等知识文档",args:[s("source","数据源 ID(来自 kb-sources)"),s("category","caliber | enum | relation | datapoint | other",!1),s("search","标题或正文关键字",!1),s("page","页码,默认 1",!1),s("page-size","每页 1~100,默认 30",!1)],func:r(async e=>{const t=e.category?String(e.category):void 0;if(t&&!["caliber","enum","relation","datapoint","other"].includes(t))throw new S("category 必须是 caliber、enum、relation、datapoint、other 之一");try{await K(e.source);const n={source:e.source,category:t,keyword:e.search,pageIndex:T(e.page,1,"page",1,2147483647),pageSize:T(e["page-size"],30,"page-size",1,100)},o=await a.kbCached(e.source,`docs|${t??""}|${n.keyword??""}|${n.pageIndex}|${n.pageSize}`,()=>a.aiKbDocs(n));return R("docs",e,o,{category:t??null,keyword:e.search??null,total:o?.totalCount??null})}catch(n){throw _(n)}},"func")}),ht=d({...l,name:"kb-doc",access:"read",description:"一致性读取一篇完整数据字典文档;自动按 nextOffset 分页,版本变化时从头重读",args:[s("source","数据源 ID(来自 kb-sources)"),s("doc","文档 ID(来自 kb-docs 或 kb-search)"),s("page-size","单次正文字符数,1~20000,默认 20000",!1)],func:r(async e=>{try{await K(e.source);const t=await a.kbCached(e.source,`doc|${String(e.doc).toLowerCase()}`,()=>a.aiKbDoc({source:e.source,doc:e.doc,limit:T(e["page-size"],2e4,"page-size",1,2e4)}));return R("doc",e,t,{doc:e.doc,title:t?.title??null,category:t?.category??null,chars:t?.totalChars??null})}catch(t){throw _(t)}},"func")}),bt=d({...l,name:"kb-search",access:"read",description:"按自然语言检索指定 source 的候选视图与业务知识;相关度不是正确率,使用前继续回读对象/文档核对",args:[s("source","数据源 ID(来自 kb-sources)"),s("question","业务问题,最长 2000 字"),s("top-tables","最多返回表数,1~10,默认 5",!1),s("top-docs","最多返回文档数,1~10,默认 3",!1)],func:r(async e=>{try{await K(e.source);const t={source:e.source,question:e.question,topTables:T(e["top-tables"],5,"top-tables",1,10),topDocs:T(e["top-docs"],3,"top-docs",1,10)},n=await a.kbCached(e.source,`search|${t.topTables}|${t.topDocs}|${a.normalizeQuestion(t.question)}`,()=>a.aiKbSearch(t));return R("search",e,n,{question:String(e.question||"").slice(0,200),hitTables:ce(n?.tables,10,o=>`${o.db}.${o.object}`),hitDocs:ce(n?.docs,10,o=>({doc:o.doc,title:o.title??null,category:o.category??null})),emptyReason:n?.emptyReason??null})}catch(t){throw _(t)}},"func")}),yt=G({...l,name:"template",access:"write",description:"复制报表 spec 与示例文件;SQL 示例必须由 AI 按用户需求、租户数据字典和确认口径重写,CLI 不生成业务 SQL",args:[s("output","目标目录(默认 ./linkai-report),需不存在或为空",!1)],func:r(async e=>{const t=b.resolve(String(e.output||"linkai-report"));if(I.existsSync(t)&&I.readdirSync(t).length)throw new S(`目录已存在且不为空:${t}`,"换一个 --output 目录");return I.cpSync(b.join(Q.templates,"report-spec"),t,{recursive:!0}),{output:t,files:J(t),next:`修改 ${b.join(t,"report.example.yaml")} 与 sql/ 下的 SQL,然后 linkai build --spec ${b.join(t,"report.example.yaml")}`}},"func")}),kt=d({...l,name:"build",access:"write",description:"严格按 BI 公共知识库执行 AI 提供的 SQL 与报表 spec(CLI 不生成业务 SQL)。默认只校验并给出执行计划;--execute 才写入;含正式名还需 --confirm",args:[s("spec","报表 spec 文件(.yaml/.json),模版用 linkai template 复制"),{name:"execute",type:"boolean",default:!1,help:"真正写入(默认只校验并输出执行计划)"},{name:"confirm",type:"boolean",default:!1,help:"用户已明确授权使用正式名(非 zz- 前缀)"},{name:"no-style",type:"boolean",default:!1,help:"跳过页面样式步骤(暖色背景、卡位透明)"}],func:r(async e=>{const t=ae.loadSpec(e.spec),n=(t.spec.datasets||[]).some(i=>!i.id&&i.sql),o=r(async()=>{const i=await a.ensureSession(),u=a.spaceOf(i);if(t.spec.tenant&&![u.tenantId,u.tenantName,u.tenantCode].includes(String(t.spec.tenant)))throw new S(`当前登录的租户是「${u.tenantName}」,与 spec.tenant「${t.spec.tenant}」不一致`,"确认目标租户后执行 linkai login --tenant <租户名>,或修改 spec.tenant");n&&await B({enforce:!0})},"beforeWrite"),c=await ae.runBuild(t,{execute:!!e.execute,confirm:!!e.confirm,style:!e["no-style"],beforeWrite:o});if(!e.execute&&n&&c.ok!==!1)try{c.dictionary=await B({enforce:!1})}catch(i){if(i instanceof a.LoginRequiredError)c.dictionary={ok:!1,reason:"未登录"};else throw i}if(c.ok===!1&&(c.stage==="validate"||c.stage==="authorize"))throw new S(`build ${c.stage} failed:
|
|
3
3
|
- ${c.errors.join(`
|
|
4
|
-
- `)}`,c.stage==="authorize"?"把正式名清单给用户确认,得到明确授权后再加 --confirm":"按错误逐条修改 spec 后重新 dry-run");return c},"func")});async function re(e){const t=String(e.mode||"");if(!["create","update"].includes(t))throw new h("必须明确 --mode create 或 --mode update;用户未说明新建还是修改时,先主动询问用户");if(!e.file)throw new h("缺少 --file <方案知识 YAML/JSON>","文件必须描述方案内容、解决的问题,以及每张业务卡片的内容、数据来源、粒度和取数逻辑");const s=G.readKnowledgeFile(e.file);return G.publishPlanKnowledge({planId:e.plan,operation:t,authoring:s.knowledge||s})}r(re,"publishPlanKnowledgeFromFile");const kt=d({...l,name:"plan-knowledge",access:"write",description:"生成、版本化并保存完整方案知识库;服务器不可用时内部留存本地。最终返回方案地址和完整知识内容,不返回 SQL 或上传信息",args:[n("plan","方案的精确 id"),n("mode","create(新方案)或 update(修改原方案)",!1),n("file","AI 编写的方案知识 YAML/JSON 文件",!1)],func:r(async e=>{const t=await re(e);return f.emit("kb.write",{library:"plan-knowledge",op:e.mode||"create",plan:e.plan,version:t?.version??t?.planKnowledge?.version??null}),t},"func")}),St=d({...l,name:"plan-knowledge-get",access:"read",description:"读取方案当前知识库;修改原方案前先读。服务器不可用时读取本地待同步版本,不展示存储信息",args:[n("plan","方案的精确 id")],func:r(async e=>{const t=await G.readCurrentPlanKnowledge(e.plan);return f.emit("kb.read",{library:"plan-knowledge",op:"doc",plan:e.plan,version:t?.version??null,exists:t?.exists??null,chars:typeof t?.content=="string"?t.content.length:null}),t},"func")}),gt=d({...l,name:"deliverable",access:"write",description:"兼容命令:等同 plan-knowledge;需要 --mode 与 --file,不再输出数据集 SQL",args:[n("plan","方案的精确 id"),n("mode","create 或 update",!1),n("file","方案知识 YAML/JSON 文件",!1)],func:re}),It=d({...l,name:"backup",access:"read",description:"修改已有对象前保存带时间戳的备份(卡片/方案 JSON 或数据集 SQL),存到 ~/.linkai/artifacts/linkai/backups/",args:[n("type","card(卡片)| plan(方案)| ds(数据集)"),n("id","对象的精确 id")],func:r(async e=>{const t=r(s=>a.artifactsDir("backups",`${e.type}-${e.id}-${te()}.${s}`),"at");if(e.type==="card")return{type:"card",output:a.writeJsonFile(t("json"),await a.cardDetail(e.id))};if(e.type==="plan")return{type:"plan",output:a.writeJsonFile(t("json"),await a.planDetail(e.id,!0))};if(e.type==="ds")return{type:"ds",output:a.writeJsonFile(t("sql"),(await a.dsDetail(e.id)).sql||"")};throw new Error("type must be card, plan or ds")},"func")}),Ct=d({...l,name:"remove",access:"write",description:"按 folderId 删除数据集/卡片/方案/报表(id 来自各 *-list 命令)。需要用户明确授权",args:[n("folder","精确的 folderId,多个用逗号分隔")],func:r(async e=>{const t=String(e.folder).split(",").map(s=>s.trim()).filter(Boolean);return await a.post("/index-block/delete-folder-bussiness",t),{deleted:t.length,recycleBin:"POST /index-block/get-recycle-station-page-list"}},"func")}),Lt=d({...l,name:"clean",access:"write",description:"按 zz- 前缀清理试验对象,按依赖顺序删除:报表 → 方案 → 卡片 → 数据集",args:[n("prefix","以 zz- 开头的前缀,如 zz-销售看板-")],func:r(async e=>a.cleanPrefix(e.prefix),"func")}),xt=d({...l,name:"api-get",access:"read",description:"调用未封装的读接口(GET,路径可省略 /api/app);先查 references/api-catalog.md。数据源、SQL 类接口不开放",args:[n("path","接口路径,如 /index-block/folders?type=1"),{name:"raw",type:"boolean",default:!1,help:"返回完整响应"}],func:r(async e=>(await a.assertApiAllowedByPolicy(e.path),a.api("GET",e.path,void 0,{raw:!!e.raw})),"func")}),ge=/\/(get-|query|search)[\w-]*|\/[\w-]*-(list|detail|info)(?:[/?]|$)/i,At=d({...l,name:"api",access:"write",accessOf:r(e=>ge.test(String(e.path||""))?"read":"write","accessOf"),description:"调用未封装的写接口(POST/PUT/DELETE,可能新建或修改数据);先查 references/api-catalog.md。数据源、SQL 类接口不开放",args:[n("method","POST、PUT 或 DELETE"),n("path","接口路径"),n("body","请求体 JSON 文件路径",!1),{name:"raw",type:"boolean",default:!1,help:"返回完整响应(code/message/extendData)"}],func:r(async e=>{const t=String(e.method).toUpperCase();if(!["POST","PUT","DELETE"].includes(t))throw new Error("method must be POST, PUT or DELETE; use api-get for GET");return await a.assertApiAllowedByPolicy(e.path),a.api(t,e.path,e.body?A(e.body):t==="POST"?{}:void 0,{raw:!!e.raw})},"func")}),ie="create(新建方案)| modify(修改已有方案)| import(导入/更新数据)| query(只读查询分析)",Et=d({...l,name:"flow-start",access:"write",description:"开始一个工作流:接到业务任务时执行一次,之后的命令自动归入它;完成后用 flow-end 结束",args:[n("goal","这个任务要完成什么(用户原话概括)"),n("type",ie),n("plan","目标方案 id(修改已有方案时)",!1),n("model",'你(AI)当前使用的具体模型与版本,如 "Claude Opus 5.5"、"gpt-5.6";不确定就写你所知道的名称',!1)],func:r(async e=>{const t=f.startFlow({goal:e.goal,type:String(e.type||"").trim(),plan:e.plan||null,model:e.model||null});return{flow:t.id,goal:t.goal,type:t.type,plan:t.plan,model:f.currentModel(),next:"同一会话里的后续命令自动归入此工作流;并行开多个任务时,在命令上加 --flow <id> 指明。完成后 linkai flow-end --status delivered"}},"func")}),qt=d({...l,name:"flow-end",access:"write",description:"结束工作流:delivered(已交付)| abandoned(放弃)| failed(失败);默认结束当前工作流,或用 --flow <id> 指定",args:[n("status","delivered | abandoned | failed"),n("plan","交付的方案 id",!1),n("knowledge-version","方案知识库版本,如 v2",!1),n("note","结果说明或失败原因",!1)],func:r(async e=>{const t=Object.fromEntries(Object.entries({plan:e.plan,knowledgeVersion:e["knowledge-version"],note:e.note}).filter(([,o])=>o!=null&&o!=="")),s=f.endFlow({id:process.env.LINKAI_FLOW||void 0,status:String(e.status||"").trim(),result:t});return{flow:s.id,status:s.status,goal:s.goal,runs:s.runs,durationMinutes:Math.round(((s.endedAt??Date.now())-s.startedAt)/6e3)/10,result:s.result}},"func")}),Dt=d({...l,name:"flow-status",access:"read",description:"查看本机工作流:当前工作流、未完成或中断的工作流(含中断原因与下一步);--all 含已结束的",args:[{name:"all",type:"boolean",default:!1,help:"包括已结束和自动关闭的工作流(最近 7 天)"}],func:r(async e=>{const t=f.currentFlow(),s=(e.all?f.listFlows():f.unfinishedFlows()).slice(0,30).map(o=>({...ae(o),runs:o.runs,startedAt:new Date(o.startedAt).toISOString(),endedAt:o.endedAt?new Date(o.endedAt).toISOString():null,result:o.result}));return{current:t,idleMinutes:f.flowIdleMinutes(),flows:s}},"func")}),R={name:"worker",type:"string",required:!1,help:"执行者名称(也可用全局参数 --worker);同一会话保持不变,如 主控、sql-1、card-2"},T=r(e=>String(e.worker||C.currentWorker()||""),"workerOf"),U=n("board","任务板 id(collab-start 返回,如 b-1a2b3c4d)"),Ot=d({...l,name:"collab-start",access:"write",description:"多会话协同:创建任务板(协调者执行一次),返回任务板 id 交给各执行者",args:[n("goal","这次协同要完成什么"),n("plan","目标方案 id(修改已有方案时)",!1),n("type",`工作流类型:${ie};默认有 --plan 为 modify,否则 create`,!1),R],func:r(async e=>{const t=C.startBoard({goal:e.goal,plan:e.plan,worker:T(e)});return f.startFlow({id:t.id,source:"board",goal:t.goal,type:e.type||(e.plan?"modify":"create"),plan:e.plan||null}),{board:t.id,coordinator:t.coordinator,goal:t.goal,next:"用 collab-task 拆分任务;各执行者写共享对象前先 collab-claim,成果用 collab-note 共享;读命令不用认领"}},"func")}),Tt=d({...l,name:"collab-task",access:"write",description:"多会话协同:新增任务(--title),或更新任务状态(--id --status todo|doing|done|blocked --result)",args:[U,n("title","新任务标题(新增时)",!1),{name:"id",type:"int",required:!1,help:"要更新的任务编号"},n("status","todo | doing | done | blocked",!1),n("result","结果或阻塞原因(文件路径、对象 id、结论)",!1),n("assignee","分配给哪个执行者",!1),n("object","任务涉及的共享对象,逗号分隔,如 card:<id>,ds:<id>",!1),R],func:r(async e=>e.id?C.updateTask({board:e.board,worker:T(e),id:e.id,status:e.status,result:e.result,assignee:e.assignee}):C.addTask({board:e.board,worker:T(e),title:e.title,assignee:e.assignee,objects:e.object}),"func")}),Ft=d({...l,name:"collab-claim",access:"write",description:"多会话协同:写共享对象前认领(全部成功或全部不认领;重复认领即续期);被别人认领时返回 COLLAB_CONFLICT",args:[U,n("object","对象,逗号分隔:card:<id> ds:<id> plan:<id> knowledge:<planId> spec:<文件> report:<id> filter:<planId>"),{name:"ttl",type:"int",default:C.DEFAULT_TTL_SECONDS,help:"认领有效秒数(60-7200),到期自动释放"},n("note","在做什么",!1),R],func:r(async e=>C.claim({board:e.board,worker:T(e),objects:e.object,ttl:e.ttl,note:e.note}),"func")}),Nt=d({...l,name:"collab-release",access:"write",description:"多会话协同:释放认领(不指定对象则释放自己在该任务板上的全部认领)",args:[U,n("object","要释放的对象,逗号分隔",!1),R],func:r(async e=>C.release({board:e.board,worker:T(e),objects:e.object}),"func")}),$t=d({...l,name:"collab-note",access:"write",description:"多会话协同:共享成果或发现(已验证的 SQL 文件、建好的对象 id、口径结论、交接、阻塞),避免别人重复做",args:[U,n("text","内容"),n("kind","finding | result | handoff | blocker | decision(默认 finding)",!1),n("ref","相关文件路径或对象 id",!1),R],func:r(async e=>C.note({board:e.board,worker:T(e),text:e.text,kind:e.kind||"finding",ref:e.ref}),"func")}),_t=d({...l,name:"collab-status",access:"read",description:"多会话协同:查看任务板(任务、有效认领、最近成果);不带 --board 列出全部进行中的任务板",args:[n("board","任务板 id",!1),{name:"notes",type:"int",default:30,help:"显示最近多少条成果记录"}],func:r(async e=>C.status({board:e.board,notes:e.notes}),"func")}),Rt=d({...l,name:"collab-close",access:"write",description:"多会话协同:关闭任务板并释放其上全部认领,同时结束对应工作流(协调者在交付后执行)",args:[U,n("status","工作流结束状态:delivered | abandoned | failed(默认 delivered)",!1),n("note","结果说明",!1),R],func:r(async e=>{const t=C.closeBoard({board:e.board,worker:T(e)});let s=null;try{s=f.endFlow({id:t.board,status:e.status||"delivered",result:e.note?{note:e.note}:null}).status}catch(o){s=`未结束:${o.message}`}return{...t,flow:s}},"func")}),Bt=d({...fe,name:"shot",access:"write",description:"在浏览器中打开报表/方案页面并截图(需要 Chrome 与 LinkAI 扩展)",args:[n("url","页面地址,如 https://bi.linkmore.com/view/plan?plan=<planId>"),n("output","截图保存路径(默认 ~/.linkai/artifacts/linkai/shots/<时间>.png)",!1),{name:"wait",type:"int",default:12,help:"等待页面渲染的秒数"}],func:r(async(e,t)=>{const s=a.readConfig(),o=new URL(t.url);if(o.origin!==s.origin)throw new Error(`URL must be on ${s.origin}`);if(/\/login/i.test(o.pathname))throw new Error("Do not open /login: it clears localStorage");const c=await a.ensureSession();await e.goto(`${s.origin}/`),await e.evaluate(a.browserSessionScript(c)),await e.goto(o.href),await e.sleep(Number(t.wait));const i=await e.evaluate("(() => ({ title: document.title, onLogin: /login/i.test(location.pathname) }))()");if(i?.onLogin)throw new Error("Page redirected to /login: the synced session was rejected; run: linkai login");let u=null,S=null;try{const p=w.resolve(P(t.output,"shots",`${te()}.png`));y.mkdirSync(w.dirname(p),{recursive:!0}),u=await e.screenshot({path:p,fullPage:!1,format:"png"})||p}catch(p){S=String(p?.message||p).slice(0,200)}return{url:await e.getCurrentUrl(),title:i?.title,loggedIn:!0,output:u,screenshotError:S}},"func")});export{At as apiCommand,xt as apiGetCommand,$e as askCommand,It as backupCommand,yt as buildCommand,Ve as cardChartCommand,Xe as cardGanttCommand,ze as cardGetCommand,Je as cardListCommand,He as cardMeterCommand,We as cardSaveCommand,Ze as cardSortCommand,Ye as cardTableCommand,Lt as cleanCommand,Ft as collabClaimCommand,Rt as collabCloseCommand,$t as collabNoteCommand,Nt as collabReleaseCommand,Ot as collabStartCommand,_t as collabStatusCommand,Tt as collabTaskCommand,gt as deliverableCommand,Te as doctorCommand,Ue as dsCreateCommand,ve as dsFieldsCommand,Be as dsListCommand,Ke as dsMatchCommand,Qe as dsRefsCommand,Pe as dsSqlCommand,je as dsTablesCommand,Me as dsUpdateCommand,Ge as fillWriteCommand,qt as flowEndCommand,Et as flowStartCommand,Dt as flowStatusCommand,Re as fmtCommand,pt as kbCatalogCommand,dt as kbCommand,wt as kbDocCommand,mt as kbDocsCommand,ft as kbObjectCommand,bt as kbSearchCommand,ut as kbSourcesCommand,Ne as kindsCommand,De as loginCommand,Oe as logoutCommand,st as planAddCommand,nt as planCreateCommand,ot as planFilterCommand,at as planGetCommand,kt as planKnowledgeCommand,St as planKnowledgeGetCommand,tt as planListCommand,et as queryCommand,Ct as removeCommand,it as reportCardCommand,rt as reportListCommand,ct as reportPublishCommand,lt as resetFilterCommand,Bt as shotCommand,_e as sqlCheckCommand,ht as templateCommand,Fe as tenantsCommand};
|
|
4
|
+
- `)}`,c.stage==="authorize"?"把正式名清单给用户确认,得到明确授权后再加 --confirm":"按错误逐条修改 spec 后重新 dry-run");return c},"func")});async function le(e){const t=String(e.mode||"");if(!["create","update"].includes(t))throw new S("必须明确 --mode create 或 --mode update;用户未说明新建还是修改时,先主动询问用户");if(!e.file)throw new S("缺少 --file <方案知识 YAML/JSON>","文件必须描述方案内容、解决的问题,以及每张业务卡片的内容、数据来源、粒度和取数逻辑");const n=W.readKnowledgeFile(e.file);return W.publishPlanKnowledge({planId:e.plan,operation:t,authoring:n.knowledge||n})}r(le,"publishPlanKnowledgeFromFile");const St=d({...l,name:"plan-knowledge",access:"write",description:"生成、版本化并保存完整方案知识库;服务器不可用时内部留存本地。最终返回方案地址和完整知识内容,不返回 SQL 或上传信息",args:[s("plan","方案的精确 id"),s("mode","create(新方案)或 update(修改原方案)",!1),s("file","AI 编写的方案知识 YAML/JSON 文件",!1)],func:r(async e=>{const t=await le(e);return f.emit("kb.write",{library:"plan-knowledge",op:e.mode||"create",plan:e.plan,version:t?.version??t?.planKnowledge?.version??null}),t},"func")}),gt=d({...l,name:"plan-knowledge-get",access:"read",description:"读取方案当前知识库;修改原方案前先读。服务器不可用时读取本地待同步版本,不展示存储信息",args:[s("plan","方案的精确 id")],func:r(async e=>{const t=await W.readCurrentPlanKnowledge(e.plan);return f.emit("kb.read",{library:"plan-knowledge",op:"doc",plan:e.plan,version:t?.version??null,exists:t?.exists??null,chars:typeof t?.content=="string"?t.content.length:null}),t},"func")}),It=d({...l,name:"deliverable",access:"write",description:"兼容命令:等同 plan-knowledge;需要 --mode 与 --file,不再输出数据集 SQL",args:[s("plan","方案的精确 id"),s("mode","create 或 update",!1),s("file","方案知识 YAML/JSON 文件",!1)],func:le}),Ct=d({...l,name:"backup",access:"read",description:"修改已有对象前保存带时间戳的备份(卡片/方案 JSON 或数据集 SQL),存到 ~/.linkai/artifacts/linkai/backups/",args:[s("type","card(卡片)| plan(方案)| ds(数据集)"),s("id","对象的精确 id")],func:r(async e=>{const t=r(n=>a.artifactsDir("backups",`${e.type}-${e.id}-${H()}.${n}`),"at");if(e.type==="card")return{type:"card",output:a.writeJsonFile(t("json"),await a.cardDetail(e.id))};if(e.type==="plan")return{type:"plan",output:a.writeJsonFile(t("json"),await a.planDetail(e.id,!0))};if(e.type==="ds")return{type:"ds",output:a.writeJsonFile(t("sql"),(await a.dsDetail(e.id)).sql||"")};throw new Error("type must be card, plan or ds")},"func")}),Lt=d({...l,name:"remove",access:"write",description:"按 folderId 删除数据集/卡片/方案/报表(id 来自各 *-list 命令)。需要用户明确授权",args:[s("folder","精确的 folderId,多个用逗号分隔")],func:r(async e=>{const t=String(e.folder).split(",").map(n=>n.trim()).filter(Boolean);return await a.post("/index-block/delete-folder-bussiness",t),{deleted:t.length,recycleBin:"POST /index-block/get-recycle-station-page-list"}},"func")}),xt=d({...l,name:"clean",access:"write",description:"按 zz- 前缀清理试验对象,按依赖顺序删除:报表 → 方案 → 卡片 → 数据集",args:[s("prefix","以 zz- 开头的前缀,如 zz-销售看板-")],func:r(async e=>a.cleanPrefix(e.prefix),"func")}),At=d({...l,name:"api-get",access:"read",description:"调用未封装的读接口(GET,路径可省略 /api/app);先查 references/api-catalog.md。数据源、SQL 类接口不开放",args:[s("path","接口路径,如 /index-block/folders?type=1"),{name:"raw",type:"boolean",default:!1,help:"返回完整响应"}],func:r(async e=>(await a.assertApiAllowedByPolicy(e.path),a.api("GET",e.path,void 0,{raw:!!e.raw})),"func")}),Ie=/\/(get-|query|search)[\w-]*|\/[\w-]*-(list|detail|info)(?:[/?]|$)/i,Et=d({...l,name:"api",access:"write",accessOf:r(e=>Ie.test(String(e.path||""))?"read":"write","accessOf"),description:"调用未封装的写接口(POST/PUT/DELETE,可能新建或修改数据);先查 references/api-catalog.md。数据源、SQL 类接口不开放",args:[s("method","POST、PUT 或 DELETE"),s("path","接口路径"),s("body","请求体 JSON 文件路径",!1),{name:"raw",type:"boolean",default:!1,help:"返回完整响应(code/message/extendData)"}],func:r(async e=>{const t=String(e.method).toUpperCase();if(!["POST","PUT","DELETE"].includes(t))throw new Error("method must be POST, PUT or DELETE; use api-get for GET");return await a.assertApiAllowedByPolicy(e.path),a.api(t,e.path,e.body?q(e.body):t==="POST"?{}:void 0,{raw:!!e.raw})},"func")}),de="create(新建方案)| modify(修改已有方案)| import(导入/更新数据)| query(只读查询分析)",qt=d({...l,name:"flow-start",access:"write",description:"开始一个工作流:接到业务任务时执行一次,之后的命令自动归入它;完成后用 flow-end 结束",args:[s("goal","这个任务要完成什么(用户原话概括)"),s("type",de),s("plan","目标方案 id(修改已有方案时)",!1),s("model",'你(AI)当前使用的具体模型与版本,如 "Claude Opus 5.5"、"gpt-5.6";不确定就写你所知道的名称',!1)],func:r(async e=>{const t=f.startFlow({goal:e.goal,type:String(e.type||"").trim(),plan:e.plan||null,model:e.model||null});return{flow:t.id,goal:t.goal,type:t.type,plan:t.plan,model:f.currentModel(),next:"同一会话里的后续命令自动归入此工作流;并行开多个任务时,在命令上加 --flow <id> 指明。完成后 linkai flow-end --status delivered"}},"func")}),Ot=d({...l,name:"flow-end",access:"write",description:"结束工作流:delivered(已交付)| abandoned(放弃)| failed(失败);默认结束当前工作流,或用 --flow <id> 指定",args:[s("status","delivered | abandoned | failed"),s("plan","交付的方案 id",!1),s("knowledge-version","方案知识库版本,如 v2",!1),s("note","结果说明或失败原因",!1)],func:r(async e=>{const t=Object.fromEntries(Object.entries({plan:e.plan,knowledgeVersion:e["knowledge-version"],note:e.note}).filter(([,o])=>o!=null&&o!=="")),n=f.endFlow({id:process.env.LINKAI_FLOW||void 0,status:String(e.status||"").trim(),result:t});return{flow:n.id,status:n.status,goal:n.goal,runs:n.runs,durationMinutes:Math.round(((n.endedAt??Date.now())-n.startedAt)/6e3)/10,result:n.result}},"func")}),Dt=d({...l,name:"flow-status",access:"read",description:"查看本机工作流:当前工作流、未完成或中断的工作流(含中断原因与下一步);--all 含已结束的",args:[{name:"all",type:"boolean",default:!1,help:"包括已结束和自动关闭的工作流(最近 7 天)"}],func:r(async e=>{const t=f.currentFlow(),n=(e.all?f.listFlows():f.unfinishedFlows()).slice(0,30).map(o=>({...oe(o),runs:o.runs,startedAt:new Date(o.startedAt).toISOString(),endedAt:o.endedAt?new Date(o.endedAt).toISOString():null,result:o.result}));return{current:t,idleMinutes:f.flowIdleMinutes(),flows:n}},"func")}),j={name:"worker",type:"string",required:!1,help:"执行者名称(也可用全局参数 --worker);同一会话保持不变,如 主控、sql-1、card-2"},N=r(e=>String(e.worker||L.currentWorker()||""),"workerOf"),M=s("board","任务板 id(collab-start 返回,如 b-1a2b3c4d)"),Tt=d({...l,name:"collab-start",access:"write",description:"多会话协同:创建任务板(协调者执行一次),返回任务板 id 交给各执行者",args:[s("goal","这次协同要完成什么"),s("plan","目标方案 id(修改已有方案时)",!1),s("type",`工作流类型:${de};默认有 --plan 为 modify,否则 create`,!1),j],func:r(async e=>{const t=L.startBoard({goal:e.goal,plan:e.plan,worker:N(e)});return f.startFlow({id:t.id,source:"board",goal:t.goal,type:e.type||(e.plan?"modify":"create"),plan:e.plan||null}),{board:t.id,coordinator:t.coordinator,goal:t.goal,next:"用 collab-task 拆分任务;各执行者写共享对象前先 collab-claim,成果用 collab-note 共享;读命令不用认领"}},"func")}),Nt=d({...l,name:"collab-task",access:"write",description:"多会话协同:新增任务(--title),或更新任务状态(--id --status todo|doing|done|blocked --result)",args:[M,s("title","新任务标题(新增时)",!1),{name:"id",type:"int",required:!1,help:"要更新的任务编号"},s("status","todo | doing | done | blocked",!1),s("result","结果或阻塞原因(文件路径、对象 id、结论)",!1),s("assignee","分配给哪个执行者",!1),s("object","任务涉及的共享对象,逗号分隔,如 card:<id>,ds:<id>",!1),j],func:r(async e=>e.id?L.updateTask({board:e.board,worker:N(e),id:e.id,status:e.status,result:e.result,assignee:e.assignee}):L.addTask({board:e.board,worker:N(e),title:e.title,assignee:e.assignee,objects:e.object}),"func")}),Ft=d({...l,name:"collab-claim",access:"write",description:"多会话协同:写共享对象前认领(全部成功或全部不认领;重复认领即续期);被别人认领时返回 COLLAB_CONFLICT",args:[M,s("object","对象,逗号分隔:card:<id> ds:<id> plan:<id> knowledge:<planId> spec:<文件> report:<id> filter:<planId>"),{name:"ttl",type:"int",default:L.DEFAULT_TTL_SECONDS,help:"认领有效秒数(60-7200),到期自动释放"},s("note","在做什么",!1),j],func:r(async e=>L.claim({board:e.board,worker:N(e),objects:e.object,ttl:e.ttl,note:e.note}),"func")}),$t=d({...l,name:"collab-release",access:"write",description:"多会话协同:释放认领(不指定对象则释放自己在该任务板上的全部认领)",args:[M,s("object","要释放的对象,逗号分隔",!1),j],func:r(async e=>L.release({board:e.board,worker:N(e),objects:e.object}),"func")}),_t=d({...l,name:"collab-note",access:"write",description:"多会话协同:共享成果或发现(已验证的 SQL 文件、建好的对象 id、口径结论、交接、阻塞),避免别人重复做",args:[M,s("text","内容"),s("kind","finding | result | handoff | blocker | decision(默认 finding)",!1),s("ref","相关文件路径或对象 id",!1),j],func:r(async e=>L.note({board:e.board,worker:N(e),text:e.text,kind:e.kind||"finding",ref:e.ref}),"func")}),Rt=d({...l,name:"collab-status",access:"read",description:"多会话协同:查看任务板(任务、有效认领、最近成果);不带 --board 列出全部进行中的任务板",args:[s("board","任务板 id",!1),{name:"notes",type:"int",default:30,help:"显示最近多少条成果记录"}],func:r(async e=>L.status({board:e.board,notes:e.notes}),"func")}),Bt=d({...l,name:"collab-close",access:"write",description:"多会话协同:关闭任务板并释放其上全部认领,同时结束对应工作流(协调者在交付后执行)",args:[M,s("status","工作流结束状态:delivered | abandoned | failed(默认 delivered)",!1),s("note","结果说明",!1),j],func:r(async e=>{const t=L.closeBoard({board:e.board,worker:N(e)});let n=null;try{n=f.endFlow({id:t.board,status:e.status||"delivered",result:e.note?{note:e.note}:null}).status}catch(o){n=`未结束:${o.message}`}return{...t,flow:n}},"func")}),jt=d({...se,name:"plan-shot",access:"read",defaultWindowMode:"foreground",description:"分析方案整页截图:按方案名称或 id 打开方案,等全部卡片加载完后把整个方案截成一张 PNG(需要 Chrome 与 LinkAI 扩展;在独立标签中进行)",args:[s("plan","方案名称或方案 id(名称按 plan-list 精确匹配,多个同名时报出候选让用户选)"),s("output","截图保存路径(默认 ~/.linkai/artifacts/linkai/shots/<方案名>-<时间>.png)",!1),{name:"width",type:"int",default:1600,help:"截图宽度(像素),默认 1600"},{name:"timeout",type:"int",default:120,help:"整个截图最多用多少秒(打开方案、等卡片取数、截图),默认 120;方案很大时加大"}],func:r(async(e,t)=>{const n=a.readConfig(),o=String(t.plan||"").trim();if(!o)throw new S("--plan 必填:方案名称或方案 id");const c=await a.ensureSession();let i;if(V.GUID.test(o))i={planId:o,name:(await a.planList()).find(w=>String(w.planId).toLowerCase()===o.toLowerCase())?.name??null};else{const{exact:w,partial:y}=V.matchPlans(await a.planList(),o);if(w.length===1)i=w[0];else if(w.length>1||y.length){const m=(w.length?w:y).slice(0,20).map(A=>({name:A.name,planId:A.planId}));throw new k("PLAN_AMBIGUOUS",`当前租户有 ${m.length} 个方案与「${o}」${w.length?"同名":"名称相近"}:${m.map(A=>A.name).join("、")}`,"把候选列给用户,请用户指定是哪一个,再用 --plan <planId> 截图",1)}else throw new k("PLAN_NOT_FOUND",`当前租户没有名为「${o}」的分析方案`,"用 linkai plan-list --search <关键字> 查找,或确认当前租户(linkai status)是否正确",1)}const u=`${n.origin}/view/plan?plan=${i.planId}&status=view`;await e.goto(`${n.origin}/`),await e.evaluate(a.browserSessionScript(c)),await e.goto(u);const g=String(i.name||i.planId).replace(/[\\/:*?"<>|\s]+/g,"_").slice(0,60),p=b.resolve($(t.output,"shots",`${g}-${H()}.png`));I.mkdirSync(b.dirname(p),{recursive:!0});const h=await V.capturePlan(e,{file:p,width:Math.min(3840,Math.max(800,Number(t.width)||1600)),timeoutMs:Math.min(600,Math.max(30,Number(t.timeout)||120))*1e3});if(h.onLogin)throw new te("bi.linkmore.com","浏览器里的领谋登录没有生效(跳到了登录页)","请用户执行 linkai login 后再试");if(!h.file)throw new k("PLAN_SHOT_TIMEOUT",`方案「${i.name||i.planId}」在限定时间内没有加载出来`,"确认方案能在网页上正常打开;方案较大时加大 --timeout 再试",1);const x=h.cards||[];return{plan:i.name,planId:i.planId,url:u,output:h.file,width:h.width,height:h.height,cards:x.length,emptyCards:x.filter(w=>w.empty).map(w=>w.title),failedCards:x.filter(w=>w.failed).map(w=>w.title),waitedSeconds:Math.round((h.waitedMs||0)/1e3),warnings:h.warnings,...process.env.LINKAI_SHOT_DEBUG==="1"?{debug:h.state}:{},next:"把截图文件交给用户(或在对话里展示);有空卡片或失败卡片时一并说明卡片名称"}},"func")}),vt=d({...se,name:"shot",access:"write",description:"在浏览器中打开报表/方案页面并截图(需要 Chrome 与 LinkAI 扩展)",args:[s("url","页面地址,如 https://bi.linkmore.com/view/plan?plan=<planId>"),s("output","截图保存路径(默认 ~/.linkai/artifacts/linkai/shots/<时间>.png)",!1),{name:"wait",type:"int",default:12,help:"等待页面渲染的秒数"}],func:r(async(e,t)=>{const n=a.readConfig(),o=new URL(t.url);if(o.origin!==n.origin)throw new Error(`URL must be on ${n.origin}`);if(/\/login/i.test(o.pathname))throw new Error("Do not open /login: it clears localStorage");const c=await a.ensureSession();await e.goto(`${n.origin}/`),await e.evaluate(a.browserSessionScript(c)),await e.goto(o.href),await e.sleep(Number(t.wait));const i=await e.evaluate("(() => ({ title: document.title, onLogin: /login/i.test(location.pathname) }))()");if(i?.onLogin)throw new Error("Page redirected to /login: the synced session was rejected; run: linkai login");let u=null,g=null;try{const p=b.resolve($(t.output,"shots",`${H()}.png`));I.mkdirSync(b.dirname(p),{recursive:!0}),u=await e.screenshot({path:p,fullPage:!1,format:"png"})||p}catch(p){g=String(p?.message||p).slice(0,200)}return{url:await e.getCurrentUrl(),title:i?.title,loggedIn:!0,output:u,screenshotError:g}},"func")});export{Et as apiCommand,At as apiGetCommand,_e as askCommand,Ct as backupCommand,kt as buildCommand,He as cardChartCommand,Ze as cardGanttCommand,Ge as cardGetCommand,ze as cardListCommand,Ye as cardMeterCommand,We as cardSaveCommand,et as cardSortCommand,Xe as cardTableCommand,xt as cleanCommand,Ft as collabClaimCommand,Bt as collabCloseCommand,_t as collabNoteCommand,$t as collabReleaseCommand,Tt as collabStartCommand,Rt as collabStatusCommand,Nt as collabTaskCommand,It as deliverableCommand,Ne as doctorCommand,Ue as dsCreateCommand,Pe as dsFieldsCommand,je as dsListCommand,Me as dsMatchCommand,Ke as dsRefsCommand,Qe as dsSqlCommand,ve as dsTablesCommand,Je as dsUpdateCommand,Ve as fillWriteCommand,Ot as flowEndCommand,qt as flowStartCommand,Dt as flowStatusCommand,Be as fmtCommand,mt as kbCatalogCommand,ut as kbCommand,ht as kbDocCommand,wt as kbDocsCommand,ft as kbObjectCommand,bt as kbSearchCommand,pt as kbSourcesCommand,$e as kindsCommand,De as loginCommand,Te as logoutCommand,ot as planAddCommand,st as planCreateCommand,rt as planFilterCommand,nt as planGetCommand,St as planKnowledgeCommand,gt as planKnowledgeGetCommand,at as planListCommand,jt as planShotCommand,tt as queryCommand,Lt as removeCommand,ct as reportCardCommand,it as reportListCommand,lt as reportPublishCommand,dt as resetFilterCommand,vt as shotCommand,Re as sqlCheckCommand,yt as templateCommand,Fe as tenantsCommand};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
var f=Object.defineProperty;var l=(t,c)=>f(t,"name",{value:c,configurable:!0});const x=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,H=16e3,m=l(t=>String(t||"").trim().replace(/\s+/g,"").toLowerCase(),"norm");function S(t,c){const i=m(c),o=t.filter(r=>m(r.name)===i),d=o.length?[]:t.filter(r=>m(r.name).includes(i)||i.includes(m(r.name)));return{exact:o,partial:d}}l(S,"matchPlans");const M=`(() => {
|
|
2
|
+
const grid = document.getElementById('layout_content_grid');
|
|
3
|
+
const spinning = [...document.querySelectorAll('[class*="spin-spinning"]')].filter(el => el.offsetParent !== null || el.getClientRects().length).length;
|
|
4
|
+
const cards = grid ? grid.querySelectorAll('.card').length : 0;
|
|
5
|
+
const empty = !!document.querySelector('.layout_empty');
|
|
6
|
+
const top = grid ? Math.ceil(grid.getBoundingClientRect().top + window.scrollY) : 0;
|
|
7
|
+
const gridHeight = grid ? Math.ceil(Math.max(grid.scrollHeight, grid.getBoundingClientRect().height)) : 0;
|
|
8
|
+
const docHeight = Math.ceil(Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0));
|
|
9
|
+
const canvases = grid ? [...grid.querySelectorAll('canvas')] : [];
|
|
10
|
+
return { url: location.href, onLogin: /\\/login/i.test(location.pathname), title: document.title,
|
|
11
|
+
hasGrid: !!grid, spinning, cards, empty, top, gridHeight, docHeight,
|
|
12
|
+
visibility: document.visibilityState, focused: document.hasFocus(), canvases: canvases.length,
|
|
13
|
+
paintedCanvases: canvases.filter(c => c.width > 0 && c.height > 0).length,
|
|
14
|
+
tables: grid ? grid.querySelectorAll('table, [class*="ag-row"]').length : 0,
|
|
15
|
+
innerWidth: window.innerWidth, innerHeight: window.innerHeight,
|
|
16
|
+
// 已完成的网络请求数:卡片取数是接口请求,数量还在变说明还有卡片在取数(转圈出现得比请求晚,单看转圈会提前截图)
|
|
17
|
+
requests: performance.getEntriesByType('resource').length };
|
|
18
|
+
})()`,w=`(() => {
|
|
19
|
+
const grid = document.getElementById('layout_content_grid');
|
|
20
|
+
if (!grid) return [];
|
|
21
|
+
return [...grid.querySelectorAll('.card')].map(card => {
|
|
22
|
+
const text = (card.innerText || '').trim();
|
|
23
|
+
const title = text.split('\\n').map(s => s.trim()).filter(Boolean)[0] || '';
|
|
24
|
+
// 有图(画过的 canvas / svg 图形)或有表格行就算有内容;没有内容且出现空状态才算空卡片(表格里常有带 empty 字样的类名,不能单看类名)
|
|
25
|
+
const painted = [...card.querySelectorAll('canvas')].some(c => c.width > 0 && c.height > 0) || card.querySelectorAll('svg path, svg rect').length > 3;
|
|
26
|
+
const rows = card.querySelectorAll('tbody tr, [class*="ag-row"]').length;
|
|
27
|
+
const emptyMark = /暂无数据|无数据|当前卡片为空/.test(text) || [...card.querySelectorAll('[class*="empty"]')].some(el => el.getClientRects().length && (el.innerText || '').trim());
|
|
28
|
+
const empty = !painted && rows === 0 && emptyMark;
|
|
29
|
+
const failed = /查询失败|加载失败|出错了|执行失败/.test(text);
|
|
30
|
+
return { title: title.slice(0, 40), empty, failed };
|
|
31
|
+
});
|
|
32
|
+
})()`;async function p(t,{timeoutMs:c,intervalMs:i=1e3,minWaitMs:o=0,stablePolls:d=2,needGrid:r=!0,now:s=l(()=>Date.now(),"now")}){const g=s();let a=null,n=0,e=null;for(;s()-g<c;){if(e=await t.evaluate(M),e?.onLogin)return{state:e,onLogin:!0};const u=(!r||e?.hasGrid||e?.empty)&&e?.spinning===0,h=`${e?.cards}|${e?.requests??""}`;if(n=u&&a!==null&&a===h?n+1:0,a=h,n>=d&&s()-g>=o)return{state:e,waitedMs:s()-g};await t.sleep(i/1e3)}return{state:e,timedOut:!0,waitedMs:s()-g}}l(p,"waitQuiet");function y(t,c=900){const i=Math.max(c,(t?.top||0)+(t?.gridHeight||0)+40,t?.docHeight||0);return{height:Math.min(16e3,i),clipped:i>16e3,need:i}}l(y,"fullHeight");async function A(t,{file:c,width:i=1600,timeoutMs:o=12e4,viewportHeight:d=900,now:r=l(()=>Date.now(),"now")}){const s=[],g=r()+o,a=await p(t,{timeoutMs:Math.max(5e3,o*.4),now:r});if(a.onLogin)return{onLogin:!0};if(a.timedOut&&!a.state?.hasGrid)return{timedOut:!0,state:a.state,warnings:["方案页面在限定时间内没有加载出来"]};a.timedOut&&s.push("方案结构加载超时,按当前状态继续");let n=y(a.state,d);try{await t.cdp("Emulation.setDeviceMetricsOverride",{width:i,height:n.height,deviceScaleFactor:1,mobile:!1});const e=await p(t,{timeoutMs:Math.max(5e3,g-r()-2e4),minWaitMs:2e3,now:r});e.timedOut&&s.push("部分卡片在限定时间内仍在加载,截图里可能有卡片还在转圈");const u=y(e.state,d);u.height!==n.height&&(n=u,await t.cdp("Emulation.setDeviceMetricsOverride",{width:i,height:n.height,deviceScaleFactor:1,mobile:!1}),await t.sleep(1)),n.clipped&&s.push(`方案太长(约 ${n.need} 像素),只截了前 16000 像素`);const h=await t.evaluate(w)||[];return await t.screenshot({path:c,format:"png",width:i,height:n.height}),{file:c,width:i,height:n.height,cards:h,state:e.state,warnings:s,waitedMs:(a.waitedMs||0)+(e.waitedMs||0)}}finally{await t.cdp("Emulation.clearDeviceMetricsOverride",{}).catch(()=>{})}}l(A,"capturePlan");export{w as CARD_SUMMARY_SCRIPT,x as GUID,H as MAX_HEIGHT,M as PAGE_STATE_SCRIPT,A as capturePlan,y as fullHeight,S as matchPlans,p as waitQuiet};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linkaicli/linkai-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.23",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"registry": "https://registry.npmjs.org/",
|
|
6
6
|
"access": "public"
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"templates/**",
|
|
43
43
|
"cli-manifest.json",
|
|
44
44
|
"README.md",
|
|
45
|
+
"MANUAL.md",
|
|
45
46
|
"GUIDE.md",
|
|
46
47
|
"AGENTS.md",
|
|
47
48
|
"llms.txt",
|
|
@@ -111,6 +112,6 @@
|
|
|
111
112
|
},
|
|
112
113
|
"homepage": "https://bi.linkmore.com",
|
|
113
114
|
"linkaiUpgrade": {
|
|
114
|
-
"minVersion": "1.0.
|
|
115
|
+
"minVersion": "1.0.23"
|
|
115
116
|
}
|
|
116
117
|
}
|
package/skills/agent-prompt.md
CHANGED
|
@@ -35,4 +35,5 @@ BI 公共知识库是强制执行约束,不是可选参考。没有先读取 S
|
|
|
35
35
|
- 数据字典必须先用 `linkai kb-sources` 选择与数据集一致的 source,再按需执行 `kb-search`、`kb-object`、`kb-docs`、`kb-doc`;404/409/503 不得回退本地占位内容。
|
|
36
36
|
- 数据字典由服务器下发、会不定时更新:**切换租户或重新登录后必须重新读取新租户的数据字典**,不能沿用其它租户或旧的结论;服务端更新过(重新探库或口径文档更新)也要重读。CLI 在 `ds-create` / `ds-update` / `build --execute`(有新数据集)前强制校验,未读或已过期返回 `DICTIONARY_REQUIRED`;`build` 预览与 `sql-check` 会提前提示。
|
|
37
37
|
- 方案完成后必须完整呈现“<方案名称>方案知识库”:版本、方案地址、方案内容、解决的问题,以及每张业务卡片各自的数据集、数据来源、粒度和取数逻辑/规则。不要输出完整 SQL,也不要展示服务器上传信息。新建方案读取 build 结果的 `planKnowledge.content`;修改方案执行 `linkai plan-knowledge --plan <planId> --mode update --file <知识文件>`。
|
|
38
|
+
- 用户要领谋方案 / 看板的截图时执行 `linkai plan-shot --plan "<方案名称或 id>"`(整页、等全部卡片取完数;同名多个时返回候选让用户选),把返回的 PNG 文件交给用户,并说明没有数据的卡片。
|
|
38
39
|
- 用户问到浏览器能力或工作原理时,先执行 linkai kb --file knowledge/browser/README.md,以该文为准回答,不补充文中没有的内容。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: linkai-browser
|
|
3
|
-
version: 2026.
|
|
3
|
+
version: 2026.11.07
|
|
4
4
|
description: 用 LinkAI 操作 Chrome 浏览器:打开页面、读取页面状态、点击输入、截图、查看网络请求,以及管理标签页、标签组和窗口。适用于网页操作、截图验收、浏览器能力相关问题。领谋 BI 报表搭建请用 linkai-report-builder。
|
|
5
5
|
allowed-tools: Bash(linkai:*), Read, Edit, Write
|
|
6
6
|
---
|
|
@@ -12,6 +12,19 @@ allowed-tools: Bash(linkai:*), Read, Edit, Write
|
|
|
12
12
|
|
|
13
13
|
查找命令:`linkai list <关键字>`;查看参数:`linkai browser <命令> --help -f json`;连接有问题时执行 `linkai doctor`。
|
|
14
14
|
|
|
15
|
+
## 领谋分析方案截图(用户说「要 xx 方案 / 看板的截图」)
|
|
16
|
+
|
|
17
|
+
直接用一条命令,不要自己用 `browser open` + `screenshot` 拼:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
linkai plan-shot --plan "<方案名称或 id>" -f json
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- 按名称找方案(多个同名或只有相近名称时返回 `PLAN_AMBIGUOUS` 和候选,列给用户选,再用 `--plan <planId>`;找不到 `PLAN_NOT_FOUND`)。
|
|
24
|
+
- 自动同步登录、打开方案、等全部卡片(包括需要滚动才加载的)取完数,把整个方案截成一张 PNG,返回 `output`(文件路径)、`cards`、`emptyCards`(没有数据的卡片名)、`failedCards`、`warnings`。
|
|
25
|
+
- 截图期间 LinkAI 自己的 Chrome 窗口会到前台(窗口在后台时 Chrome 不绘制图表),不动用户自己的标签页;截完可以继续用。
|
|
26
|
+
- 把文件交给用户(能展示图片就直接展示),有 `emptyCards` / `failedCards` / `warnings` 时一并说明。方案很大时加 `--timeout 240`;需要更宽加 `--width 1920`。
|
|
27
|
+
|
|
15
28
|
## 会话与返回结果
|
|
16
29
|
|
|
17
30
|
页面类命令都要带 `--session <名称>`,同一任务沿用同一个名称,不同名称互相隔离。需要指定 Chrome 用户时加 `--profile <名称>`。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: linkai-report-builder
|
|
3
|
-
version: 2026.11.
|
|
3
|
+
version: 2026.11.07
|
|
4
4
|
description: 领谋 BI(LMBI,bi.linkmore.com)自动搭建报表:数据集、卡片(74 种图表,含 AG 表格、甘特图、指标卡)、看板(分析方案)、数据报表、筛选器与排序,按搭建报表知识库和数据字典知识库执行;新建看板优先用 linkai build 声明式搭建,完成后交付完整方案知识库。用户要新建或修改领谋 BI 的报表、看板、分析方案、卡片、指标块、数据集、筛选器、表格样式,或提到 bi.linkmore.com 时使用。
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -19,7 +19,7 @@ description: 领谋 BI(LMBI,bi.linkmore.com)自动搭建报表:数据集
|
|
|
19
19
|
> **结束时一定 `flow-end`**:交付后 `linkai flow-end --status delivered [--plan <planId>] [--knowledge-version vN]`,放弃或失败用 `--status abandoned|failed --note <原因>`。
|
|
20
20
|
> `linkai status` 返回 `unfinishedFlows` 时先按其中的 `next` 处理:继续做就在命令上加 `--flow <id>`,不做了就 `flow-end --flow <id> --status abandoned`。
|
|
21
21
|
|
|
22
|
-
所有操作只用 `linkai
|
|
22
|
+
所有操作只用 `linkai <命令>`(纯接口,浏览器只用于截图 `plan-shot` / `shot`)。动手前读 `knowledge/report-builder/SOP.md`:
|
|
23
23
|
它规定固定顺序、哪些动作需要用户额外授权、失败与回滚。命令输出统一为 `{"ok":…, "data":…}` 的 JSON。
|
|
24
24
|
|
|
25
25
|
**BI 公共知识库是强制执行约束,不是参考建议。** 未读取 SOP 和当前任务对应篇章前不得搭建;不得凭模型经验自由发明接口、字段角色、图表配置、筛选器结构或操作步骤。知识库不可用、缺失或没有覆盖需求时,停止写操作并把缺口告诉用户。
|
|
@@ -99,7 +99,8 @@ linkai card-sort --card <cardId> --spec "优先级:asc,ID:asc" # 持
|
|
|
99
99
|
linkai query --card <cardId> [--page-size 50] [--output rows.json] [--filters filters.json]
|
|
100
100
|
linkai plan-create --name "名称" | plan-add --plan <planId> --card <cardId> --x 2 --y 0 --w 20 --h 21 | plan-filter --plan <planId> --file filters.json
|
|
101
101
|
linkai report-publish --card <cardId> --name "名称" | report-card --card <cardId> | reset-filter --page-card <userPageCardId>
|
|
102
|
-
linkai shot --
|
|
102
|
+
linkai plan-shot --plan "<方案名称或 id>" # 方案整页截图(等全部卡片取完数),返回 PNG 路径与空卡片
|
|
103
|
+
linkai shot --url "<报表/看板 URL>" # 任意页面首屏截图
|
|
103
104
|
linkai clean --prefix zz-任务- # 报表→看板→卡片→数据集 按依赖序清理
|
|
104
105
|
linkai api-get --path <路径> | api --method POST --path <路径> --body body.json # 未封装接口:先查 references/api-catalog.md
|
|
105
106
|
```
|