seeed-jetson-developer 0.1.0__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.
Files changed (71) hide show
  1. seeed_jetson_develop/__init__.py +7 -0
  2. seeed_jetson_develop/assets/recovery/1.png +0 -0
  3. seeed_jetson_develop/assets/recovery/4.jpg +0 -0
  4. seeed_jetson_develop/assets/recovery/97.png +0 -0
  5. seeed_jetson_develop/assets/recovery/button.jpg +0 -0
  6. seeed_jetson_develop/assets/recovery/classic_fc_rec_2.png +0 -0
  7. seeed_jetson_develop/assets/recovery/flash.jpg +0 -0
  8. seeed_jetson_develop/assets/recovery/flash1.jpg +0 -0
  9. seeed_jetson_develop/assets/recovery/flash_1.png +0 -0
  10. seeed_jetson_develop/assets/recovery/industrial_rec_2.png +0 -0
  11. seeed_jetson_develop/assets/recovery/mini_lsusb_3.png +0 -0
  12. seeed_jetson_develop/assets/recovery/reComputer_mini_rec.png +0 -0
  13. seeed_jetson_develop/assets/recovery/reset.png +0 -0
  14. seeed_jetson_develop/assets/recovery/robotics_lsusb_f.png +0 -0
  15. seeed_jetson_develop/cli.py +113 -0
  16. seeed_jetson_develop/core/__init__.py +3 -0
  17. seeed_jetson_develop/core/config.py +45 -0
  18. seeed_jetson_develop/core/device.py +14 -0
  19. seeed_jetson_develop/core/events.py +28 -0
  20. seeed_jetson_develop/core/platform_detect.py +27 -0
  21. seeed_jetson_develop/core/runner.py +284 -0
  22. seeed_jetson_develop/data/l4t_data.json +844 -0
  23. seeed_jetson_develop/data/product_images.json +226 -0
  24. seeed_jetson_develop/data/recovery_guides.json +222 -0
  25. seeed_jetson_develop/data/recovery_guides.py +320 -0
  26. seeed_jetson_develop/flash.py +516 -0
  27. seeed_jetson_develop/gui/__init__.py +14 -0
  28. seeed_jetson_develop/gui/ai_chat.py +877 -0
  29. seeed_jetson_develop/gui/flash_animation.py +315 -0
  30. seeed_jetson_develop/gui/main_window.py +473 -0
  31. seeed_jetson_develop/gui/main_window_modern.py +718 -0
  32. seeed_jetson_develop/gui/main_window_sdk.py +1320 -0
  33. seeed_jetson_develop/gui/main_window_v2.py +2997 -0
  34. seeed_jetson_develop/gui/runtime_i18n.py +636 -0
  35. seeed_jetson_develop/gui/styles.py +482 -0
  36. seeed_jetson_develop/gui/theme.py +958 -0
  37. seeed_jetson_develop/modules/__init__.py +0 -0
  38. seeed_jetson_develop/modules/apps/__init__.py +2 -0
  39. seeed_jetson_develop/modules/apps/data/apps.json +102 -0
  40. seeed_jetson_develop/modules/apps/data/jetson_examples.json +632 -0
  41. seeed_jetson_develop/modules/apps/page.py +792 -0
  42. seeed_jetson_develop/modules/apps/registry.py +84 -0
  43. seeed_jetson_develop/modules/community/__init__.py +2 -0
  44. seeed_jetson_develop/modules/community/page.py +14 -0
  45. seeed_jetson_develop/modules/devices/__init__.py +2 -0
  46. seeed_jetson_develop/modules/devices/diagnostics.py +244 -0
  47. seeed_jetson_develop/modules/devices/page.py +595 -0
  48. seeed_jetson_develop/modules/flash/__init__.py +2 -0
  49. seeed_jetson_develop/modules/flash/page.py +16 -0
  50. seeed_jetson_develop/modules/flash/thread.py +81 -0
  51. seeed_jetson_develop/modules/remote/__init__.py +2 -0
  52. seeed_jetson_develop/modules/remote/agent_install_dialog.py +317 -0
  53. seeed_jetson_develop/modules/remote/connector.py +94 -0
  54. seeed_jetson_develop/modules/remote/desktop_dialog.py +336 -0
  55. seeed_jetson_develop/modules/remote/desktop_remote.py +223 -0
  56. seeed_jetson_develop/modules/remote/jetson_init.py +1243 -0
  57. seeed_jetson_develop/modules/remote/native_terminal.py +197 -0
  58. seeed_jetson_develop/modules/remote/net_share.py +384 -0
  59. seeed_jetson_develop/modules/remote/net_share_dialog.py +475 -0
  60. seeed_jetson_develop/modules/remote/page.py +1324 -0
  61. seeed_jetson_develop/modules/skills/__init__.py +3 -0
  62. seeed_jetson_develop/modules/skills/data/skills.json +442 -0
  63. seeed_jetson_develop/modules/skills/engine.py +636 -0
  64. seeed_jetson_develop/modules/skills/page.py +967 -0
  65. seeed_jetson_develop/recovery.py +89 -0
  66. seeed_jetson_developer-0.1.0.dist-info/METADATA +170 -0
  67. seeed_jetson_developer-0.1.0.dist-info/RECORD +71 -0
  68. seeed_jetson_developer-0.1.0.dist-info/WHEEL +5 -0
  69. seeed_jetson_developer-0.1.0.dist-info/entry_points.txt +2 -0
  70. seeed_jetson_developer-0.1.0.dist-info/licenses/LICENSE +21 -0
  71. seeed_jetson_developer-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,7 @@
