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

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.
@@ -2564,6 +2564,7 @@ class AiBuilderFacade:
2564
2564
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="custom_buttons", error=api_error),
2565
2565
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
2566
2566
  ))
2567
+ app_name = self._read_app_name_for_builder_output(profile=profile, app_key=app_key)
2567
2568
 
2568
2569
  existing_by_id = {
2569
2570
  button_id: item
@@ -3013,6 +3014,7 @@ class AiBuilderFacade:
3013
3014
  },
3014
3015
  "verified": verified,
3015
3016
  "app_key": app_key,
3017
+ "app_name": app_name,
3016
3018
  "mode": "apply",
3017
3019
  "created": created,
3018
3020
  "updated": updated,
@@ -3663,6 +3665,7 @@ class AiBuilderFacade:
3663
3665
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="associated_resources", error=api_error),
3664
3666
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
3665
3667
  ))
3668
+ app_name = self._read_app_name_for_builder_output(profile=profile, app_key=app_key)
3666
3669
 
3667
3670
  existing_by_id = _associated_resource_index(existing_resources)
3668
3671
  blocking_issues: list[dict[str, Any]] = []
@@ -3671,7 +3674,7 @@ class AiBuilderFacade:
3671
3674
  client_key_to_patch: dict[str, AssociatedResourceUpsertPatch] = {}
3672
3675
  client_key_to_id: dict[str, int] = {}
3673
3676
  used_client_keys: set[str] = set()
3674
- force_update_resource_ids: set[int] = set()
3677
+ resolved_associated_item_refs: dict[str, list[int]] = {}
3675
3678
 
3676
3679
  upsert_resources = list(request.upsert_resources)
3677
3680
  if request.patch_resources:
@@ -3690,11 +3693,6 @@ class AiBuilderFacade:
3690
3693
  )
3691
3694
  )
3692
3695
  upsert_resources.extend(expanded_resources)
3693
- force_update_resource_ids.update(
3694
- item_id
3695
- for item_id in (_coerce_positive_int(patch.associated_item_id) for patch in expanded_resources)
3696
- if item_id is not None
3697
- )
3698
3696
  normalized_args["upsert_resources"] = [patch.model_dump(mode="json") for patch in upsert_resources]
3699
3697
  normalized_args["patch_results"] = patch_results
3700
3698
 
@@ -3732,14 +3730,9 @@ class AiBuilderFacade:
3732
3730
  touched_ids.add(associated_item_id)
3733
3731
  if client_key:
3734
3732
  client_key_to_id[client_key] = associated_item_id
3735
- operation = (
3736
- "update"
3737
- if associated_item_id in force_update_resource_ids
3738
- or _associated_resource_patch_has_match_config(patch)
3739
- else "unchanged"
3740
- if _associated_resource_matches_patch(existing_by_id[associated_item_id], patch)
3741
- else "update"
3742
- )
3733
+ has_match_config = _associated_resource_patch_has_match_config(patch)
3734
+ identity_matches = _associated_resource_matches_patch(existing_by_id[associated_item_id], patch)
3735
+ operation = "update" if has_match_config or not identity_matches else "unchanged"
3743
3736
  upsert_ops.append({"operation": operation, "associated_item_id": associated_item_id, "patch": patch, "index": index})
3744
3737
  continue
3745
3738
  matches = [
@@ -3778,34 +3771,35 @@ class AiBuilderFacade:
3778
3771
  else:
3779
3772
  upsert_ops.append({"operation": "create", "associated_item_id": None, "patch": patch, "index": index})
3780
3773
 
3781
- remove_ids = [
3782
- item_id
3783
- for item_id in (_coerce_positive_int(raw_id) for raw_id in request.remove_associated_item_ids)
3784
- if item_id is not None
3785
- ]
3774
+ remove_ids: list[int] = []
3786
3775
  for raw_id in request.remove_associated_item_ids:
3787
- item_id = _coerce_positive_int(raw_id)
3776
+ item_id, issue = _resolve_associated_resource_selector(
3777
+ raw_id,
3778
+ existing_resources=existing_resources,
3779
+ existing_by_id=existing_by_id,
3780
+ reason_path="remove_associated_item_ids",
3781
+ )
3788
3782
  if item_id is None:
3789
- blocking_issues.append({"error_code": "ASSOCIATED_RESOURCE_NOT_FOUND", "reason_path": "remove_associated_item_ids", "received": raw_id})
3783
+ blocking_issues.append(issue or {"error_code": "ASSOCIATED_RESOURCE_NOT_FOUND", "reason_path": "remove_associated_item_ids", "received": raw_id})
3790
3784
  continue
3791
- if item_id not in existing_by_id:
3792
- blocking_issues.append(_associated_resource_not_found_issue("remove_associated_item_ids", item_id, existing_by_id))
3793
- elif item_id in touched_ids:
3785
+ remove_ids.append(item_id)
3786
+ if item_id in touched_ids:
3794
3787
  blocking_issues.append(_duplicate_associated_resource_issue("remove_associated_item_ids", item_id))
3795
3788
  else:
3796
3789
  touched_ids.add(item_id)
3797
3790
 
3798
- reorder_ids = [
3799
- item_id
3800
- for item_id in (_coerce_positive_int(raw_id) for raw_id in request.reorder_associated_item_ids)
3801
- if item_id is not None
3802
- ]
3791
+ reorder_ids: list[int] = []
3803
3792
  for raw_id in request.reorder_associated_item_ids:
3804
- item_id = _coerce_positive_int(raw_id)
3793
+ item_id, issue = _resolve_associated_resource_selector(
3794
+ raw_id,
3795
+ existing_resources=existing_resources,
3796
+ existing_by_id=existing_by_id,
3797
+ reason_path="reorder_associated_item_ids",
3798
+ )
3805
3799
  if item_id is None:
3806
- blocking_issues.append({"error_code": "ASSOCIATED_RESOURCE_NOT_FOUND", "reason_path": "reorder_associated_item_ids", "received": raw_id})
3807
- elif item_id not in existing_by_id:
3808
- blocking_issues.append(_associated_resource_not_found_issue("reorder_associated_item_ids", item_id, existing_by_id))
3800
+ blocking_issues.append(issue or {"error_code": "ASSOCIATED_RESOURCE_NOT_FOUND", "reason_path": "reorder_associated_item_ids", "received": raw_id})
3801
+ continue
3802
+ reorder_ids.append(item_id)
3809
3803
 
3810
3804
  for index, view_config in enumerate(request.view_configs):
3811
3805
  refs = [str(ref or "").strip() for ref in view_config.associated_item_refs if str(ref or "").strip()]
@@ -3819,21 +3813,26 @@ class AiBuilderFacade:
3819
3813
  "message": "associated_item_refs must reference client_key values from upsert_resources in the same apply call",
3820
3814
  }
3821
3815
  )
3822
- invalid_ids = [
3823
- item_id
3824
- for item_id in view_config.associated_item_ids
3825
- if _coerce_positive_int(item_id) is None or _coerce_positive_int(item_id) not in existing_by_id
3826
- ]
3827
- if invalid_ids:
3828
- blocking_issues.append(
3829
- {
3830
- "error_code": "ASSOCIATED_RESOURCE_NOT_FOUND",
3831
- "reason_path": f"view_configs[{index}].associated_item_ids",
3832
- "invalid_associated_item_ids": invalid_ids,
3833
- "available_associated_item_ids": sorted(existing_by_id),
3834
- "message": "view_configs.associated_item_ids must use app-level associated_item_id values from app_get",
3835
- }
3816
+ resolved_ids: list[int] = []
3817
+ for raw_id in view_config.associated_item_ids:
3818
+ item_id, issue = _resolve_associated_resource_selector(
3819
+ raw_id,
3820
+ existing_resources=existing_resources,
3821
+ existing_by_id=existing_by_id,
3822
+ reason_path=f"view_configs[{index}].associated_item_ids",
3836
3823
  )
3824
+ if item_id is None:
3825
+ blocking_issues.append(
3826
+ issue
3827
+ or {
3828
+ "error_code": "ASSOCIATED_RESOURCE_NOT_FOUND",
3829
+ "reason_path": f"view_configs[{index}].associated_item_ids",
3830
+ "received": raw_id,
3831
+ }
3832
+ )
3833
+ continue
3834
+ resolved_ids.append(item_id)
3835
+ resolved_associated_item_refs[f"view_configs[{index}].associated_item_ids"] = resolved_ids
3837
3836
  raw_limit_type = str(view_config.limit_type or ("all" if view_config.visible else "")).strip().lower()
3838
3837
  if view_config.visible and raw_limit_type and raw_limit_type not in {"all", "select"}:
3839
3838
  blocking_issues.append(
@@ -3851,6 +3850,8 @@ class AiBuilderFacade:
3851
3850
  for issue in resource_match_issues
3852
3851
  ]
3853
3852
  )
