hyapi-tester 0.1.2__py3-none-any.whl
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.
- hyapi_tester/__init__.py +8 -0
- hyapi_tester/ai_qa_dialog.py +379 -0
- hyapi_tester/broker.py +302 -0
- hyapi_tester/config_dialogs.py +230 -0
- hyapi_tester/constants.py +103 -0
- hyapi_tester/fonts/JetBrainsMonoNL-Regular.ttf +0 -0
- hyapi_tester/icons/app.ico +0 -0
- hyapi_tester/icons/chevron-down.svg +1 -0
- hyapi_tester/icons/code.svg +1 -0
- hyapi_tester/icons/config.svg +11 -0
- hyapi_tester/icons/copy.svg +1 -0
- hyapi_tester/icons/file-text.svg +1 -0
- hyapi_tester/icons/folder.svg +1 -0
- hyapi_tester/icons/gear.svg +4 -0
- hyapi_tester/icons/link.svg +1 -0
- hyapi_tester/icons/play.svg +1 -0
- hyapi_tester/icons/refresh-cw.svg +1 -0
- hyapi_tester/icons/rotate-ccw.svg +1 -0
- hyapi_tester/icons/save.svg +1 -0
- hyapi_tester/icons/sparkles.svg +1 -0
- hyapi_tester/icons/trash.svg +1 -0
- hyapi_tester/icons/x.svg +1 -0
- hyapi_tester/icons/zap.svg +1 -0
- hyapi_tester/llm_service.py +984 -0
- hyapi_tester/main.py +1826 -0
- hyapi_tester/prompt_dialogs.py +298 -0
- hyapi_tester/python_highlighter.py +180 -0
- hyapi_tester/ui_icons.py +49 -0
- hyapi_tester/widgets.py +267 -0
- hyapi_tester/worker.py +92 -0
- hyapi_tester-0.1.2.dist-info/METADATA +10 -0
- hyapi_tester-0.1.2.dist-info/RECORD +35 -0
- hyapi_tester-0.1.2.dist-info/WHEEL +5 -0
- hyapi_tester-0.1.2.dist-info/entry_points.txt +2 -0
- hyapi_tester-0.1.2.dist-info/top_level.txt +1 -0
hyapi_tester/__init__.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AI 问答对话框:自由向 LLM 提问,可选附带日志框选中内容与接口文档。
|
|
3
|
+
|
|
4
|
+
多轮上下文策略(折中方案):
|
|
5
|
+
- 保留最近 ``MAX_RECENT_TURNS`` 轮原始 (user, assistant) 对话;
|
|
6
|
+
- AI 每轮以 JSON 返回 ``{answer, context}``,``context`` 是整理后的对话上下文摘要;
|
|
7
|
+
- ``context`` 作为 system prompt 一部分,供后续轮次引用;
|
|
8
|
+
- 旧轮次被压缩到 context 中后从 recent 列表移除;
|
|
9
|
+
- 界面只显示 ``answer``,不显示 ``context``。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
from PySide6.QtCore import Qt, QPoint, QRect, QTimer
|
|
15
|
+
from PySide6.QtGui import QColor, QPainter, QPen
|
|
16
|
+
from PySide6.QtWidgets import (
|
|
17
|
+
QCheckBox,
|
|
18
|
+
QDialog,
|
|
19
|
+
QHBoxLayout,
|
|
20
|
+
QLabel,
|
|
21
|
+
QMessageBox,
|
|
22
|
+
QPlainTextEdit,
|
|
23
|
+
QPushButton,
|
|
24
|
+
QTextEdit,
|
|
25
|
+
QVBoxLayout,
|
|
26
|
+
QWidget,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from .llm_service import (
|
|
30
|
+
CancelToken,
|
|
31
|
+
call_llm_qa_stream_messages,
|
|
32
|
+
read_doc_files,
|
|
33
|
+
)
|
|
34
|
+
from .ui_icons import apply_icon
|
|
35
|
+
from .widgets import HintLabel
|
|
36
|
+
from .worker import CancellableWorker
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# 最近保留的原始对话轮数(超过此数的旧轮次已被压缩到 context 中)
|
|
40
|
+
MAX_RECENT_TURNS = 4
|
|
41
|
+
|
|
42
|
+
_QA_SYSTEM_PROMPT = (
|
|
43
|
+
"你是一名测试开发助手,请根据用户的问题提供准确、简明的解答。\n\n"
|
|
44
|
+
"你必须以 JSON 格式返回,格式如下:\n"
|
|
45
|
+
"{\n"
|
|
46
|
+
' "answer": "对用户问题的回答(用户可见,支持 markdown)",\n'
|
|
47
|
+
' "context": "整理后的对话上下文摘要,保留关键事实、决策、代码片段、调试发现等,便于后续问答引用(用户不可见)"\n'
|
|
48
|
+
"}\n\n"
|
|
49
|
+
"要求:\n"
|
|
50
|
+
"- 严格返回合法 JSON,不要在 JSON 外添加任何文字\n"
|
|
51
|
+
"- 不要使用 markdown 代码块(```)包裹 JSON\n"
|
|
52
|
+
"- answer 是对用户问题的直接回答\n"
|
|
53
|
+
"- context 应精炼,避免冗长,保留后续问答可能需要的关键信息\n"
|
|
54
|
+
"- 若无需整理上下文,context 可返回空字符串"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
_SELECTION_HINT_PREFIX = (
|
|
58
|
+
"如果我的问题中提到类似:这些/这段文字/这段内容/选中内容 等等,是指如下内容:\n"
|
|
59
|
+
)
|
|
60
|
+
_DOC_HINT_PREFIX = "被测系统接口文档内容如下:\n"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Spinner(QWidget):
|
|
64
|
+
"""轻量旋转弧线指示器(类似 Material Design 圆形进度)。"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, parent=None, size=16, color="#4f6ef2", width=2):
|
|
67
|
+
super().__init__(parent)
|
|
68
|
+
self._angle = 0
|
|
69
|
+
self._timer = QTimer(self)
|
|
70
|
+
self._timer.timeout.connect(self._tick)
|
|
71
|
+
self._timer.setInterval(30)
|
|
72
|
+
self._color = QColor(color)
|
|
73
|
+
self._pen_width = width
|
|
74
|
+
self.setFixedSize(size, size)
|
|
75
|
+
self.setVisible(False)
|
|
76
|
+
|
|
77
|
+
def _tick(self):
|
|
78
|
+
self._angle = (self._angle + 10) % 360
|
|
79
|
+
self.update()
|
|
80
|
+
|
|
81
|
+
def start(self):
|
|
82
|
+
self._timer.start()
|
|
83
|
+
self.setVisible(True)
|
|
84
|
+
|
|
85
|
+
def stop(self):
|
|
86
|
+
self._timer.stop()
|
|
87
|
+
self.setVisible(False)
|
|
88
|
+
|
|
89
|
+
def paintEvent(self, event):
|
|
90
|
+
r = min(self.width(), self.height())
|
|
91
|
+
margin = self._pen_width
|
|
92
|
+
dv = r // 2 - margin
|
|
93
|
+
|
|
94
|
+
painter = QPainter(self)
|
|
95
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
96
|
+
|
|
97
|
+
# 背景圈(极淡)
|
|
98
|
+
pen = QPen(self._color)
|
|
99
|
+
pen.setWidth(self._pen_width)
|
|
100
|
+
bg = QColor(self._color)
|
|
101
|
+
bg.setAlpha(30)
|
|
102
|
+
pen.setColor(bg)
|
|
103
|
+
painter.setPen(pen)
|
|
104
|
+
painter.drawEllipse(QPoint(r // 2, r // 2), dv, dv)
|
|
105
|
+
|
|
106
|
+
# 旋转弧
|
|
107
|
+
pen.setColor(self._color)
|
|
108
|
+
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
|
109
|
+
painter.setPen(pen)
|
|
110
|
+
span = 120 * 16
|
|
111
|
+
start = self._angle * 16
|
|
112
|
+
painter.drawArc(QRect(margin, margin, dv * 2, dv * 2), start, span)
|
|
113
|
+
painter.end()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class AIQADialog(QDialog):
|
|
117
|
+
"""无模式 AI 问答对话框(多轮)。
|
|
118
|
+
|
|
119
|
+
- 多行输入框提问;
|
|
120
|
+
- 提交时若主窗口日志框有选中内容,自动附加到提示词;
|
|
121
|
+
- 可选勾选「带上接口文档」,将接口文档内容一并附上;
|
|
122
|
+
- 多轮上下文:最近 N 轮原始对话 + AI 整理的 context。
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(self, parent, broker):
|
|
126
|
+
super().__init__(parent)
|
|
127
|
+
self.setWindowTitle("AI 问答")
|
|
128
|
+
self.setMinimumSize(700, 520)
|
|
129
|
+
self._parent = parent
|
|
130
|
+
self._broker = broker
|
|
131
|
+
self._worker: CancellableWorker | None = None
|
|
132
|
+
self._cancel_token: CancelToken | None = None
|
|
133
|
+
# 多轮上下文状态
|
|
134
|
+
self._context: str = ""
|
|
135
|
+
self._recent_turns: list[tuple[str, str]] = []
|
|
136
|
+
self._pending_question: str = ""
|
|
137
|
+
self._build_ui()
|
|
138
|
+
|
|
139
|
+
def _build_ui(self):
|
|
140
|
+
layout = QVBoxLayout(self)
|
|
141
|
+
layout.setContentsMargins(12, 12, 12, 12)
|
|
142
|
+
layout.setSpacing(10)
|
|
143
|
+
|
|
144
|
+
layout.addWidget(HintLabel(
|
|
145
|
+
"向 AI 提问,支持多轮对话。若日志框当前有选中内容,会自动附到提示词中;"
|
|
146
|
+
"勾选「带上接口文档」可一并附带接口文档。"
|
|
147
|
+
))
|
|
148
|
+
|
|
149
|
+
layout.addWidget(QLabel("你的问题:"))
|
|
150
|
+
self.question_edit = QPlainTextEdit()
|
|
151
|
+
self.question_edit.setObjectName("prompt_editor")
|
|
152
|
+
self.question_edit.setPlaceholderText("请输入你的问题…")
|
|
153
|
+
layout.addWidget(self.question_edit)
|
|
154
|
+
|
|
155
|
+
opt_row = QHBoxLayout()
|
|
156
|
+
opt_row.setSpacing(10)
|
|
157
|
+
self.with_docs_check = QCheckBox("带上接口文档")
|
|
158
|
+
self.with_docs_check.setChecked(False)
|
|
159
|
+
opt_row.addWidget(self.with_docs_check)
|
|
160
|
+
opt_row.addStretch()
|
|
161
|
+
layout.addLayout(opt_row)
|
|
162
|
+
|
|
163
|
+
btn_row = QHBoxLayout()
|
|
164
|
+
btn_row.setSpacing(10)
|
|
165
|
+
self.ask_btn = QPushButton("提问")
|
|
166
|
+
self.ask_btn.setProperty("btnStyle", "primary")
|
|
167
|
+
apply_icon(self.ask_btn, "zap", "#ffffff")
|
|
168
|
+
self.ask_btn.clicked.connect(self._on_ask)
|
|
169
|
+
self.stop_btn = QPushButton("终止")
|
|
170
|
+
self.stop_btn.setProperty("btnStyle", "success")
|
|
171
|
+
apply_icon(self.stop_btn, "x", "#ffffff")
|
|
172
|
+
self.stop_btn.clicked.connect(self._on_stop)
|
|
173
|
+
self.stop_btn.setVisible(False)
|
|
174
|
+
self.clear_ctx_btn = QPushButton("清空上下文")
|
|
175
|
+
self.clear_ctx_btn.setProperty("btnStyle", "soft")
|
|
176
|
+
apply_icon(self.clear_ctx_btn, "rotate-ccw", "#4f6ef2")
|
|
177
|
+
self.clear_ctx_btn.clicked.connect(self._on_clear_context)
|
|
178
|
+
self.clear_ctx_btn.setToolTip("清空多轮对话上下文与历史")
|
|
179
|
+
close_btn = QPushButton("关闭")
|
|
180
|
+
apply_icon(close_btn, "x")
|
|
181
|
+
close_btn.clicked.connect(self.close)
|
|
182
|
+
btn_row.addWidget(self.ask_btn)
|
|
183
|
+
btn_row.addWidget(self.stop_btn)
|
|
184
|
+
btn_row.addWidget(self.clear_ctx_btn)
|
|
185
|
+
btn_row.addStretch()
|
|
186
|
+
btn_row.addWidget(close_btn)
|
|
187
|
+
layout.addLayout(btn_row)
|
|
188
|
+
|
|
189
|
+
answer_header = QHBoxLayout()
|
|
190
|
+
answer_header.addWidget(QLabel("AI 回答:"))
|
|
191
|
+
self.busy_spinner = Spinner(self)
|
|
192
|
+
answer_header.addWidget(self.busy_spinner)
|
|
193
|
+
answer_header.addStretch()
|
|
194
|
+
layout.addLayout(answer_header)
|
|
195
|
+
|
|
196
|
+
self.answer_edit = QTextEdit()
|
|
197
|
+
self.answer_edit.setObjectName("log_text_edit")
|
|
198
|
+
self.answer_edit.setReadOnly(True)
|
|
199
|
+
layout.addWidget(self.answer_edit)
|
|
200
|
+
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
# 上下文管理
|
|
203
|
+
# ------------------------------------------------------------------
|
|
204
|
+
def _on_clear_context(self):
|
|
205
|
+
"""清空多轮上下文与历史。"""
|
|
206
|
+
if self._worker is not None:
|
|
207
|
+
return
|
|
208
|
+
self._context = ""
|
|
209
|
+
self._recent_turns.clear()
|
|
210
|
+
self.answer_edit.clear()
|
|
211
|
+
self.question_edit.clear()
|
|
212
|
+
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
# 提交
|
|
215
|
+
# ------------------------------------------------------------------
|
|
216
|
+
def _collect_selected_log_text(self) -> str:
|
|
217
|
+
"""从主窗口日志框提取当前选中的纯文本。"""
|
|
218
|
+
log_edit = getattr(self._parent, "log_text_edit", None)
|
|
219
|
+
if log_edit is None:
|
|
220
|
+
return ""
|
|
221
|
+
cursor = log_edit.textCursor()
|
|
222
|
+
if cursor is None or not cursor.hasSelection():
|
|
223
|
+
return ""
|
|
224
|
+
return cursor.selectedText().replace("\u2029", "\n").strip()
|
|
225
|
+
|
|
226
|
+
def _build_current_user_prompt(self, question: str, with_docs: bool) -> str:
|
|
227
|
+
"""组装当前轮发送给 LLM 的 user 消息内容(不含历史)。"""
|
|
228
|
+
parts: list[str] = []
|
|
229
|
+
|
|
230
|
+
selected = self._collect_selected_log_text()
|
|
231
|
+
if selected:
|
|
232
|
+
parts.append(_SELECTION_HINT_PREFIX + selected)
|
|
233
|
+
|
|
234
|
+
if with_docs:
|
|
235
|
+
doc_paths = []
|
|
236
|
+
doc_list = getattr(self._parent, "doc_list", None)
|
|
237
|
+
if doc_list is not None:
|
|
238
|
+
for i in range(doc_list.count()):
|
|
239
|
+
doc_paths.append(doc_list.item(i).data(Qt.ItemDataRole.UserRole))
|
|
240
|
+
if doc_paths:
|
|
241
|
+
doc_text = read_doc_files(doc_paths)
|
|
242
|
+
parts.append(_DOC_HINT_PREFIX + doc_text)
|
|
243
|
+
else:
|
|
244
|
+
QMessageBox.information(
|
|
245
|
+
self, "无接口文档",
|
|
246
|
+
"当前未添加接口文档,「带上接口文档」将被忽略。",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
parts.append(question)
|
|
250
|
+
return "\n\n".join(parts)
|
|
251
|
+
|
|
252
|
+
def _build_messages(self, current_user_prompt: str) -> list:
|
|
253
|
+
"""构造完整 messages:system(含 context) + 最近 N 轮历史 + 当前问题。"""
|
|
254
|
+
system = _QA_SYSTEM_PROMPT
|
|
255
|
+
if self._context:
|
|
256
|
+
system += "\n\n当前对话上下文:\n" + self._context
|
|
257
|
+
messages = [{"role": "system", "content": system}]
|
|
258
|
+
|
|
259
|
+
for user_text, answer_text in self._recent_turns[-MAX_RECENT_TURNS:]:
|
|
260
|
+
messages.append({"role": "user", "content": user_text})
|
|
261
|
+
messages.append({"role": "assistant", "content": answer_text})
|
|
262
|
+
|
|
263
|
+
messages.append({"role": "user", "content": current_user_prompt})
|
|
264
|
+
return messages
|
|
265
|
+
|
|
266
|
+
def _on_ask(self):
|
|
267
|
+
if self._worker is not None:
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
question = self.question_edit.toPlainText().strip()
|
|
271
|
+
if not question:
|
|
272
|
+
QMessageBox.warning(self, "内容为空", "请输入你的问题")
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
base_url = self._parent.llm_dialog.get_base_url()
|
|
276
|
+
api_key = self._parent.llm_dialog.get_api_key()
|
|
277
|
+
model = self._parent.model_combo.currentText().strip()
|
|
278
|
+
if not base_url:
|
|
279
|
+
QMessageBox.warning(self, "缺少配置", "请填写 LLM 服务的 base_url")
|
|
280
|
+
return
|
|
281
|
+
if not api_key:
|
|
282
|
+
QMessageBox.warning(self, "缺少配置", "请填写 LLM 服务的 api_key")
|
|
283
|
+
return
|
|
284
|
+
if not model:
|
|
285
|
+
QMessageBox.warning(self, "缺少配置", "请选择或填写 LLM 模型名称")
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
with_docs = self.with_docs_check.isChecked()
|
|
289
|
+
current_user_prompt = self._build_current_user_prompt(question, with_docs)
|
|
290
|
+
messages = self._build_messages(current_user_prompt)
|
|
291
|
+
|
|
292
|
+
# 清空回答区并显示进度条,结束后直接展示干净 answer
|
|
293
|
+
self.answer_edit.clear()
|
|
294
|
+
self._set_busy(True)
|
|
295
|
+
self._cancel_token = CancelToken()
|
|
296
|
+
self._pending_question = question
|
|
297
|
+
|
|
298
|
+
def do_call():
|
|
299
|
+
return call_llm_qa_stream_messages(
|
|
300
|
+
base_url, api_key, model, messages,
|
|
301
|
+
cancel_token=self._cancel_token,
|
|
302
|
+
use_responses_api=self._parent.llm_dialog.get_use_responses_api(),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
self._worker = CancellableWorker(do_call)
|
|
306
|
+
self._worker.finished.connect(self._on_finished)
|
|
307
|
+
self._worker.error.connect(self._on_error)
|
|
308
|
+
self._worker.start()
|
|
309
|
+
|
|
310
|
+
@staticmethod
|
|
311
|
+
def _parse_qa_response(raw: str) -> tuple[str, str]:
|
|
312
|
+
"""解析 AI 返回的 JSON,返回 (answer, context)。
|
|
313
|
+
|
|
314
|
+
解析失败时返回 (raw, ""),由调用方决定是否保留旧 context。
|
|
315
|
+
"""
|
|
316
|
+
text = raw.strip()
|
|
317
|
+
# 去除可能的 markdown 代码块包裹
|
|
318
|
+
if text.startswith("```"):
|
|
319
|
+
lines = text.splitlines()
|
|
320
|
+
if lines and lines[0].startswith("```"):
|
|
321
|
+
lines = lines[1:]
|
|
322
|
+
if lines and lines[-1].strip() == "```":
|
|
323
|
+
lines = lines[:-1]
|
|
324
|
+
text = "\n".join(lines).strip()
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
data = json.loads(text)
|
|
328
|
+
if isinstance(data, dict):
|
|
329
|
+
answer = str(data.get("answer", "")).strip()
|
|
330
|
+
context = str(data.get("context", "")).strip()
|
|
331
|
+
if answer:
|
|
332
|
+
return answer, context
|
|
333
|
+
except Exception:
|
|
334
|
+
pass
|
|
335
|
+
return raw, ""
|
|
336
|
+
|
|
337
|
+
def _on_finished(self, raw_text: str):
|
|
338
|
+
answer, context = self._parse_qa_response(raw_text)
|
|
339
|
+
if context:
|
|
340
|
+
self._context = context
|
|
341
|
+
# 解析失败时 context 为空,保持旧 context 不变
|
|
342
|
+
|
|
343
|
+
self.answer_edit.setPlainText(answer)
|
|
344
|
+
|
|
345
|
+
# 记录本轮到历史(存纯问题,不含选中日志/文档)
|
|
346
|
+
self._recent_turns.append((self._pending_question, answer))
|
|
347
|
+
if len(self._recent_turns) > MAX_RECENT_TURNS:
|
|
348
|
+
self._recent_turns = self._recent_turns[-MAX_RECENT_TURNS:]
|
|
349
|
+
|
|
350
|
+
# 清空问题框,便于输入下一轮
|
|
351
|
+
self.question_edit.clear()
|
|
352
|
+
self._pending_question = ""
|
|
353
|
+
self._reset_state()
|
|
354
|
+
|
|
355
|
+
def _on_stop(self):
|
|
356
|
+
if self._cancel_token is not None:
|
|
357
|
+
self._cancel_token.cancel()
|
|
358
|
+
if self._worker is not None:
|
|
359
|
+
self._worker.cancel()
|
|
360
|
+
|
|
361
|
+
def _set_busy(self, busy: bool):
|
|
362
|
+
self.ask_btn.setVisible(not busy)
|
|
363
|
+
self.stop_btn.setVisible(busy)
|
|
364
|
+
self.clear_ctx_btn.setEnabled(not busy)
|
|
365
|
+
if busy:
|
|
366
|
+
self.busy_spinner.start()
|
|
367
|
+
else:
|
|
368
|
+
self.busy_spinner.stop()
|
|
369
|
+
|
|
370
|
+
def _on_error(self, error_msg: str):
|
|
371
|
+
if "终止" not in error_msg:
|
|
372
|
+
QMessageBox.critical(self, "AI 问答失败", error_msg)
|
|
373
|
+
self._pending_question = ""
|
|
374
|
+
self._reset_state()
|
|
375
|
+
|
|
376
|
+
def _reset_state(self):
|
|
377
|
+
self._worker = None
|
|
378
|
+
self._cancel_token = None
|
|
379
|
+
self._set_busy(False)
|
hyapi_tester/broker.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Broker 对象:作为 exec 执行 LLM 生成代码时的 globals 参数。
|
|
3
|
+
|
|
4
|
+
提供全局的 HTTP Session(client)、被测系统 API 基地址,
|
|
5
|
+
以及操作日志框、清理环境的方法。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import traceback
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SafeExit(Exception):
|
|
14
|
+
"""由 exec 环境中的安全 exit() 抛出,不视为致命错误。"""
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
from PySide6.QtCore import QObject, Signal
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# 日志颜色常量(使用 Qt 可识别的颜色名/十六进制)
|
|
22
|
+
COLOR_BLACK = "#383a42" # 默认
|
|
23
|
+
COLOR_GREEN = "#228B22" # 发送请求(森林绿,更醒目)
|
|
24
|
+
COLOR_BLUE = "#0063C5" # 接收响应(蓝)
|
|
25
|
+
COLOR_TAN = "#C4A35A" # LLM 流式输出(土黄色,更深更易辨认)
|
|
26
|
+
COLOR_RED = "#CC0000" # 错误
|
|
27
|
+
COLOR_GRAY = "#808080" # LLM 提示词(除接口文档外)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LogEmitter(QObject):
|
|
31
|
+
"""日志信号发射器,跨线程把日志事件发送到主线程的日志控件。"""
|
|
32
|
+
|
|
33
|
+
# text, color, newline
|
|
34
|
+
append_text = Signal(str, str, bool)
|
|
35
|
+
clear_log = Signal()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Broker:
|
|
39
|
+
"""
|
|
40
|
+
执行环境代理对象。
|
|
41
|
+
|
|
42
|
+
属性:
|
|
43
|
+
client: requests.Session 实例,自动维护服务端返回的 cookie。
|
|
44
|
+
api_url_base: 被测系统 API 基地址。
|
|
45
|
+
globals_vars: exec 运行时可以读写的用户全局变量存储区。
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, api_url_base: str = "http://127.0.0.1"):
|
|
49
|
+
self.client: requests.Session = requests.Session()
|
|
50
|
+
self.config: dict = {}
|
|
51
|
+
self.globals_vars: Dict[str, Any] = {}
|
|
52
|
+
self.log_emitter = LogEmitter()
|
|
53
|
+
self._wrap_client_send()
|
|
54
|
+
|
|
55
|
+
def _wrap_client_send(self):
|
|
56
|
+
"""包装 Session.send,在发送前打印请求、返回后打印响应。"""
|
|
57
|
+
original_send = self.client.send
|
|
58
|
+
original_request = self.client.request
|
|
59
|
+
broker = self
|
|
60
|
+
|
|
61
|
+
def logged_request(method, url, **kwargs):
|
|
62
|
+
if kwargs.get("timeout") is None:
|
|
63
|
+
kwargs["timeout"] = 10
|
|
64
|
+
return original_request(method, url, **kwargs)
|
|
65
|
+
|
|
66
|
+
def logged_send(request, **kwargs):
|
|
67
|
+
if kwargs.get("timeout") is None:
|
|
68
|
+
kwargs["timeout"] = 10
|
|
69
|
+
broker._log_prepared_request(request)
|
|
70
|
+
try:
|
|
71
|
+
response = original_send(request, **kwargs)
|
|
72
|
+
broker._log_response(response)
|
|
73
|
+
return response
|
|
74
|
+
except Exception:
|
|
75
|
+
broker.log(f"请求失败: {request.method} {request.url}", COLOR_RED)
|
|
76
|
+
raise
|
|
77
|
+
|
|
78
|
+
self.client.request = logged_request
|
|
79
|
+
self.client.send = logged_send
|
|
80
|
+
|
|
81
|
+
def _log_prepared_request(self, req):
|
|
82
|
+
"""打印 PreparedRequest 的完整内容。"""
|
|
83
|
+
lines = [f"请求: {req.method} {req.url}"]
|
|
84
|
+
if req.headers:
|
|
85
|
+
lines.append("请求头:")
|
|
86
|
+
for k, v in req.headers.items():
|
|
87
|
+
lines.append(f" {k}: {v}")
|
|
88
|
+
body = req.body
|
|
89
|
+
if body is not None:
|
|
90
|
+
if isinstance(body, bytes):
|
|
91
|
+
try:
|
|
92
|
+
body = body.decode("utf-8")
|
|
93
|
+
except Exception:
|
|
94
|
+
body = f"<binary {len(body)} bytes>"
|
|
95
|
+
lines.append("请求体:")
|
|
96
|
+
lines.append(self._format_body(body))
|
|
97
|
+
self.log("\n".join(lines), COLOR_GREEN)
|
|
98
|
+
|
|
99
|
+
def _log_response(self, response):
|
|
100
|
+
"""打印 Response 的基本信息。"""
|
|
101
|
+
lines = [
|
|
102
|
+
f"响应: HTTP {response.status_code} {response.reason}",
|
|
103
|
+
"响应头:",
|
|
104
|
+
json.dumps(dict(response.headers), ensure_ascii=False, indent=2),
|
|
105
|
+
"响应体:",
|
|
106
|
+
self._format_body(response.text),
|
|
107
|
+
]
|
|
108
|
+
self.log("\n".join(lines), COLOR_BLUE)
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
# 日志操作
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
def log(self, message: Any, color: Optional[str] = None):
|
|
114
|
+
"""在日志框中追加一条消息(自动换行)。
|
|
115
|
+
|
|
116
|
+
若未指定 color,使用默认黑色。
|
|
117
|
+
"""
|
|
118
|
+
text = str(message)
|
|
119
|
+
resolved = color if color is not None else COLOR_BLACK
|
|
120
|
+
self.log_emitter.append_text.emit(str(text), self._normalize_color(resolved), True)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def log_raw(self, text: str, color: str = COLOR_TAN):
|
|
124
|
+
"""在日志框中追加不带时间戳的文本,用于流式输出(默认不换行)。"""
|
|
125
|
+
self.log_emitter.append_text.emit(str(text), self._normalize_color(color), False)
|
|
126
|
+
|
|
127
|
+
def log_stream(self, chunk: str, color: str = COLOR_TAN):
|
|
128
|
+
"""流式追加文本(与 log_raw 等效,语义更明确)。"""
|
|
129
|
+
self.log_raw(chunk, color)
|
|
130
|
+
|
|
131
|
+
def log_json(self, data: Any, title: Optional[str] = None, color: str = COLOR_BLACK):
|
|
132
|
+
"""将对象以格式化 JSON 形式记录到日志框。"""
|
|
133
|
+
lines = []
|
|
134
|
+
if title:
|
|
135
|
+
lines.append(title)
|
|
136
|
+
try:
|
|
137
|
+
lines.append(json.dumps(data, ensure_ascii=False, indent=2))
|
|
138
|
+
except Exception:
|
|
139
|
+
lines.append(str(data))
|
|
140
|
+
self.log("\n".join(lines), color)
|
|
141
|
+
|
|
142
|
+
# def log_request(self, message: Any):
|
|
143
|
+
# """记录请求信息,固定绿色。"""
|
|
144
|
+
# self.log(message, COLOR_GREEN)
|
|
145
|
+
|
|
146
|
+
# def log_response(self, response: requests.Response, request_summary: Optional[str] = None):
|
|
147
|
+
# """旧版兼容方法:请求/响应已由 client 自动记录,本方法仅记录备注。"""
|
|
148
|
+
# if request_summary:
|
|
149
|
+
# self.log(f"请求: {request_summary}", COLOR_GREEN)
|
|
150
|
+
|
|
151
|
+
# def log_request_response(
|
|
152
|
+
# self,
|
|
153
|
+
# method: str,
|
|
154
|
+
# url: str,
|
|
155
|
+
# response: requests.Response,
|
|
156
|
+
# request_body: Any = None,
|
|
157
|
+
# request_headers: Optional[Dict[str, str]] = None,
|
|
158
|
+
# note: Optional[str] = None,
|
|
159
|
+
# ):
|
|
160
|
+
# """把一次完整 HTTP 请求/响应输出到日志。
|
|
161
|
+
|
|
162
|
+
# 请求和响应均由 Session 的 send 包装器自动记录,本方法只记录备注。
|
|
163
|
+
# """
|
|
164
|
+
# if note:
|
|
165
|
+
# self.log(f"备注: {note}", COLOR_GREEN)
|
|
166
|
+
|
|
167
|
+
def _format_body(self, body: Any) -> str:
|
|
168
|
+
"""将字符串/对象格式化为可读文本,过长时自动截断。"""
|
|
169
|
+
text = str(body)
|
|
170
|
+
if len(text) > 2000:
|
|
171
|
+
text = text[:2000] + "\n... (内容已截断)"
|
|
172
|
+
return text
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _normalize_color(color: str) -> str:
|
|
176
|
+
"""把常见颜色名映射到十六进制,兼容 LLM 生成的 color='green' 等写法。"""
|
|
177
|
+
if not color:
|
|
178
|
+
return COLOR_BLACK
|
|
179
|
+
c = str(color).strip().lower()
|
|
180
|
+
mapping = {
|
|
181
|
+
"black": COLOR_BLACK,
|
|
182
|
+
"green": COLOR_GREEN,
|
|
183
|
+
"blue": COLOR_BLUE,
|
|
184
|
+
"tan": COLOR_TAN,
|
|
185
|
+
"yellow": COLOR_TAN,
|
|
186
|
+
"red": COLOR_RED,
|
|
187
|
+
"error": COLOR_RED,
|
|
188
|
+
"request": COLOR_GREEN,
|
|
189
|
+
"response": COLOR_BLUE,
|
|
190
|
+
"stream": COLOR_TAN,
|
|
191
|
+
"llm": COLOR_TAN,
|
|
192
|
+
}
|
|
193
|
+
if c in mapping:
|
|
194
|
+
return mapping[c]
|
|
195
|
+
return color
|
|
196
|
+
|
|
197
|
+
def clear_log(self):
|
|
198
|
+
"""清空日志框内容。"""
|
|
199
|
+
self.log_emitter.clear_log.emit()
|
|
200
|
+
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
# 环境清理
|
|
203
|
+
# ------------------------------------------------------------------
|
|
204
|
+
def clear_client(self):
|
|
205
|
+
"""重置全局 client 对象(清除 cookies、headers 等状态)。"""
|
|
206
|
+
self.client = requests.Session()
|
|
207
|
+
self._wrap_client_send()
|
|
208
|
+
|
|
209
|
+
def close_client_connections(self):
|
|
210
|
+
"""关闭底层连接池中的所有活跃连接,用于强制中断卡住的 HTTP 请求。"""
|
|
211
|
+
for adapter in self.client.adapters.values():
|
|
212
|
+
adapter.close()
|
|
213
|
+
|
|
214
|
+
def clear_globals(self):
|
|
215
|
+
"""清理 exec 运行时的用户全局变量。"""
|
|
216
|
+
self.globals_vars.clear()
|
|
217
|
+
|
|
218
|
+
def clear_all(self):
|
|
219
|
+
"""一键清理 client、全局变量、日志框。"""
|
|
220
|
+
self.clear_exec_env()
|
|
221
|
+
self.clear_log()
|
|
222
|
+
self.log("环境已清理")
|
|
223
|
+
|
|
224
|
+
def clear_exec_env(self):
|
|
225
|
+
"""只清理执行环境(client、全局变量),不清理日志框。"""
|
|
226
|
+
self.clear_client()
|
|
227
|
+
self.clear_globals()
|
|
228
|
+
self.log("执行环境已清理(client 和全局变量已重置,日志保留)")
|
|
229
|
+
|
|
230
|
+
# ------------------------------------------------------------------
|
|
231
|
+
# exec 辅助
|
|
232
|
+
# ------------------------------------------------------------------
|
|
233
|
+
def build_globals(self) -> Dict[str, Any]:
|
|
234
|
+
"""构造 exec(code, globals) 时使用的 globals 字典。"""
|
|
235
|
+
|
|
236
|
+
def safe_exit(code=0):
|
|
237
|
+
raise SafeExit(
|
|
238
|
+
f"代码调用了 exit({code}),在测试环境中 exit 不可用。"
|
|
239
|
+
f"如需停止执行,请删除 exit() 调用。"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
g = {
|
|
243
|
+
"__builtins__": __builtins__,
|
|
244
|
+
"broker": self,
|
|
245
|
+
"client": self.client,
|
|
246
|
+
"config": self.config,
|
|
247
|
+
"log": self.log,
|
|
248
|
+
"log_raw": self.log_raw,
|
|
249
|
+
"log_stream": self.log_stream,
|
|
250
|
+
"log_json": self.log_json,
|
|
251
|
+
# "log_request": self.log_request,
|
|
252
|
+
# "log_response": self.log_response,
|
|
253
|
+
# "log_request_response": self.log_request_response,
|
|
254
|
+
"exit": safe_exit,
|
|
255
|
+
"quit": safe_exit,
|
|
256
|
+
"COLOR_BLACK": COLOR_BLACK,
|
|
257
|
+
"COLOR_GREEN": COLOR_GREEN,
|
|
258
|
+
"COLOR_BLUE": COLOR_BLUE,
|
|
259
|
+
"COLOR_TAN": COLOR_TAN,
|
|
260
|
+
"COLOR_RED": COLOR_RED,
|
|
261
|
+
"COLOR_GRAY": COLOR_GRAY,
|
|
262
|
+
}
|
|
263
|
+
g.update(self.globals_vars)
|
|
264
|
+
return g
|
|
265
|
+
|
|
266
|
+
def exec_code(self, code: str) -> Any:
|
|
267
|
+
"""在线程中调用此方法执行代码,并返回 exec 的结果。"""
|
|
268
|
+
self.log("=" * 40)
|
|
269
|
+
self.log("开始执行生成的 Python 代码")
|
|
270
|
+
self.log("=" * 40)
|
|
271
|
+
|
|
272
|
+
g = self.build_globals()
|
|
273
|
+
result = None
|
|
274
|
+
try:
|
|
275
|
+
result = exec(code, g)
|
|
276
|
+
except SafeExit as e:
|
|
277
|
+
self.log(str(e), COLOR_RED)
|
|
278
|
+
except SystemExit as e:
|
|
279
|
+
code_val = e.code if e.code is not None else ""
|
|
280
|
+
self.log(f"执行被 exit({code_val}) 中断(建议删除代码中的 exit 调用)", COLOR_RED)
|
|
281
|
+
except KeyboardInterrupt:
|
|
282
|
+
self.log("执行被用户中断", COLOR_RED)
|
|
283
|
+
raise
|
|
284
|
+
except Exception as e:
|
|
285
|
+
tb = traceback.format_exc()
|
|
286
|
+
self.log(f"执行出错: {e}", COLOR_RED)
|
|
287
|
+
self.log(tb, COLOR_RED)
|
|
288
|
+
raise
|
|
289
|
+
finally:
|
|
290
|
+
preserved = {
|
|
291
|
+
"__builtins__", "broker", "client", "config",
|
|
292
|
+
"log", "log_raw", "log_stream", "log_json",
|
|
293
|
+
# "log_request", "log_response", "log_request_response",
|
|
294
|
+
"COLOR_BLACK", "COLOR_GREEN", "COLOR_BLUE", "COLOR_TAN", "COLOR_RED", "COLOR_GRAY",
|
|
295
|
+
}
|
|
296
|
+
for key, value in g.items():
|
|
297
|
+
if key not in preserved:
|
|
298
|
+
self.globals_vars[key] = value
|
|
299
|
+
self.log("=" * 40)
|
|
300
|
+
self.log("代码执行结束")
|
|
301
|
+
self.log("=" * 40)
|
|
302
|
+
return result
|