@qingflow-tech/qingflow-cli 1.1.61-beta.4 → 1.1.61-beta.5

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.
Files changed (56) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/skills/qingflow-cli/reference/builder/30-schema-fields.md +13 -0
  5. package/skills/qingflow-cli/reference/builder/70-portal.md +37 -16
  6. package/skills/qingflow-cli/reference/examples/portal/portal_apply_v1alpha1.example.json +100 -0
  7. package/skills/qingflow-cli/reference/examples/portal/portal_sections_all_types.example.json +6 -7
  8. package/src/qingflow_mcp/app_form_validator.py +20 -3
  9. package/src/qingflow_mcp/builder_facade/app_form.py +137 -17
  10. package/src/qingflow_mcp/builder_facade/models.py +164 -13
  11. package/src/qingflow_mcp/builder_facade/portals_schema.py +397 -0
  12. package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +14 -1
  13. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +49 -9
  14. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +27 -0
  15. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_content_classification.json +146 -0
  16. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_content_extraction.json +146 -0
  17. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_content_generation.json +146 -0
  18. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_document_parsing.json +146 -0
  19. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_image_generation.json +146 -0
  20. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_image_recognition.json +146 -0
  21. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_machine_translation.json +146 -0
  22. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_text_summary.json +146 -0
  23. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +2 -2
  24. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +27 -0
  25. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date_duration.json +146 -0
  26. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/embed_field.json +146 -0
  27. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/image_select.json +146 -0
  28. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/location.json +146 -0
  29. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +2 -2
  30. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ocr.json +146 -0
  31. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +2 -2
  32. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +27 -0
  33. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +29 -2
  34. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +20 -7
  35. package/src/qingflow_mcp/builder_facade/schemas/portals/manifest.json +59 -0
  36. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/complete.json +1397 -0
  37. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/carousel.json +193 -0
  38. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/chart.json +643 -0
  39. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/favourite.json +203 -0
  40. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/filter.json +268 -0
  41. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/grid.json +265 -0
  42. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/link.json +131 -0
  43. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/task.json +199 -0
  44. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/text.json +123 -0
  45. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/view.json +196 -0
  46. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/summary.json +800 -0
  47. package/src/qingflow_mcp/builder_facade/service.py +1104 -71
  48. package/src/qingflow_mcp/cli/commands/builder.py +114 -5
  49. package/src/qingflow_mcp/public_surface.py +2 -0
  50. package/src/qingflow_mcp/server_app_builder.py +32 -1
  51. package/src/qingflow_mcp/solution/compiler/icon_utils.py +73 -0
  52. package/src/qingflow_mcp/solution/executor.py +5 -2
  53. package/src/qingflow_mcp/tools/ai_builder_tools.py +110 -12
  54. package/src/qingflow_mcp/tools/portal_tools.py +4 -54
  55. package/src/qingflow_mcp/tools/portal_writer.py +59 -0
  56. package/src/qingflow_mcp/tools/solution_tools.py +2 -0
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import sys
4
5
  from copy import deepcopy
5
6
  from pathlib import Path
6
7
 
@@ -18,6 +19,7 @@ from ...app_form_validator import (
18
19
  validate_declaration,
19
20
  validate_formula_expression,
20
21
  )
22
+ from ...builder_facade.portals_schema import PORTALS_SUPPORTED_SCHEMA_KINDS
21
23
  from ...builder_facade.views_schema import VIEWS_SUPPORTED_SCHEMA_KINDS
22
24
  from ..context import CliContext
23
25
  from ..json_io import load_json_value
@@ -260,17 +262,39 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
260
262
  portal_list = portal_subparsers.add_parser("list", help="列出可配置门户")
261
263
  portal_list.set_defaults(handler=_handle_portal_list, format_hint="builder_summary")
262
264
 
263
- portal_get = portal_subparsers.add_parser("get", help="读取门户详情")
265
+ portal_schema = portal_subparsers.add_parser("schema", help="读取门户声明 Schema")
266
+ portal_schema.add_argument("--schema-version")
267
+ portal_schema.add_argument(
268
+ "--schema-kind",
269
+ choices=PORTALS_SUPPORTED_SCHEMA_KINDS,
270
+ )
271
+ portal_schema.set_defaults(handler=_handle_portal_schema, format_hint="builder_summary", force_json_output=True)
272
+
273
+ portal_get = portal_subparsers.add_parser("get", help="读取门户声明或详情")
264
274
  portal_get.add_argument("--dash-key", required=True)
265
275
  portal_get.add_argument("--being-draft", action=argparse.BooleanOptionalAction, default=True)
276
+ portal_get.add_argument(
277
+ "--schema-kind",
278
+ choices=("summary", "complete", "raw"),
279
+ default="summary",
280
+ help="summary/complete=canonical 声明投影(重放提取锦点);raw=原始归一响应",
281
+ )
282
+ portal_get.add_argument("--schema-version")
266
283
  portal_get.set_defaults(handler=_handle_portal_get, format_hint="builder_summary")
267
284
 
