@qingflow-tech/qingflow-app-user-mcp 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/skills/qingflow-app-user/SKILL.md +2 -1
- package/skills/qingflow-app-user/references/data-gotchas.md +5 -2
- package/skills/qingflow-app-user/references/public-surface-sync.md +5 -3
- package/skills/qingflow-app-user/references/record-patterns.md +9 -0
- package/skills/qingflow-record-analysis/SKILL.md +103 -166
- package/skills/qingflow-record-analysis/agents/openai.yaml +2 -2
- package/skills/qingflow-record-analysis/references/analysis-gotchas.md +56 -110
- package/skills/qingflow-record-analysis/references/analysis-patterns.md +106 -119
- package/skills/qingflow-record-analysis/references/business-context.md +74 -0
- package/skills/qingflow-record-analysis/references/confidence-reporting.md +49 -72
- package/skills/qingflow-record-analysis/references/data-access-playbook.md +106 -0
- package/skills/qingflow-record-analysis/references/pandas-recipes.md +172 -0
- package/skills/qingflow-record-analysis/references/report-format.md +76 -0
- package/skills/qingflow-record-insert/SKILL.md +2 -2
- package/skills/qingflow-record-update/SKILL.md +1 -1
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/backend_client.py +164 -1
- package/src/qingflow_mcp/builder_facade/button_style_catalog.py +282 -0
- package/src/qingflow_mcp/builder_facade/models.py +44 -5
- package/src/qingflow_mcp/builder_facade/service.py +21 -8
- package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
- package/src/qingflow_mcp/cli/commands/app.py +47 -1
- package/src/qingflow_mcp/cli/commands/builder.py +7 -0
- package/src/qingflow_mcp/cli/commands/exports.py +111 -0
- package/src/qingflow_mcp/cli/commands/record.py +44 -5
- package/src/qingflow_mcp/cli/commands/task.py +644 -22
- package/src/qingflow_mcp/cli/commands/workspace.py +64 -2
- package/src/qingflow_mcp/cli/context.py +3 -0
- package/src/qingflow_mcp/cli/formatters.py +240 -5
- package/src/qingflow_mcp/cli/interaction.py +72 -0
- package/src/qingflow_mcp/cli/main.py +5 -0
- package/src/qingflow_mcp/cli/terminal_ui.py +218 -0
- package/src/qingflow_mcp/errors.py +2 -2
- package/src/qingflow_mcp/export_store.py +14 -0
- package/src/qingflow_mcp/public_surface.py +7 -1
- package/src/qingflow_mcp/response_trim.py +188 -10
- package/src/qingflow_mcp/server.py +37 -9
- package/src/qingflow_mcp/server_app_builder.py +4 -0
- package/src/qingflow_mcp/server_app_user.py +115 -10
- package/src/qingflow_mcp/session_store.py +57 -6
- package/src/qingflow_mcp/tools/ai_builder_tools.py +59 -16
- package/src/qingflow_mcp/tools/auth_tools.py +26 -0
- package/src/qingflow_mcp/tools/custom_button_tools.py +0 -2
- package/src/qingflow_mcp/tools/export_tools.py +1565 -0
- package/src/qingflow_mcp/tools/import_tools.py +42 -2
- package/src/qingflow_mcp/tools/record_tools.py +12793 -8612
- package/src/qingflow_mcp/tools/resource_read_tools.py +40 -1
- package/src/qingflow_mcp/tools/task_context_tools.py +26 -8
- package/skills/qingflow-record-analysis/references/dsl-templates.md +0 -93
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import select
|
|
5
|
+
import shutil
|
|
6
|
+
import textwrap
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Generic, Sequence, TextIO, TypeVar
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import termios
|
|
12
|
+
import tty
|
|
13
|
+
except ImportError: # pragma: no cover - non-POSIX fallback
|
|
14
|
+
termios = None
|
|
15
|
+
tty = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
RAW_TTY_NEWLINE = "\r\n"
|
|
20
|
+
# Give terminal escape sequences a slightly roomier window so arrow keys
|
|
21
|
+
# still parse correctly when an outer Node/npm launcher adds a bit of PTY lag.
|
|
22
|
+
ESCAPE_SEQUENCE_TIMEOUT_SECONDS = 0.2
|
|
23
|
+
ESCAPE_SEQUENCE_FOLLOWUP_TIMEOUT_SECONDS = 0.02
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class SelectionOption(Generic[T]):
|
|
28
|
+
value: T
|
|
29
|
+
label: str
|
|
30
|
+
hint: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def select_option(
|
|
34
|
+
*,
|
|
35
|
+
title: str,
|
|
36
|
+
options: Sequence[SelectionOption[T]],
|
|
37
|
+
input_stream: TextIO,
|
|
38
|
+
output_stream: TextIO,
|
|
39
|
+
page_size: int = 8,
|
|
40
|
+
) -> T | None:
|
|
41
|
+
if not options:
|
|
42
|
+
return None
|
|
43
|
+
if len(options) == 1:
|
|
44
|
+
return options[0].value
|
|
45
|
+
if not _supports_raw_selection(input_stream=input_stream, output_stream=output_stream):
|
|
46
|
+
return _select_option_via_prompt(title=title, options=options, input_stream=input_stream, output_stream=output_stream)
|
|
47
|
+
return _select_option_via_raw_terminal(
|
|
48
|
+
title=title,
|
|
49
|
+
options=options,
|
|
50
|
+
input_stream=input_stream,
|
|
51
|
+
output_stream=output_stream,
|
|
52
|
+
page_size=max(3, page_size),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _supports_raw_selection(*, input_stream: TextIO, output_stream: TextIO) -> bool:
|
|
57
|
+
if termios is None or tty is None:
|
|
58
|
+
return False
|
|
59
|
+
if not bool(getattr(input_stream, "isatty", lambda: False)()) or not bool(getattr(output_stream, "isatty", lambda: False)()):
|
|
60
|
+
return False
|
|
61
|
+
return hasattr(input_stream, "fileno")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _select_option_via_prompt(
|
|
65
|
+
*,
|
|
66
|
+
title: str,
|
|
67
|
+
options: Sequence[SelectionOption[T]],
|
|
68
|
+
input_stream: TextIO,
|
|
69
|
+
output_stream: TextIO,
|
|
70
|
+
) -> T | None:
|
|
71
|
+
output_stream.write(title + "\n")
|
|
72
|
+
for index, option in enumerate(options, start=1):
|
|
73
|
+
suffix = f" ({option.hint})" if option.hint else ""
|
|
74
|
+
output_stream.write(f"{index}. {option.label}{suffix}\n")
|
|
75
|
+
output_stream.write("请输入编号并回车,留空取消: ")
|
|
76
|
+
output_stream.flush()
|
|
77
|
+
line = input_stream.readline()
|
|
78
|
+
selected = str(line or "").strip()
|
|
79
|
+
if not selected:
|
|
80
|
+
return None
|
|
81
|
+
if not selected.isdigit():
|
|
82
|
+
return None
|
|
83
|
+
index = int(selected) - 1
|
|
84
|
+
if index < 0 or index >= len(options):
|
|
85
|
+
return None
|
|
86
|
+
return options[index].value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _select_option_via_raw_terminal(
|
|
90
|
+
*,
|
|
91
|
+
title: str,
|
|
92
|
+
options: Sequence[SelectionOption[T]],
|
|
93
|
+
input_stream: TextIO,
|
|
94
|
+
output_stream: TextIO,
|
|
95
|
+
page_size: int,
|
|
96
|
+
) -> T | None:
|
|
97
|
+
fd = input_stream.fileno()
|
|
98
|
+
original_mode = termios.tcgetattr(fd)
|
|
99
|
+
selected_index = 0
|
|
100
|
+
output_stream.write("\x1b[?1049h\x1b[?25l")
|
|
101
|
+
output_stream.flush()
|
|
102
|
+
try:
|
|
103
|
+
tty.setraw(fd)
|
|
104
|
+
while True:
|
|
105
|
+
_render_options(
|
|
106
|
+
title=title,
|
|
107
|
+
options=options,
|
|
108
|
+
selected_index=selected_index,
|
|
109
|
+
output_stream=output_stream,
|
|
110
|
+
page_size=page_size,
|
|
111
|
+
)
|
|
112
|
+
key = _read_key(input_stream)
|
|
113
|
+
if key in ("\r", "\n"):
|
|
114
|
+
return options[selected_index].value
|
|
115
|
+
if key in ("\x03", "\x1b", "q", "Q"):
|
|
116
|
+
return None
|
|
117
|
+
if key in ("\x1b[A", "k", "K"):
|
|
118
|
+
selected_index = (selected_index - 1) % len(options)
|
|
119
|
+
continue
|
|
120
|
+
if key in ("\x1b[B", "j", "J"):
|
|
121
|
+
selected_index = (selected_index + 1) % len(options)
|
|
122
|
+
finally:
|
|
123
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, original_mode)
|
|
124
|
+
output_stream.write("\x1b[?25h\x1b[?1049l")
|
|
125
|
+
output_stream.flush()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _render_options(
|
|
129
|
+
*,
|
|
130
|
+
title: str,
|
|
131
|
+
options: Sequence[SelectionOption[object]],
|
|
132
|
+
selected_index: int,
|
|
133
|
+
output_stream: TextIO,
|
|
134
|
+
page_size: int,
|
|
135
|
+
) -> None:
|
|
136
|
+
terminal_width = shutil.get_terminal_size((100, 20)).columns
|
|
137
|
+
total = len(options)
|
|
138
|
+
page_start = max(0, min(selected_index - page_size // 2, max(total - page_size, 0)))
|
|
139
|
+
visible = options[page_start: page_start + page_size]
|
|
140
|
+
lines = _render_multiline_text(title, width=terminal_width)
|
|
141
|
+
lines.extend(
|
|
142
|
+
[
|
|
143
|
+
"↑/↓ 或 j/k 选择,Enter 确认,q / Esc 取消",
|
|
144
|
+
"",
|
|
145
|
+
]
|
|
146
|
+
)
|
|
147
|
+
for offset, option in enumerate(visible, start=page_start):
|
|
148
|
+
marker = ">" if offset == selected_index else " "
|
|
149
|
+
suffix = f" [{option.hint}]" if option.hint else ""
|
|
150
|
+
lines.append(_truncate_line(f"{marker} {option.label}{suffix}", width=terminal_width))
|
|
151
|
+
if total > page_size:
|
|
152
|
+
lines.append("")
|
|
153
|
+
lines.append(f"{selected_index + 1}/{total}")
|
|
154
|
+
output_stream.write("\x1b[2J\x1b[H")
|
|
155
|
+
output_stream.write(RAW_TTY_NEWLINE.join(lines))
|
|
156
|
+
output_stream.flush()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _truncate_line(text: str, *, width: int) -> str:
|
|
160
|
+
if width <= 0 or len(text) <= width:
|
|
161
|
+
return text
|
|
162
|
+
if width <= 1:
|
|
163
|
+
return text[:width]
|
|
164
|
+
return text[: width - 1] + "…"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _render_multiline_text(text: str, *, width: int) -> list[str]:
|
|
168
|
+
parts = text.splitlines() or [text]
|
|
169
|
+
rendered: list[str] = []
|
|
170
|
+
wrap_width = max(1, width)
|
|
171
|
+
for part in parts:
|
|
172
|
+
if not part:
|
|
173
|
+
rendered.append("")
|
|
174
|
+
continue
|
|
175
|
+
initial_indent = ""
|
|
176
|
+
subsequent_indent = ""
|
|
177
|
+
stripped = part.lstrip()
|
|
178
|
+
if stripped.startswith("- "):
|
|
179
|
+
leading_spaces = len(part) - len(stripped)
|
|
180
|
+
initial_indent = part[:leading_spaces] + "- "
|
|
181
|
+
subsequent_indent = part[:leading_spaces] + " "
|
|
182
|
+
content = stripped[2:]
|
|
183
|
+
else:
|
|
184
|
+
content = part
|
|
185
|
+
wrapped = textwrap.wrap(
|
|
186
|
+
content,
|
|
187
|
+
width=wrap_width,
|
|
188
|
+
initial_indent=initial_indent,
|
|
189
|
+
subsequent_indent=subsequent_indent,
|
|
190
|
+
replace_whitespace=False,
|
|
191
|
+
drop_whitespace=False,
|
|
192
|
+
break_long_words=True,
|
|
193
|
+
)
|
|
194
|
+
rendered.extend(wrapped or [""])
|
|
195
|
+
return rendered
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _read_key(input_stream: TextIO) -> str:
|
|
199
|
+
fd = input_stream.fileno()
|
|
200
|
+
first_bytes = os.read(fd, 1)
|
|
201
|
+
if not first_bytes:
|
|
202
|
+
return ""
|
|
203
|
+
first = first_bytes.decode("utf-8", errors="ignore")
|
|
204
|
+
if first != "\x1b":
|
|
205
|
+
return first
|
|
206
|
+
chunks = [first]
|
|
207
|
+
if not select.select([fd], [], [], ESCAPE_SEQUENCE_TIMEOUT_SECONDS)[0]:
|
|
208
|
+
return first
|
|
209
|
+
while True:
|
|
210
|
+
chunk = os.read(fd, 1).decode("utf-8", errors="ignore")
|
|
211
|
+
if not chunk:
|
|
212
|
+
break
|
|
213
|
+
chunks.append(chunk)
|
|
214
|
+
if len(chunks) >= 3:
|
|
215
|
+
break
|
|
216
|
+
if not select.select([fd], [], [], ESCAPE_SEQUENCE_FOLLOWUP_TIMEOUT_SECONDS)[0]:
|
|
217
|
+
break
|
|
218
|
+
return "".join(chunks)
|
|
@@ -54,8 +54,8 @@ class QingflowApiError(Exception):
|
|
|
54
54
|
)
|
|
55
55
|
|
|
56
56
|
@classmethod
|
|
57
|
-
def config_error(cls, message: str) -> "QingflowApiError":
|
|
58
|
-
return cls(category="config", message=message)
|
|
57
|
+
def config_error(cls, message: str, *, details: JSONObject | None = None) -> "QingflowApiError":
|
|
58
|
+
return cls(category="config", message=message, details=details)
|
|
59
59
|
|
|
60
60
|
@classmethod
|
|
61
61
|
def not_supported(cls, message: str) -> "QingflowApiError":
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .import_store import _JsonEntryStore, _store_dir
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ExportJobStore(_JsonEntryStore):
|
|
10
|
+
def __init__(self, base_dir: Path | None = None, *, ttl_seconds: int = 24 * 3600) -> None:
|
|
11
|
+
super().__init__(
|
|
12
|
+
base_dir=base_dir or _store_dir("QINGFLOW_MCP_EXPORT_JOB_HOME", "export-jobs"),
|
|
13
|
+
ttl=timedelta(seconds=ttl_seconds),
|
|
14
|
+
)
|
|
@@ -80,8 +80,9 @@ USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
80
80
|
),
|
|
81
81
|
PublicToolSpec(USER_DOMAIN, "record_member_candidates", ("record_member_candidates",), cli_public=False),
|
|
82
82
|
PublicToolSpec(USER_DOMAIN, "record_department_candidates", ("record_department_candidates",), cli_public=False),
|
|
83
|
-
PublicToolSpec(USER_DOMAIN, "record_analyze", ("record_analyze",), ("record", "analyze")),
|
|
83
|
+
PublicToolSpec(USER_DOMAIN, "record_analyze", ("record_analyze",), ("record", "analyze"), mcp_public=False),
|
|
84
84
|
PublicToolSpec(USER_DOMAIN, "record_list", ("record_list",), ("record", "list"), cli_show_effective_context=True),
|
|
85
|
+
PublicToolSpec(USER_DOMAIN, "record_access", ("record_access",), ("record", "access"), cli_show_effective_context=True),
|
|
85
86
|
PublicToolSpec(USER_DOMAIN, "record_get", ("record_get_public",), ("record", "get"), cli_show_effective_context=True),
|
|
86
87
|
PublicToolSpec(USER_DOMAIN, "record_insert", ("record_insert_public",), ("record", "insert"), cli_show_effective_context=True, cli_context_write=True),
|
|
87
88
|
PublicToolSpec(USER_DOMAIN, "record_update", ("record_update_public",), ("record", "update"), cli_show_effective_context=True, cli_context_write=True),
|
|
@@ -91,6 +92,10 @@ USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
91
92
|
PublicToolSpec(USER_DOMAIN, "record_import_repair_local", ("record_import_repair_local",), ("import", "repair")),
|
|
92
93
|
PublicToolSpec(USER_DOMAIN, "record_import_start", ("record_import_start",), ("import", "start")),
|
|
93
94
|
PublicToolSpec(USER_DOMAIN, "record_import_status_get", ("record_import_status_get",), ("import", "status")),
|
|
95
|
+
PublicToolSpec(USER_DOMAIN, "record_export_start", ("record_export_start",), ("export", "start"), cli_show_effective_context=True),
|
|
96
|
+
PublicToolSpec(USER_DOMAIN, "record_export_status_get", ("record_export_status_get",), ("export", "status"), cli_show_effective_context=True),
|
|
97
|
+
PublicToolSpec(USER_DOMAIN, "record_export_get", ("record_export_get",), ("export", "get"), cli_show_effective_context=True),
|
|
98
|
+
PublicToolSpec(USER_DOMAIN, "record_export_direct", ("record_export_direct",), ("export", "direct"), cli_show_effective_context=True),
|
|
94
99
|
PublicToolSpec(USER_DOMAIN, "record_code_block_run", ("record_code_block_run",), ("record", "code-block-run"), cli_show_effective_context=True, cli_context_write=True),
|
|
95
100
|
PublicToolSpec(USER_DOMAIN, "task_list", ("task_list",), ("task", "list"), cli_show_effective_context=True),
|
|
96
101
|
PublicToolSpec(USER_DOMAIN, "task_get", ("task_get",), ("task", "get"), cli_show_effective_context=True),
|
|
@@ -130,6 +135,7 @@ BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
130
135
|
PublicToolSpec(BUILDER_DOMAIN, "role_create", ("role_create",), ("builder", "role", "create"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|
|
131
136
|
PublicToolSpec(BUILDER_DOMAIN, "app_release_edit_lock_if_mine", ("app_release_edit_lock_if_mine",), ("builder", "app", "release-edit-lock-if-mine"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|
|
132
137
|
PublicToolSpec(BUILDER_DOMAIN, "app_resolve", ("app_resolve",), ("builder", "app", "resolve"), has_contract=True, cli_show_effective_context=True),
|
|
138
|
+
PublicToolSpec(BUILDER_DOMAIN, "button_style_catalog_get", ("button_style_catalog_get",), ("builder", "button", "catalog"), has_contract=True, cli_show_effective_context=True),
|
|
133
139
|
PublicToolSpec(BUILDER_DOMAIN, "app_custom_button_list", ("app_custom_button_list",), ("builder", "button", "list"), has_contract=True, cli_show_effective_context=True),
|
|
134
140
|
PublicToolSpec(BUILDER_DOMAIN, "app_custom_button_get", ("app_custom_button_get",), ("builder", "button", "get"), has_contract=True, cli_show_effective_context=True),
|
|
135
141
|
PublicToolSpec(BUILDER_DOMAIN, "app_custom_button_create", ("app_custom_button_create",), ("builder", "button", "create"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|
|
@@ -65,7 +65,10 @@ def trim_success_response(tool_name: str | None, payload: dict[str, Any]) -> dic
|
|
|
65
65
|
if not isinstance(payload, dict):
|
|
66
66
|
return payload
|
|
67
67
|
trimmed = deepcopy(payload)
|
|
68
|
-
|
|
68
|
+
drop_keys = COMMON_SUCCESS_DROP_TOP
|
|
69
|
+
if tool_name == "user:record_get":
|
|
70
|
+
drop_keys = COMMON_SUCCESS_DROP_TOP - {"output_profile"}
|
|
71
|
+
_drop_top_keys(trimmed, drop_keys)
|
|
69
72
|
transformer = SUCCESS_POLICY_BY_TOOL.get(tool_name or "")
|
|
70
73
|
if transformer is not None:
|
|
71
74
|
transformer(trimmed)
|
|
@@ -395,6 +398,9 @@ def _trim_record_write(payload: JSONObject) -> None:
|
|
|
395
398
|
|
|
396
399
|
|
|
397
400
|
def _trim_record_get(payload: JSONObject) -> None:
|
|
401
|
+
if payload.get("fields") is not None or payload.get("semantic_context") is not None:
|
|
402
|
+
_trim_detail_context_record_get(payload)
|
|
403
|
+
return
|
|
398
404
|
data = payload.get("data")
|
|
399
405
|
if not isinstance(data, dict):
|
|
400
406
|
return
|
|
@@ -417,22 +423,142 @@ def _trim_record_get(payload: JSONObject) -> None:
|
|
|
417
423
|
payload["data"] = compact
|
|
418
424
|
|
|
419
425
|
|
|
426
|
+
def _trim_detail_context_record_get(payload: JSONObject) -> None:
|
|
427
|
+
_trim_item_list(
|
|
428
|
+
payload,
|
|
429
|
+
"fields",
|
|
430
|
+
allowed=("field_id", "title", "type", "value", "display_value", "requested_focus", "asset_ids", "file_asset_ids"),
|
|
431
|
+
)
|
|
432
|
+
references = payload.get("references")
|
|
433
|
+
if isinstance(references, list):
|
|
434
|
+
compact_refs: list[JSONObject] = []
|
|
435
|
+
for item in references:
|
|
436
|
+
if not isinstance(item, dict):
|
|
437
|
+
continue
|
|
438
|
+
compact = _pick(
|
|
439
|
+
item,
|
|
440
|
+
(
|
|
441
|
+
"field_id",
|
|
442
|
+
"field_title",
|
|
443
|
+
"target_app_key",
|
|
444
|
+
"target_record_id",
|
|
445
|
+
"target_title",
|
|
446
|
+
"target_detail_completeness",
|
|
447
|
+
"self_reference",
|
|
448
|
+
),
|
|
449
|
+
)
|
|
450
|
+
target_fields = item.get("target_fields") if isinstance(item.get("target_fields"), list) else []
|
|
451
|
+
if target_fields:
|
|
452
|
+
compact["target_fields"] = [
|
|
453
|
+
_pick(field, ("field_id", "title", "type", "value", "display_value", "asset_ids", "file_asset_ids"))
|
|
454
|
+
for field in target_fields
|
|
455
|
+
if isinstance(field, dict)
|
|
456
|
+
]
|
|
457
|
+
compact_refs.append(compact)
|
|
458
|
+
payload["references"] = compact_refs
|
|
459
|
+
media_assets = payload.get("media_assets")
|
|
460
|
+
if isinstance(media_assets, dict):
|
|
461
|
+
compact_media = _pick(media_assets, ("status", "local_dir", "warnings"))
|
|
462
|
+
items = media_assets.get("items") if isinstance(media_assets.get("items"), list) else []
|
|
463
|
+
compact_media["items"] = [
|
|
464
|
+
_pick(
|
|
465
|
+
item,
|
|
466
|
+
(
|
|
467
|
+
"asset_id",
|
|
468
|
+
"kind",
|
|
469
|
+
"source",
|
|
470
|
+
"field_id",
|
|
471
|
+
"field_title",
|
|
472
|
+
"local_path",
|
|
473
|
+
"access_status",
|
|
474
|
+
"readable_by_agent",
|
|
475
|
+
"mime_type",
|
|
476
|
+
"size_bytes",
|
|
477
|
+
"download_strategy",
|
|
478
|
+
"storage_auth_type",
|
|
479
|
+
"storage_cookie_prefix",
|
|
480
|
+
"redirected",
|
|
481
|
+
),
|
|
482
|
+
)
|
|
483
|
+
for item in items
|
|
484
|
+
if isinstance(item, dict)
|
|
485
|
+
]
|
|
486
|
+
payload["media_assets"] = compact_media
|
|
487
|
+
file_assets = payload.get("file_assets")
|
|
488
|
+
if isinstance(file_assets, dict):
|
|
489
|
+
compact_files = _pick(file_assets, ("status", "local_dir", "warnings"))
|
|
490
|
+
items = file_assets.get("items") if isinstance(file_assets.get("items"), list) else []
|
|
491
|
+
compact_files["items"] = [
|
|
492
|
+
_pick(
|
|
493
|
+
item,
|
|
494
|
+
(
|
|
495
|
+
"file_asset_id",
|
|
496
|
+
"media_asset_id",
|
|
497
|
+
"kind",
|
|
498
|
+
"source",
|
|
499
|
+
"field_id",
|
|
500
|
+
"field_title",
|
|
501
|
+
"file_name",
|
|
502
|
+
"local_path",
|
|
503
|
+
"access_status",
|
|
504
|
+
"readable_by_agent",
|
|
505
|
+
"mime_type",
|
|
506
|
+
"size_bytes",
|
|
507
|
+
"download_strategy",
|
|
508
|
+
"storage_auth_type",
|
|
509
|
+
"storage_cookie_prefix",
|
|
510
|
+
"redirected",
|
|
511
|
+
"extraction",
|
|
512
|
+
),
|
|
513
|
+
)
|
|
514
|
+
for item in items
|
|
515
|
+
if isinstance(item, dict)
|
|
516
|
+
]
|
|
517
|
+
payload["file_assets"] = compact_files
|
|
518
|
+
for key in ("data_logs", "workflow_logs"):
|
|
519
|
+
node = payload.get(key)
|
|
520
|
+
if not isinstance(node, dict):
|
|
521
|
+
continue
|
|
522
|
+
compact_node = _pick(
|
|
523
|
+
node,
|
|
524
|
+
(
|
|
525
|
+
"status",
|
|
526
|
+
"visible",
|
|
527
|
+
"reason",
|
|
528
|
+
"page",
|
|
529
|
+
"page_size",
|
|
530
|
+
"items_loaded",
|
|
531
|
+
"reported_total",
|
|
532
|
+
"has_more",
|
|
533
|
+
"complete",
|
|
534
|
+
"items",
|
|
535
|
+
),
|
|
536
|
+
)
|
|
537
|
+
payload[key] = compact_node
|
|
538
|
+
associated_resources = payload.get("associated_resources")
|
|
539
|
+
if isinstance(associated_resources, list):
|
|
540
|
+
payload["associated_resources"] = [
|
|
541
|
+
_pick(item, ("type", "name", "app_key", "app_name", "view_key", "chart_key", "view_type", "data_access"))
|
|
542
|
+
for item in associated_resources
|
|
543
|
+
if isinstance(item, dict)
|
|
544
|
+
]
|
|
545
|
+
_drop_deep_keys(payload, {"raw", "debug"})
|
|
546
|
+
|
|
547
|
+
|
|
420
548
|
def _trim_record_list(payload: JSONObject) -> None:
|
|
421
549
|
data = payload.get("data")
|
|
422
550
|
if not isinstance(data, dict):
|
|
423
551
|
return
|
|
424
552
|
pagination = data.get("pagination") if isinstance(data.get("pagination"), dict) else {}
|
|
425
|
-
returned_items = pagination.get("returned_items")
|
|
426
|
-
result_amount = pagination.get("result_amount")
|
|
427
|
-
|
|
428
|
-
truncated
|
|
429
|
-
if isinstance(result_amount, int) and isinstance(returned_items, int):
|
|
553
|
+
returned_items = pagination.get("returned_count", pagination.get("returned_items"))
|
|
554
|
+
result_amount = pagination.get("total_count", pagination.get("result_amount"))
|
|
555
|
+
truncated = bool(pagination.get("truncated"))
|
|
556
|
+
if not truncated and isinstance(result_amount, int) and isinstance(returned_items, int):
|
|
430
557
|
truncated = result_amount > returned_items
|
|
431
558
|
compact_pagination = {
|
|
432
559
|
"loaded": True,
|
|
433
|
-
"
|
|
434
|
-
"
|
|
435
|
-
"reported_total": result_amount,
|
|
560
|
+
"returned_count": returned_items,
|
|
561
|
+
"total_count": result_amount,
|
|
436
562
|
"truncated": truncated,
|
|
437
563
|
}
|
|
438
564
|
selection = data.get("selection") if isinstance(data.get("selection"), dict) else {}
|
|
@@ -445,6 +571,43 @@ def _trim_record_list(payload: JSONObject) -> None:
|
|
|
445
571
|
if view:
|
|
446
572
|
compact["view"] = _pick(view, ("view_id", "name"))
|
|
447
573
|
payload["data"] = compact
|
|
574
|
+
lookup = payload.get("lookup") if isinstance(payload.get("lookup"), dict) else {}
|
|
575
|
+
if lookup:
|
|
576
|
+
payload["lookup"] = {
|
|
577
|
+
"mode": lookup.get("mode"),
|
|
578
|
+
"query": lookup.get("query"),
|
|
579
|
+
"total_count": lookup.get("total_count", lookup.get("reported_total")),
|
|
580
|
+
"returned_count": lookup.get("returned_count"),
|
|
581
|
+
"truncated": lookup.get("truncated"),
|
|
582
|
+
"confidence": lookup.get("confidence"),
|
|
583
|
+
"next_action": lookup.get("next_action"),
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _trim_record_access(payload: JSONObject) -> None:
|
|
588
|
+
compact: dict[str, Any] = {}
|
|
589
|
+
for key in (
|
|
590
|
+
"ok",
|
|
591
|
+
"status",
|
|
592
|
+
"app_key",
|
|
593
|
+
"view_id",
|
|
594
|
+
"format",
|
|
595
|
+
"local_dir",
|
|
596
|
+
"row_count",
|
|
597
|
+
"complete",
|
|
598
|
+
"truncated",
|
|
599
|
+
"safe_for_final_conclusion",
|
|
600
|
+
"files",
|
|
601
|
+
"fields",
|
|
602
|
+
"warnings",
|
|
603
|
+
"verification",
|
|
604
|
+
"scope",
|
|
605
|
+
):
|
|
606
|
+
value = payload.get(key)
|
|
607
|
+
if value is not None:
|
|
608
|
+
compact[key] = value
|
|
609
|
+
payload.clear()
|
|
610
|
+
payload.update(compact)
|
|
448
611
|
|
|
449
612
|
|
|
450
613
|
def _trim_record_analyze(payload: JSONObject) -> None:
|
|
@@ -666,7 +829,6 @@ def _trim_import_status_payload(payload: JSONObject) -> None:
|
|
|
666
829
|
"total_rows",
|
|
667
830
|
"success_rows",
|
|
668
831
|
"failed_rows",
|
|
669
|
-
"error_file_urls",
|
|
670
832
|
"operate_time",
|
|
671
833
|
"operate_user",
|
|
672
834
|
"verification",
|
|
@@ -674,6 +836,10 @@ def _trim_import_status_payload(payload: JSONObject) -> None:
|
|
|
674
836
|
payload.pop(key, None)
|
|
675
837
|
|
|
676
838
|
|
|
839
|
+
def _trim_export_payload(payload: JSONObject) -> None:
|
|
840
|
+
payload.pop("backend_export_id", None)
|
|
841
|
+
|
|
842
|
+
|
|
677
843
|
def _summarize_import_issues(issues: list[Any]) -> dict[str, Any]:
|
|
678
844
|
total = 0
|
|
679
845
|
error_count = 0
|
|
@@ -756,6 +922,16 @@ _register_policy(
|
|
|
756
922
|
),
|
|
757
923
|
_trim_import_schema,
|
|
758
924
|
)
|
|
925
|
+
_register_policy(
|
|
926
|
+
(USER_DOMAIN,),
|
|
927
|
+
(
|
|
928
|
+
"record_export_start",
|
|
929
|
+
"record_export_status_get",
|
|
930
|
+
"record_export_get",
|
|
931
|
+
"record_export_direct",
|
|
932
|
+
),
|
|
933
|
+
_trim_export_payload,
|
|
934
|
+
)
|
|
759
935
|
_register_policy(
|
|
760
936
|
(USER_DOMAIN,),
|
|
761
937
|
(
|
|
@@ -770,6 +946,7 @@ _register_policy(
|
|
|
770
946
|
_register_policy((USER_DOMAIN,), ("record_insert", "record_update"), _trim_record_write)
|
|
771
947
|
_register_policy((USER_DOMAIN,), ("record_get",), _trim_record_get)
|
|
772
948
|
_register_policy((USER_DOMAIN,), ("record_list",), _trim_record_list)
|
|
949
|
+
_register_policy((USER_DOMAIN,), ("record_access",), _trim_record_access)
|
|
773
950
|
_register_policy((USER_DOMAIN,), ("record_analyze",), _trim_record_analyze)
|
|
774
951
|
_register_policy((USER_DOMAIN,), ("record_code_block_run",), _trim_code_block_run)
|
|
775
952
|
_register_policy((USER_DOMAIN,), ("task_list",), _trim_task_list)
|
|
@@ -818,6 +995,7 @@ _register_policy(
|
|
|
818
995
|
"role_create",
|
|
819
996
|
"app_release_edit_lock_if_mine",
|
|
820
997
|
"app_resolve",
|
|
998
|
+
"button_style_catalog_get",
|
|
821
999
|
"app_custom_button_list",
|
|
822
1000
|
"app_custom_button_get",
|
|
823
1001
|
"app_custom_button_create",
|
|
@@ -10,6 +10,7 @@ from .tools.app_tools import AppTools
|
|
|
10
10
|
from .tools.auth_tools import AuthTools
|
|
11
11
|
from .tools.code_block_tools import CodeBlockTools
|
|
12
12
|
from .tools.feedback_tools import FeedbackTools
|
|
13
|
+
from .tools.export_tools import ExportTools
|
|
13
14
|
from .tools.file_tools import FileTools
|
|
14
15
|
from .tools.import_tools import ImportTools
|
|
15
16
|
from .tools.package_tools import PackageTools
|
|
@@ -49,14 +50,15 @@ All resource tools operate with the logged-in user's Qingflow permissions.
|
|
|
49
50
|
|
|
50
51
|
If `app_key` is unknown, use `app_list` or `app_search` first.
|
|
51
52
|
If the app is known but the data range is not, use `app_get` first and choose from `accessible_views`.
|
|
52
|
-
If an accessible view has `analysis_supported=false`, do not use it for `
|
|
53
|
+
If an accessible view has `analysis_supported=false`, do not use it for `record_access` or `record_list`. `boardView` and `ganttView` are special UI views, not data-access targets.
|
|
54
|
+
`view_get(view_id=...)` also returns `export_capability`; it only means there is a supported export route, not that export permission has been verified.
|
|
53
55
|
|
|
54
56
|
## Schema-First Rule
|
|
55
57
|
|
|
56
58
|
Call `record_insert_schema_get` before `record_insert`.
|
|
57
59
|
Call `record_update_schema_get` before `record_update`.
|
|
58
60
|
Call `record_code_block_schema_get` before `record_code_block_run`.
|
|
59
|
-
Call `app_get` first when the data range is unclear, then use `record_browse_schema_get(view_id=...)` before `
|
|
61
|
+
Call `app_get` first when the data range is unclear, then use `record_browse_schema_get(view_id=...)` before `record_access`, `record_list`, or `record_get`.
|
|
60
62
|
Call `record_import_schema_get` when the import field mapping is unclear before template download or verify.
|
|
61
63
|
|
|
62
64
|
- All `field_id` values must come from the schema response.
|
|
@@ -66,7 +68,8 @@ Call `record_import_schema_get` when the import field mapping is unclear before
|
|
|
66
68
|
|
|
67
69
|
`record_insert_schema_get` returns the current user's insert-ready applicant schema; read `required_fields`, `optional_fields`, `runtime_linked_required_fields`, and `payload_template`.
|
|
68
70
|
`record_update_schema_get` returns the current record's overall update-ready writable field set across matched accessible views; read `writable_fields` and `payload_template`.
|
|
69
|
-
`record_browse_schema_get(view_id=...)` returns
|
|
71
|
+
`record_browse_schema_get(view_id=...)` returns the same readable fields shown in the selected Qingflow table view header.
|
|
72
|
+
`record_access.fields` / CSV columns and `record_list.columns / where / order_by / query_fields` use that exact same view schema.
|
|
70
73
|
`record_code_block_schema_get` returns code-block-ready schema for exact code block field selection.
|
|
71
74
|
`record_import_schema_get` returns import-ready column metadata.
|
|
72
75
|
|
|
@@ -76,16 +79,17 @@ Call `record_import_schema_get` when the import field mapping is unclear before
|
|
|
76
79
|
|
|
77
80
|
## Analytics Path
|
|
78
81
|
|
|
79
|
-
`app_get -> record_browse_schema_get(view_id=...) ->
|
|
82
|
+
`app_get -> record_browse_schema_get(view_id=...) -> record_access -> Python`
|
|
80
83
|
|
|
81
84
|
Prefer `view_id` entries from `accessible_views` where `analysis_supported=true`.
|
|
82
85
|
|
|
86
|
+
Use `record_access` to write local CSV shard files for analysis, then use Python to compute counts, rankings, ratios, trends, and final conclusions.
|
|
87
|
+
|
|
83
88
|
Use this DSL shape:
|
|
84
89
|
|
|
85
|
-
- `
|
|
86
|
-
- `
|
|
87
|
-
- `
|
|
88
|
-
- `sort`: `{{by, order}}`
|
|
90
|
+
- `columns`: `[{{field_id}}]`
|
|
91
|
+
- `where`: `[{{field_id, op, value}}]`
|
|
92
|
+
- `order_by`: `[{{field_id, direction}}]`
|
|
89
93
|
|
|
90
94
|
Important key rules:
|
|
91
95
|
|
|
@@ -106,6 +110,7 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
106
110
|
`record_code_block_schema_get -> record_code_block_run`
|
|
107
111
|
|
|
108
112
|
- Use `columns` as `[{{field_id}}]`
|
|
113
|
+
- Use `record_list(query=..., query_fields=[{{field_id}}])` for fuzzy single-record lookup, then follow `lookup.next_action`; `query_fields` is search scope and `columns` is display shape.
|
|
109
114
|
- Use `where` items as `{{field_id, op, value}}`
|
|
110
115
|
- Use `order_by` items as `{{field_id, direction}}`
|
|
111
116
|
- Legacy forms such as bare integer `field_id`, `fieldId`, `operator`, `values`, or `order` may still parse, but they are compatibility-only and not the canonical DSL
|
|
@@ -119,7 +124,10 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
119
124
|
- `linkage.kind=logic_visibility` means linked visibility or option-driven rules are involved; `linkage.kind=reference_fill` means reference/default matching logic is involved; `linkage.kind=formula_fill` means formula/default auto-fill logic is involved.
|
|
120
125
|
- `record_update_schema_get` exposes the overall writable field set for the record, but not every field combination is guaranteed; `record_update` still needs one single matched accessible view that can cover the payload.
|
|
121
126
|
- `record_delete` deletes by `record_id` or `record_ids`.
|
|
122
|
-
-
|
|
127
|
+
- `record_get` is the single-record frontend detail context tool. It returns detail-page visible fields, one-level relation targets, first-page data/workflow logs, associated views/reports, local readable image assets, local downloadable file assets, unavailable context, and `semantic_context`.
|
|
128
|
+
- Read record images from `record_get.media_assets.items[].local_path` when `readable_by_agent=true`; read attachments/documents/tables from `record_get.file_assets.items[].local_path` and `extraction.text_path` when present. `record_get` follows the frontend storage cookie redirect path for Qingflow attachments, and remote file URLs should not be treated as directly readable.
|
|
129
|
+
- `record_get.columns` are focus hints only; they do not project the detail fields. Read facts from top-level `fields[]`.
|
|
130
|
+
- When readback shape matters after insert or update, prefer `record_get` for human/detail-page context, or `record_list(..., output_profile="normalized")` for batch-shaped normalized rows.
|
|
123
131
|
|
|
124
132
|
- Read relation targets from `record_insert_schema_get` / `record_update_schema_get` relation metadata before preparing relation writes.
|
|
125
133
|
- Member and department fields may be written with natural strings directly on `record_insert` / `record_update`; only fall back to `record_member_candidates` or `record_department_candidates` when the user wants explicit candidate browsing or the write returns ambiguity that needs confirmation.
|
|
@@ -147,6 +155,25 @@ Use `record_code_block_run` when the user wants to execute a form code-block fie
|
|
|
147
155
|
- Do not modify user-uploaded files unless the user explicitly authorizes repair.
|
|
148
156
|
- If repair is authorized, keep the original file and repair a copy, then run `record_import_verify` again before `record_import_start`.
|
|
149
157
|
|
|
158
|
+
## Export Path
|
|
159
|
+
|
|
160
|
+
`view_get -> record_export_start -> record_export_status_get -> record_export_get`
|
|
161
|
+
|
|
162
|
+
- `record_export_direct` is the one-shot export path that starts the export, waits for completion, downloads locally, and still returns remote download links.
|
|
163
|
+
- Export v1 supports record views only and follows the same public `view_id` semantics as `record_list` (`system:*` and `custom:*`).
|
|
164
|
+
- `record_export_start` / `record_export_direct` support frontend-like row selection:
|
|
165
|
+
- omit `record_ids` to export all rows in the selected view
|
|
166
|
+
- pass `record_ids` to export selected rows only
|
|
167
|
+
- `record_export_start` / `record_export_direct` also support internal query selection:
|
|
168
|
+
- pass `where` to resolve matching `record_id` values first
|
|
169
|
+
- pass `order_by` to keep the internal query and export row order aligned with `record_list`
|
|
170
|
+
- then run native export as selected rows
|
|
171
|
+
- `where/order_by` and `record_ids` are mutually exclusive
|
|
172
|
+
- `record_export_start` / `record_export_direct` also support frontend-like column selection:
|
|
173
|
+
- omit `columns` to export all current-view fields
|
|
174
|
+
- pass `columns` to export only selected fields, preserving the provided order
|
|
175
|
+
- `include_workflow_log=true` maps to the native workflow-log export switch.
|
|
176
|
+
|
|
150
177
|
## Task Workflow Path
|
|
151
178
|
|
|
152
179
|
`task_list -> task_get -> task_action_execute`
|
|
@@ -190,6 +217,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
|
|
|
190
217
|
WorkspaceTools(sessions, backend).register(server)
|
|
191
218
|
FileTools(sessions, backend).register(server)
|
|
192
219
|
ImportTools(sessions, backend).register(server)
|
|
220
|
+
ExportTools(sessions, backend).register(server)
|
|
193
221
|
CodeBlockTools(sessions, backend).register(server)
|
|
194
222
|
TaskContextTools(sessions, backend).register(server)
|
|
195
223
|
RoleTools(sessions, backend).register(server)
|
|
@@ -290,6 +290,10 @@ def build_builder_server() -> FastMCP:
|
|
|
290
290
|
)
|
|
291
291
|
return ai_builder.app_resolve(profile=profile, app_key=app_key, app_name=app_name, package_id=package_id)
|
|
292
292
|
|
|
293
|
+
@server.tool()
|
|
294
|
+
def button_style_catalog_get(profile: str = DEFAULT_PROFILE) -> dict:
|
|
295
|
+
return ai_builder.button_style_catalog_get(profile=profile)
|
|
296
|
+
|
|
293
297
|
@server.tool()
|
|
294
298
|
def app_custom_button_list(profile: str = DEFAULT_PROFILE, app_key: str = "") -> dict:
|
|
295
299
|
return ai_builder.app_custom_button_list(profile=profile, app_key=app_key)
|