@qingflow-tech/qingflow-cli 1.1.46 → 1.1.47-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-cli/SKILL.md +16 -2
- package/skills/qingflow-common/SKILL.md +17 -1
- package/src/qingflow_mcp/app_form_validator.py +304 -0
- package/src/qingflow_mcp/builder_facade/service.py +5 -0
- package/src/qingflow_mcp/cli/commands/auth.py +8 -0
- package/src/qingflow_mcp/cli/commands/builder.py +327 -1
- package/src/qingflow_mcp/cli/context.py +3 -0
- package/src/qingflow_mcp/cli/formatters.py +5 -0
- package/src/qingflow_mcp/config.py +39 -0
- package/src/qingflow_mcp/public_surface.py +3 -0
- package/src/qingflow_mcp/response_trim.py +14 -0
- package/src/qingflow_mcp/server.py +2 -0
- package/src/qingflow_mcp/server_app_user.py +17 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +58 -0
- package/src/qingflow_mcp/tools/login_link_tools.py +114 -0
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Qingflow CLI 是轻流面向开发者和 AI Agent 的命令行工具。它把轻
|
|
|
7
7
|
## 安装
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npm install @qingflow-tech/qingflow-cli@1.1.
|
|
10
|
+
npm install @qingflow-tech/qingflow-cli@1.1.47-beta.1
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
推荐从官网复制这一条一键安装 Qingflow CLI:
|
|
@@ -21,7 +21,7 @@ npx qingflow-cli@latest install
|
|
|
21
21
|
## 运行
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
npx -y -p @qingflow-tech/qingflow-cli@1.1.
|
|
24
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.47-beta.1 qingflow
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
全局安装后可以直接运行:
|
|
@@ -62,7 +62,7 @@ qingflow-skills install --agent codex --scope user
|
|
|
62
62
|
如果你通过一次性 `npx -p` 执行 skills 安装,建议加 `--copy`,避免 symlink 指向 npm 临时执行缓存:
|
|
63
63
|
|
|
64
64
|
```bash
|
|
65
|
-
npx -y -p @qingflow-tech/qingflow-cli@1.1.
|
|
65
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.47-beta.1 qingflow-skills install --agent codex --scope user --copy
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
`qingflow-skills install` 默认使用 symlink,且遇到同名 skill 不会覆盖;只有显式传入 `--force` 才会替换。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qingflow-tech/qingflow-cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.47-beta.1",
|
|
4
4
|
"description": "Human-friendly Qingflow command line interface for auth, record operations, import, tasks, and stable builder flows.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/pyproject.toml
CHANGED
|
@@ -439,14 +439,28 @@ qingflow auth logout --forget-persisted
|
|
|
439
439
|
|
|
440
440
|
| 分组 | 骨架(细则见上文章节 + 「关联文件」) |
|
|
441
441
|
| ---- | ------------------------------------ |
|
|
442
|
-
| **auth** | `auth whoami` · `auth use-credential --base-url "$QINGFLOW_BASE_URL" --credential-stdin --persist` · `auth login …`(见「失败恢复时的认证方式」)· `auth logout --forget-persisted` |
|
|
442
|
+
| **auth** | `auth whoami` · `auth use-credential --base-url "$QINGFLOW_BASE_URL" --credential-stdin --persist` · `auth login …`(见「失败恢复时的认证方式」)· `auth login-link [--redirect-path PATH]`(临时网页登录链接)· `auth logout --forget-persisted` |
|
|
443
443
|
| **workspace** | `workspace list` · `workspace get --ws-id` · `workspace select`(慎用,可读数前不必;可能 **59004**) |
|
|
444
444
|
| **app / portal / view / chart** | `app list [--query 关键词]` → **`app get --app-key`**(`accessible_views` / `view_id`、`import_capability`)· `builder package list [--query 关键词]` → `builder package get --package-id` · `portal list` · `portal get --dash-key` · `view get --view-id`(只读视图元信息;`baseInfo` 或 `viewConfig` 只有 40002 / 40027 / 404 这类权限/缺失边界可用另一侧降级,非权限类后端错误不隐藏;解析 app 仅依赖同源 view/form 与可见应用树,不回退旧应用搜索)· `chart get --chart-id`(图表 `baseInfo` / 数据 / 配置分阶段读取;单个辅助阶段 40002 / 40027 / 404 只作为 warning,非权限类后端错误不隐藏) |
|
|
445
445
|
| **record** | `record schema insert`(新建记录 insert-ready schema,仅 `--app-key`)· **`browse`/`list`/`access`:`--app-key` + `--view-id`(缺一常 CONFIG)** · `record list --query ... [--query-field FIELD_ID] [--page-size N]` 用于候选定位/样本浏览 · `record get --record-id` 读详情页首屏上下文 · `record logs --record-id` 读完整数据/流程日志 JSONL · `record insert --items-file`(单条也是 items 一行)· `schema update --record-id [--view-id]` / **`update`**(单条 `--fields-file` **或** 批量 `--items-file`/`--dry-run`,二者勿混)· `record delete --app-key --record-id --view-id system:*` / `--record-ids-file` · `export start/direct --view-id` 仅用于明确导出文件 · **大 schema**:先 **[FIELD_DATA_TYPES](./reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md)** / **[RECORD_CREATE](./reference/record/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md)** / **[RECORD_UPDATE](./reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md)** / **[RECORD_DELETE](./reference/record/QINGFLOW_CLI_RECORD_DELETE_WORKFLOW.md)**,再 `-h` |
|
|
446
446
|
| **import** | `record schema import?` · `import template` → `verify` → `repair?` → `start`(**`being-enter-auditing` 必填**)→ `status`(**三者 selector 只选一**);导入权限预检的 `baseInfo` 只有 40002 / 40027 / 404 可降级为 unknown,非权限错误不能当成无导入权或继续执行;**链路与本机目录**只靠本节不够 → **[RECORD_IMPORT](./reference/record/QINGFLOW_CLI_RECORD_IMPORT_WORKFLOW.md)** |
|
|
447
|
-
| **builder** | **搭建/系统化配置**:先按 **[INDEX](./reference/00-INDEX.md)** / **[BUILDER](./reference/builder/README.md)** 选主路径,再读 `builder contract`。**完整系统/应用包**:`builder package apply --config-file` → 每个应用依次执行 `builder app-form schema`、更新时 `get --app-key APP_KEY --being-draft`、`validate --schema-version VERSION --file DECLARATION.json`、`apply --file DECLARATION.json`(固定版本、完整声明、记录返回的 `appKey`)→ 视图(按钮随视图 `action_buttons` 写)→ 门户(缺报表时写 `chart`)→ 发布/回读。旧 `schema apply` / `layout apply` 不在当前 CLI parser 中。超时、`partial_success`、`write_executed=true`、`safe_to_retry=false` 或回读不完整时先读取 AppForm draft/published,再恢复,不得重复创建或创建 V2/测试/随机后缀绕过重名。**单应用**:新建和更新遵循同一 AppForm CLI 链路,再按需求配置流程/视图。更新动作归入对应资源文档:AppForm [30](./reference/builder/30-schema-fields.md)、视图 [50](./reference/builder/50-views.md)、报表 [60](./reference/builder/60-charts.md)、门户 [70](./reference/builder/70-portal.md)、关联资源/非主链路按钮维护 [80](./reference/builder/80-buttons-associated-resources.md)、流程 [90](./reference/builder/90-workflow.md)、发布 [99](./reference/builder/99-publish-verify.md)
|
|
447
|
+
| **builder** | **搭建/系统化配置**:先按 **[INDEX](./reference/00-INDEX.md)** / **[BUILDER](./reference/builder/README.md)** 选主路径,再读 `builder contract`。**完整系统/应用包**:`builder package apply --config-file` → 每个应用依次执行 `builder app-form schema`、更新时 `get --app-key APP_KEY --being-draft`、`validate --schema-version VERSION --file DECLARATION.json`、`apply --file DECLARATION.json`(固定版本、完整声明、记录返回的 `appKey`)→ 视图(按钮随视图 `action_buttons` 写)→ 门户(缺报表时写 `chart`)→ 发布/回读。旧 `schema apply` / `layout apply` 不在当前 CLI parser 中。超时、`partial_success`、`write_executed=true`、`safe_to_retry=false` 或回读不完整时先读取 AppForm draft/published,再恢复,不得重复创建或创建 V2/测试/随机后缀绕过重名。**单应用**:新建和更新遵循同一 AppForm CLI 链路,再按需求配置流程/视图。更新动作归入对应资源文档:AppForm [30](./reference/builder/30-schema-fields.md)、视图 [50](./reference/builder/50-views.md)、报表 [60](./reference/builder/60-charts.md)、门户 [70](./reference/builder/70-portal.md)、关联资源/非主链路按钮维护 [80](./reference/builder/80-buttons-associated-resources.md)、流程 [90](./reference/builder/90-workflow.md)、发布 [99](./reference/builder/99-publish-verify.md)。**AI 公式/代码块校验**:`builder app-form validate-formula --app-key KEY --formula '<公式>' --test-case <文件路径>`(`--formula` 内联;`--test-case` **只收文件路径或 `-` stdin,不收内联 JSON**)与 `builder app-form validate-code-block --app-key KEY --code <内联文本|@文件路径> --test-case <文件路径>`(本地静态 + 后端真跑,输出 `staticErrors` / `runResult` / `runError`);两者是公式/代码块生成场景的真实校验命令,校验结论必须来自其真实执行,不得以本地模拟替代。代码内占位符 `qf_field.{字段名$$queId$$}`(**字段名不带引号**,带引号会被 CLI 自动剥离但勿依赖)与 `keyQueId` 均使用**十进制** queId(CLI 真跑前自动编码为运行时形态;错误回显自动还原为十进制,勿逆向编码 id)。test-case values 按类型:文本/电话=字符串、数字=数字字面量、日期=yyyy-MM-dd、附件=URL 字符串数组、单选/下拉/多选=选项文案(CLI 自动译为选项 id)、成员/部门=不可提供(代码判空,覆盖校验豁免)。 |
|
|
448
448
|
| **task**(须落盘见上文) | `task list [--task-box][--flow-status][--app-key][--query] --page --page-size`(**枚举、`--query`、与 `record` 视图口径勿混**:见上文 SOP · **[TASK_CONTEXT](./reference/task/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md)**)· **`get`/`log`/`report`/`action`**:默认只传 `task list` 返回的 **`data.items[].task_id`**;`report` 另须必选 **`--report-id`**;`action` **无 `--dry-run`** |
|
|
449
449
|
|
|
450
|
+
### 临时网页登录链接
|
|
451
|
+
|
|
452
|
+
仅在用户需要进入网页查看或继续操作时生成,纯数据查询不自动调用:
|
|
453
|
+
|
|
454
|
+
```bash
|
|
455
|
+
qingflow --json auth login-link
|
|
456
|
+
qingflow --profile default --json auth login-link --redirect-path "/index/app/123?view=abc"
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
- 默认使用当前会话和已选工作区;缺失时按实际错误恢复,不额外预查或重新登录。
|
|
460
|
+
- `--redirect-path` 可选,仅接受已确认的站内业务路径,可包含 query/hash;不接受外域或认证入口。示例 ID 仅作格式展示,不得照抄或编造实际目标;省略时进入工作区首页。
|
|
461
|
+
- 将返回的 `url` 作为可点击链接交付,不修改或拼造。链接短时有效且仅可使用一次,失效后重新获取,不写入公共文档或日志。
|
|
462
|
+
- 命令不会自动打开浏览器;用户仅索取链接时不提前打开,明确要求打开时才调用浏览器。
|
|
463
|
+
|
|
450
464
|
**高发必败**:旧应用搜索入口 · **`record list`/`browse`/`access` 缺 `--view-id`** · **`task report` 缺 `--report-id`** · **`record update` 批量与单条参数混用**。
|
|
451
465
|
|
|
452
466
|
更多成员策略:**[reference/QINGFLOW_CLI_MEMBER_CHEATSHEET.md](./reference/core/QINGFLOW_CLI_MEMBER_CHEATSHEET.md)** · 一次性长模板:**[reference/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md](./reference/core/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md)** · 对齐说明:**[reference/QINGFLOW_CLI_EXPLORATION_REPORT.md](./reference/core/QINGFLOW_CLI_EXPLORATION_REPORT.md)**。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: qingflow-common
|
|
3
|
-
description:
|
|
3
|
+
description: 解释轻流公共业务概念并选择 CLI 或 MCP 入口。用于能力比较、记录与任务操作、表单与流程搭建的任务分流,以及从 AI 对话进入页面查看结果、继续编辑、处理业务或进入工作区的场景。
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Qingflow Common Concepts
|
|
@@ -31,6 +31,7 @@ Do not require both surfaces. If only one is installed, complete the workflow on
|
|
|
31
31
|
| Form schema/read/apply/delete | `qingflow --json builder app-form ...` / `builder app delete` | `app_form_schema`, `app_form_get`, `app_form_apply`, `app_delete` |
|
|
32
32
|
| AppForm static validation | `qingflow --json builder app-form validate --file ...` | Performed inside `app_form_apply`; no separate tool |
|
|
33
33
|
| Record and task work | `qingflow --json record ...` / `task ...` | Qingflow MCP record and task tools |
|
|
34
|
+
| 免登录打开网页或指定业务页面 | `qingflow --json auth login-link [--redirect-path PATH]` | `get_login_link` |
|
|
34
35
|
| Package, view, portal, chart, workflow | `qingflow --json builder ...` | Qingflow MCP configuration tools |
|
|
35
36
|
|
|
36
37
|
The operation names and payload schemas are defined by the public surface. Use the current Schema or tool input schema as the source of truth, not a legacy command name or backend payload.
|
|
@@ -42,6 +43,21 @@ The operation names and payload schemas are defined by the public surface. Use t
|
|
|
42
43
|
|
|
43
44
|
Each package includes this common Skill. The CLI package installs its CLI guidance; the MCP package installs every MCP domain Skill. The source of every packaged Skill is `qingflow-support/mcp-server/skills/`.
|
|
44
45
|
|
|
46
|
+
## 从 AI 对话进入轻流页面
|
|
47
|
+
|
|
48
|
+
用户需要查看结果、继续编辑、处理业务或进入工作区页面时,MCP 调用 `get_login_link`,CLI 调用 `qingflow --json auth login-link`。纯数据查询无需页面,不自动生成链接。
|
|
49
|
+
|
|
50
|
+
- 目标路径使用用户提供或业务工具确认的站内路径,不根据应用、记录标识猜测地址。
|
|
51
|
+
- 用户明确指定页面但路径未知时继续定位或说明限制,不猜测,也不静默替换成首页。搜索未命中时继续搜索 `login` 或查看服务工具列表,不擅自打开固定正式站点;失败时如实说明。
|
|
52
|
+
|
|
53
|
+
- 默认使用当前会话及已选工作区;需要指定会话时使用 MCP 的 `profile` 或 CLI 的 `--profile`。
|
|
54
|
+
- `redirect_path` 可选,默认空:省略时进入工作区首页;传入时仅支持以单个 `/` 开头的站内业务路径,可带 query/hash。不得传完整 URL、外部地址、认证入口或多层编码路径。
|
|
55
|
+
- 示例:`get_login_link({})`;指定目标示例:`get_login_link({"redirect_path":"/index/app/123?view=abc"})`。示例标识仅作格式展示,实际目标必须来自用户提供或已经确认的页面信息,不得编造应用、记录标识。
|
|
56
|
+
- 临近交付时再生成,使用返回的 `url`,文案贴合“查看记录”“继续编辑”等上下文,不展示工作区数字 ID 或原始 JSON。
|
|
57
|
+
- CLI 示例:`qingflow --json auth login-link`;指定目标使用 `qingflow --profile default --json auth login-link --redirect-path "/index/app/123?view=abc"`,同样不得编造实际路径。
|
|
58
|
+
- 链接短时有效且仅可使用一次,失效后重新获取,不修改或拼造链接;只交付给请求用户,不写入公共文档或日志。
|
|
59
|
+
- 用户要求“给我链接”时仅交付,不提前消费;要求“打开”时才使用可用浏览器打开。浏览器不可用时提供可点击链接,不假称已经打开。
|
|
60
|
+
|
|
45
61
|
## Route Next
|
|
46
62
|
|
|
47
63
|
Use the installed package's relevant domain Skill. In the unified MCP package, select the Skill by the requested work, not by an assumed user or builder server role. Do not link to or invoke a domain Skill that the current package did not install.
|
|
@@ -24,6 +24,8 @@ _INTEGRATION_OUTPUT_TARGET_FIELD_TYPES = {
|
|
|
24
24
|
}
|
|
25
25
|
_Q_LINKER_TEMPLATE_RE = re.compile(r"\{\{(?P<key>[^{}\s]+)\}\}")
|
|
26
26
|
_FORMULA_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
27
|
+
#: 公式字段引用正则(#queId_<id>$,支持表格嵌套 <id>.<id>)
|
|
28
|
+
_FORMULA_FIELD_REF_RE = re.compile(r"#queId_([^$]+)\$")
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
class AppFormValidationError(ValueError):
|
|
@@ -460,3 +462,305 @@ def validate_declaration(
|
|
|
460
462
|
issues = [{"pointer": pointer, "message": message} for pointer, message in sorted(found)]
|
|
461
463
|
raise AppFormValidationError(issues, schema_version=pinned, api_version=api_version)
|
|
462
464
|
return {"valid": True, "schemaVersion": pinned, "apiVersion": api_version, "fieldTypes": list(types)}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def validate_formula_expression(
|
|
468
|
+
formula: str,
|
|
469
|
+
fields: list[dict[str, Any]] | None = None,
|
|
470
|
+
) -> list[dict[str, str]]:
|
|
471
|
+
"""校验单条公式表达式:函数名合法 + 字段引用存在性。不依赖整份 AppForm,不调后端。
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
formula: 公式文本,如 ``CONCAT(#queId_101$, ":", #queId_102$)``。
|
|
475
|
+
fields: 字段清单 ``[{queId, name}]``(兼容 ``que_id``/``id``);提供时校验字段引用存在性,
|
|
476
|
+
不提供时跳过字段引用校验。
|
|
477
|
+
Returns:
|
|
478
|
+
错误列表 ``[{pointer, message}]``,空列表表示本地语法通过。
|
|
479
|
+
"""
|
|
480
|
+
errors: list[dict[str, str]] = []
|
|
481
|
+
if not isinstance(formula, str) or not formula.strip():
|
|
482
|
+
errors.append({"pointer": "formula", "message": "formula is empty"})
|
|
483
|
+
return errors
|
|
484
|
+
|
|
485
|
+
# 函数名合法性(复用 AppForm formula Schema 的函数目录)
|
|
486
|
+
supported = _supported_formula_functions("v1alpha1")
|
|
487
|
+
unknown = sorted(
|
|
488
|
+
name for name in _formula_function_calls(formula) if name.upper() not in supported
|
|
489
|
+
)
|
|
490
|
+
for name in unknown:
|
|
491
|
+
errors.append({"pointer": "formula", "message": f"unsupported formula function '{name}'"})
|
|
492
|
+
|
|
493
|
+
# 字段引用存在性(#queId_<id>$,支持表格嵌套 <id>.<id>,按叶子 id 与原始 id 双重匹配)
|
|
494
|
+
if fields:
|
|
495
|
+
known_ids = {
|
|
496
|
+
str(identifier)
|
|
497
|
+
for field in fields
|
|
498
|
+
if isinstance(field, dict)
|
|
499
|
+
for identifier in (
|
|
500
|
+
field.get("queId"),
|
|
501
|
+
field.get("que_id"),
|
|
502
|
+
field.get("id"),
|
|
503
|
+
)
|
|
504
|
+
if identifier is not None and str(identifier) != ""
|
|
505
|
+
}
|
|
506
|
+
for match in _FORMULA_FIELD_REF_RE.finditer(formula):
|
|
507
|
+
raw_id = match.group(1)
|
|
508
|
+
leaf_id = raw_id.split(".")[-1]
|
|
509
|
+
if leaf_id not in known_ids and raw_id not in known_ids:
|
|
510
|
+
errors.append(
|
|
511
|
+
{
|
|
512
|
+
"pointer": "formula",
|
|
513
|
+
"message": f"field reference '{match.group(0)}' refers to unknown queId {raw_id}",
|
|
514
|
+
}
|
|
515
|
+
)
|
|
516
|
+
return errors
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def validate_code_block_static(code: str) -> list[dict[str, str]]:
|
|
520
|
+
"""校验代码块静态结构:qf_output 赋值 + 基本语法。不调后端。
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
code: 代码文本。
|
|
524
|
+
Returns:
|
|
525
|
+
错误列表 ``[{pointer, message}]``,空列表表示本地静态校验通过。
|
|
526
|
+
"""
|
|
527
|
+
errors: list[dict[str, str]] = []
|
|
528
|
+
if not isinstance(code, str) or not code.strip():
|
|
529
|
+
errors.append({"pointer": "code", "message": "code is empty"})
|
|
530
|
+
return errors
|
|
531
|
+
|
|
532
|
+
# qf_output 必须直接赋值(不能用 const/let 声明);仅当代码涉及 outputs 绑定时才提示
|
|
533
|
+
has_qf_output = bool(re.search(r"(^|[^A-Za-z0-9_$])qf_output\s*=(?!=)", code))
|
|
534
|
+
has_outputs_binding = "outputs" in code
|
|
535
|
+
if not has_qf_output and has_outputs_binding:
|
|
536
|
+
errors.append(
|
|
537
|
+
{
|
|
538
|
+
"pointer": "code",
|
|
539
|
+
"message": "code declares outputs but does not assign qf_output; "
|
|
540
|
+
"use 'qf_output = {...}' (not const/let)",
|
|
541
|
+
}
|
|
542
|
+
)
|
|
543
|
+
return errors
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# ---------------------------------------------------------------------------
|
|
547
|
+
# 代码块字段占位符编码
|
|
548
|
+
#
|
|
549
|
+
# 后端 codeBlockTest 运行时通过 CustomPushContentConverter.getQueId 解码代码里的
|
|
550
|
+
# ``qf_field.{label$$<encoded>$$}`` 段(私有混淆编码,非十进制),再按解码出的十进制
|
|
551
|
+
# queId 匹配 --test-case 的 keyQueId 填值;匹配不上会静默跳过(占位符原样留跑)。
|
|
552
|
+
# AI 生成的代码按十进制 queId 书写(与 skill/编辑器上下文一致),本模块在真跑前把
|
|
553
|
+
# 纯数字的 ``$$<digits>$$`` 段编码为运行时形态——与后端采纳链路(encryptQueId)同一
|
|
554
|
+
# 编码、同一"纯数字才编码"规则,已编码(含字母)的段原样保留,避免双重编码。
|
|
555
|
+
# ---------------------------------------------------------------------------
|
|
556
|
+
|
|
557
|
+
_ENC_KEY = 1000013
|
|
558
|
+
_INT_TO_HEX = "0123456789ABCDEF"
|
|
559
|
+
_DECIMAL_PLACEHOLDER = re.compile(r"\$\$(-?\d+)\$\$")
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def encode_que_id(que_id: int) -> str:
|
|
563
|
+
"""Java ``CustomPushContentConverter.encryptQueId`` 的逐行为等价移植。"""
|
|
564
|
+
have_prefix = que_id < 0
|
|
565
|
+
abs_id = abs(que_id)
|
|
566
|
+
ran = abs_id * 17
|
|
567
|
+
inp = str(abs_id)
|
|
568
|
+
seeds: list[int] = []
|
|
569
|
+
salt_sum = 0
|
|
570
|
+
for _ in range(max(0, 4 - len(inp))):
|
|
571
|
+
tmp = (ran % 20) + 71
|
|
572
|
+
ran = ((ran + 13) * 17) % _ENC_KEY
|
|
573
|
+
salt_sum += tmp
|
|
574
|
+
seeds.append(tmp)
|
|
575
|
+
result = [(salt_sum + i + int(ch, 16)) % 15 for i, ch in enumerate(inp)]
|
|
576
|
+
for seed in seeds:
|
|
577
|
+
pos = (ran + seed) % len(result)
|
|
578
|
+
ran = ((ran + 13) * 17) % _ENC_KEY
|
|
579
|
+
result.insert(pos, seed)
|
|
580
|
+
encoded = "".join(chr(value) if value > 15 else _INT_TO_HEX[value] for value in result)
|
|
581
|
+
return ("-" if have_prefix else "") + encoded
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def encode_code_block_field_ids(code: str) -> str:
|
|
585
|
+
"""把代码里的十进制 queId 占位符段编码为后端运行时形态。
|
|
586
|
+
|
|
587
|
+
仅处理纯数字(可带负号)的 ``$$...$$`` 段;混有字母的段视为已编码,原样保留。
|
|
588
|
+
"""
|
|
589
|
+
if not isinstance(code, str):
|
|
590
|
+
return code
|
|
591
|
+
return _DECIMAL_PLACEHOLDER.sub(
|
|
592
|
+
lambda match: f"$${encode_que_id(int(match.group(1)))}$$", code
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def referenced_code_block_field_ids(code: str) -> set[int]:
|
|
597
|
+
"""提取代码里以十进制 queId 引用的字段 id 集合(供 test-case 覆盖校验)。
|
|
598
|
+
|
|
599
|
+
只统计纯数字(可带负号)的 ``$$...$$`` 段;已编码(含字母)段解码依赖后端实现,
|
|
600
|
+
不在此覆盖校验范围内。
|
|
601
|
+
"""
|
|
602
|
+
if not isinstance(code, str):
|
|
603
|
+
return set()
|
|
604
|
+
return {int(match.group(1)) for match in _DECIMAL_PLACEHOLDER.finditer(code)}
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
# ---------------------------------------------------------------------------
|
|
608
|
+
# 代码块校验的占位符容错与回显还原
|
|
609
|
+
#
|
|
610
|
+
# 现场问题(agent 实测):
|
|
611
|
+
# 1. 模型常把占位符写成 qf_field.{"字段名$$id$$"} —— 字段名带引号。后端替换正则要求
|
|
612
|
+
# 结尾恰为 `$$}`,带引号变成 `$$"}` 匹配失败,占位符原样留在代码里直接语法错误。
|
|
613
|
+
# 这里在编码归一化前剥离字段名两侧引号,带引号写法自动修正。
|
|
614
|
+
# 2. CLI 把 `$$十进制$$` 编码为混淆形态后,校验失败的错误回显里是编码 id,模型认不出
|
|
615
|
+
# 是自己写的 queId,会试图逆向"哈希"浪费大量轮次。这里记录 编码→十进制 映射,
|
|
616
|
+
# 输出前把错误文本里的编码段还原为十进制。
|
|
617
|
+
# ---------------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
_QUOTED_PLACEHOLDER = re.compile(r'(qf_field\.\{)["\']([^$\'"{]+?)\$\$(-?\d+)\$\$["\'](\})')
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def strip_placeholder_quotes(code: str) -> tuple[str, int]:
|
|
623
|
+
"""剥离占位符字段名两侧引号:qf_field.{"名$$id$$"} → qf_field.{名$$id$$}。
|
|
624
|
+
|
|
625
|
+
Returns:
|
|
626
|
+
(修正后的代码, 修正处数)
|
|
627
|
+
"""
|
|
628
|
+
if not isinstance(code, str):
|
|
629
|
+
return code, 0
|
|
630
|
+
count = 0
|
|
631
|
+
|
|
632
|
+
def _strip(match: re.Match[str]) -> str:
|
|
633
|
+
nonlocal count
|
|
634
|
+
count += 1
|
|
635
|
+
return f"{match.group(1)}{match.group(2)}$${match.group(3)}$$}}"
|
|
636
|
+
|
|
637
|
+
return _QUOTED_PLACEHOLDER.sub(_strip, code), count
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def build_encoded_id_map(code: str) -> dict[str, str]:
|
|
641
|
+
"""对代码里的纯数字占位段构建 编码形态→十进制 映射(用于错误回显还原)。"""
|
|
642
|
+
if not isinstance(code, str):
|
|
643
|
+
return {}
|
|
644
|
+
return {
|
|
645
|
+
encode_que_id(int(match.group(1))): match.group(1)
|
|
646
|
+
for match in _DECIMAL_PLACEHOLDER.finditer(code)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def restore_decimal_ids_in_text(text: str, id_map: dict[str, str]) -> str:
|
|
651
|
+
"""把文本里出现的 `$$<编码>$$` 还原为 `$$<十进制>$$`(错误回显可读化)。"""
|
|
652
|
+
if not isinstance(text, str) or not id_map:
|
|
653
|
+
return text
|
|
654
|
+
for encoded, decimal in id_map.items():
|
|
655
|
+
text = text.replace(f"$${encoded}$$", f"$${decimal}$$")
|
|
656
|
+
return text
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
# 选择类字段 test-case 值翻译:后端只认选项 optId(数字),传文案静默变 null;
|
|
660
|
+
# 模型不可能知道 optId,这里按字段定义把"选项文案"翻译成 optId。
|
|
661
|
+
_SELECT_FIELD_TYPES = {"single_choice", "single_select", "multi_select"}
|
|
662
|
+
# 值不可推导的字段类型(成员/部门要 uid/部门id,模型无从得知):覆盖校验跳过。
|
|
663
|
+
_UNDERIVABLE_FIELD_TYPES = {"member", "department"}
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def translate_select_test_case_values(
|
|
667
|
+
fields: list[dict] | None, key_que_values: list[dict]
|
|
668
|
+
) -> tuple[list[dict], list[dict[str, str]], list[str]]:
|
|
669
|
+
"""把选择类字段的"选项文案"值翻译为 optId。
|
|
670
|
+
|
|
671
|
+
Args:
|
|
672
|
+
fields: 字段定义清单(含 que_id/type/option_details;可为 None 表示未取到)。
|
|
673
|
+
key_que_values: 已规范化的 test-case。
|
|
674
|
+
Returns:
|
|
675
|
+
(翻译后的 test-case, 翻译记录[{queId, from, to}], 无法翻译的选项错误清单)
|
|
676
|
+
"""
|
|
677
|
+
if not fields:
|
|
678
|
+
return key_que_values, [], []
|
|
679
|
+
option_lookup: dict[int, dict[str, object]] = {}
|
|
680
|
+
type_by_que: dict[int, str] = {}
|
|
681
|
+
for field in fields:
|
|
682
|
+
if not isinstance(field, dict):
|
|
683
|
+
continue
|
|
684
|
+
que_id = field.get("que_id") or field.get("queId")
|
|
685
|
+
if not isinstance(que_id, int):
|
|
686
|
+
continue
|
|
687
|
+
field_type = str(field.get("type") or "")
|
|
688
|
+
type_by_que[que_id] = field_type
|
|
689
|
+
details = field.get("option_details")
|
|
690
|
+
if isinstance(details, list):
|
|
691
|
+
by_value = {
|
|
692
|
+
str(item.get("value") or "").strip(): item.get("id")
|
|
693
|
+
for item in details
|
|
694
|
+
if isinstance(item, dict) and item.get("id") is not None
|
|
695
|
+
}
|
|
696
|
+
if by_value:
|
|
697
|
+
option_lookup[que_id] = by_value
|
|
698
|
+
|
|
699
|
+
translated = [dict(item) for item in key_que_values]
|
|
700
|
+
changes: list[dict[str, str]] = []
|
|
701
|
+
errors: list[str] = []
|
|
702
|
+
for item in translated:
|
|
703
|
+
key = item.get("keyQueId")
|
|
704
|
+
if not isinstance(key, int) or key not in option_lookup:
|
|
705
|
+
continue
|
|
706
|
+
by_value = option_lookup[key]
|
|
707
|
+
new_values: list[object] = []
|
|
708
|
+
for value in item.get("values") or []:
|
|
709
|
+
text = str(value).strip()
|
|
710
|
+
if text in by_value:
|
|
711
|
+
new_values.append(by_value[text])
|
|
712
|
+
changes.append({"queId": str(key), "from": text, "to": str(by_value[text])})
|
|
713
|
+
elif str(value).isdigit() and int(value) in set(by_value.values()):
|
|
714
|
+
new_values.append(int(value))
|
|
715
|
+
else:
|
|
716
|
+
errors.append(
|
|
717
|
+
f"field queId {key}: 选项「{text}」不存在,可选:{'/'.join(by_value.keys())}"
|
|
718
|
+
)
|
|
719
|
+
new_values.append(value)
|
|
720
|
+
item["values"] = new_values
|
|
721
|
+
return translated, changes, errors
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
# ---------------------------------------------------------------------------
|
|
725
|
+
# 公式校验的引用提取与选择类值翻译
|
|
726
|
+
#
|
|
727
|
+
# 实测(真实后端 /ai/coding/formula/validate):
|
|
728
|
+
# - 缺值字段静默变空(CONCAT 直接跳过)→ 空洞通过,需要覆盖门;
|
|
729
|
+
# - 单选/多选必须传 **[optId 数组]**:文案/optId 标量均报错;返回值为 optId 串(非文案);
|
|
730
|
+
# - 日期字段裸引用报错,套日期函数(YEAR 等)+ "yyyy-MM-dd" 字符串可用。
|
|
731
|
+
# ---------------------------------------------------------------------------
|
|
732
|
+
|
|
733
|
+
_FORMULA_REFERENCE = re.compile(r"#queId_(\d+)\$")
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def referenced_formula_field_ids(formula: str) -> set[int]:
|
|
737
|
+
"""提取公式 `#queId_<id>$` 引用的字段 id 集合(供覆盖校验)。"""
|
|
738
|
+
if not isinstance(formula, str):
|
|
739
|
+
return set()
|
|
740
|
+
return {int(m.group(1)) for m in _FORMULA_REFERENCE.finditer(formula)}
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def translate_formula_test_case_values(
|
|
744
|
+
fields: list[dict] | None, test_case: dict
|
|
745
|
+
) -> tuple[dict, list[dict[str, str]], list[str]]:
|
|
746
|
+
"""公式 testCase 值归一化(全类型实测契约)。
|
|
747
|
+
|
|
748
|
+
实测核心规律:**裸引用/函数求值统一要求 [数组] 形态**,标量一律报错(含文本/数字/日期);
|
|
749
|
+
单选/多选传 ["选项文案"] 数组直接可过且求值结果是文案本身(优于内部 id)。
|
|
750
|
+
因此本函数只做一件事:把所有标量值包装成 [标量];已是数组原样保留。
|
|
751
|
+
|
|
752
|
+
Args:
|
|
753
|
+
fields: 字段定义(当前未用,保留签名兼容;数组包装与类型无关)。
|
|
754
|
+
test_case: {queId: value} 对象。
|
|
755
|
+
Returns:
|
|
756
|
+
(归一化 test-case, 包装记录, 错误清单——恒空)
|
|
757
|
+
"""
|
|
758
|
+
if not isinstance(test_case, dict):
|
|
759
|
+
return test_case, [], []
|
|
760
|
+
translated = dict(test_case)
|
|
761
|
+
changes: list[dict[str, str]] = []
|
|
762
|
+
for key, value in list(translated.items()):
|
|
763
|
+
if not isinstance(value, list):
|
|
764
|
+
translated[key] = [value]
|
|
765
|
+
changes.append({"queId": str(key), "from": f"scalar:{value}", "to": f"[{value}]"})
|
|
766
|
+
return translated, changes, []
|
|
@@ -21167,6 +21167,11 @@ def _compact_public_field_read(*, field: dict[str, Any], layout: dict[str, Any],
|
|
|
21167
21167
|
"required": bool(field.get("required")),
|
|
21168
21168
|
"section_id": None if nested else _find_field_section_id(layout, str(field.get("name") or "")),
|
|
21169
21169
|
}
|
|
21170
|
+
if field.get("type") in {FieldType.single_select.value, FieldType.multi_select.value} and field.get(
|
|
21171
|
+
"option_details"
|
|
21172
|
+
):
|
|
21173
|
+
# 选项 id 透出:validate-code-block 需要把"选项文案"翻译成 optId(后端只认 optId)
|
|
21174
|
+
payload["option_details"] = deepcopy(field.get("option_details"))
|
|
21170
21175
|
if field.get("type") == FieldType.relation.value:
|
|
21171
21176
|
payload["target_app_key"] = field.get("target_app_key")
|
|
21172
21177
|
payload["relation_mode"] = _normalize_relation_mode(field.get("relation_mode"))
|
|
@@ -40,11 +40,19 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
40
40
|
whoami = auth_subparsers.add_parser("whoami", help="查看当前登录态")
|
|
41
41
|
whoami.set_defaults(handler=_handle_whoami, format_hint="auth_whoami")
|
|
42
42
|
|
|
43
|
+
login_link = auth_subparsers.add_parser("login-link", help="生成当前工作区的临时一次性登录链接(不自动打开)")
|
|
44
|
+
login_link.add_argument("--redirect-path", default="", help="登录后跳转的站内路径,可包含查询参数和锚点")
|
|
45
|
+
login_link.set_defaults(handler=_handle_login_link, format_hint="login_link")
|
|
46
|
+
|
|
43
47
|
logout = auth_subparsers.add_parser("logout", help="退出登录")
|
|
44
48
|
logout.add_argument("--forget-persisted", action="store_true")
|
|
45
49
|
logout.set_defaults(handler=_handle_logout, format_hint="")
|
|
46
50
|
|
|
47
51
|
|
|
52
|
+
def _handle_login_link(args: argparse.Namespace, context: CliContext) -> dict:
|
|
53
|
+
return context.login_link.get_login_link(profile=args.profile, redirect_path=args.redirect_path)
|
|
54
|
+
|
|
55
|
+
|
|
48
56
|
def _handle_login(args: argparse.Namespace, context: CliContext) -> dict:
|
|
49
57
|
if args.method == "oauth":
|
|
50
58
|
return _handle_oauth_login(args, context)
|
|
@@ -2,9 +2,23 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
from copy import deepcopy
|
|
5
|
+
from pathlib import Path
|
|
5
6
|
|
|
6
7
|
from ..context import CliContext
|
|
7
|
-
from ...app_form_validator import
|
|
8
|
+
from ...app_form_validator import (
|
|
9
|
+
AppFormValidationError,
|
|
10
|
+
build_encoded_id_map,
|
|
11
|
+
referenced_formula_field_ids,
|
|
12
|
+
translate_formula_test_case_values,
|
|
13
|
+
encode_code_block_field_ids,
|
|
14
|
+
referenced_code_block_field_ids,
|
|
15
|
+
restore_decimal_ids_in_text,
|
|
16
|
+
strip_placeholder_quotes,
|
|
17
|
+
translate_select_test_case_values,
|
|
18
|
+
validate_code_block_static,
|
|
19
|
+
validate_declaration,
|
|
20
|
+
validate_formula_expression,
|
|
21
|
+
)
|
|
8
22
|
from ..json_io import load_json_value
|
|
9
23
|
from .common import load_list_arg, load_object_arg, parse_bool_text, raise_config_error, require_list_arg
|
|
10
24
|
|
|
@@ -128,6 +142,41 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
128
142
|
app_form_validate.add_argument("--schema-version", help="固定使用的 AppForm Schema 版本")
|
|
129
143
|
app_form_validate.set_defaults(handler=_handle_app_form_validate, format_hint="builder_summary", force_json_output=True)
|
|
130
144
|
|
|
145
|
+
app_form_validate_formula = app_form_subparsers.add_parser(
|
|
146
|
+
"validate-formula", help="校验公式表达式(本地语法 + 后端求值)"
|
|
147
|
+
)
|
|
148
|
+
app_form_validate_formula.add_argument("--app-key", required=True, help="目标应用 key")
|
|
149
|
+
app_form_validate_formula.add_argument("--formula", required=True, help="公式文本")
|
|
150
|
+
app_form_validate_formula.add_argument(
|
|
151
|
+
"--test-case",
|
|
152
|
+
required=True,
|
|
153
|
+
help="测试用例 JSON 文件路径,- 表示 stdin;格式 {queId: value}",
|
|
154
|
+
)
|
|
155
|
+
app_form_validate_formula.add_argument(
|
|
156
|
+
"--fields",
|
|
157
|
+
required=False,
|
|
158
|
+
help="字段清单 JSON 文件路径(用于本地字段引用校验);缺省时从后端拉取",
|
|
159
|
+
)
|
|
160
|
+
app_form_validate_formula.set_defaults(
|
|
161
|
+
handler=_handle_app_form_validate_formula, format_hint="builder_summary", force_json_output=True
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
app_form_validate_code_block = app_form_subparsers.add_parser(
|
|
165
|
+
"validate-code-block", help="校验代码块(本地静态 + 后端真跑)"
|
|
166
|
+
)
|
|
167
|
+
app_form_validate_code_block.add_argument("--app-key", required=True, help="目标应用 key")
|
|
168
|
+
app_form_validate_code_block.add_argument(
|
|
169
|
+
"--code", required=True, help="代码文本(或 @文件路径读取)"
|
|
170
|
+
)
|
|
171
|
+
app_form_validate_code_block.add_argument(
|
|
172
|
+
"--test-case",
|
|
173
|
+
required=True,
|
|
174
|
+
help="测试用例 JSON 文件路径,- 表示 stdin;格式 [{keyQueId, ordinal?, values}]",
|
|
175
|
+
)
|
|
176
|
+
app_form_validate_code_block.set_defaults(
|
|
177
|
+
handler=_handle_app_form_validate_code_block, format_hint="builder_summary", force_json_output=True
|
|
178
|
+
)
|
|
179
|
+
|
|
131
180
|
app = builder_subparsers.add_parser("app", help="应用")
|
|
132
181
|
app_subparsers = app.add_subparsers(dest="builder_app_command", required=True)
|
|
133
182
|
|
|
@@ -395,6 +444,283 @@ def _handle_app_form_validate(args: argparse.Namespace, _context: CliContext) ->
|
|
|
395
444
|
return {"category": "validation", "status": "success", "ok": True, **result, "message": "AppForm declaration is valid"}
|
|
396
445
|
|
|
397
446
|
|
|
447
|
+
def _handle_app_form_validate_formula(args: argparse.Namespace, context: CliContext) -> dict:
|
|
448
|
+
formula = args.formula
|
|
449
|
+
test_case = load_json_value(
|
|
450
|
+
args.test_case, option_name="--test-case", max_bytes=1 * 1024 * 1024, max_depth=32
|
|
451
|
+
)
|
|
452
|
+
if not isinstance(test_case, dict):
|
|
453
|
+
raise_config_error(
|
|
454
|
+
"--test-case must be a JSON object {queId: value}", error_code="INVALID_TEST_CASE"
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
# 字段定义:优先 --fields;缺省拉全量(保留 option_details 供选择类值翻译)
|
|
458
|
+
fields_path = getattr(args, "fields", None)
|
|
459
|
+
if fields_path:
|
|
460
|
+
fields = _load_fields_for_validation(args, context)
|
|
461
|
+
else:
|
|
462
|
+
fields = _load_code_block_validation_fields(args, context) or _load_fields_for_validation(args, context)
|
|
463
|
+
|
|
464
|
+
# ① 本地语法校验(函数名 + 字段引用存在性)
|
|
465
|
+
syntax_fields = [
|
|
466
|
+
{"queId": f.get("que_id") or f.get("queId"), "name": f.get("name")} for f in (fields or [])
|
|
467
|
+
] if fields else None
|
|
468
|
+
syntax_errors = validate_formula_expression(formula, syntax_fields)
|
|
469
|
+
|
|
470
|
+
# ①' 选择类值翻译:实测后端只认 [optId 数组],文案/标量 optId 均报错——把文案自动翻成 [optId]
|
|
471
|
+
test_case, select_changes, option_errors = translate_formula_test_case_values(fields, test_case)
|
|
472
|
+
|
|
473
|
+
# ①'' 覆盖校验:公式引用缺值字段会静默变空(CONCAT 直接跳过)→ 空洞通过,这里拦下
|
|
474
|
+
referenced_ids = referenced_formula_field_ids(formula)
|
|
475
|
+
underivable = _underivable_field_ids(fields)
|
|
476
|
+
covered_ids = {int(k) for k in test_case if str(k).isdigit()}
|
|
477
|
+
missing_ids = sorted((referenced_ids - covered_ids) - underivable)
|
|
478
|
+
coverage_errors = [
|
|
479
|
+
{
|
|
480
|
+
"pointer": "test-case",
|
|
481
|
+
"message": f"field queId {que_id} is referenced by formula but has no test-case value "
|
|
482
|
+
f"(reference would evaluate as empty)",
|
|
483
|
+
}
|
|
484
|
+
for que_id in missing_ids
|
|
485
|
+
]
|
|
486
|
+
|
|
487
|
+
# ② 后端求值校验(本地有错时不浪费后端调用)
|
|
488
|
+
evaluation = None
|
|
489
|
+
evaluation_error = None
|
|
490
|
+
if not syntax_errors and not coverage_errors and not option_errors:
|
|
491
|
+
result = context.builder.app_form_evaluate_formula(
|
|
492
|
+
profile=args.profile, app_key=args.app_key, formula=formula, test_case=test_case
|
|
493
|
+
)
|
|
494
|
+
evaluation = result.get("evaluation")
|
|
495
|
+
evaluation_error = result.get("evaluationError")
|
|
496
|
+
|
|
497
|
+
ok = bool(
|
|
498
|
+
not syntax_errors
|
|
499
|
+
and not coverage_errors
|
|
500
|
+
and not option_errors
|
|
501
|
+
and isinstance(evaluation, dict)
|
|
502
|
+
and evaluation.get("valid") is True
|
|
503
|
+
)
|
|
504
|
+
return {
|
|
505
|
+
"category": "validation",
|
|
506
|
+
"status": "success" if ok else "failed",
|
|
507
|
+
"ok": ok,
|
|
508
|
+
"syntaxErrors": syntax_errors,
|
|
509
|
+
"coverageErrors": coverage_errors,
|
|
510
|
+
"optionErrors": option_errors,
|
|
511
|
+
"autoFixes": {"selectValuesTranslated": select_changes},
|
|
512
|
+
"evaluation": evaluation,
|
|
513
|
+
"evaluationError": evaluation_error,
|
|
514
|
+
"message": "formula is valid" if ok else "formula validation failed",
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _handle_app_form_validate_code_block(args: argparse.Namespace, context: CliContext) -> dict:
|
|
519
|
+
raw_code = _read_code_arg(args.code)
|
|
520
|
+
# 占位符容错:模型常给字段名加引号(qf_field.{"名$$id$$"}),后端替换正则要求结尾恰为
|
|
521
|
+
# `$$}`,带引号直接匹配失败→占位符原样进 JS→语法错误。先剥离引号再进编码。
|
|
522
|
+
code_after_quotes, quote_fixes = strip_placeholder_quotes(raw_code)
|
|
523
|
+
# 后端运行时按 encryptQueId 的编码形态解码代码里的 $$id$$ 段再匹配 test-case 填值;
|
|
524
|
+
# AI 生成的代码是十进制 queId,先编码归一化(含字母的已编码段原样保留,防双重编码)。
|
|
525
|
+
code = encode_code_block_field_ids(code_after_quotes)
|
|
526
|
+
# 错误回显还原映射:后端错误里出现的编码 id 还原为模型写的十进制,避免模型逆向混淆。
|
|
527
|
+
encoded_id_map = build_encoded_id_map(code_after_quotes)
|
|
528
|
+
test_case = load_json_value(
|
|
529
|
+
args.test_case, option_name="--test-case", max_bytes=1 * 1024 * 1024, max_depth=32
|
|
530
|
+
)
|
|
531
|
+
if not isinstance(test_case, list):
|
|
532
|
+
raise_config_error(
|
|
533
|
+
"--test-case must be a JSON array [{keyQueId, ordinal?, values}]",
|
|
534
|
+
error_code="INVALID_TEST_CASE",
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
# 字段定义(选项翻译/覆盖豁免用;拉取失败不阻断,只是少一层自动修正)
|
|
538
|
+
fields = _load_code_block_validation_fields(args, context)
|
|
539
|
+
|
|
540
|
+
# ① 本地静态校验(qf_output 赋值 + 基本语法)
|
|
541
|
+
static_errors = validate_code_block_static(code)
|
|
542
|
+
|
|
543
|
+
# ①' 选择类值翻译:后端只认选项 optId,传文案静默变 null——把文案自动翻成 optId。
|
|
544
|
+
key_que_values = _normalize_code_block_test_case(test_case)
|
|
545
|
+
key_que_values, select_changes, option_errors = translate_select_test_case_values(fields, key_que_values)
|
|
546
|
+
|
|
547
|
+
# ①'' 测试覆盖校验:代码引用的字段必须在 test-case 中给值(成员/部门类豁免——
|
|
548
|
+
# 其值是 uid/部门 id,模型无从得知,代码须自行 null 安全)。缺值时后端会把占位符
|
|
549
|
+
# 替换成字面量 null 静默跑通(空洞通过)——在这里拦下,逼 test-case 补全而不是放行。
|
|
550
|
+
covered_ids = _code_block_test_case_field_ids(key_que_values)
|
|
551
|
+
underivable = _underivable_field_ids(fields)
|
|
552
|
+
referenced_ids = referenced_code_block_field_ids(raw_code)
|
|
553
|
+
missing_ids = sorted((referenced_ids - covered_ids) - underivable)
|
|
554
|
+
coverage_errors = [
|
|
555
|
+
{
|
|
556
|
+
"pointer": "test-case",
|
|
557
|
+
"message": f"field queId {que_id} is referenced by code but has no test-case value "
|
|
558
|
+
f"(placeholder would run as literal null)",
|
|
559
|
+
}
|
|
560
|
+
for que_id in missing_ids
|
|
561
|
+
]
|
|
562
|
+
|
|
563
|
+
# ② 后端真跑校验(本地有错时不浪费后端调用)
|
|
564
|
+
run_result = None
|
|
565
|
+
run_error = None
|
|
566
|
+
if not static_errors and not coverage_errors and not option_errors:
|
|
567
|
+
result = context.builder.app_form_run_code_block(
|
|
568
|
+
profile=args.profile, app_key=args.app_key, code=code, key_que_values=key_que_values
|
|
569
|
+
)
|
|
570
|
+
run_result = result.get("runResult")
|
|
571
|
+
run_error = result.get("runError")
|
|
572
|
+
|
|
573
|
+
if isinstance(run_error, str):
|
|
574
|
+
run_error = restore_decimal_ids_in_text(run_error, encoded_id_map)
|
|
575
|
+
|
|
576
|
+
ok = not static_errors and not coverage_errors and not option_errors and run_result is not None
|
|
577
|
+
return {
|
|
578
|
+
"category": "validation",
|
|
579
|
+
"status": "success" if ok else "failed",
|
|
580
|
+
"ok": ok,
|
|
581
|
+
"staticErrors": static_errors,
|
|
582
|
+
"coverageErrors": coverage_errors,
|
|
583
|
+
"optionErrors": option_errors,
|
|
584
|
+
"autoFixes": {
|
|
585
|
+
"placeholderQuotesStripped": quote_fixes,
|
|
586
|
+
"selectValuesTranslated": select_changes,
|
|
587
|
+
},
|
|
588
|
+
"runResult": run_result,
|
|
589
|
+
"runError": run_error,
|
|
590
|
+
"message": "code block is valid" if ok else "code block validation failed",
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _load_code_block_validation_fields(
|
|
595
|
+
args: argparse.Namespace, context: CliContext
|
|
596
|
+
) -> list[dict] | None:
|
|
597
|
+
"""加载字段定义(类型+选项 optId),供选择类值翻译与成员/部门覆盖豁免;失败返回 None。"""
|
|
598
|
+
try:
|
|
599
|
+
fetched = context.builder.app_get_fields(profile=args.profile, app_key=args.app_key)
|
|
600
|
+
except Exception: # noqa: BLE001 - 字段拉取失败不阻断校验主流程
|
|
601
|
+
return None
|
|
602
|
+
if not isinstance(fetched, dict) or fetched.get("status") != "success":
|
|
603
|
+
return None
|
|
604
|
+
raw_fields = fetched.get("fields")
|
|
605
|
+
if not isinstance(raw_fields, list):
|
|
606
|
+
return None
|
|
607
|
+
return [field for field in raw_fields if isinstance(field, dict)]
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _underivable_field_ids(fields: list[dict] | None) -> set[int]:
|
|
611
|
+
"""值不可由模型推导的字段(成员/部门):test-case 覆盖校验豁免。"""
|
|
612
|
+
if not fields:
|
|
613
|
+
return set()
|
|
614
|
+
result: set[int] = set()
|
|
615
|
+
for field in fields:
|
|
616
|
+
field_type = str(field.get("type") or "")
|
|
617
|
+
if field_type in {"member", "department"}:
|
|
618
|
+
que_id = field.get("que_id") or field.get("queId")
|
|
619
|
+
if isinstance(que_id, int):
|
|
620
|
+
result.add(que_id)
|
|
621
|
+
return result
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _code_block_test_case_field_ids(key_que_values: list[dict]) -> set[int]:
|
|
625
|
+
"""test-case 覆盖的字段 id 集合:顶层 keyQueId + 表格 tableValues 行内 keyQueId。"""
|
|
626
|
+
covered: set[int] = set()
|
|
627
|
+
for item in key_que_values:
|
|
628
|
+
key = item.get("keyQueId")
|
|
629
|
+
if isinstance(key, int):
|
|
630
|
+
covered.add(key)
|
|
631
|
+
for row in item.get("tableValues") or []:
|
|
632
|
+
if isinstance(row, list):
|
|
633
|
+
for cell in row:
|
|
634
|
+
if isinstance(cell, dict) and isinstance(cell.get("keyQueId"), int):
|
|
635
|
+
covered.add(cell["keyQueId"])
|
|
636
|
+
return covered
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _load_fields_for_validation(args: argparse.Namespace, context: CliContext) -> list[dict] | None:
|
|
640
|
+
"""加载字段清单:优先 --fields 参数,缺省时从后端拉取应用字段。
|
|
641
|
+
|
|
642
|
+
后端拉取失败时返回 None(跳过字段引用校验,不阻断流程)。
|
|
643
|
+
"""
|
|
644
|
+
fields_path = getattr(args, "fields", None)
|
|
645
|
+
if fields_path:
|
|
646
|
+
fields = load_json_value(
|
|
647
|
+
fields_path, option_name="--fields", max_bytes=2 * 1024 * 1024, max_depth=64
|
|
648
|
+
)
|
|
649
|
+
if not isinstance(fields, list):
|
|
650
|
+
raise_config_error(
|
|
651
|
+
"--fields must be a JSON array of field objects", error_code="INVALID_FIELDS"
|
|
652
|
+
)
|
|
653
|
+
return fields
|
|
654
|
+
fetched = context.builder.app_get_fields(profile=args.profile, app_key=args.app_key)
|
|
655
|
+
if not isinstance(fetched, dict) or fetched.get("status") != "success":
|
|
656
|
+
return None
|
|
657
|
+
raw_fields = fetched.get("fields")
|
|
658
|
+
if not isinstance(raw_fields, list):
|
|
659
|
+
return None
|
|
660
|
+
return [
|
|
661
|
+
{"queId": field.get("que_id"), "name": field.get("name")}
|
|
662
|
+
for field in raw_fields
|
|
663
|
+
if isinstance(field, dict)
|
|
664
|
+
]
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _read_code_arg(code_arg: str) -> str:
|
|
668
|
+
"""读取 --code 参数:@ 前缀读文件内容,否则当文本。"""
|
|
669
|
+
if not isinstance(code_arg, str) or not code_arg:
|
|
670
|
+
raise_config_error("--code is required", error_code="INVALID_CODE")
|
|
671
|
+
if code_arg.startswith("@"):
|
|
672
|
+
path = code_arg[1:]
|
|
673
|
+
try:
|
|
674
|
+
return Path(path).expanduser().read_text(encoding="utf-8")
|
|
675
|
+
except OSError as exc:
|
|
676
|
+
raise_config_error(
|
|
677
|
+
f"failed to read --code from '{path}': {exc}", error_code="INVALID_CODE"
|
|
678
|
+
)
|
|
679
|
+
return code_arg
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _normalize_code_block_test_case(test_case: list) -> list[dict]:
|
|
683
|
+
"""将 --test-case 规范化为后端 OpenMatchQueValue 结构 [{keyQueId, ordinal?, values}]。"""
|
|
684
|
+
normalized: list[dict] = []
|
|
685
|
+
for item in test_case:
|
|
686
|
+
if not isinstance(item, dict):
|
|
687
|
+
raise_config_error(
|
|
688
|
+
"--test-case items must be JSON objects {keyQueId, ordinal?, values}",
|
|
689
|
+
error_code="INVALID_TEST_CASE",
|
|
690
|
+
)
|
|
691
|
+
raw_que_id = item.get("keyQueId", item.get("key_que_id"))
|
|
692
|
+
if raw_que_id is None:
|
|
693
|
+
raise_config_error(
|
|
694
|
+
"--test-case items require keyQueId", error_code="INVALID_TEST_CASE"
|
|
695
|
+
)
|
|
696
|
+
try:
|
|
697
|
+
key_que_id = int(raw_que_id)
|
|
698
|
+
except (TypeError, ValueError):
|
|
699
|
+
raise_config_error(
|
|
700
|
+
f"--test-case keyQueId must be an integer, got {raw_que_id!r}",
|
|
701
|
+
error_code="INVALID_TEST_CASE",
|
|
702
|
+
)
|
|
703
|
+
entry: dict = {"keyQueId": key_que_id}
|
|
704
|
+
if item.get("ordinal") is not None:
|
|
705
|
+
try:
|
|
706
|
+
entry["ordinal"] = int(item.get("ordinal"))
|
|
707
|
+
except (TypeError, ValueError):
|
|
708
|
+
raise_config_error(
|
|
709
|
+
f"--test-case ordinal must be an integer, got {item.get('ordinal')!r}",
|
|
710
|
+
error_code="INVALID_TEST_CASE",
|
|
711
|
+
)
|
|
712
|
+
values = item.get("values", [])
|
|
713
|
+
if not isinstance(values, list):
|
|
714
|
+
raise_config_error(
|
|
715
|
+
"--test-case item values must be a JSON array", error_code="INVALID_TEST_CASE"
|
|
716
|
+
)
|
|
717
|
+
entry["values"] = [str(value) for value in values]
|
|
718
|
+
if item.get("tableValues") is not None:
|
|
719
|
+
entry["tableValues"] = item.get("tableValues")
|
|
720
|
+
normalized.append(entry)
|
|
721
|
+
return normalized
|
|
722
|
+
|
|
723
|
+
|
|
398
724
|
def _handle_app_delete(args: argparse.Namespace, context: CliContext) -> dict:
|
|
399
725
|
return context.builder.app_delete(profile=args.profile, app_key=args.app_key)
|
|
400
726
|
|
|
@@ -10,6 +10,7 @@ from ..session_store import SessionStore
|
|
|
10
10
|
from ..tools.ai_builder_tools import AiBuilderTools
|
|
11
11
|
from ..tools.app_tools import AppTools
|
|
12
12
|
from ..tools.auth_tools import AuthTools
|
|
13
|
+
from ..tools.login_link_tools import LoginLinkTools
|
|
13
14
|
from ..tools.code_block_tools import CodeBlockTools
|
|
14
15
|
from ..tools.export_tools import ExportTools
|
|
15
16
|
from ..tools.feedback_tools import FeedbackTools
|
|
@@ -27,6 +28,7 @@ class CliContext:
|
|
|
27
28
|
sessions: CliSessionFacade
|
|
28
29
|
backend: BackendClient
|
|
29
30
|
auth: AuthTools
|
|
31
|
+
login_link: LoginLinkTools
|
|
30
32
|
workspace: WorkspaceTools
|
|
31
33
|
app: AppTools
|
|
32
34
|
resource: ResourceReadTools
|
|
@@ -57,6 +59,7 @@ def build_cli_context() -> CliContext:
|
|
|
57
59
|
sessions=sessions,
|
|
58
60
|
backend=backend,
|
|
59
61
|
auth=AuthTools(sessions, backend),
|
|
62
|
+
login_link=LoginLinkTools(sessions, backend),
|
|
60
63
|
workspace=WorkspaceTools(sessions, backend),
|
|
61
64
|
app=AppTools(sessions, backend),
|
|
62
65
|
resource=ResourceReadTools(sessions, backend),
|
|
@@ -1566,7 +1566,12 @@ def _task_action_debug_lines(result: dict[str, Any]) -> list[str]:
|
|
|
1566
1566
|
return lines
|
|
1567
1567
|
|
|
1568
1568
|
|
|
1569
|
+
def _format_login_link(result: dict[str, Any]) -> str:
|
|
1570
|
+
return f"登录链接:{result['url']}\n过期时间(Unix 毫秒):{result['expires_at']}\n链接仅可使用一次,请勿泄露。\n"
|
|
1571
|
+
|
|
1572
|
+
|
|
1569
1573
|
_FORMATTERS = {
|
|
1574
|
+
"login_link": _format_login_link,
|
|
1570
1575
|
"auth_whoami": _format_whoami,
|
|
1571
1576
|
"workspace_list": _format_workspace_list,
|
|
1572
1577
|
"workspace_get": _format_workspace_get,
|
|
@@ -2,16 +2,20 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
|
+
import re
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import Any
|
|
7
8
|
from urllib.parse import urlsplit, urlunsplit
|
|
8
9
|
|
|
10
|
+
from .errors import QingflowApiError
|
|
11
|
+
|
|
9
12
|
DEFAULT_PROFILE = "default"
|
|
10
13
|
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
11
14
|
DEFAULT_USER_AGENT = "qingflow-mcp/1.0"
|
|
12
15
|
DEFAULT_RECORD_LIST_TYPE = 8
|
|
13
16
|
ATTACHMENT_QUESTION_TYPE = 13
|
|
14
17
|
DEFAULT_BASE_URL = "https://qingflow.com/api"
|
|
18
|
+
DEFAULT_WEB_BASE_URL = "https://qingflow.com"
|
|
15
19
|
DEFAULT_CLI_OAUTH_RESOURCE_URL = "https://mcp.qingflow.com/mcp"
|
|
16
20
|
DEFAULT_FEEDBACK_APP_KEY = "e0d017kju002"
|
|
17
21
|
DEFAULT_FEEDBACK_QSOURCE_TOKEN = "mcp-feedback-7755d14748fc"
|
|
@@ -153,6 +157,41 @@ def get_default_base_url() -> str | None:
|
|
|
153
157
|
return normalize_base_url(value) if value else None
|
|
154
158
|
|
|
155
159
|
|
|
160
|
+
def get_web_base_url() -> str:
|
|
161
|
+
"""读取网页根地址;空白配置逐级回退,不复用后端或 OAuth 地址。"""
|
|
162
|
+
value = os.getenv("QINGFLOW_MCP_WEB_BASE_URL")
|
|
163
|
+
if value is None or not value.strip():
|
|
164
|
+
value = get_config_value("web_base_url", default=DEFAULT_WEB_BASE_URL)
|
|
165
|
+
if isinstance(value, str) and not value.strip():
|
|
166
|
+
value = DEFAULT_WEB_BASE_URL
|
|
167
|
+
try:
|
|
168
|
+
if not isinstance(value, str):
|
|
169
|
+
raise ValueError
|
|
170
|
+
value = value.strip()
|
|
171
|
+
parsed = urlsplit(value)
|
|
172
|
+
host = parsed.hostname or ""
|
|
173
|
+
# urlsplit 会校验 IPv6;域名标签额外拒绝空格、转义字符及无效边界。
|
|
174
|
+
valid_host = bool(host) and (":" in host or all(
|
|
175
|
+
re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label)
|
|
176
|
+
for label in host.rstrip(".").split(".")
|
|
177
|
+
))
|
|
178
|
+
if (
|
|
179
|
+
parsed.scheme not in {"http", "https"}
|
|
180
|
+
or not valid_host or host.lower() in {"null", "undefined"}
|
|
181
|
+
or parsed.username is not None or parsed.password is not None
|
|
182
|
+
or parsed.path not in {"", "/"} or "?" in value or "#" in value
|
|
183
|
+
or any(character.isspace() or ord(character) < 32 for character in value)
|
|
184
|
+
):
|
|
185
|
+
raise ValueError
|
|
186
|
+
# 访问 port 触发 urllib 的端口格式与范围校验。
|
|
187
|
+
_ = parsed.port
|
|
188
|
+
return urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
|
|
189
|
+
except ValueError:
|
|
190
|
+
raise QingflowApiError.config_error(
|
|
191
|
+
"QINGFLOW_MCP_WEB_BASE_URL(web_base_url)必须是有效的 HTTP(S) 网页根地址,不包含路径、查询参数或用户凭证"
|
|
192
|
+
) from None
|
|
193
|
+
|
|
194
|
+
|
|
156
195
|
def get_default_qf_version() -> str | None:
|
|
157
196
|
"""获取默认的 qfVersion 路由值"""
|
|
158
197
|
value = get_config_value(
|
|
@@ -33,6 +33,7 @@ USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
33
33
|
PublicToolSpec(USER_DOMAIN, "auth_login", cli_route=("auth", "login"), mcp_public=False),
|
|
34
34
|
PublicToolSpec(USER_DOMAIN, "auth_use_credential", ("auth_use_credential",), ("auth", "use-credential")),
|
|
35
35
|
PublicToolSpec(USER_DOMAIN, "auth_whoami", ("auth_whoami",), ("auth", "whoami")),
|
|
36
|
+
PublicToolSpec(USER_DOMAIN, "get_login_link", ("get_login_link",), ("auth", "login-link")),
|
|
36
37
|
PublicToolSpec(USER_DOMAIN, "auth_logout", ("auth_logout",), ("auth", "logout")),
|
|
37
38
|
PublicToolSpec(USER_DOMAIN, "workspace_list", ("workspace_list",), ("workspace", "list")),
|
|
38
39
|
PublicToolSpec(USER_DOMAIN, "workspace_get", ("workspace_get",), ("workspace", "get")),
|
|
@@ -144,6 +145,8 @@ BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
144
145
|
PublicToolSpec(BUILDER_DOMAIN, "app_resolve", ("app_resolve",), ("builder", "app", "resolve"), has_contract=True, cli_show_effective_context=True),
|
|
145
146
|
PublicToolSpec(BUILDER_DOMAIN, "app_form_schema", ("app_form_schema",), ("builder", "app-form", "schema")),
|
|
146
147
|
PublicToolSpec(BUILDER_DOMAIN, "app_form_validate", (), ("builder", "app-form", "validate"), mcp_public=False),
|
|
148
|
+
PublicToolSpec(BUILDER_DOMAIN, "app_form_validate_formula", (), ("builder", "app-form", "validate-formula"), mcp_public=False),
|
|
149
|
+
PublicToolSpec(BUILDER_DOMAIN, "app_form_validate_code_block", (), ("builder", "app-form", "validate-code-block"), mcp_public=False),
|
|
147
150
|
PublicToolSpec(BUILDER_DOMAIN, "app_form_get", ("app_form_get",), ("builder", "app-form", "get"), cli_show_effective_context=True),
|
|
148
151
|
PublicToolSpec(BUILDER_DOMAIN, "app_form_apply", ("app_form_apply",), ("builder", "app-form", "apply"), cli_show_effective_context=True, cli_context_write=True),
|
|
149
152
|
PublicToolSpec(BUILDER_DOMAIN, "app_delete", ("app_delete",), ("builder", "app", "delete"), cli_show_effective_context=True, cli_context_write=True),
|
|
@@ -51,6 +51,10 @@ COMMON_ERROR_DROP_TOP = {
|
|
|
51
51
|
|
|
52
52
|
SUCCESS_POLICY_BY_TOOL: dict[str, TransformFn] = {}
|
|
53
53
|
|
|
54
|
+
#: 这两个校验命令的产出契约明确包含 ok 字段(供阶段 2 SKILL.md 引用),
|
|
55
|
+
#: 需在 success 裁剪时保留 ok(默认 COMMON_SUCCESS_DROP_TOP 会丢弃 ok)。
|
|
56
|
+
_KEEP_OK_TOOLS = {"builder:app_form_validate_formula", "builder:app_form_validate_code_block"}
|
|
57
|
+
|
|
54
58
|
|
|
55
59
|
def trim_public_response(tool_name: str | None, payload: dict[str, Any]) -> dict[str, Any]:
|
|
56
60
|
if not isinstance(payload, dict):
|
|
@@ -85,6 +89,8 @@ def trim_success_response(tool_name: str | None, payload: dict[str, Any]) -> dic
|
|
|
85
89
|
drop_keys = COMMON_SUCCESS_DROP_TOP - {"output_profile"}
|
|
86
90
|
if tool_name in {"user:record_insert", "user:record_update", "user:record_delete"} and payload.get("ok") is False:
|
|
87
91
|
drop_keys = drop_keys - {"ok"}
|
|
92
|
+
if tool_name in _KEEP_OK_TOOLS:
|
|
93
|
+
drop_keys = drop_keys - {"ok"}
|
|
88
94
|
if tool_name == "builder:app_form_apply" and payload.get("mustReadCurrentStateBeforeRepair") is True:
|
|
89
95
|
drop_keys = drop_keys - {"suggested_next_call"}
|
|
90
96
|
_drop_top_keys(trimmed, drop_keys)
|
|
@@ -1128,6 +1134,13 @@ def _summarize_form_diff(diff: dict[str, Any]) -> dict[str, Any]:
|
|
|
1128
1134
|
return summary
|
|
1129
1135
|
|
|
1130
1136
|
|
|
1137
|
+
def _trim_login_link(payload: JSONObject) -> None:
|
|
1138
|
+
# 临时票据只通过链接交付,必须保留链接和过期时间。
|
|
1139
|
+
for key in list(payload):
|
|
1140
|
+
if key not in {"url", "expires_at"}:
|
|
1141
|
+
payload.pop(key)
|
|
1142
|
+
|
|
1143
|
+
|
|
1131
1144
|
def _register_policy(domains: tuple[str, ...], names: tuple[str, ...], transform: TransformFn) -> None:
|
|
1132
1145
|
for domain in domains:
|
|
1133
1146
|
for name in names:
|
|
@@ -1135,6 +1148,7 @@ def _register_policy(domains: tuple[str, ...], names: tuple[str, ...], transform
|
|
|
1135
1148
|
|
|
1136
1149
|
|
|
1137
1150
|
_register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("auth_use_credential", "auth_whoami"), _trim_auth_payload)
|
|
1151
|
+
_register_policy((USER_DOMAIN,), ("get_login_link",), _trim_login_link)
|
|
1138
1152
|
_register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("auth_logout",), _trim_auth_logout)
|
|
1139
1153
|
_register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("workspace_list",), _trim_workspace_list)
|
|
1140
1154
|
_register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("workspace_get", "workspace_select"), _trim_workspace_get)
|
|
@@ -13,6 +13,7 @@ from .tools.feedback_tools import FeedbackTools
|
|
|
13
13
|
from .tools.export_tools import ExportTools
|
|
14
14
|
from .tools.file_tools import FileTools
|
|
15
15
|
from .tools.import_tools import ImportTools
|
|
16
|
+
from .tools.login_link_tools import LoginLinkTools
|
|
16
17
|
from .tools.package_tools import PackageTools
|
|
17
18
|
from .tools.navigation_tools import NavigationTools
|
|
18
19
|
from .tools.directory_tools import DirectoryTools
|
|
@@ -238,6 +239,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
|
|
|
238
239
|
sessions = SessionStore()
|
|
239
240
|
backend = BackendClient()
|
|
240
241
|
AuthTools(sessions, backend).register(server)
|
|
242
|
+
LoginLinkTools(sessions, backend).register(server)
|
|
241
243
|
FeedbackTools(backend, mcp_side="通用").register(server)
|
|
242
244
|
WorkspaceTools(sessions, backend).register(server)
|
|
243
245
|
FileTools(sessions, backend).register(server)
|
|
@@ -17,6 +17,7 @@ from .tools.export_tools import ExportTools
|
|
|
17
17
|
from .tools.feedback_tools import FeedbackTools
|
|
18
18
|
from .tools.file_tools import FileTools
|
|
19
19
|
from .tools.import_tools import ImportTools
|
|
20
|
+
from .tools.login_link_tools import LOGIN_LINK_DESCRIPTION, LoginLinkTools
|
|
20
21
|
from .tools.resource_read_tools import ResourceReadTools
|
|
21
22
|
from .tools.task_context_tools import TaskContextTools
|
|
22
23
|
from .tools.workspace_tools import WorkspaceTools
|
|
@@ -30,6 +31,16 @@ def build_user_server(*, stateless_http: bool = False) -> FastMCP:
|
|
|
30
31
|
stateless_http=stateless_http,
|
|
31
32
|
instructions=f"""Use this server for Qingflow operational workflows. Current date: `{today.isoformat()}`.
|
|
32
33
|
|
|
34
|
+
## 页面交接
|
|
35
|
+
|
|
36
|
+
用户需要查看结果、继续编辑、处理业务或进入工作区页面时,使用 `get_login_link`;纯数据查询不自动签发链接。
|
|
37
|
+
目标路径由业务工具或领域 Skill 根据真实前端路由确定,不由票据工具根据业务标识推导。
|
|
38
|
+
已知目标传可选 `redirect_path`;明确指定页面但路径未知时继续定位或说明限制,不猜测,也不静默改为首页。
|
|
39
|
+
搜索未命中时继续搜索 `login` 或查看服务工具列表,不擅自打开固定正式站点;鉴权或工具失败时如实说明。
|
|
40
|
+
临近交付时再签发,使用返回的完整 URL。要求“给我链接”时仅交付,不提前消费;要求“打开”时才调用浏览器。
|
|
41
|
+
浏览器不可用时提供可点击链接;用“查看记录”“继续编辑”等文案,不展示工作区数字 ID 或原始 JSON。
|
|
42
|
+
链接约 5 分钟内一次性有效,不能作为长期书签;安全验证及原有业务权限不变。
|
|
43
|
+
|
|
33
44
|
## App Discovery
|
|
34
45
|
|
|
35
46
|
If `app_key` is unknown, use `app_list` first. Pass `query` to filter visible apps by keyword.
|
|
@@ -237,6 +248,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
|
|
|
237
248
|
code_block_tools = wrap_trimmed_methods(CodeBlockTools(sessions, backend), USER_SERVER_METHOD_MAP)
|
|
238
249
|
task_context_tools = wrap_trimmed_methods(TaskContextTools(sessions, backend), USER_SERVER_METHOD_MAP)
|
|
239
250
|
directory_tools = wrap_trimmed_methods(DirectoryTools(sessions, backend), USER_SERVER_METHOD_MAP)
|
|
251
|
+
login_link = LoginLinkTools(sessions, backend)
|
|
240
252
|
|
|
241
253
|
@server.tool()
|
|
242
254
|
def auth_use_credential(
|
|
@@ -258,6 +270,11 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
|
|
|
258
270
|
def auth_whoami(profile: str = DEFAULT_PROFILE) -> dict:
|
|
259
271
|
return auth.auth_whoami(profile=profile)
|
|
260
272
|
|
|
273
|
+
@server.tool(description=LOGIN_LINK_DESCRIPTION)
|
|
274
|
+
def get_login_link(profile: str = DEFAULT_PROFILE, redirect_path: str = "") -> dict:
|
|
275
|
+
"""获取一次性免登录链接;redirect_path 可选,仅接受站内业务路径,省略进入首页。"""
|
|
276
|
+
return login_link.get_login_link(profile=profile, redirect_path=redirect_path)
|
|
277
|
+
|
|
261
278
|
@server.tool()
|
|
262
279
|
def auth_logout(profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict:
|
|
263
280
|
return auth.auth_logout(profile=profile, forget_persisted=forget_persisted)
|
|
@@ -852,6 +852,64 @@ class AiBuilderTools(ToolBase):
|
|
|
852
852
|
"""Reconcile one complete declarative AppForm target."""
|
|
853
853
|
return self._app_form.apply(profile=profile, spec=spec)
|
|
854
854
|
|
|
855
|
+
def app_form_evaluate_formula(
|
|
856
|
+
self,
|
|
857
|
+
*,
|
|
858
|
+
profile: str,
|
|
859
|
+
app_key: str,
|
|
860
|
+
formula: str,
|
|
861
|
+
test_case: dict[int, Any],
|
|
862
|
+
) -> JSONObject:
|
|
863
|
+
"""调用后端 POST /ai/coding/formula/validate 对公式求值。
|
|
864
|
+
|
|
865
|
+
本地语法校验由 CLI handler 负责;本方法只做后端求值,捕获后端错误为 evaluationError。
|
|
866
|
+
鉴权/会话错误(未登录/未选工作区)按既有约定向上抛出 QingflowApiError。
|
|
867
|
+
"""
|
|
868
|
+
_session_profile, _backend_session, context = self._require_context(profile, require_workspace=True)
|
|
869
|
+
evaluation_result: Any = None
|
|
870
|
+
evaluation_error: str | None = None
|
|
871
|
+
try:
|
|
872
|
+
evaluation_result = self.backend.request(
|
|
873
|
+
"POST",
|
|
874
|
+
context,
|
|
875
|
+
"/ai/coding/formula/validate",
|
|
876
|
+
json_body={"appKey": app_key, "formula": formula, "testCase": test_case},
|
|
877
|
+
)
|
|
878
|
+
except QingflowApiError as exc:
|
|
879
|
+
evaluation_error = exc.message
|
|
880
|
+
return {"evaluation": evaluation_result, "evaluationError": evaluation_error}
|
|
881
|
+
|
|
882
|
+
def app_form_run_code_block(
|
|
883
|
+
self,
|
|
884
|
+
*,
|
|
885
|
+
profile: str,
|
|
886
|
+
app_key: str,
|
|
887
|
+
code: str,
|
|
888
|
+
key_que_values: list[JSONObject],
|
|
889
|
+
) -> JSONObject:
|
|
890
|
+
"""调用后端 POST /data/{appKey}/codeBlock/detection 真跑代码块。
|
|
891
|
+
|
|
892
|
+
本地静态校验由 CLI handler 负责;本方法只做后端真跑,捕获后端错误为 runError。
|
|
893
|
+
``key_que_values`` 需符合后端 OpenMatchQueValue 结构 ``{keyQueId, ordinal?, values}``。
|
|
894
|
+
|
|
895
|
+
传入的 ``code`` 是原始 JS(未经前端字段公式加密),必须声明 ``unEncrypted=True``;
|
|
896
|
+
否则后端 ``DataSvcImpl.codeBlockTest`` 会走 ``FormulaDecoder.extractFormula``
|
|
897
|
+
对原始 JS 解密并抛 ``QFRuntimeException``,导致后端真跑对所有真实代码必然失败。
|
|
898
|
+
"""
|
|
899
|
+
_session_profile, _backend_session, context = self._require_context(profile, require_workspace=True)
|
|
900
|
+
run_result: Any = None
|
|
901
|
+
run_error: str | None = None
|
|
902
|
+
try:
|
|
903
|
+
run_result = self.backend.request(
|
|
904
|
+
"POST",
|
|
905
|
+
context,
|
|
906
|
+
f"/data/{app_key}/codeBlock/detection",
|
|
907
|
+
json_body={"code": code, "keyQueValues": key_que_values, "unEncrypted": True},
|
|
908
|
+
)
|
|
909
|
+
except QingflowApiError as exc:
|
|
910
|
+
run_error = exc.message
|
|
911
|
+
return {"runResult": run_result, "runError": run_error}
|
|
912
|
+
|
|
855
913
|
def app_delete(self, *, profile: str, app_key: str) -> JSONObject:
|
|
856
914
|
"""Delete an application explicitly."""
|
|
857
915
|
return self._app_form.delete(profile=profile, app_key=app_key)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import posixpath
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib.parse import parse_qs, unquote, urlencode, urlsplit
|
|
6
|
+
|
|
7
|
+
from mcp.server.fastmcp import FastMCP
|
|
8
|
+
|
|
9
|
+
from ..config import DEFAULT_PROFILE, get_web_base_url
|
|
10
|
+
from ..errors import QingflowApiError
|
|
11
|
+
from ..json_types import JSONObject
|
|
12
|
+
from .base import ToolBase
|
|
13
|
+
|
|
14
|
+
LOGIN_LINK_DESCRIPTION = (
|
|
15
|
+
"轻流页面入口:查看页面、继续编辑、处理业务、查看结果或进入工作区(open/view/visit page in browser,login)。"
|
|
16
|
+
"从 AI 对话进入页面时,使用当前 MCP 身份和工作区生成短时(约 5 分钟)一次性免登录链接。"
|
|
17
|
+
"redirect_path 可选:传已确认的站内业务路径,可带 query/hash;省略进入首页,不接受外部地址或认证入口。"
|
|
18
|
+
"纯数据查询无需调用;用户指定页面但路径未知时先定位,不猜路径或静默改为首页。"
|
|
19
|
+
"返回 url 和毫秒整数 expires_at;必要安全验证及原有页面权限仍然生效。"
|
|
20
|
+
"用户要链接时仅交付,不提前消费;要求打开时才用浏览器打开返回的完整链接。"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_redirect_path(value: str) -> str:
|
|
25
|
+
"""只接受业务站内路径;解码仅用于校验,不改写查询参数或片段。"""
|
|
26
|
+
if not value or not value.strip():
|
|
27
|
+
return ""
|
|
28
|
+
auth_paths = (
|
|
29
|
+
"/mcp-login", "/qingclaw-login", "/passport", "/login-third-party", "/third-party-login",
|
|
30
|
+
"/third-party", "/oidc-authorize", "/oauth2/authorize", "/authorize", "/sso", "/ldap",
|
|
31
|
+
"/pcWechatAutoBind", "/code-login", "/work-wechat-error", "/ding-check-ws",
|
|
32
|
+
)
|
|
33
|
+
try:
|
|
34
|
+
path = unquote(urlsplit(value).path, errors="strict")
|
|
35
|
+
if (
|
|
36
|
+
not value.startswith("/") or value.startswith("//") or path.startswith("//")
|
|
37
|
+
or "\\" in value or "\\" in path or any(c in path for c in "%?#")
|
|
38
|
+
or any(ord(c) < 32 or ord(c) == 127 for c in value + path)
|
|
39
|
+
):
|
|
40
|
+
raise ValueError
|
|
41
|
+
normalized = posixpath.normpath(path)
|
|
42
|
+
if any(normalized == p or normalized.startswith(p + "/") for p in auth_paths):
|
|
43
|
+
raise ValueError
|
|
44
|
+
except (ValueError, UnicodeError):
|
|
45
|
+
raise QingflowApiError(category="validation", message="redirect_path 必须是站内业务路径,不能指向认证入口") from None
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LoginLinkTools(ToolBase):
|
|
50
|
+
"""登录链接工具(中文名:临时免登录链接)。
|
|
51
|
+
|
|
52
|
+
类型:会话辅助工具。
|
|
53
|
+
主要职责:
|
|
54
|
+
1. 为当前 MCP 用户签发进入当前工作区网页端的临时免登录链接;
|
|
55
|
+
2. 链接短时(5 分钟)、一次性,不携带 MCP 长期凭证或正式登录 token。
|
|
56
|
+
|
|
57
|
+
安全约束:
|
|
58
|
+
- 用户身份仅来自当前 MCP 会话(业务 token),不接收任何 uid 参数;
|
|
59
|
+
- 目标工作区固定为当前选中的 MCP 工作区,不开放任意跳转地址。
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def register(self, mcp: FastMCP) -> None:
|
|
63
|
+
"""注册当前工具到 MCP 服务。"""
|
|
64
|
+
|
|
65
|
+
@mcp.tool(description=LOGIN_LINK_DESCRIPTION)
|
|
66
|
+
def get_login_link(profile: str = DEFAULT_PROFILE, redirect_path: str = "") -> dict[str, Any]:
|
|
67
|
+
return self.get_login_link(profile=profile, redirect_path=redirect_path)
|
|
68
|
+
|
|
69
|
+
def get_login_link(self, *, profile: str, redirect_path: str = "") -> JSONObject:
|
|
70
|
+
"""执行工具方法逻辑。"""
|
|
71
|
+
|
|
72
|
+
def runner(session_profile, context):
|
|
73
|
+
target = validate_redirect_path(redirect_path)
|
|
74
|
+
web_base_url = get_web_base_url()
|
|
75
|
+
result = self.backend.request(
|
|
76
|
+
"POST",
|
|
77
|
+
context,
|
|
78
|
+
"/mcp/auth/login-link",
|
|
79
|
+
json_body={"wsId": session_profile.selected_ws_id},
|
|
80
|
+
)
|
|
81
|
+
# 后端仅提供票据,网页地址由 MCP 配置确定,避免工作区域名为空。
|
|
82
|
+
try:
|
|
83
|
+
source_url = result.get("url") if isinstance(result, dict) else None
|
|
84
|
+
if not isinstance(source_url, str):
|
|
85
|
+
raise ValueError
|
|
86
|
+
tickets = parse_qs(urlsplit(source_url).query, keep_blank_values=True).get("ticket", [])
|
|
87
|
+
if len(tickets) != 1 or not tickets[0].strip():
|
|
88
|
+
raise ValueError
|
|
89
|
+
# 后端可能把毫秒时间戳序列化为字符串,不能截断浮点数或伪造缺失值。
|
|
90
|
+
expiry = result.get("expiresAt")
|
|
91
|
+
if type(expiry) is int:
|
|
92
|
+
expires_at = expiry
|
|
93
|
+
elif isinstance(expiry, str) and expiry.isascii() and expiry.isdecimal():
|
|
94
|
+
expires_at = int(expiry)
|
|
95
|
+
else:
|
|
96
|
+
raise ValueError
|
|
97
|
+
if expires_at <= 0:
|
|
98
|
+
raise ValueError
|
|
99
|
+
except ValueError:
|
|
100
|
+
# 不把原始响应或票据放入错误信息。
|
|
101
|
+
raise QingflowApiError(
|
|
102
|
+
category="backend",
|
|
103
|
+
message="INVALID_LOGIN_LINK: 后端免登录链接的票据或有效期无效,请重新获取",
|
|
104
|
+
) from None
|
|
105
|
+
query = {"ticket": tickets[0]}
|
|
106
|
+
if target:
|
|
107
|
+
query["redirect_path"] = target
|
|
108
|
+
return {
|
|
109
|
+
"url": f"{web_base_url}/mcp-login?{urlencode(query)}",
|
|
110
|
+
"expires_at": expires_at,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# require_workspace=True:目标工作区恒为当前 MCP 会话选中的工作区
|
|
114
|
+
return self._run(profile, runner, tool_name="获取临时免登录链接")
|