@qingflow-tech/qingflow-app-builder-mcp 1.0.0

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 (105) hide show
  1. package/README.md +32 -0
  2. package/docs/local-agent-install.md +332 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow-app-builder-mcp.mjs +7 -0
  5. package/npm/lib/runtime.mjs +339 -0
  6. package/npm/scripts/postinstall.mjs +16 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +67 -0
  9. package/qingflow-app-builder-mcp +15 -0
  10. package/skills/qingflow-app-builder/SKILL.md +251 -0
  11. package/skills/qingflow-app-builder/agents/openai.yaml +4 -0
  12. package/skills/qingflow-app-builder/references/create-app.md +128 -0
  13. package/skills/qingflow-app-builder/references/environments.md +63 -0
  14. package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +123 -0
  15. package/skills/qingflow-app-builder/references/gotchas.md +64 -0
  16. package/skills/qingflow-app-builder/references/solution-playbooks.md +53 -0
  17. package/skills/qingflow-app-builder/references/tool-selection.md +93 -0
  18. package/skills/qingflow-app-builder/references/update-flow.md +158 -0
  19. package/skills/qingflow-app-builder/references/update-layout.md +68 -0
  20. package/skills/qingflow-app-builder/references/update-schema.md +68 -0
  21. package/skills/qingflow-app-builder/references/update-views.md +162 -0
  22. package/skills/qingflow-app-builder-code-integrations/SKILL.md +137 -0
  23. package/skills/qingflow-app-builder-code-integrations/agents/openai.yaml +4 -0
  24. package/skills/qingflow-app-builder-code-integrations/references/code-block.md +66 -0
  25. package/skills/qingflow-app-builder-code-integrations/references/q-linker.md +77 -0
  26. package/src/qingflow_mcp/__init__.py +5 -0
  27. package/src/qingflow_mcp/__main__.py +5 -0
  28. package/src/qingflow_mcp/backend_client.py +649 -0
  29. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  30. package/src/qingflow_mcp/builder_facade/models.py +1836 -0
  31. package/src/qingflow_mcp/builder_facade/service.py +15044 -0
  32. package/src/qingflow_mcp/cli/__init__.py +1 -0
  33. package/src/qingflow_mcp/cli/commands/__init__.py +18 -0
  34. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  35. package/src/qingflow_mcp/cli/commands/auth.py +44 -0
  36. package/src/qingflow_mcp/cli/commands/builder.py +538 -0
  37. package/src/qingflow_mcp/cli/commands/chart.py +18 -0
  38. package/src/qingflow_mcp/cli/commands/common.py +62 -0
  39. package/src/qingflow_mcp/cli/commands/imports.py +96 -0
  40. package/src/qingflow_mcp/cli/commands/portal.py +25 -0
  41. package/src/qingflow_mcp/cli/commands/record.py +331 -0
  42. package/src/qingflow_mcp/cli/commands/repo.py +80 -0
  43. package/src/qingflow_mcp/cli/commands/task.py +89 -0
  44. package/src/qingflow_mcp/cli/commands/view.py +18 -0
  45. package/src/qingflow_mcp/cli/commands/workspace.py +25 -0
  46. package/src/qingflow_mcp/cli/context.py +60 -0
  47. package/src/qingflow_mcp/cli/formatters.py +334 -0
  48. package/src/qingflow_mcp/cli/json_io.py +50 -0
  49. package/src/qingflow_mcp/cli/main.py +178 -0
  50. package/src/qingflow_mcp/config.py +513 -0
  51. package/src/qingflow_mcp/errors.py +66 -0
  52. package/src/qingflow_mcp/import_store.py +121 -0
  53. package/src/qingflow_mcp/json_types.py +18 -0
  54. package/src/qingflow_mcp/list_type_labels.py +76 -0
  55. package/src/qingflow_mcp/public_surface.py +233 -0
  56. package/src/qingflow_mcp/repository_store.py +71 -0
  57. package/src/qingflow_mcp/response_trim.py +470 -0
  58. package/src/qingflow_mcp/server.py +212 -0
  59. package/src/qingflow_mcp/server_app_builder.py +533 -0
  60. package/src/qingflow_mcp/server_app_user.py +362 -0
  61. package/src/qingflow_mcp/session_store.py +302 -0
  62. package/src/qingflow_mcp/solution/__init__.py +6 -0
  63. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  64. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  65. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  66. package/src/qingflow_mcp/solution/compiler/form_compiler.py +495 -0
  67. package/src/qingflow_mcp/solution/compiler/icon_utils.py +187 -0
  68. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  69. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  70. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  71. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  72. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  73. package/src/qingflow_mcp/solution/design_session.py +222 -0
  74. package/src/qingflow_mcp/solution/design_store.py +100 -0
  75. package/src/qingflow_mcp/solution/executor.py +2398 -0
  76. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  77. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  78. package/src/qingflow_mcp/solution/run_store.py +244 -0
  79. package/src/qingflow_mcp/solution/spec_models.py +855 -0
  80. package/src/qingflow_mcp/tools/__init__.py +1 -0
  81. package/src/qingflow_mcp/tools/ai_builder_tools.py +3419 -0
  82. package/src/qingflow_mcp/tools/app_tools.py +925 -0
  83. package/src/qingflow_mcp/tools/approval_tools.py +1062 -0
  84. package/src/qingflow_mcp/tools/auth_tools.py +875 -0
  85. package/src/qingflow_mcp/tools/base.py +388 -0
  86. package/src/qingflow_mcp/tools/code_block_tools.py +777 -0
  87. package/src/qingflow_mcp/tools/custom_button_tools.py +202 -0
  88. package/src/qingflow_mcp/tools/directory_tools.py +675 -0
  89. package/src/qingflow_mcp/tools/feedback_tools.py +238 -0
  90. package/src/qingflow_mcp/tools/file_tools.py +409 -0
  91. package/src/qingflow_mcp/tools/import_tools.py +2189 -0
  92. package/src/qingflow_mcp/tools/navigation_tools.py +210 -0
  93. package/src/qingflow_mcp/tools/package_tools.py +326 -0
  94. package/src/qingflow_mcp/tools/portal_tools.py +158 -0
  95. package/src/qingflow_mcp/tools/qingbi_report_tools.py +374 -0
  96. package/src/qingflow_mcp/tools/record_tools.py +14037 -0
  97. package/src/qingflow_mcp/tools/repository_dev_tools.py +552 -0
  98. package/src/qingflow_mcp/tools/resource_read_tools.py +421 -0
  99. package/src/qingflow_mcp/tools/role_tools.py +112 -0
  100. package/src/qingflow_mcp/tools/solution_tools.py +4054 -0
  101. package/src/qingflow_mcp/tools/task_context_tools.py +2228 -0
  102. package/src/qingflow_mcp/tools/task_tools.py +890 -0
  103. package/src/qingflow_mcp/tools/view_tools.py +335 -0
  104. package/src/qingflow_mcp/tools/workflow_tools.py +376 -0
  105. package/src/qingflow_mcp/tools/workspace_tools.py +125 -0
