@josephyan/qingflow-cli 0.2.0-beta.1000

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +31 -0
  2. package/docs/local-agent-install.md +309 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow.mjs +5 -0
  5. package/npm/lib/runtime.mjs +346 -0
  6. package/npm/scripts/postinstall.mjs +16 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +67 -0
  9. package/qingflow +15 -0
  10. package/src/qingflow_mcp/__init__.py +37 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +649 -0
  13. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +1846 -0
  15. package/src/qingflow_mcp/builder_facade/service.py +16502 -0
  16. package/src/qingflow_mcp/cli/__init__.py +1 -0
  17. package/src/qingflow_mcp/cli/commands/__init__.py +18 -0
  18. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  19. package/src/qingflow_mcp/cli/commands/auth.py +112 -0
  20. package/src/qingflow_mcp/cli/commands/builder.py +539 -0
  21. package/src/qingflow_mcp/cli/commands/chart.py +18 -0
  22. package/src/qingflow_mcp/cli/commands/common.py +62 -0
  23. package/src/qingflow_mcp/cli/commands/imports.py +96 -0
  24. package/src/qingflow_mcp/cli/commands/portal.py +25 -0
  25. package/src/qingflow_mcp/cli/commands/record.py +331 -0
  26. package/src/qingflow_mcp/cli/commands/repo.py +80 -0
  27. package/src/qingflow_mcp/cli/commands/task.py +141 -0
  28. package/src/qingflow_mcp/cli/commands/view.py +18 -0
  29. package/src/qingflow_mcp/cli/commands/workspace.py +110 -0
  30. package/src/qingflow_mcp/cli/context.py +60 -0
  31. package/src/qingflow_mcp/cli/formatters.py +573 -0
  32. package/src/qingflow_mcp/cli/json_io.py +50 -0
  33. package/src/qingflow_mcp/cli/main.py +186 -0
  34. package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
  35. package/src/qingflow_mcp/cli/terminal_ui.py +173 -0
  36. package/src/qingflow_mcp/config.py +407 -0
  37. package/src/qingflow_mcp/errors.py +66 -0
  38. package/src/qingflow_mcp/id_utils.py +49 -0
  39. package/src/qingflow_mcp/import_store.py +121 -0
  40. package/src/qingflow_mcp/json_types.py +18 -0
  41. package/src/qingflow_mcp/list_type_labels.py +76 -0
  42. package/src/qingflow_mcp/public_surface.py +243 -0
  43. package/src/qingflow_mcp/repository_store.py +71 -0
  44. package/src/qingflow_mcp/response_trim.py +841 -0
  45. package/src/qingflow_mcp/server.py +216 -0
  46. package/src/qingflow_mcp/server_app_builder.py +543 -0
  47. package/src/qingflow_mcp/server_app_user.py +386 -0
  48. package/src/qingflow_mcp/session_store.py +369 -0
  49. package/src/qingflow_mcp/solution/__init__.py +6 -0
  50. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  51. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  52. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  53. package/src/qingflow_mcp/solution/compiler/form_compiler.py +495 -0
  54. package/src/qingflow_mcp/solution/compiler/icon_utils.py +187 -0
  55. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  56. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  57. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  58. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  59. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  60. package/src/qingflow_mcp/solution/design_session.py +222 -0
  61. package/src/qingflow_mcp/solution/design_store.py +100 -0
  62. package/src/qingflow_mcp/solution/executor.py +2398 -0
  63. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  64. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  65. package/src/qingflow_mcp/solution/run_store.py +244 -0
  66. package/src/qingflow_mcp/solution/spec_models.py +855 -0
  67. package/src/qingflow_mcp/tools/__init__.py +1 -0
  68. package/src/qingflow_mcp/tools/ai_builder_tools.py +3449 -0
  69. package/src/qingflow_mcp/tools/app_tools.py +926 -0
  70. package/src/qingflow_mcp/tools/approval_tools.py +1062 -0
  71. package/src/qingflow_mcp/tools/auth_tools.py +1133 -0
  72. package/src/qingflow_mcp/tools/base.py +281 -0
  73. package/src/qingflow_mcp/tools/code_block_tools.py +777 -0
  74. package/src/qingflow_mcp/tools/custom_button_tools.py +202 -0
  75. package/src/qingflow_mcp/tools/directory_tools.py +675 -0
  76. package/src/qingflow_mcp/tools/feedback_tools.py +238 -0
  77. package/src/qingflow_mcp/tools/file_tools.py +409 -0
  78. package/src/qingflow_mcp/tools/import_tools.py +2223 -0
  79. package/src/qingflow_mcp/tools/navigation_tools.py +210 -0
  80. package/src/qingflow_mcp/tools/package_tools.py +326 -0
  81. package/src/qingflow_mcp/tools/portal_tools.py +158 -0
  82. package/src/qingflow_mcp/tools/qingbi_report_tools.py +374 -0
  83. package/src/qingflow_mcp/tools/record_tools.py +14291 -0
  84. package/src/qingflow_mcp/tools/repository_dev_tools.py +552 -0
  85. package/src/qingflow_mcp/tools/resource_read_tools.py +503 -0
  86. package/src/qingflow_mcp/tools/role_tools.py +112 -0
  87. package/src/qingflow_mcp/tools/solution_tools.py +4054 -0
  88. package/src/qingflow_mcp/tools/task_context_tools.py +2986 -0
  89. package/src/qingflow_mcp/tools/task_tools.py +889 -0
  90. package/src/qingflow_mcp/tools/view_tools.py +335 -0
  91. package/src/qingflow_mcp/tools/workflow_tools.py +376 -0
  92. package/src/qingflow_mcp/tools/workspace_tools.py +266 -0