3853
+ if resolved_associated_item_refs:
3854
+ normalized_args["resolved_associated_item_refs"] = deepcopy(resolved_associated_item_refs)
3854
3855
 
3855
3856
  if blocking_issues:
3856
3857
  return finalize(
@@ -4008,12 +4009,33 @@ class AiBuilderFacade:
4008
4009
  resources_after = []
4009
4010
 
4010
4011
  for index, view_config in enumerate(request.view_configs):
4012
+ resolved_view_config_ids = resolved_associated_item_refs.get(f"view_configs[{index}].associated_item_ids", [])
4013
+ view_name = str(view_config.view_key or "").strip()
4014
+ missing_ref_ids = [
4015
+ str(ref or "").strip()
4016
+ for ref in view_config.associated_item_refs
4017
+ if str(ref or "").strip() and str(ref or "").strip() not in client_key_to_id
4018
+ ]
4019
+ if missing_ref_ids:
4020
+ failed.append(
4021
+ {
4022
+ "operation": "view_config",
4023
+ "index": index,
4024
+ "view_key": view_config.view_key,
4025
+ "view_name": view_name,
4026
+ "status": "failed",
4027
+ "error_code": "ASSOCIATED_RESOURCE_REF_UNRESOLVED",
4028
+ "missing_refs": missing_ref_ids,
4029
+ "message": "view_config references associated resources that were not successfully created or resolved; skipped view config write to avoid clearing existing associations",
4030
+ }
4031
+ )
4032
+ continue
4011
4033
  selected_ids = [
4012
4034
  item_id
4013
4035
  for item_id in (
4014
4036
  _coerce_positive_int(raw_id)
4015
4037
  for raw_id in [
4016
- *view_config.associated_item_ids,
4038
+ *resolved_view_config_ids,
4017
4039
  *[client_key_to_id.get(str(ref or "").strip()) for ref in view_config.associated_item_refs],
4018
4040
  ]
4019
4041
  )
@@ -4024,19 +4046,21 @@ class AiBuilderFacade:
4024
4046
  available_resources=resources_after,
4025
4047
  )
4026
4048
  if issues:
4027
- failed.append({"operation": "view_config", "index": index, "view_key": view_config.view_key, "status": "failed", "issues": issues})
4049
+ failed.append({"operation": "view_config", "index": index, "view_key": view_config.view_key, "view_name": view_name, "status": "failed", "issues": issues})
4028
4050
  continue
4029
4051
  try:
4030
4052
  write_executed = True
4031
4053
  self._update_view_associated_resources_config(profile=profile, view_key=view_config.view_key, associated_resources_payload=view_payload)
4032
4054
  try:
4033
4055
  config = self.views.view_get_config(profile=profile, viewgraph_key=view_config.view_key).get("result") or {}
4056
+ view_name = _extract_view_name(config if isinstance(config, dict) else {}) or view_name
4034
4057
  actual_config = _extract_view_associated_resources_config(config if isinstance(config, dict) else {}, available_resources=resources_after)
4035
4058
  verified_config = expected_config is not None and _associated_resources_config_matches(expected_config, actual_config)
4036
4059
  if not verified_config and expected_config is not None:
4037
4060
  self._update_view_associated_resources_config(profile=profile, view_key=view_config.view_key, associated_resources_payload=view_payload)
4038
4061
  refreshed_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4039
4062
  refreshed_config = self.views.view_get_config(profile=profile, viewgraph_key=view_config.view_key).get("result") or {}
4063
+ view_name = _extract_view_name(refreshed_config if isinstance(refreshed_config, dict) else {}) or view_name
4040
4064
  actual_config = _extract_view_associated_resources_config(
4041
4065
  refreshed_config if isinstance(refreshed_config, dict) else {},
4042
4066
  available_resources=refreshed_resources,
@@ -4045,10 +4069,10 @@ class AiBuilderFacade:
4045
4069
  except (QingflowApiError, RuntimeError):
4046
4070
  actual_config = {}
4047
4071
  verified_config = False
4048
- view_config_results.append({"index": index, "view_key": view_config.view_key, "status": "success" if verified_config else "partial_success", "associated_resources_verified": verified_config, "expected": expected_config, "actual": actual_config})
4072
+ view_config_results.append({"index": index, "view_key": view_config.view_key, "view_name": view_name, "status": "success" if verified_config else "partial_success", "associated_resources_verified": verified_config, "expected": expected_config, "actual": actual_config})
4049
4073
  except (QingflowApiError, RuntimeError) as error:
4050
4074
  api_error = _coerce_api_error(error)
4051
- failed.append({"operation": "view_config", "index": index, "view_key": view_config.view_key, "status": "failed", "error_code": "VIEW_ASSOCIATED_RESOURCES_WRITE_FAILED", "message": api_error.message, "transport_error": _transport_error_payload(api_error)})
4075
+ failed.append({"operation": "view_config", "index": index, "view_key": view_config.view_key, "view_name": view_name, "status": "failed", "error_code": "VIEW_ASSOCIATED_RESOURCES_WRITE_FAILED", "message": api_error.message, "transport_error": _transport_error_payload(api_error)})
4052
4076
 
4053
4077
  final_resources: list[dict[str, Any]] = []
4054
4078
  readback_failed = False
@@ -4115,6 +4139,7 @@ class AiBuilderFacade:
4115
4139
  str(index): _summarize_compiled_match_rules(rules)
4116
4140
  for index, rules in compiled_resource_match_rules.items()
4117
4141
  },
4142
+ "resolved_associated_item_refs": resolved_associated_item_refs,
4118
4143
  },
4119
4144
  "request_id": None,
4120
4145
  "suggested_next_call": None if verified else {"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
@@ -4123,6 +4148,7 @@ class AiBuilderFacade:
4123
4148
  "verification": {"associated_resources_verified": pool_verified, "associated_resource_view_configs_verified": view_configs_verified, "readback_loaded": not readback_failed},
4124
4149
  "verified": verified,
4125
4150
  "app_key": app_key,
4151
+ "app_name": app_name,
4126
4152
  "mode": "apply",
4127
4153
  "created": created,
4128
4154
  "updated": updated,
@@ -4601,9 +4627,18 @@ class AiBuilderFacade:
4601
4627
  "associated_resources": len(associated_resources),
4602
4628
  "custom_buttons": len(custom_buttons),
4603
4629
  }
