autoglm-gui 0.3.2__tar.gz → 0.4.2__tar.gz

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 (47) hide show
  1. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/AutoGLM_GUI/__main__.py +6 -2
  2. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/AutoGLM_GUI/adb_plus/screenshot.py +1 -4
  3. autoglm_gui-0.4.2/AutoGLM_GUI/api/__init__.py +66 -0
  4. autoglm_gui-0.4.2/AutoGLM_GUI/api/agents.py +231 -0
  5. autoglm_gui-0.4.2/AutoGLM_GUI/api/control.py +111 -0
  6. autoglm_gui-0.4.2/AutoGLM_GUI/api/devices.py +29 -0
  7. autoglm_gui-0.4.2/AutoGLM_GUI/api/media.py +163 -0
  8. autoglm_gui-0.4.2/AutoGLM_GUI/schemas.py +127 -0
  9. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/AutoGLM_GUI/scrcpy_stream.py +70 -30
  10. autoglm_gui-0.4.2/AutoGLM_GUI/server.py +5 -0
  11. autoglm_gui-0.4.2/AutoGLM_GUI/state.py +33 -0
  12. autoglm_gui-0.3.2/AutoGLM_GUI/static/assets/about-2K7DgoQw.js → autoglm_gui-0.4.2/AutoGLM_GUI/static/assets/about-C9954kpk.js +1 -1
  13. autoglm_gui-0.4.2/AutoGLM_GUI/static/assets/chat-CSgek5Xo.js +25 -0
  14. autoglm_gui-0.4.2/AutoGLM_GUI/static/assets/index-7WS8sURE.css +1 -0
  15. autoglm_gui-0.3.2/AutoGLM_GUI/static/assets/index-Cc7aUqXq.js → autoglm_gui-0.4.2/AutoGLM_GUI/static/assets/index-CCRJFa_5.js +1 -1
  16. autoglm_gui-0.3.2/AutoGLM_GUI/static/assets/index-BynheeWl.js → autoglm_gui-0.4.2/AutoGLM_GUI/static/assets/index-DZmNavz6.js +6 -6
  17. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/AutoGLM_GUI/static/index.html +2 -2
  18. autoglm_gui-0.4.2/AutoGLM_GUI/version.py +8 -0
  19. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/PKG-INFO +64 -9
  20. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/README.md +63 -8
  21. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/phone_agent/adb/connection.py +0 -1
  22. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/phone_agent/adb/device.py +0 -2
  23. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/phone_agent/adb/input.py +0 -1
  24. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/phone_agent/adb/screenshot.py +0 -1
  25. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/phone_agent/agent.py +1 -1
  26. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/pyproject.toml +6 -1
  27. autoglm_gui-0.3.2/AutoGLM_GUI/server.py +0 -620
  28. autoglm_gui-0.3.2/AutoGLM_GUI/static/assets/chat-DjOHP9wp.js +0 -25
  29. autoglm_gui-0.3.2/AutoGLM_GUI/static/assets/index-CrqBLMxN.css +0 -1
  30. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/.gitignore +0 -0
  31. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/AutoGLM_GUI/__init__.py +0 -0
  32. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/AutoGLM_GUI/adb_plus/__init__.py +0 -0
  33. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/AutoGLM_GUI/adb_plus/touch.py +0 -0
  34. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/LICENSE +0 -0
  35. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/phone_agent/__init__.py +0 -0
  36. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/phone_agent/actions/__init__.py +0 -0
  37. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/phone_agent/actions/handler.py +0 -0
  38. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/phone_agent/adb/__init__.py +0 -0
  39. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/phone_agent/config/__init__.py +0 -0
  40. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/phone_agent/config/apps.py +0 -0
  41. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/phone_agent/config/i18n.py +0 -0
  42. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/phone_agent/config/prompts.py +0 -0
  43. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/phone_agent/config/prompts_en.py +0 -0
  44. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/phone_agent/config/prompts_zh.py +0 -0
  45. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/phone_agent/model/__init__.py +0 -0
  46. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/phone_agent/model/client.py +0 -0
  47. {autoglm_gui-0.3.2 → autoglm_gui-0.4.2}/scrcpy-server-v3.3.3 +0 -0