@@ -0,0 +1,238 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from mcp.server.fastmcp import FastMCP
6
+
7
+ from ..backend_client import BackendClient
8
+ from ..config import get_feedback_app_key, get_feedback_base_url, get_feedback_qsource_token, normalize_base_url
9
+ from ..errors import QingflowApiError, raise_tool_error
10
+ from ..json_types import JSONObject
11
+ from .base import tool_cn_name
12
+
13
+
14
+ CATEGORY_MAP = {
15
+ "feature_request": "功能需求",
16
+ "bug_report": "问题反馈",
17
+ "ux_feedback": "体验建议",
18
+ "unsupported_scenario": "不支持场景",
19
+ "other": "其他",
20
+ }
21
+
22
+ IMPACT_SCOPE_MAP = {
23
+ "personal": "仅个人",
24
+ "small_team": "小范围团队",
25
+ "cross_team": "跨团队",
26
+ "global": "全局",
27
+ }
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class FeedbackTools:
32
+ backend: BackendClient
33
+ mcp_side: str
34
+
35
+ def register(self, mcp: FastMCP) -> None:
36
+ """注册当前工具到 MCP 服务。"""
37
+ @mcp.tool()
38
+ def feedback_submit(
39
+ category: str = "",
40
+ title: str = "",
41
+ description: str = "",
42
+ expected_behavior: str | None = None,
43
+ actual_behavior: str | None = None,
44
+ impact_scope: str | None = None,
45
+ tool_name: str | None = None,
46
+ app_key: str | None = None,
47
+ record_id: str | int | None = None,
48
+ workflow_node_id: str | int | None = None,
49
+ note: str | None = None,
50
+ ) -> JSONObject:
51
+ """Submit product feedback to the Qingflow MCP team.
52
+
53
+ Use this when the current MCP capability is unsupported, awkward, or still cannot satisfy the user's need
54
+ after reasonable attempts. This helper writes through the internal q-source feedback intake, does not
55
+ require Qingflow login or workspace selection, and should be called only after explicit user confirmation.
56
+ """
57
+ return self.feedback_submit(
58
+ category=category,
59
+ title=title,
60
+ description=description,
61
+ expected_behavior=expected_behavior,
62
+ actual_behavior=actual_behavior,
63
+ impact_scope=impact_scope,
64
+ tool_name=tool_name,
65
+ app_key=app_key,
66
+ record_id=record_id,
67
+ workflow_node_id=workflow_node_id,
68
+ note=note,
69
+ )
70
+
71
+ @tool_cn_name("提交反馈")
72
+ def feedback_submit(
73
+ self,
74
+ *,
75
+ category: str,
76
+ title: str,
77
+ description: str,
78
+ expected_behavior: str | None,
79
+ actual_behavior: str | None,
80
+ impact_scope: str | None,
81
+ tool_name: str | None,
82
+ app_key: str | None,
83
+ record_id: str | int | None,
84
+ workflow_node_id: str | int | None,
85
+ note: str | None,
86
+ ) -> JSONObject:
87
+ """执行工具方法逻辑。"""
88
+ qsource_token = get_feedback_qsource_token()
89
+ if not qsource_token:
90
+ raise_tool_error(
91
+ QingflowApiError(
92
+ category="config",
93
+ message=(
94
+ "feedback_submit is not configured. Set "
95
+ "feedback.qsource_token or QINGFLOW_MCP_FEEDBACK_QSOURCE_TOKEN first."
96
+ ),
97
+ details={"error_code": "FEEDBACK_NOT_CONFIGURED"},
98
+ )
99
+ )
100
+
101
+ base_url = get_feedback_base_url()
102
+ if not base_url:
103
+ raise_tool_error(
104
+ QingflowApiError.config_error(
105
+ "feedback_submit requires a base_url. Configure feedback.base_url or default_base_url."
106
+ )
107
+ )
108
+
109
+ normalized_payload = self._build_payload(
110
+ category=category,
111
+ title=title,
112
+ description=description,
113
+ expected_behavior=expected_behavior,
114
+ actual_behavior=actual_behavior,
115
+ impact_scope=impact_scope,
116
+ tool_name=tool_name,
117
+ app_key=app_key,
118
+ record_id=record_id,
119
+ workflow_node_id=workflow_node_id,
120
+ note=note,
121
+ )
122
+
123
+ try:
124
+ response = self.backend.public_request_with_meta(
125
+ "POST",
126
+ base_url,
127
+ f"/qsource/{qsource_token}",
128
+ json_body=normalized_payload,
129
+ unwrap=True,
130
+ qf_version=None,
131
+ )
132
+ except QingflowApiError as exc:
133
+ raise_tool_error(exc)
134
+ result = response.data if isinstance(response.data, dict) else {}
135
+ feedback_request_id = result.get("requestId") if isinstance(result, dict) else None
136
+
137
+ return {
138
+ "ok": True,
139
+ "request_route": {
140
+ "base_url": normalize_base_url(base_url) or base_url,
141
+ "qf_version": None,
142
+ "qf_version_source": "not_applicable",
143
+ },
144
+ "submission_mode": "qsource_passive",
145
+ "feedback_target": {
146
+ "app_key": get_feedback_app_key(),
147
+ "mcp_side": self.mcp_side,
148
+ },
149
+ "normalized_payload": normalized_payload,
150
+ "feedback_request_id": feedback_request_id,
151
+ }
152
+
153
+ def _build_payload(
154
+ self,
155
+ *,
156
+ category: str,
157
+ title: str,
158
+ description: str,
159
+ expected_behavior: str | None,
160
+ actual_behavior: str | None,
161
+ impact_scope: str | None,
162
+ tool_name: str | None,
163
+ app_key: str | None,
164
+ record_id: str | int | None,
165
+ workflow_node_id: str | int | None,
166
+ note: str | None,
167
+ ) -> JSONObject:
168
+ """执行内部辅助逻辑。"""
169
+ payload: JSONObject = {
170
+ "title": self._require_text("title", title),
171
+ "category": self._normalize_label("category", category, CATEGORY_MAP, required=True),
172
+ "description": self._require_text("description", description),
173
+ "submit_method": "AI代提",
174
+ "status": "待处理",
175
+ "mcp_side": self.mcp_side,
176
+ }
177
+
178
+ optional_text = {
179
+ "expected_result": expected_behavior,
180
+ "actual_behavior": actual_behavior,
181
+ "tool_name": tool_name,
182
+ "app_key": app_key,
183
+ "note": note,
184
+ }
185
+ for key, value in optional_text.items():
186
+ normalized = self._normalize_optional_text(value)
187
+ if normalized is not None:
188
+ payload[key] = normalized
189
+
190
+ if impact_scope is not None and str(impact_scope).strip():
191
+ payload["impact_scope"] = self._normalize_label("impact_scope", impact_scope, IMPACT_SCOPE_MAP, required=False)
192
+
193
+ if record_id is not None and str(record_id).strip():
194
+ payload["record_id"] = str(record_id).strip()
195
+ if workflow_node_id is not None and str(workflow_node_id).strip():
196
+ payload["workflow_node_id"] = str(workflow_node_id).strip()
197
+
198
+ return payload
199
+
200
+ def _normalize_label(self, field: str, value: str, mapping: dict[str, str], *, required: bool) -> str:
201
+ """执行内部辅助逻辑。"""
202
+ text = str(value or "").strip()
203
+ if not text:
204
+ if required:
205
+ raise_tool_error(QingflowApiError.config_error(f"{field} is required"))
206
+ return ""
207
+
208
+ canonical = text.lower()
209
+ if canonical in mapping:
210
+ return mapping[canonical]
211
+ if text in mapping.values():
212
+ return text
213
+ supported_values = list(mapping.keys()) + list(mapping.values())
214
+ raise_tool_error(
215
+ QingflowApiError(
216
+ category="config",
217
+ message=f"{field} must be one of the supported canonical values or labels",
218
+ details={
219
+ "error_code": "FEEDBACK_INVALID_INPUT",
220
+ "field": field,
221
+ "supported_values": supported_values,
222
+ },
223
+ )
224
+ )
225
+
226
+ def _require_text(self, field: str, value: str) -> str:
227
+ """执行内部辅助逻辑。"""
228
+ normalized = str(value or "").strip()
229
+ if not normalized:
230
+ raise_tool_error(QingflowApiError.config_error(f"{field} is required"))
231
+ return normalized
232
+
233
+ def _normalize_optional_text(self, value: str | None) -> str | None:
234
+ """执行内部辅助逻辑。"""
235
+ if value is None:
236
+ return None
237
+ normalized = str(value).strip()
238
+ return normalized or None
@@ -0,0 +1,409 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import mimetypes
6
+ import random
7
+ import string
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from urllib.parse import quote
11
+
12
+ from mcp.server.fastmcp import FastMCP
13
+
14
+ from ..config import DEFAULT_PROFILE
15
+ from ..errors import QingflowApiError, raise_tool_error
16
+ from ..json_types import JSONObject
17
+ from .base import ToolBase, tool_cn_name
18
+
19
+
20
+ ATTACHMENT_UPLOAD_INFO_FALLBACK_CODES = {"40118", 40118}
21
+ LEGACY_OSS_FORM_REQUIRED_KEYS = ("key", "policy", "signature", "ossAccessKeyId")
22
+
23
+
24
+ class FileTools(ToolBase):
25
+ """文件工具(中文名:上传与下载中转)。
26
+
27
+ 类型:文件传输工具。
28
+ 主要职责:
29
+ 1. 申请上传凭证与上传地址;
30
+ 2. 支持本地路径、URL、Base64 内容三类上传输入;
31
+ 3. 返回标准化文件元信息供导入与记录附件使用。
32
+ """
33
+
34
+ def register(self, mcp: FastMCP) -> None:
35
+ """注册当前工具到 MCP 服务。"""
36
+ @mcp.tool()
37
+ def file_get_upload_info(
38
+ profile: str = DEFAULT_PROFILE,
39
+ upload_kind: str = "attachment",
40
+ file_name: str = "",
41
+ file_size: int = 0,
42
+ upload_mark: str | None = None,
43
+ content_type: str | None = None,
44
+ bucket_type: str | None = None,
45
+ path_id: int | None = None,
46
+ file_related_url: str | None = None,
47
+ ) -> dict[str, Any]:
48
+ return self.file_get_upload_info(
49
+ profile=profile,
50
+ upload_kind=upload_kind,
51
+ file_name=file_name,
52
+ file_size=file_size,
53
+ upload_mark=upload_mark,
54
+ content_type=content_type,
55
+ bucket_type=bucket_type,
56
+ path_id=path_id,
57
+ file_related_url=file_related_url,
58
+ )
59
+
60
+ @mcp.tool()
61
+ def file_upload_local(
62
+ profile: str = DEFAULT_PROFILE,
63
+ upload_kind: str = "attachment",
64
+ file_path: str = "",
65
+ upload_mark: str | None = None,
66
+ content_type: str | None = None,
67
+ bucket_type: str | None = None,
68
+ path_id: int | None = None,
69
+ file_related_url: str | None = None,
70
+ ) -> dict[str, Any]:
71
+ return self.file_upload_local(
72
+ profile=profile,
73
+ upload_kind=upload_kind,
74
+ file_path=file_path,
75
+ upload_mark=upload_mark,
76
+ content_type=content_type,
77
+ bucket_type=bucket_type,
78
+ path_id=path_id,
79
+ file_related_url=file_related_url,
80
+ )
81
+
82
+ @tool_cn_name("文件上传信息")
83
+ def file_get_upload_info(
84
+ self,
85
+ *,
86
+ profile: str,
87
+ upload_kind: str,
88
+ file_name: str,
89
+ file_size: int,
90
+ upload_mark: str | None = None,
91
+ content_type: str | None = None,
92
+ bucket_type: str | None = None,
93
+ path_id: int | None = None,
94
+ file_related_url: str | None = None,
95
+ ) -> dict[str, Any]:
96
+ """执行文件处理相关逻辑。"""
97
+ def runner(session_profile, context):
98
+ upload_info = self._request_upload_info_with_fallback(
99
+ context,
100
+ upload_kind=upload_kind,
101
+ file_name=file_name,
102
+ file_size=file_size,
103
+ upload_mark=upload_mark,
104
+ content_type=content_type,
105
+ bucket_type=bucket_type,
106
+ path_id=path_id,
107
+ file_related_url=file_related_url,
108
+ )
109
+ return {
110
+ "profile": profile,
111
+ "ws_id": session_profile.selected_ws_id,
112
+ "upload_kind": upload_kind,
113
+ "requested_upload_kind": upload_info["requested_upload_kind"],
114
+ "effective_upload_kind": upload_info["effective_upload_kind"],
115
+ "upload_fallback_applied": upload_info["fallback_applied"],
116
+ "upload_fallback_reason": upload_info["fallback_reason"],
117
+ "file_name": file_name,
118
+ "file_size": file_size,
119
+ "request_route": self.backend.describe_route(context),
120
+ "result": upload_info["result"],
121
+ }
122
+
123
+ return self._run(profile, runner)
124
+
125
+ @tool_cn_name("本地文件上传")
126
+ def file_upload_local(
127
+ self,
128
+ *,
129
+ profile: str,
130
+ upload_kind: str,
131
+ file_path: str,
132
+ upload_mark: str | None = None,
133
+ content_type: str | None = None,
134
+ bucket_type: str | None = None,
135
+ path_id: int | None = None,
136
+ file_related_url: str | None = None,
137
+ ) -> dict[str, Any]:
138
+ """执行文件处理相关逻辑。"""
139
+ path = Path(file_path).expanduser()
140
+ if not path.is_file():
141
+ raise_tool_error(QingflowApiError.config_error("file_path must point to an existing file"))
142
+ file_name = path.name
143
+ file_size = path.stat().st_size
144
+ resolved_content_type = content_type or mimetypes.guess_type(file_name)[0] or "application/octet-stream"
145
+
146
+ def runner(session_profile, context):
147
+ upload_info = self._request_upload_info_with_fallback(
148
+ context,
149
+ upload_kind=upload_kind,
150
+ file_name=file_name,
151
+ file_size=file_size,
152
+ upload_mark=upload_mark,
153
+ content_type=resolved_content_type,
154
+ bucket_type=bucket_type,
155
+ path_id=path_id,
156
+ file_related_url=file_related_url,
157
+ )
158
+ result = upload_info["result"]
159
+ if not isinstance(result, dict):
160
+ raise QingflowApiError.config_error("upload endpoint did not return a structured upload payload")
161
+ content = path.read_bytes()
162
+ upload_protocol = "binary_put"
163
+ if self._is_legacy_oss_form_upload(result):
164
+ upload_result = self._upload_legacy_oss_form(
165
+ result,
166
+ file_name=file_name,
167
+ content=content,
168
+ content_type=resolved_content_type,
169
+ )
170
+ upload_protocol = "oss_form_post"
171
+ else:
172
+ upload_url = str(result.get("uploadUrl") or "").strip()
173
+ if not upload_url:
174
+ raise QingflowApiError.config_error("upload endpoint did not return uploadUrl")
175
+ upload_result = self.backend.upload_binary(upload_url, content, content_type=resolved_content_type)
176
+ download_url = result.get("downloadUrl")
177
+ return {
178
+ "profile": profile,
179
+ "ws_id": session_profile.selected_ws_id,
180
+ "upload_kind": upload_kind,
181
+ "requested_upload_kind": upload_info["requested_upload_kind"],
182
+ "effective_upload_kind": upload_info["effective_upload_kind"],
183
+ "upload_fallback_applied": upload_info["fallback_applied"],
184
+ "upload_fallback_reason": upload_info["fallback_reason"],
185
+ "file_name": file_name,
186
+ "file_size": file_size,
187
+ "content_type": resolved_content_type,
188
+ "request_route": self.backend.describe_route(context),
189
+ "upload_protocol": upload_protocol,
190
+ "result": result,
191
+ "upload_result": upload_result,
192
+ "download_url": download_url,
193
+ "attachment_value": {
194
+ "value": download_url,
195
+ "otherInfo": file_name,
196
+ "name": file_name,
197
+ },
198
+ "comment_file_info": {
199
+ "url": download_url,
200
+ "name": file_name,
201
+ "uploadFileSize": file_size,
202
+ },
203
+ }
204
+
205
+ return self._run(profile, runner)
206
+
207
+ def _build_file_upload_bo(
208
+ self,
209
+ *,
210
+ upload_kind: str,
211
+ file_name: str,
212
+ file_size: int,
213
+ upload_mark: str | None,
214
+ content_type: str | None,
215
+ bucket_type: str | None,
216
+ path_id: int | None,
217
+ file_related_url: str | None,
218
+ ) -> dict[str, Any]:
219
+ """执行内部辅助逻辑。"""
220
+ if not file_name:
221
+ raise_tool_error(QingflowApiError.config_error("file_name is required"))
222
+ if file_size <= 0:
223
+ raise_tool_error(QingflowApiError.config_error("file_size must be positive"))
224
+ if upload_kind == "attachment" and not upload_mark:
225
+ raise_tool_error(QingflowApiError.config_error("upload_mark is required for attachment uploads and should usually be the app_key"))
226
+ payload: dict[str, Any] = {
227
+ "fileName": file_name,
228
+ "fileSize": file_size,
229
+ }
230
+ if upload_mark:
231
+ payload["uploadMark"] = upload_mark
232
+ if content_type:
233
+ payload["contentType"] = content_type
234
+ if bucket_type:
235
+ payload["bucketType"] = bucket_type
236
+ if path_id is not None:
237
+ payload["pathId"] = path_id
238
+ if file_related_url:
239
+ payload["fileRelatedUrl"] = file_related_url
240
+ return payload
241
+
242
+ def _resolve_upload_endpoint(self, upload_kind: str) -> str:
243
+ """执行内部辅助逻辑。"""
244
+ normalized = upload_kind.strip().lower()
245
+ if normalized == "attachment":
246
+ return "/upload/puburl"
247
+ if normalized == "login":
248
+ return "/upload/url"
249
+ if normalized == "anonymous":
250
+ return "/upload/anonymousurl"
251
+ raise_tool_error(QingflowApiError.config_error("upload_kind must be one of: attachment, login, anonymous"))
252
+ raise AssertionError("unreachable")
253
+
254
+ def _encode_formula(self, formula: str) -> str:
255
+ """执行内部辅助逻辑。"""
256
+ encoded = base64.b64encode(quote(formula, safe="").encode("utf-8")).decode("utf-8")
257
+ return f"{self._random_string(16)}{encoded}{self._random_string(16)}"
258
+
259
+ def _random_string(self, length: int) -> str:
260
+ """执行内部辅助逻辑。"""
261
+ alphabet = string.ascii_letters + string.digits
262
+ return "".join(random.choice(alphabet) for _ in range(length))
263
+
264
+ def _request_upload_info_with_fallback(
265
+ self,
266
+ context,
267
+ *,
268
+ upload_kind: str,
269
+ file_name: str,
270
+ file_size: int,
271
+ upload_mark: str | None,
272
+ content_type: str | None,
273
+ bucket_type: str | None,
274
+ path_id: int | None,
275
+ file_related_url: str | None,
276
+ ) -> dict[str, Any]:
277
+ """执行内部辅助逻辑。"""
278
+ requested_kind = upload_kind.strip().lower()
279
+ attempted_kinds = [requested_kind]
280
+ if requested_kind == "attachment":
281
+ attempted_kinds.append("login")
282
+ last_error: QingflowApiError | None = None
283
+ for current_kind in attempted_kinds:
284
+ try:
285
+ result = self._request_upload_info(
286
+ context,
287
+ upload_kind=current_kind,
288
+ file_name=file_name,
289
+ file_size=file_size,
290
+ upload_mark=upload_mark,
291
+ content_type=content_type,
292
+ bucket_type=bucket_type,
293
+ path_id=path_id,
294
+ file_related_url=file_related_url,
295
+ )
296
+ return {
297
+ "requested_upload_kind": requested_kind,
298
+ "effective_upload_kind": current_kind,
299
+ "fallback_applied": current_kind != requested_kind,
300
+ "fallback_reason": (
301
+ f"backend rejected '{requested_kind}' upload info; retried with '{current_kind}'"
302
+ if current_kind != requested_kind
303
+ else None
304
+ ),
305
+ "result": result,
306
+ }
307
+ except QingflowApiError as error:
308
+ last_error = error
309
+ if not self._should_retry_attachment_upload_info(requested_kind, current_kind, error):
310
+ raise
311
+ assert last_error is not None
312
+ raise last_error
313
+
314
+ def _request_upload_info(
315
+ self,
316
+ context,
317
+ *,
318
+ upload_kind: str,
319
+ file_name: str,
320
+ file_size: int,
321
+ upload_mark: str | None,
322
+ content_type: str | None,
323
+ bucket_type: str | None,
324
+ path_id: int | None,
325
+ file_related_url: str | None,
326
+ ) -> JSONObject:
327
+ """执行内部辅助逻辑。"""
328
+ endpoint = self._resolve_upload_endpoint(upload_kind)
329
+ file_upload_bo = self._build_file_upload_bo(
330
+ upload_kind=upload_kind,
331
+ file_name=file_name,
332
+ file_size=file_size,
333
+ upload_mark=upload_mark,
334
+ content_type=content_type,
335
+ bucket_type=bucket_type,
336
+ path_id=path_id,
337
+ file_related_url=file_related_url,
338
+ )
339
+ encrypted_payload = {
340
+ "upload": self._encode_formula(json.dumps(file_upload_bo, ensure_ascii=False, separators=(",", ":"))),
341
+ "fileName": file_name,
342
+ "fileSize": file_size,
343
+ }
344
+ if path_id is not None:
345
+ encrypted_payload["pathId"] = path_id
346
+ if bucket_type:
347
+ encrypted_payload["bucketType"] = bucket_type
348
+ result = self.backend.request("POST", context, endpoint, json_body=encrypted_payload)
349
+ if not isinstance(result, dict):
350
+ raise QingflowApiError.config_error("upload endpoint did not return a structured upload payload")
351
+ return result
352
+
353
+ def _should_retry_attachment_upload_info(
354
+ self,
355
+ requested_kind: str,
356
+ attempted_kind: str,
357
+ error: QingflowApiError,
358
+ ) -> bool:
359
+ """执行内部辅助逻辑。"""
360
+ return (
361
+ requested_kind == "attachment"
362
+ and attempted_kind == "attachment"
363
+ and error.backend_code in ATTACHMENT_UPLOAD_INFO_FALLBACK_CODES
364
+ )
365
+
366
+ def _is_legacy_oss_form_upload(self, payload: JSONObject) -> bool:
367
+ """执行内部辅助逻辑。"""
368
+ return all(str(payload.get(key) or "").strip() for key in LEGACY_OSS_FORM_REQUIRED_KEYS)
369
+
370
+ def _upload_legacy_oss_form(
371
+ self,
372
+ upload_info: JSONObject,
373
+ *,
374
+ file_name: str,
375
+ content: bytes,
376
+ content_type: str,
377
+ ) -> dict[str, Any]:
378
+ """执行内部辅助逻辑。"""
379
+ upload_url = self._resolve_legacy_oss_form_url(upload_info)
380
+ if not upload_url:
381
+ raise QingflowApiError.config_error("legacy upload payload is missing host/uploadAccelerateHost")
382
+ form_fields = {
383
+ "key": str(upload_info["key"]),
384
+ "policy": str(upload_info["policy"]),
385
+ "signature": str(upload_info["signature"]),
386
+ "OSSAccessKeyId": str(upload_info["ossAccessKeyId"]),
387
+ }
388
+ callback = str(upload_info.get("callback") or "").strip()
389
+ if callback:
390
+ form_fields["callback"] = callback
391
+ return self.backend.upload_form_file(
392
+ upload_url,
393
+ form_fields=form_fields,
394
+ file_field="file",
395
+ file_name=file_name,
396
+ content=content,
397
+ content_type=content_type,
398
+ )
399
+
400
+ def _resolve_legacy_oss_form_url(self, upload_info: JSONObject) -> str:
401
+ """执行内部辅助逻辑。"""
402
+ for key in ("uploadAccelerateHost", "host", "uploadUrl"):
403
+ value = str(upload_info.get(key) or "").strip()
404
+ if not value:
405
+ continue
406
+ if value.startswith(("http://", "https://")):
407
+ return value
408
+ return f"https://{value.lstrip('/')}"
409
+ return ""