4630
+ app_name = str(
4631
+ base_result.get("formTitle")
4632
+ or base_result.get("title")
4633
+ or base_result.get("appName")
4634
+ or base_result.get("name")
4635
+ or app_key
4636
+ ).strip() or app_key
4604
4637
  response = AppReadSummaryResponse(
4605
4638
  app_key=app_key,
4606
- title=base_result.get("formTitle"),
4639
+ app_name=app_name,
4640
+ name=app_name,
4641
+ title=app_name,
4607
4642
  app_icon=str(base_result.get("appIcon") or "").strip() or None,
4608
4643
  visibility=_public_visibility_from_member_auth(base_result.get("auth")),
4609
4644
  tag_ids=_coerce_int_list(base_result.get("tagIds")),
@@ -5430,7 +5465,7 @@ class AiBuilderFacade:
5430
5465
  for index, patch, config in semantic_patches:
5431
5466
  config_payload = config.model_dump(mode="json", exclude_none=True)
5432
5467
  reason_base = f"upsert_buttons[{index}].trigger_add_data_config"
5433
- if config.que_relation:
5468
+ if "que_relation" in config.model_fields_set:
5434
5469
  issues.append(
5435
5470
  {
5436
5471
  "error_code": "MIXED_CUSTOM_BUTTON_MAPPING_MODES",
@@ -5734,11 +5769,12 @@ class AiBuilderFacade:
5734
5769
  )
5735
5770
  return {"verified": False, "write_executed": False, "write_succeeded": False, "view_configs": results, "failed": failed, "warnings": warnings}
5736
5771
 
5737
- view_keys = {
5738
- _extract_view_key(view)
5772
+ existing_views_by_key = {
5773
+ _extract_view_key(view): view
5739
5774
  for view in (existing_views if isinstance(existing_views, list) else [])
5740
5775
  if isinstance(view, dict) and _extract_view_key(view)
5741
5776
  }
5777
+ view_keys = set(existing_views_by_key)
5742
5778
  button_inventory: dict[int, dict[str, Any]] = {}
5743
5779
  for item in [*existing_buttons, *readback_buttons]:
5744
5780
  if not isinstance(item, dict):
@@ -5770,6 +5806,7 @@ class AiBuilderFacade:
5770
5806
  try:
5771
5807
  current_response = self.views.view_get_config(profile=profile, viewgraph_key=view_key)
5772
5808
  current_config = current_response.get("result") if isinstance(current_response.get("result"), dict) else {}
5809
+ view_name = _extract_view_name(current_config) or _extract_view_name(existing_views_by_key.get(view_key) or {}) or view_key
5773
5810
  except (QingflowApiError, RuntimeError) as error:
5774
5811
  api_error = _coerce_api_error(error)
5775
5812
  issue = {
@@ -5778,6 +5815,7 @@ class AiBuilderFacade:
5778
5815
  "status": "failed",
5779
5816
  "error_code": "VIEW_CONFIG_READ_FAILED",
5780
5817
  "view_key": view_key,
5818
+ "view_name": _extract_view_name(existing_views_by_key.get(view_key) or {}) or view_key,
5781
5819
  "message": api_error.message,
5782
5820
  "transport_error": _transport_error_payload(api_error),
5783
5821
  }
@@ -5868,6 +5906,7 @@ class AiBuilderFacade:
5868
5906
  "status": "failed",
5869
5907
  "error_code": "INSIDE_BUTTON_BACKEND_UNSUPPORTED",
5870
5908
  "view_key": view_key,
5909
+ "view_name": view_name,
5871
5910
  "message": "backend rejected inside/list-button placement; header/detail placements were retried without inside buttons",
5872
5911
  "backend_message": api_error.message,
5873
5912
  "transport_error": _transport_error_payload(api_error),
@@ -5880,9 +5919,10 @@ class AiBuilderFacade:
5880
5919
  "index": config_index,
5881
5920
  "operation": "view_config",
5882
5921
  "status": "failed",
5883
- "error_code": "VIEW_BUTTON_CONFIG_WRITE_FAILED",
5884
- "view_key": view_key,
5885
- "message": fallback_api_error.message,
5922
+ "error_code": "VIEW_BUTTON_CONFIG_WRITE_FAILED",
5923
+ "view_key": view_key,
5924
+ "view_name": view_name,
5925
+ "message": fallback_api_error.message,
5886
5926
  "transport_error": _transport_error_payload(fallback_api_error),
5887
5927
  "initial_error": _transport_error_payload(api_error),
5888
5928
  }
@@ -5897,6 +5937,7 @@ class AiBuilderFacade:
5897
5937
  "status": "failed",
5898
5938
  "error_code": "INSIDE_BUTTON_BACKEND_UNSUPPORTED",
5899
5939
  "view_key": view_key,
5940
+ "view_name": view_name,
5900
5941
  "message": "backend rejected inside/list-button placement",
5901
5942
  "backend_message": api_error.message,
5902
5943
  "transport_error": _transport_error_payload(api_error),
@@ -5913,6 +5954,7 @@ class AiBuilderFacade:
5913
5954
  "status": "failed",
5914
5955
  "error_code": "VIEW_BUTTON_CONFIG_WRITE_FAILED",
5915
5956
  "view_key": view_key,
5957
+ "view_name": view_name,
5916
5958
  "message": api_error.message,
5917
5959
  "transport_error": _transport_error_payload(api_error),
5918
5960
  }
@@ -5942,6 +5984,7 @@ class AiBuilderFacade:
5942
5984
  "operation": "view_config",
5943
5985
  "status": "success" if verified else ("partial_success" if unsupported_list_issue is not None else "unverified"),
5944
5986
  "view_key": view_key,
5987
+ "view_name": view_name,
5945
5988
  "mode": "replace" if replace_existing else "merge",
5946
5989
  "buttons_configured": len(new_dtos),
5947
5990
  "view_buttons_verified": verified,
@@ -5964,6 +6007,7 @@ class AiBuilderFacade:
5964
6007
  "operation": "view_config",
5965
6008
  "status": "unverified",
5966
6009
  "view_key": view_key,
6010
+ "view_name": view_name,
5967
6011
  "mode": "replace" if replace_existing else "merge",
5968
6012
  "buttons_configured": len(new_dtos),
5969
6013
  "view_buttons_verified": None,
@@ -7044,6 +7088,7 @@ class AiBuilderFacade:
7044
7088
  },
7045
7089
  "app_key": target.app_key,
7046
7090
  "app_icon": str(resolved.get("app_icon") or visual_result.get("app_icon") or "").strip() or None,
7091
+ "app_name": str(visual_result.get("app_name_after") or target.app_name),
7047
7092
  "app_name_before": str(visual_result.get("app_name_before") or target.app_name),
7048
7093
  "app_name_after": str(visual_result.get("app_name_after") or target.app_name),
7049
7094
  "app_base_updated": bool(visual_result.get("updated")),
@@ -7284,7 +7329,8 @@ class AiBuilderFacade:
7284
7329
  actual_app_name = str(base_info.get("formTitle") or effective_app_name).strip() or effective_app_name
7285
7330
  actual_app_icon = str(base_info.get("appIcon") or visual_result.get("app_icon") or "").strip() or None
7286
7331
  expected_app_icon = str(visual_result.get("app_icon") or "").strip() or None
7287
- app_base_verified = actual_app_name == effective_app_name and actual_app_icon == expected_app_icon
7332
+ app_icon_verified = True if expected_app_icon is None else actual_app_icon == expected_app_icon
7333
+ app_base_verified = actual_app_name == effective_app_name and app_icon_verified
7288
7334
  verified = app_base_verified and relation_target_metadata_verified
7289
7335
  response = {
7290
7336
  "status": "success" if verified else "partial_success",
@@ -7308,6 +7354,7 @@ class AiBuilderFacade:
7308
7354
  },
7309
7355
  "app_key": target.app_key,
7310
7356
  "app_icon": str(visual_result.get("app_icon") or "").strip() or None,
7357
+ "app_name": effective_app_name,
7311
7358
  "app_name_before": str(visual_result.get("app_name_before") or target.app_name),
7312
7359
  "app_name_after": effective_app_name,
7313
7360
  "app_base_updated": bool(visual_result.get("updated")),
@@ -7505,6 +7552,7 @@ class AiBuilderFacade:
7505
7552
  },
7506
7553
  "app_key": target.app_key,
7507
7554
  "app_icon": str(visual_result.get("app_icon") or resolved.get("app_icon") or "").strip() or None,
7555
+ "app_name": effective_app_name,
7508
7556
  "app_name_before": str(visual_result.get("app_name_before") or target.app_name),
7509
7557
  "app_name_after": effective_app_name,
7510
7558
  "app_base_updated": bool(visual_result.get("updated")),
@@ -7514,6 +7562,13 @@ class AiBuilderFacade:
7514
7562
  "updated": updated,
7515
7563
  "removed": removed,
7516
7564
  },
7565
+ "field_diff_details": _schema_field_diff_details(
7566
+ added=added,
7567
+ updated=updated,
7568
+ removed=removed,
7569
+ before_fields=original_fields,
7570
+ after_fields=current_fields,
7571
+ ),
7517
7572
  "verified": False,
