autoglm-gui 1.0.2__py3-none-any.whl → 1.2.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 (38) hide show
  1. AutoGLM_GUI/adb_plus/__init__.py +12 -1
  2. AutoGLM_GUI/adb_plus/mdns.py +192 -0
  3. AutoGLM_GUI/adb_plus/pair.py +60 -0
  4. AutoGLM_GUI/adb_plus/qr_pair.py +372 -0
  5. AutoGLM_GUI/adb_plus/serial.py +61 -2
  6. AutoGLM_GUI/adb_plus/version.py +81 -0
  7. AutoGLM_GUI/api/__init__.py +16 -1
  8. AutoGLM_GUI/api/agents.py +329 -94
  9. AutoGLM_GUI/api/devices.py +304 -100
  10. AutoGLM_GUI/api/workflows.py +70 -0
  11. AutoGLM_GUI/device_manager.py +760 -0
  12. AutoGLM_GUI/exceptions.py +18 -0
  13. AutoGLM_GUI/phone_agent_manager.py +549 -0
  14. AutoGLM_GUI/phone_agent_patches.py +146 -0
  15. AutoGLM_GUI/schemas.py +380 -2
  16. AutoGLM_GUI/state.py +21 -0
  17. AutoGLM_GUI/static/assets/{about-BOnRPlKQ.js → about-PcGX7dIG.js} +1 -1
  18. AutoGLM_GUI/static/assets/chat-B0FKL2ne.js +124 -0
  19. AutoGLM_GUI/static/assets/dialog-BSNX0L1i.js +45 -0
  20. AutoGLM_GUI/static/assets/index-BjYIY--m.css +1 -0
  21. AutoGLM_GUI/static/assets/index-CnEYDOXp.js +11 -0
  22. AutoGLM_GUI/static/assets/index-DOt5XNhh.js +1 -0
  23. AutoGLM_GUI/static/assets/logo-Cyfm06Ym.png +0 -0
  24. AutoGLM_GUI/static/assets/workflows-B1hgBC_O.js +1 -0
  25. AutoGLM_GUI/static/favicon.ico +0 -0
  26. AutoGLM_GUI/static/index.html +9 -2
  27. AutoGLM_GUI/static/logo-192.png +0 -0
  28. AutoGLM_GUI/static/logo-512.png +0 -0
  29. AutoGLM_GUI/workflow_manager.py +181 -0
  30. {autoglm_gui-1.0.2.dist-info → autoglm_gui-1.2.0.dist-info}/METADATA +80 -35
  31. {autoglm_gui-1.0.2.dist-info → autoglm_gui-1.2.0.dist-info}/RECORD +34 -19
  32. AutoGLM_GUI/static/assets/chat-CGW6uMKB.js +0 -149
  33. AutoGLM_GUI/static/assets/index-CRFVU0eu.js +0 -1
  34. AutoGLM_GUI/static/assets/index-DH-Dl4tK.js +0 -10
  35. AutoGLM_GUI/static/assets/index-DzUQ89YC.css +0 -1
  36. {autoglm_gui-1.0.2.dist-info → autoglm_gui-1.2.0.dist-info}/WHEEL +0 -0
  37. {autoglm_gui-1.0.2.dist-info → autoglm_gui-1.2.0.dist-info}/entry_points.txt +0 -0
  38. {autoglm_gui-1.0.2.dist-info → autoglm_gui-1.2.0.dist-info}/licenses/LICENSE +0 -0
AutoGLM_GUI/api/agents.py CHANGED
@@ -1,13 +1,19 @@
1
1
  """Agent lifecycle and chat routes."""
2
2
 
3
3
  import json
4
+ import queue
5
+ import threading
6
+ from typing import Any
4
7
 
5
8
  from fastapi import APIRouter, HTTPException
6
9
  from fastapi.responses import StreamingResponse
7
10
  from pydantic import ValidationError
8
11
 
9
12
  from AutoGLM_GUI.config import config
