autoglm-gui 1.4.0__py3-none-any.whl → 1.5.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.
- AutoGLM_GUI/__init__.py +11 -0
- AutoGLM_GUI/__main__.py +26 -8
- AutoGLM_GUI/actions/__init__.py +6 -0
- AutoGLM_GUI/actions/handler.py +196 -0
- AutoGLM_GUI/actions/types.py +15 -0
- AutoGLM_GUI/adb/__init__.py +53 -0
- AutoGLM_GUI/adb/apps.py +227 -0
- AutoGLM_GUI/adb/connection.py +323 -0
- AutoGLM_GUI/adb/device.py +171 -0
- AutoGLM_GUI/adb/input.py +67 -0
- AutoGLM_GUI/adb/screenshot.py +11 -0
- AutoGLM_GUI/adb/timing.py +167 -0
- AutoGLM_GUI/adb_plus/keyboard_installer.py +4 -2
- AutoGLM_GUI/adb_plus/qr_pair.py +8 -8
- AutoGLM_GUI/adb_plus/screenshot.py +22 -1
- AutoGLM_GUI/adb_plus/serial.py +38 -20
- AutoGLM_GUI/adb_plus/touch.py +4 -9
- AutoGLM_GUI/agents/__init__.py +51 -0
- AutoGLM_GUI/agents/events.py +19 -0
- AutoGLM_GUI/agents/factory.py +153 -0
- AutoGLM_GUI/agents/glm/__init__.py +7 -0
- AutoGLM_GUI/agents/glm/agent.py +292 -0
- AutoGLM_GUI/agents/glm/message_builder.py +81 -0
- AutoGLM_GUI/agents/glm/parser.py +110 -0
- AutoGLM_GUI/agents/glm/prompts_en.py +77 -0
- AutoGLM_GUI/agents/glm/prompts_zh.py +75 -0
- AutoGLM_GUI/agents/mai/__init__.py +28 -0
- AutoGLM_GUI/agents/mai/agent.py +405 -0
- AutoGLM_GUI/agents/mai/parser.py +254 -0
- AutoGLM_GUI/agents/mai/prompts.py +103 -0
- AutoGLM_GUI/agents/mai/traj_memory.py +91 -0
- AutoGLM_GUI/agents/protocols.py +27 -0
- AutoGLM_GUI/agents/stream_runner.py +188 -0
- AutoGLM_GUI/api/__init__.py +71 -11
- AutoGLM_GUI/api/agents.py +190 -229
- AutoGLM_GUI/api/control.py +9 -6
- AutoGLM_GUI/api/devices.py +112 -28
- AutoGLM_GUI/api/health.py +13 -0
- AutoGLM_GUI/api/history.py +78 -0
- AutoGLM_GUI/api/layered_agent.py +306 -181
- AutoGLM_GUI/api/mcp.py +11 -10
- AutoGLM_GUI/api/media.py +64 -1
- AutoGLM_GUI/api/scheduled_tasks.py +98 -0
- AutoGLM_GUI/api/version.py +23 -10
- AutoGLM_GUI/api/workflows.py +2 -1
- AutoGLM_GUI/config.py +72 -14
- AutoGLM_GUI/config_manager.py +98 -27
- AutoGLM_GUI/device_adapter.py +263 -0
- AutoGLM_GUI/device_manager.py +248 -29
- AutoGLM_GUI/device_protocol.py +266 -0
- AutoGLM_GUI/devices/__init__.py +49 -0
- AutoGLM_GUI/devices/adb_device.py +200 -0
- AutoGLM_GUI/devices/mock_device.py +185 -0
- AutoGLM_GUI/devices/remote_device.py +177 -0
- AutoGLM_GUI/exceptions.py +3 -3
- AutoGLM_GUI/history_manager.py +164 -0
- AutoGLM_GUI/i18n.py +81 -0
- AutoGLM_GUI/metrics.py +13 -20
- AutoGLM_GUI/model/__init__.py +5 -0
- AutoGLM_GUI/model/message_builder.py +69 -0
- AutoGLM_GUI/model/types.py +24 -0
- AutoGLM_GUI/models/__init__.py +10 -0
- AutoGLM_GUI/models/history.py +96 -0
- AutoGLM_GUI/models/scheduled_task.py +71 -0
- AutoGLM_GUI/parsers/__init__.py +22 -0
- AutoGLM_GUI/parsers/base.py +50 -0
- AutoGLM_GUI/parsers/phone_parser.py +58 -0
- AutoGLM_GUI/phone_agent_manager.py +118 -367
- AutoGLM_GUI/platform_utils.py +31 -2
- AutoGLM_GUI/prompt_config.py +15 -0
- AutoGLM_GUI/prompts/__init__.py +32 -0
- AutoGLM_GUI/scheduler_manager.py +304 -0
- AutoGLM_GUI/schemas.py +272 -63
- AutoGLM_GUI/scrcpy_stream.py +159 -37
- AutoGLM_GUI/server.py +3 -1
- AutoGLM_GUI/socketio_server.py +114 -29
- AutoGLM_GUI/state.py +10 -30
- AutoGLM_GUI/static/assets/{about-DeclntHg.js → about-BQm96DAl.js} +1 -1
- AutoGLM_GUI/static/assets/alert-dialog-B42XxGPR.js +1 -0
- AutoGLM_GUI/static/assets/chat-C0L2gQYG.js +129 -0
- AutoGLM_GUI/static/assets/circle-alert-D4rSJh37.js +1 -0
- AutoGLM_GUI/static/assets/dialog-DZ78cEcj.js +45 -0
- AutoGLM_GUI/static/assets/history-DFBv7TGc.js +1 -0
- AutoGLM_GUI/static/assets/index-Bzyv2yQ2.css +1 -0
- AutoGLM_GUI/static/assets/{index-zQ4KKDHt.js → index-CmZSnDqc.js} +1 -1
- AutoGLM_GUI/static/assets/index-CssG-3TH.js +11 -0
- AutoGLM_GUI/static/assets/label-BCUzE_nm.js +1 -0
- AutoGLM_GUI/static/assets/logs-eoFxn5of.js +1 -0
- AutoGLM_GUI/static/assets/popover-DLsuV5Sx.js +1 -0
- AutoGLM_GUI/static/assets/scheduled-tasks-MyqGJvy_.js +1 -0
- AutoGLM_GUI/static/assets/square-pen-zGWYrdfj.js +1 -0
- AutoGLM_GUI/static/assets/textarea-BX6y7uM5.js +1 -0
- AutoGLM_GUI/static/assets/workflows-CYFs6ssC.js +1 -0
- AutoGLM_GUI/static/index.html +2 -2
- AutoGLM_GUI/types.py +142 -0
- {autoglm_gui-1.4.0.dist-info → autoglm_gui-1.5.0.dist-info}/METADATA +178 -92
- autoglm_gui-1.5.0.dist-info/RECORD +157 -0
- mai_agent/base.py +137 -0
- mai_agent/mai_grounding_agent.py +263 -0
- mai_agent/mai_naivigation_agent.py +526 -0
- mai_agent/prompt.py +148 -0
- mai_agent/unified_memory.py +67 -0
- mai_agent/utils.py +73 -0
- AutoGLM_GUI/api/dual_model.py +0 -311
- AutoGLM_GUI/dual_model/__init__.py +0 -53
- AutoGLM_GUI/dual_model/decision_model.py +0 -664
- AutoGLM_GUI/dual_model/dual_agent.py +0 -917
- AutoGLM_GUI/dual_model/protocols.py +0 -354
- AutoGLM_GUI/dual_model/vision_model.py +0 -442
- AutoGLM_GUI/mai_ui_adapter/agent_wrapper.py +0 -291
- AutoGLM_GUI/phone_agent_patches.py +0 -146
- AutoGLM_GUI/static/assets/chat-Iut2yhSw.js +0 -125
- AutoGLM_GUI/static/assets/dialog-BfdcBs1x.js +0 -45
- AutoGLM_GUI/static/assets/index-5hCCwHA7.css +0 -1
- AutoGLM_GUI/static/assets/index-DHF1NZh0.js +0 -12
- AutoGLM_GUI/static/assets/workflows-xiplap-r.js +0 -1
- autoglm_gui-1.4.0.dist-info/RECORD +0 -100
- {autoglm_gui-1.4.0.dist-info → autoglm_gui-1.5.0.dist-info}/WHEEL +0 -0
- {autoglm_gui-1.4.0.dist-info → autoglm_gui-1.5.0.dist-info}/entry_points.txt +0 -0
- {autoglm_gui-1.4.0.dist-info → autoglm_gui-1.5.0.dist-info}/licenses/LICENSE +0 -0
AutoGLM_GUI/schemas.py
CHANGED
|
@@ -2,65 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
4
|
|
|
5
|
-
from pydantic import BaseModel,
|
|
5
|
+
from pydantic import BaseModel, field_validator
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
class
|
|
9
|
-
|
|
10
|
-
api_key: str | None = None
|
|
11
|
-
model_name: str | None = None
|
|
12
|
-
max_tokens: int = 3000
|
|
13
|
-
temperature: float = 0.0
|
|
14
|
-
top_p: float = 0.85
|
|
15
|
-
frequency_penalty: float = 0.2
|
|
16
|
-
|
|
17
|
-
@field_validator("base_url")
|
|
18
|
-
@classmethod
|
|
19
|
-
def validate_base_url(cls, v: str | None) -> str | None:
|
|
20
|
-
"""验证 base_url 格式."""
|
|
21
|
-
if v is None:
|
|
22
|
-
return v
|
|
23
|
-
v = v.strip()
|
|
24
|
-
if not v:
|
|
25
|
-
return None
|
|
26
|
-
# 检查是否是有效的 HTTP/HTTPS URL
|
|
27
|
-
if not re.match(r"^https?://", v):
|
|
28
|
-
raise ValueError("base_url must start with http:// or https://")
|
|
29
|
-
return v
|
|
8
|
+
class InitRequest(BaseModel):
|
|
9
|
+
device_id: str # Device ID (required)
|
|
30
10
|
|
|
11
|
+
# Agent configuration (factory pattern)
|
|
12
|
+
agent_type: str = "glm" # Agent type to use (e.g., "glm", "mai")
|
|
13
|
+
agent_config_params: dict | None = None # Agent-specific configuration parameters
|
|
31
14
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
device_id: str | None = None
|
|
35
|
-
lang: str = "cn"
|
|
36
|
-
system_prompt: str | None = None
|
|
37
|
-
verbose: bool = True
|
|
38
|
-
|
|
39
|
-
@field_validator("max_steps")
|
|
40
|
-
@classmethod
|
|
41
|
-
def validate_max_steps(cls, v: int) -> int:
|
|
42
|
-
"""验证 max_steps 范围."""
|
|
43
|
-
if v <= 0:
|
|
44
|
-
raise ValueError("max_steps must be positive")
|
|
45
|
-
if v > 1000:
|
|
46
|
-
raise ValueError("max_steps must be <= 1000")
|
|
47
|
-
return v
|
|
15
|
+
# Hot-reload support
|
|
16
|
+
force: bool = False # Force re-initialization even if agent already exists
|
|
48
17
|
|
|
49
|
-
@field_validator("
|
|
18
|
+
@field_validator("agent_type")
|
|
50
19
|
@classmethod
|
|
51
|
-
def
|
|
52
|
-
"""验证
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
20
|
+
def validate_agent_type(cls, v: str) -> str:
|
|
21
|
+
"""验证 agent_type 有效性."""
|
|
22
|
+
# Don't import at module level to avoid circular imports
|
|
23
|
+
from AutoGLM_GUI.agents import is_agent_type_registered
|
|
24
|
+
|
|
25
|
+
if not is_agent_type_registered(v):
|
|
26
|
+
raise ValueError(
|
|
27
|
+
f"Unknown agent_type: '{v}'. "
|
|
28
|
+
f"Please register the agent type first or use a known type."
|
|
29
|
+
)
|
|
56
30
|
return v
|
|
57
31
|
|
|
58
32
|
|
|
59
|
-
class InitRequest(BaseModel):
|
|
60
|
-
model: APIModelConfig | None = Field(default=None, alias="model_config")
|
|
61
|
-
agent: APIAgentConfig | None = Field(default=None, alias="agent_config")
|
|
62
|
-
|
|
63
|
-
|
|
64
33
|
class ChatRequest(BaseModel):
|
|
65
34
|
message: str
|
|
66
35
|
device_id: str # 设备 ID(必填)
|
|
@@ -320,11 +289,17 @@ class ConfigResponse(BaseModel):
|
|
|
320
289
|
api_key: str # 返回实际值(明文)
|
|
321
290
|
source: str # "CLI arguments" | "environment variables" | "config file (...)" | "default"
|
|
322
291
|
|
|
323
|
-
#
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
292
|
+
# Agent 类型配置
|
|
293
|
+
agent_type: str = "glm" # Agent type (e.g., "glm", "mai")
|
|
294
|
+
agent_config_params: dict | None = None # Agent-specific configuration
|
|
295
|
+
|
|
296
|
+
# Agent 执行配置
|
|
297
|
+
default_max_steps: int = 100 # 单次任务最大执行步数
|
|
298
|
+
|
|
299
|
+
# 决策模型配置(用于分层代理)
|
|
300
|
+
decision_base_url: str | None = None
|
|
301
|
+
decision_model_name: str | None = None
|
|
302
|
+
decision_api_key: str | None = None
|
|
328
303
|
|
|
329
304
|
conflicts: list[dict] | None = None # 配置冲突信息(可选)
|
|
330
305
|
|
|
@@ -336,12 +311,30 @@ class ConfigSaveRequest(BaseModel):
|
|
|
336
311
|
model_name: str = "autoglm-phone-9b"
|
|
337
312
|
api_key: str | None = None
|
|
338
313
|
|
|
339
|
-
#
|
|
340
|
-
|
|
314
|
+
# Agent 类型配置
|
|
315
|
+
agent_type: str = "glm" # Agent type to use (e.g., "glm", "mai")
|
|
316
|
+
agent_config_params: dict | None = None # Agent-specific configuration parameters
|
|
317
|
+
|
|
318
|
+
# Agent 执行配置
|
|
319
|
+
default_max_steps: int | None = None # 单次任务最大执行步数
|
|
320
|
+
|
|
321
|
+
# 决策模型配置(用于分层代理)
|
|
341
322
|
decision_base_url: str | None = None
|
|
342
323
|
decision_model_name: str | None = None
|
|
343
324
|
decision_api_key: str | None = None
|
|
344
325
|
|
|
326
|
+
@field_validator("default_max_steps")
|
|
327
|
+
@classmethod
|
|
328
|
+
def validate_default_max_steps(cls, v: int | None) -> int | None:
|
|
329
|
+
"""验证 default_max_steps 范围."""
|
|
330
|
+
if v is None:
|
|
331
|
+
return v
|
|
332
|
+
if v <= 0:
|
|
333
|
+
raise ValueError("default_max_steps must be positive")
|
|
334
|
+
if v > 1000:
|
|
335
|
+
raise ValueError("default_max_steps must be <= 1000")
|
|
336
|
+
return v
|
|
337
|
+
|
|
345
338
|
@field_validator("base_url")
|
|
346
339
|
@classmethod
|
|
347
340
|
def validate_base_url(cls, v: str) -> str:
|
|
@@ -365,12 +358,21 @@ class ConfigSaveRequest(BaseModel):
|
|
|
365
358
|
@classmethod
|
|
366
359
|
def validate_decision_base_url(cls, v: str | None) -> str | None:
|
|
367
360
|
"""验证 decision_base_url 格式."""
|
|
368
|
-
if v is None
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
361
|
+
if v is not None and v.strip():
|
|
362
|
+
if not re.match(r"^https?://", v):
|
|
363
|
+
raise ValueError(
|
|
364
|
+
"decision_base_url must start with http:// or https://"
|
|
365
|
+
)
|
|
366
|
+
return v.rstrip("/")
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
@field_validator("decision_model_name")
|
|
370
|
+
@classmethod
|
|
371
|
+
def validate_decision_model_name(cls, v: str | None) -> str | None:
|
|
372
|
+
"""验证 decision_model_name 非空."""
|
|
373
|
+
if v is not None and v.strip():
|
|
374
|
+
return v.strip()
|
|
375
|
+
return None
|
|
374
376
|
|
|
375
377
|
|
|
376
378
|
class WiFiConnectRequest(BaseModel):
|
|
@@ -595,3 +597,210 @@ class WorkflowListResponse(BaseModel):
|
|
|
595
597
|
"""Workflow 列表响应."""
|
|
596
598
|
|
|
597
599
|
workflows: list[WorkflowResponse]
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
class RemoteDeviceInfo(BaseModel):
|
|
603
|
+
"""远程设备信息."""
|
|
604
|
+
|
|
605
|
+
device_id: str
|
|
606
|
+
model: str
|
|
607
|
+
platform: str
|
|
608
|
+
status: str
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
class RemoteDeviceDiscoverRequest(BaseModel):
|
|
612
|
+
"""远程设备发现请求."""
|
|
613
|
+
|
|
614
|
+
base_url: str
|
|
615
|
+
timeout: int = 5
|
|
616
|
+
|
|
617
|
+
@field_validator("base_url")
|
|
618
|
+
@classmethod
|
|
619
|
+
def validate_base_url(cls, v: str) -> str:
|
|
620
|
+
v = v.strip().rstrip("/")
|
|
621
|
+
if not v.startswith(("http://", "https://")):
|
|
622
|
+
raise ValueError("base_url must start with http:// or https://")
|
|
623
|
+
return v
|
|
624
|
+
|
|
625
|
+
@field_validator("timeout")
|
|
626
|
+
@classmethod
|
|
627
|
+
def validate_timeout(cls, v: int) -> int:
|
|
628
|
+
if v <= 0:
|
|
629
|
+
raise ValueError("timeout must be positive")
|
|
630
|
+
if v > 30:
|
|
631
|
+
raise ValueError("timeout must be <= 30 seconds")
|
|
632
|
+
return v
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
class RemoteDeviceDiscoverResponse(BaseModel):
|
|
636
|
+
"""远程设备发现响应."""
|
|
637
|
+
|
|
638
|
+
success: bool
|
|
639
|
+
devices: list[RemoteDeviceInfo]
|
|
640
|
+
message: str
|
|
641
|
+
error: str | None = None
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
class RemoteDeviceAddRequest(BaseModel):
|
|
645
|
+
"""添加远程设备请求."""
|
|
646
|
+
|
|
647
|
+
base_url: str
|
|
648
|
+
device_id: str
|
|
649
|
+
|
|
650
|
+
@field_validator("base_url")
|
|
651
|
+
@classmethod
|
|
652
|
+
def validate_base_url(cls, v: str) -> str:
|
|
653
|
+
v = v.strip().rstrip("/")
|
|
654
|
+
if not v.startswith(("http://", "https://")):
|
|
655
|
+
raise ValueError("base_url must start with http:// or https://")
|
|
656
|
+
return v
|
|
657
|
+
|
|
658
|
+
@field_validator("device_id")
|
|
659
|
+
@classmethod
|
|
660
|
+
def validate_device_id(cls, v: str) -> str:
|
|
661
|
+
v = v.strip()
|
|
662
|
+
if not v:
|
|
663
|
+
raise ValueError("device_id cannot be empty")
|
|
664
|
+
if len(v) > 100:
|
|
665
|
+
raise ValueError("device_id too long (max 100 characters)")
|
|
666
|
+
return v
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
class RemoteDeviceAddResponse(BaseModel):
|
|
670
|
+
"""添加远程设备响应."""
|
|
671
|
+
|
|
672
|
+
success: bool
|
|
673
|
+
message: str
|
|
674
|
+
serial: str | None = None
|
|
675
|
+
error: str | None = None
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
class RemoteDeviceRemoveRequest(BaseModel):
|
|
679
|
+
"""移除远程设备请求."""
|
|
680
|
+
|
|
681
|
+
serial: str
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
class RemoteDeviceRemoveResponse(BaseModel):
|
|
685
|
+
"""移除远程设备响应."""
|
|
686
|
+
|
|
687
|
+
success: bool
|
|
688
|
+
message: str
|
|
689
|
+
error: str | None = None
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
class ReinitAllAgentsResponse(BaseModel):
|
|
693
|
+
"""批量重新初始化 agent 响应."""
|
|
694
|
+
|
|
695
|
+
success: bool
|
|
696
|
+
total: int
|
|
697
|
+
succeeded: list[str]
|
|
698
|
+
failed: dict[str, str]
|
|
699
|
+
message: str
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
# History Models
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
class HistoryRecordResponse(BaseModel):
|
|
706
|
+
"""历史记录条目响应."""
|
|
707
|
+
|
|
708
|
+
id: str
|
|
709
|
+
task_text: str
|
|
710
|
+
final_message: str
|
|
711
|
+
success: bool
|
|
712
|
+
steps: int
|
|
713
|
+
start_time: str
|
|
714
|
+
end_time: str | None
|
|
715
|
+
duration_ms: int
|
|
716
|
+
source: str
|
|
717
|
+
source_detail: str
|
|
718
|
+
error_message: str | None
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
class HistoryListResponse(BaseModel):
|
|
722
|
+
"""历史记录列表响应."""
|
|
723
|
+
|
|
724
|
+
records: list[HistoryRecordResponse]
|
|
725
|
+
total: int
|
|
726
|
+
limit: int
|
|
727
|
+
offset: int
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
# Scheduled Task Models
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
class ScheduledTaskCreate(BaseModel):
|
|
734
|
+
"""创建定时任务请求."""
|
|
735
|
+
|
|
736
|
+
name: str
|
|
737
|
+
workflow_uuid: str
|
|
738
|
+
device_serialno: str
|
|
739
|
+
cron_expression: str
|
|
740
|
+
enabled: bool = True
|
|
741
|
+
|
|
742
|
+
@field_validator("name")
|
|
743
|
+
@classmethod
|
|
744
|
+
def validate_name(cls, v: str) -> str:
|
|
745
|
+
if not v or not v.strip():
|
|
746
|
+
raise ValueError("name cannot be empty")
|
|
747
|
+
return v.strip()
|
|
748
|
+
|
|
749
|
+
@field_validator("cron_expression")
|
|
750
|
+
@classmethod
|
|
751
|
+
def validate_cron(cls, v: str) -> str:
|
|
752
|
+
if not v or not v.strip():
|
|
753
|
+
raise ValueError("cron_expression cannot be empty")
|
|
754
|
+
parts = v.strip().split()
|
|
755
|
+
if len(parts) != 5:
|
|
756
|
+
raise ValueError(
|
|
757
|
+
"cron_expression must have 5 fields (minute hour day month weekday)"
|
|
758
|
+
)
|
|
759
|
+
return v.strip()
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
class ScheduledTaskUpdate(BaseModel):
|
|
763
|
+
"""更新定时任务请求."""
|
|
764
|
+
|
|
765
|
+
name: str | None = None
|
|
766
|
+
workflow_uuid: str | None = None
|
|
767
|
+
device_serialno: str | None = None
|
|
768
|
+
cron_expression: str | None = None
|
|
769
|
+
enabled: bool | None = None
|
|
770
|
+
|
|
771
|
+
@field_validator("cron_expression")
|
|
772
|
+
@classmethod
|
|
773
|
+
def validate_cron(cls, v: str | None) -> str | None:
|
|
774
|
+
if v is None:
|
|
775
|
+
return v
|
|
776
|
+
if not v.strip():
|
|
777
|
+
raise ValueError("cron_expression cannot be empty")
|
|
778
|
+
parts = v.strip().split()
|
|
779
|
+
if len(parts) != 5:
|
|
780
|
+
raise ValueError(
|
|
781
|
+
"cron_expression must have 5 fields (minute hour day month weekday)"
|
|
782
|
+
)
|
|
783
|
+
return v.strip()
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
class ScheduledTaskResponse(BaseModel):
|
|
787
|
+
"""定时任务响应."""
|
|
788
|
+
|
|
789
|
+
id: str
|
|
790
|
+
name: str
|
|
791
|
+
workflow_uuid: str
|
|
792
|
+
device_serialno: str
|
|
793
|
+
cron_expression: str
|
|
794
|
+
enabled: bool
|
|
795
|
+
created_at: str
|
|
796
|
+
updated_at: str
|
|
797
|
+
last_run_time: str | None
|
|
798
|
+
last_run_success: bool | None
|
|
799
|
+
last_run_message: str | None
|
|
800
|
+
next_run_time: str | None = None
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
class ScheduledTaskListResponse(BaseModel):
|
|
804
|
+
"""定时任务列表响应."""
|
|
805
|
+
|
|
806
|
+
tasks: list[ScheduledTaskResponse]
|
AutoGLM_GUI/scrcpy_stream.py
CHANGED
|
@@ -5,9 +5,10 @@ import os
|
|
|
5
5
|
import socket
|
|
6
6
|
import subprocess
|
|
7
7
|
import sys
|
|
8
|
+
import time
|
|
8
9
|
from dataclasses import dataclass
|
|
9
10
|
from pathlib import Path
|
|
10
|
-
from
|
|
11
|
+
from asyncio.subprocess import Process as AsyncProcess
|
|
11
12
|
|
|
12
13
|
from AutoGLM_GUI.adb_plus import check_device_available
|
|
13
14
|
from AutoGLM_GUI.logger import logger
|
|
@@ -23,6 +24,74 @@ from AutoGLM_GUI.scrcpy_protocol import (
|
|
|
23
24
|
)
|
|
24
25
|
|
|
25
26
|
|
|
27
|
+
async def is_port_available(port: int, host: str = "127.0.0.1") -> bool:
|
|
28
|
+
"""Test if TCP port is available for binding.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
port: TCP port number
|
|
32
|
+
host: Host address to test
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
True if port can be bound (available), False otherwise
|
|
36
|
+
"""
|
|
37
|
+
sock = None
|
|
38
|
+
try:
|
|
39
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
40
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
41
|
+
sock.setblocking(False)
|
|
42
|
+
sock.bind((host, port))
|
|
43
|
+
logger.debug(f"Port {port} is available for binding")
|
|
44
|
+
return True
|
|
45
|
+
except OSError as e:
|
|
46
|
+
# Handle cross-platform errno for "Address already in use"
|
|
47
|
+
# macOS: 48, Linux: 98, Windows: 10048
|
|
48
|
+
logger.debug(f"Port {port} is occupied: {e}")
|
|
49
|
+
return False
|
|
50
|
+
finally:
|
|
51
|
+
if sock:
|
|
52
|
+
sock.close()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def wait_for_port_release(
|
|
56
|
+
port: int,
|
|
57
|
+
timeout: float = 5.0,
|
|
58
|
+
poll_interval: float = 0.2,
|
|
59
|
+
host: str = "127.0.0.1",
|
|
60
|
+
) -> bool:
|
|
61
|
+
"""Wait for TCP port to become available with polling.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
port: TCP port to wait for
|
|
65
|
+
timeout: Maximum wait time in seconds (default: 5.0)
|
|
66
|
+
poll_interval: Check interval in seconds (default: 0.2)
|
|
67
|
+
host: Host address
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
True if port became available, False if timeout
|
|
71
|
+
"""
|
|
72
|
+
start_time = time.time()
|
|
73
|
+
attempt = 0
|
|
74
|
+
|
|
75
|
+
while time.time() - start_time < timeout:
|
|
76
|
+
attempt += 1
|
|
77
|
+
if await is_port_available(port, host):
|
|
78
|
+
elapsed = time.time() - start_time
|
|
79
|
+
logger.info(
|
|
80
|
+
f"Port {port} became available after {elapsed:.2f}s ({attempt} checks)"
|
|
81
|
+
)
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
# Log progress every second for debugging
|
|
85
|
+
if attempt % 5 == 0: # Every 1 second (5 * 0.2s)
|
|
86
|
+
elapsed = time.time() - start_time
|
|
87
|
+
logger.debug(f"Still waiting for port {port}... ({elapsed:.1f}s elapsed)")
|
|
88
|
+
|
|
89
|
+
await asyncio.sleep(poll_interval)
|
|
90
|
+
|
|
91
|
+
logger.warning(f"Port {port} did not release within {timeout}s timeout")
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
26
95
|
@dataclass
|
|
27
96
|
class ScrcpyServerOptions:
|
|
28
97
|
max_size: int
|
|
@@ -69,7 +138,7 @@ class ScrcpyStreamer:
|
|
|
69
138
|
self.idr_interval_s = idr_interval_s
|
|
70
139
|
self.stream_options = stream_options or ScrcpyVideoStreamOptions()
|
|
71
140
|
|
|
72
|
-
self.scrcpy_process:
|
|
141
|
+
self.scrcpy_process: subprocess.Popen[bytes] | AsyncProcess | None = None
|
|
73
142
|
self.tcp_socket: socket.socket | None = None
|
|
74
143
|
self.forward_cleanup_needed = False
|
|
75
144
|
|
|
@@ -83,8 +152,9 @@ class ScrcpyStreamer:
|
|
|
83
152
|
def _find_scrcpy_server(self) -> str:
|
|
84
153
|
"""Find scrcpy-server binary path."""
|
|
85
154
|
# Priority 1: PyInstaller bundled path (for packaged executable)
|
|
86
|
-
|
|
87
|
-
|
|
155
|
+
meipass = getattr(sys, "_MEIPASS", None)
|
|
156
|
+
if meipass:
|
|
157
|
+
bundled_server = Path(meipass) / "scrcpy-server-v3.3.3"
|
|
88
158
|
if bundled_server.exists():
|
|
89
159
|
logger.info(f"Using bundled scrcpy-server: {bundled_server}")
|
|
90
160
|
return str(bundled_server)
|
|
@@ -158,29 +228,44 @@ class ScrcpyStreamer:
|
|
|
158
228
|
raise RuntimeError(f"Failed to start scrcpy server: {e}") from e
|
|
159
229
|
|
|
160
230
|
async def _cleanup_existing_server(self) -> None:
|
|
161
|
-
"""Kill existing scrcpy server processes
|
|
231
|
+
"""Kill existing scrcpy server processes and wait for port release."""
|
|
162
232
|
cmd_base = ["adb"]
|
|
163
233
|
if self.device_id:
|
|
164
234
|
cmd_base.extend(["-s", self.device_id])
|
|
165
235
|
|
|
166
236
|
# Method 1: Try pkill
|
|
237
|
+
logger.debug("Killing scrcpy processes via pkill...")
|
|
167
238
|
cmd = cmd_base + ["shell", "pkill", "-9", "-f", "app_process.*scrcpy"]
|
|
168
239
|
await run_cmd_silently(cmd)
|
|
169
240
|
|
|
170
241
|
# Method 2: Find and kill by PID (more reliable)
|
|
242
|
+
logger.debug("Killing scrcpy processes via PID...")
|
|
171
243
|
cmd = cmd_base + [
|
|
172
244
|
"shell",
|
|
173
245
|
"ps -ef | grep 'app_process.*scrcpy' | grep -v grep | awk '{print $2}' | xargs kill -9",
|
|
174
246
|
]
|
|
175
247
|
await run_cmd_silently(cmd)
|
|
176
248
|
|
|
177
|
-
# Method 3: Remove port forward
|
|
249
|
+
# Method 3: Remove port forward
|
|
250
|
+
logger.debug(f"Removing ADB port forward on port {self.port}...")
|
|
178
251
|
cmd_remove_forward = cmd_base + ["forward", "--remove", f"tcp:{self.port}"]
|
|
179
252
|
await run_cmd_silently(cmd_remove_forward)
|
|
180
253
|
|
|
181
|
-
# Wait for
|
|
182
|
-
logger.
|
|
183
|
-
await
|
|
254
|
+
# Wait for port to be truly available (instead of fixed sleep)
|
|
255
|
+
logger.info(f"Waiting for port {self.port} to be released...")
|
|
256
|
+
port_released = await wait_for_port_release(
|
|
257
|
+
self.port,
|
|
258
|
+
timeout=5.0, # Max 5 seconds (vs old fixed 2s)
|
|
259
|
+
poll_interval=0.2, # Check every 200ms
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if not port_released:
|
|
263
|
+
logger.warning(
|
|
264
|
+
f"Port {self.port} still occupied after cleanup. "
|
|
265
|
+
"Will attempt to start anyway (may fail)."
|
|
266
|
+
)
|
|
267
|
+
else:
|
|
268
|
+
logger.info(f"Port {self.port} successfully released and ready")
|
|
184
269
|
|
|
185
270
|
async def _push_server(self) -> None:
|
|
186
271
|
"""Push scrcpy-server to device."""
|
|
@@ -220,9 +305,9 @@ class ScrcpyStreamer:
|
|
|
220
305
|
)
|
|
221
306
|
|
|
222
307
|
async def _start_server(self) -> None:
|
|
223
|
-
"""Start scrcpy server on device with retry
|
|
308
|
+
"""Start scrcpy server on device with intelligent retry."""
|
|
224
309
|
max_retries = 3
|
|
225
|
-
retry_delay =
|
|
310
|
+
retry_delay = 1.0 # Reduced from 2s (cleanup handles waiting now)
|
|
226
311
|
|
|
227
312
|
options = self._build_server_options()
|
|
228
313
|
|
|
@@ -262,53 +347,89 @@ class ScrcpyStreamer:
|
|
|
262
347
|
|
|
263
348
|
# Check if process is still running
|
|
264
349
|
error_msg = None
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
350
|
+
proc = self.scrcpy_process
|
|
351
|
+
if proc is not None:
|
|
352
|
+
if is_windows():
|
|
353
|
+
if proc.poll() is not None: # type: ignore[union-attr]
|
|
354
|
+
stdout, stderr = proc.communicate() # type: ignore[union-attr]
|
|
355
|
+
error_msg = stderr.decode() if stderr else stdout.decode()
|
|
356
|
+
else:
|
|
357
|
+
if proc.returncode is not None: # type: ignore[union-attr]
|
|
358
|
+
stdout, stderr = await proc.communicate() # type: ignore[union-attr]
|
|
359
|
+
error_msg = stderr.decode() if stderr else stdout.decode()
|
|
273
360
|
|
|
274
361
|
if error_msg is not None:
|
|
362
|
+
# Detailed error classification
|
|
275
363
|
if "Address already in use" in error_msg:
|
|
364
|
+
logger.error(
|
|
365
|
+
f"Port {self.port} conflict detected (attempt {attempt + 1}/{max_retries}). "
|
|
366
|
+
f"Error: {error_msg[:200]}"
|
|
367
|
+
)
|
|
276
368
|
if attempt < max_retries - 1:
|
|
277
369
|
logger.warning(
|
|
278
|
-
f"
|
|
370
|
+
f"Retrying with aggressive cleanup in {retry_delay}s..."
|
|
279
371
|
)
|
|
280
372
|
await self._cleanup_existing_server()
|
|
281
373
|
await asyncio.sleep(retry_delay)
|
|
282
374
|
continue
|
|
375
|
+
# Specific error for port conflicts
|
|
283
376
|
raise RuntimeError(
|
|
284
|
-
f"
|
|
377
|
+
f"Port {self.port} persistently occupied after {max_retries} attempts. "
|
|
378
|
+
"Please check if another scrcpy instance is running."
|
|
285
379
|
)
|
|
286
|
-
|
|
380
|
+
else:
|
|
381
|
+
# Non-port errors fail immediately (no retry)
|
|
382
|
+
logger.error(f"Scrcpy server startup failed: {error_msg[:200]}")
|
|
383
|
+
raise RuntimeError(f"Scrcpy server failed to start: {error_msg}")
|
|
287
384
|
|
|
385
|
+
logger.info("Scrcpy server started successfully")
|
|
288
386
|
return
|
|
289
387
|
|
|
290
388
|
raise RuntimeError("Failed to start scrcpy server after maximum retries")
|
|
291
389
|
|
|
292
390
|
async def _connect_socket(self) -> None:
|
|
293
391
|
"""Connect to scrcpy TCP socket."""
|
|
294
|
-
|
|
295
|
-
|
|
392
|
+
# Retry connection with exponential backoff (max ~6 seconds total)
|
|
393
|
+
max_attempts = 10
|
|
394
|
+
retry_delay = 0.3
|
|
296
395
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
)
|
|
301
|
-
logger.debug("Set socket receive buffer to 2MB")
|
|
302
|
-
except OSError as e:
|
|
303
|
-
logger.warning(f"Failed to set socket buffer size: {e}")
|
|
396
|
+
for attempt in range(max_attempts):
|
|
397
|
+
# Create a fresh socket for each attempt to avoid "Invalid argument" error
|
|
398
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
399
|
+
sock.settimeout(5)
|
|
304
400
|
|
|
305
|
-
for _ in range(5):
|
|
306
401
|
try:
|
|
307
|
-
|
|
308
|
-
|
|
402
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 2 * 1024 * 1024)
|
|
403
|
+
except OSError as e:
|
|
404
|
+
logger.debug(f"Failed to set socket buffer size: {e}")
|
|
405
|
+
|
|
406
|
+
try:
|
|
407
|
+
sock.connect(("localhost", self.port))
|
|
408
|
+
sock.settimeout(None)
|
|
409
|
+
self.tcp_socket = sock # Only assign on success
|
|
410
|
+
logger.debug(f"Connected to scrcpy server on attempt {attempt + 1}")
|
|
309
411
|
return
|
|
310
|
-
except (ConnectionRefusedError, OSError):
|
|
311
|
-
|
|
412
|
+
except (ConnectionRefusedError, OSError) as e:
|
|
413
|
+
# Close the failed socket
|
|
414
|
+
try:
|
|
415
|
+
sock.close()
|
|
416
|
+
except Exception:
|
|
417
|
+
pass
|
|
418
|
+
|
|
419
|
+
if attempt < max_attempts - 1:
|
|
420
|
+
logger.debug(
|
|
421
|
+
f"Connection attempt {attempt + 1}/{max_attempts} failed: {e}. "
|
|
422
|
+
f"Retrying in {retry_delay}s..."
|
|
423
|
+
)
|
|
424
|
+
await asyncio.sleep(retry_delay)
|
|
425
|
+
# Gradually increase delay for later attempts
|
|
426
|
+
if attempt >= 3:
|
|
427
|
+
retry_delay = 0.5
|
|
428
|
+
else:
|
|
429
|
+
logger.error(
|
|
430
|
+
f"Failed to connect after {max_attempts} attempts. "
|
|
431
|
+
f"Last error: {e}"
|
|
432
|
+
)
|
|
312
433
|
|
|
313
434
|
raise ConnectionError("Failed to connect to scrcpy server")
|
|
314
435
|
|
|
@@ -431,7 +552,8 @@ class ScrcpyStreamer:
|
|
|
431
552
|
if self.scrcpy_process:
|
|
432
553
|
try:
|
|
433
554
|
self.scrcpy_process.terminate()
|
|
434
|
-
self.scrcpy_process.
|
|
555
|
+
if isinstance(self.scrcpy_process, subprocess.Popen):
|
|
556
|
+
self.scrcpy_process.wait(timeout=2)
|
|
435
557
|
except Exception:
|
|
436
558
|
try:
|
|
437
559
|
self.scrcpy_process.kill()
|
AutoGLM_GUI/server.py
CHANGED
|
@@ -5,6 +5,8 @@ from socketio import ASGIApp
|
|
|
5
5
|
from AutoGLM_GUI.api import app as fastapi_app
|
|
6
6
|
from AutoGLM_GUI.socketio_server import sio
|
|
7
7
|
|
|
8
|
-
app = ASGIApp(
|
|
8
|
+
app = ASGIApp(
|
|
9
|
+
other_asgi_app=fastapi_app, socketio_server=sio, socketio_path="/socket.io"
|
|
10
|
+
)
|
|
9
11
|
|
|
10
12
|
__all__ = ["app"]
|