7518
7573
  "tag_ids_after": [],
7519
7574
  "package_attached": None,
@@ -7533,6 +7588,13 @@ class AiBuilderFacade:
7533
7588
  try:
7534
7589
  verified = self.app_read(profile=profile, app_key=target.app_key, include_raw=False)
7535
7590
  verified_field_names = {field["name"] for field in verified["schema"]["fields"]}
7591
+ response["field_diff_details"] = _schema_field_diff_details(
7592
+ added=added,
7593
+ updated=updated,
7594
+ removed=removed,
7595
+ before_fields=original_fields,
7596
+ after_fields=cast(list[dict[str, Any]], verified["schema"]["fields"]),
7597
+ )
7536
7598
  verification_ok = all(name in verified_field_names for name in added + updated) and all(name not in verified_field_names for name in removed)
7537
7599
  data_display_verification = _verify_data_display_readback(
7538
7600
  form_settings=verified.get("form_settings"),
@@ -7564,7 +7626,8 @@ class AiBuilderFacade:
7564
7626
  actual_app_name = str(base_info.get("formTitle") or effective_app_name).strip() or effective_app_name
7565
7627
  actual_app_icon = str(base_info.get("appIcon") or visual_result.get("app_icon") or "").strip() or None
7566
7628
  expected_app_icon = str(visual_result.get("app_icon") or "").strip() or None
7567
- app_base_verified = actual_app_name == effective_app_name and actual_app_icon == expected_app_icon
7629
+ app_icon_verified = True if expected_app_icon is None else actual_app_icon == expected_app_icon
7630
+ app_base_verified = actual_app_name == effective_app_name and app_icon_verified
7568
7631
  except (QingflowApiError, RuntimeError) as error:
7569
7632
  base_error = _coerce_api_error(error)
7570
7633
  if verification_error is None:
@@ -7658,6 +7721,7 @@ class AiBuilderFacade:
7658
7721
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="schema", error=api_error),
7659
7722
  suggested_next_call={"tool_name": "app_get_layout", "arguments": {"profile": profile, "app_key": app_key}},
7660
7723
  ))
7724
+ app_name = str(schema_result.get("formTitle") or schema_result.get("title") or schema_result.get("appName") or app_key).strip() or app_key
7661
7725
  parsed = _parse_schema(schema_result)
7662
7726
  current_fields = parsed["fields"]
7663
7727
  requested_sections, missing_selectors = _resolve_layout_sections_to_names(requested_sections, current_fields)
@@ -7740,6 +7804,7 @@ class AiBuilderFacade:
7740
7804
  "warnings": [],
7741
7805
  "verification": {"layout_verified": True, "layout_summary_verified": True},
7742
7806
  "app_key": app_key,
7807
+ "app_name": app_name,
7743
7808
  "layout_diff": {
7744
7809
  "mode": mode.value,
7745
7810
  "replaced": mode == LayoutApplyMode.replace,
@@ -7828,6 +7893,7 @@ class AiBuilderFacade:
7828
7893
  "warnings": [],
7829
7894
  "verification": {"layout_verified": False, "layout_summary_verified": False, "layout_read_unavailable": True},
7830
7895
  "app_key": app_key,
7896
+ "app_name": app_name,
7831
7897
  "layout_diff": {
7832
7898
  "mode": mode.value,
7833
7899
  "replaced": mode == LayoutApplyMode.replace,
@@ -7884,6 +7950,7 @@ class AiBuilderFacade:
7884
7950
  "warnings": warnings,
7885
7951
  "verification": {"layout_verified": layout_verified, "layout_summary_verified": layout_summary_verified},
7886
7952
  "app_key": app_key,
7953
+ "app_name": app_name,
7887
7954
  "layout_diff": {
7888
7955
  "mode": mode.value,
7889
7956
  "replaced": mode == LayoutApplyMode.replace,
@@ -7946,6 +8013,7 @@ class AiBuilderFacade:
7946
8013
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="workflow", error=api_error),
7947
8014
  suggested_next_call={"tool_name": "app_get_flow", "arguments": {"profile": profile, "app_key": app_key}},
7948
8015
  ))
8016
+ app_name = str(base.get("formTitle") or base.get("title") or base.get("appName") or app_key).strip() or app_key
7949
8017
  entity = _entity_spec_from_app(base_info=base, schema=schema, views=None)
7950
8018
  current_fields = _parse_schema(schema)["fields"]
7951
8019
  normalized_nodes, resolution_issues = self._normalize_flow_nodes(profile=profile, current_fields=current_fields, nodes=nodes)
@@ -8113,6 +8181,7 @@ class AiBuilderFacade:
8113
8181
  "workflow_read_unavailable": verified_nodes_unavailable,
8114
8182
  },
8115
8183
  "app_key": app_key,
8184
+ "app_name": app_name,
8116
8185
  "flow_diff": {"mode": "replace", "node_count": desired_node_count},
8117
8186
  "verified": workflow_verified,
8118
8187
  }
@@ -8188,6 +8257,7 @@ class AiBuilderFacade:
8188
8257
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="views", error=api_error),
8189
8258
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
8190
8259
  ))
8260
+ app_name = str(base.get("formTitle") or base.get("title") or base.get("appName") or "").strip() or None
8191
8261
  existing_views = existing_views or []
8192
8262
  existing_by_key: dict[str, dict[str, Any]] = {}
8193
8263
  existing_by_name: dict[str, list[dict[str, Any]]] = {}
@@ -8295,9 +8365,15 @@ class AiBuilderFacade:
8295
8365
  )
8296
8366
  )
8297
8367
  removed: list[str] = []
8368
+ removed_keys: set[str] = set()
8298
8369
  view_results: list[dict[str, Any]] = []
8299
- for name in remove_views:
8300
- matches = existing_by_name.get(name, [])
8370
+ failed_views: list[dict[str, Any]] = []
8371
+ for selector in remove_views:
8372
+ selector_text = str(selector or "").strip()
8373
+ if not selector_text:
8374
+ continue
8375
+ key_match = existing_by_key.get(selector_text)
8376
+ matches = [key_match] if isinstance(key_match, dict) else existing_by_name.get(selector_text, [])
8301
8377
  if len(matches) > 1:
8302
8378
  return _failed(
8303
8379
  "AMBIGUOUS_VIEW",
@@ -8305,7 +8381,7 @@ class AiBuilderFacade:
8305
8381
  normalized_args=normalized_args,
8306
8382
  details={
8307
8383
  "app_key": app_key,
8308
- "view_name": name,
8384
+ "view_name": selector_text,
8309
8385
  "matches": [
8310
8386
  {"name": _extract_view_name(view), "view_key": _extract_view_key(view), "type": _normalize_view_type_name(view.get("viewgraphType") or view.get("type"))}
8311
8387
  for view in matches
@@ -8313,21 +8389,36 @@ class AiBuilderFacade:
8313
8389
  },
8314
8390
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
8315
8391
  )
8392
+ if not matches:
8393
+ failed_view = {
8394
+ "name": selector_text,
8395
+ "view_key": selector_text,
8396
+ "type": None,
8397
+ "status": "failed",
8398
+ "error_code": "VIEW_NOT_FOUND",
8399
+ "message": "remove_views item did not match an existing view name or view_key",
8400
+ }
8401
+ failed_views.append(failed_view)
8402
+ view_results.append(deepcopy(failed_view))
8403
+ continue
8316
8404
  if len(matches) == 1:
8317
8405
  key = _extract_view_key(matches[0])
8406
+ removed_name = _extract_view_name(matches[0]) or selector_text
8318
8407
  self.views.view_delete(profile=profile, viewgraph_key=key)
8319
- removed.append(name)
8320
- existing_by_name.pop(name, None)
8321
- existing_by_key.pop(key, None)
8322
- view_results.append({"name": name, "view_key": key, "type": None, "status": "removed"})
8408
+ removed.append(removed_name)
8409
+ if key:
8410
+ removed_keys.add(key)
8411
+ existing_by_key.pop(key, None)
8412
+ existing_by_name.pop(removed_name, None)
8413
+ view_results.append({"name": removed_name, "view_key": key, "type": None, "status": "removed"})
8323
8414
  created: list[str] = []
8324
8415
  updated: list[str] = []
8325
- failed_views: list[dict[str, Any]] = []
8326
8416
  existing_view_list = [
8327
8417
  view
8328
8418
  for view in (existing_views if isinstance(existing_views, list) else [])
8329
8419
  if isinstance(view, dict)
8330
- and _extract_view_name(view) not in remove_views
8420
+ and _extract_view_name(view) not in removed
8421
+ and _extract_view_key(view) not in removed_keys
8331
8422
  ]
8332
8423
  for ordinal, patch in enumerate(upsert_views, start=1):
8333
8424
  apply_columns = _resolve_view_visible_field_names(patch)
@@ -8540,6 +8631,11 @@ class AiBuilderFacade:
8540
8631
  if not existing_key and query_condition_payload_for_apply is None:
8541
8632
  query_condition_payload_for_apply = _empty_view_query_conditions_payload()
8542
8633
  expected_query_conditions_for_verify = _normalize_view_query_conditions_for_compare(query_condition_payload_for_apply)
8634
+ if not existing_key and associated_resources_payload_for_apply is None:
8635
+ associated_resources_payload_for_apply, _, _ = _build_view_associated_resources_payload(
8636
+ associated_resources={"visible": True, "limit_type": "all", "associated_item_ids": []},
8637
+ available_resources=associated_resources,
8638
+ )
8543
8639
  try:
8544
8640
  view_auth_override = (
8545
8641
  self._compile_visibility_to_member_auth(profile=profile, visibility=patch.visibility)
@@ -9149,9 +9245,11 @@ class AiBuilderFacade:
9149
9245
  verification_by_view.append(
9150
9246
  {
9151
9247
  "name": name,
9248
+ "view_key": item.get("view_key"),
9152
9249
  "type": item.get("type"),
9153
9250
  "status": "removed",
9154
- "present_in_readback": None if verified_views_unavailable else name not in verified_names,
9251
+ "present_in_readback": None if verified_views_unavailable else name in verified_names,
9252
+ "removed_verified": None if verified_views_unavailable else name not in verified_names,
9155
9253
  }
9156
9254
  )
9157
9255
  else:
@@ -9172,7 +9270,7 @@ class AiBuilderFacade:
9172
9270
  view_filters_verified = verified and not filter_readback_pending and not filter_mismatches
9173
9271
  view_query_conditions_verified = verified and not query_condition_readback_pending and not query_condition_mismatches
9174
9272
  view_associated_resources_verified = verified and not associated_resource_readback_pending and not associated_resource_mismatches
9175
- view_buttons_verified = verified and not button_readback_pending and not button_mismatches
9273
+ view_buttons_verified = verified and not button_readback_pending and not button_mismatches and not custom_button_readback_pending
9176
9274
  noop = not created and not updated and not removed
9177
9275
  if failed_views:
9178
9276
  successful_changes = bool(created or updated or removed)
@@ -9241,6 +9339,7 @@ class AiBuilderFacade:
9241
9339
  "custom_button_readback_pending_entries": deepcopy(custom_button_readback_pending_entries),
9242
9340
  },
9243
9341
  "app_key": app_key,
9342
+ "app_name": app_name,
9244
9343
  "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": failed_views},
9245
9344
  "verified": verified and view_filters_verified and view_query_conditions_verified and view_associated_resources_verified and view_buttons_verified,
9246
9345
  }
@@ -9313,6 +9412,7 @@ class AiBuilderFacade:
9313
9412
  "by_view": verification_by_view,
9314
9413
  },
9315
9414
  "app_key": app_key,
9415
+ "app_name": app_name,
9316
9416
  "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": []},
9317
9417
  "verified": all_verified,
9318
9418
  }
@@ -9338,6 +9438,7 @@ class AiBuilderFacade:
9338
9438
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
9339
9439
  )
9340
9440
  tag_ids_before = _coerce_int_list(base_before.get("tagIds"))
9441
+ app_name_before = str(base_before.get("formTitle") or base_before.get("title") or base_before.get("appName") or "").strip() or None
9341
9442
  already_published = bool(base_before.get("appPublishStatus") in {1, 2})
9342
9443
  package_already_attached = None if not expected_package_tag_id else expected_package_tag_id in tag_ids_before
9343
9444
  try:
@@ -9368,6 +9469,7 @@ class AiBuilderFacade:
9368
9469
  "warnings": [],
9369
9470
  "verification": {"published": True, "package_attached": package_already_attached, "views_ok": True},
9370
9471
  "app_key": app_key,
9472
+ "app_name": app_name_before,
9371
9473
  "published": True,
9372
9474
  "package_attached": package_already_attached,
9373
9475
  "tag_ids_after": tag_ids_before,
@@ -9400,6 +9502,7 @@ class AiBuilderFacade:
9400
9502
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
9401
9503
  )
9402
9504
  tag_ids_after = _coerce_int_list(base.get("tagIds"))
9505
+ app_name_after = str(base.get("formTitle") or base.get("title") or base.get("appName") or app_name_before or "").strip() or None
9403
9506
  package_attached = None if not expected_package_tag_id else expected_package_tag_id in tag_ids_after
9404
9507
  try:
9405
9508
  views, views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
@@ -9440,6 +9543,7 @@ class AiBuilderFacade:
9440
9543
  "warnings": warnings,
9441
9544
  "verification": {"published": bool(base.get("appPublishStatus") in {1, 2}), "package_attached": package_attached, "views_ok": views_ok, "views_read_unavailable": views_unavailable},
9442
9545
  "app_key": app_key,
9546
+ "app_name": app_name_after,
9443
9547
  "published": bool(base.get("appPublishStatus") in {1, 2}),
9444
9548
  "package_attached": package_attached,
9445
9549
  "tag_ids_after": tag_ids_after,
@@ -9577,6 +9681,7 @@ class AiBuilderFacade:
9577
9681
  if resolved_outcome is not None:
9578
9682
  permission_outcomes.append(resolved_outcome)
9579
9683
  app_key = str(app_result.get("app_key") or request.app_key)
9684
+ app_name = str(app_result.get("app_name") or "").strip() or None
9580
9685
  permission_outcome = self._guard_app_permission(
9581
9686
  profile=profile,
9582
9687
  app_key=app_key,
@@ -9657,6 +9762,13 @@ class AiBuilderFacade:
9657
9762
 
9658
9763
  for patch in upsert_charts:
9659
9764
  try:
9765
+ dataset_source = _chart_patch_dataset_source_type(patch)
9766
+ if dataset_source:
9767
+ raise ValueError(
9768
+ "app_charts_apply only creates or edits app-source QingBI reports with dataSourceType=qingflow; "
9769
+ f"dataset report source '{dataset_source}' is not supported for creation/update yet. "
9770
+ "Create the dataset report in QingBI first, then attach it with app_associated_resources_apply using report_source='dataset'."
9771
+ )
9660
9772
  config_update_requested = _chart_patch_updates_chart_config(patch)
9661
9773
  chart_visible_auth = (
9662
9774
  self._compile_visibility_to_chart_visible_auth(profile=profile, visibility=patch.visibility)
@@ -9680,6 +9792,13 @@ class AiBuilderFacade:
9680
9792
  existing_name = str((existing or {}).get("chartName") or "").strip()
9681
9793
  existing_type = _normalize_backend_chart_type((existing or {}).get("chartType"))
9682
9794
  target_type = _map_public_chart_type_to_backend(patch.chart_type)
9795
+ existing_source_type = _chart_item_dataset_source_type(existing or {})
9796
+ if existing_source_type:
9797
+ raise ValueError(
9798
+ "app_charts_apply only creates or edits app-source QingBI reports with dataSourceType=qingflow; "
9799
+ f"existing chart '{chart_id or patch.name}' uses dataset report source '{existing_source_type}' and is not supported for update yet. "
9800
+ "Update it in QingBI directly, then attach the existing report with app_associated_resources_apply using report_source='dataset'."
9801
+ )
9683
9802
  if existing is None:
9684
9803
  temp_chart_id = str(patch.chart_id or f"mcp_{uuid4().hex[:16]}")
9685
9804
  create_payload = {
@@ -9908,6 +10027,7 @@ class AiBuilderFacade:
9908
10027
  "chart_list_source": readback_list_source or existing_chart_list_source,
9909
10028
  },
9910
10029
  "app_key": app_key,
10030
+ "app_name": app_name,
9911
10031
  "chart_results": chart_results,
9912
10032
  "verified": False if failed_items else verified,
9913
10033
  })