1
+ """
2
+ Seeed Jetson Flash - A tool for flashing Jetson devices
3
+ """
4
+
5
+ __version__ = "0.1.0"
6
+ __author__ = "Seeed Studio"
7
+ __email__ = "support@seeedstudio.com"
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 命令行接口模块
4
+ """
5
+ import click
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ from .flash import JetsonFlasher
10
+ from .recovery import RecoveryGuide
11
+
12
+
13
+ @click.group()
14
+ @click.version_option()
15
+ def cli():
16
+ """Seeed Jetson Flash - 为 Jetson 设备刷机的工具"""
17
+ pass
18
+
19
+
20
+ @cli.command()
21
+ @click.option('--product', '-p', required=True, help='产品型号 (例如: j4012mini)')
22
+ @click.option('--l4t', '-l', required=True, help='L4T 版本 (例如: 36.3.0)')
23
+ @click.option('--download-only', is_flag=True, help='仅下载固件,不刷写')
24
+ @click.option('--skip-verify', is_flag=True, help='跳过 SHA256 校验')
25
+ def flash(product, l4t, download_only, skip_verify):
26
+ """刷写 Jetson 设备"""
27
+ flasher = JetsonFlasher(product, l4t)
28
+
29
+ click.echo(f"正在为 {product} 准备 L4T {l4t} 固件...")
30
+
31
+ # 下载固件
32
+ if not flasher.download_firmware():
33
+ click.echo("固件下载失败", err=True)
34
+ return
35
+
36
+ # 校验固件
37
+ if not skip_verify:
38
+ if not flasher.verify_firmware():
39
+ click.echo("固件校验失败", err=True)
40
+ return
41
+
42
+ if download_only:
43
+ click.echo("固件下载完成")
44
+ return
45
+
46
+ # 解压固件
47
+ if not flasher.extract_firmware():
48
+ click.echo("固件解压失败", err=True)
49
+ return
50
+
51
+ # 刷写固件
52
+ if not flasher.flash_firmware():
53
+ click.echo("固件刷写失败", err=True)
54
+ return
55
+
56
+ click.echo("刷写完成!")
57
+
58
+
59
+ @cli.command()
60
+ @click.option('--product', '-p', required=True, help='产品型号')
61
+ def recovery(product):
62
+ """显示进入 Recovery 模式的教程"""
63
+ guide = RecoveryGuide(product)
64
+ guide.show_guide()
65
+
66
+
67
+ @cli.command()
68
+ def list_products():
69
+ """列出所有支持的产品"""
70
+ data_path = Path(__file__).parent / "data" / "l4t_data.json"
71
+ with open(data_path, 'r') as f:
72
+ data = json.load(f)
73
+
74
+ products = {}
75
+ for item in data:
76
+ product = item['product']
77
+ if product not in products:
78
+ products[product] = []
79
+ products[product].append(item['l4t'])
80
+
81
+ click.echo("支持的产品列表:\n")
82
+ for product, l4t_versions in sorted(products.items()):
83
+ click.echo(f" {product}")
84
+ click.echo(f" L4T 版本: {', '.join(l4t_versions)}")
85
+ click.echo()
86
+
87
+
88
+ @cli.command()
89
+ def gui():
90
+ """启动图形界面"""
91
+ try:
92
+ # 直接导入 main 函数,避免通过 __init__.py
93
+ import sys
94
+ from pathlib import Path
95
+
96
+ # 确保可以找到模块
97
+ module_path = Path(__file__).parent
98
+ if str(module_path) not in sys.path:
99
+ sys.path.insert(0, str(module_path))
100
+
101
+ from seeed_jetson_develop.gui.main_window import main as gui_main
102
+ gui_main()
103
+ except ImportError as e:
104
+ click.echo(f"错误: 无法启动 GUI,请安装 PyQt5: pip install PyQt5", err=True)
105
+ click.echo(f"详细错误: {e}", err=True)
106
+
107
+
108
+ def main():
109
+ cli()
110
+
111
+
112
+ if __name__ == '__main__':
113
+ main()
@@ -0,0 +1,3 @@
1
+ from .events import bus
2
+ from .runner import Runner
3
+ from .device import DeviceInfo
@@ -0,0 +1,45 @@
1
+ """全局配置持久化"""
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+
6
+ _CONFIG_PATH = Path.home() / ".config" / "seeed-jetson-tool" / "config.json"
7
+ DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com"
8
+
9
+
10
+ def load() -> dict:
11
+ try:
12
+ return json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
13
+ except Exception:
14
+ return {}
15
+
16
+
17
+ def save(data: dict):
18
+ _CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
19
+ _CONFIG_PATH.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
20
+
21
+
22
+ def get_runtime_anthropic_settings() -> dict:
23
+ data = load()
24
+
25
+ config_key = (data.get("anthropic_api_key") or "").strip()
26
+ env_key = (os.environ.get("ANTHROPIC_API_KEY") or "").strip()
27
+ api_key = config_key or env_key
28
+ api_key_source = "config" if config_key else ("env" if env_key else "none")
29
+
30
+ config_url = (data.get("anthropic_base_url") or "").strip()
31
+ env_url = (os.environ.get("ANTHROPIC_BASE_URL") or "").strip()
32
+ base_url = config_url or env_url or DEFAULT_ANTHROPIC_BASE_URL
33
+ if config_url:
34
+ base_url_source = "config"
35
+ elif env_url:
36
+ base_url_source = "env"
37
+ else:
38
+ base_url_source = "default"
39
+
40
+ return {
41
+ "api_key": api_key,
42
+ "api_key_source": api_key_source,
43
+ "base_url": base_url,
44
+ "base_url_source": base_url_source,
45
+ }
@@ -0,0 +1,14 @@
1
+ """设备信息数据类"""
2
+ from dataclasses import dataclass, field
3
+ from typing import Optional
4
+
5
+
6
+ @dataclass
7
+ class DeviceInfo:
8
+ ip: str = ""
9
+ hostname: str = ""
10
+ model: str = "" # e.g. "reComputer J4012"
11
+ jetpack: str = "" # e.g. "6.0"
12
+ l4t: str = "" # e.g. "36.3.0"
13
+ connected: bool = False
14
+ diagnostics: dict = field(default_factory=dict)
@@ -0,0 +1,28 @@
1
+ """全局事件总线 — 模块间通信,避免直接 import 耦合"""
2
+ from PyQt5.QtCore import QObject, pyqtSignal
3
+
4
+
5
+ class EventBus(QObject):
6
+ # devices 模块
7
+ device_connected = pyqtSignal(dict) # payload: {ip, name, model}
8
+ device_disconnected = pyqtSignal(str) # payload: ip
9
+ diagnostics_done = pyqtSignal(dict) # payload: {item: status}
10
+
11
+ # flash 模块
12
+ flash_started = pyqtSignal(str, str) # product, l4t
13
+ flash_completed = pyqtSignal(bool, str) # success, message
14
+
15
+ # skills 模块
16
+ skill_run_requested = pyqtSignal(str) # skill_id
17
+ skill_completed = pyqtSignal(str, bool, str) # skill_id, success, log
18
+
19
+ # apps 模块
20
+ app_install_requested = pyqtSignal(str) # app_id
21
+ app_installed = pyqtSignal(str, bool) # app_id, success
22
+
23
+ # 导航
24
+ navigate_to = pyqtSignal(int) # page index
25
+
26
+
27
+ # 全局单例,所有模块 from seeed_jetson_develop.core import bus
28
+ bus = EventBus()
@@ -0,0 +1,27 @@
1
+ """运行环境检测 — 判断是否在 Jetson 设备本地运行"""
2
+ import os
3
+ import platform
4
+
5
+
6
+ def is_jetson() -> bool:
7
+ """
8
+ 检测当前是否运行在 NVIDIA Jetson 设备上。
9
+ 判断依据:Linux + aarch64 架构 + Tegra 特有文件。
10
+ """
11
+ if platform.system() != "Linux":
12
+ return False
13
+ if platform.machine() not in ("aarch64", "armv8l"):
14
+ return False
15
+ # Tegra release 标志文件
16
+ if os.path.exists("/etc/nv_tegra_release"):
17
+ return True
18
+ # 设备树 model 文件
19
+ model_file = "/proc/device-tree/model"
20
+ if os.path.exists(model_file):
21
+ try:
22
+ with open(model_file, "rb") as f:
23
+ model = f.read().decode("utf-8", errors="ignore").lower()
24
+ return "jetson" in model or "tegra" in model
25
+ except Exception:
26
+ pass
27
+ return False
@@ -0,0 +1,284 @@
1
+ """命令执行引擎 — 本地或远程 SSH 执行,统一接口"""
2
+ import os
3
+ import re
4
+ import shlex
5
+ import subprocess
6
+ from typing import Callable, Optional
7
+
8
+
9
+ class Runner:
10
+ """
11
+ 本地命令执行器。
12
+ 后续可扩展 SSHRunner(Runner) 实现远程执行,接口保持一致。
13
+ """
14
+
15
+ def run(
16
+ self,
17
+ cmd: str,
18
+ timeout: int = 30,
19
+ on_output: Optional[Callable[[str], None]] = None,
20
+ ) -> tuple[int, str]:
21
+ """
22
+ 执行命令,返回 (returncode, output)。
23
+ on_output: 实时输出回调,每行调用一次。
24
+ 同时处理 \\n 和 \\r 分隔(支持 pip 进度条等 \\r 刷新场景)。
25
+ """
26
+ env = {**os.environ, "PYTHONUNBUFFERED": "1"}
27
+ try:
28
+ proc = subprocess.Popen(
29
+ cmd, shell=True,
30
+ stdout=subprocess.PIPE,
31
+ stderr=subprocess.STDOUT,
32
+ env=env,
33
+ )
34
+ fd = proc.stdout.fileno()
35
+ buf = b""
36
+ lines = []
37
+
38
+ while True:
39
+ try:
40
+ chunk = os.read(fd, 65536) # 有多少读多少,立即返回
41
+ except OSError:
42
+ break
43
+ if not chunk:
44
+ break
45
+ buf += chunk
46
+ # 按 \n 或 \r 切割,保留最后未完成的片段
47
+ parts = re.split(rb"[\n\r]+", buf)
48
+ for part in parts[:-1]:
49
+ line = part.decode("utf-8", errors="replace").strip()
50
+ if line:
51
+ lines.append(line)
52
+ if on_output:
53
+ on_output(line)
54
+ buf = parts[-1]
55
+
56
+ # 冲刷剩余缓冲
57
+ if buf.strip():
58
+ line = buf.decode("utf-8", errors="replace").strip()
59
+ lines.append(line)
60
+ if on_output:
61
+ on_output(line)
62
+
63
+ proc.wait(timeout=timeout)
64
+ return proc.returncode, "\n".join(lines)
65
+ except subprocess.TimeoutExpired:
66
+ proc.kill()
67
+ return -1, "timeout"
68
+ except Exception as e:
69
+ return -1, str(e)
70
+
71
+
72
+ class SSHRunner(Runner):
73
+ """
74
+ 远程 SSH 命令执行器,接口与 Runner 一致。
75
+ 每次 run() 建立一条独立 SSH 连接,适合诊断类低频调用。
76
+ """
77
+
78
+ def __init__(self, host: str, username: str = "seeed",
79
+ password: str = "", port: int = 22, sudo_password: str = ""):
80
+ self.host = host
81
+ self.username = username
82
+ self.password = password
83
+ self.sudo_password = sudo_password or password
84
+ self.port = port
85
+
86
+ def _wrap_with_sudo_password(self, cmd: str) -> str:
87
+ wrapper_parts = ["export TERM=${TERM:-xterm-256color};"]
88
+ if self.sudo_password:
89
+ wrapper_parts.extend([
90
+ f"export SEEED_SUDO_PASSWORD={shlex.quote(self.sudo_password)};",
91
+ "sudo() { printf '%s\\n' \"$SEEED_SUDO_PASSWORD\" | command sudo -S \"$@\"; };",
92
+ "export -f sudo;",
93
+ ])
94
+ wrapper_parts.append(cmd)
95
+ wrapper = " ".join(wrapper_parts)
96
+ return f"bash -lc {shlex.quote(wrapper)}"
97
+
98
+ def open_sftp(self):
99
+ """建立 SSH 连接并返回 (client, sftp),调用方负责 client.close()。"""
100
+ import paramiko
101
+ client = paramiko.SSHClient()
102
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
103
+ client.connect(
104
+ self.host,
105
+ port=self.port,
106
+ username=self.username,
107
+ password=self.password or None,
108
+ timeout=10,
109
+ look_for_keys=True,
110
+ allow_agent=True,
111
+ )
112
+ client.get_transport().set_keepalive(30)
113
+ sftp = client.open_sftp()
114
+ return client, sftp
115
+
116
+ def run(
117
+ self,
118
+ cmd: str,
119
+ timeout: int = 30,
120
+ on_output: Optional[Callable[[str], None]] = None,
121
+ ) -> tuple[int, str]:
122
+ try:
123
+ import paramiko
124
+ client = paramiko.SSHClient()
125
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
126
+ client.connect(
127
+ self.host,
128
+ port=self.port,
129
+ username=self.username,
130
+ password=self.password or None,
131
+ timeout=10,
132
+ look_for_keys=True,
133
+ allow_agent=True,
134
+ )
135
+ client.get_transport().set_keepalive(30)
136
+ try:
137
+ _, stdout, stderr = client.exec_command(
138
+ self._wrap_with_sudo_password(cmd),
139
+ timeout=timeout,
140
+ )
141
+ lines = []
142
+ for raw in stdout:
143
+ line = raw.rstrip("\n")
144
+ lines.append(line)
145
+ if on_output:
146
+ on_output(line)
147
+ rc = stdout.channel.recv_exit_status()
148
+ err = stderr.read().decode("utf-8", errors="replace").strip()
149
+ if err:
150
+ for line in err.splitlines():
151
+ if on_output:
152
+ on_output(line)
153
+ lines.extend(line for line in err.splitlines() if line)
154
+ out = "\n".join(lines)
155
+ return rc, out
156
+ finally:
157
+ client.close()
158
+ except Exception as e:
159
+ return -1, str(e)
160
+
161
+
162
+ class SerialRunner(Runner):
163
+ """
164
+ 通过串口登录 Jetson 并执行命令,接口与 SSHRunner 一致。
165
+ 每次 run() 建立一次串口会话,适合诊断类低频调用。
166
+ """
167
+
168
+ def __init__(self, port: str, username: str = "seeed", password: str = ""):
169
+ self.port = port
170
+ self.username = username
171
+ self.password = password
172
+
173
+ def run(
174
+ self,
175
+ cmd: str,
176
+ timeout: int = 30,
177
+ on_output: Optional[Callable[[str], None]] = None,
178
+ ) -> tuple[int, str]:
179
+ import re
180
+ import time
181
+ try:
182
+ import serial
183
+ except ImportError:
184
+ return -1, "pyserial 未安装,请运行 pip install pyserial"
185
+
186
+ lines: list[str] = []
187
+
188
+ def _emit(text: str):
189
+ if on_output:
190
+ on_output(text)
191
+ lines.append(text)
192
+
193
+ try:
194
+ ser = serial.Serial(
195
+ self.port, baudrate=115200, timeout=0.1,
196
+ bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE,
197
+ stopbits=serial.STOPBITS_ONE,
198
+ )
199
+ except Exception as exc:
200
+ return -1, str(exc)
201
+
202
+ def _read_until(patterns: list[str], wait: float) -> str:
203
+ buf = ""
204
+ deadline = time.time() + wait
205
+ while time.time() < deadline:
206
+ if ser.in_waiting:
207
+ chunk = ser.read(ser.in_waiting).decode("utf-8", errors="ignore")
208
+ if chunk:
209
+ buf += chunk
210
+ else:
211
+ time.sleep(0.05)
212
+ for p in patterns:
213
+ if re.search(p, buf):
214
+ return buf
215
+ return buf
216
+
217
+ try:
218
+ for _ in range(3):
219
+ ser.write(b"\r\n")
220
+ time.sleep(0.3)
221
+ buf = _read_until([r"login:", r"[$#]\s*"], 8.0)
222
+
223
+ if re.search(r"login:", buf):
224
+ time.sleep(0.2)
225
+ ser.write((self.username + "\r\n").encode())
226
+ buf = _read_until([r"[Pp]assword:", r"[$#]\s*"], 8.0)
227
+
228
+ if re.search(r"[Pp]assword:", buf):
229
+ time.sleep(0.2)
230
+ ser.write((self.password + "\r\n").encode())
231
+ buf = _read_until(
232
+ [r"[$#]\s*", r"[Ll]ogin incorrect", r"[Aa]uthentication failure"], 10.0)
233
+
234
+ if not re.search(r"[$#]\s*", buf):
235
+ if re.search(r"[Ll]ogin incorrect|[Aa]uthentication failure", buf):
236
+ return -1, "用户名或密码错误"
237
+ return -1, "登录失败,未检测到 shell 提示符"
238
+
239
+ time.sleep(0.2)
240
+ ser.write(b"export TERM=xterm-256color\r\n")
241
+ _read_until([r"[$#]\s*"], 3.0)
242
+ ser.write((cmd + "\r\n").encode())
243
+ result = _read_until([r"[$#]\s*"], float(timeout))
244
+
245
+ # 去掉 ANSI 转义码,提取命令输出
246
+ clean = re.sub(r"\x1B\[[0-?]*[ -/]*[@-~]", "", result)
247
+ clean = re.sub(r"\x1B[@-_]", "", clean)
248
+ # 去掉回显的命令行和最后的提示符
249
+ out_lines = []
250
+ skip_first = True
251
+ for line in clean.splitlines():
252
+ stripped = line.strip()
253
+ if skip_first and cmd.strip() in stripped:
254
+ skip_first = False
255
+ continue
256
+ if re.search(r"[$#]\s*$", stripped) and not stripped.replace("$", "").replace("#", "").strip():
257
+ continue
258
+ if stripped:
259
+ out_lines.append(stripped)
260
+ if on_output:
261
+ on_output(stripped)
262
+ return 0, "\n".join(out_lines)
263
+ except Exception as exc:
264
+ return -1, str(exc)
265
+ finally:
266
+ try:
267
+ ser.close()
268
+ except Exception:
269
+ pass
270
+
271
+
272
+ # ── 全局活跃 Runner 单例 ────────────────────────────────────────────────────
273
+ _active_runner: Optional[Runner] = None
274
+
275
+
276
+ def get_runner() -> Runner:
277
+ """返回全局活跃 Runner(SSH 已连接则为 SSHRunner,否则为本地 Runner)。"""
278
+ return _active_runner if _active_runner is not None else Runner()
279
+
280
+
281
+ def set_runner(runner: Optional[Runner]) -> None:
282
+ """切换全局 Runner。传 None 恢复本地模式。"""
283
+ global _active_runner
284
+ _active_runner = runner