285
+ portal_validate = portal_subparsers.add_parser("validate", help="只校验门户声明,不访问后端")
286
+ portal_validate.add_argument("--file", required=True, help="门户声明 JSON 文件,- 表示 stdin")
287
+ portal_validate.add_argument("--schema-version", help="固定使用的门户 Schema 版本;缺省读文件内 schema_version,再缺省 latest")
288
+ portal_validate.set_defaults(handler=_handle_portal_validate, format_hint="builder_summary", force_json_output=True)
289
+
268
290
  portal_apply = portal_subparsers.add_parser("apply", help="更新门户")
291
+ portal_apply.add_argument("--file", help="声明文件主形态(完整 portal 声明 JSON,支持 - stdin);与 patch 参数互斥")
292
+ portal_apply.add_argument("--schema-version", help="声明使用的 Schema 版本;缺省读文件内 schema_version,再缺省 latest")
269
293
  portal_apply.add_argument("--dash-key", default="")
270
294
  portal_apply.add_argument("--dash-name", default="")
271
295
  portal_apply.add_argument("--package-id", type=int)
272
- portal_apply.add_argument("--publish", action=argparse.BooleanOptionalAction, default=True)
273
- portal_apply.add_argument("--payload-file")
296
+ portal_apply.add_argument("--publish", action=argparse.BooleanOptionalAction, default=None, help="发布开关;--file 声明模式下缺省尊重声明内 publish 字段(再缺省 true),显式传参时覆盖声明值")
297
+ portal_apply.add_argument("--payload-file", help="兼容:完整门户 payload JSON(裸 DTO 透传);新代码改用 --file")
274
298
  portal_apply.add_argument("--sections-file")
275
299
  portal_apply.add_argument("--layout-preset", default="")
276
300
  portal_apply.add_argument("--visibility-file")
@@ -981,8 +1005,36 @@ def _handle_portal_list(args: argparse.Namespace, context: CliContext) -> dict:
981
1005
  return context.builder.portal_list(profile=args.profile)
982
1006
 
983
1007
 
1008
+ def _handle_portal_schema(args: argparse.Namespace, context: CliContext) -> dict:
1009
+ return context.builder.portal_schema(
1010
+ schema_version=getattr(args, "schema_version", None),
1011
+ schema_kind=getattr(args, "schema_kind", None),
1012
+ )
1013
+
1014
+
1015
+ def _handle_portal_validate(args: argparse.Namespace, context: CliContext) -> dict:
1016
+ payload = load_json_value(
1017
+ args.file,
1018
+ option_name="--file",
1019
+ max_bytes=2 * 1024 * 1024,
1020
+ max_depth=64,
1021
+ )
1022
+ if not isinstance(payload, dict):
1023
+ raise_config_error("builder portal validate --file must contain a JSON object")
1024
+ return context.builder.portal_validate(
1025
+ declaration=payload,
1026
+ schema_version=getattr(args, "schema_version", None),
1027
+ )
1028
+
1029
+
984
1030
  def _handle_portal_get(args: argparse.Namespace, context: CliContext) -> dict:
985
- return context.builder.portal_get(profile=args.profile, dash_key=args.dash_key, being_draft=bool(args.being_draft))
1031
+ return context.builder.portal_get(
1032
+ profile=args.profile,
1033
+ dash_key=args.dash_key,
1034
+ being_draft=bool(args.being_draft),
1035
+ schema_kind=getattr(args, "schema_kind", "summary") or "summary",
1036
+ schema_version=getattr(args, "schema_version", None),
1037
+ )
986
1038
 
987
1039
 
988
1040
  def _handle_view_get(args: argparse.Namespace, context: CliContext) -> dict:
@@ -1385,6 +1437,62 @@ def _handle_charts_apply(args: argparse.Namespace, context: CliContext) -> dict:
1385
1437
 
1386
1438
 
1387
1439
  def _handle_portal_apply(args: argparse.Namespace, context: CliContext) -> dict:
1440
+ file_declaration = (
1441
+ load_json_value(args.file, option_name="--file", max_bytes=4 * 1024 * 1024, max_depth=64)
1442
+ if getattr(args, "file", None)
1443
+ else None
1444
+ )
1445
+ file_schema_version: str | None = None
1446
+ legacy_args_used = False
1447
+ if file_declaration is not None:
1448
+ if not isinstance(file_declaration, dict):
1449
+ raise_config_error("builder portal apply --file must contain a JSON object")
1450
+ legacy_keys = (
1451
+ getattr(args, "sections_file", None),
1452
+ getattr(args, "patch_sections_file", None),
1453
+ getattr(args, "visibility_file", None),
1454
+ getattr(args, "auth_file", None),
1455
+ getattr(args, "dash_global_config_file", None),
1456
+ getattr(args, "config_file", None),
1457
+ getattr(args, "payload_file", None),
1458
+ getattr(args, "icon", None),
1459
+ getattr(args, "color", None),
1460
+ (getattr(args, "dash_name", "") or ""),
1461
+ (getattr(args, "layout_preset", "") or ""),
1462
+ )
1463
+ if any(bool(item) for item in legacy_keys):
1464
+ raise_config_error(
1465
+ "builder portal apply --file cannot be combined with patch parameters.",
1466
+ fix_hint="Put the full declaration in the --file JSON; keep only --schema-version/--publish/--dash-key flags.",
1467
+ error_code="ARGUMENT_ERROR",
1468
+ )
1469
+ if getattr(args, "package_id", None) is not None:
1470
+ file_declaration.setdefault("package_tag_id", args.package_id)
1471
+ if getattr(args, "dash_key", ""):
1472
+ file_declaration["dash_key"] = args.dash_key
1473
+ # publish 三态对齐 MCP(声明值优先、显式 flag 最高):声明内 publish 弹出后由 flag 解析——
1474
+ # flag 未传时尊重声明值(含幂等重放的 publish:false),声明也未写时才缺省 true。
1475
+ flag_publish = getattr(args, "publish", None)
1476
+ declaration_publish = file_declaration.pop("publish", None)
1477
+ effective_publish = (
1478
+ bool(flag_publish)
1479
+ if flag_publish is not None
1480
+ else (bool(declaration_publish) if declaration_publish is not None else True)
1481
+ )
1482
+ file_schema_version = str(file_declaration.pop("schema_version", None) or file_declaration.pop("schemaVersion", None) or "").strip() or None
1483
+ effective_schema_version = getattr(args, "schema_version", None) or file_schema_version
1484
+ return context.builder.portal_apply(
1485
+ profile=args.profile,
1486
+ payload=file_declaration,
1487
+ schema_version=effective_schema_version,
1488
+ publish=effective_publish,
1489
+ )
1490
+ legacy_args_used = True
1491
+ if legacy_args_used:
1492
+ print(
1493
+ "[deprecation] builder portal apply patch parameters are in a compatibility window; prefer `--file <declaration.json>`",
1494
+ file=sys.stderr,
1495
+ )
1388
1496
  payload = load_object_arg(args.payload_file, option_name="--payload-file") if getattr(args, "payload_file", None) else None
1389
1497
  payload_obj = payload if isinstance(payload, dict) else {}
1390
1498
  effective_dash_name = (args.dash_name or str(payload_obj.get("dash_name") or payload_obj.get("dashName") or payload_obj.get("name") or "")).strip()
@@ -1413,7 +1521,7 @@ def _handle_portal_apply(args: argparse.Namespace, context: CliContext) -> dict:
1413
1521
  dash_key=args.dash_key,
1414
1522
  dash_name=args.dash_name,
1415
1523
  package_id=args.package_id,
1416
- publish=bool(args.publish),
1524
+ publish=bool(args.publish if args.publish is not None else True),
1417
1525
  sections=sections,
1418
1526
  layout_preset=args.layout_preset,
1419
1527
  visibility=load_object_arg(args.visibility_file, option_name="--visibility-file"),
@@ -1424,6 +1532,7 @@ def _handle_portal_apply(args: argparse.Namespace, context: CliContext) -> dict:
1424
1532
  dash_global_config=load_object_arg(args.dash_global_config_file, option_name="--dash-global-config-file"),
1425
1533
  config=load_object_arg(args.config_file, option_name="--config-file"),
1426
1534
  payload=payload,
1535
+ schema_version=getattr(args, "schema_version", None),
1427
1536
  patch_sections=patch_sections,
1428
1537
  )
1429
1538
 
@@ -184,6 +184,8 @@ BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
184
184
  PublicToolSpec(BUILDER_DOMAIN, "app_views_get", ("app_views_get",), ("builder", "views", "get"), cli_show_effective_context=True),
185
185
  PublicToolSpec(BUILDER_DOMAIN, "app_views_validate", (), ("builder", "views", "validate"), mcp_public=True),