@@ -9936,6 +10056,7 @@ class AiBuilderFacade:
9936
10056
  "chart_list_source": existing_chart_list_source if noop else readback_list_source,
9937
10057
  },
9938
10058
  "app_key": app_key,
10059
+ "app_name": app_name,
9939
10060
  "chart_results": chart_results,
9940
10061
  "verified": result_verified,
9941
10062
  })
@@ -10221,6 +10342,9 @@ class AiBuilderFacade:
10221
10342
  "publish_failed": publish_failed,
10222
10343
  },
10223
10344
  "dash_key": dash_key,
10345
+ "dash_name": update_payload.get("dashName"),
10346
+ "package_id": target_package_tag_id,
10347
+ "created": creating,
10224
10348
  "published": published,
10225
10349
  "verified": verified,
10226
10350
  "draft_result": draft_result,
@@ -10313,6 +10437,49 @@ class AiBuilderFacade:
10313
10437
  verification["published"] = False
10314
10438
  return response
10315
10439
  publish_result = self._publish_current_edit_version(profile=profile, app_key=app_key, edit_version_no=edit_version_no)
10440
+ if publish_result.get("status") == "failed" and publish_result.get("error_code") == "APP_EDIT_LOCKED":
10441
+ details = response.get("details")
10442
+ if not isinstance(details, dict):
10443
+ details = {}
10444
+ response["details"] = details
10445
+ suggested = publish_result.get("suggested_next_call")
10446
+ release_args = suggested.get("arguments") if isinstance(suggested, dict) else {}
10447
+ if isinstance(release_args, dict):
10448
+ release_result = self.app_release_edit_lock_if_mine(
10449
+ profile=profile,
10450
+ app_key=app_key,
10451
+ lock_owner_email=str(release_args.get("lock_owner_email") or ""),
10452
+ lock_owner_name=str(release_args.get("lock_owner_name") or ""),
10453
+ )
10454
+ details["edit_lock_release_result"] = release_result
10455
+ if release_result.get("status") == "success":
10456
+ retry_result = self._publish_current_edit_version(profile=profile, app_key=app_key, edit_version_no=None)
10457
+ details["publish_retry_after_edit_lock_release"] = retry_result
10458
+ publish_result = retry_result
10459
+ response["retried_after_edit_lock_release"] = True
10460
+ response["edit_lock_released"] = True
10461
+ elif release_result.get("error_code") == "EDIT_LOCK_OWNER_UNKNOWN" and edit_version_no is not None:
10462
+ try:
10463
+ self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
10464
+ release_result = {
10465
+ "status": "success",
10466
+ "error_code": None,
10467
+ "recoverable": False,
10468
+ "message": "released current apply edit context before publish retry",
10469
+ "details": {"app_key": app_key, "edit_version_no": edit_version_no, "release_strategy": "current_apply_edit_context"},
10470
+ "verification": {"released": True},
10471
+ "app_key": app_key,
10472
+ "verified": True,
10473
+ }
10474
+ details["edit_lock_release_result"] = release_result
10475
+ retry_result = self._publish_current_edit_version(profile=profile, app_key=app_key, edit_version_no=None)
10476
+ details["publish_retry_after_edit_lock_release"] = retry_result
10477
+ publish_result = retry_result
10478
+ response["retried_after_edit_lock_release"] = True
10479
+ response["edit_lock_released"] = True
10480
+ except (QingflowApiError, RuntimeError) as error:
10481
+ api_error = _coerce_api_error(error)
10482
+ details["current_apply_edit_context_release_error"] = _transport_error_payload(api_error)
10316
10483
  response["publish_result"] = publish_result
10317
10484
  response["published"] = bool(publish_result.get("published"))
10318
10485
  verification["published"] = bool(publish_result.get("published"))
@@ -10337,6 +10504,14 @@ class AiBuilderFacade:
10337
10504
  "schema_source": schema_source,
10338
10505
  }
10339
10506
 
