@qingflow-tech/qingflow-app-builder-mcp 1.0.10 → 1.0.11

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.
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  from copy import deepcopy
4
4
  import json
5
5
  import time
6
+ from typing import Any
6
7
 
7
8
  from pydantic import ValidationError
8
9
 
@@ -49,6 +50,15 @@ from ..builder_facade.models import (
49
50
  ViewsPlanRequest,
50
51
  )
51
52
  from ..builder_facade.service import AiBuilderFacade, INTEGRATION_OUTPUT_TARGET_FIELD_TYPES
53
+ from ..solution.compiler.icon_utils import (
54
+ GENERIC_WORKSPACE_ICON_NAMES,
55
+ WORKSPACE_ICON_COLORS,
56
+ WORKSPACE_ICON_NAMES,
57
+ normalize_workspace_icon_name,
58
+ validate_workspace_icon_choice,
59
+ workspace_icon_catalog_payload,
60
+ workspace_icon_config,
61
+ )
52
62
  from .app_tools import AppTools
53
63
  from .base import ToolBase, tool_cn_name
54
64
  from .custom_button_tools import CustomButtonTools
@@ -117,6 +127,14 @@ class AiBuilderTools(ToolBase):
117
127
  def builder_tool_contract(tool_name: str = "") -> JSONObject:
118
128
  return self.builder_tool_contract(tool_name=tool_name)
119
129
 
130
+ @mcp.tool()
131
+ def workspace_icon_catalog_get(profile: str = DEFAULT_PROFILE) -> JSONObject:
132
+ return self.workspace_icon_catalog_get(profile=profile)
133
+
134
+ @mcp.tool()
135
+ def package_list(profile: str = DEFAULT_PROFILE, trial_status: str = "all", query: str = "") -> JSONObject:
136
+ return self.package_list(profile=profile, trial_status=trial_status, query=query)
137
+
120
138
  @mcp.tool()
121
139
  def package_get(profile: str = DEFAULT_PROFILE, package_id: int = 0) -> JSONObject:
122
140
  return self.package_get(profile=profile, package_id=package_id)
@@ -482,9 +500,12 @@ class AiBuilderTools(ToolBase):
482
500
  profile: str = DEFAULT_PROFILE,
483
501
  dash_key: str = "",
484
502
  dash_name: str = "",
503
+ name: str = "",
485
504
  package_id: int | None = None,
486
505
  publish: bool = True,
487
506
  sections: list[JSONObject] | None = None,
507
+ pages: list[JSONObject] | None = None,
508
+ layout_preset: str = "",
488
509
  visibility: JSONObject | None = None,
489
510
  auth: JSONObject | None = None,
490
511
  icon: str | None = None,
@@ -492,10 +513,14 @@ class AiBuilderTools(ToolBase):
492
513
  hide_copyright: bool | None = None,
493
514
  dash_global_config: JSONObject | None = None,
494
515
  config: JSONObject | None = None,
516
+ payload: JSONObject | None = None,
495
517
  ) -> JSONObject:
518
+ payload = payload if isinstance(payload, dict) else {}
496
519
  has_dash_key = bool((dash_key or "").strip())
497
- has_dash_name = bool((dash_name or "").strip())
498
- has_package_id = package_id is not None
520
+ effective_dash_name = (dash_name or name or str(payload.get("dash_name") or payload.get("dashName") or payload.get("name") or "")).strip()
521
+ has_dash_name = bool(effective_dash_name)
522
+ effective_package_id = package_id if package_id is not None else payload.get("package_id") or payload.get("packageId") or payload.get("package_tag_id")
523
+ has_package_id = effective_package_id is not None
499
524
  if has_dash_key and has_package_id:
500
525
  return _config_failure(
501
526
  tool_name="portal_apply",
@@ -512,9 +537,12 @@ class AiBuilderTools(ToolBase):
512
537
  profile=profile,
513
538
  dash_key=dash_key,
514
539
  dash_name=dash_name,
540
+ name=name,
515
541
  package_id=package_id,
516
542
  publish=publish,
517
543
  sections=sections or [],
544
+ pages=pages or [],
545
+ layout_preset=layout_preset,
518
546
  visibility=visibility,
519
547
  auth=auth,
520
548
  icon=icon,
@@ -522,6 +550,7 @@ class AiBuilderTools(ToolBase):
522
550
  hide_copyright=hide_copyright,
523
551
  dash_global_config=dash_global_config,
524
552
  config=config or {},
553
+ payload=payload,
525
554
  )
526
555
 
527
556
  @mcp.tool()
@@ -537,14 +566,14 @@ class AiBuilderTools(ToolBase):
537
566
  )
538
567
 
539
568
  @tool_cn_name("分组列表查询")
540
- def package_list(self, *, profile: str, trial_status: str = "all") -> JSONObject:
569
+ def package_list(self, *, profile: str, trial_status: str = "all", query: str = "") -> JSONObject:
541
570
  """执行分组与包相关逻辑。"""
542
- normalized_args = {"trial_status": trial_status}
571
+ normalized_args = {"trial_status": trial_status, "query": query}
543
572
  return _safe_tool_call(
544
- lambda: self._facade.package_list(profile=profile, trial_status=trial_status),
573
+ lambda: self._facade.package_list(profile=profile, trial_status=trial_status, query=query),
545
574
  error_code="PACKAGE_LIST_FAILED",
546
575
  normalized_args=normalized_args,
547
- suggested_next_call={"tool_name": "package_list", "arguments": {"profile": profile, "trial_status": trial_status}},
576
+ suggested_next_call={"tool_name": "package_list", "arguments": {"profile": profile, "trial_status": trial_status, "query": query}},
548
577
  )
549
578
 
550
579
  @tool_cn_name("分组解析")
@@ -611,6 +640,27 @@ class AiBuilderTools(ToolBase):
611
640
  "contract": contract,
612
641
  }
613
642
 
643
+ @tool_cn_name("工作区图标目录")
644
+ def workspace_icon_catalog_get(self, *, profile: str = DEFAULT_PROFILE) -> JSONObject:
645
+ """读取应用、应用包、门户可用的工作区图标候选。"""
646
+ catalog = workspace_icon_catalog_payload()
647
+ return {
648
+ "status": "success",
649
+ "error_code": None,
650
+ "recoverable": False,
651
+ "message": "loaded workspace icon catalog",
652
+ "profile": profile,
653
+ "icon_names": catalog["icon_names"],
654
+ "icon_colors": catalog["icon_colors"],
655
+ "generic_icon_names": catalog["generic_icon_names"],
656
+ "common_examples": catalog["common_examples"],
657
+ "notes": catalog["notes"],
658
+ "count": len(catalog["icon_names"]),
659
+ "color_count": len(catalog["icon_colors"]),
660
+ "warnings": [],
661
+ "verification": {"source": "backend AiBuildConstant ICON_NAMES/ICON_COLORS"},
662
+ }
663
+
614
664
  @tool_cn_name("分组创建")