186
186
  PublicToolSpec(BUILDER_DOMAIN, "app_views_apply", ("app_views_apply",), ("builder", "views", "apply"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
187
+ PublicToolSpec(BUILDER_DOMAIN, "portal_schema", (), ("builder", "portal", "schema"), mcp_public=True),
188
+ PublicToolSpec(BUILDER_DOMAIN, "portal_validate", (), ("builder", "portal", "validate"), mcp_public=True),
187
189
  PublicToolSpec(BUILDER_DOMAIN, "app_charts_apply", ("app_charts_apply",), ("builder", "charts", "apply"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
188
190
  PublicToolSpec(BUILDER_DOMAIN, "portal_apply", ("portal_apply",), ("builder", "portal", "apply"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
189
191
  PublicToolSpec(BUILDER_DOMAIN, "portal_delete", ("portal_delete",), ("builder", "portal", "delete"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
@@ -482,13 +482,36 @@ def build_builder_server() -> PolicyAwareFastMCP:
482
482
  def portal_list(profile: str = DEFAULT_PROFILE) -> dict:
483
483
  return ai_builder.portal_list(profile=profile)
484
484
 
485
+ @server.tool()
486
+ def portal_schema(
487
+ schema_version: str | None = None,
488
+ schema_kind: str | None = None,
489
+ ) -> dict:
490
+ """Get a complete portals schema or a focused component schema such as chart, view, grid, or carousel."""
491
+ return ai_builder.portal_schema(schema_version=schema_version, schema_kind=schema_kind)
492
+
493
+ @server.tool()
494
+ def portal_validate(
495
+ declaration: dict | None = None,
496
+ schema_version: str | None = None,
497
+ ) -> dict:
498
+ return ai_builder.portal_validate(declaration=declaration or {}, schema_version=schema_version)
499
+
485
500
  @server.tool()
486
501
  def portal_get(
487
502
  profile: str = DEFAULT_PROFILE,
488
503
  dash_key: str = "",
489
504
  being_draft: bool = True,
505
+ schema_kind: str = "summary",
506
+ schema_version: str | None = None,
490
507
  ) -> dict:
491
- return ai_builder.portal_get(profile=profile, dash_key=dash_key, being_draft=being_draft)
508
+ return ai_builder.portal_get(
509
+ profile=profile,
510
+ dash_key=dash_key,
511
+ being_draft=being_draft,
512
+ schema_kind=schema_kind,
513
+ schema_version=schema_version,
514
+ )
492
515
 
493
516
  @server.tool()
494
517
  def view_get(profile: str = DEFAULT_PROFILE, view_key: str = "") -> dict:
@@ -624,6 +647,10 @@ def build_builder_server() -> PolicyAwareFastMCP:
624
647
  color: str | None = None,
625
648
  hide_copyright: bool | None = None,
626
649
  dash_global_config: dict | None = None,
650
+ global_config: dict | None = None,
651
+ style_template_id: int | None = None,
652
+ overall_style: dict | None = None,
653
+ schema_version: str | None = None,
627
654
  config: dict | None = None,
628
655
  payload: dict | None = None,
629
656
  patch_sections: list[dict] | None = None,
@@ -660,6 +687,10 @@ def build_builder_server() -> PolicyAwareFastMCP:
660
687
  color=color,
661
688
  hide_copyright=hide_copyright,
662
689
  dash_global_config=dash_global_config,
690
+ global_config=global_config,
691
+ style_template_id=style_template_id,
692
+ overall_style=overall_style,
693
+ schema_version=schema_version,
663
694
  config=config or {},
664
695
  payload=payload,
665
696
  patch_sections=patch_sections,
@@ -312,6 +312,13 @@ def parse_workspace_icon(value: str | None) -> tuple[str | None, str | None, str
312
312
  stripped = str(value).strip()
313
313
  if not stripped:
314
314
  return None, None, None
315
+ if _looks_like_icon_url(stripped):
316
+ # URL 形态旧图标(如 https://…/dashboard_security.png)不是目录图标:目录名与
317
+ # 色均无法表达。返回 (None, None, None) 而不是按关键字把 URL 归一成假目录名
318
+ # ('dashboard' 命中 → 'view-grid')——后者经编译重编码会销毁存量 URL 图标,
319
+ # 并派生出不存在的默认色(r5 实测 50 门户 color diff 根因)。声明面按「未表达」
320
+ # 处理(读面 gap 记账);更新语义 apply 不触碰未指定图标,URL 原样存活。
321
+ return None, None, None
315
322
  if _looks_like_icon_json(stripped):
316
323
  try:
317
324
  payload = json.loads(stripped)
@@ -324,6 +331,11 @@ def parse_workspace_icon(value: str | None) -> tuple[str | None, str | None, str
324
331
  return normalize_workspace_icon_name(stripped), None, None
325
332
 
326
333
 
334
+ def _looks_like_icon_url(value: str) -> bool:
335
+ lowered = value.strip().lower()
336
+ return lowered.startswith(("http://", "https://", "//"))
337
+
338
+
327
339
  def workspace_icon_config(value: str | None) -> dict[str, str | None]:
328
340
  icon_name, icon_color, icon_text = parse_workspace_icon(value)
329
341
  raw = str(value).strip() if value not in (None, "") else None
@@ -442,6 +454,67 @@ def encode_workspace_icon_with_defaults(
442
454
  )
443
455
 
444
456
 
457
+ def update_workspace_icon_from_existing(
458
+ *,
459
+ icon: str | None,
460
+ color: str | None,
461
+ title: str,
462
+ existing_icon: str | None,
463
+ fallback_icon_name: str | None = None,
464
+ ) -> str:
465
+ """更新语义的 dashIcon 重编码(round-trip 保真)。
466
+
467
+ 后端 updateDashBaseInfo 对 dashIcon 无校验直接透传落库;生产存量存在三类非目录形态:
468
+ 文字图标(iconName 空 + iconText=名字首字)、旧版 URL 图标(icon 键携带 URL)、
469
+ 空(iconName/iconText 均空)。encode_workspace_icon_with_defaults 的 fallback 语义
470
+ (为新建资源合成目录图标)会把「声明 icon 未指定」的存量形态重写为 fallback 图标。
471
+
472
+ 更新语义规则:
473
+ - 声明 icon 显式指定且与存量 iconName 归一后一致 → JSON 级最小 patch:仅当声明
474
+ 显式指定了不同 color 才改写 iconColor,否则存量 JSON 原串透传(byte 级保真);
475
+ iconText 等非目录形态键始终保留——生产存量存在 iconName+iconText 同设形态
476
+ (r7 全量实测 48 门),全量重编码会把 iconText 清成 null。
477
+ - 声明 icon 显式指定且为其他图标 → 走 encode(用户意图覆写);
478
+ - 声明 icon 未指定且无存量 → 走 encode_with_defaults(创建/首次合成);
479
+ - 声明 icon 未指定且有存量:
480
+ - color 也未指定 → 原样保留存量 JSON;
481
+ - color 指定 → JSON 级最小 patch(仅替换 iconColor,iconName/iconText/icon URL
482
+ 等存量形态键原样保留)。
483
+ """
484
+ existing_payload = str(existing_icon or "").strip()
485
+ if existing_payload and _looks_like_icon_json(existing_payload):
486
+ try:
487
+ parsed = json.loads(existing_payload)
488
+ except ValueError:
489
+ parsed = None
490
+ if isinstance(parsed, dict):
491
+ existing_name, existing_color, _ = parse_workspace_icon(existing_payload)
492
+ if icon is None:
493
+ if color is None:
494
+ return existing_payload
495
+ patched = dict(parsed)
496
+ patched["iconColor"] = color
497
+ return json.dumps(patched, ensure_ascii=False, separators=(",", ":"))
498
+ if (
499
+ existing_name is not None
500
+ and not _looks_like_icon_json(icon)
501
+ and not _looks_like_icon_url(icon)
502
+ and normalize_workspace_icon_name(icon) == existing_name
503
+ ):
504
+ if color is None or color == existing_color:
505
+ return existing_payload
506
+ patched = dict(parsed)
507
+ patched["iconColor"] = color
508
+ return json.dumps(patched, ensure_ascii=False, separators=(",", ":"))
509
+ return encode_workspace_icon_with_defaults(
510
+ icon=icon,
511
+ color=color,
512
+ title=title,
513
+ fallback_icon_name=fallback_icon_name,
514
+ existing_icon=existing_icon,
515
+ )
516
+
517
+
445
518
  def normalize_workspace_icon_name(icon: str | None) -> str | None:
446
519
  if not icon:
447
520
  return None
@@ -10,6 +10,7 @@ from ..tools.app_tools import AppTools
10
10
  from ..tools.navigation_tools import NavigationTools
11
11
  from ..tools.package_tools import PackageTools
12
12
  from ..tools.portal_tools import PortalTools
13
+ from ..tools.portal_writer import PortalWriter
13
14
  from ..tools.qingbi_report_tools import QingbiReportTools
14
15
  from ..tools.record_tools import RecordTools
15
16
  from ..tools.role_tools import RoleTools
@@ -39,6 +40,7 @@ class SolutionExecutor:
39
40
  view_writer: "ViewWriter | None" = None,
40
41
  chart_tools: QingbiReportTools,
41
42
  portal_tools: PortalTools,
43
+ portal_writer: "PortalWriter | None" = None,
42
44
  navigation_tools: NavigationTools,
43
45
  workflow_tools: Any | None = None,
44
46
  ) -> None:
@@ -51,6 +53,7 @@ class SolutionExecutor:
51
53
  self.view_writer = view_writer
52
54
  self.chart_tools = chart_tools
53
55
  self.portal_tools = portal_tools
56
+ self.portal_writer = portal_writer or PortalWriter(portal_tools.sessions, portal_tools.backend)
54
57
  self.navigation_tools = navigation_tools
55
58
  self.workflow_tools = workflow_tools
56
59
 
@@ -624,7 +627,7 @@ class SolutionExecutor:
624
627
  result = portal_artifact
625
628
  if not dash_key:
626
629
  create_payload = self._resolve_portal_payload(compiled.portal_plan["create_payload"], store, base_payload=None)
627
- result = self.portal_tools.portal_create(profile=profile, payload=create_payload)
630
+ result = self.portal_writer.create_portal(profile=profile, payload=create_payload)
628
631
  portal_result = result.get("result") or {}
629
632
  dash_key = portal_result.get("dashKey")
630
633
  store.set_artifact("portal", "result", result)
@@ -655,7 +658,7 @@ class SolutionExecutor:
655
658
  ),
656
659
  )
657
660
  update_payload = self._resolve_portal_payload(compiled.portal_plan["update_payload"], store, base_payload=base_payload)
658
- self.portal_tools.portal_update(profile=profile, dash_key=dash_key, payload=update_payload)
661
+ self.portal_writer.update_portal(profile=profile, dash_key=dash_key, payload=update_payload)
659
662
  self._refresh_portal_artifact(profile=profile, store=store, being_draft=True, artifact_key="draft_result")
660
663
 
661
664
  def _build_navigation(self, profile: str, compiled: CompiledSolution, store: RunArtifactStore) -> None:
@@ -699,13 +699,36 @@ class AiBuilderTools(ToolBase):
699
699
  def portal_list(profile: str = DEFAULT_PROFILE) -> JSONObject:
700
700
  return self.portal_list(profile=profile)
701
701
 
702
+ @mcp.tool()
703
+ def portal_schema(
704
+ schema_version: str | None = None,
705
+ schema_kind: str | None = None,
706
+ ) -> JSONObject:
707
+ """Get a complete portals schema or a focused component schema such as chart, view, grid, or carousel."""
708
+ return self.portal_schema(schema_version=schema_version, schema_kind=schema_kind)
709
+
710
+ @mcp.tool()
711
+ def portal_validate(
712
+ declaration: JSONObject | None = None,
713
+ schema_version: str | None = None,
714
+ ) -> JSONObject:
715
+ return self.portal_validate(declaration=declaration or {}, schema_version=schema_version)
716
+
702
717
  @mcp.tool()
703
718
  def portal_get(
704
719
  profile: str = DEFAULT_PROFILE,
705
720
  dash_key: str = "",
706
721
  being_draft: bool = True,
722
+ schema_kind: str = "summary",
723
+ schema_version: str | None = None,
707
724
  ) -> JSONObject:
708
- return self.portal_get(profile=profile, dash_key=dash_key, being_draft=being_draft)
725
+ return self.portal_get(
726
+ profile=profile,
727
+ dash_key=dash_key,
728
+ being_draft=being_draft,
729
+ schema_kind=schema_kind,
730
+ schema_version=schema_version,
731
+ )
709
732
 
710
733
  @mcp.tool()
711
734
  def view_get(profile: str = DEFAULT_PROFILE, view_key: str = "") -> JSONObject:
@@ -845,6 +868,10 @@ class AiBuilderTools(ToolBase):
845
868
  color: str | None = None,
846
869
  hide_copyright: bool | None = None,
847
870
  dash_global_config: JSONObject | None = None,
871
+ global_config: JSONObject | None = None,
872
+ style_template_id: int | None = None,
873
+ overall_style: JSONObject | None = None,
874
+ schema_version: str | None = None,
848
875
  config: JSONObject | None = None,
849
876
  payload: JSONObject | None = None,
850
877
  patch_sections: list[JSONObject] | None = None,
@@ -883,6 +910,10 @@ class AiBuilderTools(ToolBase):
883
910
  color=color,
884
911
  hide_copyright=hide_copyright,
885
912
  dash_global_config=dash_global_config,
913
+ global_config=global_config,
914
+ style_template_id=style_template_id,
915
+ overall_style=overall_style,
916
+ schema_version=schema_version,
886
917
  config=config or {},
887
918
  payload=payload,
888
919
  patch_sections=patch_sections,
@@ -2052,16 +2083,58 @@ class AiBuilderTools(ToolBase):
2052
2083
  suggested_next_call={"tool_name": "portal_list", "arguments": {"profile": profile}},
2053
2084
  ))
2054
2085
 
2055
- def portal_get(self, *, profile: str, dash_key: str, being_draft: bool = True) -> JSONObject:
2056
- """执行门户相关逻辑。"""
2057
- normalized_args = {"dash_key": dash_key, "being_draft": being_draft}
2086
+ def portal_get(
2087
+ self,
2088
+ *,
2089
+ profile: str,
2090
+ dash_key: str,
2091
+ being_draft: bool = True,
2092
+ schema_kind: str = "summary",
2093
+ schema_version: str | None = None,
2094
+ ) -> JSONObject:
2095
+ """读取门户详情;schema_kind=summary|complete 返回 canonical 声明,raw 为原始归一响应。"""
2096
+ normalized_args = {"dash_key": dash_key, "being_draft": being_draft, "schema_kind": schema_kind, "schema_version": schema_version}
2058
2097
  return _publicize_package_fields(_safe_tool_call(
2059
- lambda: self._facade.portal_get(profile=profile, dash_key=dash_key, being_draft=being_draft),
2098
+ lambda: self._facade.portal_get(
2099
+ profile=profile,
2100
+ dash_key=dash_key,
2101
+ being_draft=being_draft,
2102
+ schema_kind=schema_kind,
2103
+ schema_version=schema_version,
2104
+ ),
2060
2105
  error_code="PORTAL_GET_FAILED",
2061
2106
  normalized_args=normalized_args,
2062
2107
  suggested_next_call={"tool_name": "portal_get", "arguments": {"profile": profile, **normalized_args}},
2063
2108
  ))
2064
2109
 
2110
+ def portal_schema(
2111
+ self,
2112
+ *,
2113
+ schema_version: str | None = None,
2114
+ schema_kind: str | None = None,
2115
+ ) -> JSONObject:
2116
+ """读取版本化门户声明 schema(纯离线)。"""
2117
+ return _publicize_package_fields(_safe_tool_call(
2118
+ lambda: self._facade.portal_schema(schema_version=schema_version, schema_kind=schema_kind),
2119
+ error_code="PORTAL_SCHEMA_FAILED",
2120
+ normalized_args={"schema_version": schema_version, "schema_kind": schema_kind},
2121
+ suggested_next_call={"tool_name": "portal_schema", "arguments": {"schema_kind": "summary"}},
2122
+ ))
2123
+
2124
+ def portal_validate(
2125
+ self,
2126
+ *,
2127
+ declaration: JSONObject,
2128
+ schema_version: str | None = None,
2129
+ ) -> JSONObject:
2130
+ """纯离线校验一条门户声明(不访问后端)。"""
2131
+ return _publicize_package_fields(_safe_tool_call(
2132
+ lambda: self._facade.portal_validate(declaration=declaration, schema_version=schema_version),
2133
+ error_code="PORTAL_VALIDATE_FAILED",
2134
+ normalized_args={"schema_version": schema_version},
2135
+ suggested_next_call={"tool_name": "portal_schema", "arguments": {"schema_kind": "complete"}},
2136
+ ))
2137
+
2065
2138
  def portal_read_summary(self, *, profile: str, dash_key: str, being_draft: bool = True) -> JSONObject:
2066
2139
  """执行门户相关逻辑。"""
2067
2140
  normalized_args = {"dash_key": dash_key, "being_draft": being_draft}
@@ -4233,6 +4306,10 @@ class AiBuilderTools(ToolBase):
4233
4306
  color: str | None = None,
4234
4307
  hide_copyright: bool | None = None,
4235
4308
  dash_global_config: JSONObject | None = None,
4309
+ global_config: JSONObject | None = None,
4310
+ style_template_id: int | None = None,
4311
+ overall_style: JSONObject | None = None,
4312
+ schema_version: str | None = None,
4236
4313
  config: JSONObject | None = None,
4237
4314
  payload: JSONObject | None = None,
4238
4315
  patch_sections: list[JSONObject] | None = None,
@@ -4284,6 +4361,14 @@ class AiBuilderTools(ToolBase):
4284
4361
  request_payload["hide_copyright"] = hide_copyright
4285
4362
  if dash_global_config is not None:
4286
4363
  request_payload["dash_global_config"] = dash_global_config
4364
+ if global_config is not None:
4365
+ request_payload["global_config"] = global_config
4366
+ if style_template_id is not None:
4367
+ request_payload["style_template_id"] = style_template_id
4368
+ if overall_style is not None:
4369
+ request_payload["overall_style"] = overall_style
4370
+ if schema_version is not None:
4371
+ request_payload["schema_version"] = schema_version
4287
4372
  if config:
4288
4373
  merged_config = dict(request_payload.get("config") or {}) if isinstance(request_payload.get("config"), dict) else {}
4289
4374
  merged_config.update(config)
@@ -4315,16 +4400,24 @@ class AiBuilderTools(ToolBase):
4315
4400
  ))
4316
4401
  normalized_args = request.model_dump(mode="json")
4317
4402
  normalized_args["package_id"] = normalized_args.pop("package_tag_id", package_id)
4318
- icon_failure = _validate_workspace_icon_for_builder(
4319
- tool_name="portal_apply",
4320
- icon=str(request.icon or ""),
4321
- color=str(request.color or ""),
4322
- creating=not bool(str(request.dash_key or "").strip()),
4323
- )
4403
+ portal_creating = not bool(str(request.dash_key or "").strip())
4404
+ # icon/color 目录校验仅对创建语义生效(CLI 防呆,后端 createDash 亦无校验):
4405
+ # 更新语义(dash_key 定位)后端 updateDashBaseInfo 对 dashIcon 无目录校验直接
4406
+ # 透传落库,生产存量存在旧版 hex 色值(#13c2c2 等)——提取透传的重放声明会被
4407
+ # 目录校验拒之门外(WORKSPACE_ICON_COLOR_NOT_FOUND),故更新语义不做目录校验。
4408
+ if portal_creating:
4409
+ icon_failure = _validate_workspace_icon_for_builder(
4410
+ tool_name="portal_apply",
4411
+ icon=str(request.icon or ""),
4412
+ color=str(request.color or ""),
4413
+ creating=True,
4414
+ )
4415
+ else:
4416
+ icon_failure = None
4324
4417
  if icon_failure is not None:
4325
4418
  return self._attach_apply_envelope(profile, "portal_apply", icon_failure)
4326
4419
  result = _publicize_package_fields(_safe_tool_call(
4327
- lambda: self._facade.portal_apply(profile=profile, request=request),
4420
+ lambda: self._facade.portal_apply(profile=profile, request=request, schema_version=schema_version),
4328
4421
  error_code="PORTAL_APPLY_FAILED",
4329
4422
  normalized_args=normalized_args,
4330
4423
  suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
@@ -5738,6 +5831,11 @@ def _publicize_package_fields(value):
5738
5831
  }
5739
5832
  public: JSONObject = {}
5740
5833
  for key, item in value.items():
5834
+ if key == "declaration":
5835
+ # canonical 声明保持 schema 契约命名(package_tag_id 等),不做公开名重命名,
5836
+ # 否则 get 投影的 declaration 无法直接回喂 validate/apply(round-trip 破坏)。
5837
+ public[key] = item
5838
+ continue
5741
5839
  public_key = key_map.get(key, key)
5742
5840
  public[public_key] = _publicize_package_fields(item)
5743
5841
  return public
@@ -14,8 +14,10 @@ class PortalTools(ToolBase):
14
14
  类型:门户配置工具。
15
15
  主要职责:
16
16
  1. 查询门户列表与详情;
17
- 2. 维护门户基础信息与配置;
18
- 3. 执行门户发布与删除操作。
17
+ 2. 执行门户发布与删除操作。
18
+
19
+ 创建/更新写入已下沉 ``PortalWriter``(tools/portal_writer.py),
20
+ 公开层唯一入口是 ``builder portal apply`` / ``portal_apply``。
19
21
  """
20
22
 
21
23
  def register(self, mcp: FastMCP) -> None:
@@ -32,18 +34,6 @@ class PortalTools(ToolBase):
32
34
  def portal_get_base_info(profile: str = DEFAULT_PROFILE, dash_key: str = "") -> JSONObject:
33
35
  return self.portal_get_base_info(profile=profile, dash_key=dash_key)
34
36
 
35
- @mcp.tool()
36
- def portal_create(profile: str = DEFAULT_PROFILE, payload: JSONObject | None = None) -> JSONObject:
37
- return self.portal_create(profile=profile, payload=payload or {})
38
-
39
- @mcp.tool(description=self._high_risk_tool_description(operation="update", target="portal base settings"))
40
- def portal_update_base_info(profile: str = DEFAULT_PROFILE, dash_key: str = "", payload: JSONObject | None = None) -> JSONObject:
41
- return self.portal_update_base_info(profile=profile, dash_key=dash_key, payload=payload or {})
42
-
43
- @mcp.tool(description=self._high_risk_tool_description(operation="update", target="portal configuration"))
44
- def portal_update(profile: str = DEFAULT_PROFILE, dash_key: str = "", payload: JSONObject | None = None) -> JSONObject:
45
- return self.portal_update(profile=profile, dash_key=dash_key, payload=payload or {})
46
-
47
37
  @mcp.tool(description=self._high_risk_tool_description(operation="delete", target="portal configuration"))
48
38
  def portal_delete(profile: str = DEFAULT_PROFILE, dash_key: str = "") -> JSONObject:
49
39
  return self.portal_delete(profile=profile, dash_key=dash_key)
@@ -70,16 +60,6 @@ class PortalTools(ToolBase):
70
60
 
71
61
  return self._run(profile, runner, tool_name='门户详情')
72
62
 
73
- def portal_create(self, *, profile: str, payload: JSONObject) -> JSONObject:
74
- """执行门户相关逻辑。"""
75
- body = self._require_dict(payload)
76
-
77
- def runner(session_profile, context):
78
- result = self.backend.request("POST", context, "/dash", json_body=body)
79
- return {"profile": profile, "ws_id": session_profile.selected_ws_id, "result": result}
80
-
81
- return self._run(profile, runner, tool_name='创建门户')
82
-
83
63
  def portal_get_base_info(self, *, profile: str, dash_key: str) -> JSONObject:
84
64
  """执行门户相关逻辑。"""
85
65
  self._require_dash_key(dash_key)
@@ -119,36 +99,6 @@ class PortalTools(ToolBase):
119
99
 
120
100
  return self._run(profile, runner, tool_name='门户基础信息')
121
101
 
122
- def portal_update_base_info(self, *, profile: str, dash_key: str, payload: JSONObject) -> JSONObject:
123
- """执行门户相关逻辑。"""
124
- self._require_dash_key(dash_key)
125
- body = self._require_dict(payload)
126
-
127
- def runner(session_profile, context):
128
- result = self.backend.request("POST", context, f"/dash/{dash_key}/baseInfo", json_body=body)
129
- return self._attach_human_review_notice(
130
- {"profile": profile, "ws_id": session_profile.selected_ws_id, "dash_key": dash_key, "result": result},
131
- operation="update",
132
- target="portal base settings",
133
- )
134
-
135
- return self._run(profile, runner, tool_name='更新门户基础信息')
136
-
137
- def portal_update(self, *, profile: str, dash_key: str, payload: JSONObject) -> JSONObject:
138
- """执行门户相关逻辑。"""
139
- self._require_dash_key(dash_key)
140
- body = self._require_dict(payload)
141
-
142
- def runner(session_profile, context):
143
- result = self.backend.request("POST", context, f"/dash/{dash_key}", json_body=body)
144
- return self._attach_human_review_notice(
145
- {"profile": profile, "ws_id": session_profile.selected_ws_id, "dash_key": dash_key, "result": result},
146
- operation="update",
147
- target="portal configuration",
148
- )
149
-
150
- return self._run(profile, runner, tool_name='更新门户配置')
151
-
152
102
  def portal_delete(self, *, profile: str, dash_key: str) -> JSONObject:
153
103
  """执行门户相关逻辑。"""
154
104
  self._require_dash_key(dash_key)