13
+ from AutoGLM_GUI.logger import logger
14
+ from AutoGLM_GUI.phone_agent_patches import apply_patches
10
15
  from AutoGLM_GUI.schemas import (
16
+ AbortRequest,
11
17
  APIAgentConfig,
12
18
  APIModelConfig,
13
19
  ChatRequest,
@@ -19,8 +25,6 @@ from AutoGLM_GUI.schemas import (
19
25
  StatusResponse,
20
26
  )
21
27
  from AutoGLM_GUI.state import (
22
- agent_configs,
23
- agents,
24
28
  non_blocking_takeover,
25
29
  )
26
30
  from AutoGLM_GUI.version import APP_VERSION
@@ -28,8 +32,29 @@ from phone_agent import PhoneAgent
28
32
  from phone_agent.agent import AgentConfig
29
33
  from phone_agent.model import ModelConfig
30
34
 
35
+ # Apply monkey patches to phone_agent
36
+ apply_patches()
37
+
31
38
  router = APIRouter()
32
39
 
40
+ # Active chat sessions (device_id -> stop_event)
41
+ # Used for aborting ongoing conversations
42
+ _active_chats: dict[str, threading.Event] = {}
43
+ _active_chats_lock = threading.Lock()
44
+
45
+
46
+ def _release_device_lock_when_done(
47
+ device_id: str, threads: list[threading.Thread]
48
+ ) -> None:
49
+ """Block until threads finish, then release the device lock via manager."""
50
+ from AutoGLM_GUI.phone_agent_manager import PhoneAgentManager
51
+
52
+ for thread in threads:
53
+ thread.join()
54
+
55
+ manager = PhoneAgentManager.get_instance()
56
+ manager.release_device(device_id)
57
+
33
58
 
34
59
  @router.post("/api/init")
35
60
  def init_agent(request: InitRequest) -> dict:
@@ -95,13 +120,20 @@ def init_agent(request: InitRequest) -> dict:
95
120
  verbose=req_agent_config.verbose,
96
121
  )
97
122
 
98
- agents[device_id] = PhoneAgent(
99
- model_config=model_config,
100
- agent_config=agent_config,
101
- takeover_callback=non_blocking_takeover,
102
- )
123
+ # Initialize agent via PhoneAgentManager (thread-safe, transactional)
124
+ from AutoGLM_GUI.phone_agent_manager import PhoneAgentManager
103
125
 
104
- agent_configs[device_id] = (model_config, agent_config)
126
+ manager = PhoneAgentManager.get_instance()
127
+ try:
128
+ manager.initialize_agent(
129
+ device_id=device_id,
130
+ model_config=model_config,
131
+ agent_config=agent_config,
132
+ takeover_callback=non_blocking_takeover,
133
+ )
134
+ except Exception as e:
135
+ logger.error(f"Failed to initialize agent: {e}")
136
+ raise HTTPException(status_code=500, detail=str(e))
105
137
 