@@ -12,7 +12,9 @@ import webbrowser
12
12
  DEFAULT_MODEL_NAME = "autoglm-phone-9b"
13
13
 
14
14
 
15
- def find_available_port(start_port: int = 8000, max_attempts: int = 100, host: str = "127.0.0.1") -> int:
15
+ def find_available_port(
16
+ start_port: int = 8000, max_attempts: int = 100, host: str = "127.0.0.1"
17
+ ) -> int:
16
18
  """Find an available port starting from start_port.
17
19
 
18
20
  Args:
@@ -52,7 +54,9 @@ def open_browser(host: str, port: int, delay: float = 1.5) -> None:
52
54
 
53
55
  def _open():
54
56
  time.sleep(delay)
55
- url = f"http://127.0.0.1:{port}" if host == "0.0.0.0" else f"http://{host}:{port}"
57
+ url = (
58
+ f"http://127.0.0.1:{port}" if host == "0.0.0.0" else f"http://{host}:{port}"
59
+ )
56
60
  try:
57
61
  webbrowser.open(url)
58
62
  except Exception as e:
@@ -10,7 +10,6 @@ import base64
10
10
  import subprocess
11
11
  from dataclasses import dataclass
12
12
  from io import BytesIO
13
- from typing import Iterable
14
13
 
15
14
  from PIL import Image
16
15
 
@@ -72,9 +71,7 @@ def capture_screenshot(
72
71
  return _fallback_screenshot()
73
72
 
74
73
 
75
- def _try_capture(
76
- device_id: str | None, adb_path: str, timeout: int
77
- ) -> bytes | None:
74
+ def _try_capture(device_id: str | None, adb_path: str, timeout: int) -> bytes | None:
78
75
  """Run exec-out screencap and return raw bytes or None on failure."""
79
76
  cmd: list[str | bytes] = [adb_path]
80
77
  if device_id:
@@ -0,0 +1,66 @@
1
+ """FastAPI application factory and route registration."""
2
+
3
+ from importlib.resources import files
4
+ from pathlib import Path
5
+
6
+ from fastapi import FastAPI
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from fastapi.responses import FileResponse
9
+ from fastapi.staticfiles import StaticFiles
10
+
11
+ from AutoGLM_GUI.version import APP_VERSION
12
+
13
+ from . import agents, control, devices, media
14
+
15
+
16
+ def _get_static_dir() -> Path | None:
17
+ """Locate packaged static assets."""
18
+ try:
19
+ static_dir = files("AutoGLM_GUI").joinpath("static")
20
+ if hasattr(static_dir, "_path"):
21
+ path = Path(str(static_dir))
22
+ if path.exists():
23
+ return path
24
+ path = Path(str(static_dir))
25
+ if path.exists():
26
+ return path
27
+ except (TypeError, FileNotFoundError):
28
+ pass
29
+
30
+ return None
31
+
32
+
33
+ def create_app() -> FastAPI:
34
+ """Build the FastAPI app with routers and static assets."""
35
+ app = FastAPI(title="AutoGLM-GUI API", version=APP_VERSION)
36
+
37
+ app.add_middleware(
38
+ CORSMiddleware,
39
+ allow_origins=["http://localhost:3000"],
40
+ allow_credentials=True,
41
+ allow_methods=["*"],
42
+ allow_headers=["*"],
43
+ )
44
+
45
+ app.include_router(agents.router)
46
+ app.include_router(devices.router)
47
+ app.include_router(control.router)
48
+ app.include_router(media.router)
49
+
50
+ static_dir = _get_static_dir()
51
+ if static_dir is not None and static_dir.exists():
52
+ assets_dir = static_dir / "assets"
53
+ if assets_dir.exists():
54
+ app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
55
+
56
+ @app.get("/{full_path:path}")
57
+ async def serve_spa(full_path: str) -> FileResponse:
58
+ file_path = static_dir / full_path
59
+ if file_path.is_file():
60
+ return FileResponse(file_path)
61
+ return FileResponse(static_dir / "index.html")
62
+
63
+ return app
64
+
65
+
66
+ app = create_app()
@@ -0,0 +1,231 @@
1
+ """Agent lifecycle and chat routes."""
2
+
3
+ import json
4
+
5
+ from fastapi import APIRouter, HTTPException
6
+ from fastapi.responses import StreamingResponse
7
+ from phone_agent import PhoneAgent
8
+ from phone_agent.agent import AgentConfig
9
+ from phone_agent.model import ModelConfig
10
+
11
+ from AutoGLM_GUI.schemas import (
12
+ APIAgentConfig,
13
+ APIModelConfig,
14
+ ChatRequest,
15
+ ChatResponse,
16
+ InitRequest,
17
+ ResetRequest,
18
+ StatusResponse,
19
+ )
20
+ from AutoGLM_GUI.state import (
21
+ DEFAULT_API_KEY,
22
+ DEFAULT_BASE_URL,
23
+ DEFAULT_MODEL_NAME,
24
+ agent_configs,
25
+ agents,
26
+ non_blocking_takeover,
27
+ )
28
+ from AutoGLM_GUI.version import APP_VERSION
29
+
30
+ router = APIRouter()
31
+
32
+
33
+ @router.post("/api/init")
34
+ def init_agent(request: InitRequest) -> dict:
35
+ """初始化 PhoneAgent(多设备支持)。"""
36
+ req_model_config = request.model or APIModelConfig()
37
+ req_agent_config = request.agent or APIAgentConfig()
38
+
39
+ device_id = req_agent_config.device_id
40
+ if not device_id:
41
+ raise HTTPException(
42
+ status_code=400, detail="device_id is required in agent_config"
43
+ )
44
+
45
+ base_url = req_model_config.base_url or DEFAULT_BASE_URL
46
+ api_key = req_model_config.api_key or DEFAULT_API_KEY
47
+ model_name = req_model_config.model_name or DEFAULT_MODEL_NAME
48
+
49
+ if not base_url:
50
+ raise HTTPException(
51
+ status_code=400, detail="base_url is required (in model_config or env)"
52
+ )
53
+
54
+ model_config = ModelConfig(
55
+ base_url=base_url,
56
+ api_key=api_key,
57
+ model_name=model_name,
58
+ max_tokens=req_model_config.max_tokens,
59
+ temperature=req_model_config.temperature,
60
+ top_p=req_model_config.top_p,
61
+ frequency_penalty=req_model_config.frequency_penalty,
62
+ )
63
+
64
+ agent_config = AgentConfig(
65
+ max_steps=req_agent_config.max_steps,
66
+ device_id=device_id,
67
+ lang=req_agent_config.lang,
68
+ system_prompt=req_agent_config.system_prompt,
69
+ verbose=req_agent_config.verbose,
70
+ )
71
+
72
+ agents[device_id] = PhoneAgent(
73
+ model_config=model_config,
74
+ agent_config=agent_config,
75
+ takeover_callback=non_blocking_takeover,
76
+ )
77
+
78
+ agent_configs[device_id] = (model_config, agent_config)
79
+
80
+ return {
81
+ "success": True,
82
+ "device_id": device_id,
83
+ "message": f"Agent initialized for device {device_id}",
84
+ }
85
+
86
+
87
+ @router.post("/api/chat", response_model=ChatResponse)
88
+ def chat(request: ChatRequest) -> ChatResponse:
89
+ """发送任务给 Agent 并执行。"""
90
+ device_id = request.device_id
91
+ if device_id not in agents:
92
+ raise HTTPException(
93
+ status_code=400, detail="Agent not initialized. Call /api/init first."
94
+ )
95
+
96
+ agent = agents[device_id]
97
+
98
+ try:
99
+ result = agent.run(request.message)
100
+ steps = agent.step_count
101
+ agent.reset()
102
+
103
+ return ChatResponse(result=result, steps=steps, success=True)
104
+ except Exception as e:
105
+ return ChatResponse(result=str(e), steps=0, success=False)
106
+
107
+
108
+ @router.post("/api/chat/stream")
109
+ def chat_stream(request: ChatRequest):
110
+ """发送任务给 Agent 并实时推送执行进度(SSE,多设备支持)。"""
111
+ device_id = request.device_id
112
+
113
+ if device_id not in agents:
114
+ raise HTTPException(
115
+ status_code=400,
116
+ detail=f"Device {device_id} not initialized. Call /api/init first.",
117
+ )
118
+
119
+ agent = agents[device_id]
120
+
121
+ def event_generator():
122
+ """SSE 事件生成器"""
123
+ try:
124
+ step_result = agent.step(request.message)
125
+ while True:
126
+ event_data = {
127
+ "type": "step",
128
+ "step": agent.step_count,
129
+ "thinking": step_result.thinking,
130
+ "action": step_result.action,
131
+ "success": step_result.success,
132
+ "finished": step_result.finished,
133
+ }
134
+
135
+ yield "event: step\n"
136
+ yield f"data: {json.dumps(event_data, ensure_ascii=False)}\n\n"
137
+
138
+ if step_result.finished:
139
+ done_data = {
140
+ "type": "done",
141
+ "message": step_result.message,
142
+ "steps": agent.step_count,
143
+ "success": step_result.success,
144
+ }
145
+ yield "event: done\n"
146
+ yield f"data: {json.dumps(done_data, ensure_ascii=False)}\n\n"
147
+ break
148
+
149
+ if agent.step_count >= agent.agent_config.max_steps:
150
+ done_data = {
151
+ "type": "done",
152
+ "message": "Max steps reached",
153
+ "steps": agent.step_count,
154
+ "success": step_result.success,
155
+ }
156
+ yield "event: done\n"
157
+ yield f"data: {json.dumps(done_data, ensure_ascii=False)}\n\n"
158
+ break
159
+
160
+ step_result = agent.step()
161
+
162
+ agent.reset()
163
+
164
+ except Exception as e:
165
+ error_data = {
166
+ "type": "error",
167
+ "message": str(e),
168
+ }
169
+ yield "event: error\n"
170
+ yield f"data: {json.dumps(error_data, ensure_ascii=False)}\n\n"
171
+
172
+ return StreamingResponse(
173
+ event_generator(),
174
+ media_type="text/event-stream",
175
+ headers={
176
+ "Cache-Control": "no-cache",
177
+ "Connection": "keep-alive",
178
+ "X-Accel-Buffering": "no",
179
+ },
180
+ )
181
+
182
+
183
+ @router.get("/api/status", response_model=StatusResponse)
184
+ def get_status(device_id: str | None = None) -> StatusResponse:
185
+ """获取 Agent 状态和版本信息(多设备支持)。"""
186
+ if device_id is None:
187
+ return StatusResponse(
188
+ version=APP_VERSION,
189
+ initialized=len(agents) > 0,
190
+ step_count=0,
191
+ )
192
+
193
+ if device_id not in agents:
194
+ return StatusResponse(
195
+ version=APP_VERSION,
196
+ initialized=False,
197
+ step_count=0,
198
+ )
199
+
200
+ agent = agents[device_id]
201
+ return StatusResponse(
202
+ version=APP_VERSION,
203
+ initialized=True,
204
+ step_count=agent.step_count,
205
+ )
206
+
207
+
208
+ @router.post("/api/reset")
209
+ def reset_agent(request: ResetRequest) -> dict:
210
+ """重置 Agent 状态(多设备支持)。"""
211
+ device_id = request.device_id
212
+
213
+ if device_id not in agents:
214
+ raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
215
+
216
+ agent = agents[device_id]
217
+ agent.reset()
218
+
219
+ if device_id in agent_configs:
220
+ model_config, agent_config = agent_configs[device_id]
221
+ agents[device_id] = PhoneAgent(
222
+ model_config=model_config,
223
+ agent_config=agent_config,
224
+ takeover_callback=non_blocking_takeover,
225
+ )
226
+
227
+ return {
228
+ "success": True,
229
+ "device_id": device_id,
230
+ "message": f"Agent reset for device {device_id}",
231
+ }
@@ -0,0 +1,111 @@
1
+ """Device control routes (tap/swipe/touch)."""
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from AutoGLM_GUI.schemas import (
6
+ SwipeRequest,
7
+ SwipeResponse,
8
+ TapRequest,
9
+ TapResponse,
10
+ TouchDownRequest,
11
+ TouchDownResponse,
12
+ TouchMoveRequest,
13
+ TouchMoveResponse,
14
+ TouchUpRequest,
15
+ TouchUpResponse,
16
+ )
17
+
18
+ router = APIRouter()
19
+
20
+
21
+ @router.post("/api/control/tap", response_model=TapResponse)
22
+ def control_tap(request: TapRequest) -> TapResponse:
23
+ """Execute tap at specified device coordinates."""
24
+ try:
25
+ from phone_agent.adb import tap
26
+
27
+ tap(
28
+ x=request.x,
29
+ y=request.y,
30
+ device_id=request.device_id,
31
+ delay=request.delay,
32
+ )
33
+
34
+ return TapResponse(success=True)
35
+ except Exception as e:
36
+ return TapResponse(success=False, error=str(e))
37
+
38
+
39
+ @router.post("/api/control/swipe", response_model=SwipeResponse)
40
+ def control_swipe(request: SwipeRequest) -> SwipeResponse:
41
+ """Execute swipe from start to end coordinates."""
42
+ try:
43
+ from phone_agent.adb import swipe
44
+
45
+ swipe(
46
+ start_x=request.start_x,
47
+ start_y=request.start_y,
48
+ end_x=request.end_x,
49
+ end_y=request.end_y,
50
+ duration_ms=request.duration_ms,
51
+ device_id=request.device_id,
52
+ delay=request.delay,
53
+ )
54
+
55
+ return SwipeResponse(success=True)
56
+ except Exception as e:
57
+ return SwipeResponse(success=False, error=str(e))
58
+
59
+
60
+ @router.post("/api/control/touch/down", response_model=TouchDownResponse)
61
+ def control_touch_down(request: TouchDownRequest) -> TouchDownResponse:
62
+ """Send touch DOWN event at specified device coordinates."""
63
+ try:
64
+ from AutoGLM_GUI.adb_plus import touch_down
65
+
66
+ touch_down(
67
+ x=request.x,
68
+ y=request.y,
69
+ device_id=request.device_id,
70
+ delay=request.delay,
71
+ )
72
+
73
+ return TouchDownResponse(success=True)
74
+ except Exception as e:
75
+ return TouchDownResponse(success=False, error=str(e))
76
+
77
+
78
+ @router.post("/api/control/touch/move", response_model=TouchMoveResponse)
79
+ def control_touch_move(request: TouchMoveRequest) -> TouchMoveResponse:
80
+ """Send touch MOVE event at specified device coordinates."""
81
+ try:
82
+ from AutoGLM_GUI.adb_plus import touch_move
83
+
84
+ touch_move(
85
+ x=request.x,
86
+ y=request.y,
87
+ device_id=request.device_id,
88
+ delay=request.delay,
89
+ )
90
+
91
+ return TouchMoveResponse(success=True)
92
+ except Exception as e:
93
+ return TouchMoveResponse(success=False, error=str(e))
94
+
95
+
96
+ @router.post("/api/control/touch/up", response_model=TouchUpResponse)
97
+ def control_touch_up(request: TouchUpRequest) -> TouchUpResponse:
98
+ """Send touch UP event at specified device coordinates."""
99
+ try:
100
+ from AutoGLM_GUI.adb_plus import touch_up
101
+
102
+ touch_up(
103
+ x=request.x,
104
+ y=request.y,
105
+ device_id=request.device_id,
106
+ delay=request.delay,
107
+ )
108
+
109
+ return TouchUpResponse(success=True)
110
+ except Exception as e:
111
+ return TouchUpResponse(success=False, error=str(e))
@@ -0,0 +1,29 @@
1
+ """Device discovery routes."""
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from AutoGLM_GUI.schemas import DeviceListResponse
6
+ from AutoGLM_GUI.state import agents
7
+
8
+ router = APIRouter()
9
+
10
+
11
+ @router.get("/api/devices", response_model=DeviceListResponse)
12
+ def list_devices() -> DeviceListResponse:
13
+ """列出所有 ADB 设备。"""
14
+ from phone_agent.adb import list_devices as adb_list
15
+
16
+ adb_devices = adb_list()
17
+
18
+ return DeviceListResponse(
19
+ devices=[
20
+ {
21
+ "id": d.device_id,
22
+ "model": d.model or "Unknown",
23
+ "status": d.status,
24
+ "connection_type": d.connection_type.value,
25
+ "is_initialized": d.device_id in agents,
26
+ }
27
+ for d in adb_devices
28
+ ]
29
+ )
@@ -0,0 +1,163 @@
1
+ """Media routes: screenshot, video stream, stream reset."""
2
+
3
+ import asyncio
4
+
5
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
6
+
7
+ from AutoGLM_GUI.adb_plus import capture_screenshot
8
+ from AutoGLM_GUI.schemas import ScreenshotRequest, ScreenshotResponse
9
+ from AutoGLM_GUI.scrcpy_stream import ScrcpyStreamer
10
+ from AutoGLM_GUI.state import scrcpy_locks, scrcpy_streamers
11
+
12
+ router = APIRouter()
13
+
14
+
15
+ @router.post("/api/video/reset")
16
+ async def reset_video_stream(device_id: str | None = None) -> dict:
17
+ """Reset video stream (cleanup scrcpy server,多设备支持)."""
18
+ if device_id:
19
+ if device_id in scrcpy_locks:
20
+ async with scrcpy_locks[device_id]:
21
+ if device_id in scrcpy_streamers:
22
+ print(f"[video/reset] Stopping streamer for device {device_id}")
23
+ scrcpy_streamers[device_id].stop()
24
+ del scrcpy_streamers[device_id]
25
+ print(f"[video/reset] Streamer reset for device {device_id}")
26
+ return {
27
+ "success": True,
28
+ "message": f"Video stream reset for device {device_id}",
29
+ }
30
+ return {
31
+ "success": True,
32
+ "message": f"No active video stream for device {device_id}",
33
+ }
34
+ return {"success": True, "message": f"No video stream for device {device_id}"}
35
+
36
+ device_ids = list(scrcpy_streamers.keys())
37
+ for dev_id in device_ids:
38
+ if dev_id in scrcpy_locks:
39
+ async with scrcpy_locks[dev_id]:
40
+ if dev_id in scrcpy_streamers:
41
+ scrcpy_streamers[dev_id].stop()
42
+ del scrcpy_streamers[dev_id]
43
+ print("[video/reset] All streamers reset")
44
+ return {"success": True, "message": "All video streams reset"}
45
+
46
+
47
+ @router.post("/api/screenshot", response_model=ScreenshotResponse)
48
+ def take_screenshot(request: ScreenshotRequest) -> ScreenshotResponse:
49
+ """获取设备截图。此操作无副作用,不影响 PhoneAgent 运行。"""
50
+ try:
51
+ screenshot = capture_screenshot(device_id=request.device_id)
52
+ return ScreenshotResponse(
53
+ success=True,
54
+ image=screenshot.base64_data,
55
+ width=screenshot.width,
56
+ height=screenshot.height,
57
+ is_sensitive=screenshot.is_sensitive,
58
+ )
59
+ except Exception as e:
60
+ return ScreenshotResponse(
61
+ success=False,
62
+ image="",
63
+ width=0,
64
+ height=0,
65
+ is_sensitive=False,
66
+ error=str(e),
67
+ )
68
+
69
+
70
+ @router.websocket("/api/video/stream")
71
+ async def video_stream_ws(websocket: WebSocket, device_id: str | None = None):
72
+ """Stream real-time H.264 video from scrcpy server via WebSocket(多设备支持)."""
73
+ await websocket.accept()
74
+
75
+ if not device_id:
76
+ await websocket.send_json({"error": "device_id is required"})
77
+ return
78
+
79
+ print(f"[video/stream] WebSocket connection for device {device_id}")
80
+
81
+ if device_id not in scrcpy_locks:
82
+ scrcpy_locks[device_id] = asyncio.Lock()
83
+
84
+ async with scrcpy_locks[device_id]:
85
+ if device_id not in scrcpy_streamers:
86
+ print(f"[video/stream] Creating streamer for device {device_id}")
87
+ scrcpy_streamers[device_id] = ScrcpyStreamer(
88
+ device_id=device_id, max_size=1280, bit_rate=4_000_000
89
+ )
90
+
91
+ try:
92
+ print(f"[video/stream] Starting scrcpy server for device {device_id}")
93
+ await scrcpy_streamers[device_id].start()
94
+ print(f"[video/stream] Scrcpy server started for device {device_id}")
95
+ except Exception as e:
96
+ import traceback
97
+
98
+ print(f"[video/stream] Failed to start streamer: {e}")
99
+ print(f"[video/stream] Traceback:\n{traceback.format_exc()}")
100
+ scrcpy_streamers[device_id].stop()
101
+ del scrcpy_streamers[device_id]
102
+ try:
103
+ await websocket.send_json({"error": str(e)})
104
+ except Exception:
105
+ pass
106
+ return
107
+ else:
108
+ print(f"[video/stream] Reusing streamer for device {device_id}")
109
+
110
+ streamer = scrcpy_streamers[device_id]
111
+ if streamer.cached_sps and streamer.cached_pps:
112
+ init_data = streamer.cached_sps + streamer.cached_pps
113
+ await websocket.send_bytes(init_data)
114
+ print(f"[video/stream] Sent SPS/PPS for device {device_id}")
115
+ else:
116
+ print(
117
+ f"[video/stream] Warning: No cached SPS/PPS for device {device_id}"
118
+ )
119
+
120
+ streamer = scrcpy_streamers[device_id]
121
+
122
+ stream_failed = False
123
+ try:
124
+ chunk_count = 0
125
+ while True:
126
+ try:
127
+ h264_chunk = await streamer.read_h264_chunk()
128
+ await websocket.send_bytes(h264_chunk)
129
+ chunk_count += 1
130
+ if chunk_count % 100 == 0:
131
+ print(
132
+ f"[video/stream] Device {device_id}: Sent {chunk_count} chunks"
133
+ )
134
+ except ConnectionError as e:
135
+ print(f"[video/stream] Device {device_id}: Connection error: {e}")
136
+ stream_failed = True
137
+ try:
138
+ await websocket.send_json({"error": f"Stream error: {str(e)}"})
139
+ except Exception:
140
+ pass
141
+ break
142
+
143
+ except WebSocketDisconnect:
144
+ print(f"[video/stream] Device {device_id}: Client disconnected")
145
+ except Exception as e:
146
+ import traceback
147
+
148
+ print(f"[video/stream] Device {device_id}: Error: {e}")
149
+ print(f"[video/stream] Traceback:\n{traceback.format_exc()}")
150
+ stream_failed = True
151
+ try:
152
+ await websocket.send_json({"error": str(e)})
153
+ except Exception:
154
+ pass
155
+
156
+ if stream_failed:
157
+ async with scrcpy_locks[device_id]:
158
+ if device_id in scrcpy_streamers:
159
+ print(f"[video/stream] Resetting streamer for device {device_id}")
160
+ scrcpy_streamers[device_id].stop()
161
+ del scrcpy_streamers[device_id]
162
+
163
+ print(f"[video/stream] Device {device_id}: Stream ended")