10507
+ def _read_app_name_for_builder_output(self, *, profile: str, app_key: str) -> str | None:
10508
+ try:
10509
+ base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
10510
+ except (QingflowApiError, RuntimeError):
10511
+ return None
10512
+ app_name = str(base.get("formTitle") or base.get("title") or base.get("appName") or "").strip()
10513
+ return app_name or None
10514
+
10340
10515
  def _sync_system_view_apply_config(
10341
10516
  self,
10342
10517
  *,
@@ -12192,6 +12367,8 @@ def _extract_edit_lock_owner(message: str) -> JSONObject:
12192
12367
  patterns = [
12193
12368
  r"应用已被\s*(?P<name>[^((]+?)\s*[((](?P<email>[^))]+)[))]\s*编辑",
12194
12369
  r"edited by\s*(?P<name>[^<(]+?)\s*<(?P<email>[^>]+)>",
12370
+ r"active editor\s+(?P<email>[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})",
12371
+ r"(?P<email>[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})",
12195
12372
  ]
12196
12373
  for pattern in patterns:
12197
12374
  match = re.search(pattern, text)
@@ -12337,13 +12514,16 @@ def _bi_field_id_for_field(*, app_key: str, field: dict[str, Any], qingbi_fields
12337
12514
 
12338
12515
 
12339
12516
  def _map_public_chart_type_to_backend(chart_type: PublicChartType) -> str:
12340
- return {
12517
+ aliases = {
12341
12518
  PublicChartType.target: "indicator",
12519
+ PublicChartType.indicator: "indicator",
12342
12520
  PublicChartType.pie: "pie",
12343
12521
  PublicChartType.bar: "bar",
12344
12522
  PublicChartType.line: "line",
12345
12523
  PublicChartType.table: "detail",
12346
- }[chart_type]
12524
+ PublicChartType.detail: "detail",
12525
+ }
12526
+ return aliases.get(chart_type, chart_type.value)
12347
12527
 
12348
12528
 
12349
12529
  _CHART_PARTIAL_PATCH_KEY_ALIASES = {
@@ -12579,6 +12759,25 @@ def _build_public_metric_fields(
12579
12759
  return metrics or [_default_public_total_metric()]
12580
12760
 
12581
12761
 
12762
+ def _split_axis_metric_fields(metrics: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
12763
+ if not metrics:
12764
+ default_metric = _default_public_total_metric()
12765
+ return [deepcopy(default_metric)], [deepcopy(default_metric)]
12766
+ if len(metrics) == 1:
12767
+ return [deepcopy(metrics[0])], [deepcopy(metrics[0])]
12768
+ return [deepcopy(metrics[0])], [deepcopy(metrics[1])]
12769
+
12770
+
12771
+ def _two_gauge_metric_fields(metrics: list[dict[str, Any]], *, requested_metric_count: int) -> list[dict[str, Any]]:
12772
+ if not metrics:
12773
+ return []
12774
+ if len(metrics) == 1:
12775
+ if requested_metric_count <= 0:
12776
+ return [deepcopy(metrics[0])]
12777
+ return [deepcopy(metrics[0]), _default_public_total_metric()]
12778
+ return [deepcopy(metrics[0]), deepcopy(metrics[1])]
12779
+
12780
+
12582
12781
  def _build_public_chart_filter_matrix(
12583
12782
  rules: list[Any],
12584
12783
  *,
@@ -12647,23 +12846,28 @@ def _build_public_chart_config_payload(
12647
12846
  for selector in list(config.pop("query_condition_field_ids", []) or []):
12648
12847
  field = _resolve_public_field(selector, field_lookup=field_lookup)
12649
12848
  query_condition_field_ids.append(_bi_field_id_for_field(app_key=app_key, field=field, qingbi_fields_by_id=qingbi_fields_by_id))
12849
+ backend_chart_type = _map_public_chart_type_to_backend(patch.chart_type)
12850
+ if backend_chart_type == "gauge" and not patch.indicator_field_ids and "selectedMetrics" not in config:
12851
+ raise ValueError("gauge charts require at least one indicator_field_ids value; pass one metric and the CLI will pair it with 数据总量")
12852
+ selected_dimensions = _build_public_dimension_fields(
12853
+ patch.dimension_field_ids,
12854
+ app_key=app_key,
12855
+ field_lookup=field_lookup,
12856
+ qingbi_fields_by_id=qingbi_fields_by_id,
12857
+ )
12858
+ selected_metrics = _build_public_metric_fields(
12859
+ patch.indicator_field_ids,
12860
+ app_key=app_key,
12861
+ field_lookup=field_lookup,
12862
+ qingbi_fields_by_id=qingbi_fields_by_id,
12863
+ aggregate=aggregate,
12864
+ )
12650
12865
  payload: dict[str, Any] = {
12651
12866
  "chartName": patch.name,
12652
- "chartType": _map_public_chart_type_to_backend(patch.chart_type),
12867
+ "chartType": backend_chart_type,
12653
12868
  "dataSource": {"dataSourceId": app_key, "dataSourceType": "qingflow"},
12654
- "selectedDimensions": _build_public_dimension_fields(
12655
- patch.dimension_field_ids,
12656
- app_key=app_key,
12657
- field_lookup=field_lookup,
12658
- qingbi_fields_by_id=qingbi_fields_by_id,
12659
- ),
12660
- "selectedMetrics": _build_public_metric_fields(
12661
- patch.indicator_field_ids,
12662
- app_key=app_key,
12663
- field_lookup=field_lookup,
12664
- qingbi_fields_by_id=qingbi_fields_by_id,
12665
- aggregate=aggregate,
12666
- ),
12869
+ "selectedDimensions": selected_dimensions,
12870
+ "selectedMetrics": selected_metrics,
12667
12871
  "beforeAggregationFilterMatrix": before_filters,
12668
12872
  "afterAggregationFilterMatrix": after_filters,
12669
12873
  "chartStyleConfigs": deepcopy(config.pop("chartStyleConfigs", [])),
@@ -12679,6 +12883,23 @@ def _build_public_chart_config_payload(
12679
12883
  "queryConditionStatus": bool(config.pop("queryConditionStatus", bool(query_condition_field_ids))),
12680
12884
  "queryConditionExact": bool(config.pop("queryConditionExact", False)),
12681
12885
  }
12886
+ if backend_chart_type == "summary":
12887
+ payload.pop("selectedDimensions", None)
12888
+ payload.setdefault("xDimensions", deepcopy(selected_dimensions))
12889
+ payload.setdefault("yDimensions", [])
12890
+ elif backend_chart_type == "scatter":
12891
+ x_metrics, y_metrics = _split_axis_metric_fields(selected_metrics)
12892
+ payload.pop("selectedMetrics", None)
12893
+ payload.setdefault("xMetrics", x_metrics)
12894
+ payload.setdefault("yMetrics", y_metrics)
12895
+ elif backend_chart_type == "dualaxes":
12896
+ left_metrics, right_metrics = _split_axis_metric_fields(selected_metrics)
12897
+ payload.pop("selectedMetrics", None)
12898
+ payload.setdefault("leftMetrics", left_metrics)
12899
+ payload.setdefault("rightMetrics", right_metrics)
12900
+ elif backend_chart_type == "gauge":
12901
+ payload["selectedDimensions"] = []
12902
+ payload["selectedMetrics"] = _two_gauge_metric_fields(selected_metrics, requested_metric_count=len(patch.indicator_field_ids or []))
12682
12903
  for key in (
12683
12904
  "selectedTime",
12684
12905
  "xDimensions",
@@ -12701,6 +12922,46 @@ def _chart_patch_updates_chart_config(patch: ChartUpsertPatch) -> bool:
12701
12922
  return bool({"dimension_field_ids", "indicator_field_ids", "filters", "config"} & explicit_fields)
12702
12923
 
12703
12924
 
12925
+ def _chart_patch_dataset_source_type(patch: ChartUpsertPatch) -> str:
12926
+ config = patch.config if isinstance(patch.config, dict) else {}
12927
+ candidates = [
12928
+ config.get("dataSourceType"),
12929
+ config.get("data_source_type"),
12930
+ ]
12931
+ data_source = config.get("dataSource")
12932
+ if isinstance(data_source, dict):
12933
+ candidates.extend([data_source.get("dataSourceType"), data_source.get("data_source_type"), data_source.get("type")])
12934
+ data_source = config.get("data_source")
12935
+ if isinstance(data_source, dict):
12936
+ candidates.extend([data_source.get("dataSourceType"), data_source.get("data_source_type"), data_source.get("type")])
12937
+ for candidate in candidates:
12938
+ normalized = str(candidate or "").strip().lower()
12939
+ if not normalized:
12940
+ continue
12941
+ if normalized not in {"qingflow", "app"}:
12942
+ return normalized
12943
+ return ""
12944
+
12945
+
12946
+ def _chart_item_dataset_source_type(item: dict[str, Any]) -> str:
12947
+ candidates = [
12948
+ item.get("dataSourceType"),
12949
+ item.get("data_source_type"),
12950
+ item.get("sourceType"),
12951
+ item.get("source_type"),
12952
+ ]
12953
+ data_source = item.get("dataSource")
12954
+ if isinstance(data_source, dict):
12955
+ candidates.extend([data_source.get("dataSourceType"), data_source.get("data_source_type"), data_source.get("type")])
12956
+ for candidate in candidates:
12957
+ normalized = str(candidate or "").strip().lower()
12958
+ if not normalized:
12959
+ continue
12960
+ if normalized not in {"qingflow", "app", "bi_qingflow"}:
12961
+ return normalized
12962
+ return ""
12963
+
12964
+
12704
12965
  def _build_public_portal_base_payload(
12705
12966
  *,
12706
12967
  dash_name: str,
@@ -12771,16 +13032,33 @@ def _normalize_backend_chart_type(value: Any) -> str:
12771
13032
  "12": "dualaxes",
12772
13033
  "13": "map",
12773
13034
  "14": "timeline",
13035
+ "15": "area",
13036
+ "16": "stacked_column",
13037
+ "17": "stacked_bar",
13038
+ "18": "rose",
13039
+ "19": "stacked_area",
13040
+ "20": "pct_stack_col",
13041
+ "21": "pct_stack_bar",
13042
+ "22": "pct_stack_area",
13043
+ "23": "waterfall",
13044
+ "24": "gauge",
13045
+ "25": "heatmap",
13046
+ "26": "histogram",
13047
+ "27": "treemap",
12774
13048
  }
12775
- return by_code.get(raw, raw.lower())
13049
+ aliases = {
13050
+ "percent_stacked_column": "pct_stack_col",
13051
+ "percent_stacked_bar": "pct_stack_bar",
13052
+ "percent_stacked_area": "pct_stack_area",
13053
+ }
13054
+ normalized = by_code.get(raw, raw.lower())
13055
+ return aliases.get(normalized, normalized)
12776
13056
 
12777
13057
 
12778
13058
  def _public_chart_type_from_backend(value: Any) -> str:
12779
13059
  normalized = _normalize_backend_chart_type(value)
12780
13060
  return {
12781
13061
  "indicator": PublicChartType.target.value,
12782
- "summary": PublicChartType.target.value,
12783
- "columnar": PublicChartType.bar.value,
12784
13062
  "detail": PublicChartType.table.value,
12785
13063
  }.get(normalized, normalized)
12786
13064
 
@@ -14067,6 +14345,38 @@ def _parse_schema(schema: dict[str, Any]) -> dict[str, Any]:
14067
14345
  return parsed
14068
14346
 
14069
14347
 
14348
+ def _schema_field_identity(field: dict[str, Any] | None, *, fallback_name: str | None = None) -> dict[str, Any]:
14349
+ field = field or {}
14350
+ name = str(field.get("name") or fallback_name or "").strip() or None
14351
+ field_id = str(field.get("field_id") or "").strip() or None
14352
+ que_id = _coerce_positive_int(field.get("que_id") or field.get("queId"))
14353
+ return {
14354
+ "name": name,
14355
+ "field_id": field_id,
14356
+ "que_id": que_id,
14357
+ }
14358
+
14359
+
14360
+ def _schema_field_diff_details(
14361
+ *,
14362
+ added: list[str],
14363
+ updated: list[str],
14364
+ removed: list[str],
14365
+ before_fields: list[dict[str, Any]],
14366
+ after_fields: list[dict[str, Any]],
14367
+ ) -> dict[str, list[dict[str, Any]]]:
14368
+ before_by_name = {str(field.get("name") or ""): field for field in before_fields if str(field.get("name") or "").strip()}
14369
+ after_by_name = {str(field.get("name") or ""): field for field in after_fields if str(field.get("name") or "").strip()}
14370
+ return {
14371
+ "added": [_schema_field_identity(after_by_name.get(name), fallback_name=name) for name in added],
14372
+ "updated": [
14373
+ _schema_field_identity(after_by_name.get(name) or before_by_name.get(name), fallback_name=name)
14374
+ for name in updated
14375
+ ],
14376
+ "removed": [_schema_field_identity(before_by_name.get(name), fallback_name=name) for name in removed],
14377
+ }
14378
+
14379
+
14070
14380
  def _resolve_layout_sections_to_names(
14071
14381
  requested_sections: list[dict[str, Any]],
14072
14382
  fields: list[dict[str, Any]],
@@ -19234,7 +19544,7 @@ def _compare_view_button_summaries(
19234
19544
  expected_without_pending = [item for index, item in enumerate(expected) if index not in pending_indexes]
19235
19545
  if _sorted_view_button_compare_signatures(actual) == _sorted_view_button_compare_signatures(expected_without_pending):
19236
19546
  return {
19237
- "verified": True,
19547
+ "verified": False,
19238
19548
  "custom_button_readback_pending": True,
19239
19549
  "pending_custom_buttons": deepcopy(pending_custom_buttons),
19240
19550
  }
@@ -19245,7 +19555,7 @@ def _compare_view_button_summaries(
19245
19555
  and _sorted_view_button_compare_signatures(actual_other) == _sorted_view_button_compare_signatures(expected_other)
19246
19556
  )
19247
19557
  return {
19248
- "verified": custom_button_readback_pending,
19558
+ "verified": False,
19249
19559
  "custom_button_readback_pending": custom_button_readback_pending,
19250
19560
  "pending_custom_buttons": deepcopy(expected_custom) if custom_button_readback_pending else [],
19251
19561
  }
@@ -20129,7 +20439,7 @@ def _build_view_associated_resources_payload(
20129
20439
  "missing_fields": [],
20130
20440
  "received": raw_item_id,
20131
20441
  "message": "associated_item_id must be an app-level associated resource id from app_get.associated_resources",
20132
- "next_action": "call app_get and use associated_resources[].associated_item_id, not chart_id",
20442
+ "next_action": "prefer app_associated_resources_apply for associated report/view display; it can resolve associated_item_id, chart_id/chart_key, or view_key",
20133
20443
  }
20134
20444
  ]
20135
20445
  item_ids.append(item_id)
@@ -20143,7 +20453,7 @@ def _build_view_associated_resources_payload(
20143
20453
  "invalid_associated_item_ids": missing_ids,
20144
20454
  "available_associated_item_ids": sorted(available_by_id),
20145
20455
  "message": "associated_resource references ids that are not in the app-level associated resource pool",
20146
- "next_action": "call app_get and use associated_resources[].associated_item_id, not chart_id",
20456
+ "next_action": "prefer app_associated_resources_apply for associated report/view display; it can resolve associated_item_id, chart_id/chart_key, or view_key",
20147
20457
  }
20148
20458
  ]
20149
20459
  payload = {
@@ -20561,6 +20871,77 @@ def _associated_resource_upsert_payload_from_existing_item(
20561
20871
  return _compact_dict(payload)
20562
20872
 
20563
20873
 
20874
+ def _resolve_associated_resource_selector(
20875
+ selector: Any,
20876
+ *,
20877
+ existing_resources: list[dict[str, Any]],
20878
+ existing_by_id: dict[int, dict[str, Any]],
20879
+ reason_path: str,
20880
+ ) -> tuple[int | None, dict[str, Any] | None]:
20881
+ item_id = _coerce_positive_int(selector)
20882
+ if item_id is not None:
20883
+ if item_id in existing_by_id:
20884
+ return item_id, None
20885
+ raw = str(selector or "").strip()
20886
+ if not raw:
20887
+ return None, {
20888
+ "error_code": "ASSOCIATED_RESOURCE_NOT_FOUND",
20889
+ "reason_path": reason_path,
20890
+ "received": selector,
20891
+ "available_associated_item_ids": sorted(existing_by_id),
20892
+ "message": "associated resource selector cannot be empty",
20893
+ }
20894
+ matches: list[dict[str, Any]] = []
20895
+ for resource in existing_resources:
20896
+ if not isinstance(resource, dict):
20897
+ continue
20898
+ candidates = [
20899
+ resource.get("chart_id"),
20900
+ resource.get("chart_key"),
20901
+ resource.get("view_key"),
20902
+ resource.get("associated_item_id"),
20903
+ ]
20904
+ if raw in {str(candidate).strip() for candidate in candidates if candidate not in {None, ""}}:
20905
+ matches.append(resource)
20906
+ if len(matches) == 1:
20907
+ resolved_id = _coerce_positive_int(matches[0].get("associated_item_id"))
20908
+ if resolved_id is not None:
20909
+ return resolved_id, None
20910
+ if len(matches) > 1:
20911
+ return None, {
20912
+ "error_code": "AMBIGUOUS_ASSOCIATED_RESOURCE",
20913
+ "reason_path": reason_path,
20914
+ "received": selector,
20915
+ "candidate_associated_item_ids": [
20916
+ item_id
20917
+ for item_id in (_coerce_positive_int(item.get("associated_item_id")) for item in matches)
20918
+ if item_id is not None
20919
+ ],
20920
+ "message": "selector matches multiple associated resources; pass associated_item_id",
20921
+ }
20922
+ return None, {
20923
+ "error_code": "ASSOCIATED_RESOURCE_NOT_FOUND",
20924
+ "reason_path": reason_path,
20925
+ "received": selector,
20926
+ "available_associated_item_ids": sorted(existing_by_id),
20927
+ "available_chart_ids": sorted(
20928
+ {
20929
+ str(resource.get("chart_id") or resource.get("chart_key") or "").strip()
20930
+ for resource in existing_resources
20931
+ if str(resource.get("chart_id") or resource.get("chart_key") or "").strip()
20932
+ }
20933
+ ),
20934
+ "available_view_keys": sorted(
20935
+ {
20936
+ str(resource.get("view_key") or "").strip()
20937
+ for resource in existing_resources
20938
+ if str(resource.get("view_key") or "").strip()
20939
+ }
20940
+ ),
20941
+ "message": "selector must be an associated_item_id, QingBI chart_id/chart_key, or Qingflow view_key from the app-level associated resource pool",
20942
+ }
20943
+
20944
+
20564
20945
  def _associated_resource_not_found_issue(reason_path: str, associated_item_id: int, existing_by_id: dict[int, dict[str, Any]]) -> dict[str, Any]:
20565
20946
  return {
20566
20947
  "error_code": "ASSOCIATED_RESOURCE_NOT_FOUND",
@@ -20568,7 +20949,7 @@ def _associated_resource_not_found_issue(reason_path: str, associated_item_id: i
20568
20949
  "associated_item_id": associated_item_id,
20569
20950
  "available_associated_item_ids": sorted(existing_by_id),
20570
20951
  "message": "associated_item_id is not in the app-level associated resource pool",
20571
- "next_action": "call app_get and use associated_resources[].associated_item_id; do not pass chart_id/chart_key",
20952
+ "next_action": "call app_get and use associated_resources[].associated_item_id, chart_id/chart_key, or view_key",
20572
20953
  }
20573
20954
 
20574
20955