106
138
  return {
107
139
  "success": True,
@@ -113,20 +145,29 @@ def init_agent(request: InitRequest) -> dict:
113
145
  @router.post("/api/chat", response_model=ChatResponse)
114
146
  def chat(request: ChatRequest) -> ChatResponse:
115
147
  """发送任务给 Agent 并执行。"""
148
+ from AutoGLM_GUI.exceptions import DeviceBusyError
149
+ from AutoGLM_GUI.phone_agent_manager import PhoneAgentManager
150
+
116
151
  device_id = request.device_id
117
- if device_id not in agents:
152
+ manager = PhoneAgentManager.get_instance()
153
+
154
+ # Check if agent is initialized
155
+ if not manager.is_initialized(device_id):
118
156
  raise HTTPException(
119
157
  status_code=400, detail="Agent not initialized. Call /api/init first."
120
158
  )
121
159
 
122
- agent = agents[device_id]
123
-
160
+ # Use context manager for automatic lock management
124
161
  try:
125
- result = agent.run(request.message)
126
- steps = agent.step_count
127
- agent.reset()
128
-
129
- return ChatResponse(result=result, steps=steps, success=True)
162
+ with manager.use_agent(device_id, timeout=None) as agent:
163
+ result = agent.run(request.message)
164
+ steps = agent.step_count
165
+ agent.reset()
166
+ return ChatResponse(result=result, steps=steps, success=True)
167
+ except DeviceBusyError:
168
+ raise HTTPException(
169
+ status_code=409, detail=f"Device {device_id} is busy. Please wait."
170
+ )
130
171
  except Exception as e:
131
172
  return ChatResponse(result=str(e), steps=0, success=False)
132
173
 
@@ -134,96 +175,279 @@ def chat(request: ChatRequest) -> ChatResponse:
134
175
  @router.post("/api/chat/stream")
135
176
  def chat_stream(request: ChatRequest):
136
177
  """发送任务给 Agent 并实时推送执行进度(SSE,多设备支持)。"""
178
+ from AutoGLM_GUI.exceptions import AgentNotInitializedError, DeviceBusyError
179
+ from AutoGLM_GUI.phone_agent_manager import PhoneAgentManager
180
+
137
181
  device_id = request.device_id
182
+ manager = PhoneAgentManager.get_instance()
138
183
 
139
- if device_id not in agents:
184
+ # Check if agent is initialized
185
+ if not manager.is_initialized(device_id):
140
186
  raise HTTPException(
141
187
  status_code=400,
142
188
  detail=f"Device {device_id} not initialized. Call /api/init first.",
143
189
  )
144
190
 
145
- agent = agents[device_id]
191
+ # Acquire device lock (non-blocking) to prevent concurrent requests
192
+ try:
193
+ manager.acquire_device(device_id, timeout=0, raise_on_timeout=True)
194
+ except DeviceBusyError:
195
+ raise HTTPException(
196
+ status_code=409,
197
+ detail=f"Device {device_id} is already processing a request. Please wait.",
198
+ )
199
+
200
+ try:
201
+ # Get the original agent to copy its config
202
+ original_agent = manager.get_agent(device_id)
146
203
 
147
- def event_generator():
148
- """SSE 事件生成器"""
204
+ # Get the stored configs for this device
149
205
  try:
150
- step_result = agent.step(request.message)
151
- while True:
152
- event_data = {
153
- "type": "step",
154
- "step": agent.step_count,
155
- "thinking": step_result.thinking,
156
- "action": step_result.action,
157
- "success": step_result.success,
158
- "finished": step_result.finished,
206
+ model_config, agent_config = manager.get_config(device_id)
207
+ except AgentNotInitializedError:
208
+ manager.release_device(device_id)
209
+ raise HTTPException(
210
+ status_code=400,
211
+ detail=f"Device {device_id} config not found.",
212
+ )
213
+
214
+ def event_generator():
215
+ """SSE 事件生成器"""
216
+ threads: list[threading.Thread] = []
217
+ stop_event = threading.Event()
218
+
219
+ # Register stop_event to global mapping for abort support
220
+ with _active_chats_lock:
221
+ _active_chats[device_id] = stop_event
222
+
223
+ try:
224
+ # Create a queue to collect events from the agent
225
+ event_queue: queue.Queue[tuple[str, Any]] = queue.Queue()
226
+
227
+ # Create a callback to handle thinking chunks
228
+ def on_thinking_chunk(chunk: str):
229
+ """Emit thinking chunks as they arrive"""
230
+ if not stop_event.is_set():
231
+ chunk_data = {
232
+ "type": "thinking_chunk",
233
+ "chunk": chunk,
234
+ }
235
+ event_queue.put(("thinking_chunk", chunk_data))
236
+
237
+ # Create a new agent instance
238
+ streaming_agent = PhoneAgent(
239
+ model_config=model_config,
240
+ agent_config=agent_config,
241
+ takeover_callback=non_blocking_takeover,
242
+ )
243
+
244
+ # Copy context from original agent (thread-safe due to device lock)
245
+ streaming_agent._context = original_agent._context.copy()
246
+ streaming_agent._step_count = original_agent._step_count
247
+
248
+ # Monkey-patch the model_client.request to inject the callback
249
+ original_request = streaming_agent.model_client.request
250
+
251
+ def patched_request(messages, **kwargs):
252
+ # Inject the on_thinking_chunk callback
253
+ return original_request(
254
+ messages, on_thinking_chunk=on_thinking_chunk
255
+ )
256
+
257
+ streaming_agent.model_client.request = patched_request
258
+
259
+ # Early abort check (before starting any steps)
260
+ if stop_event.is_set():
261
+ logger.info(
262
+ f"[Abort] Agent for device {device_id} received abort signal before starting steps"
263
+ )
264
+ yield "event: aborted\n"
265
+ yield 'data: {"type": "aborted", "message": "Chat aborted by user"}\n\n'
266
+ return
267
+
268
+ # Run agent step in a separate thread
269
+ step_result: list[Any] = [None]
270
+ error_result: list[Any] = [None]
271
+
272
+ def run_step(is_first: bool = True, task: str | None = None):
273
+ try:
274
+ # Check before starting step
275
+ if stop_event.is_set():
276
+ logger.info(
277
+ f"[Abort] Agent for device {device_id} received abort signal before step execution"
278
+ )
279
+ return
280
+
281
+ if is_first:
282
+ result = streaming_agent.step(task)
283
+ else:
284
+ result = streaming_agent.step()
285
+
286
+ # Check after step completes
287
+ if stop_event.is_set():
288
+ logger.info(
289
+ f"[Abort] Agent for device {device_id} received abort signal after step execution"
290
+ )
291
+ return
292
+
293
+ step_result[0] = result
294
+ except Exception as e:
295
+ error_result[0] = e
296
+ finally:
297
+ event_queue.put(("step_done", None))
298
+
299
+ # Start first step
300
+ thread = threading.Thread(
301
+ target=run_step, args=(True, request.message), daemon=True
302
+ )
303
+ thread.start()
304
+ threads.append(thread)
305
+
306
+ while not stop_event.is_set():
307
+ # Wait for events from the queue
308
+ try:
309
+ event_type, event_data = event_queue.get(timeout=0.1)
310
+ except queue.Empty:
311
+ # Check again on timeout
312
+ if stop_event.is_set():
313
+ break
314
+ continue
315
+
316
+ if event_type == "thinking_chunk":
317
+ yield "event: thinking_chunk\n"
318
+ yield f"data: {json.dumps(event_data, ensure_ascii=False)}\n\n"
319
+
320
+ elif event_type == "step_done":
321
+ # Check for errors
322
+ if error_result[0]:
323
+ raise error_result[0]
324
+
325
+ result = step_result[0]
326
+ event_data = {
327
+ "type": "step",
328
+ "step": streaming_agent.step_count,
329
+ "thinking": result.thinking,
330
+ "action": result.action,
331
+ "success": result.success,
332
+ "finished": result.finished,
333
+ }
334
+
335
+ yield "event: step\n"
336
+ yield f"data: {json.dumps(event_data, ensure_ascii=False)}\n\n"
337
+
338
+ if result.finished:
339
+ done_data = {
340
+ "type": "done",
341
+ "message": result.message,
342
+ "steps": streaming_agent.step_count,
343
+ "success": result.success,
344
+ }
345
+ yield "event: done\n"
346
+ yield f"data: {json.dumps(done_data, ensure_ascii=False)}\n\n"
347
+ break
348
+
349
+ if (
350
+ streaming_agent.step_count
351
+ >= streaming_agent.agent_config.max_steps
352
+ ):
353
+ done_data = {
354
+ "type": "done",
355
+ "message": "Max steps reached",
356
+ "steps": streaming_agent.step_count,
357
+ "success": result.success,
358
+ }
359
+ yield "event: done\n"
360
+ yield f"data: {json.dumps(done_data, ensure_ascii=False)}\n\n"
361
+ break
362
+
363
+ # Start next step
364
+ step_result[0] = None
365
+ error_result[0] = None
366
+ thread = threading.Thread(
367
+ target=run_step, args=(False, None), daemon=True
368
+ )
369
+ thread.start()
370
+ threads.append(thread)
371
+
372
+ # Check if loop exited due to abort
373
+ if stop_event.is_set():
374
+ logger.info(
375
+ f"[Abort] Agent for device {device_id} event loop terminated due to abort signal"
376
+ )
377
+ yield "event: aborted\n"
378
+ yield 'data: {"type": "aborted", "message": "Chat aborted by user"}\n\n'
379
+
380
+ # Update original agent state (thread-safe due to device lock)
381
+ original_agent._context = streaming_agent._context
382
+ original_agent._step_count = streaming_agent._step_count
383
+
384
+ original_agent.reset()
385
+
386
+ except Exception as e:
387
+ error_data = {
388
+ "type": "error",
389
+ "message": str(e),
159
390
  }
160
-
161
- yield "event: step\n"
162
- yield f"data: {json.dumps(event_data, ensure_ascii=False)}\n\n"
163
-
164
- if step_result.finished:
165
- done_data = {
166
- "type": "done",
167
- "message": step_result.message,
168
- "steps": agent.step_count,
169
- "success": step_result.success,
170
- }
171
- yield "event: done\n"
172
- yield f"data: {json.dumps(done_data, ensure_ascii=False)}\n\n"
173
- break
174
-
175
- if agent.step_count >= agent.agent_config.max_steps:
176
- done_data = {
177
- "type": "done",
178
- "message": "Max steps reached",
179
- "steps": agent.step_count,
180
- "success": step_result.success,
181
- }
182
- yield "event: done\n"
183
- yield f"data: {json.dumps(done_data, ensure_ascii=False)}\n\n"
184
- break
185
-
186
- step_result = agent.step()
187
-
188
- agent.reset()
189
-
190
- except Exception as e:
191
- error_data = {
192
- "type": "error",
193
- "message": str(e),
194
- }
195
- yield "event: error\n"
196
- yield f"data: {json.dumps(error_data, ensure_ascii=False)}\n\n"
197
-
198
- return StreamingResponse(
199
- event_generator(),
200
- media_type="text/event-stream",
201
- headers={
202
- "Cache-Control": "no-cache",
203
- "Connection": "keep-alive",
204
- "X-Accel-Buffering": "no",
205
- },
206
- )
391
+ yield "event: error\n"
392
+ yield f"data: {json.dumps(error_data, ensure_ascii=False)}\n\n"
393
+ finally:
394
+ # Clean up active chats mapping
395
+ with _active_chats_lock:
396
+ _active_chats.pop(device_id, None)
397
+
398
+ # Signal all threads to stop
399
+ stop_event.set()
400
+
401
+ alive_threads = [thread for thread in threads if thread.is_alive()]
402
+ if alive_threads:
403
+ # Release lock after background threads complete
404
+ cleanup_thread = threading.Thread(
405
+ target=_release_device_lock_when_done,
406
+ args=(device_id, alive_threads),
407
+ daemon=True,
408
+ )
409
+ cleanup_thread.start()
410
+ else:
411
+ # Release lock immediately if no threads are alive
412
+ manager.release_device(device_id)
413
+
414
+ return StreamingResponse(
415
+ event_generator(),
416
+ media_type="text/event-stream",
417
+ headers={
418
+ "Cache-Control": "no-cache",
419
+ "Connection": "keep-alive",
420
+ "X-Accel-Buffering": "no",
421
+ },
422
+ )
423
+ except Exception:
424
+ # Release lock if exception occurs before generator starts
425
+ manager.release_device(device_id)
426
+ raise
207
427
 
208
428
 
209
429
  @router.get("/api/status", response_model=StatusResponse)
210
430
  def get_status(device_id: str | None = None) -> StatusResponse:
211
431
  """获取 Agent 状态和版本信息(多设备支持)。"""
432
+ from AutoGLM_GUI.phone_agent_manager import PhoneAgentManager
433
+
434
+ manager = PhoneAgentManager.get_instance()
435
+
212
436
  if device_id is None:
213
437
  return StatusResponse(
214
438
  version=APP_VERSION,
215
- initialized=len(agents) > 0,
439
+ initialized=len(manager.list_agents()) > 0,
216
440
  step_count=0,
217
441
  )
218
442
 
219
- if device_id not in agents:
443
+ if not manager.is_initialized(device_id):
220
444
  return StatusResponse(
221
445
  version=APP_VERSION,
222
446
  initialized=False,
223
447
  step_count=0,
224
448
  )
225
449
 
226
- agent = agents[device_id]
450
+ agent = manager.get_agent(device_id)
227
451
  return StatusResponse(
228
452
  version=APP_VERSION,
229
453
  initialized=True,
@@ -234,27 +458,38 @@ def get_status(device_id: str | None = None) -> StatusResponse:
234
458
  @router.post("/api/reset")
235
459
  def reset_agent(request: ResetRequest) -> dict:
236
460
  """重置 Agent 状态(多设备支持)。"""
461
+ from AutoGLM_GUI.exceptions import AgentNotInitializedError
462
+ from AutoGLM_GUI.phone_agent_manager import PhoneAgentManager
463
+
237
464
  device_id = request.device_id
465
+ manager = PhoneAgentManager.get_instance()
238
466
 
239
- if device_id not in agents:
467
+ try:
468
+ manager.reset_agent(device_id)
469
+ return {
470
+ "success": True,
471
+ "device_id": device_id,
472
+ "message": f"Agent reset for device {device_id}",
473
+ }
474
+ except AgentNotInitializedError:
240
475
  raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
241
476
 
242
- agent = agents[device_id]
243
- agent.reset()
244
477
 
245
- if device_id in agent_configs:
246
- model_config, agent_config = agent_configs[device_id]
247
- agents[device_id] = PhoneAgent(
248
- model_config=model_config,
249
- agent_config=agent_config,
250
- takeover_callback=non_blocking_takeover,
251
- )
478
+ @router.post("/api/chat/abort")
479
+ def abort_chat(request: AbortRequest) -> dict:
480
+ """中断正在进行的对话流。"""
481
+ from AutoGLM_GUI.logger import logger
252
482
 
253
- return {
254
- "success": True,
255
- "device_id": device_id,
256
- "message": f"Agent reset for device {device_id}",
257
- }
483
+ device_id = request.device_id
484
+
485
+ with _active_chats_lock:
486
+ if device_id in _active_chats:
487
+ logger.info(f"Aborting chat for device {device_id}")
488
+ _active_chats[device_id].set() # 设置中断标志
489
+ return {"success": True, "message": "Abort requested"}
490
+ else:
491
+ logger.warning(f"No active chat found for device {device_id}")
492
+ return {"success": False, "message": "No active chat found"}
258
493
 
259
494
 
260
495
  @router.get("/api/config", response_model=ConfigResponse)