615
665
  def package_create(
616
666
  self,
@@ -691,6 +741,14 @@ class AiBuilderTools(ToolBase):
691
741
  "package_apply",
692
742
  _visibility_validation_failure(str(exc), tool_name="package_apply", exc=exc),
693
743
  )
744
+ icon_failure = _validate_workspace_icon_for_builder(
745
+ tool_name="package_apply",
746
+ icon=icon,
747
+ color=color,
748
+ creating=package_id is None and bool(create_if_missing),
749
+ )
750
+ if icon_failure is not None:
751
+ return _attach_builder_apply_envelope("package_apply", icon_failure)
694
752
  normalized_args = {
695
753
  "package_id": package_id,
696
754
  **({"package_name": package_name} if str(package_name or "").strip() else {}),
@@ -1657,6 +1715,68 @@ class AiBuilderTools(ToolBase):
1657
1715
  message="app_schema_apply multi-app mode requires non-empty apps.",
1658
1716
  fix_hint="Pass apps as a non-empty list of app schema items.",
1659
1717
  )
1718
+ icon_errors: list[JSONObject] = []
1719
+ seen_new_app_icons: dict[str, int] = {}
1720
+ for index, raw_item in enumerate(apps):
1721
+ if not isinstance(raw_item, dict):
1722
+ continue
1723
+ app_key = str(raw_item.get("app_key") or raw_item.get("appKey") or "").strip()
1724
+ creating_item = not app_key
1725
+ icon_failure = _validate_workspace_icon_for_builder(
1726
+ tool_name="app_schema_apply",
1727
+ icon=str(raw_item.get("icon") or ""),
1728
+ color=str(raw_item.get("color") or ""),
1729
+ creating=creating_item,
1730
+ )
1731
+ if icon_failure is not None:
1732
+ icon_errors.append(
1733
+ {
1734
+ "index": index,
1735
+ "row_number": index + 1,
1736
+ "error_code": icon_failure.get("error_code"),
1737
+ "message": icon_failure.get("message"),
1738
+ "details": icon_failure.get("details"),
1739
+ }
1740
+ )
1741
+ continue
1742
+ _ok, _error_code, _message, icon_details = validate_workspace_icon_choice(
1743
+ icon=str(raw_item.get("icon") or ""),
1744
+ color=str(raw_item.get("color") or ""),
1745
+ require_explicit=creating_item,
1746
+ disallow_generic=creating_item,
1747
+ )
1748
+ normalized_icon = str(icon_details.get("normalized_icon") or "").strip()
1749
+ if creating_item and normalized_icon:
1750
+ if normalized_icon in seen_new_app_icons:
1751
+ icon_errors.append(
1752
+ {
1753
+ "index": index,
1754
+ "row_number": index + 1,
1755
+ "error_code": "DUPLICATE_WORKSPACE_ICON_IN_BATCH",
1756
+ "message": f"apps[{index}] reuses icon '{normalized_icon}' from apps[{seen_new_app_icons[normalized_icon]}]",
1757
+ "details": {
1758
+ "icon": normalized_icon,
1759
+ "first_index": seen_new_app_icons[normalized_icon],
1760
+ "duplicate_index": index,
1761
+ "icon_catalog_command": "qingflow --json builder icon catalog",
1762
+ },
1763
+ }
1764
+ )
1765
+ else:
1766
+ seen_new_app_icons[normalized_icon] = index
1767
+ if icon_errors:
1768
+ return _config_failure(
1769
+ tool_name="app_schema_apply",
1770
+ error_code="WORKSPACE_ICON_BATCH_INVALID",
1771
+ message="one or more apps have invalid workspace icon configuration",
1772
+ fix_hint="Call `qingflow --json builder icon catalog`, choose a distinct non-template icon and color for each new app, then retry.",
1773
+ details={"icon_errors": icon_errors},
1774
+ allowed_values={
1775
+ "workspace_icon.icon_names": list(WORKSPACE_ICON_NAMES),
1776
+ "workspace_icon.icon_colors": list(WORKSPACE_ICON_COLORS),
1777
+ "workspace_icon.generic_icon_names": list(GENERIC_WORKSPACE_ICON_NAMES),
1778
+ },
1779
+ )
1660
1780
 
1661
1781
  client_key_to_app_key: dict[str, str] = {}
1662
1782
  created_app_keys: list[str] = []
@@ -1868,6 +1988,14 @@ class AiBuilderTools(ToolBase):
1868
1988
  ) -> JSONObject:
1869
1989
  """执行内部辅助逻辑。"""
1870
1990
  effective_app_name = app_name or app_title