@@ -0,0 +1,875 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from mcp.server.fastmcp import FastMCP
6
+
7
+ from ..backend_client import BackendRequestContext, BackendResponse
8
+ from ..config import (
9
+ DEFAULT_PROFILE,
10
+ get_default_base_url,
11
+ get_default_qf_version,
12
+ normalize_base_url,
13
+ )
14
+ from ..errors import QingflowApiError, raise_tool_error
15
+ from ..session_store import SessionStore
16
+ from .base import ToolBase, tool_cn_name
17
+
18
+
19
+ class AuthTools(ToolBase):
20
+ """认证类工具(中文名:身份与会话工具)。
21
+
22
+ 主要职责:
23
+ 1. 使用 credential 调用 /mcp/auth/context 建立会话;
24
+ 2. 查询当前登录身份与工作区成员信息;
25
+ 3. 退出并清理当前 profile 会话。
26
+ """
27
+
28
+ def __init__(self, sessions: SessionStore, backend) -> None:
29
+ """执行内部辅助逻辑。"""
30
+ super().__init__(sessions, backend)
31
+
32
+ def register(self, mcp: FastMCP) -> None:
33
+ """注册当前工具到 MCP 服务。"""
34
+ @mcp.tool(
35
+ description=(
36
+ "类型:认证工具;中文名:凭证登录。"
37
+ "用途:使用 createClaw 提供的 credential 交换上下文并建立本地会话。"
38
+ )
39
+ )
40
+ def auth_use_credential(
41
+ profile: str = DEFAULT_PROFILE,
42
+ base_url: str | None = None,
43
+ qf_version: str | None = None,
44
+ credential: str = "",
45
+ persist: bool = False,
46
+ ) -> dict[str, Any]:
47
+ return self.auth_use_credential(
48
+ profile=profile,
49
+ base_url=base_url,
50
+ qf_version=qf_version,
51
+ credential=credential,
52
+ persist=persist,
53
+ )
54
+
55
+ @mcp.tool(
56
+ description=(
57
+ "类型:认证工具;中文名:我的身份。"
58
+ "用途:查看当前 profile 的登录身份、工作区与权限信息。"
59
+ )
60
+ )
61
+ def auth_whoami(profile: str = DEFAULT_PROFILE) -> dict[str, Any]:
62
+ return self.auth_whoami(profile=profile)
63
+
64
+ @mcp.tool(
65
+ description=(
66
+ "类型:认证工具;中文名:退出登录。"
67
+ "用途:退出当前 profile,并可选清理持久化会话。"
68
+ )
69
+ )
70
+ def auth_logout(profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict[str, Any]:
71
+ return self.auth_logout(profile=profile, forget_persisted=forget_persisted)
72
+
73
+ @tool_cn_name("凭证登录")
74
+ def auth_use_credential(
75
+ self,
76
+ *,
77
+ profile: str = DEFAULT_PROFILE,
78
+ base_url: str | None = None,
79
+ qf_version: str | None = None,
80
+ credential: str,
81
+ persist: bool = False,
82
+ ) -> dict[str, Any]:
83
+ """执行认证与会话相关逻辑。"""
84
+ normalized_base_url = self._normalize_base_url(base_url)
85
+ normalized_qf_version, qf_version_source = self._resolve_qf_version_input(qf_version)
86
+ normalized_credential = str(credential).strip()
87
+ if not normalized_credential:
88
+ raise_tool_error(QingflowApiError.config_error("credential is required"))
89
+
90
+ context_payload, detected_qf_version = self._fetch_auth_context(
91
+ normalized_base_url,
92
+ normalized_credential,
93
+ qf_version=normalized_qf_version,
94
+ )
95
+ token = self._normalize_text(context_payload.get("token"))
96
+ if not token:
97
+ raise_tool_error(QingflowApiError(category="auth", message="Credential context did not return a valid Qingflow token"))
98
+
99
+ response_qf_version = self._normalize_text(context_payload.get("qfVersion"))
100
+ resolved_qf_version, resolved_qf_version_source = self._resolve_backend_qf_version(
101
+ response_qf_version or detected_qf_version,
102
+ fallback_qf_version=normalized_qf_version,
103
+ fallback_source=qf_version_source,
104
+ )
105
+ resolved_base_url = self._normalize_text(context_payload.get("baseUrl")) or normalized_base_url
106
+ selected_ws_id = self._coerce_int(context_payload.get("wsId"))
107
+ if selected_ws_id is None or selected_ws_id <= 0:
108
+ raise_tool_error(QingflowApiError(category="auth", message="Credential context did not return a valid wsId"))
109
+ selected_ws_name = self._normalize_text(context_payload.get("wsName"))
110
+ uid = self._coerce_int(context_payload.get("uid"))
111
+ if uid is None:
112
+ raise_tool_error(QingflowApiError(category="auth", message="Credential context did not return valid user info"))
113
+
114
+ session_profile = self.sessions.save_session(
115
+ profile=profile,
116
+ base_url=resolved_base_url,
117
+ qf_version=resolved_qf_version,
118
+ qf_version_source=resolved_qf_version_source,
119
+ token=token,
120
+ login_token=None,
121
+ credential=normalized_credential,
122
+ uid=uid,
123
+ email=self._normalize_text(context_payload.get("email")),
124
+ nick_name=self._normalize_text(
125
+ context_payload.get("nickName")
126
+ or context_payload.get("displayName")
127
+ or context_payload.get("name")
128
+ ),
129
+ persist=persist,
130
+ )
131
+ session_profile = self.sessions.select_workspace(profile, ws_id=selected_ws_id, ws_name=selected_ws_name)
132
+
133
+ return {
134
+ "profile": session_profile.profile,
135
+ "base_url": session_profile.base_url,
136
+ "qf_version": session_profile.qf_version,
137
+ "qf_version_source": session_profile.qf_version_source,
138
+ "uid": session_profile.uid,
139
+ "email": session_profile.email,
140
+ "nick_name": session_profile.nick_name,
141
+ "selected_ws_id": session_profile.selected_ws_id,
142
+ "selected_ws_name": session_profile.selected_ws_name,
143
+ "suggested_ws_id": session_profile.selected_ws_id,
144
+ "suggested_ws_name": session_profile.selected_ws_name,
145
+ "persisted": session_profile.persisted,
146
+ "request_route": self._request_route_payload(
147
+ BackendRequestContext(
148
+ base_url=session_profile.base_url,
149
+ token=token,
150
+ ws_id=session_profile.selected_ws_id,
151
+ qf_version=session_profile.qf_version,
152
+ qf_version_source=session_profile.qf_version_source,
153
+ )
154
+ ),
155
+ }
156
+
157
+ @tool_cn_name("我的身份")
158
+ def auth_whoami(self, *, profile: str = DEFAULT_PROFILE) -> dict[str, Any]:
159
+ """执行认证与会话相关逻辑。"""
160
+ def build_response(
161
+ session_profile, # type: ignore[no-untyped-def]
162
+ backend_session, # type: ignore[no-untyped-def]
163
+ context: BackendRequestContext,
164
+ ) -> dict[str, Any]:
165
+ if self._should_refresh_identity_metadata(session_profile):
166
+ refreshed_profile = self._refresh_identity_metadata(
167
+ profile=profile,
168
+ session_profile=session_profile,
169
+ backend_session=backend_session,
170
+ context=context,
171
+ )
172
+ if refreshed_profile is not None:
173
+ session_profile = refreshed_profile
174
+ response = {
175
+ "profile": session_profile.profile,
176
+ "base_url": session_profile.base_url,
177
+ "qf_version": session_profile.qf_version,
178
+ "qf_version_source": session_profile.qf_version_source,
179
+ "uid": session_profile.uid,
180
+ "email": session_profile.email,
181
+ "nick_name": session_profile.nick_name,
182
+ "selected_ws_id": session_profile.selected_ws_id,
183
+ "selected_ws_name": session_profile.selected_ws_name,
184
+ "persisted": session_profile.persisted,
185
+ "request_route": self._request_route_payload(context),
186
+ }
187
+ member_info, member_warnings = self._workspace_member_info(
188
+ session_profile=session_profile,
189
+ backend_session=backend_session,
190
+ )
191
+ response.update(member_info)
192
+ if member_warnings:
193
+ response["warnings"] = member_warnings
194
+ return response
195
+
196
+ session_profile = None
197
+ backend_session = None
198
+ try:
199
+ session_profile, backend_session, context = self._require_context(profile, require_workspace=False)
200
+ if backend_session.credential:
201
+ self._probe_token_validity(
202
+ session_profile=session_profile,
203
+ backend_session=backend_session,
204
+ )
205
+ return build_response(session_profile, backend_session, context)
206
+ except QingflowApiError as error:
207
+ if (
208
+ error.looks_like_invalid_token()
209
+ and session_profile is not None
210
+ and backend_session is not None
211
+ and self._refresh_session_from_credential(
212
+ profile,
213
+ session_profile=session_profile,
214
+ backend_session=backend_session,
215
+ )
216
+ ):
217
+ try:
218
+ refreshed_profile, refreshed_backend_session, refreshed_context = self._require_context(
219
+ profile,
220
+ require_workspace=False,
221
+ )
222
+ return build_response(refreshed_profile, refreshed_backend_session, refreshed_context)
223
+ except QingflowApiError as refreshed_error:
224
+ self._handle_error(profile, refreshed_error)
225
+ self._handle_error(profile, error)
226
+ raise AssertionError("unreachable")
227
+
228
+ def _probe_token_validity(
229
+ self,
230
+ *,
231
+ session_profile, # type: ignore[no-untyped-def]
232
+ backend_session, # type: ignore[no-untyped-def]
233
+ ) -> None:
234
+ """执行内部辅助逻辑。"""
235
+ probe_context = BackendRequestContext(
236
+ base_url=backend_session.base_url,
237
+ token=backend_session.token,
238
+ ws_id=session_profile.selected_ws_id,
239
+ qf_version=backend_session.qf_version,
240
+ qf_version_source=backend_session.qf_version_source,
241
+ )
242
+ try:
243
+ self.backend.request("GET", probe_context, "/user")
244
+ except QingflowApiError as error:
245
+ if error.looks_like_invalid_token():
246
+ raise
247
+
248
+ @tool_cn_name("退出登录")
249
+ def auth_logout(self, *, profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict[str, Any]:
250
+ """执行认证与会话相关逻辑。"""
251
+ if not self.sessions.has_profile(profile):
252
+ raise_tool_error(QingflowApiError.auth_required(profile))
253
+ self.sessions.logout(profile, forget_persisted=forget_persisted)
254
+ return {
255
+ "profile": profile,
256
+ "logged_out": True,
257
+ "forgot_persisted": forget_persisted,
258
+ }
259
+
260
+ def _normalize_base_url(self, base_url: str | None) -> str:
261
+ """执行内部辅助逻辑。"""
262
+ normalized_base_url = normalize_base_url(base_url) or get_default_base_url()
263
+ if not normalized_base_url:
264
+ raise_tool_error(
265
+ QingflowApiError.config_error(
266
+ "base_url is required or configure default_base_url / QINGFLOW_MCP_DEFAULT_BASE_URL"
267
+ )
268
+ )
269
+ return normalized_base_url
270
+
271
+ def _normalize_qf_version(self, qf_version: str | None) -> str | None:
272
+ """执行内部辅助逻辑。"""
273
+ if qf_version is not None:
274
+ normalized = str(qf_version).strip()
275
+ return normalized or None
276
+ return get_default_qf_version()
277
+
278
+ def _resolve_qf_version_input(self, qf_version: str | None) -> tuple[str | None, str]:
279
+ """执行内部辅助逻辑。"""
280
+ if qf_version is not None:
281
+ normalized = self._normalize_qf_version(qf_version)
282
+ return normalized, "explicit" if normalized else "unset"
283
+ normalized = self._normalize_qf_version(None)
284
+ if normalized:
285
+ return normalized, "default_config"
286
+ return None, "unset"
287
+
288
+ def _resolve_backend_qf_version(
289
+ self,
290
+ backend_qf_version: str | None,
291
+ *,
292
+ fallback_qf_version: str | None,
293
+ fallback_source: str,
294
+ ) -> tuple[str | None, str]:
295
+ """执行内部辅助逻辑。"""
296
+ if backend_qf_version:
297
+ return backend_qf_version, "backend_response"
298
+ return fallback_qf_version, fallback_source
299
+
300
+ def _fetch_auth_context(
301
+ self,
302
+ base_url: str,
303
+ credential: str,
304
+ *,
305
+ qf_version: str | None,
306
+ ) -> tuple[dict[str, Any], str | None]:
307
+ """执行内部辅助逻辑。"""
308
+ response = self.backend.public_request_with_meta(
309
+ "POST",
310
+ base_url,
311
+ "/mcp/auth/context",
312
+ json_body={"credential": credential},
313
+ qf_version=qf_version,
314
+ )
315
+ payload = self._unwrap_auth_context_payload(response.data)
316
+ return payload, response.qf_response_version
317
+
318
+ def _unwrap_auth_context_payload(self, payload: Any) -> dict[str, Any]:
319
+ """执行内部辅助逻辑。"""
320
+ if not isinstance(payload, dict):
321
+ raise_tool_error(QingflowApiError(category="auth", message="Credential context did not return a valid result"))
322
+ for key in ("data", "result"):
323
+ nested = payload.get(key)
324
+ if isinstance(nested, dict):
325
+ return nested
326
+ return payload
327
+
328
+ def _workspace_system_version(self, workspace: Any) -> str | None:
329
+ """执行内部辅助逻辑。"""
330
+ if not isinstance(workspace, dict):
331
+ return None
332
+ value = workspace.get("systemVersion")
333
+ if value is None:
334
+ return None
335
+ normalized = str(value).strip()
336
+ return normalized or None
337
+
338
+ def _fetch_user_info(
339
+ self,
340
+ base_url: str,
341
+ token: str,
342
+ ws_id: int | None,
343
+ *,
344
+ qf_version: str | None,
345
+ qf_version_source: str | None,
346
+ ) -> tuple[dict[str, Any], str | None]:
347
+ """执行内部辅助逻辑。"""
348
+ request_context = BackendRequestContext(
349
+ base_url=base_url,
350
+ token=token,
351
+ ws_id=ws_id,
352
+ qf_version=qf_version,
353
+ qf_version_source=qf_version_source,
354
+ )
355
+ try:
356
+ user_response = self.backend.request_with_meta("GET", request_context, "/user")
357
+ user_info = user_response.data
358
+ if isinstance(user_info, dict):
359
+ return user_info, user_response.qf_response_version
360
+ except QingflowApiError as original_error:
361
+ if ws_id is not None:
362
+ raise original_error
363
+ first_workspace, workspace_qf_version = self._fetch_first_workspace(
364
+ base_url,
365
+ token,
366
+ qf_version=qf_version,
367
+ qf_version_source=qf_version_source,
368
+ )
369
+ if not first_workspace:
370
+ raise original_error
371
+ first_ws_id = first_workspace.get("wsId")
372
+ if not first_ws_id:
373
+ raise original_error
374
+ effective_qf_version = workspace_qf_version or qf_version
375
+ effective_qf_version_source = "backend_response" if workspace_qf_version else qf_version_source
376
+ fallback_context = BackendRequestContext(
377
+ base_url=base_url,
378
+ token=token,
379
+ ws_id=int(first_ws_id),
380
+ qf_version=effective_qf_version,
381
+ qf_version_source=effective_qf_version_source,
382
+ )
383
+ user_response = self.backend.request_with_meta("GET", fallback_context, "/user")
384
+ user_info = user_response.data
385
+ if isinstance(user_info, dict):
386
+ return user_info, user_response.qf_response_version or effective_qf_version
387
+ raise original_error
388
+ raise_tool_error(QingflowApiError(category="auth", message="Token validation did not return valid user info"))
389
+
390
+ def _try_fetch_user_info(
391
+ self,
392
+ base_url: str,
393
+ token: str,
394
+ *,
395
+ qf_version: str | None,
396
+ qf_version_source: str | None,
397
+ ) -> tuple[dict[str, Any] | None, str | None]:
398
+ """执行内部辅助逻辑。"""
399
+ try:
400
+ return self._fetch_user_info(
401
+ base_url,
402
+ token,
403
+ None,
404
+ qf_version=qf_version,
405
+ qf_version_source=qf_version_source,
406
+ )
407
+ except QingflowApiError:
408
+ return None, None
409
+
410
+ def _fetch_first_workspace(
411
+ self,
412
+ base_url: str,
413
+ token: str,
414
+ *,
415
+ qf_version: str | None,
416
+ qf_version_source: str | None,
417
+ ) -> tuple[dict[str, Any] | None, str | None]:
418
+ """执行内部辅助逻辑。"""
419
+ page_response = self.backend.request_with_meta(
420
+ "POST",
421
+ BackendRequestContext(
422
+ base_url=base_url,
423
+ token=token,
424
+ ws_id=None,
425
+ qf_version=qf_version,
426
+ qf_version_source=qf_version_source,
427
+ ),
428
+ "/user/workspaceList/pageQuery",
429
+ json_body={"pageNum": 1, "pageSize": 1},
430
+ )
431
+ page = page_response.data
432
+ if not isinstance(page, dict):
433
+ return None, page_response.qf_response_version
434
+ workspaces = page.get("list") or []
435
+ if not workspaces:
436
+ return None, page_response.qf_response_version
437
+ first_workspace = workspaces[0]
438
+ return (first_workspace if isinstance(first_workspace, dict) else None), page_response.qf_response_version
439
+
440
+ def _fetch_workspace(
441
+ self,
442
+ base_url: str,
443
+ token: str,
444
+ ws_id: int,
445
+ *,
446
+ qf_version: str | None,
447
+ qf_version_source: str | None,
448
+ ) -> dict[str, Any]:
449
+ """执行内部辅助逻辑。"""
450
+ workspace = self.backend.request(
451
+ "GET",
452
+ BackendRequestContext(
453
+ base_url=base_url,
454
+ token=token,
455
+ ws_id=None,
456
+ qf_version=qf_version,
457
+ qf_version_source=qf_version_source,
458
+ ),
459
+ f"/user/workspace/{ws_id}",
460
+ )
461
+ if not isinstance(workspace, dict):
462
+ raise_tool_error(QingflowApiError(category="workspace", message=f"Workspace {ws_id} is not accessible"))
463
+ return workspace
464
+
465
+ def _request_route_payload(self, context: BackendRequestContext) -> dict[str, Any]:
466
+ """执行内部辅助逻辑。"""
467
+ describe_route = getattr(self.backend, "describe_route", None)
468
+ if callable(describe_route):
469
+ payload = describe_route(context)
470
+ if isinstance(payload, dict):
471
+ return payload
472
+ return {
473
+ "base_url": context.base_url,
474
+ "qf_version": context.qf_version,
475
+ "qf_version_source": context.qf_version_source or ("context" if context.qf_version else "unknown"),
476
+ }
477
+
478
+ def _should_refresh_identity_metadata(self, session_profile) -> bool: # type: ignore[no-untyped-def]
479
+ """执行内部辅助逻辑。"""
480
+ return (
481
+ session_profile.uid == 0
482
+ or session_profile.email is None
483
+ or session_profile.nick_name is None
484
+ or session_profile.selected_ws_name is None
485
+ )
486
+
487
+ def _refresh_identity_metadata(
488
+ self,
489
+ *,
490
+ profile: str,
491
+ session_profile, # type: ignore[no-untyped-def]
492
+ backend_session, # type: ignore[no-untyped-def]
493
+ context: BackendRequestContext,
494
+ ):
495
+ """执行内部辅助逻辑。"""
496
+ try:
497
+ user_info, _ = self._fetch_user_info(
498
+ session_profile.base_url,
499
+ backend_session.token,
500
+ session_profile.selected_ws_id,
501
+ qf_version=session_profile.qf_version,
502
+ qf_version_source=session_profile.qf_version_source,
503
+ )
504
+ except QingflowApiError:
505
+ return None
506
+
507
+ ws_name = session_profile.selected_ws_name
508
+ if session_profile.selected_ws_id is not None:
509
+ workspace = self._fetch_workspace_with_name_fallback(
510
+ session_profile.base_url,
511
+ backend_session.token,
512
+ session_profile.selected_ws_id,
513
+ qf_version=session_profile.qf_version,
514
+ qf_version_source=session_profile.qf_version_source,
515
+ )
516
+ if isinstance(workspace, dict):
517
+ ws_name = (
518
+ str(workspace.get("workspaceName") or workspace.get("wsName") or workspace.get("remark") or "").strip()
519
+ or ws_name
520
+ )
521
+ email = user_info["email"] if "email" in user_info else session_profile.email
522
+ nick_name = (
523
+ user_info.get("nickName")
524
+ or user_info.get("displayName")
525
+ or user_info.get("name")
526
+ or session_profile.nick_name
527
+ )
528
+
529
+ uid = user_info.get("uid")
530
+ refreshed = self.sessions.update_profile_metadata(
531
+ profile,
532
+ uid=int(uid) if uid is not None else session_profile.uid,
533
+ email=email,
534
+ nick_name=nick_name,
535
+ selected_ws_id=session_profile.selected_ws_id,
536
+ selected_ws_name=ws_name,
537
+ )
538
+ return refreshed
539
+
540
+ def _workspace_member_info(
541
+ self,
542
+ *,
543
+ session_profile, # type: ignore[no-untyped-def]
544
+ backend_session, # type: ignore[no-untyped-def]
545
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
546
+ """执行内部辅助逻辑。"""
547
+ default_payload = {
548
+ "departments": [],
549
+ "roles": [],
550
+ "permission_level": None,
551
+ }
552
+ ws_id = session_profile.selected_ws_id
553
+ if ws_id is None:
554
+ return default_payload, []
555
+
556
+ context = BackendRequestContext(
557
+ base_url=backend_session.base_url,
558
+ token=backend_session.token,
559
+ ws_id=ws_id,
560
+ qf_version=backend_session.qf_version,
561
+ qf_version_source=backend_session.qf_version_source,
562
+ )
563
+ permission_level = self._resolve_permission_level(
564
+ self._workspace_auth(context, ws_id=ws_id)
565
+ )
566
+ payload = dict(default_payload)
567
+ payload["permission_level"] = permission_level
568
+
569
+ member = self._lookup_current_member(
570
+ context=context,
571
+ uid=session_profile.uid,
572
+ email=session_profile.email,
573
+ nick_name=session_profile.nick_name,
574
+ )
575
+ if member is None:
576
+ return payload, [
577
+ {
578
+ "code": "CURRENT_MEMBER_PROFILE_UNAVAILABLE",
579
+ "message": (
580
+ "auth_whoami could not resolve current member departments and roles "
581
+ f"in workspace {ws_id}."
582
+ ),
583
+ }
584
+ ]
585
+
586
+ payload["departments"] = self._compact_departments(member)
587
+ payload["roles"] = self._compact_roles(member)
588
+ return payload, []
589
+
590
+ def _workspace_auth(self, context: BackendRequestContext, *, ws_id: int) -> int | None:
591
+ """执行内部辅助逻辑。"""
592
+ workspace = self._fetch_workspace_auth_from_detail(context, ws_id=ws_id)
593
+ if workspace is not None:
594
+ return workspace
595
+ return self._fetch_workspace_auth_from_list(context, ws_id=ws_id)
596
+
597
+ def _fetch_workspace_auth_from_detail(self, context: BackendRequestContext, *, ws_id: int) -> int | None:
598
+ """执行内部辅助逻辑。"""
599
+ try:
600
+ workspace = self.backend.request("GET", context, f"/user/workspace/{ws_id}")
601
+ except QingflowApiError:
602
+ return None
603
+ if not isinstance(workspace, dict):
604
+ return None
605
+ return self._coerce_auth_value(workspace.get("auth"))
606
+
607
+ def _fetch_workspace_auth_from_list(self, context: BackendRequestContext, *, ws_id: int) -> int | None:
608
+ """执行内部辅助逻辑。"""
609
+ try:
610
+ payload = self.backend.request(
611
+ "POST",
612
+ context,
613
+ "/user/workspaceList/pageQuery",
614
+ json_body={"pageNum": 1, "pageSize": 100, "authList": [0, 1, 2, 3]},
615
+ )
616
+ except QingflowApiError:
617
+ return None
618
+ workspaces = payload.get("list") if isinstance(payload, dict) else []
619
+ if not isinstance(workspaces, list):
620
+ return None
621
+ for item in workspaces:
622
+ if not isinstance(item, dict) or item.get("wsId") != ws_id:
623
+ continue
624
+ return self._coerce_auth_value(item.get("auth"))
625
+ return None
626
+
627
+ def _lookup_current_member(
628
+ self,
629
+ *,
630
+ context: BackendRequestContext,
631
+ uid: int | None,
632
+ email: str | None,
633
+ nick_name: str | None,
634
+ ) -> dict[str, Any] | None:
635
+ """执行内部辅助逻辑。"""
636
+ candidates: list[dict[str, Any]] = []
637
+ for keyword in (email, nick_name):
638
+ member = self._search_member_once(context, uid=uid, keyword=keyword)
639
+ if member is not None:
640
+ return member
641
+ if keyword:
642
+ candidates.extend(self._search_member_items(context, keyword=keyword))
643
+ if uid is not None and uid > 0:
644
+ for item in candidates:
645
+ if self._same_member(item, uid=uid):
646
+ return item
647
+ return self._search_member_once(context, uid=uid, keyword=None)
648
+ return None
649
+
650
+ def _search_member_once(
651
+ self,
652
+ context: BackendRequestContext,
653
+ *,
654
+ uid: int | None,
655
+ keyword: str | None,
656
+ ) -> dict[str, Any] | None:
657
+ """执行内部辅助逻辑。"""
658
+ for item in self._search_member_items(context, keyword=keyword):
659
+ if self._same_member(item, uid=uid):
660
+ return item
661
+ return None
662
+
663
+ def _search_member_items(self, context: BackendRequestContext, *, keyword: str | None) -> list[dict[str, Any]]:
664
+ """执行内部辅助逻辑。"""
665
+ params: dict[str, Any] = {"pageNum": 1, "pageSize": 100, "containDisable": True}
666
+ normalized_keyword = str(keyword or "").strip()
667
+ if normalized_keyword:
668
+ params["keyword"] = normalized_keyword
669
+ try:
670
+ payload = self.backend.request("GET", context, "/contact", params=params)
671
+ except QingflowApiError:
672
+ return []
673
+ items = self._extract_items(payload)
674
+ return [item for item in items if isinstance(item, dict)]
675
+
676
+ def _same_member(self, item: dict[str, Any], *, uid: int | None) -> bool:
677
+ """执行内部辅助逻辑。"""
678
+ if uid is None or uid <= 0:
679
+ return False
680
+ for key in ("uid", "id", "userId"):
681
+ value = item.get(key)
682
+ if value is None:
683
+ continue
684
+ coerced = self._coerce_int(value)
685
+ if coerced is not None and coerced == uid:
686
+ return True
687
+ if str(value).strip() == str(uid):
688
+ return True
689
+ return False
690
+
691
+ def _compact_departments(self, member: dict[str, Any]) -> list[dict[str, Any]]:
692
+ """执行内部辅助逻辑。"""
693
+ items: list[dict[str, Any]] = []
694
+ seen: set[tuple[int | None, str | None]] = set()
695
+ for depart in self._walk_nested_items(member.get("departs")):
696
+ if not isinstance(depart, dict):
697
+ continue
698
+ dept_id = self._coerce_int(
699
+ depart.get("deptId", depart.get("departId", depart.get("id")))
700
+ )
701
+ dept_name = self._normalize_text(
702
+ depart.get("deptName", depart.get("departName", depart.get("name")))
703
+ )
704
+ key = (dept_id, dept_name)
705
+ if key in seen or (dept_id is None and dept_name is None):
706
+ continue
707
+ seen.add(key)
708
+ item = {"dept_id": dept_id, "dept_name": dept_name}
709
+ items.append({k: v for k, v in item.items() if v is not None})
710
+ return items
711
+
712
+ def _compact_roles(self, member: dict[str, Any]) -> list[dict[str, Any]]:
713
+ """执行内部辅助逻辑。"""
714
+ items: list[dict[str, Any]] = []
715
+ seen: set[tuple[int | None, str | None]] = set()
716
+ for role in self._walk_nested_items(member.get("roles")):
717
+ if not isinstance(role, dict):
718
+ continue
719
+ role_id = self._coerce_int(role.get("roleId", role.get("id")))
720
+ role_name = self._normalize_text(role.get("roleName", role.get("name")))
721
+ key = (role_id, role_name)
722
+ if key in seen or (role_id is None and role_name is None):
723
+ continue
724
+ seen.add(key)
725
+ item = {"role_id": role_id, "role_name": role_name}
726
+ items.append({k: v for k, v in item.items() if v is not None})
727
+ return items
728
+
729
+ def _resolve_permission_level(self, auth_code: int | None) -> str | None:
730
+ """执行内部辅助逻辑。"""
731
+ mapping = {
732
+ 2: "超级管理",
733
+ 1: "系统管理员",
734
+ 3: "子管理员",
735
+ 0: "基本成员",
736
+ }
737
+ return mapping.get(auth_code)
738
+
739
+ def _coerce_auth_value(self, value: Any) -> int | None:
740
+ """执行内部辅助逻辑。"""
741
+ coerced = self._coerce_int(value)
742
+ if coerced is not None:
743
+ return coerced
744
+ normalized = self._normalize_text(value)
745
+ if normalized is None:
746
+ return None
747
+ lowered = normalized.lower()
748
+ if lowered in {"creator", "workspaccreator", "workspacecreator"}:
749
+ return 2
750
+ if lowered in {"admin", "administrator"}:
751
+ return 1
752
+ if lowered in {"subadmin", "dataadmin"}:
753
+ return 3
754
+ if lowered in {"member", "visitor", "normal"}:
755
+ return 0
756
+ return None
757
+
758
+ def _extract_items(self, payload: Any) -> list[Any]:
759
+ """执行内部辅助逻辑。"""
760
+ if isinstance(payload, list):
761
+ return payload
762
+ if not isinstance(payload, dict):
763
+ return []
764
+ for key in ("list", "items", "rows", "result"):
765
+ value = payload.get(key)
766
+ if isinstance(value, list):
767
+ return value
768
+ for key in ("data", "page"):
769
+ nested = payload.get(key)
770
+ if isinstance(nested, list):
771
+ return nested
772
+ if isinstance(nested, dict):
773
+ for nested_key in ("list", "items", "rows", "result"):
774
+ value = nested.get(nested_key)
775
+ if isinstance(value, list):
776
+ return value
777
+ return []
778
+
779
+ def _walk_nested_items(self, value: Any) -> list[Any]:
780
+ """执行内部辅助逻辑。"""
781
+ if isinstance(value, list):
782
+ items: list[Any] = []
783
+ for item in value:
784
+ items.extend(self._walk_nested_items(item))
785
+ return items
786
+ return [value]
787
+
788
+ def _coerce_int(self, value: Any) -> int | None:
789
+ """执行内部辅助逻辑。"""
790
+ if isinstance(value, bool) or value is None:
791
+ return None
792
+ if isinstance(value, int):
793
+ return value
794
+ try:
795
+ return int(str(value).strip())
796
+ except (TypeError, ValueError):
797
+ return None
798
+
799
+ def _normalize_text(self, value: Any) -> str | None:
800
+ """执行内部辅助逻辑。"""
801
+ if value is None:
802
+ return None
803
+ text = str(value).strip()
804
+ return text or None
805
+
806
+ def _fetch_workspace_with_name_fallback(
807
+ self,
808
+ base_url: str,
809
+ token: str,
810
+ ws_id: int,
811
+ *,
812
+ qf_version: str | None,
813
+ qf_version_source: str | None,
814
+ ) -> dict[str, Any] | None:
815
+ """执行内部辅助逻辑。"""
816
+ try:
817
+ workspace = self._fetch_workspace(
818
+ base_url,
819
+ token,
820
+ ws_id,
821
+ qf_version=qf_version,
822
+ qf_version_source=qf_version_source,
823
+ )
824
+ except QingflowApiError:
825
+ workspace = None
826
+ if isinstance(workspace, dict):
827
+ workspace_name = str(workspace.get("workspaceName") or workspace.get("wsName") or "").strip()
828
+ if workspace_name:
829
+ return workspace
830
+ try:
831
+ fallback = self._fetch_workspace_from_list(
832
+ base_url,
833
+ token,
834
+ ws_id,
835
+ qf_version=qf_version,
836
+ qf_version_source=qf_version_source,
837
+ )
838
+ except QingflowApiError:
839
+ fallback = None
840
+ return fallback or workspace
841
+
842
+ def _fetch_workspace_from_list(
843
+ self,
844
+ base_url: str,
845
+ token: str,
846
+ ws_id: int,
847
+ *,
848
+ qf_version: str | None,
849
+ qf_version_source: str | None,
850
+ ) -> dict[str, Any] | None:
851
+ """执行内部辅助逻辑。"""
852
+ payload = self.backend.request(
853
+ "POST",
854
+ BackendRequestContext(
855
+ base_url=base_url,
856
+ token=token,
857
+ ws_id=ws_id,
858
+ qf_version=qf_version,
859
+ qf_version_source=qf_version_source,
860
+ ),
861
+ "/user/workspaceList/pageQuery",
862
+ json_body={"pageNum": 1, "pageSize": 100, "authList": [0, 1, 2]},
863
+ )
864
+ workspaces = payload.get("list") if isinstance(payload, dict) else []
865
+ if not isinstance(workspaces, list):
866
+ return None
867
+ found = next(
868
+ (
869
+ item
870
+ for item in workspaces
871
+ if isinstance(item, dict) and item.get("wsId") == ws_id
872
+ ),
873
+ None,
874
+ )
875
+ return found if isinstance(found, dict) else None