1991
+ icon_failure = _validate_workspace_icon_for_builder(
1992
+ tool_name="app_schema_apply",
1993
+ icon=icon,
1994
+ color=color,
1995
+ creating=not bool(str(app_key or "").strip()) and bool(create_if_missing),
1996
+ )
1997
+ if icon_failure is not None:
1998
+ return icon_failure
1871
1999
  plan_result = self._rewrite_plan_result_for_apply(
1872
2000
  result=self.app_schema_plan(
1873
2001
  profile=profile,
@@ -2364,9 +2492,12 @@ class AiBuilderTools(ToolBase):
2364
2492
  profile: str,
2365
2493
  dash_key: str = "",
2366
2494
  dash_name: str = "",
2495
+ name: str = "",
2367
2496
  package_id: int | None = None,
2368
2497
  publish: bool = True,
2369
2498
  sections: list[JSONObject] | None = None,
2499
+ pages: list[JSONObject] | None = None,
2500
+ layout_preset: str = "",
2370
2501
  visibility: JSONObject | None = None,
2371
2502
  auth: JSONObject | None = None,
2372
2503
  icon: str | None = None,
@@ -2374,25 +2505,44 @@ class AiBuilderTools(ToolBase):
2374
2505
  hide_copyright: bool | None = None,
2375
2506
  dash_global_config: JSONObject | None = None,
2376
2507
  config: JSONObject | None = None,
2508
+ payload: JSONObject | None = None,
2377
2509
  ) -> JSONObject:
2378
2510
  """执行门户相关逻辑。"""
2511
+ request_payload: dict[str, Any] = dict(payload) if isinstance(payload, dict) else {}
2512
+ if dash_key:
2513
+ request_payload["dash_key"] = dash_key
2514
+ if dash_name:
2515
+ request_payload["dash_name"] = dash_name
2516
+ elif name:
2517
+ request_payload["name"] = name
2518
+ if package_id is not None:
2519
+ request_payload["package_id"] = package_id
2520
+ if "publish" not in request_payload or publish is False:
2521
+ request_payload["publish"] = publish
2522
+ if sections:
2523
+ request_payload["sections"] = sections
2524
+ if pages:
2525
+ request_payload["pages"] = pages
2526
+ if layout_preset:
2527
+ request_payload["layout_preset"] = layout_preset
2528
+ if visibility is not None:
2529
+ request_payload["visibility"] = visibility
2530
+ if auth is not None:
2531
+ request_payload["auth"] = auth
2532
+ if icon is not None:
2533
+ request_payload["icon"] = icon
2534
+ if color is not None:
2535
+ request_payload["color"] = color
2536
+ if hide_copyright is not None:
2537
+ request_payload["hide_copyright"] = hide_copyright
2538
+ if dash_global_config is not None:
2539
+ request_payload["dash_global_config"] = dash_global_config
2540
+ if config:
2541
+ merged_config = dict(request_payload.get("config") or {}) if isinstance(request_payload.get("config"), dict) else {}
2542
+ merged_config.update(config)
2543
+ request_payload["config"] = merged_config
2379
2544
  try:
2380
- request = PortalApplyRequest.model_validate(
2381
- {
2382
- "dash_key": dash_key or None,
2383
- "dash_name": dash_name or None,
2384
- "package_tag_id": package_id,
2385
- "publish": publish,
2386
- "sections": sections or [],
2387
- "visibility": visibility,
2388
- "auth": auth,
2389
- "icon": icon,
2390
- "color": color,
2391
- "hide_copyright": hide_copyright,
2392
- "dash_global_config": dash_global_config,
2393
- "config": config or {},
2394
- }
2395
- )
2545
+ request = PortalApplyRequest.model_validate(request_payload)
2396
2546
  except ValidationError as exc:
2397
2547
  return _attach_builder_apply_envelope("portal_apply", _visibility_validation_failure(
2398
2548
  str(exc),
@@ -2405,6 +2555,7 @@ class AiBuilderTools(ToolBase):
2405
2555
  "dash_name": dash_name or "业务门户",
2406
2556
  "package_id": package_id or 1001,
2407
2557
  "publish": True,
2558
+ "layout_preset": "dashboard_2col",
2408
2559
  "sections": [
2409
2560
  {
2410
2561
  "title": "经营概览",
@@ -2417,6 +2568,14 @@ class AiBuilderTools(ToolBase):
2417
2568
  ))
2418
2569
  normalized_args = request.model_dump(mode="json")
2419
2570
  normalized_args["package_id"] = normalized_args.pop("package_tag_id", package_id)
2571
+ icon_failure = _validate_workspace_icon_for_builder(
2572
+ tool_name="portal_apply",
2573
+ icon=str(request.icon or ""),
2574
+ color=str(request.color or ""),
2575
+ creating=not bool(str(request.dash_key or "").strip()),
2576
+ )
2577
+ if icon_failure is not None:
2578
+ return _attach_builder_apply_envelope("portal_apply", icon_failure)
2420
2579
  result = _publicize_package_fields(_safe_tool_call(
2421
2580
  lambda: self._facade.portal_apply(profile=profile, request=request),
2422
2581
  error_code="PORTAL_APPLY_FAILED",
@@ -2771,20 +2930,34 @@ def _visibility_validation_failure(
2771
2930
  return result
2772
2931
 
2773
2932
 
2774
- def _config_failure(*, tool_name: str, message: str, fix_hint: str) -> JSONObject:
2933
+ def _config_failure(
2934
+ *,
2935
+ tool_name: str,
2936
+ message: str,
2937
+ fix_hint: str,
2938
+ error_code: str = "CONFIG_ERROR",
2939
+ details: JSONObject | None = None,
2940
+ allowed_values: JSONObject | None = None,
2941
+ ) -> JSONObject:
2775
2942
  contract = _BUILDER_TOOL_CONTRACTS.get(tool_name or "")
2943
+ public_allowed_values = deepcopy(contract.get("allowed_values", {})) if isinstance(contract, dict) else {}
2944
+ if allowed_values:
2945
+ public_allowed_values.update(deepcopy(allowed_values))
2946
+ public_details: JSONObject = {
2947
+ "fix_hint": fix_hint,
2948
+ "allowed_keys": deepcopy(contract.get("allowed_keys", [])) if isinstance(contract, dict) else [],
2949
+ }
2950
+ if details:
2951
+ public_details.update(deepcopy(details))
2776
2952
  return {
2777
2953
  "status": "failed",
2778
- "error_code": "CONFIG_ERROR",
2954
+ "error_code": error_code,
2779
2955
  "recoverable": True,
2780
2956
  "message": message,
2781
2957
  "normalized_args": {},
2782
2958
  "missing_fields": [],
2783
- "allowed_values": deepcopy(contract.get("allowed_values", {})) if isinstance(contract, dict) else {},
2784
- "details": {
2785
- "fix_hint": fix_hint,
2786
- "allowed_keys": deepcopy(contract.get("allowed_keys", [])) if isinstance(contract, dict) else [],
2787
- },
2959
+ "allowed_values": public_allowed_values,
2960
+ "details": public_details,
2788
2961
  "suggested_next_call": None,
2789
2962
  "request_id": None,
2790
2963
  "backend_code": None,
@@ -2794,6 +2967,52 @@ def _config_failure(*, tool_name: str, message: str, fix_hint: str) -> JSONObjec
2794
2967
  }
2795
2968
 
2796
2969
 
2970
+ def _workspace_icon_config_failure(
2971
+ *,
2972
+ tool_name: str,
2973
+ error_code: str,
2974
+ message: str,
2975
+ details: JSONObject,
2976
+ ) -> JSONObject:
2977
+ return _config_failure(
2978
+ tool_name=tool_name,
2979
+ error_code=error_code,
2980
+ message=message,
2981
+ fix_hint="Call `qingflow --json builder icon catalog`, choose an explicit non-template icon and color, then retry.",
2982
+ details=details,
2983
+ allowed_values={
2984
+ "workspace_icon.icon_names": list(WORKSPACE_ICON_NAMES),
2985
+ "workspace_icon.icon_colors": list(WORKSPACE_ICON_COLORS),
2986
+ "workspace_icon.generic_icon_names": list(GENERIC_WORKSPACE_ICON_NAMES),
2987
+ },
2988
+ )
2989
+
2990
+
2991
+ def _validate_workspace_icon_for_builder(
2992
+ *,
2993
+ tool_name: str,
2994
+ icon: str | None,
2995
+ color: str | None,
2996
+ creating: bool,
2997
+ ) -> JSONObject | None:
2998
+ if not creating and not (str(icon or "").strip() or str(color or "").strip()):
2999
+ return None
3000
+ ok, error_code, message, details = validate_workspace_icon_choice(
3001
+ icon=icon,
3002
+ color=color,
3003
+ require_explicit=creating,
3004
+ disallow_generic=creating,
3005
+ )
3006
+ if ok:
3007
+ return None
3008
+ return _workspace_icon_config_failure(
3009
+ tool_name=tool_name,
3010
+ error_code=error_code or "WORKSPACE_ICON_INVALID",
3011
+ message=message or "invalid workspace icon configuration",
3012
+ details=details,
3013
+ )
3014
+
3015
+
2797
3016
  def _safe_tool_call(
2798
3017
  call,
2799
3018
  *,
@@ -2842,6 +3061,7 @@ def _publicize_package_fields(value):
2842
3061
  "tag_ids_after": "package_ids_after",
2843
3062
  "tag_name": "package_name",
2844
3063
  "tag_icon": "icon",
3064
+ "iconConfig": "icon_config",
2845
3065
  "package_tag_id": "package_id",
2846
3066
  "package_tag_ids": "package_ids",
2847
3067
  "expected_package_tag_id": "expected_package_id",
@@ -2865,7 +3085,7 @@ def _builder_contract_with_apply_output(tool_name: str, contract: JSONObject) ->
2865
3085
  public["output_contract"] = {
2866
3086
  "schema_version": BUILDER_APPLY_SCHEMA_VERSION,
2867
3087
  "preferred_ui_fields": ["operation", "summary", "resources"],
2868
- "resource_fields": ["resource_type", "operation", "status", "id", "key", "name", "ids", "parent", "error_code", "message"],
3088
+ "resource_fields": ["resource_type", "operation", "status", "id", "key", "name", "ids", "parent", "icon_config", "error_code", "message"],
2869
3089
  "legacy_fields_preserved": True,
2870
3090
  }
2871
3091
  return public
@@ -3013,6 +3233,41 @@ def _builder_app_parent(payload: JSONObject) -> JSONObject | None:
3013
3233
  return _builder_parent("app", key=app_key, name=app_name)
3014
3234
 
3015
3235
 
3236
+ def _builder_icon_config(raw_icon: object = None, *, icon: object = None, color: object = None) -> JSONObject | None:
3237
+ raw = str(raw_icon).strip() if raw_icon not in (None, "") else ""
3238
+ explicit_icon = str(icon).strip() if icon not in (None, "") else ""
3239
+ explicit_color = str(color).strip() if color not in (None, "") else ""
3240
+ if raw:
3241
+ if raw.startswith("{") and raw.endswith("}"):
3242
+ config = workspace_icon_config(raw)
3243
+ else:
3244
+ config = {
3245
+ "icon_name": normalize_workspace_icon_name(raw),
3246
+ "icon_color": explicit_color or None,
3247
+ "icon_text": None,
3248
+ "raw": raw,
3249
+ }
3250
+ if any(config.get(key) for key in ("icon_name", "icon_color", "icon_text", "raw")):
3251
+ return config
3252
+ if explicit_icon or explicit_color:
3253
+ return {
3254
+ "icon_name": normalize_workspace_icon_name(explicit_icon) if explicit_icon else None,
3255
+ "icon_color": explicit_color or None,
3256
+ "icon_text": None,
3257
+ "raw": None,
3258
+ }
3259
+ return None
3260
+
3261
+
3262
+ def _builder_container_icon_config(container: object, *, raw_keys: tuple[str, ...], icon_keys: tuple[str, ...] = ("icon",), color_keys: tuple[str, ...] = ("color",)) -> JSONObject | None:
3263
+ if not isinstance(container, dict):
3264
+ return None
3265
+ raw_icon = next((container.get(key) for key in raw_keys if container.get(key) not in (None, "")), None)
3266
+ icon = next((container.get(key) for key in icon_keys if container.get(key) not in (None, "")), None)
3267
+ color = next((container.get(key) for key in color_keys if container.get(key) not in (None, "")), None)
3268
+ return _builder_icon_config(raw_icon, icon=icon, color=color)
3269
+
3270
+
3016
3271
  def _builder_resource(
3017
3272
  *,
3018
3273
  resource_type: str,
@@ -3023,10 +3278,11 @@ def _builder_resource(
3023
3278
  name: object = None,
3024
3279
  ids: JSONObject | None = None,
3025
3280
  parent: JSONObject | None = None,
3281
+ icon_config: JSONObject | None = None,
3026
3282
  error_code: object = None,
3027
3283
  message: object = None,
3028
3284
  ) -> JSONObject:
3029
- return {
3285
+ resource = {
3030
3286
  "resource_type": resource_type,
3031
3287
  "operation": operation,
3032
3288
  "status": status,
@@ -3038,6 +3294,9 @@ def _builder_resource(
3038
3294
  "error_code": str(error_code) if error_code not in (None, "") else None,
3039
3295
  "message": str(message) if message not in (None, "") else None,
3040
3296
  }
3297
+ if icon_config:
3298
+ resource["icon_config"] = icon_config
3299
+ return resource
3041
3300
 
3042
3301
 
3043
3302
  def _builder_app_resource(payload: JSONObject, *, operation: str) -> JSONObject:
@@ -3046,6 +3305,11 @@ def _builder_app_resource(payload: JSONObject, *, operation: str) -> JSONObject:
3046
3305
  operation = "failed"
3047
3306
  app_key = _builder_payload_app_key(payload)
3048
3307
  app_name = _builder_payload_app_name(payload)
3308
+ normalized_args = payload.get("normalized_args") if isinstance(payload.get("normalized_args"), dict) else {}
3309
+ icon_config = (
3310
+ _builder_container_icon_config(payload, raw_keys=("app_icon", "appIcon"))
3311
+ or _builder_container_icon_config(normalized_args, raw_keys=("app_icon", "appIcon", "icon"))
3312
+ )
3049
3313
  return _builder_resource(
3050
3314
  resource_type="app",
3051
3315
  operation=operation,
@@ -3053,6 +3317,7 @@ def _builder_app_resource(payload: JSONObject, *, operation: str) -> JSONObject:
3053
3317
  key=app_key,
3054
3318
  name=app_name,
3055
3319
  ids={"app_key": app_key} if app_key not in (None, "") else {},
3320
+ icon_config=icon_config,
3056
3321
  error_code=payload.get("error_code"),
3057
3322
  message=payload.get("message") if status == "failed" else None,
3058
3323
  )
@@ -3095,6 +3360,11 @@ def _builder_package_resources(payload: JSONObject) -> list[JSONObject]:
3095
3360
  package_name = payload.get("package_name") or payload.get("name")
3096
3361
  status = _builder_status(payload, "success")
3097
3362
  operation = "failed" if status == "failed" else ("created" if bool(payload.get("created")) else "updated")
3363
+ normalized_args = payload.get("normalized_args") if isinstance(payload.get("normalized_args"), dict) else {}
3364
+ icon_config = (
3365
+ _builder_container_icon_config(payload, raw_keys=("icon", "tagIcon", "tag_icon"))
3366
+ or _builder_container_icon_config(normalized_args, raw_keys=("icon", "tagIcon", "tag_icon"))
3367
+ )
3098
3368
  return [
3099
3369
  _builder_resource(
3100
3370
  resource_type="package",
@@ -3104,6 +3374,7 @@ def _builder_package_resources(payload: JSONObject) -> list[JSONObject]:
3104
3374
  key=str(package_id) if package_id not in (None, "") else None,
3105
3375
  name=package_name,
3106
3376
  ids={"package_id": package_id} if package_id not in (None, "") else {},
3377
+ icon_config=icon_config,
3107
3378
  error_code=payload.get("error_code"),
3108
3379
  message=payload.get("message") if status == "failed" else None,
3109
3380
  )
@@ -3121,6 +3392,10 @@ def _builder_schema_resources(payload: JSONObject) -> list[JSONObject]:
3121
3392
  status = _builder_status(item, "success")
3122
3393
  operation = "failed" if status == "failed" else ("created" if bool(item.get("created")) else "updated")
3123
3394
  parent = _builder_parent("app", key=item.get("app_key"), name=item.get("app_name"))
3395
+ icon_config = (
3396
+ _builder_container_icon_config(item, raw_keys=("app_icon", "appIcon", "icon"))
3397
+ or _builder_container_icon_config(item.get("shell_result"), raw_keys=("app_icon", "appIcon", "icon"))
3398
+ )
3124
3399
  resources.append(
3125
3400
  _builder_resource(
3126
3401
  resource_type="app",
@@ -3133,6 +3408,7 @@ def _builder_schema_resources(payload: JSONObject) -> list[JSONObject]:
3133
3408
  **({"package_id": package_id} if package_id not in (None, "") else {}),
3134
3409
  },
3135
3410
  parent=package_parent,
3411
+ icon_config=icon_config,
3136
3412
  error_code=item.get("error_code"),
3137
3413
  message=item.get("message") if status == "failed" else None,
3138
3414
  )
@@ -3145,6 +3421,11 @@ def _builder_schema_resources(payload: JSONObject) -> list[JSONObject]:
3145
3421
  app_key = payload.get("app_key")
3146
3422
  app_name = payload.get("app_name_after") or payload.get("app_name")
3147
3423
  parent = _builder_parent("app", key=app_key, name=app_name)
3424
+ normalized_args = payload.get("normalized_args") if isinstance(payload.get("normalized_args"), dict) else {}
3425
+ icon_config = (
3426
+ _builder_container_icon_config(payload, raw_keys=("app_icon", "appIcon", "icon"))
3427
+ or _builder_container_icon_config(normalized_args, raw_keys=("icon", "app_icon", "appIcon"))
3428
+ )
3148
3429
  resources = [
3149
3430
  _builder_resource(
3150
3431
  resource_type="app",
@@ -3153,6 +3434,7 @@ def _builder_schema_resources(payload: JSONObject) -> list[JSONObject]:
3153
3434
  key=app_key,
3154
3435
  name=app_name,
3155
3436
  ids={"app_key": app_key} if app_key else {},
3437
+ icon_config=icon_config,
3156
3438
  error_code=payload.get("error_code"),
3157
3439
  message=payload.get("message") if status == "failed" else None,
3158
3440
  )
@@ -3254,6 +3536,7 @@ def _builder_view_verification_by_name(payload: JSONObject) -> dict[str, JSONObj
3254
3536
 
3255
3537
 
3256
3538
  def _builder_view_identity(item: object, verification_by_name: dict[str, JSONObject]) -> tuple[str | None, str | None, str, object, object]:
3539
+ verification: JSONObject | None = None
3257
3540
  if isinstance(item, dict):
3258
3541
  name = item.get("name") or item.get("view_name") or item.get("viewName")
3259
3542
  view_key = item.get("view_key") or item.get("viewKey")
@@ -3274,6 +3557,16 @@ def _builder_view_identity(item: object, verification_by_name: dict[str, JSONObj
3274
3557
  matching = verification.get("matching_view_keys")
3275
3558
  if isinstance(matching, list) and matching:
3276
3559
  view_key = matching[0]
3560
+ if name and verification is None:
3561
+ verification = verification_by_name.get(str(name))
3562
+ if isinstance(verification, dict):
3563
+ verification_status = str(verification.get("status") or "").strip()
3564
+ if verification_status in {"removed", "readback_pending"}:
3565
+ status = "readback_pending"
3566
+ if verification_status == "removed":
3567
+ status = "removed"
3568
+ error_code = error_code or verification.get("error_code")
3569
+ message = message or verification.get("message")
3277
3570
  return (
3278
3571
  str(name) if name not in (None, "") else None,
3279
3572
  str(view_key) if view_key not in (None, "") else None,
@@ -3290,16 +3583,17 @@ def _builder_chart_resources(payload: JSONObject) -> list[JSONObject]:
3290
3583
  if not isinstance(item, dict):
3291
3584
  continue
3292
3585
  status = str(item.get("status") or "success")
3293
- operation = _builder_operation(status, fallback="updated")
3586
+ operation = _builder_operation(item.get("operation") or status, fallback="updated")
3294
3587
  if status == "failed":
3295
3588
  operation = "failed"
3589
+ resource_status = "failed" if status == "failed" else ("readback_pending" if status == "readback_pending" else "success")
3296
3590
  chart_id = item.get("chart_id") or item.get("chartId")
3297
3591
  chart_key = item.get("chart_key") or item.get("chartKey")
3298
3592
  resources.append(
3299
3593
  _builder_resource(
3300
3594
  resource_type="chart",
3301
3595
  operation=operation,
3302
- status="failed" if status == "failed" else "success",
3596
+ status=resource_status,
3303
3597
  id_value=chart_id,
3304
3598
  key=chart_key or chart_id,
3305
3599
  name=item.get("name") or item.get("chart_name") or item.get("chartName"),
@@ -3311,7 +3605,7 @@ def _builder_chart_resources(payload: JSONObject) -> list[JSONObject]:
3311
3605
  },
3312
3606
  parent=parent,
3313
3607
  error_code=item.get("error_code"),
3314
- message=item.get("message") if status == "failed" else None,
3608
+ message=item.get("message") if status in {"failed", "readback_pending"} else None,
3315
3609
  )
3316
3610
  )
3317
3611
  return resources
@@ -3352,6 +3646,12 @@ def _builder_portal_resources(payload: JSONObject) -> list[JSONObject]:
3352
3646
  parent = None
3353
3647
  if package_id:
3354
3648
  parent = _builder_parent("package", id_value=package_id, key=package_id)
3649
+ icon_config = (
3650
+ _builder_container_icon_config(payload, raw_keys=("dash_icon", "dashIcon", "icon"))
3651
+ or _builder_container_icon_config(draft_result, raw_keys=("dashIcon", "dash_icon", "icon"))
3652
+ or _builder_container_icon_config(live_result, raw_keys=("dashIcon", "dash_icon", "icon"))
3653
+ or _builder_container_icon_config(normalized_args, raw_keys=("icon", "dash_icon", "dashIcon"))
3654
+ )
3355
3655
  return [
3356
3656
  _builder_resource(
3357
3657
  resource_type="portal",
@@ -3364,6 +3664,7 @@ def _builder_portal_resources(payload: JSONObject) -> list[JSONObject]:
3364
3664
  **({"package_id": package_id} if package_id else {}),
3365
3665
  },
3366
3666
  parent=parent,
3667
+ icon_config=icon_config,
3367
3668
  error_code=payload.get("error_code"),
3368
3669
  message=payload.get("message") if status == "failed" else None,
3369
3670
  )
@@ -3393,7 +3694,7 @@ def _builder_button_resources(payload: JSONObject) -> list[JSONObject]:
3393
3694
  },
3394
3695
  parent=parent,
3395
3696
  error_code=item.get("error_code"),
3396
- message=item.get("message") if status == "failed" else None,
3697
+ message=item.get("message") if status in {"failed", "readback_pending"} else None,
3397
3698
  )
3398
3699
  )
3399
3700
  for item in payload.get("view_configs") or []:
@@ -3413,7 +3714,7 @@ def _builder_button_resources(payload: JSONObject) -> list[JSONObject]:
3413
3714
  },
3414
3715
  parent=parent,
3415
3716
  error_code=item.get("error_code"),
3416
- message=item.get("message") if status == "failed" else None,
3717
+ message=item.get("message") if status in {"failed", "readback_pending"} else None,
3417
3718
  )
3418
3719
  )
3419
3720
  return resources
@@ -3457,7 +3758,7 @@ def _builder_associated_resource_resources(payload: JSONObject) -> list[JSONObje
3457
3758
  },
3458
3759
  parent=parent,
3459
3760
  error_code=item.get("error_code"),
3460
- message=item.get("message") if status == "failed" else None,
3761
+ message=item.get("message") if status in {"failed", "readback_pending"} else None,
3461
3762
  )
3462
3763
  )
3463
3764
  for item in payload.get("view_configs") or []:
@@ -3656,6 +3957,40 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3656
3957
  "tool_name": "chart_get",
3657
3958
  },
3658
3959
  },
3960
+ "workspace_icon_catalog_get": {
3961
+ "allowed_keys": [],
3962
+ "aliases": {},
3963
+ "allowed_values": {
3964
+ "icon": list(WORKSPACE_ICON_NAMES),
3965
+ "color": list(WORKSPACE_ICON_COLORS),
3966
+ "generic_icon_names": list(GENERIC_WORKSPACE_ICON_NAMES),
3967
+ },
3968
+ "execution_notes": [
3969
+ "read this before creating app packages, apps, or portals when choosing supported workspace icons",
3970
+ "the CLI validates icon/color candidates but does not infer business defaults from resource names",
3971
+ "new app/package/portal creation requires explicit non-template icon + color",
3972
+ ],
3973
+ "minimal_example": {
3974
+ "profile": "default",
3975
+ },
3976
+ },
3977
+ "package_list": {
3978
+ "allowed_keys": ["trial_status", "query"],
3979
+ "aliases": {"trialStatus": "trial_status", "keyword": "query"},
3980
+ "allowed_values": {"trial_status": ["all"]},
3981
+ "execution_notes": [
3982
+ "lists app packages visible to the current builder profile by calling backend GET /tag?trialStatus=...",
3983
+ "query is applied locally to package_id/tag_id/package_name/tag_name after /tag returns",
3984
+ "does not fall back to app list because app list cannot represent empty packages, duplicate package names, or package-level permissions",
3985
+ "returns package_id/package_name plus compatible tag_id/tag_name; use package_get for package detail before editing",
3986
+ "permission failures are returned as PACKAGE_LIST_FAILED with backend transport details",
3987
+ ],
3988
+ "minimal_example": {
3989
+ "profile": "default",
3990
+ "trial_status": "all",
3991
+ "query": "产品研发",
3992
+ },
3993
+ },
3659
3994
  "package_get": {
3660
3995
  "allowed_keys": ["package_id"],
3661
3996
  "aliases": {"packageId": "package_id"},
@@ -3680,9 +4015,17 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3680
4015
  "iconColor": "color",
3681
4016
  "allowDetach": "allow_detach",
3682
4017
  },
3683
- "allowed_values": deepcopy(_VISIBILITY_ALLOWED_VALUES),
4018
+ "allowed_values": {
4019
+ **deepcopy(_VISIBILITY_ALLOWED_VALUES),
4020
+ "workspace_icon.icon_names": list(WORKSPACE_ICON_NAMES),
4021
+ "workspace_icon.icon_colors": list(WORKSPACE_ICON_COLORS),
4022
+ "workspace_icon.generic_icon_names": list(GENERIC_WORKSPACE_ICON_NAMES),
4023
+ },
3684
4024
  "execution_notes": [
3685
4025
  "create or update package metadata, visibility, grouping, and ordering in one call",
4026
+ "creating a package requires explicit icon + color; icon=template is blocked because it is too generic",
4027
+ "updating a package preserves existing icon/color when omitted; explicit icon/color values are still validated",
4028
+ "call workspace_icon_catalog_get or `qingflow --json builder icon catalog` for supported icon/color candidates",
3686
4029
  "metadata keys omitted on update are preserved",
3687
4030
  "package_id maps internally to backend tagId; do not use tag_id in public calls",
3688
4031
  "items is a full package layout tree; omitting existing app/portal items is blocked unless allow_detach=true",
@@ -3706,7 +4049,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3706
4049
  "profile": "default",
3707
4050
  "package_name": "项目管理",
3708
4051
  "create_if_missing": True,
3709
- "icon": "files-folder",
4052
+ "icon": "briefcase",
3710
4053
  "color": "azure",
3711
4054
  "visibility": deepcopy(_VISIBILITY_WORKSPACE_EXAMPLE),
3712
4055
  },
@@ -3819,7 +4162,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3819
4162
  },
3820
4163
  "execution_notes": [
3821
4164
  "use this read-only tool before button writes when an agent needs a supported icon or color choice",
3822
- "current frontend only supports template icons and template colors from this catalog",
4165
+ "current frontend only supports button icons and button colors from this catalog",
3823
4166
  "text/icon color is unified through text_color; there is no separate icon_color",
3824
4167
  ],
3825
4168
  "minimal_example": {
@@ -3921,6 +4264,8 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3921
4264
  "default placements are header and detail; header maps to frontend top buttons",
3922
4265
  "placement=list configures backend INSIDE row/list buttons; header maps to TOP and detail maps to DETAIL",
3923
4266
  "remove_buttons supports button_id or exact unique button_text",
4267
+ "after a remove_buttons DELETE is sent, the tool verifies deletion by single button_id readback; removed[] returns delete_executed, readback_status, and safe_to_retry_delete=false",
4268
+ "if a removed button returns readback_status=unavailable or still_exists, treat the result as readback pending and do not blindly repeat the delete",
3924
4269
  "all operations share one edit context and publish after at least one write succeeds; there is no draft-only mode for this tool",
3925
4270
  "background_color and text_color cannot both be white",
3926
4271
  ],
@@ -4063,6 +4408,8 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4063
4408
  "match_mappings.source_field accepts source schema fields plus system fields 数据ID(-17) and 编号(0); match_mappings compiles to backend matchRules",
4064
4409
  "do not write raw match_rules unless preserving a legacy backend config; match_mappings and match_rules are mutually exclusive",
4065
4410
  "client_key only lets a view_config reference a resource created earlier in the same apply call through associated_item_refs; it is not persisted and cannot deduplicate later apply calls",
4411
+ "remove_associated_item_ids sends DELETE and verifies deletion with one associated-resource pool readback because the backend has no confirmed single-item GET; removed[] returns delete_executed, readback_status, and safe_to_retry_delete=false",
4412
+ "if an associated resource delete returns readback_status=unavailable or still_exists, treat the result as readback pending and do not blindly repeat the delete",
4066
4413
  "this tool publishes after at least one write succeeds; there is no draft-only mode",
4067
4414
  "visible=false hides the associated-resource area without clearing previous selected ids; visible=true with limit_type=all shows the whole app-level pool",
4068
4415
  ],
@@ -4129,17 +4476,22 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4129
4476
  "field.type": [member.value for member in PublicFieldType],
4130
4477
  "field.relation_mode": [member.value for member in PublicRelationMode],
4131
4478
  "field_type_ids": sorted(FIELD_TYPE_ID_ALIASES.keys()),
4479
+ "workspace_icon.icon_names": list(WORKSPACE_ICON_NAMES),
4480
+ "workspace_icon.icon_colors": list(WORKSPACE_ICON_COLORS),
4481
+ "workspace_icon.generic_icon_names": list(GENERIC_WORKSPACE_ICON_NAMES),
4132
4482
  **deepcopy(_VISIBILITY_ALLOWED_VALUES),
4133
4483
  },
4134
4484
  "execution_notes": [
4135
4485
  "create mode may set visibility for the new app; edit mode may update visibility on an existing app",
4486
+ "create mode should include explicit non-template icon + color; apply mode enforces this before writing",
4487
+ "call workspace_icon_catalog_get or `qingflow --json builder icon catalog` for supported icon/color candidates",
4136
4488
  *_VISIBILITY_EXECUTION_NOTES,
4137
4489
  ],
4138
4490
  "minimal_example": {
4139
4491
  "profile": "default",
4140
4492
  "app_name": "研发项目管理",
4141
4493
  "package_id": 1001,
4142
- "icon": "template",
4494
+ "icon": "briefcase",
4143
4495
  "color": "emerald",
4144
4496
  "visibility": deepcopy(_VISIBILITY_WORKSPACE_EXAMPLE),
4145
4497
  "create_if_missing": True,
@@ -4239,6 +4591,10 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4239
4591
  "multi-app relation fields may use target_app_ref to point at another apps[].client_key; the tool creates/resolves app shells first and compiles it to target_app_key",
4240
4592
  "multi-app mode is not transactional; read created_app_keys and apps[].status before retrying, and retry only failed app items",
4241
4593
  "create mode defaults new app visibility to workspace/not when visibility is omitted; edit mode preserves current visibility when omitted",
4594
+ "create mode requires explicit icon + color; icon=template is blocked because it is too generic",
4595
+ "multi-app create mode requires each new app item to include a distinct non-template icon and a valid color",
4596
+ "edit mode preserves existing icon/color when omitted; explicit icon/color values are still validated",
4597
+ "call workspace_icon_catalog_get or `qingflow --json builder icon catalog` for supported icon/color candidates",
4242
4598
  *_VISIBILITY_EXECUTION_NOTES,
4243
4599
  "update_fields is the field-level partial update path; it reads current form schema and preserves untouched field config",
4244
4600
  "multiple relation fields are backend-risky; read verification.relation_field_limit_verified and warnings before declaring the schema stable",
@@ -4263,7 +4619,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4263
4619
  "profile": "default",
4264
4620
  "app_name": "研发项目管理",
4265
4621
  "package_id": 1001,
4266
- "icon": "template",
4622
+ "icon": "briefcase",
4267
4623
  "color": "emerald",
4268
4624
  "visibility": deepcopy(_VISIBILITY_WORKSPACE_EXAMPLE),
4269
4625
  "create_if_missing": True,
@@ -4284,6 +4640,8 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4284
4640
  {
4285
4641
  "client_key": "employee",
4286
4642
  "app_name": "员工花名册",
4643
+ "icon": "business-personalcard",
4644
+ "color": "emerald",
4287
4645
  "add_fields": [
4288
4646
  {"name": "员工名称", "type": "text", "as_data_title": True},
4289
4647
  {"name": "员工照片", "type": "attachment", "as_data_cover": True},
@@ -4292,6 +4650,8 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4292
4650
  {
4293
4651
  "client_key": "worklog",
4294
4652
  "app_name": "工时表",
4653
+ "icon": "clock",
4654
+ "color": "blue",
4295
4655
  "add_fields": [
4296
4656
  {"name": "工时标题", "type": "text", "as_data_title": True},
4297
4657
  {
@@ -4631,6 +4991,8 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4631
4991
  "filters are saved fixed filters that apply when the view opens; query_conditions configure the frontend query panel and only apply after a user enters query values",
4632
4992
  "upsert_views[].query_conditions.rows is a layout matrix of field names; it is compiled to backend queryCondition queIds",
4633
4993
  "use patch_views for partial parameter replacement on existing views; the tool reads current config, merges patch_views[].set/unset, then submits the backend full-save payload internally",
4994
+ "remove_views accepts a raw view_key or an exact unique view name; after DELETE the tool verifies deletion by single view_key readback, not by a full app view list",
4995
+ "deleted views return verification.by_view[].delete_executed, readback_status, and safe_to_retry_delete=false; if readback is pending, do not blindly repeat the delete",
4634
4996
  "new views created by app_views_apply default associated report/view display to visible with limit_type=all; existing views preserve their current associated display unless patch_views/upsert_views explicitly changes associated_resources",
4635
4997
  "associated report/view resource pool and per-view selected resources are configured through app_associated_resources_apply; app_views_apply only keeps legacy associated_resources input compatible",
4636
4998
  "for multi-value operators such as in, pass values as a list; value may also be used as an alias when it already contains a list",
@@ -4757,6 +5119,8 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4757
5119
  "filters are saved fixed filters that apply when the view opens; query_conditions configure the frontend query panel and only apply after a user enters query values",
4758
5120
  "upsert_views[].query_conditions.rows is a layout matrix of field names; it is compiled to backend queryCondition queIds",
4759
5121
  "use patch_views for partial parameter replacement on existing views; the public update mode is patch even though the backend save is still a full view payload",
5122
+ "remove_views accepts a raw view_key or an exact unique view name; after DELETE the tool verifies deletion by single view_key readback, not by a full app view list",
5123
+ "deleted views return verification.by_view[].delete_executed, readback_status, and safe_to_retry_delete=false; if readback is pending, do not blindly repeat the delete",
4760
5124
  "new views created by app_views_apply default associated report/view display to visible with limit_type=all; existing views preserve their current associated display unless patch_views/upsert_views explicitly changes associated_resources",
4761
5125
  "associated report/view resource pool and per-view selected resources are configured through app_associated_resources_apply; app_views_apply keeps legacy associated_resources input compatible but it is no longer the recommended public contract",
4762
5126
  "for multi-value operators such as in, pass values as a list; value may also be used as an alias when it already contains a list",
@@ -4850,6 +5214,8 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4850
5214
  "execution_notes": [
4851
5215
  "returns compact current field configuration for one app",
4852
5216
  "use this before app_schema_apply when you need exact field definitions",
5217
+ "also returns chart_fields from QingBI datasource fields; app_charts_apply field selectors should use chart_fields because record/schema-visible fields and QingBI fields are not the same schema",
5218
+ "chart_fields[].field_id supports field_<queId> selectors, while chart_fields[].bi_field_id is the raw QingBI fieldId accepted by report configs",
4853
5219
  "subtable fields include nested subfields using the same compact field shape",
4854
5220
  ],
4855
5221
  "minimal_example": {
@@ -4971,6 +5337,12 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4971
5337
  "successful create results must return a real backend chart_id",
4972
5338
  "upsert_charts[].visibility compiles to QingBI base visibleAuth only",
4973
5339
  "visibility-only updates keep the existing chart config and do not rewrite rawDataConfigDTO.authInfo",
5340
+ "chart dimension/metric/filter/query fields are resolved from app_get_fields.chart_fields (QingBI datasource fields), not record schema or form-only fields",
5341
+ "system fields such as 申请人/申请时间/编号 are usable only when they appear in chart_fields; otherwise app_charts_apply returns CHART_FIELD_NOT_IN_QINGBI_SCHEMA",
5342
+ "low-frequency chart types have local prevalidation: gauge requires 0 dimensions and 2 non-duplicated metrics; histogram requires at most 1 dimension and exactly 1 plain numeric metric",
5343
+ "chart rule failures return chart_results[].diagnostics with rule_code, expected, actual, offending_fields, and next_action; backend 81002/81005 are translated when possible",
5344
+ "remove_chart_ids deletes by chart_id and verifies each deleted chart with single chart_id readback; pure delete does not read the full chart list",
5345
+ "if delete readback is unavailable or still finds the chart, chart_results[] returns delete_executed=true, readback_status, and safe_to_retry_delete=false; do not blindly repeat delete",
4974
5346
  *_VISIBILITY_EXECUTION_NOTES,
4975
5347
  ],
4976
5348
  "minimal_example": {
@@ -5014,9 +5386,10 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5014
5386
  },
5015
5387
  },
5016
5388
  "portal_apply": {
5017
- "allowed_keys": ["dash_key", "dash_name", "package_id", "publish", "sections", "visibility", "auth", "icon", "color", "hide_copyright", "dash_global_config", "config"],
5389
+ "allowed_keys": ["dash_key", "dash_name", "name", "package_id", "publish", "sections", "pages", "payload", "layout_preset", "visibility", "auth", "icon", "color", "hide_copyright", "dash_global_config", "config"],
5018
5390
  "aliases": {
5019
5391
  "packageId": "package_id",
5392
+ "name": "dash_name",
5020
5393
  "sourceType": "source_type",
5021
5394
  "chartRef": "chart_ref",
5022
5395
  "viewRef": "view_ref",
@@ -5029,20 +5402,34 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5029
5402
  "viewRef": "view_ref",
5030
5403
  "dashStyleConfigBO": "dash_style_config",
5031
5404
  },
5032
- "allowed_values": {"section.source_type": ["chart", "view", "grid", "filter", "text", "link"], **deepcopy(_VISIBILITY_ALLOWED_VALUES)},
5033
- "execution_notes": [
5034
- "use exactly one resource mode",
5035
- "update mode: dash_key",
5036
- "create mode: package_id + dash_name",
5037
- "portal_apply uses replace semantics for sections",
5038
- "when editing an existing portal, sections may be omitted to update only base info such as visibility, icon, or package",
5039
- "portal section-level patch is not exposed; supplying sections means full sections replacement",
5040
- "remove a section by omitting it from the new sections list",
5041
- "package_id is required when creating a new portal",
5042
- "publish=false only guarantees draft and base-info updates; it does not claim live has changed",
5405
+ "allowed_values": {
5406
+ "section.source_type": ["chart", "view", "grid", "filter", "text", "link"],
5407
+ "workspace_icon.icon_names": list(WORKSPACE_ICON_NAMES),
5408
+ "workspace_icon.icon_colors": list(WORKSPACE_ICON_COLORS),
5409
+ "workspace_icon.generic_icon_names": list(GENERIC_WORKSPACE_ICON_NAMES),
5410
+ **deepcopy(_VISIBILITY_ALLOWED_VALUES),
5411
+ },
5412
+ "execution_notes": [
5413
+ "use exactly one resource mode",
5414
+ "update mode: dash_key",
5415
+ "create mode: package_id + dash_name",
5416
+ "create mode requires explicit icon + color; icon=template is blocked because it is too generic",
5417
+ "edit mode preserves existing icon/color when omitted; explicit icon/color values are still validated",
5418
+ "call workspace_icon_catalog_get or `qingflow --json builder icon catalog` for supported icon/color candidates",
5419
+ "portal_apply uses replace semantics for sections",
5420
+ "when editing an existing portal, sections may be omitted to update only base info such as visibility, icon, or package",
5421
+ "portal section-level patch is not exposed; supplying sections means full sections replacement",
5422
+ "remove a section by omitting it from the new sections list",
5423
+ "package_id is required when creating a new portal",
5424
+ "publish=false only guarantees draft and base-info updates; it does not claim live has changed",
5043
5425
  "chart_ref resolves by chart_id first, then exact unique chart_name",
5044
5426
  "view_ref resolves by view_key first, then exact unique view_name",
5427
+ "pc layout uses a 24-column grid; mobile layout uses a 6-column grid",
5428
+ "if unsure about layout, omit position or use layout_preset=auto/dashboard_2col/dashboard_3col",
5429
+ "two-column pc layout should use x=0/12 with cols=12; three-column pc layout should use x=0/8/16 with cols=8",
5430
+ "x=0/6 with cols=6 only occupies the left half of the pc portal and triggers PORTAL_LAYOUT_HALF_WIDTH",
5045
5431
  "position.pc/mobile is the canonical portal layout shape",
5432
+ "compat payload accepts name -> dash_name and single pages[0].components -> sections",
5046
5433
  "visibility is the canonical public auth shape; auth is kept only as a deprecated compatibility alias",
5047
5434
  "passing visibility and auth together is rejected as VISIBILITY_CONFLICT",
5048
5435
  *_VISIBILITY_EXECUTION_NOTES,
@@ -5051,7 +5438,10 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5051
5438
  "profile": "default",
5052
5439
  "dash_name": "经营门户",
5053
5440
  "package_id": 1001,
5441
+ "icon": "view-grid",
5442
+ "color": "blue",
5054
5443
  "publish": True,
5444
+ "layout_preset": "dashboard_2col",
5055
5445
  "visibility": deepcopy(_VISIBILITY_WORKSPACE_EXAMPLE),
5056
5446
  "sections": [
5057
5447
  {
@@ -5065,6 +5455,23 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5065
5455
  }
5066
5456
  ],
5067
5457
  },
5458
+ "compat_payload_example": {
5459
+ "name": "经营门户",
5460
+ "package_id": 1001,
5461
+ "layout_preset": "dashboard_2col",
5462
+ "pages": [
5463
+ {
5464
+ "title": "经营总览",
5465
+ "components": [
5466
+ {
5467
+ "title": "销售趋势",
5468
+ "source_type": "chart",
5469
+ "chart_ref": {"app_key": "APP_KEY", "chart_id": "CHART_ID"},
5470
+ }
5471
+ ],
5472
+ }
5473
+ ],
5474
+ },
5068
5475
  "minimal_section_example": {
5069
5476
  "title": "订单概览",
5070
5477
  "source_type": "view",